AstrBot 4.10.1__py3-none-any.whl → 4.10.3__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 (65) hide show
  1. astrbot/builtin_stars/astrbot/long_term_memory.py +186 -0
  2. astrbot/builtin_stars/astrbot/main.py +128 -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 +537 -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 +9 -0
  37. astrbot/core/agent/runners/tool_loop_agent_runner.py +2 -1
  38. astrbot/core/backup/__init__.py +26 -0
  39. astrbot/core/backup/constants.py +77 -0
  40. astrbot/core/backup/exporter.py +476 -0
  41. astrbot/core/backup/importer.py +761 -0
  42. astrbot/core/config/default.py +1 -1
  43. astrbot/core/log.py +1 -1
  44. astrbot/core/pipeline/process_stage/method/agent_sub_stages/internal.py +1 -1
  45. astrbot/core/pipeline/waking_check/stage.py +2 -1
  46. astrbot/core/provider/entities.py +32 -9
  47. astrbot/core/provider/provider.py +3 -1
  48. astrbot/core/provider/sources/anthropic_source.py +80 -27
  49. astrbot/core/provider/sources/fishaudio_tts_api_source.py +14 -6
  50. astrbot/core/provider/sources/gemini_source.py +75 -26
  51. astrbot/core/provider/sources/openai_source.py +68 -25
  52. astrbot/core/star/command_management.py +45 -4
  53. astrbot/core/star/context.py +1 -1
  54. astrbot/core/star/star_manager.py +11 -13
  55. astrbot/core/utils/astrbot_path.py +34 -0
  56. astrbot/dashboard/routes/__init__.py +2 -0
  57. astrbot/dashboard/routes/backup.py +589 -0
  58. astrbot/dashboard/routes/command.py +2 -1
  59. astrbot/dashboard/routes/log.py +44 -10
  60. astrbot/dashboard/server.py +8 -1
  61. {astrbot-4.10.1.dist-info → astrbot-4.10.3.dist-info}/METADATA +2 -2
  62. {astrbot-4.10.1.dist-info → astrbot-4.10.3.dist-info}/RECORD +65 -26
  63. {astrbot-4.10.1.dist-info → astrbot-4.10.3.dist-info}/WHEEL +0 -0
  64. {astrbot-4.10.1.dist-info → astrbot-4.10.3.dist-info}/entry_points.txt +0 -0
  65. {astrbot-4.10.1.dist-info → astrbot-4.10.3.dist-info}/licenses/LICENSE +0 -0
@@ -5,7 +5,7 @@ from typing import Any, TypedDict
5
5
 
6
6
  from astrbot.core.utils.astrbot_path import get_astrbot_data_path
7
7
 
8
- VERSION = "4.10.1"
8
+ VERSION = "4.10.3"
9
9
  DB_PATH = os.path.join(get_astrbot_data_path(), "data_v4.db")
10
10
 
11
11
  WEBHOOK_SUPPORTED_PLATFORMS = [
astrbot/core/log.py CHANGED
@@ -58,7 +58,7 @@ def is_plugin_path(pathname):
58
58
  return False
59
59
 
60
60
  norm_path = os.path.normpath(pathname)
61
- return ("data/plugins" in norm_path) or ("packages/" in norm_path)
61
+ return ("data/plugins" in norm_path) or ("astrbot/builtin_stars/" in norm_path)
62
62
 
63
63
 
64
64
  def get_short_level_name(level_name):
@@ -390,7 +390,7 @@ class InternalAgentSubStage(Stage):
390
390
  return
391
391
 
392
392
  req.prompt = event.message_str[len(provider_wake_prefix) :]
393
- # func_tool selection 现在已经转移到 packages/astrbot 插件中进行选择。
393
+ # func_tool selection 现在已经转移到 astrbot/builtin_stars/astrbot 插件中进行选择。
394
394
  # req.func_tool = self.ctx.plugin_manager.context.get_llm_tool_manager()
395
395
  for comp in event.message_obj.message:
396
396
  if isinstance(comp, Image):
@@ -136,7 +136,8 @@ class WakingCheckStage(Stage):
136
136
  ):
137
137
  if (
138
138
  self.disable_builtin_commands
139
- and handler.handler_module_path == "packages.builtin_commands.main"
139
+ and handler.handler_module_path
140
+ == "astrbot.builtin_stars.builtin_commands.main"
140
141
  ):
141
142
  logger.debug("skipping builtin command")
142
143
  continue
@@ -14,6 +14,7 @@ import astrbot.core.message.components as Comp
14
14
  from astrbot import logger
15
15
  from astrbot.core.agent.message import (
16
16
  AssistantMessageSegment,
17
+ ContentPart,
17
18
  ToolCall,
18
19
  ToolCallMessageSegment,
19
20
  )
@@ -92,6 +93,8 @@ class ProviderRequest:
92
93
  """会话 ID"""
93
94
  image_urls: list[str] = field(default_factory=list)
94
95
  """图片 URL 列表"""
96
+ extra_user_content_parts: list[ContentPart] = field(default_factory=list)
97
+ """额外的用户消息内容部分列表,用于在用户消息后添加额外的内容块(如系统提醒、指令等)。支持 dict 或 ContentPart 对象"""
95
98
  func_tool: ToolSet | None = None
96
99
  """可用的函数工具"""
97
100
  contexts: list[dict] = field(default_factory=list)
@@ -166,13 +169,23 @@ class ProviderRequest:
166
169
 
167
170
  async def assemble_context(self) -> dict:
168
171
  """将请求(prompt 和 image_urls)包装成 OpenAI 的消息格式。"""
172
+ # 构建内容块列表
173
+ content_blocks = []
174
+
175
+ # 1. 用户原始发言(OpenAI 建议:用户发言在前)
176
+ if self.prompt and self.prompt.strip():
177
+ content_blocks.append({"type": "text", "text": self.prompt})
178
+ elif self.image_urls:
179
+ # 如果没有文本但有图片,添加占位文本
180
+ content_blocks.append({"type": "text", "text": "[图片]"})
181
+
182
+ # 2. 额外的内容块(系统提醒、指令等)
183
+ if self.extra_user_content_parts:
184
+ for part in self.extra_user_content_parts:
185
+ content_blocks.append(part.model_dump())
186
+
187
+ # 3. 图片内容
169
188
  if self.image_urls:
170
- user_content = {
171
- "role": "user",
172
- "content": [
173
- {"type": "text", "text": self.prompt if self.prompt else "[图片]"},
174
- ],
175
- }
176
189
  for image_url in self.image_urls:
177
190
  if image_url.startswith("http"):
178
191
  image_path = await download_image_by_url(image_url)
@@ -185,11 +198,21 @@ class ProviderRequest:
185
198
  if not image_data:
186
199
  logger.warning(f"图片 {image_url} 得到的结果为空,将忽略。")
187
200
  continue
188
- user_content["content"].append(
201
+ content_blocks.append(
189
202
  {"type": "image_url", "image_url": {"url": image_data}},
190
203
  )
191
- return user_content
192
- return {"role": "user", "content": self.prompt}
204
+
205
+ # 只有当只有一个来自 prompt 的文本块且没有额外内容块时,才降级为简单格式以保持向后兼容
206
+ if (
207
+ len(content_blocks) == 1
208
+ and content_blocks[0]["type"] == "text"
209
+ and not self.extra_user_content_parts
210
+ and not self.image_urls
211
+ ):
212
+ return {"role": "user", "content": content_blocks[0]["text"]}
213
+
214
+ # 否则返回多模态格式
215
+ return {"role": "user", "content": content_blocks}
193
216
 
194
217
  async def _encode_image_bs64(self, image_url: str) -> str:
195
218
  """将图片转换为 base64"""
@@ -4,7 +4,7 @@ import os
4
4
  from collections.abc import AsyncGenerator
5
5
  from typing import TypeAlias, Union
6
6
 
7
- from astrbot.core.agent.message import Message
7
+ from astrbot.core.agent.message import ContentPart, Message
8
8
  from astrbot.core.agent.tool import ToolSet
9
9
  from astrbot.core.provider.entities import (
10
10
  LLMResponse,
@@ -103,6 +103,7 @@ class Provider(AbstractProvider):
103
103
  system_prompt: str | None = None,
104
104
  tool_calls_result: ToolCallsResult | list[ToolCallsResult] | None = None,
105
105
  model: str | None = None,
106
+ extra_user_content_parts: list[ContentPart] | None = None,
106
107
  **kwargs,
107
108
  ) -> LLMResponse:
108
109
  """获得 LLM 的文本对话结果。会使用当前的模型进行对话。
@@ -114,6 +115,7 @@ class Provider(AbstractProvider):
114
115
  tools: tool set
115
116
  contexts: 上下文,和 prompt 二选一使用
116
117
  tool_calls_result: 回传给 LLM 的工具调用结果。参考: https://platform.openai.com/docs/guides/function-calling
118
+ extra_user_content_parts: 额外的内容块列表,用于在用户消息后添加额外的文本块(如系统提醒、指令等)
117
119
  kwargs: 其他参数
118
120
 
119
121
  Notes:
@@ -11,6 +11,7 @@ from anthropic.types.usage import Usage
11
11
 
12
12
  from astrbot import logger
13
13
  from astrbot.api.provider import Provider
14
+ from astrbot.core.agent.message import ContentPart, ImageURLPart, TextPart
14
15
  from astrbot.core.provider.entities import LLMResponse, TokenUsage
15
16
  from astrbot.core.provider.func_tool_manager import ToolSet
16
17
  from astrbot.core.utils.io import download_image_by_url
@@ -68,7 +69,7 @@ class ProviderAnthropic(Provider):
68
69
  blocks = []
69
70
  if isinstance(message["content"], str):
70
71
  blocks.append({"type": "text", "text": message["content"]})
71
- if "tool_calls" in message:
72
+ if "tool_calls" in message and isinstance(message["tool_calls"], list):
72
73
  for tool_call in message["tool_calls"]:
73
74
  blocks.append( # noqa: PERF401
74
75
  {
@@ -132,6 +133,9 @@ class ProviderAnthropic(Provider):
132
133
 
133
134
  extra_body = self.provider_config.get("custom_extra_body", {})
134
135
 
136
+ if "max_tokens" not in payloads:
137
+ payloads["max_tokens"] = 1024
138
+
135
139
  completion = await self.client.messages.create(
136
140
  **payloads, stream=False, extra_body=extra_body
137
141
  )
@@ -181,6 +185,9 @@ class ProviderAnthropic(Provider):
181
185
  usage = TokenUsage()
182
186
  extra_body = self.provider_config.get("custom_extra_body", {})
183
187
 
188
+ if "max_tokens" not in payloads:
189
+ payloads["max_tokens"] = 1024
190
+
184
191
  async with self.client.messages.stream(
185
192
  **payloads, extra_body=extra_body
186
193
  ) as stream:
@@ -296,13 +303,16 @@ class ProviderAnthropic(Provider):
296
303
  system_prompt=None,
297
304
  tool_calls_result=None,
298
305
  model=None,
306
+ extra_user_content_parts=None,
299
307
  **kwargs,
300
308
  ) -> LLMResponse:
301
309
  if contexts is None:
302
310
  contexts = []
303
311
  new_record = None
304
312
  if prompt is not None:
305
- new_record = await self.assemble_context(prompt, image_urls)
313
+ new_record = await self.assemble_context(
314
+ prompt, image_urls, extra_user_content_parts
315
+ )
306
316
  context_query = self._ensure_message_to_dicts(contexts)
307
317
  if new_record:
308
318
  context_query.append(new_record)
@@ -342,21 +352,24 @@ class ProviderAnthropic(Provider):
342
352
 
343
353
  async def text_chat_stream(
344
354
  self,
345
- prompt,
355
+ prompt=None,
346
356
  session_id=None,
347
- image_urls=...,
357
+ image_urls=None,
348
358
  func_tool=None,
349
- contexts=...,
359
+ contexts=None,
350
360
  system_prompt=None,
351
361
  tool_calls_result=None,
352
362
  model=None,
363
+ extra_user_content_parts=None,
353
364
  **kwargs,
354
365
  ):
355
366
  if contexts is None:
356
367
  contexts = []
357
368
  new_record = None
358
369
  if prompt is not None:
359
- new_record = await self.assemble_context(prompt, image_urls)
370
+ new_record = await self.assemble_context(
371
+ prompt, image_urls, extra_user_content_parts
372
+ )
360
373
  context_query = self._ensure_message_to_dicts(contexts)
361
374
  if new_record:
362
375
  context_query.append(new_record)
@@ -388,15 +401,15 @@ class ProviderAnthropic(Provider):
388
401
  async for llm_response in self._query_stream(payloads, func_tool):
389
402
  yield llm_response
390
403
 
391
- async def assemble_context(self, text: str, image_urls: list[str] | None = None):
404
+ async def assemble_context(
405
+ self,
406
+ text: str,
407
+ image_urls: list[str] | None = None,
408
+ extra_user_content_parts: list[ContentPart] | None = None,
409
+ ):
392
410
  """组装上下文,支持文本和图片"""
393
- if not image_urls:
394
- return {"role": "user", "content": text}
395
-
396
- content = []
397
- content.append({"type": "text", "text": text})
398
411
 
399
- for image_url in image_urls:
412
+ async def resolve_image_url(image_url: str) -> dict | None:
400
413
  if image_url.startswith("http"):
401
414
  image_path = await download_image_by_url(image_url)
402
415
  image_data = await self.encode_image_bs64(image_path)
@@ -408,28 +421,68 @@ class ProviderAnthropic(Provider):
408
421
 
409
422
  if not image_data:
410
423
  logger.warning(f"图片 {image_url} 得到的结果为空,将忽略。")
411
- continue
424
+ return None
412
425
 
413
426
  # Get mime type for the image
414
427
  mime_type, _ = guess_type(image_url)
415
428
  if not mime_type:
416
429
  mime_type = "image/jpeg" # Default to JPEG if can't determine
417
430
 
418
- content.append(
419
- {
420
- "type": "image",
421
- "source": {
422
- "type": "base64",
423
- "media_type": mime_type,
424
- "data": (
425
- image_data.split("base64,")[1]
426
- if "base64," in image_data
427
- else image_data
428
- ),
429
- },
431
+ return {
432
+ "type": "image",
433
+ "source": {
434
+ "type": "base64",
435
+ "media_type": mime_type,
436
+ "data": (
437
+ image_data.split("base64,")[1]
438
+ if "base64," in image_data
439
+ else image_data
440
+ ),
430
441
  },
431
- )
442
+ }
443
+
444
+ content = []
432
445
 
446
+ # 1. 用户原始发言(OpenAI 建议:用户发言在前)
447
+ if text:
448
+ content.append({"type": "text", "text": text})
449
+ elif image_urls:
450
+ # 如果没有文本但有图片,添加占位文本
451
+ content.append({"type": "text", "text": "[图片]"})
452
+ elif extra_user_content_parts:
453
+ # 如果只有额外内容块,也需要添加占位文本
454
+ content.append({"type": "text", "text": " "})
455
+
456
+ # 2. 额外的内容块(系统提醒、指令等)
457
+ if extra_user_content_parts:
458
+ for block in extra_user_content_parts:
459
+ if isinstance(block, TextPart):
460
+ content.append({"type": "text", "text": block.text})
461
+ elif isinstance(block, ImageURLPart):
462
+ image_dict = await resolve_image_url(block.image_url.url)
463
+ if image_dict:
464
+ content.append(image_dict)
465
+ else:
466
+ raise ValueError(f"不支持的额外内容块类型: {type(block)}")
467
+
468
+ # 3. 图片内容
469
+ if image_urls:
470
+ for image_url in image_urls:
471
+ image_dict = await resolve_image_url(image_url)
472
+ if image_dict:
473
+ content.append(image_dict)
474
+
475
+ # 如果只有主文本且没有额外内容块和图片,返回简单格式以保持向后兼容
476
+ if (
477
+ text
478
+ and not extra_user_content_parts
479
+ and not image_urls
480
+ and len(content) == 1
481
+ and content[0]["type"] == "text"
482
+ ):
483
+ return {"role": "user", "content": content[0]["text"]}
484
+
485
+ # 否则返回多模态格式
433
486
  return {"role": "user", "content": content}
434
487
 
435
488
  async def encode_image_bs64(self, image_url: str) -> str:
@@ -56,10 +56,14 @@ class ProviderFishAudioTTSAPI(TTSProvider):
56
56
  "api_base",
57
57
  "https://api.fish-audio.cn/v1",
58
58
  )
59
+ try:
60
+ self.timeout: int = int(provider_config.get("timeout", 20))
61
+ except ValueError:
62
+ self.timeout = 20
59
63
  self.headers = {
60
64
  "Authorization": f"Bearer {self.chosen_api_key}",
61
65
  }
62
- self.set_model(provider_config["model"])
66
+ self.set_model(provider_config.get("model", None))
63
67
 
64
68
  async def _get_reference_id_by_character(self, character: str) -> str | None:
65
69
  """获取角色的reference_id
@@ -135,17 +139,21 @@ class ProviderFishAudioTTSAPI(TTSProvider):
135
139
  path = os.path.join(temp_dir, f"fishaudio_tts_api_{uuid.uuid4()}.wav")
136
140
  self.headers["content-type"] = "application/msgpack"
137
141
  request = await self._generate_request(text)
138
- async with AsyncClient(base_url=self.api_base).stream(
142
+ async with AsyncClient(base_url=self.api_base, timeout=self.timeout).stream(
139
143
  "POST",
140
144
  "/tts",
141
145
  headers=self.headers,
142
146
  content=ormsgpack.packb(request, option=ormsgpack.OPT_SERIALIZE_PYDANTIC),
143
147
  ) as response:
144
- if response.headers["content-type"] == "audio/wav":
148
+ if response.status_code == 200 and response.headers.get(
149
+ "content-type", ""
150
+ ).startswith("audio/"):
145
151
  with open(path, "wb") as f:
146
152
  async for chunk in response.aiter_bytes():
147
153
  f.write(chunk)
148
154
  return path
149
- body = await response.aread()
150
- text = body.decode("utf-8", errors="replace")
151
- raise Exception(f"Fish Audio API请求失败: {text}")
155
+ error_bytes = await response.aread()
156
+ error_text = error_bytes.decode("utf-8", errors="replace")[:1024]
157
+ raise Exception(
158
+ f"Fish Audio API请求失败: 状态码 {response.status_code}, 响应内容: {error_text}"
159
+ )
@@ -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
@@ -680,13 +681,16 @@ class ProviderGoogleGenAI(Provider):
680
681
  system_prompt=None,
681
682
  tool_calls_result=None,
682
683
  model=None,
684
+ extra_user_content_parts=None,
683
685
  **kwargs,
684
686
  ) -> LLMResponse:
685
687
  if contexts is None:
686
688
  contexts = []
687
689
  new_record = None
688
690
  if prompt is not None:
689
- new_record = await self.assemble_context(prompt, image_urls)
691
+ new_record = await self.assemble_context(
692
+ prompt, image_urls, extra_user_content_parts
693
+ )
690
694
  context_query = self._ensure_message_to_dicts(contexts)
691
695
  if new_record:
692
696
  context_query.append(new_record)
@@ -732,13 +736,16 @@ class ProviderGoogleGenAI(Provider):
732
736
  system_prompt=None,
733
737
  tool_calls_result=None,
734
738
  model=None,
739
+ extra_user_content_parts=None,
735
740
  **kwargs,
736
741
  ) -> AsyncGenerator[LLMResponse, None]:
737
742
  if contexts is None:
738
743
  contexts = []
739
744
  new_record = None
740
745
  if prompt is not None:
741
- new_record = await self.assemble_context(prompt, image_urls)
746
+ new_record = await self.assemble_context(
747
+ prompt, image_urls, extra_user_content_parts
748
+ )
742
749
  context_query = self._ensure_message_to_dicts(contexts)
743
750
  if new_record:
744
751
  context_query.append(new_record)
@@ -797,33 +804,75 @@ class ProviderGoogleGenAI(Provider):
797
804
  self.chosen_api_key = key
798
805
  self._init_client()
799
806
 
800
- async def assemble_context(self, text: str, image_urls: list[str] | None = None):
807
+ async def assemble_context(
808
+ self,
809
+ text: str,
810
+ image_urls: list[str] | None = None,
811
+ extra_user_content_parts: list[ContentPart] | None = None,
812
+ ):
801
813
  """组装上下文。"""
802
- if image_urls:
803
- user_content = {
804
- "role": "user",
805
- "content": [{"type": "text", "text": text if text else "[图片]"}],
814
+
815
+ async def resolve_image_part(image_url: str) -> dict | None:
816
+ if image_url.startswith("http"):
817
+ image_path = await download_image_by_url(image_url)
818
+ image_data = await self.encode_image_bs64(image_path)
819
+ elif image_url.startswith("file:///"):
820
+ image_path = image_url.replace("file:///", "")
821
+ image_data = await self.encode_image_bs64(image_path)
822
+ else:
823
+ image_data = await self.encode_image_bs64(image_url)
824
+ if not image_data:
825
+ logger.warning(f"图片 {image_url} 得到的结果为空,将忽略。")
826
+ return None
827
+ return {
828
+ "type": "image_url",
829
+ "image_url": {"url": image_data},
806
830
  }
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)
831
+
832
+ # 构建内容块列表
833
+ content_blocks = []
834
+
835
+ # 1. 用户原始发言(OpenAI 建议:用户发言在前)
836
+ if text:
837
+ content_blocks.append({"type": "text", "text": text})
838
+ elif image_urls:
839
+ # 如果没有文本但有图片,添加占位文本
840
+ content_blocks.append({"type": "text", "text": "[图片]"})
841
+ elif extra_user_content_parts:
842
+ # 如果只有额外内容块,也需要添加占位文本
843
+ content_blocks.append({"type": "text", "text": " "})
844
+
845
+ # 2. 额外的内容块(系统提醒、指令等)
846
+ if extra_user_content_parts:
847
+ for part in extra_user_content_parts:
848
+ if isinstance(part, TextPart):
849
+ content_blocks.append({"type": "text", "text": part.text})
850
+ elif isinstance(part, ImageURLPart):
851
+ image_part = await resolve_image_part(part.image_url.url)
852
+ if image_part:
853
+ content_blocks.append(image_part)
814
854
  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}
855
+ raise ValueError(f"不支持的额外内容块类型: {type(part)}")
856
+
857
+ # 3. 图片内容
858
+ if image_urls:
859
+ for image_url in image_urls:
860
+ image_part = await resolve_image_part(image_url)
861
+ if image_part:
862
+ content_blocks.append(image_part)
863
+
864
+ # 如果只有主文本且没有额外内容块和图片,返回简单格式以保持向后兼容
865
+ if (
866
+ text
867
+ and not extra_user_content_parts
868
+ and not image_urls
869
+ and len(content_blocks) == 1
870
+ and content_blocks[0]["type"] == "text"
871
+ ):
872
+ return {"role": "user", "content": content_blocks[0]["text"]}
873
+
874
+ # 否则返回多模态格式
875
+ return {"role": "user", "content": content_blocks}
827
876
 
828
877
  async def encode_image_bs64(self, image_url: str) -> str:
829
878
  """将图片转换为 base64"""
@@ -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
@@ -348,6 +348,7 @@ class ProviderOpenAIOfficial(Provider):
348
348
  system_prompt: str | None = None,
349
349
  tool_calls_result: ToolCallsResult | list[ToolCallsResult] | None = None,
350
350
  model: str | None = None,
351
+ extra_user_content_parts: list[ContentPart] | None = None,
351
352
  **kwargs,
352
353
  ) -> tuple:
353
354
  """准备聊天所需的有效载荷和上下文"""
@@ -355,7 +356,9 @@ class ProviderOpenAIOfficial(Provider):
355
356
  contexts = []
356
357
  new_record = None
357
358
  if prompt is not None:
358
- new_record = await self.assemble_context(prompt, image_urls)
359
+ new_record = await self.assemble_context(
360
+ prompt, image_urls, extra_user_content_parts
361
+ )
359
362
  context_query = self._ensure_message_to_dicts(contexts)
360
363
  if new_record:
361
364
  context_query.append(new_record)
@@ -476,6 +479,7 @@ class ProviderOpenAIOfficial(Provider):
476
479
  system_prompt=None,
477
480
  tool_calls_result=None,
478
481
  model=None,
482
+ extra_user_content_parts=None,
479
483
  **kwargs,
480
484
  ) -> LLMResponse:
481
485
  payloads, context_query = await self._prepare_chat_payload(
@@ -485,6 +489,7 @@ class ProviderOpenAIOfficial(Provider):
485
489
  system_prompt,
486
490
  tool_calls_result,
487
491
  model=model,
492
+ extra_user_content_parts=extra_user_content_parts,
488
493
  **kwargs,
489
494
  )
490
495
 
@@ -624,33 +629,71 @@ class ProviderOpenAIOfficial(Provider):
624
629
  self,
625
630
  text: str,
626
631
  image_urls: list[str] | None = None,
632
+ extra_user_content_parts: list[ContentPart] | None = None,
627
633
  ) -> dict:
628
634
  """组装成符合 OpenAI 格式的 role 为 user 的消息段"""
629
- if image_urls:
630
- user_content = {
631
- "role": "user",
632
- "content": [{"type": "text", "text": text if text else "[图片]"}],
635
+
636
+ async def resolve_image_part(image_url: str) -> dict | None:
637
+ if image_url.startswith("http"):
638
+ image_path = await download_image_by_url(image_url)
639
+ image_data = await self.encode_image_bs64(image_path)
640
+ elif image_url.startswith("file:///"):
641
+ image_path = image_url.replace("file:///", "")
642
+ image_data = await self.encode_image_bs64(image_path)
643
+ else:
644
+ image_data = await self.encode_image_bs64(image_url)
645
+ if not image_data:
646
+ logger.warning(f"图片 {image_url} 得到的结果为空,将忽略。")
647
+ return None
648
+ return {
649
+ "type": "image_url",
650
+ "image_url": {"url": image_data},
633
651
  }
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)
652
+
653
+ # 构建内容块列表
654
+ content_blocks = []
655
+
656
+ # 1. 用户原始发言(OpenAI 建议:用户发言在前)
657
+ if text:
658
+ content_blocks.append({"type": "text", "text": text})
659
+ elif image_urls:
660
+ # 如果没有文本但有图片,添加占位文本
661
+ content_blocks.append({"type": "text", "text": "[图片]"})
662
+ elif extra_user_content_parts:
663
+ # 如果只有额外内容块,也需要添加占位文本
664
+ content_blocks.append({"type": "text", "text": " "})
665
+
666
+ # 2. 额外的内容块(系统提醒、指令等)
667
+ if extra_user_content_parts:
668
+ for part in extra_user_content_parts:
669
+ if isinstance(part, TextPart):
670
+ content_blocks.append({"type": "text", "text": part.text})
671
+ elif isinstance(part, ImageURLPart):
672
+ image_part = await resolve_image_part(part.image_url.url)
673
+ if image_part:
674
+ content_blocks.append(image_part)
641
675
  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}
676
+ raise ValueError(f"不支持的额外内容块类型: {type(part)}")
677
+
678
+ # 3. 图片内容
679
+ if image_urls:
680
+ for image_url in image_urls:
681
+ image_part = await resolve_image_part(image_url)
682
+ if image_part:
683
+ content_blocks.append(image_part)
684
+
685
+ # 如果只有主文本且没有额外内容块和图片,返回简单格式以保持向后兼容
686
+ if (
687
+ text
688
+ and not extra_user_content_parts
689
+ and not image_urls
690
+ and len(content_blocks) == 1
691
+ and content_blocks[0]["type"] == "text"
692
+ ):
693
+ return {"role": "user", "content": content_blocks[0]["text"]}
694
+
695
+ # 否则返回多模态格式
696
+ return {"role": "user", "content": content_blocks}
654
697
 
655
698
  async def encode_image_bs64(self, image_url: str) -> str:
656
699
  """将图片转换为 base64"""