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.
- astrbot/builtin_stars/astrbot/process_llm_request.py +42 -1
- astrbot/builtin_stars/builtin_commands/commands/__init__.py +0 -2
- astrbot/builtin_stars/builtin_commands/commands/persona.py +68 -6
- astrbot/builtin_stars/builtin_commands/main.py +0 -26
- astrbot/cli/__init__.py +1 -1
- astrbot/core/agent/runners/tool_loop_agent_runner.py +91 -1
- astrbot/core/agent/tool.py +61 -20
- astrbot/core/astr_agent_hooks.py +3 -1
- astrbot/core/astr_agent_run_util.py +243 -1
- astrbot/core/astr_agent_tool_exec.py +2 -2
- astrbot/core/{sandbox → computer}/booters/base.py +4 -4
- astrbot/core/{sandbox → computer}/booters/boxlite.py +2 -2
- astrbot/core/computer/booters/local.py +234 -0
- astrbot/core/{sandbox → computer}/booters/shipyard.py +2 -2
- astrbot/core/computer/computer_client.py +102 -0
- astrbot/core/{sandbox → computer}/tools/__init__.py +2 -1
- astrbot/core/{sandbox → computer}/tools/fs.py +1 -1
- astrbot/core/computer/tools/python.py +94 -0
- astrbot/core/{sandbox → computer}/tools/shell.py +13 -5
- astrbot/core/config/default.py +90 -9
- astrbot/core/db/__init__.py +94 -1
- astrbot/core/db/po.py +46 -0
- astrbot/core/db/sqlite.py +248 -0
- astrbot/core/message/components.py +2 -2
- astrbot/core/persona_mgr.py +162 -2
- astrbot/core/pipeline/context_utils.py +2 -2
- astrbot/core/pipeline/preprocess_stage/stage.py +1 -1
- astrbot/core/pipeline/process_stage/method/agent_sub_stages/internal.py +73 -6
- astrbot/core/pipeline/process_stage/utils.py +31 -4
- astrbot/core/pipeline/scheduler.py +1 -1
- astrbot/core/pipeline/waking_check/stage.py +0 -1
- astrbot/core/platform/sources/aiocqhttp/aiocqhttp_message_event.py +3 -3
- astrbot/core/platform/sources/aiocqhttp/aiocqhttp_platform_adapter.py +32 -14
- astrbot/core/platform/sources/dingtalk/dingtalk_adapter.py +61 -2
- astrbot/core/platform/sources/dingtalk/dingtalk_event.py +57 -11
- astrbot/core/platform/sources/qqofficial/qqofficial_message_event.py +5 -7
- astrbot/core/platform/sources/webchat/webchat_adapter.py +1 -0
- astrbot/core/platform/sources/webchat/webchat_event.py +24 -0
- astrbot/core/provider/manager.py +38 -0
- astrbot/core/provider/provider.py +54 -0
- astrbot/core/provider/sources/gemini_embedding_source.py +1 -1
- astrbot/core/provider/sources/gemini_source.py +12 -9
- astrbot/core/provider/sources/genie_tts.py +128 -0
- astrbot/core/provider/sources/openai_embedding_source.py +1 -1
- astrbot/core/skills/__init__.py +3 -0
- astrbot/core/skills/skill_manager.py +237 -0
- astrbot/core/star/command_management.py +1 -1
- astrbot/core/star/config.py +1 -1
- astrbot/core/star/context.py +9 -8
- astrbot/core/star/filter/command.py +1 -1
- astrbot/core/star/filter/custom_filter.py +2 -2
- astrbot/core/star/register/star_handler.py +2 -4
- astrbot/core/utils/astrbot_path.py +6 -0
- astrbot/dashboard/routes/__init__.py +2 -0
- astrbot/dashboard/routes/config.py +236 -2
- astrbot/dashboard/routes/live_chat.py +423 -0
- astrbot/dashboard/routes/persona.py +265 -1
- astrbot/dashboard/routes/skills.py +148 -0
- astrbot/dashboard/routes/util.py +102 -0
- astrbot/dashboard/server.py +21 -5
- {astrbot-4.12.3.dist-info → astrbot-4.13.0.dist-info}/METADATA +1 -1
- {astrbot-4.12.3.dist-info → astrbot-4.13.0.dist-info}/RECORD +69 -63
- astrbot/builtin_stars/builtin_commands/commands/tool.py +0 -31
- astrbot/core/sandbox/sandbox_client.py +0 -52
- astrbot/core/sandbox/tools/python.py +0 -74
- /astrbot/core/{sandbox → computer}/olayer/__init__.py +0 -0
- /astrbot/core/{sandbox → computer}/olayer/filesystem.py +0 -0
- /astrbot/core/{sandbox → computer}/olayer/python.py +0 -0
- /astrbot/core/{sandbox → computer}/olayer/shell.py +0 -0
- {astrbot-4.12.3.dist-info → astrbot-4.13.0.dist-info}/WHEEL +0 -0
- {astrbot-4.12.3.dist-info → astrbot-4.13.0.dist-info}/entry_points.txt +0 -0
- {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,
|
|
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,
|
|
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
|
|
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
|
|
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
|
|
26
|
+
"""Download file from the computer."""
|
|
27
27
|
...
|
|
28
28
|
|
|
29
29
|
async def available(self) -> bool:
|
|
30
|
-
"""Check if the
|
|
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
|
|
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(
|
|
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
|
|
6
|
+
from .base import ComputerBooter
|
|
7
7
|
|
|
8
8
|
|
|
9
|
-
class ShipyardBooter(
|
|
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
|
]
|