AstrBot 4.11.4__py3-none-any.whl → 4.12.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 (41) hide show
  1. astrbot/cli/__init__.py +1 -1
  2. astrbot/core/agent/runners/tool_loop_agent_runner.py +10 -8
  3. astrbot/core/config/default.py +66 -2
  4. astrbot/core/db/__init__.py +84 -2
  5. astrbot/core/db/po.py +65 -0
  6. astrbot/core/db/sqlite.py +225 -4
  7. astrbot/core/pipeline/process_stage/method/agent_sub_stages/internal.py +103 -49
  8. astrbot/core/pipeline/process_stage/utils.py +40 -0
  9. astrbot/core/platform/astr_message_event.py +23 -4
  10. astrbot/core/platform/sources/discord/discord_platform_adapter.py +2 -0
  11. astrbot/core/platform/sources/telegram/tg_adapter.py +2 -0
  12. astrbot/core/platform/sources/webchat/webchat_adapter.py +3 -2
  13. astrbot/core/platform/sources/webchat/webchat_event.py +17 -4
  14. astrbot/core/provider/sources/anthropic_source.py +44 -0
  15. astrbot/core/sandbox/booters/base.py +31 -0
  16. astrbot/core/sandbox/booters/boxlite.py +186 -0
  17. astrbot/core/sandbox/booters/shipyard.py +67 -0
  18. astrbot/core/sandbox/olayer/__init__.py +5 -0
  19. astrbot/core/sandbox/olayer/filesystem.py +33 -0
  20. astrbot/core/sandbox/olayer/python.py +19 -0
  21. astrbot/core/sandbox/olayer/shell.py +21 -0
  22. astrbot/core/sandbox/sandbox_client.py +52 -0
  23. astrbot/core/sandbox/tools/__init__.py +10 -0
  24. astrbot/core/sandbox/tools/fs.py +188 -0
  25. astrbot/core/sandbox/tools/python.py +74 -0
  26. astrbot/core/sandbox/tools/shell.py +55 -0
  27. astrbot/core/star/context.py +162 -44
  28. astrbot/dashboard/routes/__init__.py +2 -0
  29. astrbot/dashboard/routes/chat.py +40 -12
  30. astrbot/dashboard/routes/chatui_project.py +245 -0
  31. astrbot/dashboard/routes/session_management.py +545 -0
  32. astrbot/dashboard/server.py +1 -0
  33. {astrbot-4.11.4.dist-info → astrbot-4.12.1.dist-info}/METADATA +2 -1
  34. {astrbot-4.11.4.dist-info → astrbot-4.12.1.dist-info}/RECORD +37 -28
  35. astrbot/builtin_stars/python_interpreter/main.py +0 -536
  36. astrbot/builtin_stars/python_interpreter/metadata.yaml +0 -4
  37. astrbot/builtin_stars/python_interpreter/requirements.txt +0 -1
  38. astrbot/builtin_stars/python_interpreter/shared/api.py +0 -22
  39. {astrbot-4.11.4.dist-info → astrbot-4.12.1.dist-info}/WHEEL +0 -0
  40. {astrbot-4.11.4.dist-info → astrbot-4.12.1.dist-info}/entry_points.txt +0 -0
  41. {astrbot-4.11.4.dist-info → astrbot-4.12.1.dist-info}/licenses/LICENSE +0 -0
@@ -2,10 +2,11 @@
2
2
 
3
3
  import asyncio
4
4
  import json
5
+ import os
5
6
  from collections.abc import AsyncGenerator
6
7
 
7
8
  from astrbot.core import logger
8
- from astrbot.core.agent.message import Message
9
+ from astrbot.core.agent.message import Message, TextPart
9
10
  from astrbot.core.agent.response import AgentStats
10
11
  from astrbot.core.agent.tool import ToolSet
11
12
  from astrbot.core.astr_agent_context import AstrAgentContext
@@ -35,8 +36,16 @@ from .....astr_agent_tool_exec import FunctionToolExecutor
35
36
  from ....context import PipelineContext, call_event_hook
36
37
  from ...stage import Stage
37
38
  from ...utils import (
39
+ CHATUI_EXTRA_PROMPT,
40
+ EXECUTE_SHELL_TOOL,
41
+ FILE_DOWNLOAD_TOOL,
42
+ FILE_UPLOAD_TOOL,
38
43
  KNOWLEDGE_BASE_QUERY_TOOL,
39
44
  LLM_SAFETY_MODE_SYSTEM_PROMPT,
45
+ PYTHON_TOOL,
46
+ SANDBOX_MODE_PROMPT,
47
+ TOOL_CALL_PROMPT,
48
+ decoded_blocked,
40
49
  retrieve_knowledge_base,
41
50
  )
42
51
 
@@ -93,6 +102,8 @@ class InternalAgentSubStage(Stage):
93
102
  "safety_mode_strategy", "system_prompt"
94
103
  )
95
104
 
105
+ self.sandbox_cfg = settings.get("sandbox", {})
106
+
96
107
  self.conv_manager = ctx.plugin_manager.context.conversation_manager
97
108
 
98
109
  def _select_provider(self, event: AstrMessageEvent):
@@ -341,54 +352,45 @@ class InternalAgentSubStage(Stage):
341
352
  prov: Provider,
342
353
  ):
343
354
  """处理 WebChat 平台的特殊情况,包括第一次 LLM 对话时总结对话内容生成 title"""
344
- if not req.conversation:
355
+ from astrbot.core import db_helper
356
+
357
+ chatui_session_id = event.session_id.split("!")[-1]
358
+ user_prompt = req.prompt
359
+
360
+ session = await db_helper.get_platform_session_by_id(chatui_session_id)
361
+
362
+ if (
363
+ not user_prompt
364
+ or not chatui_session_id
365
+ or not session
366
+ or session.display_name
367
+ ):
345
368
  return
346
- conversation = await self.conv_manager.get_conversation(
347
- event.unified_msg_origin,
348
- req.conversation.cid,
369
+
370
+ llm_resp = await prov.text_chat(
371
+ system_prompt=(
372
+ "You are a conversation title generator. "
373
+ "Generate a concise title in the same language as the user’s input, "
374
+ "no more than 10 words, capturing only the core topic."
375
+ "If the input is a greeting, small talk, or has no clear topic, "
376
+ "(e.g., “hi”, “hello”, “haha”), return <None>. "
377
+ "Output only the title itself or <None>, with no explanations."
378
+ ),
379
+ prompt=(
380
+ f"Generate a concise title for the following user query:\n{user_prompt}"
381
+ ),
349
382
  )
350
- if conversation and not req.conversation.title:
351
- messages = json.loads(conversation.history)
352
- latest_pair = messages[-2:]
353
- if not latest_pair:
383
+ if llm_resp and llm_resp.completion_text:
384
+ title = llm_resp.completion_text.strip()
385
+ if not title or "<None>" in title:
354
386
  return
355
- content = latest_pair[0].get("content", "")
356
- if isinstance(content, list):
357
- # 多模态
358
- text_parts = []
359
- for item in content:
360
- if isinstance(item, dict):
361
- if item.get("type") == "text":
362
- text_parts.append(item.get("text", ""))
363
- elif item.get("type") == "image":
364
- text_parts.append("[图片]")
365
- elif isinstance(item, str):
366
- text_parts.append(item)
367
- cleaned_text = "User: " + " ".join(text_parts).strip()
368
- elif isinstance(content, str):
369
- cleaned_text = "User: " + content.strip()
370
- else:
371
- return
372
- logger.debug(f"WebChat 对话标题生成请求,清理后的文本: {cleaned_text}")
373
- llm_resp = await prov.text_chat(
374
- system_prompt="You are expert in summarizing user's query.",
375
- prompt=(
376
- f"Please summarize the following query of user:\n"
377
- f"{cleaned_text}\n"
378
- "Only output the summary within 10 words, DO NOT INCLUDE any other text."
379
- "You must use the same language as the user."
380
- "If you think the dialog is too short to summarize, only output a special mark: `<None>`"
381
- ),
387
+ logger.info(
388
+ f"Generated chatui title for session {chatui_session_id}: {title}"
389
+ )
390
+ await db_helper.update_platform_session(
391
+ session_id=chatui_session_id,
392
+ display_name=title,
382
393
  )
383
- if llm_resp and llm_resp.completion_text:
384
- title = llm_resp.completion_text.strip()
385
- if not title or "<None>" in title:
386
- return
387
- await self.conv_manager.update_conversation_title(
388
- unified_msg_origin=event.unified_msg_origin,
389
- title=title,
390
- conversation_id=req.conversation.cid,
391
- )
392
394
 
393
395
  async def _save_to_history(
394
396
  self,
@@ -466,6 +468,24 @@ class InternalAgentSubStage(Stage):
466
468
  f"Unsupported llm_safety_mode strategy: {self.safety_mode_strategy}.",
467
469
  )
468
470
 
471
+ def _apply_sandbox_tools(self, req: ProviderRequest, session_id: str) -> None:
472
+ """Add sandbox tools to the provider request."""
473
+ if req.func_tool is None:
474
+ req.func_tool = ToolSet()
475
+ if self.sandbox_cfg.get("booter") == "shipyard":
476
+ ep = self.sandbox_cfg.get("shipyard_endpoint", "")
477
+ at = self.sandbox_cfg.get("shipyard_access_token", "")
478
+ if not ep or not at:
479
+ logger.error("Shipyard sandbox configuration is incomplete.")
480
+ return
481
+ os.environ["SHIPYARD_ENDPOINT"] = ep
482
+ os.environ["SHIPYARD_ACCESS_TOKEN"] = at
483
+ req.func_tool.add_tool(EXECUTE_SHELL_TOOL)
484
+ req.func_tool.add_tool(PYTHON_TOOL)
485
+ req.func_tool.add_tool(FILE_UPLOAD_TOOL)
486
+ req.func_tool.add_tool(FILE_DOWNLOAD_TOOL)
487
+ req.system_prompt += f"\n{SANDBOX_MODE_PROMPT}\n"
488
+
469
489
  async def process(
470
490
  self, event: AstrMessageEvent, provider_wake_prefix: str
471
491
  ) -> AsyncGenerator[None, None]:
@@ -501,6 +521,14 @@ class InternalAgentSubStage(Stage):
501
521
  logger.debug("skip llm request: empty message and no provider_request")
502
522
  return
503
523
 
524
+ api_base = provider.provider_config.get("api_base", "")
525
+ for host in decoded_blocked:
526
+ if host in api_base:
527
+ logger.error(
528
+ f"Provider API base {api_base} is blocked due to security reasons. Please use another ai provider."
529
+ )
530
+ return
531
+
504
532
  logger.debug("ready to request llm provider")
505
533
 
506
534
  # 通知等待调用 LLM(在获取锁之前)
@@ -536,6 +564,20 @@ class InternalAgentSubStage(Stage):
536
564
  image_path = await comp.convert_to_file_path()
537
565
  req.image_urls.append(image_path)
538
566
 
567
+ req.extra_user_content_parts.append(
568
+ TextPart(text=f"[Image Attachment: path {image_path}]")
569
+ )
570
+ elif isinstance(comp, File) and self.sandbox_cfg.get(
571
+ "enable", False
572
+ ):
573
+ file_path = await comp.get_file()
574
+ file_name = comp.name or os.path.basename(file_path)
575
+ req.extra_user_content_parts.append(
576
+ TextPart(
577
+ text=f"[File Attachment: name {file_name}, path {file_path}]"
578
+ )
579
+ )
580
+
539
581
  conversation = await self._get_session_conv(event)
540
582
  req.conversation = conversation
541
583
  req.contexts = json.loads(conversation.history)
@@ -586,6 +628,10 @@ class InternalAgentSubStage(Stage):
586
628
  if self.llm_safety_mode:
587
629
  self._apply_llm_safety_mode(req)
588
630
 
631
+ # apply sandbox tools
632
+ if self.sandbox_cfg.get("enable", False):
633
+ self._apply_sandbox_tools(req, req.session_id)
634
+
589
635
  stream_to_general = (
590
636
  self.unsupported_streaming_strategy == "turn_off"
591
637
  and not event.platform_meta.support_streaming_message
@@ -609,6 +655,18 @@ class InternalAgentSubStage(Stage):
609
655
  "limit"
610
656
  ]["context"]
611
657
 
658
+ # ChatUI 对话的标题生成
659
+ if event.get_platform_name() == "webchat":
660
+ asyncio.create_task(self._handle_webchat(event, req, provider))
661
+
662
+ # 注入 ChatUI 额外 prompt
663
+ # 比如 follow-up questions 提示等
664
+ req.system_prompt += f"\n{CHATUI_EXTRA_PROMPT}\n"
665
+
666
+ # 注入基本 prompt
667
+ if req.func_tool and req.func_tool.tools:
668
+ req.system_prompt += f"\n{TOOL_CALL_PROMPT}\n"
669
+
612
670
  await agent_runner.reset(
613
671
  provider=provider,
614
672
  request=req,
@@ -679,10 +737,6 @@ class InternalAgentSubStage(Stage):
679
737
  agent_runner.stats,
680
738
  )
681
739
 
682
- # 异步处理 WebChat 特殊情况
683
- if event.get_platform_name() == "webchat":
684
- asyncio.create_task(self._handle_webchat(event, req, provider))
685
-
686
740
  asyncio.create_task(
687
741
  Metric.upload(
688
742
  llm_tick=1,
@@ -1,3 +1,5 @@
1
+ import base64
2
+
1
3
  from pydantic import Field
2
4
  from pydantic.dataclasses import dataclass
3
5
 
@@ -5,6 +7,12 @@ from astrbot.api import logger, sp
5
7
  from astrbot.core.agent.run_context import ContextWrapper
6
8
  from astrbot.core.agent.tool import FunctionTool, ToolExecResult
7
9
  from astrbot.core.astr_agent_context import AstrAgentContext
10
+ from astrbot.core.sandbox.tools import (
11
+ ExecuteShellTool,
12
+ FileDownloadTool,
13
+ FileUploadTool,
14
+ PythonTool,
15
+ )
8
16
  from astrbot.core.star.context import Context
9
17
 
10
18
  LLM_SAFETY_MODE_SYSTEM_PROMPT = """You are running in Safe Mode.
@@ -19,6 +27,28 @@ Rules:
19
27
  - Output same language as the user's input.
20
28
  """
21
29
 
30
+ SANDBOX_MODE_PROMPT = (
31
+ "You have access to a sandboxed environment and can execute shell commands and Python code securely."
32
+ # "Your have extended skills library, such as PDF processing, image generation, data analysis, etc. "
33
+ # "Before handling complex tasks, please retrieve and review the documentation in the in /app/skills/ directory. "
34
+ # "If the current task matches the description of a specific skill, prioritize following the workflow defined by that skill."
35
+ # "Use `ls /app/skills/` to list all available skills. "
36
+ # "Use `cat /app/skills/{skill_name}/SKILL.md` to read the documentation of a specific skill."
37
+ # "SKILL.md might be large, you can read the description first, which is located in the YAML frontmatter of the file."
38
+ # "Use shell commands such as grep, sed, awk to extract relevant information from the documentation as needed.\n"
39
+ )
40
+
41
+ TOOL_CALL_PROMPT = (
42
+ "You MUST NOT return an empty response, especially after invoking a tool."
43
+ "Before calling any tool, provide a brief explanatory message to the user stating the purpose of the tool call."
44
+ "After the tool call is completed, you must briefly summarize the results returned by the tool for the user."
45
+ )
46
+
47
+ CHATUI_EXTRA_PROMPT = (
48
+ 'When you answered, you need to add a follow up question / summarization but do not add "Follow up" words. '
49
+ "Such as, user asked you to generate codes, you can add: Do you need me to run these codes for you?"
50
+ )
51
+
22
52
 
23
53
  @dataclass
24
54
  class KnowledgeBaseQueryTool(FunctionTool[AstrAgentContext]):
@@ -135,3 +165,13 @@ async def retrieve_knowledge_base(
135
165
 
136
166
 
137
167
  KNOWLEDGE_BASE_QUERY_TOOL = KnowledgeBaseQueryTool()
168
+
169
+ EXECUTE_SHELL_TOOL = ExecuteShellTool()
170
+ PYTHON_TOOL = PythonTool()
171
+ FILE_UPLOAD_TOOL = FileUploadTool()
172
+ FILE_DOWNLOAD_TOOL = FileDownloadTool()
173
+
174
+ # we prevent astrbot from connecting to known malicious hosts
175
+ # these hosts are base64 encoded
176
+ BLOCKED = {"dGZid2h2d3IuY2xvdWQuc2VhbG9zLmlv", "a291cmljaGF0"}
177
+ decoded_blocked = [base64.b64decode(b).decode("utf-8") for b in BLOCKED]
@@ -42,8 +42,6 @@ class AstrMessageEvent(abc.ABC):
42
42
  """消息对象, AstrBotMessage。带有完整的消息结构。"""
43
43
  self.platform_meta = platform_meta
44
44
  """消息平台的信息, 其中 name 是平台的类型,如 aiocqhttp"""
45
- self.session_id = session_id
46
- """用户的会话 ID。可以直接使用下面的 unified_msg_origin"""
47
45
  self.role = "member"
48
46
  """用户是否是管理员。如果是管理员,这里是 admin"""
49
47
  self.is_wake = False
@@ -51,12 +49,12 @@ class AstrMessageEvent(abc.ABC):
51
49
  self.is_at_or_wake_command = False
52
50
  """是否是 At 机器人或者带有唤醒词或者是私聊(插件注册的事件监听器会让 is_wake 设为 True, 但是不会让这个属性置为 True)"""
53
51
  self._extras: dict[str, Any] = {}
54
- self.session = MessageSesion(
52
+ self.session = MessageSession(
55
53
  platform_name=platform_meta.id,
56
54
  message_type=message_obj.type,
57
55
  session_id=session_id,
58
56
  )
59
- self.unified_msg_origin = str(self.session)
57
+ # self.unified_msg_origin = str(self.session)
60
58
  """统一的消息来源字符串。格式为 platform_name:message_type:session_id"""
61
59
  self._result: MessageEventResult | None = None
62
60
  """消息事件的结果"""
@@ -72,6 +70,27 @@ class AstrMessageEvent(abc.ABC):
72
70
  # back_compability
73
71
  self.platform = platform_meta
74
72
 
73
+ @property
74
+ def unified_msg_origin(self) -> str:
75
+ """统一的消息来源字符串。格式为 platform_name:message_type:session_id"""
76
+ return str(self.session)
77
+
78
+ @unified_msg_origin.setter
79
+ def unified_msg_origin(self, value: str):
80
+ """设置统一的消息来源字符串。格式为 platform_name:message_type:session_id"""
81
+ self.new_session = MessageSession.from_str(value)
82
+ self.session = self.new_session
83
+
84
+ @property
85
+ def session_id(self) -> str:
86
+ """用户的会话 ID。可以直接使用下面的 unified_msg_origin"""
87
+ return self.session.session_id
88
+
89
+ @session_id.setter
90
+ def session_id(self, value: str):
91
+ """设置用户的会话 ID。可以直接使用下面的 unified_msg_origin"""
92
+ self.session.session_id = value
93
+
75
94
  def get_platform_name(self):
76
95
  """获取这个事件所属的平台的类型(如 aiocqhttp, slack, discord 等)。
77
96
 
@@ -370,6 +370,8 @@ class DiscordPlatformAdapter(Platform):
370
370
  for handler_md in star_handlers_registry:
371
371
  if not star_map[handler_md.handler_module_path].activated:
372
372
  continue
373
+ if not handler_md.enabled:
374
+ continue
373
375
  for event_filter in handler_md.event_filters:
374
376
  cmd_info = self._extract_command_info(event_filter, handler_md)
375
377
  if not cmd_info:
@@ -161,6 +161,8 @@ class TelegramPlatformAdapter(Platform):
161
161
  handler_metadata = handler_md
162
162
  if not star_map[handler_metadata.handler_module_path].activated:
163
163
  continue
164
+ if not handler_metadata.enabled:
165
+ continue
164
166
  for event_filter in handler_metadata.event_filters:
165
167
  cmd_info = self._extract_command_info(
166
168
  event_filter,
@@ -93,7 +93,8 @@ class WebChatAdapter(Platform):
93
93
  session: MessageSesion,
94
94
  message_chain: MessageChain,
95
95
  ):
96
- await WebChatMessageEvent._send(message_chain, session.session_id)
96
+ message_id = f"active_{str(uuid.uuid4())}"
97
+ await WebChatMessageEvent._send(message_id, message_chain, session.session_id)
97
98
  await super().send_by_session(session, message_chain)
98
99
 
99
100
  async def _get_message_history(
@@ -196,7 +197,7 @@ class WebChatAdapter(Platform):
196
197
 
197
198
  abm.session_id = f"webchat!{username}!{cid}"
198
199
 
199
- abm.message_id = str(uuid.uuid4())
200
+ abm.message_id = payload.get("message_id")
200
201
 
201
202
  # 处理消息段列表
202
203
  message_parts = payload.get("message", [])
@@ -21,7 +21,10 @@ class WebChatMessageEvent(AstrMessageEvent):
21
21
 
22
22
  @staticmethod
23
23
  async def _send(
24
- message: MessageChain | None, session_id: str, streaming: bool = False
24
+ message_id: str,
25
+ message: MessageChain | None,
26
+ session_id: str,
27
+ streaming: bool = False,
25
28
  ) -> str | None:
26
29
  cid = session_id.split("!")[-1]
27
30
  web_chat_back_queue = webchat_queue_mgr.get_or_create_back_queue(cid)
@@ -31,6 +34,7 @@ class WebChatMessageEvent(AstrMessageEvent):
31
34
  "type": "end",
32
35
  "data": "",
33
36
  "streaming": False,
37
+ "message_id": message_id,
34
38
  }, # end means this request is finished
35
39
  )
36
40
  return
@@ -45,6 +49,7 @@ class WebChatMessageEvent(AstrMessageEvent):
45
49
  "data": data,
46
50
  "streaming": streaming,
47
51
  "chain_type": message.type,
52
+ "message_id": message_id,
48
53
  },
49
54
  )
50
55
  elif isinstance(comp, Json):
@@ -54,6 +59,7 @@ class WebChatMessageEvent(AstrMessageEvent):
54
59
  "data": json.dumps(comp.data, ensure_ascii=False),
55
60
  "streaming": streaming,
56
61
  "chain_type": message.type,
62
+ "message_id": message_id,
57
63
  },
58
64
  )
59
65
  elif isinstance(comp, Image):
@@ -69,6 +75,7 @@ class WebChatMessageEvent(AstrMessageEvent):
69
75
  "type": "image",
70
76
  "data": data,
71
77
  "streaming": streaming,
78
+ "message_id": message_id,
72
79
  },
73
80
  )
74
81
  elif isinstance(comp, Record):
@@ -84,6 +91,7 @@ class WebChatMessageEvent(AstrMessageEvent):
84
91
  "type": "record",
85
92
  "data": data,
86
93
  "streaming": streaming,
94
+ "message_id": message_id,
87
95
  },
88
96
  )
89
97
  elif isinstance(comp, File):
@@ -94,12 +102,13 @@ class WebChatMessageEvent(AstrMessageEvent):
94
102
  filename = f"{uuid.uuid4()!s}{ext}"
95
103
  dest_path = os.path.join(imgs_dir, filename)
96
104
  shutil.copy2(file_path, dest_path)
97
- data = f"[FILE]{filename}|{original_name}"
105
+ data = f"[FILE]{filename}"
98
106
  await web_chat_back_queue.put(
99
107
  {
100
108
  "type": "file",
101
109
  "data": data,
102
110
  "streaming": streaming,
111
+ "message_id": message_id,
103
112
  },
104
113
  )
105
114
  else:
@@ -108,7 +117,8 @@ class WebChatMessageEvent(AstrMessageEvent):
108
117
  return data
109
118
 
110
119
  async def send(self, message: MessageChain | None):
111
- await WebChatMessageEvent._send(message, session_id=self.session_id)
120
+ message_id = self.message_obj.message_id
121
+ await WebChatMessageEvent._send(message_id, message, session_id=self.session_id)
112
122
  await super().send(MessageChain([]))
113
123
 
114
124
  async def send_streaming(self, generator, use_fallback: bool = False):
@@ -116,6 +126,7 @@ class WebChatMessageEvent(AstrMessageEvent):
116
126
  reasoning_content = ""
117
127
  cid = self.session_id.split("!")[-1]
118
128
  web_chat_back_queue = webchat_queue_mgr.get_or_create_back_queue(cid)
129
+ message_id = self.message_obj.message_id
119
130
  async for chain in generator:
120
131
  # if chain.type == "break" and final_data:
121
132
  # # 分割符
@@ -130,7 +141,8 @@ class WebChatMessageEvent(AstrMessageEvent):
130
141
  # continue
131
142
 
132
143
  r = await WebChatMessageEvent._send(
133
- chain,
144
+ message_id=message_id,
145
+ message=chain,
134
146
  session_id=self.session_id,
135
147
  streaming=True,
136
148
  )
@@ -147,6 +159,7 @@ class WebChatMessageEvent(AstrMessageEvent):
147
159
  "data": final_data,
148
160
  "reasoning": reasoning_content,
149
161
  "streaming": True,
162
+ "message_id": message_id,
150
163
  },
151
164
  )
152
165
  await super().send_streaming(generator, use_fallback)
@@ -127,6 +127,50 @@ class ProviderAnthropic(Provider):
127
127
  ],
128
128
  },
129
129
  )
130
+ elif message["role"] == "user":
131
+ if isinstance(message.get("content"), list):
132
+ converted_content = []
133
+ for part in message["content"]:
134
+ if part.get("type") == "image_url":
135
+ # Convert OpenAI image_url format to Anthropic image format
136
+ image_url_data = part.get("image_url", {})
137
+ url = image_url_data.get("url", "")
138
+ if url.startswith("data:"):
139
+ try:
140
+ _, base64_data = url.split(",", 1)
141
+ # Detect actual image format from binary data
142
+ image_bytes = base64.b64decode(base64_data)
143
+ media_type = self._detect_image_mime_type(
144
+ image_bytes
145
+ )
146
+ converted_content.append(
147
+ {
148
+ "type": "image",
149
+ "source": {
150
+ "type": "base64",
151
+ "media_type": media_type,
152
+ "data": base64_data,
153
+ },
154
+ }
155
+ )
156
+ except ValueError:
157
+ logger.warning(
158
+ f"Failed to parse image data URI: {url[:50]}..."
159
+ )
160
+ else:
161
+ logger.warning(
162
+ f"Unsupported image URL format for Anthropic: {url[:50]}..."
163
+ )
164
+ else:
165
+ converted_content.append(part)
166
+ new_messages.append(
167
+ {
168
+ "role": "user",
169
+ "content": converted_content,
170
+ }
171
+ )
172
+ else:
173
+ new_messages.append(message)
130
174
  else:
131
175
  new_messages.append(message)
132
176
 
@@ -0,0 +1,31 @@
1
+ from ..olayer import FileSystemComponent, PythonComponent, ShellComponent
2
+
3
+
4
+ class SandboxBooter:
5
+ @property
6
+ def fs(self) -> FileSystemComponent: ...
7
+
8
+ @property
9
+ def python(self) -> PythonComponent: ...
10
+
11
+ @property
12
+ def shell(self) -> ShellComponent: ...
13
+
14
+ async def boot(self, session_id: str) -> None: ...
15
+
16
+ async def shutdown(self) -> None: ...
17
+
18
+ async def upload_file(self, path: str, file_name: str) -> dict:
19
+ """Upload file to sandbox.
20
+
21
+ Should return a dict with `success` (bool) and `file_path` (str) keys.
22
+ """
23
+ ...
24
+
25
+ async def download_file(self, remote_path: str, local_path: str):
26
+ """Download file from sandbox."""
27
+ ...
28
+
29
+ async def available(self) -> bool:
30
+ """Check if the sandbox is available."""
31
+ ...