fix(lark): Defer card creation and renew on tool call break (#6743)

* fix(lark): defer streaming card creation and renew card on tool call break

- Defer CardKit streaming card creation until the first text token
  arrives, preventing an empty card from rendering before content.
- Handle `type="break"` signal in send_streaming: close the current
  card and lazily create a new one for post-tool-call text, so the
  new card appears below the tool status message in correct order.
- Only emit "break" signal when show_tool_use is enabled; when tool
  output is hidden, the AI response continues on the same card.

* style: format ruff

* fix: cr bug

* fix: cr
This commit is contained in:
若月千鸮
2026-03-21 17:08:25 +08:00
committed by GitHub
parent 25c136ef95
commit 173583781e
2 changed files with 91 additions and 33 deletions
+7 -2
View File
@@ -165,8 +165,13 @@ async def run_agent(
# 对于其他情况,暂时先不处理
continue
elif resp.type == "tool_call":
if agent_runner.streaming:
# 用来标记流式响应需要分节
if agent_runner.streaming and show_tool_use:
# 向下游平台发送 "break" 分段信号(空 MessageChain,不携带数据)。
# 平台适配器收到后会关闭当前流式消息,并在后续文本到来时创建新消息。
# 仅在 show_tool_use 为 True 时才发送:此时紧接着会通过
# astr_event.send() 独立发送工具状态消息(如"🔨 调用工具: xxx"),
# 需要分段才能保证消息顺序正确。
# 若 show_tool_use 为 False,不会有独立消息插入,无需分段。
yield MessageChain(chain=[], type="break")
tool_info = _extract_chain_json_data(resp.data["chain"])
@@ -743,35 +743,20 @@ class LarkMessageEvent(AstrMessageEvent):
async def send_streaming(self, generator, use_fallback: bool = False):
"""使用 CardKit 流式卡片实现打字机效果。
流程:创建卡片实体 → 发送消息 → 流式更新文本 → 关闭流式模式。
流程:首字到来时创建卡片实体 → 发送消息 → 流式更新文本 → 关闭流式模式。
卡片创建延迟到第一个文本 token 到达时,避免工具调用阶段就渲染空卡片。
使用解耦发送循环,LLM token 到达时只更新 buffer 并唤醒发送协程,
发送频率由网络 RTT 自然限流。
"""
# Step 1: 创建流式卡片实体
card_id = await self._create_streaming_card()
if not card_id:
logger.warning("[Lark] 无法创建流式卡片,回退到非流式发送")
await self._fallback_send_streaming(generator, use_fallback)
return
# Step 2: 发送卡片消息
sent = await self._send_card_message(
card_id,
reply_message_id=self.message_obj.message_id,
)
if not sent:
logger.error("[Lark] 发送流式卡片消息失败,回退到非流式发送")
await self._fallback_send_streaming(generator, use_fallback)
return
logger.info("[Lark] 流式输出: 使用 CardKit 流式卡片")
# Step 3: 解耦发送循环 (Event-driven, 参考 Telegram Draft 路径)
# Lazy-init: card & sender loop created on first text token
card_id = None
sequence = 0
delta = ""
last_sent = ""
done = False
text_changed = asyncio.Event()
sender_task = None
fallback_used = False # 回退路径已处理 Metric,避免重复上报
async def _sender_loop() -> None:
"""信号驱动的文本发送循环,有新内容就发,RTT 自然限流。"""
@@ -788,7 +773,32 @@ class LarkMessageEvent(AstrMessageEvent):
if delta != snapshot:
text_changed.set()
sender_task = asyncio.create_task(_sender_loop())
async def _consume_rest_and_fallback(gen, initial_text: str) -> None:
"""Card creation failed; consume remaining chunks and send non-streaming."""
nonlocal fallback_used
fallback_used = True
buffer = MessageChain().message(initial_text) if initial_text else None
async for chain in gen:
if not isinstance(chain, MessageChain):
continue
if buffer is None:
buffer = chain
else:
buffer.chain.extend(chain.chain)
if buffer:
buffer.squash_plain()
await self.send(buffer)
await Metric.upload(msg_event_tick=1, adapter_name=self.platform_meta.name)
self._has_send_oper = True
async def _flush_and_close_card() -> None:
"""补发最终文本并关闭当前卡片的流式模式。"""
nonlocal sequence
if delta and delta != last_sent:
sequence += 1
await self._update_streaming_text(card_id, delta, sequence)
sequence += 1
await self._close_streaming_mode(card_id, sequence)
try:
async for chain in generator:
@@ -796,26 +806,69 @@ class LarkMessageEvent(AstrMessageEvent):
continue
if chain.type == "break":
# 飞书卡片不支持分段,忽略 break
# Tool call boundary: close current card, next text
# token will lazily create a new one below the tool
# status message.
if card_id and sender_task:
done = True
text_changed.set()
await sender_task
await _flush_and_close_card()
# Reset for lazy new-card creation
card_id = None
sequence = 0
delta = ""
last_sent = ""
done = False
sender_task = None
continue
for comp in chain.chain:
if isinstance(comp, Plain):
delta += comp.text
# Lazy card creation on first text token
if card_id is None:
card_id = await self._create_streaming_card()
if not card_id:
logger.warning(
"[Lark] 无法创建流式卡片,回退到非流式发送"
)
await _consume_rest_and_fallback(generator, delta)
return
sent = await self._send_card_message(
card_id,
reply_message_id=self.message_obj.message_id,
)
if not sent:
logger.error(
"[Lark] 发送流式卡片消息失败,回退到非流式发送"
)
await _consume_rest_and_fallback(generator, delta)
return
logger.info("[Lark] 流式输出: 使用 CardKit 流式卡片")
sender_task = asyncio.create_task(_sender_loop())
text_changed.set()
finally:
done = True
text_changed.set()
await sender_task
if sender_task:
await sender_task
# Step 4: 必要时补发最终文本 + 关闭流式模式
if delta and delta != last_sent:
sequence += 1
await self._update_streaming_text(card_id, delta, sequence)
# If no text was produced at all, no card was created
if card_id is None:
if not fallback_used:
await Metric.upload(
msg_event_tick=1, adapter_name=self.platform_meta.name
)
self._has_send_oper = True
return
sequence += 1
await self._close_streaming_mode(card_id, sequence)
await _flush_and_close_card()
# Step 5: 内联父类 send_streaming 的副作用
# 内联父类 send_streaming 的副作用
await Metric.upload(msg_event_tick=1, adapter_name=self.platform_meta.name)
self._has_send_oper = True