Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 16 additions & 3 deletions astrbot/core/pipeline/result_decorate/stage.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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":
Comment on lines 277 to +279
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion: Guard against None or empty reasoning when building the Lark panel marker.

Casting event.get_extra("_llm_reasoning_content") with str(...) turns None into the literal "None" and still inserts a marker for empty/whitespace-only content. Since downstream logic ignores empty content, 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 when reasoning_content is non-empty to avoid spurious markers and "None" text.

Suggested change
# 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":
# inject reasoning content to chain
reasoning_content = (event.get_extra("_llm_reasoning_content") or "").strip()
if reasoning_content and event.get_platform_name() == "lark":

result.chain.insert(
0,
Json(
data={
"type": "lark_collapsible_panel_reasoning",
"title": "💭 Thinking",
Comment on lines +284 to +285
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The values for type ("lark_collapsible_panel_reasoning") and the default title ("💭 Thinking") are hardcoded here and also in astrbot/core/platform/sources/lark/lark_event.py. Using these 'magic strings' across multiple files can lead to inconsistencies and makes the code harder to maintain.

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 = []
Expand Down
248 changes: 229 additions & 19 deletions astrbot/core/platform/sources/lark/lark_event.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -280,6 +280,152 @@ async def _convert_to_lark(message: MessageChain, lark_client: lark.Client) -> l
ret.append(_stage)
return ret

@staticmethod
Comment thread
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
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

There is significant code duplication between _build_reasoning_collapsible_panel and _build_reasoning_card when creating the collapsible_panel JSON structure. This makes the code harder to maintain, as any change to the panel structure needs to be applied in two places.

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 _build_reasoning_collapsible_panel and _build_reasoning_card can be simplified by calling this new method. This will make the code cleaner and easier to manage.

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,
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand Down
Loading