AstrBot 4.10.2__py3-none-any.whl → 4.10.4__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 (84) hide show
  1. astrbot/builtin_stars/astrbot/long_term_memory.py +186 -0
  2. astrbot/builtin_stars/astrbot/main.py +120 -0
  3. astrbot/builtin_stars/astrbot/metadata.yaml +4 -0
  4. astrbot/builtin_stars/astrbot/process_llm_request.py +245 -0
  5. astrbot/builtin_stars/builtin_commands/commands/__init__.py +31 -0
  6. astrbot/builtin_stars/builtin_commands/commands/admin.py +77 -0
  7. astrbot/builtin_stars/builtin_commands/commands/alter_cmd.py +173 -0
  8. astrbot/builtin_stars/builtin_commands/commands/conversation.py +366 -0
  9. astrbot/builtin_stars/builtin_commands/commands/help.py +88 -0
  10. astrbot/builtin_stars/builtin_commands/commands/llm.py +20 -0
  11. astrbot/builtin_stars/builtin_commands/commands/persona.py +142 -0
  12. astrbot/builtin_stars/builtin_commands/commands/plugin.py +120 -0
  13. astrbot/builtin_stars/builtin_commands/commands/provider.py +329 -0
  14. astrbot/builtin_stars/builtin_commands/commands/setunset.py +36 -0
  15. astrbot/builtin_stars/builtin_commands/commands/sid.py +36 -0
  16. astrbot/builtin_stars/builtin_commands/commands/t2i.py +23 -0
  17. astrbot/builtin_stars/builtin_commands/commands/tool.py +31 -0
  18. astrbot/builtin_stars/builtin_commands/commands/tts.py +36 -0
  19. astrbot/builtin_stars/builtin_commands/commands/utils/rst_scene.py +26 -0
  20. astrbot/builtin_stars/builtin_commands/main.py +237 -0
  21. astrbot/builtin_stars/builtin_commands/metadata.yaml +4 -0
  22. astrbot/builtin_stars/python_interpreter/main.py +536 -0
  23. astrbot/builtin_stars/python_interpreter/metadata.yaml +4 -0
  24. astrbot/builtin_stars/python_interpreter/requirements.txt +1 -0
  25. astrbot/builtin_stars/python_interpreter/shared/api.py +22 -0
  26. astrbot/builtin_stars/reminder/main.py +266 -0
  27. astrbot/builtin_stars/reminder/metadata.yaml +4 -0
  28. astrbot/builtin_stars/session_controller/main.py +114 -0
  29. astrbot/builtin_stars/session_controller/metadata.yaml +5 -0
  30. astrbot/builtin_stars/web_searcher/engines/__init__.py +111 -0
  31. astrbot/builtin_stars/web_searcher/engines/bing.py +30 -0
  32. astrbot/builtin_stars/web_searcher/engines/sogo.py +52 -0
  33. astrbot/builtin_stars/web_searcher/main.py +436 -0
  34. astrbot/builtin_stars/web_searcher/metadata.yaml +4 -0
  35. astrbot/cli/__init__.py +1 -1
  36. astrbot/core/agent/message.py +32 -1
  37. astrbot/core/agent/runners/tool_loop_agent_runner.py +26 -8
  38. astrbot/core/astr_agent_hooks.py +6 -0
  39. astrbot/core/backup/__init__.py +26 -0
  40. astrbot/core/backup/constants.py +77 -0
  41. astrbot/core/backup/exporter.py +477 -0
  42. astrbot/core/backup/importer.py +761 -0
  43. astrbot/core/config/astrbot_config.py +2 -0
  44. astrbot/core/config/default.py +47 -6
  45. astrbot/core/knowledge_base/chunking/recursive.py +10 -2
  46. astrbot/core/log.py +1 -1
  47. astrbot/core/pipeline/process_stage/method/agent_sub_stages/internal.py +184 -174
  48. astrbot/core/pipeline/result_decorate/stage.py +65 -57
  49. astrbot/core/pipeline/waking_check/stage.py +31 -3
  50. astrbot/core/platform/sources/aiocqhttp/aiocqhttp_platform_adapter.py +15 -29
  51. astrbot/core/platform/sources/dingtalk/dingtalk_adapter.py +1 -6
  52. astrbot/core/platform/sources/dingtalk/dingtalk_event.py +15 -1
  53. astrbot/core/platform/sources/lark/lark_adapter.py +2 -10
  54. astrbot/core/platform/sources/misskey/misskey_adapter.py +0 -5
  55. astrbot/core/platform/sources/misskey/misskey_utils.py +0 -3
  56. astrbot/core/platform/sources/qqofficial/qqofficial_platform_adapter.py +4 -9
  57. astrbot/core/platform/sources/qqofficial_webhook/qo_webhook_adapter.py +4 -9
  58. astrbot/core/platform/sources/satori/satori_adapter.py +6 -1
  59. astrbot/core/platform/sources/slack/slack_adapter.py +3 -6
  60. astrbot/core/platform/sources/webchat/webchat_adapter.py +0 -1
  61. astrbot/core/platform/sources/wechatpadpro/wechatpadpro_adapter.py +3 -5
  62. astrbot/core/provider/entities.py +41 -10
  63. astrbot/core/provider/provider.py +3 -1
  64. astrbot/core/provider/sources/anthropic_source.py +140 -30
  65. astrbot/core/provider/sources/fishaudio_tts_api_source.py +14 -6
  66. astrbot/core/provider/sources/gemini_source.py +112 -29
  67. astrbot/core/provider/sources/minimax_tts_api_source.py +4 -1
  68. astrbot/core/provider/sources/openai_source.py +93 -56
  69. astrbot/core/provider/sources/xai_source.py +29 -0
  70. astrbot/core/provider/sources/xinference_stt_provider.py +24 -12
  71. astrbot/core/star/context.py +1 -1
  72. astrbot/core/star/star_manager.py +52 -13
  73. astrbot/core/utils/astrbot_path.py +34 -0
  74. astrbot/core/utils/pip_installer.py +20 -1
  75. astrbot/dashboard/routes/__init__.py +2 -0
  76. astrbot/dashboard/routes/backup.py +1093 -0
  77. astrbot/dashboard/routes/config.py +45 -0
  78. astrbot/dashboard/routes/log.py +44 -10
  79. astrbot/dashboard/server.py +9 -1
  80. {astrbot-4.10.2.dist-info → astrbot-4.10.4.dist-info}/METADATA +1 -1
  81. {astrbot-4.10.2.dist-info → astrbot-4.10.4.dist-info}/RECORD +84 -44
  82. {astrbot-4.10.2.dist-info → astrbot-4.10.4.dist-info}/WHEEL +0 -0
  83. {astrbot-4.10.2.dist-info → astrbot-4.10.4.dist-info}/entry_points.txt +0 -0
  84. {astrbot-4.10.2.dist-info → astrbot-4.10.4.dist-info}/licenses/LICENSE +0 -0
@@ -13,6 +13,7 @@ from google.genai.errors import APIError
13
13
  import astrbot.core.message.components as Comp
14
14
  from astrbot import logger
15
15
  from astrbot.api.provider import Provider
16
+ from astrbot.core.agent.message import ContentPart, ImageURLPart, TextPart
16
17
  from astrbot.core.message.message_event_result import MessageChain
17
18
  from astrbot.core.provider.entities import LLMResponse, TokenUsage
18
19
  from astrbot.core.provider.func_tool_manager import ToolSet
@@ -320,9 +321,37 @@ class ProviderGoogleGenAI(Provider):
320
321
  append_or_extend(gemini_contents, parts, types.UserContent)
321
322
 
322
323
  elif role == "assistant":
323
- if content:
324
+ if isinstance(content, str):
324
325
  parts = [types.Part.from_text(text=content)]
325
326
  append_or_extend(gemini_contents, parts, types.ModelContent)
327
+ elif isinstance(content, list):
328
+ parts = []
329
+ thinking_signature = None
330
+ text = ""
331
+ for part in content:
332
+ # for most cases, assistant content only contains two parts: think and text
333
+ if part.get("type") == "think":
334
+ thinking_signature = part.get("encrypted") or None
335
+ else:
336
+ text += str(part.get("text"))
337
+
338
+ if thinking_signature and isinstance(thinking_signature, str):
339
+ try:
340
+ thinking_signature = base64.b64decode(thinking_signature)
341
+ except Exception as e:
342
+ logger.warning(
343
+ f"Failed to decode google gemini thinking signature: {e}",
344
+ exc_info=True,
345
+ )
346
+ thinking_signature = None
347
+ parts.append(
348
+ types.Part(
349
+ text=text,
350
+ thought_signature=thinking_signature,
351
+ )
352
+ )
353
+ append_or_extend(gemini_contents, parts, types.ModelContent)
354
+
326
355
  elif not native_tool_enabled and "tool_calls" in message:
327
356
  parts = []
328
357
  for tool in message["tool_calls"]:
@@ -440,7 +469,8 @@ class ProviderGoogleGenAI(Provider):
440
469
  for part in result_parts:
441
470
  if part.text:
442
471
  chain.append(Comp.Plain(part.text))
443
- elif (
472
+
473
+ if (
444
474
  part.function_call
445
475
  and part.function_call.name is not None
446
476
  and part.function_call.args is not None
@@ -457,13 +487,18 @@ class ProviderGoogleGenAI(Provider):
457
487
  llm_response.tools_call_extra_content[tool_call_id] = {
458
488
  "google": {"thought_signature": ts_bs64}
459
489
  }
460
- elif (
490
+
491
+ if (
461
492
  part.inline_data
462
493
  and part.inline_data.mime_type
463
494
  and part.inline_data.mime_type.startswith("image/")
464
495
  and part.inline_data.data
465
496
  ):
466
497
  chain.append(Comp.Image.fromBytes(part.inline_data.data))
498
+
499
+ if ts := part.thought_signature:
500
+ # only keep the last thinking signature
501
+ llm_response.reasoning_signature = base64.b64encode(ts).decode("utf-8")
467
502
  return MessageChain(chain=chain)
468
503
 
469
504
  async def _query(self, payloads: dict, tools: ToolSet | None) -> LLMResponse:
@@ -680,13 +715,16 @@ class ProviderGoogleGenAI(Provider):
680
715
  system_prompt=None,
681
716
  tool_calls_result=None,
682
717
  model=None,
718
+ extra_user_content_parts=None,
683
719
  **kwargs,
684
720
  ) -> LLMResponse:
685
721
  if contexts is None:
686
722
  contexts = []
687
723
  new_record = None
688
724
  if prompt is not None:
689
- new_record = await self.assemble_context(prompt, image_urls)
725
+ new_record = await self.assemble_context(
726
+ prompt, image_urls, extra_user_content_parts
727
+ )
690
728
  context_query = self._ensure_message_to_dicts(contexts)
691
729
  if new_record:
692
730
  context_query.append(new_record)
@@ -732,13 +770,16 @@ class ProviderGoogleGenAI(Provider):
732
770
  system_prompt=None,
733
771
  tool_calls_result=None,
734
772
  model=None,
773
+ extra_user_content_parts=None,
735
774
  **kwargs,
736
775
  ) -> AsyncGenerator[LLMResponse, None]:
737
776
  if contexts is None:
738
777
  contexts = []
739
778
  new_record = None
740
779
  if prompt is not None:
741
- new_record = await self.assemble_context(prompt, image_urls)
780
+ new_record = await self.assemble_context(
781
+ prompt, image_urls, extra_user_content_parts
782
+ )
742
783
  context_query = self._ensure_message_to_dicts(contexts)
743
784
  if new_record:
744
785
  context_query.append(new_record)
@@ -797,33 +838,75 @@ class ProviderGoogleGenAI(Provider):
797
838
  self.chosen_api_key = key
798
839
  self._init_client()
799
840
 
800
- async def assemble_context(self, text: str, image_urls: list[str] | None = None):
841
+ async def assemble_context(
842
+ self,
843
+ text: str,
844
+ image_urls: list[str] | None = None,
845
+ extra_user_content_parts: list[ContentPart] | None = None,
846
+ ):
801
847
  """组装上下文。"""
802
- if image_urls:
803
- user_content = {
804
- "role": "user",
805
- "content": [{"type": "text", "text": text if text else "[图片]"}],
848
+
849
+ async def resolve_image_part(image_url: str) -> dict | None:
850
+ if image_url.startswith("http"):
851
+ image_path = await download_image_by_url(image_url)
852
+ image_data = await self.encode_image_bs64(image_path)
853
+ elif image_url.startswith("file:///"):
854
+ image_path = image_url.replace("file:///", "")
855
+ image_data = await self.encode_image_bs64(image_path)
856
+ else:
857
+ image_data = await self.encode_image_bs64(image_url)
858
+ if not image_data:
859
+ logger.warning(f"图片 {image_url} 得到的结果为空,将忽略。")
860
+ return None
861
+ return {
862
+ "type": "image_url",
863
+ "image_url": {"url": image_data},
806
864
  }
807
- for image_url in image_urls:
808
- if image_url.startswith("http"):
809
- image_path = await download_image_by_url(image_url)
810
- image_data = await self.encode_image_bs64(image_path)
811
- elif image_url.startswith("file:///"):
812
- image_path = image_url.replace("file:///", "")
813
- image_data = await self.encode_image_bs64(image_path)
865
+
866
+ # 构建内容块列表
867
+ content_blocks = []
868
+
869
+ # 1. 用户原始发言(OpenAI 建议:用户发言在前)
870
+ if text:
871
+ content_blocks.append({"type": "text", "text": text})
872
+ elif image_urls:
873
+ # 如果没有文本但有图片,添加占位文本
874
+ content_blocks.append({"type": "text", "text": "[图片]"})
875
+ elif extra_user_content_parts:
876
+ # 如果只有额外内容块,也需要添加占位文本
877
+ content_blocks.append({"type": "text", "text": " "})
878
+
879
+ # 2. 额外的内容块(系统提醒、指令等)
880
+ if extra_user_content_parts:
881
+ for part in extra_user_content_parts:
882
+ if isinstance(part, TextPart):
883
+ content_blocks.append({"type": "text", "text": part.text})
884
+ elif isinstance(part, ImageURLPart):
885
+ image_part = await resolve_image_part(part.image_url.url)
886
+ if image_part:
887
+ content_blocks.append(image_part)
814
888
  else:
815
- image_data = await self.encode_image_bs64(image_url)
816
- if not image_data:
817
- logger.warning(f"图片 {image_url} 得到的结果为空,将忽略。")
818
- continue
819
- user_content["content"].append(
820
- {
821
- "type": "image_url",
822
- "image_url": {"url": image_data},
823
- },
824
- )
825
- return user_content
826
- return {"role": "user", "content": text}
889
+ raise ValueError(f"不支持的额外内容块类型: {type(part)}")
890
+
891
+ # 3. 图片内容
892
+ if image_urls:
893
+ for image_url in image_urls:
894
+ image_part = await resolve_image_part(image_url)
895
+ if image_part:
896
+ content_blocks.append(image_part)
897
+
898
+ # 如果只有主文本且没有额外内容块和图片,返回简单格式以保持向后兼容
899
+ if (
900
+ text
901
+ and not extra_user_content_parts
902
+ and not image_urls
903
+ and len(content_blocks) == 1
904
+ and content_blocks[0]["type"] == "text"
905
+ ):
906
+ return {"role": "user", "content": content_blocks[0]["text"]}
907
+
908
+ # 否则返回多模态格式
909
+ return {"role": "user", "content": content_blocks}
827
910
 
828
911
  async def encode_image_bs64(self, image_url: str) -> str:
829
912
  """将图片转换为 base64"""
@@ -51,7 +51,7 @@ class ProviderMiniMaxTTSAPI(TTSProvider):
51
51
  "voice_id": ""
52
52
  if self.is_timber_weight
53
53
  else provider_config.get("minimax-voice-id", ""),
54
- "emotion": provider_config.get("minimax-voice-emotion", "neutral"),
54
+ "emotion": provider_config.get("minimax-voice-emotion", "auto"),
55
55
  "latex_read": provider_config.get("minimax-voice-latex", False),
56
56
  "english_normalization": provider_config.get(
57
57
  "minimax-voice-english-normalization",
@@ -59,6 +59,9 @@ class ProviderMiniMaxTTSAPI(TTSProvider):
59
59
  ),
60
60
  }
61
61
 
62
+ if self.voice_setting["emotion"] == "auto":
63
+ self.voice_setting.pop("emotion", None)
64
+
62
65
  self.audio_setting: dict = {
63
66
  "sample_rate": 32000,
64
67
  "bitrate": 128000,
@@ -17,7 +17,7 @@ from openai.types.completion_usage import CompletionUsage
17
17
  import astrbot.core.message.components as Comp
18
18
  from astrbot import logger
19
19
  from astrbot.api.provider import Provider
20
- from astrbot.core.agent.message import Message
20
+ from astrbot.core.agent.message import ContentPart, ImageURLPart, Message, TextPart
21
21
  from astrbot.core.agent.tool import ToolSet
22
22
  from astrbot.core.message.message_event_result import MessageChain
23
23
  from astrbot.core.provider.entities import LLMResponse, TokenUsage, ToolCallsResult
@@ -74,28 +74,6 @@ class ProviderOpenAIOfficial(Provider):
74
74
 
75
75
  self.reasoning_key = "reasoning_content"
76
76
 
77
- def _maybe_inject_xai_search(self, payloads: dict, **kwargs):
78
- """当开启 xAI 原生搜索时,向请求体注入 Live Search 参数。
79
-
80
- - 仅在 provider_config.xai_native_search 为 True 时生效
81
- - 默认注入 {"mode": "auto"}
82
- - 允许通过 kwargs 使用 xai_search_mode 覆盖(on/auto/off)
83
- """
84
- if not bool(self.provider_config.get("xai_native_search", False)):
85
- return
86
-
87
- mode = kwargs.get("xai_search_mode", "auto")
88
- mode = str(mode).lower()
89
- if mode not in ("auto", "on", "off"):
90
- mode = "auto"
91
-
92
- # off 时不注入,保持与未开启一致
93
- if mode == "off":
94
- return
95
-
96
- # OpenAI SDK 不识别的字段会在 _query/_query_stream 中放入 extra_body
97
- payloads["search_parameters"] = {"mode": mode}
98
-
99
77
  async def get_models(self):
100
78
  try:
101
79
  models_str = []
@@ -134,10 +112,6 @@ class ProviderOpenAIOfficial(Provider):
134
112
 
135
113
  model = payloads.get("model", "").lower()
136
114
 
137
- # 针对 deepseek 模型的特殊处理:deepseek-reasoner调用必须移除 tools ,否则将被切换至 deepseek-chat
138
- if model == "deepseek-reasoner" and "tools" in payloads:
139
- del payloads["tools"]
140
-
141
115
  completion = await self.client.chat.completions.create(
142
116
  **payloads,
143
117
  stream=False,
@@ -251,10 +225,14 @@ class ProviderOpenAIOfficial(Provider):
251
225
  def _extract_usage(self, usage: CompletionUsage) -> TokenUsage:
252
226
  ptd = usage.prompt_tokens_details
253
227
  cached = ptd.cached_tokens if ptd and ptd.cached_tokens else 0
228
+ prompt_tokens = 0 if usage.prompt_tokens is None else usage.prompt_tokens
229
+ completion_tokens = (
230
+ 0 if usage.completion_tokens is None else usage.completion_tokens
231
+ )
254
232
  return TokenUsage(
255
- input_other=usage.prompt_tokens - cached,
256
- input_cached=ptd.cached_tokens if ptd and ptd.cached_tokens else 0,
257
- output=usage.completion_tokens,
233
+ input_other=prompt_tokens - cached,
234
+ input_cached=cached,
235
+ output=completion_tokens,
258
236
  )
259
237
 
260
238
  async def _parse_openai_completion(
@@ -348,6 +326,7 @@ class ProviderOpenAIOfficial(Provider):
348
326
  system_prompt: str | None = None,
349
327
  tool_calls_result: ToolCallsResult | list[ToolCallsResult] | None = None,
350
328
  model: str | None = None,
329
+ extra_user_content_parts: list[ContentPart] | None = None,
351
330
  **kwargs,
352
331
  ) -> tuple:
353
332
  """准备聊天所需的有效载荷和上下文"""
@@ -355,7 +334,9 @@ class ProviderOpenAIOfficial(Provider):
355
334
  contexts = []
356
335
  new_record = None
357
336
  if prompt is not None:
358
- new_record = await self.assemble_context(prompt, image_urls)
337
+ new_record = await self.assemble_context(
338
+ prompt, image_urls, extra_user_content_parts
339
+ )
359
340
  context_query = self._ensure_message_to_dicts(contexts)
360
341
  if new_record:
361
342
  context_query.append(new_record)
@@ -378,11 +359,27 @@ class ProviderOpenAIOfficial(Provider):
378
359
 
379
360
  payloads = {"messages": context_query, "model": model}
380
361
 
381
- # xAI origin search tool inject
382
- self._maybe_inject_xai_search(payloads, **kwargs)
362
+ self._finally_convert_payload(payloads)
383
363
 
384
364
  return payloads, context_query
385
365
 
366
+ def _finally_convert_payload(self, payloads: dict):
367
+ """Finally convert the payload. Such as think part conversion, tool inject."""
368
+ for message in payloads.get("messages", []):
369
+ if message.get("role") == "assistant" and isinstance(
370
+ message.get("content"), list
371
+ ):
372
+ reasoning_content = ""
373
+ new_content = [] # not including think part
374
+ for part in message["content"]:
375
+ if part.get("type") == "think":
376
+ reasoning_content += str(part.get("think"))
377
+ else:
378
+ new_content.append(part)
379
+ message["content"] = new_content
380
+ # reasoning key is "reasoning_content"
381
+ message["reasoning_content"] = reasoning_content
382
+
386
383
  async def _handle_api_error(
387
384
  self,
388
385
  e: Exception,
@@ -476,6 +473,7 @@ class ProviderOpenAIOfficial(Provider):
476
473
  system_prompt=None,
477
474
  tool_calls_result=None,
478
475
  model=None,
476
+ extra_user_content_parts=None,
479
477
  **kwargs,
480
478
  ) -> LLMResponse:
481
479
  payloads, context_query = await self._prepare_chat_payload(
@@ -485,6 +483,7 @@ class ProviderOpenAIOfficial(Provider):
485
483
  system_prompt,
486
484
  tool_calls_result,
487
485
  model=model,
486
+ extra_user_content_parts=extra_user_content_parts,
488
487
  **kwargs,
489
488
  )
490
489
 
@@ -624,33 +623,71 @@ class ProviderOpenAIOfficial(Provider):
624
623
  self,
625
624
  text: str,
626
625
  image_urls: list[str] | None = None,
626
+ extra_user_content_parts: list[ContentPart] | None = None,
627
627
  ) -> dict:
628
628
  """组装成符合 OpenAI 格式的 role 为 user 的消息段"""
629
- if image_urls:
630
- user_content = {
631
- "role": "user",
632
- "content": [{"type": "text", "text": text if text else "[图片]"}],
629
+
630
+ async def resolve_image_part(image_url: str) -> dict | None:
631
+ if image_url.startswith("http"):
632
+ image_path = await download_image_by_url(image_url)
633
+ image_data = await self.encode_image_bs64(image_path)
634
+ elif image_url.startswith("file:///"):
635
+ image_path = image_url.replace("file:///", "")
636
+ image_data = await self.encode_image_bs64(image_path)
637
+ else:
638
+ image_data = await self.encode_image_bs64(image_url)
639
+ if not image_data:
640
+ logger.warning(f"图片 {image_url} 得到的结果为空,将忽略。")
641
+ return None
642
+ return {
643
+ "type": "image_url",
644
+ "image_url": {"url": image_data},
633
645
  }
634
- for image_url in image_urls:
635
- if image_url.startswith("http"):
636
- image_path = await download_image_by_url(image_url)
637
- image_data = await self.encode_image_bs64(image_path)
638
- elif image_url.startswith("file:///"):
639
- image_path = image_url.replace("file:///", "")
640
- image_data = await self.encode_image_bs64(image_path)
646
+
647
+ # 构建内容块列表
648
+ content_blocks = []
649
+
650
+ # 1. 用户原始发言(OpenAI 建议:用户发言在前)
651
+ if text:
652
+ content_blocks.append({"type": "text", "text": text})
653
+ elif image_urls:
654
+ # 如果没有文本但有图片,添加占位文本
655
+ content_blocks.append({"type": "text", "text": "[图片]"})
656
+ elif extra_user_content_parts:
657
+ # 如果只有额外内容块,也需要添加占位文本
658
+ content_blocks.append({"type": "text", "text": " "})
659
+
660
+ # 2. 额外的内容块(系统提醒、指令等)
661
+ if extra_user_content_parts:
662
+ for part in extra_user_content_parts:
663
+ if isinstance(part, TextPart):
664
+ content_blocks.append({"type": "text", "text": part.text})
665
+ elif isinstance(part, ImageURLPart):
666
+ image_part = await resolve_image_part(part.image_url.url)
667
+ if image_part:
668
+ content_blocks.append(image_part)
641
669
  else:
642
- image_data = await self.encode_image_bs64(image_url)
643
- if not image_data:
644
- logger.warning(f"图片 {image_url} 得到的结果为空,将忽略。")
645
- continue
646
- user_content["content"].append(
647
- {
648
- "type": "image_url",
649
- "image_url": {"url": image_data},
650
- },
651
- )
652
- return user_content
653
- return {"role": "user", "content": text}
670
+ raise ValueError(f"不支持的额外内容块类型: {type(part)}")
671
+
672
+ # 3. 图片内容
673
+ if image_urls:
674
+ for image_url in image_urls:
675
+ image_part = await resolve_image_part(image_url)
676
+ if image_part:
677
+ content_blocks.append(image_part)
678
+
679
+ # 如果只有主文本且没有额外内容块和图片,返回简单格式以保持向后兼容
680
+ if (
681
+ text
682
+ and not extra_user_content_parts
683
+ and not image_urls
684
+ and len(content_blocks) == 1
685
+ and content_blocks[0]["type"] == "text"
686
+ ):
687
+ return {"role": "user", "content": content_blocks[0]["text"]}
688
+
689
+ # 否则返回多模态格式
690
+ return {"role": "user", "content": content_blocks}
654
691
 
655
692
  async def encode_image_bs64(self, image_url: str) -> str:
656
693
  """将图片转换为 base64"""
@@ -0,0 +1,29 @@
1
+ from ..register import register_provider_adapter
2
+ from .openai_source import ProviderOpenAIOfficial
3
+
4
+
5
+ @register_provider_adapter(
6
+ "xai_chat_completion", "xAI Chat Completion Provider Adapter"
7
+ )
8
+ class ProviderXAI(ProviderOpenAIOfficial):
9
+ def __init__(
10
+ self,
11
+ provider_config: dict,
12
+ provider_settings: dict,
13
+ ) -> None:
14
+ super().__init__(provider_config, provider_settings)
15
+
16
+ def _maybe_inject_xai_search(self, payloads: dict):
17
+ """当开启 xAI 原生搜索时,向请求体注入 Live Search 参数。
18
+
19
+ - 仅在 provider_config.xai_native_search 为 True 时生效
20
+ - 默认注入 {"mode": "auto"}
21
+ """
22
+ if not bool(self.provider_config.get("xai_native_search", False)):
23
+ return
24
+ # OpenAI SDK 不识别的字段会在 _query/_query_stream 中放入 extra_body
25
+ payloads["search_parameters"] = {"mode": "auto"}
26
+
27
+ def _finally_convert_payload(self, payloads: dict):
28
+ self._maybe_inject_xai_search(payloads)
29
+ super()._finally_convert_payload(payloads)
@@ -8,7 +8,10 @@ from xinference_client.client.restful.async_restful_client import (
8
8
 
9
9
  from astrbot.core import logger
10
10
  from astrbot.core.utils.astrbot_path import get_astrbot_data_path
11
- from astrbot.core.utils.tencent_record_helper import tencent_silk_to_wav
11
+ from astrbot.core.utils.tencent_record_helper import (
12
+ convert_to_pcm_wav,
13
+ tencent_silk_to_wav,
14
+ )
12
15
 
13
16
  from ..entities import ProviderType
14
17
  from ..provider import STTProvider
@@ -111,17 +114,22 @@ class ProviderXinferenceSTT(STTProvider):
111
114
  return ""
112
115
 
113
116
  # 2. Check for conversion
114
- needs_conversion = False
115
- if (
116
- audio_url.endswith((".amr", ".silk"))
117
- or is_tencent
118
- or b"SILK" in audio_bytes[:8]
119
- ):
120
- needs_conversion = True
117
+ conversion_type = None
118
+
119
+ if b"SILK" in audio_bytes[:8]:
120
+ conversion_type = "silk"
121
+ elif b"#!AMR" in audio_bytes[:6]:
122
+ conversion_type = "amr"
123
+ elif audio_url.endswith(".silk") or is_tencent:
124
+ conversion_type = "silk"
125
+ elif audio_url.endswith(".amr"):
126
+ conversion_type = "amr"
121
127
 
122
128
  # 3. Perform conversion if needed
123
- if needs_conversion:
124
- logger.info("Audio requires conversion, using temporary files...")
129
+ if conversion_type:
130
+ logger.info(
131
+ f"Audio requires conversion ({conversion_type}), using temporary files..."
132
+ )
125
133
  temp_dir = os.path.join(get_astrbot_data_path(), "temp")
126
134
  os.makedirs(temp_dir, exist_ok=True)
127
135
 
@@ -132,8 +140,12 @@ class ProviderXinferenceSTT(STTProvider):
132
140
  with open(input_path, "wb") as f:
133
141
  f.write(audio_bytes)
134
142
 
135
- logger.info("Converting silk/amr file to wav ...")
136
- await tencent_silk_to_wav(input_path, output_path)
143
+ if conversion_type == "silk":
144
+ logger.info("Converting silk to wav ...")
145
+ await tencent_silk_to_wav(input_path, output_path)
146
+ elif conversion_type == "amr":
147
+ logger.info("Converting amr to wav ...")
148
+ await convert_to_pcm_wav(input_path, output_path)
137
149
 
138
150
  with open(output_path, "rb") as f:
139
151
  audio_bytes = f.read()
@@ -377,7 +377,7 @@ class Context:
377
377
  if not module_path:
378
378
  _parts = []
379
379
  module_part = tool.__module__.split(".")
380
- flags = ["packages", "plugins"]
380
+ flags = ["builtin_stars", "plugins"]
381
381
  for i, part in enumerate(module_part):
382
382
  _parts.append(part)
383
383
  if part in flags and i + 1 < len(module_part):