Skip to content

Commit 3c6cd22

Browse files
authored
feat(lark): add collapsible reasoning panel support and enhance message handling (#6831)
* feat(lark): add collapsible reasoning panel support and enhance message handling * feat(lark): refactor collapsible panel creation for improved readability and maintainability
1 parent 189d378 commit 3c6cd22

2 files changed

Lines changed: 245 additions & 22 deletions

File tree

astrbot/core/pipeline/result_decorate/stage.py

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
from collections.abc import AsyncGenerator
66

77
from astrbot.core import file_token_service, html_renderer, logger
8-
from astrbot.core.message.components import At, Image, Node, Plain, Record, Reply
8+
from astrbot.core.message.components import At, Image, Json, Node, Plain, Record, Reply
99
from astrbot.core.message.message_event_result import ResultContentType
1010
from astrbot.core.pipeline.content_safety_check.stage import ContentSafetyCheckStage
1111
from astrbot.core.platform.astr_message_event import AstrMessageEvent
@@ -275,8 +275,21 @@ async def process(
275275
and event.get_extra("_llm_reasoning_content")
276276
):
277277
# inject reasoning content to chain
278-
reasoning_content = event.get_extra("_llm_reasoning_content")
279-
result.chain.insert(0, Plain(f"🤔 思考: {reasoning_content}\n"))
278+
reasoning_content = str(event.get_extra("_llm_reasoning_content"))
279+
if event.get_platform_name() == "lark":
280+
result.chain.insert(
281+
0,
282+
Json(
283+
data={
284+
"type": "lark_collapsible_panel_reasoning",
285+
"title": "💭 Thinking",
286+
"expanded": False,
287+
"content": reasoning_content,
288+
},
289+
),
290+
)
291+
else:
292+
result.chain.insert(0, Plain(f"🤔 思考: {reasoning_content}\n"))
280293

281294
if should_tts and tts_provider:
282295
new_chain = []

astrbot/core/platform/sources/lark/lark_event.py

Lines changed: 229 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@
2828

2929
from astrbot import logger
3030
from astrbot.api.event import AstrMessageEvent, MessageChain
31-
from astrbot.api.message_components import At, File, Plain, Record, Video
31+
from astrbot.api.message_components import At, File, Json, Plain, Record, Video
3232
from astrbot.api.message_components import Image as AstrBotImage
3333
from astrbot.core.utils.astrbot_path import get_astrbot_temp_path
3434
from astrbot.core.utils.io import download_image_by_url
@@ -280,6 +280,152 @@ async def _convert_to_lark(message: MessageChain, lark_client: lark.Client) -> l
280280
ret.append(_stage)
281281
return ret
282282

283+
@staticmethod
284+
def _build_collapsible_panel_element(
285+
reasoning_content: str,
286+
title: str,
287+
expanded: bool = False,
288+
) -> dict:
289+
return {
290+
"tag": "collapsible_panel",
291+
"expanded": expanded,
292+
"background_color": "grey",
293+
"padding": "8px 8px 8px 8px",
294+
"margin": "4px 0px 4px 0px",
295+
"border": {
296+
"color": "grey",
297+
"corner_radius": "6px",
298+
},
299+
"header": {
300+
"title": {
301+
"tag": "plain_text",
302+
"content": title,
303+
},
304+
"background_color": "grey",
305+
},
306+
"elements": [
307+
{
308+
"tag": "markdown",
309+
"content": reasoning_content,
310+
}
311+
],
312+
}
313+
314+
@staticmethod
315+
def _build_reasoning_collapsible_panel(reasoning_content: str, title: str) -> dict:
316+
return {
317+
"schema": "2.0",
318+
"body": {
319+
"elements": [
320+
LarkMessageEvent._build_collapsible_panel_element(
321+
reasoning_content=reasoning_content,
322+
title=title,
323+
expanded=False,
324+
)
325+
]
326+
},
327+
}
328+
329+
@staticmethod
330+
def _build_reasoning_card(message_chain: MessageChain) -> dict | None:
331+
elements: list[dict] = []
332+
for comp in message_chain.chain:
333+
if isinstance(comp, Json) and isinstance(comp.data, dict):
334+
if comp.data.get("type") != "lark_collapsible_panel_reasoning":
335+
continue
336+
reasoning_content = str(comp.data.get("content", "")).strip()
337+
if not reasoning_content:
338+
continue
339+
elements.append(
340+
LarkMessageEvent._build_collapsible_panel_element(
341+
reasoning_content=reasoning_content,
342+
title=str(comp.data.get("title", "💭 Thinking")),
343+
expanded=bool(comp.data.get("expanded", False)),
344+
)
345+
)
346+
elif isinstance(comp, Plain):
347+
if comp.text:
348+
elements.append({"tag": "markdown", "content": comp.text})
349+
else:
350+
return None
351+
352+
return {
353+
"schema": "2.0",
354+
"body": {
355+
"elements": elements,
356+
},
357+
} if elements else None
358+
359+
@staticmethod
360+
async def _send_interactive_card(
361+
card_json: dict,
362+
lark_client: lark.Client,
363+
reply_message_id: str | None = None,
364+
receive_id: str | None = None,
365+
receive_id_type: str | None = None,
366+
) -> bool:
367+
if lark_client.cardkit is None:
368+
logger.error("[Lark] API Client cardkit 模块未初始化,无法发送卡片")
369+
return False
370+
371+
try:
372+
response = await lark_client.cardkit.v1.card.acreate(
373+
CreateCardRequest.builder()
374+
.request_body(
375+
CreateCardRequestBody.builder()
376+
.type("card_json")
377+
.data(json.dumps(card_json, ensure_ascii=False))
378+
.build()
379+
)
380+
.build()
381+
)
382+
except Exception as e:
383+
logger.error(f"[Lark] 创建卡片失败: {e}")
384+
return False
385+
386+
if not response.success():
387+
logger.error(f"[Lark] 创建卡片失败({response.code}): {response.msg}")
388+
return False
389+
if response.data is None or not response.data.card_id:
390+
logger.error("[Lark] 创建卡片成功但未返回 card_id")
391+
return False
392+
393+
card_content = json.dumps(
394+
{"type": "card", "data": {"card_id": response.data.card_id}},
395+
ensure_ascii=False,
396+
)
397+
return await LarkMessageEvent._send_im_message(
398+
lark_client,
399+
content=card_content,
400+
msg_type="interactive",
401+
reply_message_id=reply_message_id,
402+
receive_id=receive_id,
403+
receive_id_type=receive_id_type,
404+
)
405+
406+
@staticmethod
407+
async def _send_collapsible_reasoning_panel(
408+
reasoning_content: str,
409+
title: str,
410+
lark_client: lark.Client,
411+
reply_message_id: str | None = None,
412+
receive_id: str | None = None,
413+
receive_id_type: str | None = None,
414+
) -> bool:
415+
if not reasoning_content:
416+
return True
417+
card_json = LarkMessageEvent._build_reasoning_collapsible_panel(
418+
reasoning_content=reasoning_content,
419+
title=title,
420+
)
421+
return await LarkMessageEvent._send_interactive_card(
422+
card_json,
423+
lark_client=lark_client,
424+
reply_message_id=reply_message_id,
425+
receive_id=receive_id,
426+
receive_id_type=receive_id_type,
427+
)
428+
283429
@staticmethod
284430
async def send_message_chain(
285431
message_chain: MessageChain,
@@ -317,27 +463,89 @@ async def send_message_chain(
317463
else:
318464
other_components.append(comp)
319465

466+
has_reasoning_marker = any(
467+
isinstance(comp, Json)
468+
and isinstance(comp.data, dict)
469+
and comp.data.get("type") == "lark_collapsible_panel_reasoning"
470+
for comp in other_components
471+
)
472+
if (
473+
has_reasoning_marker
474+
and not file_components
475+
and not audio_components
476+
and not media_components
477+
):
478+
card_json = LarkMessageEvent._build_reasoning_card(message_chain)
479+
if card_json and await LarkMessageEvent._send_interactive_card(
480+
card_json,
481+
lark_client=lark_client,
482+
reply_message_id=reply_message_id,
483+
receive_id=receive_id,
484+
receive_id_type=receive_id_type,
485+
):
486+
return
487+
320488
# 先发送非文件内容(如果有)
321489
if other_components:
322-
temp_chain = MessageChain()
323-
temp_chain.chain = other_components
324-
res = await LarkMessageEvent._convert_to_lark(temp_chain, lark_client)
325-
326-
if res: # 只在有内容时发送
327-
wrapped = {
328-
"zh_cn": {
329-
"title": "",
330-
"content": res,
331-
},
332-
}
333-
await LarkMessageEvent._send_im_message(
490+
buffered_components: list = []
491+
492+
async def _flush_buffer() -> None:
493+
nonlocal buffered_components
494+
if not buffered_components:
495+
return
496+
497+
pending_chain = MessageChain()
498+
pending_chain.chain = buffered_components
499+
buffered_components = []
500+
501+
res = await LarkMessageEvent._convert_to_lark(
502+
pending_chain,
334503
lark_client,
335-
content=json.dumps(wrapped),
336-
msg_type="post",
337-
reply_message_id=reply_message_id,
338-
receive_id=receive_id,
339-
receive_id_type=receive_id_type,
340504
)
505+
if res: # 只在有内容时发送
506+
wrapped = {
507+
"zh_cn": {
508+
"title": "",
509+
"content": res,
510+
},
511+
}
512+
await LarkMessageEvent._send_im_message(
513+
lark_client,
514+
content=json.dumps(wrapped),
515+
msg_type="post",
516+
reply_message_id=reply_message_id,
517+
receive_id=receive_id,
518+
receive_id_type=receive_id_type,
519+
)
520+
521+
# 维持组件顺序:遇到折叠面板标记先 flush 当前普通内容并发送卡片
522+
for comp in other_components:
523+
if isinstance(comp, Json) and isinstance(comp.data, dict):
524+
comp_type = comp.data.get("type")
525+
if comp_type == "lark_collapsible_panel_reasoning":
526+
await _flush_buffer()
527+
if reason_text := str(comp.data.get("content", "")).strip():
528+
panel_title = str(
529+
comp.data.get("title", "💭 Thinking"),
530+
)
531+
success = await LarkMessageEvent._send_collapsible_reasoning_panel(
532+
reasoning_content=reason_text,
533+
title=panel_title,
534+
lark_client=lark_client,
535+
reply_message_id=reply_message_id,
536+
receive_id=receive_id,
537+
receive_id_type=receive_id_type,
538+
)
539+
if not success:
540+
buffered_components.append(
541+
Plain(
542+
f"🤔 {panel_title}: {reason_text}",
543+
),
544+
)
545+
continue
546+
buffered_components.append(comp)
547+
548+
await _flush_buffer()
341549

342550
# 发送附件
343551
for file_comp in file_components:
@@ -765,7 +973,7 @@ async def _sender_loop() -> None:
765973
await text_changed.wait()
766974
text_changed.clear()
767975
snapshot = delta
768-
if snapshot and snapshot != last_sent:
976+
if snapshot and snapshot != last_sent and card_id:
769977
sequence += 1
770978
ok = await self._update_streaming_text(card_id, snapshot, sequence)
771979
if ok:
@@ -793,6 +1001,8 @@ async def _consume_rest_and_fallback(gen, initial_text: str) -> None:
7931001

7941002
async def _flush_and_close_card() -> None:
7951003
"""补发最终文本并关闭当前卡片的流式模式。"""
1004+
if not card_id:
1005+
return
7961006
nonlocal sequence
7971007
if delta and delta != last_sent:
7981008
sequence += 1

0 commit comments

Comments
 (0)