|
28 | 28 |
|
29 | 29 | from astrbot import logger |
30 | 30 | 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 |
32 | 32 | from astrbot.api.message_components import Image as AstrBotImage |
33 | 33 | from astrbot.core.utils.astrbot_path import get_astrbot_temp_path |
34 | 34 | 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 |
280 | 280 | ret.append(_stage) |
281 | 281 | return ret |
282 | 282 |
|
| 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 | + |
283 | 429 | @staticmethod |
284 | 430 | async def send_message_chain( |
285 | 431 | message_chain: MessageChain, |
@@ -317,27 +463,89 @@ async def send_message_chain( |
317 | 463 | else: |
318 | 464 | other_components.append(comp) |
319 | 465 |
|
| 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 | + |
320 | 488 | # 先发送非文件内容(如果有) |
321 | 489 | 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, |
334 | 503 | 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, |
340 | 504 | ) |
| 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() |
341 | 549 |
|
342 | 550 | # 发送附件 |
343 | 551 | for file_comp in file_components: |
@@ -765,7 +973,7 @@ async def _sender_loop() -> None: |
765 | 973 | await text_changed.wait() |
766 | 974 | text_changed.clear() |
767 | 975 | snapshot = delta |
768 | | - if snapshot and snapshot != last_sent: |
| 976 | + if snapshot and snapshot != last_sent and card_id: |
769 | 977 | sequence += 1 |
770 | 978 | ok = await self._update_streaming_text(card_id, snapshot, sequence) |
771 | 979 | if ok: |
@@ -793,6 +1001,8 @@ async def _consume_rest_and_fallback(gen, initial_text: str) -> None: |
793 | 1001 |
|
794 | 1002 | async def _flush_and_close_card() -> None: |
795 | 1003 | """补发最终文本并关闭当前卡片的流式模式。""" |
| 1004 | + if not card_id: |
| 1005 | + return |
796 | 1006 | nonlocal sequence |
797 | 1007 | if delta and delta != last_sent: |
798 | 1008 | sequence += 1 |
|
0 commit comments