AstrBot 4.8.0__py3-none-any.whl → 4.9.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (106) hide show
  1. astrbot/cli/__init__.py +1 -1
  2. astrbot/core/agent/runners/tool_loop_agent_runner.py +0 -1
  3. astrbot/core/agent/tool.py +7 -2
  4. astrbot/core/astr_agent_tool_exec.py +5 -1
  5. astrbot/core/config/astrbot_config.py +4 -0
  6. astrbot/core/config/default.py +72 -1
  7. astrbot/core/config/i18n_utils.py +1 -0
  8. astrbot/core/core_lifecycle.py +1 -1
  9. astrbot/core/db/__init__.py +2 -3
  10. astrbot/core/db/migration/migra_3_to_4.py +2 -0
  11. astrbot/core/db/migration/sqlite_v3.py +6 -4
  12. astrbot/core/db/po.py +16 -15
  13. astrbot/core/db/sqlite.py +4 -3
  14. astrbot/core/db/vec_db/faiss_impl/embedding_storage.py +2 -0
  15. astrbot/core/event_bus.py +6 -1
  16. astrbot/core/knowledge_base/retrieval/manager.py +5 -1
  17. astrbot/core/log.py +2 -1
  18. astrbot/core/message/components.py +9 -3
  19. astrbot/core/persona_mgr.py +2 -2
  20. astrbot/core/pipeline/content_safety_check/stage.py +1 -1
  21. astrbot/core/pipeline/context_utils.py +2 -1
  22. astrbot/core/pipeline/process_stage/method/star_request.py +1 -2
  23. astrbot/core/pipeline/process_stage/stage.py +1 -1
  24. astrbot/core/pipeline/respond/stage.py +8 -2
  25. astrbot/core/pipeline/result_decorate/stage.py +89 -22
  26. astrbot/core/pipeline/scheduler.py +5 -1
  27. astrbot/core/pipeline/waking_check/stage.py +10 -0
  28. astrbot/core/platform/astr_message_event.py +5 -3
  29. astrbot/core/platform/astrbot_message.py +2 -2
  30. astrbot/core/platform/manager.py +4 -0
  31. astrbot/core/platform/platform.py +11 -3
  32. astrbot/core/platform/platform_metadata.py +1 -1
  33. astrbot/core/platform/register.py +1 -0
  34. astrbot/core/platform/sources/aiocqhttp/aiocqhttp_message_event.py +8 -6
  35. astrbot/core/platform/sources/aiocqhttp/aiocqhttp_platform_adapter.py +9 -5
  36. astrbot/core/platform/sources/dingtalk/dingtalk_adapter.py +24 -16
  37. astrbot/core/platform/sources/dingtalk/dingtalk_event.py +5 -2
  38. astrbot/core/platform/sources/discord/client.py +16 -4
  39. astrbot/core/platform/sources/discord/components.py +2 -2
  40. astrbot/core/platform/sources/discord/discord_platform_adapter.py +52 -24
  41. astrbot/core/platform/sources/discord/discord_platform_event.py +29 -8
  42. astrbot/core/platform/sources/lark/lark_adapter.py +183 -20
  43. astrbot/core/platform/sources/lark/lark_event.py +39 -4
  44. astrbot/core/platform/sources/lark/server.py +206 -0
  45. astrbot/core/platform/sources/misskey/misskey_adapter.py +2 -3
  46. astrbot/core/platform/sources/qqofficial/qqofficial_message_event.py +62 -18
  47. astrbot/core/platform/sources/qqofficial/qqofficial_platform_adapter.py +13 -7
  48. astrbot/core/platform/sources/qqofficial_webhook/qo_webhook_adapter.py +5 -3
  49. astrbot/core/platform/sources/qqofficial_webhook/qo_webhook_server.py +2 -1
  50. astrbot/core/platform/sources/slack/client.py +9 -2
  51. astrbot/core/platform/sources/slack/slack_adapter.py +15 -9
  52. astrbot/core/platform/sources/slack/slack_event.py +8 -7
  53. astrbot/core/platform/sources/telegram/tg_adapter.py +1 -1
  54. astrbot/core/platform/sources/telegram/tg_event.py +23 -27
  55. astrbot/core/platform/sources/webchat/webchat_adapter.py +2 -2
  56. astrbot/core/platform/sources/webchat/webchat_event.py +2 -2
  57. astrbot/core/platform/sources/wechatpadpro/wechatpadpro_adapter.py +26 -9
  58. astrbot/core/platform/sources/wecom/wecom_adapter.py +25 -28
  59. astrbot/core/platform/sources/wecom/wecom_event.py +2 -2
  60. astrbot/core/platform/sources/wecom_ai_bot/wecomai_event.py +3 -3
  61. astrbot/core/platform/sources/weixin_official_account/weixin_offacc_adapter.py +30 -25
  62. astrbot/core/platform/sources/weixin_official_account/weixin_offacc_event.py +10 -7
  63. astrbot/core/provider/func_tool_manager.py +3 -3
  64. astrbot/core/provider/manager.py +130 -74
  65. astrbot/core/provider/provider.py +12 -1
  66. astrbot/core/provider/sources/azure_tts_source.py +31 -9
  67. astrbot/core/provider/sources/bailian_rerank_source.py +4 -0
  68. astrbot/core/provider/sources/dashscope_tts.py +3 -2
  69. astrbot/core/provider/sources/edge_tts_source.py +1 -1
  70. astrbot/core/provider/sources/fishaudio_tts_api_source.py +5 -4
  71. astrbot/core/provider/sources/gemini_embedding_source.py +15 -5
  72. astrbot/core/provider/sources/gemini_source.py +12 -10
  73. astrbot/core/provider/sources/minimax_tts_api_source.py +4 -2
  74. astrbot/core/provider/sources/openai_embedding_source.py +2 -2
  75. astrbot/core/provider/sources/openai_source.py +4 -0
  76. astrbot/core/provider/sources/sensevoice_selfhosted_source.py +5 -2
  77. astrbot/core/provider/sources/vllm_rerank_source.py +1 -0
  78. astrbot/core/provider/sources/whisper_api_source.py +1 -1
  79. astrbot/core/provider/sources/whisper_selfhosted_source.py +6 -2
  80. astrbot/core/provider/sources/xinference_rerank_source.py +10 -2
  81. astrbot/core/star/context.py +2 -2
  82. astrbot/core/star/register/star_handler.py +22 -5
  83. astrbot/core/star/star_handler.py +85 -4
  84. astrbot/core/updator.py +3 -3
  85. astrbot/core/utils/io.py +1 -1
  86. astrbot/core/utils/session_waiter.py +17 -10
  87. astrbot/core/utils/shared_preferences.py +32 -0
  88. astrbot/core/utils/t2i/__init__.py +2 -2
  89. astrbot/core/utils/t2i/local_strategy.py +25 -31
  90. astrbot/core/utils/tencent_record_helper.py +1 -1
  91. astrbot/core/utils/version_comparator.py +6 -3
  92. astrbot/core/utils/webhook_utils.py +19 -0
  93. astrbot/dashboard/routes/chat.py +14 -9
  94. astrbot/dashboard/routes/config.py +10 -20
  95. astrbot/dashboard/routes/conversation.py +91 -1
  96. astrbot/dashboard/routes/knowledge_base.py +253 -78
  97. astrbot/dashboard/routes/log.py +13 -8
  98. astrbot/dashboard/routes/platform.py +1 -1
  99. astrbot/dashboard/routes/plugin.py +113 -52
  100. astrbot/dashboard/routes/route.py +2 -0
  101. astrbot/dashboard/server.py +6 -3
  102. {astrbot-4.8.0.dist-info → astrbot-4.9.1.dist-info}/METADATA +9 -1
  103. {astrbot-4.8.0.dist-info → astrbot-4.9.1.dist-info}/RECORD +106 -105
  104. {astrbot-4.8.0.dist-info → astrbot-4.9.1.dist-info}/WHEEL +0 -0
  105. {astrbot-4.8.0.dist-info → astrbot-4.9.1.dist-info}/entry_points.txt +0 -0
  106. {astrbot-4.8.0.dist-info → astrbot-4.9.1.dist-info}/licenses/LICENSE +0 -0
@@ -20,7 +20,7 @@ class FontManager:
20
20
  _font_cache = {}
21
21
 
22
22
  @classmethod
23
- def get_font(cls, size: int) -> ImageFont.FreeTypeFont:
23
+ def get_font(cls, size: int) -> ImageFont.FreeTypeFont|ImageFont.ImageFont:
24
24
  """获取指定大小的字体,优先从缓存获取"""
25
25
  if size in cls._font_cache:
26
26
  return cls._font_cache[size]
@@ -66,23 +66,17 @@ class TextMeasurer:
66
66
  """测量文本尺寸的工具类"""
67
67
 
68
68
  @staticmethod
69
- def get_text_size(text: str, font: ImageFont.FreeTypeFont) -> Tuple[int, int]:
69
+ def get_text_size(text: str, font: ImageFont.FreeTypeFont|ImageFont.ImageFont) -> tuple[int, int]:
70
70
  """获取文本的尺寸"""
71
- try:
72
- # PIL 9.0.0 以上版本
73
- return (
74
- font.getbbox(text)[2:]
75
- if hasattr(font, "getbbox")
76
- else font.getsize(text)
77
- )
78
- except Exception:
79
- # 兼容旧版本
80
- return font.getsize(text)
71
+
72
+ # 依赖库Pillow>=11.2.1,不再需要考虑<9.0.0
73
+ left, top, right, bottom = font.getbbox("Hello world")
74
+ return int(right - left), int(bottom - top)
81
75
 
82
76
  @staticmethod
83
77
  def split_text_to_fit_width(
84
- text: str, font: ImageFont.FreeTypeFont, max_width: int
85
- ) -> List[str]:
78
+ text: str, font: ImageFont.FreeTypeFont|ImageFont.ImageFont, max_width: int
79
+ ) -> list[str]:
86
80
  """将文本拆分为多行,确保每行不超过指定宽度"""
87
81
  lines = []
88
82
  if not text:
@@ -126,7 +120,7 @@ class MarkdownElement(ABC):
126
120
  def render(
127
121
  self,
128
122
  image: Image.Image,
129
- draw: ImageDraw.Draw,
123
+ draw: ImageDraw.ImageDraw,
130
124
  x: int,
131
125
  y: int,
132
126
  image_width: int,
@@ -152,7 +146,7 @@ class TextElement(MarkdownElement):
152
146
  def render(
153
147
  self,
154
148
  image: Image.Image,
155
- draw: ImageDraw.Draw,
149
+ draw: ImageDraw.ImageDraw,
156
150
  x: int,
157
151
  y: int,
158
152
  image_width: int,
@@ -186,7 +180,7 @@ class BoldTextElement(MarkdownElement):
186
180
  def render(
187
181
  self,
188
182
  image: Image.Image,
189
- draw: ImageDraw.Draw,
183
+ draw: ImageDraw.ImageDraw,
190
184
  x: int,
191
185
  y: int,
192
186
  image_width: int,
@@ -251,7 +245,7 @@ class ItalicTextElement(MarkdownElement):
251
245
  def render(
252
246
  self,
253
247
  image: Image.Image,
254
- draw: ImageDraw.Draw,
248
+ draw: ImageDraw.ImageDraw,
255
249
  x: int,
256
250
  y: int,
257
251
  image_width: int,
@@ -299,7 +293,7 @@ class ItalicTextElement(MarkdownElement):
299
293
  # 倾斜变换,使用仿射变换实现斜体效果
300
294
  # 变换矩阵: [1, 0.2, 0, 0, 1, 0]
301
295
  italic_img = text_img.transform(
302
- text_img.size, Image.AFFINE, (1, 0.2, 0, 0, 1, 0), Image.BICUBIC
296
+ text_img.size, Image.Transform.AFFINE, (1, 0.2, 0, 0, 1, 0), Image.Resampling.BICUBIC
303
297
  )
304
298
 
305
299
  # 粘贴到原图像
@@ -331,7 +325,7 @@ class UnderlineTextElement(MarkdownElement):
331
325
  def render(
332
326
  self,
333
327
  image: Image.Image,
334
- draw: ImageDraw.Draw,
328
+ draw: ImageDraw.ImageDraw,
335
329
  x: int,
336
330
  y: int,
337
331
  image_width: int,
@@ -371,7 +365,7 @@ class StrikethroughTextElement(MarkdownElement):
371
365
  def render(
372
366
  self,
373
367
  image: Image.Image,
374
- draw: ImageDraw.Draw,
368
+ draw: ImageDraw.ImageDraw,
375
369
  x: int,
376
370
  y: int,
377
371
  image_width: int,
@@ -422,7 +416,7 @@ class HeaderElement(MarkdownElement):
422
416
  def render(
423
417
  self,
424
418
  image: Image.Image,
425
- draw: ImageDraw.Draw,
419
+ draw: ImageDraw.ImageDraw,
426
420
  x: int,
427
421
  y: int,
428
422
  image_width: int,
@@ -458,7 +452,7 @@ class QuoteElement(MarkdownElement):
458
452
  def render(
459
453
  self,
460
454
  image: Image.Image,
461
- draw: ImageDraw.Draw,
455
+ draw: ImageDraw.ImageDraw,
462
456
  x: int,
463
457
  y: int,
464
458
  image_width: int,
@@ -502,7 +496,7 @@ class ListItemElement(MarkdownElement):
502
496
  def render(
503
497
  self,
504
498
  image: Image.Image,
505
- draw: ImageDraw.Draw,
499
+ draw: ImageDraw.ImageDraw,
506
500
  x: int,
507
501
  y: int,
508
502
  image_width: int,
@@ -532,7 +526,7 @@ class ListItemElement(MarkdownElement):
532
526
  class CodeBlockElement(MarkdownElement):
533
527
  """代码块元素"""
534
528
 
535
- def __init__(self, content: List[str]):
529
+ def __init__(self, content: list[str]):
536
530
  super().__init__("\n".join(content))
537
531
 
538
532
  def calculate_height(self, image_width: int, font_size: int) -> int:
@@ -552,7 +546,7 @@ class CodeBlockElement(MarkdownElement):
552
546
  def render(
553
547
  self,
554
548
  image: Image.Image,
555
- draw: ImageDraw.Draw,
549
+ draw: ImageDraw.ImageDraw,
556
550
  x: int,
557
551
  y: int,
558
552
  image_width: int,
@@ -595,7 +589,7 @@ class InlineCodeElement(MarkdownElement):
595
589
  def render(
596
590
  self,
597
591
  image: Image.Image,
598
- draw: ImageDraw.Draw,
592
+ draw: ImageDraw.ImageDraw,
599
593
  x: int,
600
594
  y: int,
601
595
  image_width: int,
@@ -667,7 +661,7 @@ class ImageElement(MarkdownElement):
667
661
  def render(
668
662
  self,
669
663
  image: Image.Image,
670
- draw: ImageDraw.Draw,
664
+ draw: ImageDraw.ImageDraw,
671
665
  x: int,
672
666
  y: int,
673
667
  image_width: int,
@@ -686,7 +680,7 @@ class ImageElement(MarkdownElement):
686
680
  if pasted_image.width > max_width:
687
681
  ratio = max_width / pasted_image.width
688
682
  new_size = (int(max_width), int(pasted_image.height * ratio))
689
- pasted_image = pasted_image.resize(new_size, Image.LANCZOS)
683
+ pasted_image = pasted_image.resize(new_size, Image.Resampling.LANCZOS)
690
684
 
691
685
  # 计算居中位置
692
686
  paste_x = x + (image_width - pasted_image.width) // 2 - 10
@@ -705,7 +699,7 @@ class MarkdownParser:
705
699
  """Markdown解析器,将文本解析为元素"""
706
700
 
707
701
  @staticmethod
708
- async def parse(text: str) -> List[MarkdownElement]:
702
+ async def parse(text: str) -> list[MarkdownElement]:
709
703
  elements = []
710
704
  lines = text.split("\n")
711
705
 
@@ -847,7 +841,7 @@ class MarkdownRenderer:
847
841
  self,
848
842
  font_size: int = 26,
849
843
  width: int = 800,
850
- bg_color: Tuple[int, int, int] = (255, 255, 255),
844
+ bg_color: tuple[int, int, int] = (255, 255, 255),
851
845
  ):
852
846
  self.font_size = font_size
853
847
  self.width = width
@@ -68,7 +68,7 @@ async def convert_to_pcm_wav(input_path: str, output_path: str) -> str:
68
68
  from pyffmpeg import FFmpeg
69
69
 
70
70
  ff = FFmpeg()
71
- ff.convert(input=input_path, output=output_path)
71
+ ff.convert(input_file=input_path, output_file=output_path)
72
72
  except Exception as e:
73
73
  logger.debug(f"pyffmpeg 转换失败: {e}, 尝试使用 ffmpeg 命令行进行转换")
74
74
 
@@ -60,9 +60,12 @@ class VersionComparator:
60
60
  return -1
61
61
  if isinstance(p1, str) and isinstance(p2, int):
62
62
  return 1
63
- if (isinstance(p1, int) and isinstance(p2, int)) or (
64
- isinstance(p1, str) and isinstance(p2, str)
65
- ):
63
+ if isinstance(p1, int) and isinstance(p2, int):
64
+ if p1 > p2:
65
+ return 1
66
+ if p1 < p2:
67
+ return -1
68
+ if isinstance(p1, str) and isinstance(p2, str):
66
69
  if p1 > p2:
67
70
  return 1
68
71
  if p1 < p2:
@@ -1,4 +1,7 @@
1
+ import uuid
2
+
1
3
  from astrbot.core import astrbot_config, logger
4
+ from astrbot.core.config.default import WEBHOOK_SUPPORTED_PLATFORMS
2
5
 
3
6
 
4
7
  def _get_callback_api_base() -> str:
@@ -45,3 +48,19 @@ def log_webhook_info(platform_name: str, webhook_uuid: str):
45
48
  "====================\n"
46
49
  )
47
50
  logger.info(display_log)
51
+
52
+
53
+ def ensure_platform_webhook_config(platform_cfg: dict) -> bool:
54
+ """为支持统一 webhook 的平台自动生成 webhook_uuid
55
+
56
+ Args:
57
+ platform_cfg (dict): 平台配置字典
58
+
59
+ Returns:
60
+ bool: 如果生成了 webhook_uuid 则返回 True,否则返回 False
61
+ """
62
+ pt = platform_cfg.get("type", "")
63
+ if pt in WEBHOOK_SUPPORTED_PLATFORMS and not platform_cfg.get("webhook_uuid"):
64
+ platform_cfg["webhook_uuid"] = uuid.uuid4().hex[:16]
65
+ return True
66
+ return False
@@ -4,7 +4,9 @@ import mimetypes
4
4
  import os
5
5
  import uuid
6
6
  from contextlib import asynccontextmanager
7
+ from typing import cast
7
8
 
9
+ from quart import Response as QuartResponse
8
10
  from quart import g, make_response, request, send_file
9
11
 
10
12
  from astrbot.core import logger
@@ -424,16 +426,19 @@ class ChatRoute(Route):
424
426
  sender_name=username,
425
427
  )
426
428
 
427
- response = await make_response(
428
- stream(),
429
- {
430
- "Content-Type": "text/event-stream",
431
- "Cache-Control": "no-cache",
432
- "Transfer-Encoding": "chunked",
433
- "Connection": "keep-alive",
434
- },
429
+ response = cast(
430
+ QuartResponse,
431
+ await make_response(
432
+ stream(),
433
+ {
434
+ "Content-Type": "text/event-stream",
435
+ "Cache-Control": "no-cache",
436
+ "Transfer-Encoding": "chunked",
437
+ "Connection": "keep-alive",
438
+ },
439
+ ),
435
440
  )
436
- response.timeout = None # fix SSE auto disconnect issue # pyright: ignore[reportAttributeAccessIssue]
441
+ response.timeout = None # fix SSE auto disconnect issue
437
442
  return response
438
443
 
439
444
  async def delete_webchat_session(self):
@@ -2,7 +2,7 @@ import asyncio
2
2
  import inspect
3
3
  import os
4
4
  import traceback
5
- import uuid
5
+ from typing import Any
6
6
 
7
7
  from quart import request
8
8
 
@@ -14,7 +14,6 @@ from astrbot.core.config.default import (
14
14
  CONFIG_METADATA_3_SYSTEM,
15
15
  DEFAULT_CONFIG,
16
16
  DEFAULT_VALUE_MAP,
17
- WEBHOOK_SUPPORTED_PLATFORMS,
18
17
  )
19
18
  from astrbot.core.config.i18n_utils import ConfigMetadataI18n
20
19
  from astrbot.core.core_lifecycle import AstrBotCoreLifecycle
@@ -22,11 +21,12 @@ from astrbot.core.platform.register import platform_cls_map, platform_registry
22
21
  from astrbot.core.provider import Provider
23
22
  from astrbot.core.provider.register import provider_registry
24
23
  from astrbot.core.star.star import star_registry
24
+ from astrbot.core.utils.webhook_utils import ensure_platform_webhook_config
25
25
 
26
26
  from .route import Response, Route, RouteContext
27
27
 
28
28
 
29
- def try_cast(value: str, type_: str):
29
+ def try_cast(value: Any, type_: str):
30
30
  if type_ == "int":
31
31
  try:
32
32
  return int(value)
@@ -505,9 +505,9 @@ class ConfigRoute(Route):
505
505
  if not isinstance(inst, EmbeddingProvider):
506
506
  return Response().error("提供商不是 EmbeddingProvider 类型").__dict__
507
507
 
508
- # 初始化
509
- if getattr(inst, "initialize", None):
510
- await inst.initialize()
508
+ init_fn = getattr(inst, "initialize", None)
509
+ if inspect.iscoroutinefunction(init_fn):
510
+ await init_fn()
511
511
 
512
512
  # 获取嵌入向量维度
513
513
  vec = await inst.get_embedding("echo")
@@ -558,13 +558,8 @@ class ConfigRoute(Route):
558
558
  async def post_new_platform(self):
559
559
  new_platform_config = await request.json
560
560
 
561
- # 如果是支持统一 webhook 模式的平台,且启用了统一 webhook 模式,自动生成 webhook_uuid
562
- platform_type = new_platform_config.get("type", "")
563
- if platform_type in WEBHOOK_SUPPORTED_PLATFORMS:
564
- if new_platform_config.get("unified_webhook_mode", False):
565
- # 如果没有 webhook_uuid 或为空,自动生成
566
- if not new_platform_config.get("webhook_uuid"):
567
- new_platform_config["webhook_uuid"] = uuid.uuid4().hex[:16]
561
+ # 如果是支持统一 webhook 模式的平台,生成 webhook_uuid
562
+ ensure_platform_webhook_config(new_platform_config)
568
563
 
569
564
  self.config["platform"].append(new_platform_config)
570
565
  try:
@@ -596,12 +591,7 @@ class ConfigRoute(Route):
596
591
  return Response().error("参数错误").__dict__
597
592
 
598
593
  # 如果是支持统一 webhook 模式的平台,且启用了统一 webhook 模式,确保有 webhook_uuid
599
- platform_type = new_config.get("type", "")
600
- if platform_type in WEBHOOK_SUPPORTED_PLATFORMS:
601
- if new_config.get("unified_webhook_mode", False):
602
- # 如果没有 webhook_uuid 或为空,自动生成
603
- if not new_config.get("webhook_uuid"):
604
- new_config["webhook_uuid"] = uuid.uuid4().hex
594
+ ensure_platform_webhook_config(new_config)
605
595
 
606
596
  for i, platform in enumerate(self.config["platform"]):
607
597
  if platform["id"] == platform_id:
@@ -777,7 +767,7 @@ class ConfigRoute(Route):
777
767
  return {"metadata": CONFIG_METADATA_2, "config": config}
778
768
 
779
769
  async def _get_plugin_config(self, plugin_name: str):
780
- ret = {"metadata": None, "config": None}
770
+ ret: dict = {"metadata": None, "config": None}
781
771
 
782
772
  for plugin_md in star_registry:
783
773
  if plugin_md.name == plugin_name:
@@ -1,7 +1,9 @@
1
1
  import json
2
2
  import traceback
3
+ from datetime import datetime
4
+ from io import BytesIO
3
5
 
4
- from quart import request
6
+ from quart import request, send_file
5
7
 
6
8
  from astrbot.core import logger
7
9
  from astrbot.core.core_lifecycle import AstrBotCoreLifecycle
@@ -30,6 +32,7 @@ class ConversationRoute(Route):
30
32
  "POST",
31
33
  self.update_history,
32
34
  ),
35
+ "/conversation/export": ("POST", self.export_conversations),
33
36
  }
34
37
  self.db_helper = db_helper
35
38
  self.conv_mgr = core_lifecycle.conversation_manager
@@ -283,3 +286,90 @@ class ConversationRoute(Route):
283
286
  except Exception as e:
284
287
  logger.error(f"更新对话历史失败: {e!s}\n{traceback.format_exc()}")
285
288
  return Response().error(f"更新对话历史失败: {e!s}").__dict__
289
+
290
+ async def export_conversations(self):
291
+ """批量导出对话为 JSONL 格式"""
292
+ try:
293
+ data = await request.get_json()
294
+ conversations_to_export = data.get("conversations", [])
295
+
296
+ if not conversations_to_export:
297
+ return Response().error("导出列表不能为空").__dict__
298
+
299
+ # 收集所有对话的内容
300
+ jsonl_lines = []
301
+ exported_count = 0
302
+ failed_items = []
303
+
304
+ for conv_info in conversations_to_export:
305
+ user_id = conv_info.get("user_id")
306
+ cid = conv_info.get("cid")
307
+
308
+ if not user_id or not cid:
309
+ failed_items.append(
310
+ f"user_id:{user_id}, cid:{cid} - 缺少必要参数",
311
+ )
312
+ continue
313
+
314
+ try:
315
+ conversation = await self.conv_mgr.get_conversation(
316
+ unified_msg_origin=user_id,
317
+ conversation_id=cid,
318
+ )
319
+
320
+ if not conversation:
321
+ failed_items.append(
322
+ f"user_id:{user_id}, cid:{cid} - 对话不存在"
323
+ )
324
+ continue
325
+
326
+ # 解析对话内容 (history is always a JSON string from _convert_conv_from_v2_to_v1)
327
+ content = json.loads(conversation.history)
328
+
329
+ # 创建导出记录
330
+ export_record = {
331
+ "cid": cid,
332
+ "user_id": user_id,
333
+ "platform_id": conversation.platform_id,
334
+ "title": conversation.title,
335
+ "persona_id": conversation.persona_id,
336
+ "created_at": conversation.created_at,
337
+ "updated_at": conversation.updated_at,
338
+ "content": content,
339
+ }
340
+
341
+ # 将记录转换为 JSON 字符串并添加到 JSONL
342
+ jsonl_lines.append(json.dumps(export_record, ensure_ascii=False))
343
+ exported_count += 1
344
+
345
+ except Exception as e:
346
+ failed_items.append(f"user_id:{user_id}, cid:{cid} - {e!s}")
347
+ logger.error(
348
+ f"导出对话失败: user_id={user_id}, cid={cid}, error={e!s}"
349
+ )
350
+
351
+ if exported_count == 0:
352
+ return Response().error("没有成功导出任何对话").__dict__
353
+
354
+ # 创建 JSONL 内容
355
+ jsonl_content = "\n".join(jsonl_lines)
356
+
357
+ # 创建一个内存文件对象
358
+ file_obj = BytesIO(jsonl_content.encode("utf-8"))
359
+ file_obj.seek(0)
360
+
361
+ # 生成文件名
362
+ timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
363
+ filename = f"astrbot_conversations_export_{timestamp}.jsonl"
364
+
365
+ # 返回文件流
366
+ return await send_file(
367
+ file_obj,
368
+ mimetype="application/jsonl",
369
+ as_attachment=True,
370
+ attachment_filename=filename,
371
+ )
372
+
373
+ except Exception as e:
374
+ logger.error(f"批量导出对话失败: {e!s}\n{traceback.format_exc()}")
375
+ return Response().error(f"批量导出对话失败: {e!s}").__dict__