-
-
Notifications
You must be signed in to change notification settings - Fork 2.1k
feat(lark): add collapsible reasoning panel support and enhance message handling #6831
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -5,7 +5,7 @@ | |
| 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 @@ async def process( | |
| 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", | ||
|
Comment on lines
+284
to
+285
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The values for It's recommended to define these as constants in a shared module and import them where needed. This ensures consistency and makes future updates easier. For example, you could create a constants file or add to an existing one: # In a shared constants module
LARK_REASONING_PANEL_TYPE = "lark_collapsible_panel_reasoning"
LARK_REASONING_DEFAULT_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 = [] | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -28,7 +28,7 @@ | |
|
|
||
| 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 @@ async def _convert_to_lark(message: MessageChain, lark_client: lark.Client) -> l | |
| ret.append(_stage) | ||
| return ret | ||
|
|
||
| @staticmethod | ||
|
sourcery-ai[bot] marked this conversation as resolved.
|
||
| 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)), | ||
| ) | ||
| ) | ||
|
Comment on lines
+315
to
+345
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. There is significant code duplication between To improve this, you could extract the common logic for building a panel element into a new helper method. For example: @staticmethod
def _build_collapsible_panel_element(content: str, title: str, expanded: bool) -> 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": content},
],
}Then, both |
||
| 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 @@ async def send_message_chain( | |
| 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) | ||
|
|
||
| if res: # 只在有内容时发送 | ||
| wrapped = { | ||
| "zh_cn": { | ||
| "title": "", | ||
| "content": res, | ||
| }, | ||
| } | ||
| await LarkMessageEvent._send_im_message( | ||
| buffered_components: list = [] | ||
|
|
||
| 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 @@ async def _sender_loop() -> None: | |
| 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 @@ async def _consume_rest_and_fallback(gen, initial_text: str) -> None: | |
|
|
||
| async def _flush_and_close_card() -> None: | ||
| """补发最终文本并关闭当前卡片的流式模式。""" | ||
| if not card_id: | ||
| return | ||
| nonlocal sequence | ||
| if delta and delta != last_sent: | ||
| sequence += 1 | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
suggestion: Guard against
Noneor empty reasoning when building the Lark panel marker.Casting
event.get_extra("_llm_reasoning_content")withstr(...)turnsNoneinto the literal "None" and still inserts a marker for empty/whitespace-only content. Since downstream logic ignores emptycontent, this leaves you with a no-op component. Consider normalizing first, e.g.raw = event.get_extra(...); reasoning_content = (raw or "").strip(), and only inserting the reasoning component whenreasoning_contentis non-empty to avoid spurious markers and "None" text.