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
This commit is contained in:
Soulter
2026-03-23 16:12:43 +08:00
committed by GitHub
parent 189d378f91
commit 3c6cd22e2c
2 changed files with 244 additions and 21 deletions
+16 -3
View File
@@ -5,7 +5,7 @@ import traceback
from collections.abc import AsyncGenerator
from astrbot.core import file_token_service, html_renderer, logger
from astrbot.core.message.components import At, Image, Node, Plain, Record, Reply
from astrbot.core.message.components import At, Image, Json, Node, Plain, Record, Reply
from astrbot.core.message.message_event_result import ResultContentType
from astrbot.core.pipeline.content_safety_check.stage import ContentSafetyCheckStage
from astrbot.core.platform.astr_message_event import AstrMessageEvent
@@ -275,8 +275,21 @@ class ResultDecorateStage(Stage):
and event.get_extra("_llm_reasoning_content")
):
# inject reasoning content to chain
reasoning_content = event.get_extra("_llm_reasoning_content")
result.chain.insert(0, Plain(f"🤔 思考: {reasoning_content}\n"))
reasoning_content = str(event.get_extra("_llm_reasoning_content"))
if event.get_platform_name() == "lark":
result.chain.insert(
0,
Json(
data={
"type": "lark_collapsible_panel_reasoning",
"title": "💭 Thinking",
"expanded": False,
"content": reasoning_content,
},
),
)
else:
result.chain.insert(0, Plain(f"🤔 思考: {reasoning_content}\n"))
if should_tts and tts_provider:
new_chain = []
+228 -18
View File
@@ -28,7 +28,7 @@ from lark_oapi.api.im.v1 import (
from astrbot import logger
from astrbot.api.event import AstrMessageEvent, MessageChain
from astrbot.api.message_components import At, File, Plain, Record, Video
from astrbot.api.message_components import At, File, Json, Plain, Record, Video
from astrbot.api.message_components import Image as AstrBotImage
from astrbot.core.utils.astrbot_path import get_astrbot_temp_path
from astrbot.core.utils.io import download_image_by_url
@@ -280,6 +280,152 @@ class LarkMessageEvent(AstrMessageEvent):
ret.append(_stage)
return ret
@staticmethod
def _build_collapsible_panel_element(
reasoning_content: str,
title: str,
expanded: bool = False,
) -> dict:
return {
"tag": "collapsible_panel",
"expanded": expanded,
"background_color": "grey",
"padding": "8px 8px 8px 8px",
"margin": "4px 0px 4px 0px",
"border": {
"color": "grey",
"corner_radius": "6px",
},
"header": {
"title": {
"tag": "plain_text",
"content": title,
},
"background_color": "grey",
},
"elements": [
{
"tag": "markdown",
"content": reasoning_content,
}
],
}
@staticmethod
def _build_reasoning_collapsible_panel(reasoning_content: str, title: str) -> dict:
return {
"schema": "2.0",
"body": {
"elements": [
LarkMessageEvent._build_collapsible_panel_element(
reasoning_content=reasoning_content,
title=title,
expanded=False,
)
]
},
}
@staticmethod
def _build_reasoning_card(message_chain: MessageChain) -> dict | None:
elements: list[dict] = []
for comp in message_chain.chain:
if isinstance(comp, Json) and isinstance(comp.data, dict):
if comp.data.get("type") != "lark_collapsible_panel_reasoning":
continue
reasoning_content = str(comp.data.get("content", "")).strip()
if not reasoning_content:
continue
elements.append(
LarkMessageEvent._build_collapsible_panel_element(
reasoning_content=reasoning_content,
title=str(comp.data.get("title", "💭 Thinking")),
expanded=bool(comp.data.get("expanded", False)),
)
)
elif isinstance(comp, Plain):
if comp.text:
elements.append({"tag": "markdown", "content": comp.text})
else:
return None
return {
"schema": "2.0",
"body": {
"elements": elements,
},
} if elements else None
@staticmethod
async def _send_interactive_card(
card_json: dict,
lark_client: lark.Client,
reply_message_id: str | None = None,
receive_id: str | None = None,
receive_id_type: str | None = None,
) -> bool:
if lark_client.cardkit is None:
logger.error("[Lark] API Client cardkit 模块未初始化,无法发送卡片")
return False
try:
response = await lark_client.cardkit.v1.card.acreate(
CreateCardRequest.builder()
.request_body(
CreateCardRequestBody.builder()
.type("card_json")
.data(json.dumps(card_json, ensure_ascii=False))
.build()
)
.build()
)
except Exception as e:
logger.error(f"[Lark] 创建卡片失败: {e}")
return False
if not response.success():
logger.error(f"[Lark] 创建卡片失败({response.code}): {response.msg}")
return False
if response.data is None or not response.data.card_id:
logger.error("[Lark] 创建卡片成功但未返回 card_id")
return False
card_content = json.dumps(
{"type": "card", "data": {"card_id": response.data.card_id}},
ensure_ascii=False,
)
return await LarkMessageEvent._send_im_message(
lark_client,
content=card_content,
msg_type="interactive",
reply_message_id=reply_message_id,
receive_id=receive_id,
receive_id_type=receive_id_type,
)
@staticmethod
async def _send_collapsible_reasoning_panel(
reasoning_content: str,
title: str,
lark_client: lark.Client,
reply_message_id: str | None = None,
receive_id: str | None = None,
receive_id_type: str | None = None,
) -> bool:
if not reasoning_content:
return True
card_json = LarkMessageEvent._build_reasoning_collapsible_panel(
reasoning_content=reasoning_content,
title=title,
)
return await LarkMessageEvent._send_interactive_card(
card_json,
lark_client=lark_client,
reply_message_id=reply_message_id,
receive_id=receive_id,
receive_id_type=receive_id_type,
)
@staticmethod
async def send_message_chain(
message_chain: MessageChain,
@@ -317,27 +463,89 @@ class LarkMessageEvent(AstrMessageEvent):
else:
other_components.append(comp)
has_reasoning_marker = any(
isinstance(comp, Json)
and isinstance(comp.data, dict)
and comp.data.get("type") == "lark_collapsible_panel_reasoning"
for comp in other_components
)
if (
has_reasoning_marker
and not file_components
and not audio_components
and not media_components
):
card_json = LarkMessageEvent._build_reasoning_card(message_chain)
if card_json and await LarkMessageEvent._send_interactive_card(
card_json,
lark_client=lark_client,
reply_message_id=reply_message_id,
receive_id=receive_id,
receive_id_type=receive_id_type,
):
return
# 先发送非文件内容(如果有)
if other_components:
temp_chain = MessageChain()
temp_chain.chain = other_components
res = await LarkMessageEvent._convert_to_lark(temp_chain, lark_client)
buffered_components: list = []
if res: # 只在有内容时发送
wrapped = {
"zh_cn": {
"title": "",
"content": res,
},
}
await LarkMessageEvent._send_im_message(
async def _flush_buffer() -> None:
nonlocal buffered_components
if not buffered_components:
return
pending_chain = MessageChain()
pending_chain.chain = buffered_components
buffered_components = []
res = await LarkMessageEvent._convert_to_lark(
pending_chain,
lark_client,
content=json.dumps(wrapped),
msg_type="post",
reply_message_id=reply_message_id,
receive_id=receive_id,
receive_id_type=receive_id_type,
)
if res: # 只在有内容时发送
wrapped = {
"zh_cn": {
"title": "",
"content": res,
},
}
await LarkMessageEvent._send_im_message(
lark_client,
content=json.dumps(wrapped),
msg_type="post",
reply_message_id=reply_message_id,
receive_id=receive_id,
receive_id_type=receive_id_type,
)
# 维持组件顺序:遇到折叠面板标记先 flush 当前普通内容并发送卡片
for comp in other_components:
if isinstance(comp, Json) and isinstance(comp.data, dict):
comp_type = comp.data.get("type")
if comp_type == "lark_collapsible_panel_reasoning":
await _flush_buffer()
if reason_text := str(comp.data.get("content", "")).strip():
panel_title = str(
comp.data.get("title", "💭 Thinking"),
)
success = await LarkMessageEvent._send_collapsible_reasoning_panel(
reasoning_content=reason_text,
title=panel_title,
lark_client=lark_client,
reply_message_id=reply_message_id,
receive_id=receive_id,
receive_id_type=receive_id_type,
)
if not success:
buffered_components.append(
Plain(
f"🤔 {panel_title}: {reason_text}",
),
)
continue
buffered_components.append(comp)
await _flush_buffer()
# 发送附件
for file_comp in file_components:
@@ -765,7 +973,7 @@ class LarkMessageEvent(AstrMessageEvent):
await text_changed.wait()
text_changed.clear()
snapshot = delta
if snapshot and snapshot != last_sent:
if snapshot and snapshot != last_sent and card_id:
sequence += 1
ok = await self._update_streaming_text(card_id, snapshot, sequence)
if ok:
@@ -793,6 +1001,8 @@ class LarkMessageEvent(AstrMessageEvent):
async def _flush_and_close_card() -> None:
"""补发最终文本并关闭当前卡片的流式模式。"""
if not card_id:
return
nonlocal sequence
if delta and delta != last_sent:
sequence += 1