AstrBot 4.12.3__py3-none-any.whl → 4.13.0__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 (72) hide show
  1. astrbot/builtin_stars/astrbot/process_llm_request.py +42 -1
  2. astrbot/builtin_stars/builtin_commands/commands/__init__.py +0 -2
  3. astrbot/builtin_stars/builtin_commands/commands/persona.py +68 -6
  4. astrbot/builtin_stars/builtin_commands/main.py +0 -26
  5. astrbot/cli/__init__.py +1 -1
  6. astrbot/core/agent/runners/tool_loop_agent_runner.py +91 -1
  7. astrbot/core/agent/tool.py +61 -20
  8. astrbot/core/astr_agent_hooks.py +3 -1
  9. astrbot/core/astr_agent_run_util.py +243 -1
  10. astrbot/core/astr_agent_tool_exec.py +2 -2
  11. astrbot/core/{sandbox → computer}/booters/base.py +4 -4
  12. astrbot/core/{sandbox → computer}/booters/boxlite.py +2 -2
  13. astrbot/core/computer/booters/local.py +234 -0
  14. astrbot/core/{sandbox → computer}/booters/shipyard.py +2 -2
  15. astrbot/core/computer/computer_client.py +102 -0
  16. astrbot/core/{sandbox → computer}/tools/__init__.py +2 -1
  17. astrbot/core/{sandbox → computer}/tools/fs.py +1 -1
  18. astrbot/core/computer/tools/python.py +94 -0
  19. astrbot/core/{sandbox → computer}/tools/shell.py +13 -5
  20. astrbot/core/config/default.py +90 -9
  21. astrbot/core/db/__init__.py +94 -1
  22. astrbot/core/db/po.py +46 -0
  23. astrbot/core/db/sqlite.py +248 -0
  24. astrbot/core/message/components.py +2 -2
  25. astrbot/core/persona_mgr.py +162 -2
  26. astrbot/core/pipeline/context_utils.py +2 -2
  27. astrbot/core/pipeline/preprocess_stage/stage.py +1 -1
  28. astrbot/core/pipeline/process_stage/method/agent_sub_stages/internal.py +73 -6
  29. astrbot/core/pipeline/process_stage/utils.py +31 -4
  30. astrbot/core/pipeline/scheduler.py +1 -1
  31. astrbot/core/pipeline/waking_check/stage.py +0 -1
  32. astrbot/core/platform/sources/aiocqhttp/aiocqhttp_message_event.py +3 -3
  33. astrbot/core/platform/sources/aiocqhttp/aiocqhttp_platform_adapter.py +32 -14
  34. astrbot/core/platform/sources/dingtalk/dingtalk_adapter.py +61 -2
  35. astrbot/core/platform/sources/dingtalk/dingtalk_event.py +57 -11
  36. astrbot/core/platform/sources/qqofficial/qqofficial_message_event.py +5 -7
  37. astrbot/core/platform/sources/webchat/webchat_adapter.py +1 -0
  38. astrbot/core/platform/sources/webchat/webchat_event.py +24 -0
  39. astrbot/core/provider/manager.py +38 -0
  40. astrbot/core/provider/provider.py +54 -0
  41. astrbot/core/provider/sources/gemini_embedding_source.py +1 -1
  42. astrbot/core/provider/sources/gemini_source.py +12 -9
  43. astrbot/core/provider/sources/genie_tts.py +128 -0
  44. astrbot/core/provider/sources/openai_embedding_source.py +1 -1
  45. astrbot/core/skills/__init__.py +3 -0
  46. astrbot/core/skills/skill_manager.py +237 -0
  47. astrbot/core/star/command_management.py +1 -1
  48. astrbot/core/star/config.py +1 -1
  49. astrbot/core/star/context.py +9 -8
  50. astrbot/core/star/filter/command.py +1 -1
  51. astrbot/core/star/filter/custom_filter.py +2 -2
  52. astrbot/core/star/register/star_handler.py +2 -4
  53. astrbot/core/utils/astrbot_path.py +6 -0
  54. astrbot/dashboard/routes/__init__.py +2 -0
  55. astrbot/dashboard/routes/config.py +236 -2
  56. astrbot/dashboard/routes/live_chat.py +423 -0
  57. astrbot/dashboard/routes/persona.py +265 -1
  58. astrbot/dashboard/routes/skills.py +148 -0
  59. astrbot/dashboard/routes/util.py +102 -0
  60. astrbot/dashboard/server.py +21 -5
  61. {astrbot-4.12.3.dist-info → astrbot-4.13.0.dist-info}/METADATA +1 -1
  62. {astrbot-4.12.3.dist-info → astrbot-4.13.0.dist-info}/RECORD +69 -63
  63. astrbot/builtin_stars/builtin_commands/commands/tool.py +0 -31
  64. astrbot/core/sandbox/sandbox_client.py +0 -52
  65. astrbot/core/sandbox/tools/python.py +0 -74
  66. /astrbot/core/{sandbox → computer}/olayer/__init__.py +0 -0
  67. /astrbot/core/{sandbox → computer}/olayer/filesystem.py +0 -0
  68. /astrbot/core/{sandbox → computer}/olayer/python.py +0 -0
  69. /astrbot/core/{sandbox → computer}/olayer/shell.py +0 -0
  70. {astrbot-4.12.3.dist-info → astrbot-4.13.0.dist-info}/WHEEL +0 -0
  71. {astrbot-4.12.3.dist-info → astrbot-4.13.0.dist-info}/entry_points.txt +0 -0
  72. {astrbot-4.12.3.dist-info → astrbot-4.13.0.dist-info}/licenses/LICENSE +0 -0
@@ -1,3 +1,6 @@
1
+ import asyncio
2
+ import re
3
+ import time
1
4
  import traceback
2
5
  from collections.abc import AsyncGenerator
3
6
 
@@ -5,13 +8,14 @@ from astrbot.core import logger
5
8
  from astrbot.core.agent.message import Message
6
9
  from astrbot.core.agent.runners.tool_loop_agent_runner import ToolLoopAgentRunner
7
10
  from astrbot.core.astr_agent_context import AstrAgentContext
8
- from astrbot.core.message.components import Json
11
+ from astrbot.core.message.components import BaseMessageComponent, Json, Plain
9
12
  from astrbot.core.message.message_event_result import (
10
13
  MessageChain,
11
14
  MessageEventResult,
12
15
  ResultContentType,
13
16
  )
14
17
  from astrbot.core.provider.entities import LLMResponse
18
+ from astrbot.core.provider.provider import TTSProvider
15
19
 
16
20
  AgentRunner = ToolLoopAgentRunner[AstrAgentContext]
17
21
 
@@ -131,3 +135,241 @@ async def run_agent(
131
135
  else:
132
136
  astr_event.set_result(MessageEventResult().message(err_msg))
133
137
  return
138
+
139
+
140
+ async def run_live_agent(
141
+ agent_runner: AgentRunner,
142
+ tts_provider: TTSProvider | None = None,
143
+ max_step: int = 30,
144
+ show_tool_use: bool = True,
145
+ show_reasoning: bool = False,
146
+ ) -> AsyncGenerator[MessageChain | None, None]:
147
+ """Live Mode 的 Agent 运行器,支持流式 TTS
148
+
149
+ Args:
150
+ agent_runner: Agent 运行器
151
+ tts_provider: TTS Provider 实例
152
+ max_step: 最大步数
153
+ show_tool_use: 是否显示工具使用
154
+ show_reasoning: 是否显示推理过程
155
+
156
+ Yields:
157
+ MessageChain: 包含文本或音频数据的消息链
158
+ """
159
+ # 如果没有 TTS Provider,直接发送文本
160
+ if not tts_provider:
161
+ async for chain in run_agent(
162
+ agent_runner,
163
+ max_step=max_step,
164
+ show_tool_use=show_tool_use,
165
+ stream_to_general=False,
166
+ show_reasoning=show_reasoning,
167
+ ):
168
+ yield chain
169
+ return
170
+
171
+ support_stream = tts_provider.support_stream()
172
+ if support_stream:
173
+ logger.info("[Live Agent] 使用流式 TTS(原生支持 get_audio_stream)")
174
+ else:
175
+ logger.info(
176
+ f"[Live Agent] 使用 TTS({tts_provider.meta().type} "
177
+ "使用 get_audio,将按句子分块生成音频)"
178
+ )
179
+
180
+ # 统计数据初始化
181
+ tts_start_time = time.time()
182
+ tts_first_frame_time = 0.0
183
+ first_chunk_received = False
184
+
185
+ # 创建队列
186
+ text_queue: asyncio.Queue[str | None] = asyncio.Queue()
187
+ # audio_queue stored bytes or (text, bytes)
188
+ audio_queue: asyncio.Queue[bytes | tuple[str, bytes] | None] = asyncio.Queue()
189
+
190
+ # 1. 启动 Agent Feeder 任务:负责运行 Agent 并将文本分句喂给 text_queue
191
+ feeder_task = asyncio.create_task(
192
+ _run_agent_feeder(
193
+ agent_runner, text_queue, max_step, show_tool_use, show_reasoning
194
+ )
195
+ )
196
+
197
+ # 2. 启动 TTS 任务:负责从 text_queue 读取文本并生成音频到 audio_queue
198
+ if support_stream:
199
+ tts_task = asyncio.create_task(
200
+ _safe_tts_stream_wrapper(tts_provider, text_queue, audio_queue)
201
+ )
202
+ else:
203
+ tts_task = asyncio.create_task(
204
+ _simulated_stream_tts(tts_provider, text_queue, audio_queue)
205
+ )
206
+
207
+ # 3. 主循环:从 audio_queue 读取音频并 yield
208
+ try:
209
+ while True:
210
+ queue_item = await audio_queue.get()
211
+
212
+ if queue_item is None:
213
+ break
214
+
215
+ text = None
216
+ if isinstance(queue_item, tuple):
217
+ text, audio_data = queue_item
218
+ else:
219
+ audio_data = queue_item
220
+
221
+ if not first_chunk_received:
222
+ # 记录首帧延迟(从开始处理到收到第一个音频块)
223
+ tts_first_frame_time = time.time() - tts_start_time
224
+ first_chunk_received = True
225
+
226
+ # 将音频数据封装为 MessageChain
227
+ import base64
228
+
229
+ audio_b64 = base64.b64encode(audio_data).decode("utf-8")
230
+ comps: list[BaseMessageComponent] = [Plain(audio_b64)]
231
+ if text:
232
+ comps.append(Json(data={"text": text}))
233
+ chain = MessageChain(chain=comps, type="audio_chunk")
234
+ yield chain
235
+
236
+ except Exception as e:
237
+ logger.error(f"[Live Agent] 运行时发生错误: {e}", exc_info=True)
238
+ finally:
239
+ # 清理任务
240
+ if not feeder_task.done():
241
+ feeder_task.cancel()
242
+ if not tts_task.done():
243
+ tts_task.cancel()
244
+
245
+ # 确保队列被消费
246
+ pass
247
+
248
+ tts_end_time = time.time()
249
+
250
+ # 发送 TTS 统计信息
251
+ try:
252
+ astr_event = agent_runner.run_context.context.event
253
+ if astr_event.get_platform_name() == "webchat":
254
+ tts_duration = tts_end_time - tts_start_time
255
+ await astr_event.send(
256
+ MessageChain(
257
+ type="tts_stats",
258
+ chain=[
259
+ Json(
260
+ data={
261
+ "tts_total_time": tts_duration,
262
+ "tts_first_frame_time": tts_first_frame_time,
263
+ "tts": tts_provider.meta().type,
264
+ "chat_model": agent_runner.provider.get_model(),
265
+ }
266
+ )
267
+ ],
268
+ )
269
+ )
270
+ except Exception as e:
271
+ logger.error(f"发送 TTS 统计信息失败: {e}")
272
+
273
+
274
+ async def _run_agent_feeder(
275
+ agent_runner: AgentRunner,
276
+ text_queue: asyncio.Queue,
277
+ max_step: int,
278
+ show_tool_use: bool,
279
+ show_reasoning: bool,
280
+ ):
281
+ """运行 Agent 并将文本输出分句放入队列"""
282
+ buffer = ""
283
+ try:
284
+ async for chain in run_agent(
285
+ agent_runner,
286
+ max_step=max_step,
287
+ show_tool_use=show_tool_use,
288
+ stream_to_general=False,
289
+ show_reasoning=show_reasoning,
290
+ ):
291
+ if chain is None:
292
+ continue
293
+
294
+ # 提取文本
295
+ text = chain.get_plain_text()
296
+ if text:
297
+ buffer += text
298
+
299
+ # 分句逻辑:匹配标点符号
300
+ # r"([.。!!??\n]+)" 会保留分隔符
301
+ parts = re.split(r"([.。!!??\n]+)", buffer)
302
+
303
+ if len(parts) > 1:
304
+ # 处理完整的句子
305
+ # range step 2 因为 split 后是 [text, delim, text, delim, ...]
306
+ temp_buffer = ""
307
+ for i in range(0, len(parts) - 1, 2):
308
+ sentence = parts[i]
309
+ delim = parts[i + 1]
310
+ full_sentence = sentence + delim
311
+ temp_buffer += full_sentence
312
+
313
+ if len(temp_buffer) >= 10:
314
+ if temp_buffer.strip():
315
+ logger.info(f"[Live Agent Feeder] 分句: {temp_buffer}")
316
+ await text_queue.put(temp_buffer)
317
+ temp_buffer = ""
318
+
319
+ # 更新 buffer 为剩余部分
320
+ buffer = temp_buffer + parts[-1]
321
+
322
+ # 处理剩余 buffer
323
+ if buffer.strip():
324
+ await text_queue.put(buffer)
325
+
326
+ except Exception as e:
327
+ logger.error(f"[Live Agent Feeder] Error: {e}", exc_info=True)
328
+ finally:
329
+ # 发送结束信号
330
+ await text_queue.put(None)
331
+
332
+
333
+ async def _safe_tts_stream_wrapper(
334
+ tts_provider: TTSProvider,
335
+ text_queue: asyncio.Queue[str | None],
336
+ audio_queue: "asyncio.Queue[bytes | tuple[str, bytes] | None]",
337
+ ):
338
+ """包装原生流式 TTS 确保异常处理和队列关闭"""
339
+ try:
340
+ await tts_provider.get_audio_stream(text_queue, audio_queue)
341
+ except Exception as e:
342
+ logger.error(f"[Live TTS Stream] Error: {e}", exc_info=True)
343
+ finally:
344
+ await audio_queue.put(None)
345
+
346
+
347
+ async def _simulated_stream_tts(
348
+ tts_provider: TTSProvider,
349
+ text_queue: asyncio.Queue[str | None],
350
+ audio_queue: "asyncio.Queue[bytes | tuple[str, bytes] | None]",
351
+ ):
352
+ """模拟流式 TTS 分句生成音频"""
353
+ try:
354
+ while True:
355
+ text = await text_queue.get()
356
+ if text is None:
357
+ break
358
+
359
+ try:
360
+ audio_path = await tts_provider.get_audio(text)
361
+
362
+ if audio_path:
363
+ with open(audio_path, "rb") as f:
364
+ audio_data = f.read()
365
+ await audio_queue.put((text, audio_data))
366
+ except Exception as e:
367
+ logger.error(
368
+ f"[Live TTS Simulated] Error processing text '{text[:20]}...': {e}"
369
+ )
370
+ # 继续处理下一句
371
+
372
+ except Exception as e:
373
+ logger.error(f"[Live TTS Simulated] Critical Error: {e}", exc_info=True)
374
+ finally:
375
+ await audio_queue.put(None)
@@ -256,7 +256,7 @@ async def call_local_llm_tool(
256
256
  # 这里逐步执行异步生成器, 对于每个 yield 返回的 ret, 执行下面的代码
257
257
  # 返回值只能是 MessageEventResult 或者 None(无返回值)
258
258
  _has_yielded = True
259
- if isinstance(ret, (MessageEventResult, CommandResult)):
259
+ if isinstance(ret, MessageEventResult | CommandResult):
260
260
  # 如果返回值是 MessageEventResult, 设置结果并继续
261
261
  event.set_result(ret)
262
262
  yield
@@ -273,7 +273,7 @@ async def call_local_llm_tool(
273
273
  elif inspect.iscoroutine(ready_to_call):
274
274
  # 如果只是一个协程, 直接执行
275
275
  ret = await ready_to_call
276
- if isinstance(ret, (MessageEventResult, CommandResult)):
276
+ if isinstance(ret, MessageEventResult | CommandResult):
277
277
  event.set_result(ret)
278
278
  yield
279
279
  else:
@@ -1,7 +1,7 @@
1
1
  from ..olayer import FileSystemComponent, PythonComponent, ShellComponent
2
2
 
3
3
 
4
- class SandboxBooter:
4
+ class ComputerBooter:
5
5
  @property
6
6
  def fs(self) -> FileSystemComponent: ...
7
7
 
@@ -16,16 +16,16 @@ class SandboxBooter:
16
16
  async def shutdown(self) -> None: ...
17
17
 
18
18
  async def upload_file(self, path: str, file_name: str) -> dict:
19
- """Upload file to sandbox.
19
+ """Upload file to the computer.
20
20
 
21
21
  Should return a dict with `success` (bool) and `file_path` (str) keys.
22
22
  """
23
23
  ...
24
24
 
25
25
  async def download_file(self, remote_path: str, local_path: str):
26
- """Download file from sandbox."""
26
+ """Download file from the computer."""
27
27
  ...
28
28
 
29
29
  async def available(self) -> bool:
30
- """Check if the sandbox is available."""
30
+ """Check if the computer is available."""
31
31
  ...
@@ -11,7 +11,7 @@ from shipyard.shell import ShellComponent as ShipyardShellComponent
11
11
  from astrbot.api import logger
12
12
 
13
13
  from ..olayer import FileSystemComponent, PythonComponent, ShellComponent
14
- from .base import SandboxBooter
14
+ from .base import ComputerBooter
15
15
 
16
16
 
17
17
  class MockShipyardSandboxClient:
@@ -124,7 +124,7 @@ class MockShipyardSandboxClient:
124
124
  loop -= 1
125
125
 
126
126
 
127
- class BoxliteBooter(SandboxBooter):
127
+ class BoxliteBooter(ComputerBooter):
128
128
  async def boot(self, session_id: str) -> None:
129
129
  logger.info(
130
130
  f"Booting(Boxlite) for session: {session_id}, this may take a while..."
@@ -0,0 +1,234 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import os
5
+ import shutil
6
+ import subprocess
7
+ import sys
8
+ from dataclasses import dataclass
9
+ from typing import Any
10
+
11
+ from astrbot.api import logger
12
+ from astrbot.core.utils.astrbot_path import (
13
+ get_astrbot_data_path,
14
+ get_astrbot_root,
15
+ get_astrbot_temp_path,
16
+ )
17
+
18
+ from ..olayer import FileSystemComponent, PythonComponent, ShellComponent
19
+ from .base import ComputerBooter
20
+
21
+ _BLOCKED_COMMAND_PATTERNS = [
22
+ " rm -rf ",
23
+ " rm -fr ",
24
+ " rm -r ",
25
+ " mkfs",
26
+ " dd if=",
27
+ " shutdown",
28
+ " reboot",
29
+ " poweroff",
30
+ " halt",
31
+ " sudo ",
32
+ ":(){:|:&};:",
33
+ " kill -9 ",
34
+ " killall ",
35
+ ]
36
+
37
+
38
+ def _is_safe_command(command: str) -> bool:
39
+ cmd = f" {command.strip().lower()} "
40
+ return not any(pat in cmd for pat in _BLOCKED_COMMAND_PATTERNS)
41
+
42
+
43
+ def _ensure_safe_path(path: str) -> str:
44
+ abs_path = os.path.abspath(path)
45
+ allowed_roots = [
46
+ os.path.abspath(get_astrbot_root()),
47
+ os.path.abspath(get_astrbot_data_path()),
48
+ os.path.abspath(get_astrbot_temp_path()),
49
+ ]
50
+ if not any(abs_path.startswith(root) for root in allowed_roots):
51
+ raise PermissionError("Path is outside the allowed computer roots.")
52
+ return abs_path
53
+
54
+
55
+ @dataclass
56
+ class LocalShellComponent(ShellComponent):
57
+ async def exec(
58
+ self,
59
+ command: str,
60
+ cwd: str | None = None,
61
+ env: dict[str, str] | None = None,
62
+ timeout: int | None = 30,
63
+ shell: bool = True,
64
+ background: bool = False,
65
+ ) -> dict[str, Any]:
66
+ if not _is_safe_command(command):
67
+ raise PermissionError("Blocked unsafe shell command.")
68
+
69
+ def _run() -> dict[str, Any]:
70
+ run_env = os.environ.copy()
71
+ if env:
72
+ run_env.update({str(k): str(v) for k, v in env.items()})
73
+ working_dir = _ensure_safe_path(cwd) if cwd else get_astrbot_root()
74
+ if background:
75
+ proc = subprocess.Popen(
76
+ command,
77
+ shell=shell,
78
+ cwd=working_dir,
79
+ env=run_env,
80
+ stdout=subprocess.PIPE,
81
+ stderr=subprocess.PIPE,
82
+ text=True,
83
+ )
84
+ return {"pid": proc.pid, "stdout": "", "stderr": "", "exit_code": None}
85
+ result = subprocess.run(
86
+ command,
87
+ shell=shell,
88
+ cwd=working_dir,
89
+ env=run_env,
90
+ timeout=timeout,
91
+ capture_output=True,
92
+ text=True,
93
+ )
94
+ return {
95
+ "stdout": result.stdout,
96
+ "stderr": result.stderr,
97
+ "exit_code": result.returncode,
98
+ }
99
+
100
+ return await asyncio.to_thread(_run)
101
+
102
+
103
+ @dataclass
104
+ class LocalPythonComponent(PythonComponent):
105
+ async def exec(
106
+ self,
107
+ code: str,
108
+ kernel_id: str | None = None,
109
+ timeout: int = 30,
110
+ silent: bool = False,
111
+ ) -> dict[str, Any]:
112
+ def _run() -> dict[str, Any]:
113
+ try:
114
+ result = subprocess.run(
115
+ [os.environ.get("PYTHON", sys.executable), "-c", code],
116
+ timeout=timeout,
117
+ capture_output=True,
118
+ text=True,
119
+ )
120
+ stdout = "" if silent else result.stdout
121
+ stderr = result.stderr if result.returncode != 0 else ""
122
+ return {
123
+ "data": {
124
+ "output": {"text": stdout, "images": []},
125
+ "error": stderr,
126
+ }
127
+ }
128
+ except subprocess.TimeoutExpired:
129
+ return {
130
+ "data": {
131
+ "output": {"text": "", "images": []},
132
+ "error": "Execution timed out.",
133
+ }
134
+ }
135
+
136
+ return await asyncio.to_thread(_run)
137
+
138
+
139
+ @dataclass
140
+ class LocalFileSystemComponent(FileSystemComponent):
141
+ async def create_file(
142
+ self, path: str, content: str = "", mode: int = 0o644
143
+ ) -> dict[str, Any]:
144
+ def _run() -> dict[str, Any]:
145
+ abs_path = _ensure_safe_path(path)
146
+ os.makedirs(os.path.dirname(abs_path), exist_ok=True)
147
+ with open(abs_path, "w", encoding="utf-8") as f:
148
+ f.write(content)
149
+ os.chmod(abs_path, mode)
150
+ return {"success": True, "path": abs_path}
151
+
152
+ return await asyncio.to_thread(_run)
153
+
154
+ async def read_file(self, path: str, encoding: str = "utf-8") -> dict[str, Any]:
155
+ def _run() -> dict[str, Any]:
156
+ abs_path = _ensure_safe_path(path)
157
+ with open(abs_path, encoding=encoding) as f:
158
+ content = f.read()
159
+ return {"success": True, "content": content}
160
+
161
+ return await asyncio.to_thread(_run)
162
+
163
+ async def write_file(
164
+ self, path: str, content: str, mode: str = "w", encoding: str = "utf-8"
165
+ ) -> dict[str, Any]:
166
+ def _run() -> dict[str, Any]:
167
+ abs_path = _ensure_safe_path(path)
168
+ os.makedirs(os.path.dirname(abs_path), exist_ok=True)
169
+ with open(abs_path, mode, encoding=encoding) as f:
170
+ f.write(content)
171
+ return {"success": True, "path": abs_path}
172
+
173
+ return await asyncio.to_thread(_run)
174
+
175
+ async def delete_file(self, path: str) -> dict[str, Any]:
176
+ def _run() -> dict[str, Any]:
177
+ abs_path = _ensure_safe_path(path)
178
+ if os.path.isdir(abs_path):
179
+ shutil.rmtree(abs_path)
180
+ else:
181
+ os.remove(abs_path)
182
+ return {"success": True, "path": abs_path}
183
+
184
+ return await asyncio.to_thread(_run)
185
+
186
+ async def list_dir(
187
+ self, path: str = ".", show_hidden: bool = False
188
+ ) -> dict[str, Any]:
189
+ def _run() -> dict[str, Any]:
190
+ abs_path = _ensure_safe_path(path)
191
+ entries = os.listdir(abs_path)
192
+ if not show_hidden:
193
+ entries = [e for e in entries if not e.startswith(".")]
194
+ return {"success": True, "entries": entries}
195
+
196
+ return await asyncio.to_thread(_run)
197
+
198
+
199
+ class LocalBooter(ComputerBooter):
200
+ def __init__(self) -> None:
201
+ self._fs = LocalFileSystemComponent()
202
+ self._python = LocalPythonComponent()
203
+ self._shell = LocalShellComponent()
204
+
205
+ async def boot(self, session_id: str) -> None:
206
+ logger.info(f"Local computer booter initialized for session: {session_id}")
207
+
208
+ async def shutdown(self) -> None:
209
+ logger.info("Local computer booter shutdown complete.")
210
+
211
+ @property
212
+ def fs(self) -> FileSystemComponent:
213
+ return self._fs
214
+
215
+ @property
216
+ def python(self) -> PythonComponent:
217
+ return self._python
218
+
219
+ @property
220
+ def shell(self) -> ShellComponent:
221
+ return self._shell
222
+
223
+ async def upload_file(self, path: str, file_name: str) -> dict:
224
+ raise NotImplementedError(
225
+ "LocalBooter does not support upload_file operation. Use shell instead."
226
+ )
227
+
228
+ async def download_file(self, remote_path: str, local_path: str):
229
+ raise NotImplementedError(
230
+ "LocalBooter does not support download_file operation. Use shell instead."
231
+ )
232
+
233
+ async def available(self) -> bool:
234
+ return True
@@ -3,10 +3,10 @@ from shipyard import ShipyardClient, Spec
3
3
  from astrbot.api import logger
4
4
 
5
5
  from ..olayer import FileSystemComponent, PythonComponent, ShellComponent
6
- from .base import SandboxBooter
6
+ from .base import ComputerBooter
7
7
 
8
8
 
9
- class ShipyardBooter(SandboxBooter):
9
+ class ShipyardBooter(ComputerBooter):
10
10
  def __init__(
11
11
  self,
12
12
  endpoint_url: str,
@@ -0,0 +1,102 @@
1
+ import os
2
+ import shutil
3
+ import uuid
4
+ from pathlib import Path
5
+
6
+ from astrbot.api import logger
7
+ from astrbot.core.skills.skill_manager import SANDBOX_SKILLS_ROOT
8
+ from astrbot.core.star.context import Context
9
+ from astrbot.core.utils.astrbot_path import (
10
+ get_astrbot_skills_path,
11
+ get_astrbot_temp_path,
12
+ )
13
+
14
+ from .booters.base import ComputerBooter
15
+ from .booters.local import LocalBooter
16
+
17
+ session_booter: dict[str, ComputerBooter] = {}
18
+ local_booter: ComputerBooter | None = None
19
+
20
+
21
+ async def _sync_skills_to_sandbox(booter: ComputerBooter) -> None:
22
+ skills_root = get_astrbot_skills_path()
23
+ if not os.path.isdir(skills_root):
24
+ return
25
+ if not any(Path(skills_root).iterdir()):
26
+ return
27
+
28
+ temp_dir = get_astrbot_temp_path()
29
+ os.makedirs(temp_dir, exist_ok=True)
30
+ zip_base = os.path.join(temp_dir, "skills_bundle")
31
+ zip_path = f"{zip_base}.zip"
32
+
33
+ try:
34
+ if os.path.exists(zip_path):
35
+ os.remove(zip_path)
36
+ shutil.make_archive(zip_base, "zip", skills_root)
37
+ remote_zip = Path(SANDBOX_SKILLS_ROOT) / "skills.zip"
38
+ await booter.shell.exec(f"mkdir -p {SANDBOX_SKILLS_ROOT}")
39
+ upload_result = await booter.upload_file(zip_path, str(remote_zip))
40
+ if not upload_result.get("success", False):
41
+ raise RuntimeError("Failed to upload skills bundle to sandbox.")
42
+ await booter.shell.exec(
43
+ f"unzip -o {remote_zip} -d {SANDBOX_SKILLS_ROOT} && rm -f {remote_zip}"
44
+ )
45
+ finally:
46
+ if os.path.exists(zip_path):
47
+ try:
48
+ os.remove(zip_path)
49
+ except Exception:
50
+ logger.warning(f"Failed to remove temp skills zip: {zip_path}")
51
+
52
+
53
+ async def get_booter(
54
+ context: Context,
55
+ session_id: str,
56
+ ) -> ComputerBooter:
57
+ config = context.get_config(umo=session_id)
58
+
59
+ sandbox_cfg = config.get("provider_settings", {}).get("sandbox", {})
60
+ booter_type = sandbox_cfg.get("booter", "shipyard")
61
+
62
+ if session_id in session_booter:
63
+ booter = session_booter[session_id]
64
+ if not await booter.available():
65
+ # rebuild
66
+ session_booter.pop(session_id, None)
67
+ if session_id not in session_booter:
68
+ uuid_str = uuid.uuid5(uuid.NAMESPACE_DNS, session_id).hex
69
+ if booter_type == "shipyard":
70
+ from .booters.shipyard import ShipyardBooter
71
+
72
+ ep = sandbox_cfg.get("shipyard_endpoint", "")
73
+ token = sandbox_cfg.get("shipyard_access_token", "")
74
+ ttl = sandbox_cfg.get("shipyard_ttl", 3600)
75
+ max_sessions = sandbox_cfg.get("shipyard_max_sessions", 10)
76
+
77
+ client = ShipyardBooter(
78
+ endpoint_url=ep, access_token=token, ttl=ttl, session_num=max_sessions
79
+ )
80
+ elif booter_type == "boxlite":
81
+ from .booters.boxlite import BoxliteBooter
82
+
83
+ client = BoxliteBooter()
84
+ else:
85
+ raise ValueError(f"Unknown booter type: {booter_type}")
86
+
87
+ try:
88
+ await client.boot(uuid_str)
89
+ await _sync_skills_to_sandbox(client)
90
+ except Exception as e:
91
+ logger.error(f"Error booting sandbox for session {session_id}: {e}")
92
+ raise e
93
+
94
+ session_booter[session_id] = client
95
+ return session_booter[session_id]
96
+
97
+
98
+ def get_local_booter() -> ComputerBooter:
99
+ global local_booter
100
+ if local_booter is None:
101
+ local_booter = LocalBooter()
102
+ return local_booter
@@ -1,10 +1,11 @@
1
1
  from .fs import FileDownloadTool, FileUploadTool
2
- from .python import PythonTool
2
+ from .python import LocalPythonTool, PythonTool
3
3
  from .shell import ExecuteShellTool
4
4
 
5
5
  __all__ = [
6
6
  "FileUploadTool",
7
7
  "PythonTool",
8
+ "LocalPythonTool",
8
9
  "ExecuteShellTool",
9
10
  "FileDownloadTool",
10
11
  ]