Compare commits
37 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 71d4357ca7 | |||
| 97081bf543 | |||
| 9c9239073e | |||
| c78ac6acd7 | |||
| ac427af3c8 | |||
| b7160c9c58 | |||
| 86715813ad | |||
| a363a2ddcd | |||
| f2af8e58e2 | |||
| 937f0b7f32 | |||
| c52ab1346b | |||
| c8fca4e6a0 | |||
| 45397e941d | |||
| ce0a024757 | |||
| 792e348076 | |||
| 068094708e | |||
| 661bcfd890 | |||
| 0a58eaecdd | |||
| d564926e6b | |||
| 7d1709667e | |||
| ebdecf8dce | |||
| 3698b771dd | |||
| 12b4ee0a2b | |||
| 9de0fe304c | |||
| e5cac2684f | |||
| 1646547cb4 | |||
| dca88d8ab8 | |||
| 2e2da4b4ce | |||
| 6df966e9a2 | |||
| a89e7b3f55 | |||
| ea7c387fcb | |||
| 350c18b741 | |||
| fdbed75ce4 | |||
| 9fec29c1a3 | |||
| 972b5ffb86 | |||
| 33e67bf925 | |||
| 185501d1b5 |
@@ -1,536 +0,0 @@
|
|||||||
import asyncio
|
|
||||||
import json
|
|
||||||
import os
|
|
||||||
import re
|
|
||||||
import shutil
|
|
||||||
import time
|
|
||||||
import uuid
|
|
||||||
from collections import defaultdict
|
|
||||||
|
|
||||||
import aiodocker
|
|
||||||
import aiohttp
|
|
||||||
|
|
||||||
from astrbot.api import llm_tool, logger, star
|
|
||||||
from astrbot.api.event import AstrMessageEvent, MessageEventResult, filter
|
|
||||||
from astrbot.api.message_components import File, Image
|
|
||||||
from astrbot.api.provider import ProviderRequest
|
|
||||||
from astrbot.core.message.components import BaseMessageComponent
|
|
||||||
from astrbot.core.utils.astrbot_path import get_astrbot_data_path
|
|
||||||
from astrbot.core.utils.io import download_file, download_image_by_url
|
|
||||||
|
|
||||||
PROMPT = """
|
|
||||||
## Task
|
|
||||||
You need to generate python codes to solve user's problem: {prompt}
|
|
||||||
|
|
||||||
{extra_input}
|
|
||||||
|
|
||||||
## Limit
|
|
||||||
1. Available libraries:
|
|
||||||
- standard libs
|
|
||||||
- `Pillow`
|
|
||||||
- `requests`
|
|
||||||
- `numpy`
|
|
||||||
- `matplotlib`
|
|
||||||
- `scipy`
|
|
||||||
- `scikit-learn`
|
|
||||||
- `beautifulsoup4`
|
|
||||||
- `pandas`
|
|
||||||
- `opencv-python`
|
|
||||||
- `python-docx`
|
|
||||||
- `python-pptx`
|
|
||||||
- `pymupdf` (Do not use fpdf, reportlab, etc.)
|
|
||||||
- `mplfonts`
|
|
||||||
You can only use these libraries and the libraries that they depend on.
|
|
||||||
2. Do not generate malicious code.
|
|
||||||
3. Use given `shared.api` package to output the result.
|
|
||||||
It has 3 functions: `send_text(text: str)`, `send_image(image_path: str)`, `send_file(file_path: str)`.
|
|
||||||
For Image and file, you must save it to `output` folder.
|
|
||||||
4. You must only output the code, do not output the result of the code and any other information.
|
|
||||||
5. The output language is same as user's input language.
|
|
||||||
6. Please first provide relevant knowledge about user's problem appropriately.
|
|
||||||
|
|
||||||
## Example
|
|
||||||
1. User's problem: `please solve the fabonacci sequence problem.`
|
|
||||||
Output:
|
|
||||||
```python
|
|
||||||
from shared.api import send_text, send_image, send_file
|
|
||||||
|
|
||||||
def fabonacci(n):
|
|
||||||
if n <= 1:
|
|
||||||
return n
|
|
||||||
else:
|
|
||||||
return fabonacci(n-1) + fabonacci(n-2)
|
|
||||||
|
|
||||||
result = fabonacci(10)
|
|
||||||
send_text("The fabonacci sequence is a series of numbers in which each number is the sum of the two preceding ones, starting from 0 and 1.")
|
|
||||||
send_text("Let's calculate the fabonacci sequence of 10: " + result) # send_text is a function to send pure text to user
|
|
||||||
```
|
|
||||||
|
|
||||||
2. User's problem: `please draw a sin(x) function.`
|
|
||||||
Output:
|
|
||||||
```python
|
|
||||||
from shared.api import send_text, send_image, send_file
|
|
||||||
import numpy as np
|
|
||||||
import matplotlib.pyplot as plt
|
|
||||||
|
|
||||||
x = np.linspace(0, 2*np.pi, 100)
|
|
||||||
y = np.sin(x)
|
|
||||||
plt.plot(x, y)
|
|
||||||
plt.savefig("output/sin_x.png")
|
|
||||||
send_text("The sin(x) is a periodic function with a period of 2π, and the value range is [-1, 1]. The following is the image of sin(x).")
|
|
||||||
send_image("output/sin_x.png") # send_image is a function to send image to user
|
|
||||||
send_text("If you need more information, please let me know :)")
|
|
||||||
```
|
|
||||||
|
|
||||||
{extra_prompt}
|
|
||||||
"""
|
|
||||||
|
|
||||||
DEFAULT_CONFIG = {
|
|
||||||
"sandbox": {
|
|
||||||
"image": "soulter/astrbot-code-interpreter-sandbox",
|
|
||||||
"docker_mirror": "", # cjie.eu.org
|
|
||||||
},
|
|
||||||
"docker_host_astrbot_abs_path": "",
|
|
||||||
}
|
|
||||||
PATH = os.path.join(get_astrbot_data_path(), "config", "python_interpreter.json")
|
|
||||||
|
|
||||||
|
|
||||||
class Main(star.Star):
|
|
||||||
"""基于 Docker 沙箱的 Python 代码执行器"""
|
|
||||||
|
|
||||||
def __init__(self, context: star.Context) -> None:
|
|
||||||
self.context = context
|
|
||||||
self.curr_dir = os.path.dirname(os.path.abspath(__file__))
|
|
||||||
|
|
||||||
self.shared_path = os.path.join("data", "py_interpreter_shared")
|
|
||||||
if not os.path.exists(self.shared_path):
|
|
||||||
# 复制 api.py 到 shared 目录
|
|
||||||
os.makedirs(self.shared_path, exist_ok=True)
|
|
||||||
shared_api_file = os.path.join(self.curr_dir, "shared", "api.py")
|
|
||||||
shutil.copy(shared_api_file, self.shared_path)
|
|
||||||
self.workplace_path = os.path.join("data", "py_interpreter_workplace")
|
|
||||||
os.makedirs(self.workplace_path, exist_ok=True)
|
|
||||||
|
|
||||||
self.user_file_msg_buffer = defaultdict(list)
|
|
||||||
"""存放用户上传的文件和图片"""
|
|
||||||
self.user_waiting = {}
|
|
||||||
"""正在等待用户的文件或图片"""
|
|
||||||
|
|
||||||
# 加载配置
|
|
||||||
if not os.path.exists(PATH):
|
|
||||||
self.config = DEFAULT_CONFIG
|
|
||||||
self._save_config()
|
|
||||||
else:
|
|
||||||
with open(PATH) as f:
|
|
||||||
self.config = json.load(f)
|
|
||||||
|
|
||||||
async def initialize(self):
|
|
||||||
ok = await self.is_docker_available()
|
|
||||||
if not ok:
|
|
||||||
logger.info(
|
|
||||||
"Docker 不可用,代码解释器将无法使用,astrbot-python-interpreter 将自动禁用。",
|
|
||||||
)
|
|
||||||
# await self.context._star_manager.turn_off_plugin(
|
|
||||||
# "astrbot-python-interpreter"
|
|
||||||
# )
|
|
||||||
|
|
||||||
async def file_upload(self, file_path: str):
|
|
||||||
"""上传图像文件到 S3"""
|
|
||||||
ext = os.path.splitext(file_path)[1]
|
|
||||||
S3_URL = "https://s3.neko.soulter.top/astrbot-s3"
|
|
||||||
with open(file_path, "rb") as f:
|
|
||||||
file = f.read()
|
|
||||||
|
|
||||||
s3_file_url = f"{S3_URL}/{uuid.uuid4().hex}{ext}"
|
|
||||||
|
|
||||||
async with (
|
|
||||||
aiohttp.ClientSession(
|
|
||||||
headers={"Accept": "application/json"},
|
|
||||||
trust_env=True,
|
|
||||||
) as session,
|
|
||||||
session.put(s3_file_url, data=file) as resp,
|
|
||||||
):
|
|
||||||
if resp.status != 200:
|
|
||||||
raise Exception(f"Failed to upload image: {resp.status}")
|
|
||||||
return s3_file_url
|
|
||||||
|
|
||||||
async def is_docker_available(self) -> bool:
|
|
||||||
"""Check if docker is available"""
|
|
||||||
try:
|
|
||||||
async with aiodocker.Docker() as docker:
|
|
||||||
await docker.version()
|
|
||||||
return True
|
|
||||||
except BaseException as e:
|
|
||||||
logger.info(f"检查 Docker 可用性: {e}")
|
|
||||||
return False
|
|
||||||
|
|
||||||
async def get_image_name(self) -> str:
|
|
||||||
"""Get the image name"""
|
|
||||||
if self.config["sandbox"]["docker_mirror"]:
|
|
||||||
return f"{self.config['sandbox']['docker_mirror']}/{self.config['sandbox']['image']}"
|
|
||||||
return self.config["sandbox"]["image"]
|
|
||||||
|
|
||||||
def _save_config(self):
|
|
||||||
with open(PATH, "w") as f:
|
|
||||||
json.dump(self.config, f)
|
|
||||||
|
|
||||||
async def gen_magic_code(self) -> str:
|
|
||||||
return uuid.uuid4().hex[:8]
|
|
||||||
|
|
||||||
async def download_image(
|
|
||||||
self,
|
|
||||||
image_url: str,
|
|
||||||
workplace_path: str,
|
|
||||||
filename: str,
|
|
||||||
) -> str:
|
|
||||||
"""Download image from url to workplace_path"""
|
|
||||||
async with aiohttp.ClientSession(trust_env=True) as session:
|
|
||||||
async with session.get(image_url) as resp:
|
|
||||||
if resp.status != 200:
|
|
||||||
return ""
|
|
||||||
image_path = os.path.join(workplace_path, f"{filename}.jpg")
|
|
||||||
with open(image_path, "wb") as f:
|
|
||||||
f.write(await resp.read())
|
|
||||||
return f"{filename}.jpg"
|
|
||||||
|
|
||||||
async def tidy_code(self, code: str) -> str:
|
|
||||||
"""Tidy the code"""
|
|
||||||
pattern = r"```(?:py|python)?\n(.*?)\n```"
|
|
||||||
match = re.search(pattern, code, re.DOTALL)
|
|
||||||
if match is None:
|
|
||||||
raise ValueError("The code is not in the code block.")
|
|
||||||
return match.group(1)
|
|
||||||
|
|
||||||
@filter.event_message_type(filter.EventMessageType.ALL)
|
|
||||||
async def on_message(self, event: AstrMessageEvent):
|
|
||||||
"""处理消息"""
|
|
||||||
uid = event.get_sender_id()
|
|
||||||
if uid not in self.user_waiting:
|
|
||||||
return
|
|
||||||
for comp in event.message_obj.message:
|
|
||||||
if isinstance(comp, File):
|
|
||||||
file_path = await comp.get_file()
|
|
||||||
if file_path.startswith("http"):
|
|
||||||
name = comp.name if comp.name else uuid.uuid4().hex[:8]
|
|
||||||
temp_dir = os.path.join(get_astrbot_data_path(), "temp")
|
|
||||||
path = os.path.join(temp_dir, name)
|
|
||||||
await download_file(file_path, path)
|
|
||||||
else:
|
|
||||||
path = file_path
|
|
||||||
self.user_file_msg_buffer[event.get_session_id()].append(path)
|
|
||||||
logger.debug(f"User {uid} uploaded file: {path}")
|
|
||||||
yield event.plain_result(f"代码执行器: 文件已经上传: {path}")
|
|
||||||
if uid in self.user_waiting:
|
|
||||||
del self.user_waiting[uid]
|
|
||||||
elif isinstance(comp, Image):
|
|
||||||
image_url = comp.url if comp.url else comp.file
|
|
||||||
if image_url is None:
|
|
||||||
raise ValueError("Image URL is None")
|
|
||||||
if image_url.startswith("http"):
|
|
||||||
image_path = await download_image_by_url(image_url)
|
|
||||||
elif image_url.startswith("file:///"):
|
|
||||||
image_path = image_url.replace("file:///", "")
|
|
||||||
else:
|
|
||||||
image_path = image_url
|
|
||||||
self.user_file_msg_buffer[event.get_session_id()].append(image_path)
|
|
||||||
logger.debug(f"User {uid} uploaded image: {image_path}")
|
|
||||||
yield event.plain_result(f"代码执行器: 图片已经上传: {image_path}")
|
|
||||||
if uid in self.user_waiting:
|
|
||||||
del self.user_waiting[uid]
|
|
||||||
|
|
||||||
@filter.on_llm_request()
|
|
||||||
async def on_llm_req(self, event: AstrMessageEvent, request: ProviderRequest):
|
|
||||||
if event.get_session_id() in self.user_file_msg_buffer:
|
|
||||||
files = self.user_file_msg_buffer[event.get_session_id()]
|
|
||||||
if not request.prompt:
|
|
||||||
request.prompt = ""
|
|
||||||
request.prompt += f"\nUser provided files: {files}"
|
|
||||||
|
|
||||||
@filter.command_group("pi")
|
|
||||||
def pi(self):
|
|
||||||
"""代码执行器配置"""
|
|
||||||
|
|
||||||
@pi.command("absdir")
|
|
||||||
async def pi_absdir(self, event: AstrMessageEvent, path: str = ""):
|
|
||||||
"""设置 Docker 宿主机绝对路径"""
|
|
||||||
if not path:
|
|
||||||
yield event.plain_result(
|
|
||||||
f"当前 Docker 宿主机绝对路径: {self.config.get('docker_host_astrbot_abs_path', '')}",
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
self.config["docker_host_astrbot_abs_path"] = path
|
|
||||||
self._save_config()
|
|
||||||
yield event.plain_result(f"设置 Docker 宿主机绝对路径成功: {path}")
|
|
||||||
|
|
||||||
@pi.command("mirror")
|
|
||||||
async def pi_mirror(self, event: AstrMessageEvent, url: str = ""):
|
|
||||||
"""Docker 镜像地址"""
|
|
||||||
if not url:
|
|
||||||
yield event.plain_result(f"""当前 Docker 镜像地址: {self.config["sandbox"]["docker_mirror"]}。
|
|
||||||
使用 `pi mirror <url>` 来设置 Docker 镜像地址。
|
|
||||||
您所设置的 Docker 镜像地址将会自动加在 Docker 镜像名前。如: `soulter/astrbot-code-interpreter-sandbox` -> `cjie.eu.org/soulter/astrbot-code-interpreter-sandbox`。
|
|
||||||
""")
|
|
||||||
else:
|
|
||||||
self.config["sandbox"]["docker_mirror"] = url
|
|
||||||
self._save_config()
|
|
||||||
yield event.plain_result("设置 Docker 镜像地址成功。")
|
|
||||||
|
|
||||||
@pi.command("repull")
|
|
||||||
async def pi_repull(self, event: AstrMessageEvent):
|
|
||||||
"""重新拉取沙箱镜像"""
|
|
||||||
async with aiodocker.Docker() as docker:
|
|
||||||
image_name = await self.get_image_name()
|
|
||||||
try:
|
|
||||||
await docker.images.get(image_name)
|
|
||||||
await docker.images.delete(image_name, force=True)
|
|
||||||
except aiodocker.exceptions.DockerError:
|
|
||||||
pass
|
|
||||||
await docker.images.pull(image_name)
|
|
||||||
yield event.plain_result("重新拉取沙箱镜像成功。")
|
|
||||||
|
|
||||||
@pi.command("file")
|
|
||||||
async def pi_file(self, event: AstrMessageEvent):
|
|
||||||
"""在规定秒数(60s)内上传一个文件"""
|
|
||||||
uid = event.get_sender_id()
|
|
||||||
self.user_waiting[uid] = time.time()
|
|
||||||
tip = "文件"
|
|
||||||
yield event.plain_result(f"代码执行器: 请在 60s 内上传一个{tip}。")
|
|
||||||
await asyncio.sleep(60)
|
|
||||||
if uid in self.user_waiting:
|
|
||||||
yield event.plain_result(
|
|
||||||
f"代码执行器: {event.get_sender_name()}/{event.get_sender_id()} 未在规定时间内上传{tip}。",
|
|
||||||
)
|
|
||||||
self.user_waiting.pop(uid)
|
|
||||||
|
|
||||||
@pi.command("clear", alias=["clean"])
|
|
||||||
async def pi_file_clean(self, event: AstrMessageEvent):
|
|
||||||
"""清理用户上传的文件"""
|
|
||||||
uid = event.get_sender_id()
|
|
||||||
if uid in self.user_waiting:
|
|
||||||
self.user_waiting.pop(uid)
|
|
||||||
yield event.plain_result(
|
|
||||||
f"代码执行器: {event.get_sender_name()}/{event.get_sender_id()} 已清理。",
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
yield event.plain_result(
|
|
||||||
f"代码执行器: {event.get_sender_name()}/{event.get_sender_id()} 没有等待上传文件。",
|
|
||||||
)
|
|
||||||
|
|
||||||
@pi.command("list")
|
|
||||||
async def pi_file_list(self, event: AstrMessageEvent):
|
|
||||||
"""列出用户上传的文件"""
|
|
||||||
uid = event.get_sender_id()
|
|
||||||
if uid in self.user_file_msg_buffer:
|
|
||||||
files = self.user_file_msg_buffer[uid]
|
|
||||||
yield event.plain_result(
|
|
||||||
f"代码执行器: {event.get_sender_name()}/{event.get_sender_id()} 上传的文件: {files}",
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
yield event.plain_result(
|
|
||||||
f"代码执行器: {event.get_sender_name()}/{event.get_sender_id()} 没有上传文件。",
|
|
||||||
)
|
|
||||||
|
|
||||||
@llm_tool("python_interpreter")
|
|
||||||
async def python_interpreter(self, event: AstrMessageEvent):
|
|
||||||
"""Use this tool only if user really want to solve a complex problem and the problem can be solved very well by Python code.
|
|
||||||
For example, user can use this tool to solve math problems, edit image, docx, pptx, pdf, etc.
|
|
||||||
"""
|
|
||||||
if not await self.is_docker_available():
|
|
||||||
yield event.plain_result("Docker 在当前机器不可用,无法沙箱化执行代码。")
|
|
||||||
|
|
||||||
plain_text = event.message_str
|
|
||||||
|
|
||||||
# 创建必要的工作目录和幻术码
|
|
||||||
magic_code = await self.gen_magic_code()
|
|
||||||
workplace_path = os.path.join(self.workplace_path, magic_code)
|
|
||||||
output_path = os.path.join(workplace_path, "output")
|
|
||||||
os.makedirs(workplace_path, exist_ok=True)
|
|
||||||
os.makedirs(output_path, exist_ok=True)
|
|
||||||
|
|
||||||
files = []
|
|
||||||
# 文件
|
|
||||||
for file_path in self.user_file_msg_buffer[event.get_session_id()]:
|
|
||||||
if not file_path:
|
|
||||||
continue
|
|
||||||
elif not os.path.exists(file_path):
|
|
||||||
logger.warning(f"文件 {file_path} 不存在,已忽略。")
|
|
||||||
continue
|
|
||||||
# cp
|
|
||||||
file_name = os.path.basename(file_path)
|
|
||||||
shutil.copy(file_path, os.path.join(workplace_path, file_name))
|
|
||||||
files.append(file_name)
|
|
||||||
|
|
||||||
logger.debug(f"user query: {plain_text}, files: {files}")
|
|
||||||
|
|
||||||
# 整理额外输入
|
|
||||||
extra_inputs = ""
|
|
||||||
if files:
|
|
||||||
extra_inputs += f"User provided files: {files}\n"
|
|
||||||
|
|
||||||
obs = ""
|
|
||||||
n = 5
|
|
||||||
|
|
||||||
async with aiodocker.Docker() as docker:
|
|
||||||
for i in range(n):
|
|
||||||
if i > 0:
|
|
||||||
logger.info(f"Try {i + 1}/{n}")
|
|
||||||
|
|
||||||
PROMPT_ = PROMPT.format(
|
|
||||||
prompt=plain_text,
|
|
||||||
extra_input=extra_inputs,
|
|
||||||
extra_prompt=obs,
|
|
||||||
)
|
|
||||||
provider = self.context.get_using_provider()
|
|
||||||
llm_response = await provider.text_chat(
|
|
||||||
prompt=PROMPT_,
|
|
||||||
session_id=f"{event.session_id}_{magic_code}_{i!s}",
|
|
||||||
)
|
|
||||||
|
|
||||||
logger.debug(
|
|
||||||
"code interpreter llm gened code:" + llm_response.completion_text,
|
|
||||||
)
|
|
||||||
|
|
||||||
# 整理代码并保存
|
|
||||||
code_clean = await self.tidy_code(llm_response.completion_text)
|
|
||||||
with open(os.path.join(workplace_path, "exec.py"), "w") as f:
|
|
||||||
f.write(code_clean)
|
|
||||||
|
|
||||||
# 检查有没有image
|
|
||||||
image_name = await self.get_image_name()
|
|
||||||
try:
|
|
||||||
await docker.images.get(image_name)
|
|
||||||
except aiodocker.exceptions.DockerError:
|
|
||||||
# 拉取镜像
|
|
||||||
logger.info(f"未找到沙箱镜像,正在尝试拉取 {image_name}...")
|
|
||||||
await docker.images.pull(image_name)
|
|
||||||
|
|
||||||
yield event.plain_result(
|
|
||||||
f"使用沙箱执行代码中,请稍等...(尝试次数: {i + 1}/{n})",
|
|
||||||
)
|
|
||||||
|
|
||||||
self.docker_host_astrbot_abs_path = self.config.get(
|
|
||||||
"docker_host_astrbot_abs_path",
|
|
||||||
"",
|
|
||||||
)
|
|
||||||
if self.docker_host_astrbot_abs_path:
|
|
||||||
host_shared = os.path.join(
|
|
||||||
self.docker_host_astrbot_abs_path,
|
|
||||||
self.shared_path,
|
|
||||||
)
|
|
||||||
host_output = os.path.join(
|
|
||||||
self.docker_host_astrbot_abs_path,
|
|
||||||
output_path,
|
|
||||||
)
|
|
||||||
host_workplace = os.path.join(
|
|
||||||
self.docker_host_astrbot_abs_path,
|
|
||||||
workplace_path,
|
|
||||||
)
|
|
||||||
|
|
||||||
else:
|
|
||||||
host_shared = os.path.abspath(self.shared_path)
|
|
||||||
host_output = os.path.abspath(output_path)
|
|
||||||
host_workplace = os.path.abspath(workplace_path)
|
|
||||||
|
|
||||||
logger.debug(
|
|
||||||
f"host_shared: {host_shared}, host_output: {host_output}, host_workplace: {host_workplace}",
|
|
||||||
)
|
|
||||||
|
|
||||||
container = await docker.containers.run(
|
|
||||||
{
|
|
||||||
"Image": image_name,
|
|
||||||
"Cmd": ["python", "exec.py"],
|
|
||||||
"Memory": 512 * 1024 * 1024,
|
|
||||||
"NanoCPUs": 1000000000,
|
|
||||||
"HostConfig": {
|
|
||||||
"Binds": [
|
|
||||||
f"{host_shared}:/astrbot_sandbox/shared:ro",
|
|
||||||
f"{host_output}:/astrbot_sandbox/output:rw",
|
|
||||||
f"{host_workplace}:/astrbot_sandbox:rw",
|
|
||||||
],
|
|
||||||
},
|
|
||||||
"Env": [f"MAGIC_CODE={magic_code}"],
|
|
||||||
"AutoRemove": True,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
logger.debug(f"Container {container.id} created.")
|
|
||||||
logs = await self.run_container(container)
|
|
||||||
|
|
||||||
logger.debug(f"Container {container.id} finished.")
|
|
||||||
logger.debug(f"Container {container.id} logs: {logs}")
|
|
||||||
|
|
||||||
# 发送结果
|
|
||||||
pattern = r"\[ASTRBOT_(TEXT|IMAGE|FILE)_OUTPUT#\w+\]: (.*)"
|
|
||||||
ok = False
|
|
||||||
traceback = ""
|
|
||||||
for idx, log in enumerate(logs):
|
|
||||||
match = re.match(pattern, log)
|
|
||||||
if match:
|
|
||||||
ok = True
|
|
||||||
if match.group(1) == "TEXT":
|
|
||||||
yield event.plain_result(match.group(2))
|
|
||||||
elif match.group(1) == "IMAGE":
|
|
||||||
image_path = os.path.join(workplace_path, match.group(2))
|
|
||||||
logger.debug(f"Sending image: {image_path}")
|
|
||||||
yield event.image_result(image_path)
|
|
||||||
elif match.group(1) == "FILE":
|
|
||||||
file_path = os.path.join(workplace_path, match.group(2))
|
|
||||||
# logger.debug(f"Sending file: {file_path}")
|
|
||||||
# file_s3_url = await self.file_upload(file_path)
|
|
||||||
# logger.info(f"文件上传到 AstrBot 云节点: {file_s3_url}")
|
|
||||||
file_name = os.path.basename(file_path)
|
|
||||||
chain: list[BaseMessageComponent] = [
|
|
||||||
File(name=file_name, file=file_path)
|
|
||||||
]
|
|
||||||
yield event.set_result(MessageEventResult(chain=chain))
|
|
||||||
|
|
||||||
elif (
|
|
||||||
"Traceback (most recent call last)" in log or "[Error]: " in log
|
|
||||||
):
|
|
||||||
traceback = "\n".join(logs[idx:])
|
|
||||||
|
|
||||||
if not ok:
|
|
||||||
if traceback:
|
|
||||||
obs = f"## Observation \n When execute the code: ```python\n{code_clean}\n```\n\n Error occurred:\n\n{traceback}\n Need to improve/fix the code."
|
|
||||||
else:
|
|
||||||
logger.warning(
|
|
||||||
f"未从沙箱输出中捕获到合法的输出。沙箱输出日志: {logs}",
|
|
||||||
)
|
|
||||||
break
|
|
||||||
else:
|
|
||||||
# 成功了
|
|
||||||
self.user_file_msg_buffer.pop(event.get_session_id())
|
|
||||||
return
|
|
||||||
|
|
||||||
yield event.plain_result(
|
|
||||||
"经过多次尝试后,未从沙箱输出中捕获到合法的输出,请更换问法或者查看日志。",
|
|
||||||
)
|
|
||||||
|
|
||||||
@pi.command("cleanfile")
|
|
||||||
async def pi_cleanfile(self, event: AstrMessageEvent):
|
|
||||||
"""清理用户上传的文件"""
|
|
||||||
for file in self.user_file_msg_buffer[event.get_session_id()]:
|
|
||||||
try:
|
|
||||||
os.remove(file)
|
|
||||||
except BaseException as e:
|
|
||||||
logger.error(f"删除文件 {file} 失败: {e}")
|
|
||||||
|
|
||||||
self.user_file_msg_buffer.pop(event.get_session_id())
|
|
||||||
yield event.plain_result(f"用户 {event.get_session_id()} 上传的文件已清理。")
|
|
||||||
|
|
||||||
async def run_container(
|
|
||||||
self,
|
|
||||||
container: aiodocker.docker.DockerContainer,
|
|
||||||
timeout: int = 20,
|
|
||||||
) -> list[str]:
|
|
||||||
"""Run the container and get the output"""
|
|
||||||
try:
|
|
||||||
await container.wait(timeout=timeout)
|
|
||||||
logs = await container.log(stdout=True, stderr=True)
|
|
||||||
return logs
|
|
||||||
except asyncio.TimeoutError:
|
|
||||||
logger.warning(f"Container {container.id} timeout.")
|
|
||||||
await container.kill()
|
|
||||||
return [f"[Error]: Container has been killed due to timeout ({timeout}s)."]
|
|
||||||
finally:
|
|
||||||
await container.delete()
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
name: astrbot-python-interpreter
|
|
||||||
desc: Python 代码执行器
|
|
||||||
author: Soulter
|
|
||||||
version: 0.0.1
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
aiodocker
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
import os
|
|
||||||
|
|
||||||
|
|
||||||
def _get_magic_code():
|
|
||||||
"""防止注入攻击"""
|
|
||||||
return os.getenv("MAGIC_CODE")
|
|
||||||
|
|
||||||
|
|
||||||
def send_text(text: str):
|
|
||||||
print(f"[ASTRBOT_TEXT_OUTPUT#{_get_magic_code()}]: {text}")
|
|
||||||
|
|
||||||
|
|
||||||
def send_image(image_path: str):
|
|
||||||
if not os.path.exists(image_path):
|
|
||||||
raise Exception(f"Image file not found: {image_path}")
|
|
||||||
print(f"[ASTRBOT_IMAGE_OUTPUT#{_get_magic_code()}]: {image_path}")
|
|
||||||
|
|
||||||
|
|
||||||
def send_file(file_path: str):
|
|
||||||
if not os.path.exists(file_path):
|
|
||||||
raise Exception(f"File not found: {file_path}")
|
|
||||||
print(f"[ASTRBOT_FILE_OUTPUT#{_get_magic_code()}]: {file_path}")
|
|
||||||
@@ -113,6 +113,14 @@ DEFAULT_CONFIG = {
|
|||||||
"provider": "moonshotai",
|
"provider": "moonshotai",
|
||||||
"moonshotai_api_key": "",
|
"moonshotai_api_key": "",
|
||||||
},
|
},
|
||||||
|
"sandbox": {
|
||||||
|
"enable": False,
|
||||||
|
"booter": "shipyard",
|
||||||
|
"shipyard_endpoint": "",
|
||||||
|
"shipyard_access_token": "",
|
||||||
|
"shipyard_ttl": 3600,
|
||||||
|
"shipyard_max_sessions": 10,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
"provider_stt_settings": {
|
"provider_stt_settings": {
|
||||||
"enable": False,
|
"enable": False,
|
||||||
@@ -2539,6 +2547,62 @@ CONFIG_METADATA_3 = {
|
|||||||
# "provider_settings.enable": True,
|
# "provider_settings.enable": True,
|
||||||
# },
|
# },
|
||||||
# },
|
# },
|
||||||
|
"sandbox": {
|
||||||
|
"description": "Agent 沙箱环境",
|
||||||
|
"type": "object",
|
||||||
|
"items": {
|
||||||
|
"provider_settings.sandbox.enable": {
|
||||||
|
"description": "启用沙箱环境",
|
||||||
|
"type": "bool",
|
||||||
|
"hint": "启用后,Agent 可以使用沙箱环境中的工具和资源,如 Python 代码执行、Shell 等。",
|
||||||
|
},
|
||||||
|
"provider_settings.sandbox.booter": {
|
||||||
|
"description": "沙箱环境驱动器",
|
||||||
|
"type": "string",
|
||||||
|
"options": ["shipyard"],
|
||||||
|
"condition": {
|
||||||
|
"provider_settings.sandbox.enable": True,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"provider_settings.sandbox.shipyard_endpoint": {
|
||||||
|
"description": "Shipyard API Endpoint",
|
||||||
|
"type": "string",
|
||||||
|
"hint": "Shipyard 服务的 API 访问地址。",
|
||||||
|
"condition": {
|
||||||
|
"provider_settings.sandbox.enable": True,
|
||||||
|
"provider_settings.sandbox.booter": "shipyard",
|
||||||
|
},
|
||||||
|
"_special": "check_shipyard_connection",
|
||||||
|
},
|
||||||
|
"provider_settings.sandbox.shipyard_access_token": {
|
||||||
|
"description": "Shipyard Access Token",
|
||||||
|
"type": "string",
|
||||||
|
"hint": "用于访问 Shipyard 服务的访问令牌。",
|
||||||
|
"condition": {
|
||||||
|
"provider_settings.sandbox.enable": True,
|
||||||
|
"provider_settings.sandbox.booter": "shipyard",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"provider_settings.sandbox.shipyard_ttl": {
|
||||||
|
"description": "Shipyard Session TTL",
|
||||||
|
"type": "int",
|
||||||
|
"hint": "Shipyard 会话的生存时间(秒)。",
|
||||||
|
"condition": {
|
||||||
|
"provider_settings.sandbox.enable": True,
|
||||||
|
"provider_settings.sandbox.booter": "shipyard",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"provider_settings.sandbox.shipyard_max_sessions": {
|
||||||
|
"description": "Shipyard Max Sessions",
|
||||||
|
"type": "int",
|
||||||
|
"hint": "Shipyard 最大会话数量。",
|
||||||
|
"condition": {
|
||||||
|
"provider_settings.sandbox.enable": True,
|
||||||
|
"provider_settings.sandbox.booter": "shipyard",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
"truncate_and_compress": {
|
"truncate_and_compress": {
|
||||||
"description": "上下文管理策略",
|
"description": "上下文管理策略",
|
||||||
"type": "object",
|
"type": "object",
|
||||||
|
|||||||
@@ -2,10 +2,11 @@
|
|||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import json
|
import json
|
||||||
|
import os
|
||||||
from collections.abc import AsyncGenerator
|
from collections.abc import AsyncGenerator
|
||||||
|
|
||||||
from astrbot.core import logger
|
from astrbot.core import logger
|
||||||
from astrbot.core.agent.message import Message
|
from astrbot.core.agent.message import Message, TextPart
|
||||||
from astrbot.core.agent.response import AgentStats
|
from astrbot.core.agent.response import AgentStats
|
||||||
from astrbot.core.agent.tool import ToolSet
|
from astrbot.core.agent.tool import ToolSet
|
||||||
from astrbot.core.astr_agent_context import AstrAgentContext
|
from astrbot.core.astr_agent_context import AstrAgentContext
|
||||||
@@ -35,8 +36,13 @@ from .....astr_agent_tool_exec import FunctionToolExecutor
|
|||||||
from ....context import PipelineContext, call_event_hook
|
from ....context import PipelineContext, call_event_hook
|
||||||
from ...stage import Stage
|
from ...stage import Stage
|
||||||
from ...utils import (
|
from ...utils import (
|
||||||
|
EXECUTE_SHELL_TOOL,
|
||||||
|
FILE_DOWNLOAD_TOOL,
|
||||||
|
FILE_UPLOAD_TOOL,
|
||||||
KNOWLEDGE_BASE_QUERY_TOOL,
|
KNOWLEDGE_BASE_QUERY_TOOL,
|
||||||
LLM_SAFETY_MODE_SYSTEM_PROMPT,
|
LLM_SAFETY_MODE_SYSTEM_PROMPT,
|
||||||
|
PYTHON_TOOL,
|
||||||
|
SANDBOX_MODE_PROMPT,
|
||||||
decoded_blocked,
|
decoded_blocked,
|
||||||
retrieve_knowledge_base,
|
retrieve_knowledge_base,
|
||||||
)
|
)
|
||||||
@@ -94,6 +100,8 @@ class InternalAgentSubStage(Stage):
|
|||||||
"safety_mode_strategy", "system_prompt"
|
"safety_mode_strategy", "system_prompt"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
self.sandbox_cfg = settings.get("sandbox", {})
|
||||||
|
|
||||||
self.conv_manager = ctx.plugin_manager.context.conversation_manager
|
self.conv_manager = ctx.plugin_manager.context.conversation_manager
|
||||||
|
|
||||||
def _select_provider(self, event: AstrMessageEvent):
|
def _select_provider(self, event: AstrMessageEvent):
|
||||||
@@ -458,6 +466,24 @@ class InternalAgentSubStage(Stage):
|
|||||||
f"Unsupported llm_safety_mode strategy: {self.safety_mode_strategy}.",
|
f"Unsupported llm_safety_mode strategy: {self.safety_mode_strategy}.",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def _apply_sandbox_tools(self, req: ProviderRequest, session_id: str) -> None:
|
||||||
|
"""Add sandbox tools to the provider request."""
|
||||||
|
if req.func_tool is None:
|
||||||
|
req.func_tool = ToolSet()
|
||||||
|
if self.sandbox_cfg.get("booter") == "shipyard":
|
||||||
|
ep = self.sandbox_cfg.get("shipyard_endpoint", "")
|
||||||
|
at = self.sandbox_cfg.get("shipyard_access_token", "")
|
||||||
|
if not ep or not at:
|
||||||
|
logger.error("Shipyard sandbox configuration is incomplete.")
|
||||||
|
return
|
||||||
|
os.environ["SHIPYARD_ENDPOINT"] = ep
|
||||||
|
os.environ["SHIPYARD_ACCESS_TOKEN"] = at
|
||||||
|
req.func_tool.add_tool(EXECUTE_SHELL_TOOL)
|
||||||
|
req.func_tool.add_tool(PYTHON_TOOL)
|
||||||
|
req.func_tool.add_tool(FILE_UPLOAD_TOOL)
|
||||||
|
req.func_tool.add_tool(FILE_DOWNLOAD_TOOL)
|
||||||
|
req.system_prompt += f"\n{SANDBOX_MODE_PROMPT}\n"
|
||||||
|
|
||||||
async def process(
|
async def process(
|
||||||
self, event: AstrMessageEvent, provider_wake_prefix: str
|
self, event: AstrMessageEvent, provider_wake_prefix: str
|
||||||
) -> AsyncGenerator[None, None]:
|
) -> AsyncGenerator[None, None]:
|
||||||
@@ -536,6 +562,20 @@ class InternalAgentSubStage(Stage):
|
|||||||
image_path = await comp.convert_to_file_path()
|
image_path = await comp.convert_to_file_path()
|
||||||
req.image_urls.append(image_path)
|
req.image_urls.append(image_path)
|
||||||
|
|
||||||
|
req.extra_user_content_parts.append(
|
||||||
|
TextPart(text=f"[Image Attachment: path {image_path}]")
|
||||||
|
)
|
||||||
|
elif isinstance(comp, File) and self.sandbox_cfg.get(
|
||||||
|
"enable", False
|
||||||
|
):
|
||||||
|
file_path = await comp.get_file()
|
||||||
|
file_name = comp.name or os.path.basename(file_path)
|
||||||
|
req.extra_user_content_parts.append(
|
||||||
|
TextPart(
|
||||||
|
text=f"[File Attachment: name {file_name}, path {file_path}]"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
conversation = await self._get_session_conv(event)
|
conversation = await self._get_session_conv(event)
|
||||||
req.conversation = conversation
|
req.conversation = conversation
|
||||||
req.contexts = json.loads(conversation.history)
|
req.contexts = json.loads(conversation.history)
|
||||||
@@ -586,6 +626,10 @@ class InternalAgentSubStage(Stage):
|
|||||||
if self.llm_safety_mode:
|
if self.llm_safety_mode:
|
||||||
self._apply_llm_safety_mode(req)
|
self._apply_llm_safety_mode(req)
|
||||||
|
|
||||||
|
# apply sandbox tools
|
||||||
|
if self.sandbox_cfg.get("enable", False):
|
||||||
|
self._apply_sandbox_tools(req, req.session_id)
|
||||||
|
|
||||||
stream_to_general = (
|
stream_to_general = (
|
||||||
self.unsupported_streaming_strategy == "turn_off"
|
self.unsupported_streaming_strategy == "turn_off"
|
||||||
and not event.platform_meta.support_streaming_message
|
and not event.platform_meta.support_streaming_message
|
||||||
|
|||||||
@@ -7,6 +7,12 @@ from astrbot.api import logger, sp
|
|||||||
from astrbot.core.agent.run_context import ContextWrapper
|
from astrbot.core.agent.run_context import ContextWrapper
|
||||||
from astrbot.core.agent.tool import FunctionTool, ToolExecResult
|
from astrbot.core.agent.tool import FunctionTool, ToolExecResult
|
||||||
from astrbot.core.astr_agent_context import AstrAgentContext
|
from astrbot.core.astr_agent_context import AstrAgentContext
|
||||||
|
from astrbot.core.sandbox.tools import (
|
||||||
|
ExecuteShellTool,
|
||||||
|
FileDownloadTool,
|
||||||
|
FileUploadTool,
|
||||||
|
PythonTool,
|
||||||
|
)
|
||||||
from astrbot.core.star.context import Context
|
from astrbot.core.star.context import Context
|
||||||
|
|
||||||
LLM_SAFETY_MODE_SYSTEM_PROMPT = """You are running in Safe Mode.
|
LLM_SAFETY_MODE_SYSTEM_PROMPT = """You are running in Safe Mode.
|
||||||
@@ -21,6 +27,20 @@ Rules:
|
|||||||
- Output same language as the user's input.
|
- Output same language as the user's input.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
SANDBOX_MODE_PROMPT = (
|
||||||
|
"You have access to a sandboxed environment and can execute shell commands and Python code securely."
|
||||||
|
# "Your have extended skills library, such as PDF processing, image generation, data analysis, etc. "
|
||||||
|
# "Before handling complex tasks, please retrieve and review the documentation in the in /app/skills/ directory. "
|
||||||
|
# "If the current task matches the description of a specific skill, prioritize following the workflow defined by that skill."
|
||||||
|
# "Use `ls /app/skills/` to list all available skills. "
|
||||||
|
# "Use `cat /app/skills/{skill_name}/SKILL.md` to read the documentation of a specific skill."
|
||||||
|
# "SKILL.md might be large, you can read the description first, which is located in the YAML frontmatter of the file."
|
||||||
|
# "Use shell commands such as grep, sed, awk to extract relevant information from the documentation as needed.\n"
|
||||||
|
"Note:\n"
|
||||||
|
"1. If you use shell, your command will always runs in the /home/<username>/workspace directory.\n"
|
||||||
|
"2. If you use IPython, you would better use absolute paths when dealing with files to avoid confusion.\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class KnowledgeBaseQueryTool(FunctionTool[AstrAgentContext]):
|
class KnowledgeBaseQueryTool(FunctionTool[AstrAgentContext]):
|
||||||
@@ -138,6 +158,11 @@ async def retrieve_knowledge_base(
|
|||||||
|
|
||||||
KNOWLEDGE_BASE_QUERY_TOOL = KnowledgeBaseQueryTool()
|
KNOWLEDGE_BASE_QUERY_TOOL = KnowledgeBaseQueryTool()
|
||||||
|
|
||||||
|
EXECUTE_SHELL_TOOL = ExecuteShellTool()
|
||||||
|
PYTHON_TOOL = PythonTool()
|
||||||
|
FILE_UPLOAD_TOOL = FileUploadTool()
|
||||||
|
FILE_DOWNLOAD_TOOL = FileDownloadTool()
|
||||||
|
|
||||||
# we prevent astrbot from connecting to known malicious hosts
|
# we prevent astrbot from connecting to known malicious hosts
|
||||||
# these hosts are base64 encoded
|
# these hosts are base64 encoded
|
||||||
BLOCKED = {"dGZid2h2d3IuY2xvdWQuc2VhbG9zLmlv", "a291cmljaGF0"}
|
BLOCKED = {"dGZid2h2d3IuY2xvdWQuc2VhbG9zLmlv", "a291cmljaGF0"}
|
||||||
|
|||||||
@@ -93,7 +93,8 @@ class WebChatAdapter(Platform):
|
|||||||
session: MessageSesion,
|
session: MessageSesion,
|
||||||
message_chain: MessageChain,
|
message_chain: MessageChain,
|
||||||
):
|
):
|
||||||
await WebChatMessageEvent._send(message_chain, session.session_id)
|
message_id = f"active_{str(uuid.uuid4())}"
|
||||||
|
await WebChatMessageEvent._send(message_id, message_chain, session.session_id)
|
||||||
await super().send_by_session(session, message_chain)
|
await super().send_by_session(session, message_chain)
|
||||||
|
|
||||||
async def _get_message_history(
|
async def _get_message_history(
|
||||||
@@ -196,7 +197,7 @@ class WebChatAdapter(Platform):
|
|||||||
|
|
||||||
abm.session_id = f"webchat!{username}!{cid}"
|
abm.session_id = f"webchat!{username}!{cid}"
|
||||||
|
|
||||||
abm.message_id = str(uuid.uuid4())
|
abm.message_id = payload.get("message_id")
|
||||||
|
|
||||||
# 处理消息段列表
|
# 处理消息段列表
|
||||||
message_parts = payload.get("message", [])
|
message_parts = payload.get("message", [])
|
||||||
|
|||||||
@@ -21,7 +21,10 @@ class WebChatMessageEvent(AstrMessageEvent):
|
|||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
async def _send(
|
async def _send(
|
||||||
message: MessageChain | None, session_id: str, streaming: bool = False
|
message_id: str,
|
||||||
|
message: MessageChain | None,
|
||||||
|
session_id: str,
|
||||||
|
streaming: bool = False,
|
||||||
) -> str | None:
|
) -> str | None:
|
||||||
cid = session_id.split("!")[-1]
|
cid = session_id.split("!")[-1]
|
||||||
web_chat_back_queue = webchat_queue_mgr.get_or_create_back_queue(cid)
|
web_chat_back_queue = webchat_queue_mgr.get_or_create_back_queue(cid)
|
||||||
@@ -31,6 +34,7 @@ class WebChatMessageEvent(AstrMessageEvent):
|
|||||||
"type": "end",
|
"type": "end",
|
||||||
"data": "",
|
"data": "",
|
||||||
"streaming": False,
|
"streaming": False,
|
||||||
|
"message_id": message_id,
|
||||||
}, # end means this request is finished
|
}, # end means this request is finished
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
@@ -45,6 +49,7 @@ class WebChatMessageEvent(AstrMessageEvent):
|
|||||||
"data": data,
|
"data": data,
|
||||||
"streaming": streaming,
|
"streaming": streaming,
|
||||||
"chain_type": message.type,
|
"chain_type": message.type,
|
||||||
|
"message_id": message_id,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
elif isinstance(comp, Json):
|
elif isinstance(comp, Json):
|
||||||
@@ -54,6 +59,7 @@ class WebChatMessageEvent(AstrMessageEvent):
|
|||||||
"data": json.dumps(comp.data, ensure_ascii=False),
|
"data": json.dumps(comp.data, ensure_ascii=False),
|
||||||
"streaming": streaming,
|
"streaming": streaming,
|
||||||
"chain_type": message.type,
|
"chain_type": message.type,
|
||||||
|
"message_id": message_id,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
elif isinstance(comp, Image):
|
elif isinstance(comp, Image):
|
||||||
@@ -69,6 +75,7 @@ class WebChatMessageEvent(AstrMessageEvent):
|
|||||||
"type": "image",
|
"type": "image",
|
||||||
"data": data,
|
"data": data,
|
||||||
"streaming": streaming,
|
"streaming": streaming,
|
||||||
|
"message_id": message_id,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
elif isinstance(comp, Record):
|
elif isinstance(comp, Record):
|
||||||
@@ -84,6 +91,7 @@ class WebChatMessageEvent(AstrMessageEvent):
|
|||||||
"type": "record",
|
"type": "record",
|
||||||
"data": data,
|
"data": data,
|
||||||
"streaming": streaming,
|
"streaming": streaming,
|
||||||
|
"message_id": message_id,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
elif isinstance(comp, File):
|
elif isinstance(comp, File):
|
||||||
@@ -94,12 +102,13 @@ class WebChatMessageEvent(AstrMessageEvent):
|
|||||||
filename = f"{uuid.uuid4()!s}{ext}"
|
filename = f"{uuid.uuid4()!s}{ext}"
|
||||||
dest_path = os.path.join(imgs_dir, filename)
|
dest_path = os.path.join(imgs_dir, filename)
|
||||||
shutil.copy2(file_path, dest_path)
|
shutil.copy2(file_path, dest_path)
|
||||||
data = f"[FILE]{filename}|{original_name}"
|
data = f"[FILE]{filename}"
|
||||||
await web_chat_back_queue.put(
|
await web_chat_back_queue.put(
|
||||||
{
|
{
|
||||||
"type": "file",
|
"type": "file",
|
||||||
"data": data,
|
"data": data,
|
||||||
"streaming": streaming,
|
"streaming": streaming,
|
||||||
|
"message_id": message_id,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
@@ -108,7 +117,8 @@ class WebChatMessageEvent(AstrMessageEvent):
|
|||||||
return data
|
return data
|
||||||
|
|
||||||
async def send(self, message: MessageChain | None):
|
async def send(self, message: MessageChain | None):
|
||||||
await WebChatMessageEvent._send(message, session_id=self.session_id)
|
message_id = self.message_obj.message_id
|
||||||
|
await WebChatMessageEvent._send(message_id, message, session_id=self.session_id)
|
||||||
await super().send(MessageChain([]))
|
await super().send(MessageChain([]))
|
||||||
|
|
||||||
async def send_streaming(self, generator, use_fallback: bool = False):
|
async def send_streaming(self, generator, use_fallback: bool = False):
|
||||||
@@ -116,6 +126,7 @@ class WebChatMessageEvent(AstrMessageEvent):
|
|||||||
reasoning_content = ""
|
reasoning_content = ""
|
||||||
cid = self.session_id.split("!")[-1]
|
cid = self.session_id.split("!")[-1]
|
||||||
web_chat_back_queue = webchat_queue_mgr.get_or_create_back_queue(cid)
|
web_chat_back_queue = webchat_queue_mgr.get_or_create_back_queue(cid)
|
||||||
|
message_id = self.message_obj.message_id
|
||||||
async for chain in generator:
|
async for chain in generator:
|
||||||
# if chain.type == "break" and final_data:
|
# if chain.type == "break" and final_data:
|
||||||
# # 分割符
|
# # 分割符
|
||||||
@@ -130,7 +141,8 @@ class WebChatMessageEvent(AstrMessageEvent):
|
|||||||
# continue
|
# continue
|
||||||
|
|
||||||
r = await WebChatMessageEvent._send(
|
r = await WebChatMessageEvent._send(
|
||||||
chain,
|
message_id=message_id,
|
||||||
|
message=chain,
|
||||||
session_id=self.session_id,
|
session_id=self.session_id,
|
||||||
streaming=True,
|
streaming=True,
|
||||||
)
|
)
|
||||||
@@ -147,6 +159,7 @@ class WebChatMessageEvent(AstrMessageEvent):
|
|||||||
"data": final_data,
|
"data": final_data,
|
||||||
"reasoning": reasoning_content,
|
"reasoning": reasoning_content,
|
||||||
"streaming": True,
|
"streaming": True,
|
||||||
|
"message_id": message_id,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
await super().send_streaming(generator, use_fallback)
|
await super().send_streaming(generator, use_fallback)
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
from ..olayer import FileSystemComponent, PythonComponent, ShellComponent
|
||||||
|
|
||||||
|
|
||||||
|
class SandboxBooter:
|
||||||
|
@property
|
||||||
|
def fs(self) -> FileSystemComponent: ...
|
||||||
|
|
||||||
|
@property
|
||||||
|
def python(self) -> PythonComponent: ...
|
||||||
|
|
||||||
|
@property
|
||||||
|
def shell(self) -> ShellComponent: ...
|
||||||
|
|
||||||
|
async def boot(self, session_id: str) -> None: ...
|
||||||
|
|
||||||
|
async def shutdown(self) -> None: ...
|
||||||
|
|
||||||
|
async def upload_file(self, path: str, file_name: str) -> dict:
|
||||||
|
"""Upload file to sandbox.
|
||||||
|
|
||||||
|
Should return a dict with `success` (bool) and `file_path` (str) keys.
|
||||||
|
"""
|
||||||
|
...
|
||||||
|
|
||||||
|
async def download_file(self, remote_path: str, local_path: str):
|
||||||
|
"""Download file from sandbox."""
|
||||||
|
...
|
||||||
|
|
||||||
|
async def available(self) -> bool:
|
||||||
|
"""Check if the sandbox is available."""
|
||||||
|
...
|
||||||
@@ -0,0 +1,186 @@
|
|||||||
|
import asyncio
|
||||||
|
import random
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import aiohttp
|
||||||
|
import boxlite
|
||||||
|
from shipyard.filesystem import FileSystemComponent as ShipyardFileSystemComponent
|
||||||
|
from shipyard.python import PythonComponent as ShipyardPythonComponent
|
||||||
|
from shipyard.shell import ShellComponent as ShipyardShellComponent
|
||||||
|
|
||||||
|
from astrbot.api import logger
|
||||||
|
|
||||||
|
from ..olayer import FileSystemComponent, PythonComponent, ShellComponent
|
||||||
|
from .base import SandboxBooter
|
||||||
|
|
||||||
|
|
||||||
|
class MockShipyardSandboxClient:
|
||||||
|
def __init__(self, sb_url: str) -> None:
|
||||||
|
self.sb_url = sb_url.rstrip("/")
|
||||||
|
|
||||||
|
async def _exec_operation(
|
||||||
|
self,
|
||||||
|
ship_id: str,
|
||||||
|
operation_type: str,
|
||||||
|
payload: dict[str, Any],
|
||||||
|
session_id: str,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
async with aiohttp.ClientSession() as session:
|
||||||
|
headers = {"X-SESSION-ID": session_id}
|
||||||
|
async with session.post(
|
||||||
|
f"{self.sb_url}/{operation_type}",
|
||||||
|
json=payload,
|
||||||
|
headers=headers,
|
||||||
|
) as response:
|
||||||
|
if response.status == 200:
|
||||||
|
return await response.json()
|
||||||
|
else:
|
||||||
|
error_text = await response.text()
|
||||||
|
raise Exception(
|
||||||
|
f"Failed to exec operation: {response.status} {error_text}"
|
||||||
|
)
|
||||||
|
|
||||||
|
async def upload_file(self, path: str, remote_path: str) -> dict:
|
||||||
|
"""Upload a file to the sandbox"""
|
||||||
|
url = f"http://{self.sb_url}/upload"
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Read file content
|
||||||
|
with open(path, "rb") as f:
|
||||||
|
file_content = f.read()
|
||||||
|
|
||||||
|
# Create multipart form data
|
||||||
|
data = aiohttp.FormData()
|
||||||
|
data.add_field(
|
||||||
|
"file",
|
||||||
|
file_content,
|
||||||
|
filename=remote_path.split("/")[-1],
|
||||||
|
content_type="application/octet-stream",
|
||||||
|
)
|
||||||
|
data.add_field("file_path", remote_path)
|
||||||
|
|
||||||
|
timeout = aiohttp.ClientTimeout(total=120) # 2 minutes for file upload
|
||||||
|
|
||||||
|
async with aiohttp.ClientSession(timeout=timeout) as session:
|
||||||
|
async with session.post(url, data=data) as response:
|
||||||
|
if response.status == 200:
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"message": "File uploaded successfully",
|
||||||
|
"file_path": remote_path,
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
error_text = await response.text()
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"error": f"Server returned {response.status}: {error_text}",
|
||||||
|
"message": "File upload failed",
|
||||||
|
}
|
||||||
|
|
||||||
|
except aiohttp.ClientError as e:
|
||||||
|
logger.error(f"Failed to upload file: {e}")
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"error": f"Connection error: {str(e)}",
|
||||||
|
"message": "File upload failed",
|
||||||
|
}
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"error": "File upload timeout",
|
||||||
|
"message": "File upload failed",
|
||||||
|
}
|
||||||
|
except FileNotFoundError:
|
||||||
|
logger.error(f"File not found: {path}")
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"error": f"File not found: {path}",
|
||||||
|
"message": "File upload failed",
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Unexpected error uploading file: {e}")
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"error": f"Internal error: {str(e)}",
|
||||||
|
"message": "File upload failed",
|
||||||
|
}
|
||||||
|
|
||||||
|
async def wait_healthy(self, ship_id: str, session_id: str) -> None:
|
||||||
|
"""Mock wait healthy"""
|
||||||
|
loop = 60
|
||||||
|
while loop > 0:
|
||||||
|
try:
|
||||||
|
logger.info(
|
||||||
|
f"Checking health for sandbox {ship_id} on {self.sb_url}..."
|
||||||
|
)
|
||||||
|
url = f"{self.sb_url}/health"
|
||||||
|
async with aiohttp.ClientSession() as session:
|
||||||
|
async with session.get(url) as response:
|
||||||
|
if response.status == 200:
|
||||||
|
logger.info(f"Sandbox {ship_id} is healthy")
|
||||||
|
return
|
||||||
|
except Exception:
|
||||||
|
await asyncio.sleep(1)
|
||||||
|
loop -= 1
|
||||||
|
|
||||||
|
|
||||||
|
class BoxliteBooter(SandboxBooter):
|
||||||
|
async def boot(self, session_id: str) -> None:
|
||||||
|
logger.info(
|
||||||
|
f"Booting(Boxlite) for session: {session_id}, this may take a while..."
|
||||||
|
)
|
||||||
|
random_port = random.randint(20000, 30000)
|
||||||
|
self.box = boxlite.SimpleBox(
|
||||||
|
image="soulter/shipyard-ship",
|
||||||
|
memory_mib=512,
|
||||||
|
cpus=1,
|
||||||
|
ports=[
|
||||||
|
{
|
||||||
|
"host_port": random_port,
|
||||||
|
"guest_port": 8123,
|
||||||
|
}
|
||||||
|
],
|
||||||
|
)
|
||||||
|
await self.box.start()
|
||||||
|
logger.info(f"Boxlite booter started for session: {session_id}")
|
||||||
|
self.mocked = MockShipyardSandboxClient(
|
||||||
|
sb_url=f"http://127.0.0.1:{random_port}"
|
||||||
|
)
|
||||||
|
self._fs = ShipyardFileSystemComponent(
|
||||||
|
client=self.mocked, # type: ignore
|
||||||
|
ship_id=self.box.id,
|
||||||
|
session_id=session_id,
|
||||||
|
)
|
||||||
|
self._python = ShipyardPythonComponent(
|
||||||
|
client=self.mocked, # type: ignore
|
||||||
|
ship_id=self.box.id,
|
||||||
|
session_id=session_id,
|
||||||
|
)
|
||||||
|
self._shell = ShipyardShellComponent(
|
||||||
|
client=self.mocked, # type: ignore
|
||||||
|
ship_id=self.box.id,
|
||||||
|
session_id=session_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
await self.mocked.wait_healthy(self.box.id, session_id)
|
||||||
|
|
||||||
|
async def shutdown(self) -> None:
|
||||||
|
logger.info(f"Shutting down Boxlite booter for ship: {self.box.id}")
|
||||||
|
self.box.shutdown()
|
||||||
|
logger.info(f"Boxlite booter for ship: {self.box.id} stopped")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def fs(self) -> FileSystemComponent:
|
||||||
|
return self._fs
|
||||||
|
|
||||||
|
@property
|
||||||
|
def python(self) -> PythonComponent:
|
||||||
|
return self._python
|
||||||
|
|
||||||
|
@property
|
||||||
|
def shell(self) -> ShellComponent:
|
||||||
|
return self._shell
|
||||||
|
|
||||||
|
async def upload_file(self, path: str, file_name: str) -> dict:
|
||||||
|
"""Upload file to sandbox"""
|
||||||
|
return await self.mocked.upload_file(path, file_name)
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
from shipyard import ShipyardClient, Spec
|
||||||
|
|
||||||
|
from astrbot.api import logger
|
||||||
|
|
||||||
|
from ..olayer import FileSystemComponent, PythonComponent, ShellComponent
|
||||||
|
from .base import SandboxBooter
|
||||||
|
|
||||||
|
|
||||||
|
class ShipyardBooter(SandboxBooter):
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
endpoint_url: str,
|
||||||
|
access_token: str,
|
||||||
|
ttl: int = 3600,
|
||||||
|
session_num: int = 10,
|
||||||
|
) -> None:
|
||||||
|
self._sandbox_client = ShipyardClient(
|
||||||
|
endpoint_url=endpoint_url, access_token=access_token
|
||||||
|
)
|
||||||
|
self._ttl = ttl
|
||||||
|
self._session_num = session_num
|
||||||
|
|
||||||
|
async def boot(self, session_id: str) -> None:
|
||||||
|
ship = await self._sandbox_client.create_ship(
|
||||||
|
ttl=self._ttl,
|
||||||
|
spec=Spec(cpus=1.0, memory="512m"),
|
||||||
|
max_session_num=self._session_num,
|
||||||
|
session_id=session_id,
|
||||||
|
)
|
||||||
|
logger.info(f"Got sandbox ship: {ship.id} for session: {session_id}")
|
||||||
|
self._ship = ship
|
||||||
|
|
||||||
|
async def shutdown(self) -> None:
|
||||||
|
pass
|
||||||
|
|
||||||
|
@property
|
||||||
|
def fs(self) -> FileSystemComponent:
|
||||||
|
return self._ship.fs
|
||||||
|
|
||||||
|
@property
|
||||||
|
def python(self) -> PythonComponent:
|
||||||
|
return self._ship.python
|
||||||
|
|
||||||
|
@property
|
||||||
|
def shell(self) -> ShellComponent:
|
||||||
|
return self._ship.shell
|
||||||
|
|
||||||
|
async def upload_file(self, path: str, file_name: str) -> dict:
|
||||||
|
"""Upload file to sandbox"""
|
||||||
|
return await self._ship.upload_file(path, file_name)
|
||||||
|
|
||||||
|
async def download_file(self, remote_path: str, local_path: str):
|
||||||
|
"""Download file from sandbox."""
|
||||||
|
return await self._ship.download_file(remote_path, local_path)
|
||||||
|
|
||||||
|
async def available(self) -> bool:
|
||||||
|
"""Check if the sandbox is available."""
|
||||||
|
try:
|
||||||
|
ship_id = self._ship.id
|
||||||
|
data = await self._sandbox_client.get_ship(ship_id)
|
||||||
|
if not data:
|
||||||
|
return False
|
||||||
|
health = bool(data.get("status", 0) == 1)
|
||||||
|
return health
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error checking Shipyard sandbox availability: {e}")
|
||||||
|
return False
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
from .filesystem import FileSystemComponent
|
||||||
|
from .python import PythonComponent
|
||||||
|
from .shell import ShellComponent
|
||||||
|
|
||||||
|
__all__ = ["PythonComponent", "ShellComponent", "FileSystemComponent"]
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
"""
|
||||||
|
File system component
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Any, Protocol
|
||||||
|
|
||||||
|
|
||||||
|
class FileSystemComponent(Protocol):
|
||||||
|
async def create_file(
|
||||||
|
self, path: str, content: str = "", mode: int = 0o644
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Create a file with the specified content"""
|
||||||
|
...
|
||||||
|
|
||||||
|
async def read_file(self, path: str, encoding: str = "utf-8") -> dict[str, Any]:
|
||||||
|
"""Read file content"""
|
||||||
|
...
|
||||||
|
|
||||||
|
async def write_file(
|
||||||
|
self, path: str, content: str, mode: str = "w", encoding: str = "utf-8"
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Write content to file"""
|
||||||
|
...
|
||||||
|
|
||||||
|
async def delete_file(self, path: str) -> dict[str, Any]:
|
||||||
|
"""Delete file or directory"""
|
||||||
|
...
|
||||||
|
|
||||||
|
async def list_dir(
|
||||||
|
self, path: str = ".", show_hidden: bool = False
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""List directory contents"""
|
||||||
|
...
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
"""
|
||||||
|
Python/IPython component
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Any, Protocol
|
||||||
|
|
||||||
|
|
||||||
|
class PythonComponent(Protocol):
|
||||||
|
"""Python/IPython operations component"""
|
||||||
|
|
||||||
|
async def exec(
|
||||||
|
self,
|
||||||
|
code: str,
|
||||||
|
kernel_id: str | None = None,
|
||||||
|
timeout: int = 30,
|
||||||
|
silent: bool = False,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Execute Python code"""
|
||||||
|
...
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
"""
|
||||||
|
Shell component
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Any, Protocol
|
||||||
|
|
||||||
|
|
||||||
|
class ShellComponent(Protocol):
|
||||||
|
"""Shell operations component"""
|
||||||
|
|
||||||
|
async def exec(
|
||||||
|
self,
|
||||||
|
command: str,
|
||||||
|
cwd: str | None = None,
|
||||||
|
env: dict[str, str] | None = None,
|
||||||
|
timeout: int | None = 30,
|
||||||
|
shell: bool = True,
|
||||||
|
background: bool = False,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Execute shell command"""
|
||||||
|
...
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import uuid
|
||||||
|
|
||||||
|
from astrbot.api import logger
|
||||||
|
from astrbot.core.star.context import Context
|
||||||
|
|
||||||
|
from .booters.base import SandboxBooter
|
||||||
|
|
||||||
|
session_booter: dict[str, SandboxBooter] = {}
|
||||||
|
|
||||||
|
|
||||||
|
async def get_booter(
|
||||||
|
context: Context,
|
||||||
|
session_id: str,
|
||||||
|
) -> SandboxBooter:
|
||||||
|
config = context.get_config(umo=session_id)
|
||||||
|
|
||||||
|
sandbox_cfg = config.get("provider_settings", {}).get("sandbox", {})
|
||||||
|
booter_type = sandbox_cfg.get("booter", "shipyard")
|
||||||
|
|
||||||
|
if session_id in session_booter:
|
||||||
|
booter = session_booter[session_id]
|
||||||
|
if not await booter.available():
|
||||||
|
# rebuild
|
||||||
|
session_booter.pop(session_id, None)
|
||||||
|
if session_id not in session_booter:
|
||||||
|
uuid_str = uuid.uuid5(uuid.NAMESPACE_DNS, session_id).hex
|
||||||
|
if booter_type == "shipyard":
|
||||||
|
from .booters.shipyard import ShipyardBooter
|
||||||
|
|
||||||
|
ep = sandbox_cfg.get("shipyard_endpoint", "")
|
||||||
|
token = sandbox_cfg.get("shipyard_access_token", "")
|
||||||
|
ttl = sandbox_cfg.get("shipyard_ttl", 3600)
|
||||||
|
max_sessions = sandbox_cfg.get("shipyard_max_sessions", 10)
|
||||||
|
|
||||||
|
client = ShipyardBooter(
|
||||||
|
endpoint_url=ep, access_token=token, ttl=ttl, session_num=max_sessions
|
||||||
|
)
|
||||||
|
elif booter_type == "boxlite":
|
||||||
|
from .booters.boxlite import BoxliteBooter
|
||||||
|
|
||||||
|
client = BoxliteBooter()
|
||||||
|
else:
|
||||||
|
raise ValueError(f"Unknown booter type: {booter_type}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
await client.boot(uuid_str)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error booting sandbox for session {session_id}: {e}")
|
||||||
|
raise e
|
||||||
|
|
||||||
|
session_booter[session_id] = client
|
||||||
|
return session_booter[session_id]
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
from .fs import FileDownloadTool, FileUploadTool
|
||||||
|
from .python import PythonTool
|
||||||
|
from .shell import ExecuteShellTool
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"FileUploadTool",
|
||||||
|
"PythonTool",
|
||||||
|
"ExecuteShellTool",
|
||||||
|
"FileDownloadTool",
|
||||||
|
]
|
||||||
@@ -0,0 +1,188 @@
|
|||||||
|
import os
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
|
||||||
|
from astrbot.api import FunctionTool, logger
|
||||||
|
from astrbot.api.event import MessageChain
|
||||||
|
from astrbot.core.agent.run_context import ContextWrapper
|
||||||
|
from astrbot.core.agent.tool import ToolExecResult
|
||||||
|
from astrbot.core.astr_agent_context import AstrAgentContext
|
||||||
|
from astrbot.core.message.components import File
|
||||||
|
from astrbot.core.utils.astrbot_path import get_astrbot_temp_path
|
||||||
|
|
||||||
|
from ..sandbox_client import get_booter
|
||||||
|
|
||||||
|
# @dataclass
|
||||||
|
# class CreateFileTool(FunctionTool):
|
||||||
|
# name: str = "astrbot_create_file"
|
||||||
|
# description: str = "Create a new file in the sandbox."
|
||||||
|
# parameters: dict = field(
|
||||||
|
# default_factory=lambda: {
|
||||||
|
# "type": "object",
|
||||||
|
# "properties": {
|
||||||
|
# "path": {
|
||||||
|
# "path": "string",
|
||||||
|
# "description": "The path where the file should be created, relative to the sandbox root. Must not use absolute paths or traverse outside the sandbox.",
|
||||||
|
# },
|
||||||
|
# "content": {
|
||||||
|
# "type": "string",
|
||||||
|
# "description": "The content to write into the file.",
|
||||||
|
# },
|
||||||
|
# },
|
||||||
|
# "required": ["path", "content"],
|
||||||
|
# }
|
||||||
|
# )
|
||||||
|
|
||||||
|
# async def call(
|
||||||
|
# self, context: ContextWrapper[AstrAgentContext], path: str, content: str
|
||||||
|
# ) -> ToolExecResult:
|
||||||
|
# sb = await get_booter(
|
||||||
|
# context.context.context,
|
||||||
|
# context.context.event.unified_msg_origin,
|
||||||
|
# )
|
||||||
|
# try:
|
||||||
|
# result = await sb.fs.create_file(path, content)
|
||||||
|
# return json.dumps(result)
|
||||||
|
# except Exception as e:
|
||||||
|
# return f"Error creating file: {str(e)}"
|
||||||
|
|
||||||
|
|
||||||
|
# @dataclass
|
||||||
|
# class ReadFileTool(FunctionTool):
|
||||||
|
# name: str = "astrbot_read_file"
|
||||||
|
# description: str = "Read the content of a file in the sandbox."
|
||||||
|
# parameters: dict = field(
|
||||||
|
# default_factory=lambda: {
|
||||||
|
# "type": "object",
|
||||||
|
# "properties": {
|
||||||
|
# "path": {
|
||||||
|
# "type": "string",
|
||||||
|
# "description": "The path of the file to read, relative to the sandbox root. Must not use absolute paths or traverse outside the sandbox.",
|
||||||
|
# },
|
||||||
|
# },
|
||||||
|
# "required": ["path"],
|
||||||
|
# }
|
||||||
|
# )
|
||||||
|
|
||||||
|
# async def call(self, context: ContextWrapper[AstrAgentContext], path: str):
|
||||||
|
# sb = await get_booter(
|
||||||
|
# context.context.context,
|
||||||
|
# context.context.event.unified_msg_origin,
|
||||||
|
# )
|
||||||
|
# try:
|
||||||
|
# result = await sb.fs.read_file(path)
|
||||||
|
# return result
|
||||||
|
# except Exception as e:
|
||||||
|
# return f"Error reading file: {str(e)}"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class FileUploadTool(FunctionTool):
|
||||||
|
name: str = "astrbot_upload_file"
|
||||||
|
description: str = "Upload a local file to the sandbox. The file must exist on the local filesystem."
|
||||||
|
parameters: dict = field(
|
||||||
|
default_factory=lambda: {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"local_path": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "The local file path to upload. This must be an absolute path to an existing file on the local filesystem.",
|
||||||
|
},
|
||||||
|
# "remote_path": {
|
||||||
|
# "type": "string",
|
||||||
|
# "description": "The filename to use in the sandbox. If not provided, file will be saved to the working directory with the same name as the local file.",
|
||||||
|
# },
|
||||||
|
},
|
||||||
|
"required": ["local_path"],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
async def call(
|
||||||
|
self,
|
||||||
|
context: ContextWrapper[AstrAgentContext],
|
||||||
|
local_path: str,
|
||||||
|
):
|
||||||
|
sb = await get_booter(
|
||||||
|
context.context.context,
|
||||||
|
context.context.event.unified_msg_origin,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
# Check if file exists
|
||||||
|
if not os.path.exists(local_path):
|
||||||
|
return f"Error: File does not exist: {local_path}"
|
||||||
|
|
||||||
|
if not os.path.isfile(local_path):
|
||||||
|
return f"Error: Path is not a file: {local_path}"
|
||||||
|
|
||||||
|
# Use basename if sandbox_filename is not provided
|
||||||
|
remote_path = os.path.basename(local_path)
|
||||||
|
|
||||||
|
# Upload file to sandbox
|
||||||
|
result = await sb.upload_file(local_path, remote_path)
|
||||||
|
logger.debug(f"Upload result: {result}")
|
||||||
|
success = result.get("success", False)
|
||||||
|
|
||||||
|
if not success:
|
||||||
|
return f"Error uploading file: {result.get('message', 'Unknown error')}"
|
||||||
|
|
||||||
|
file_path = result.get("file_path", "")
|
||||||
|
logger.info(f"File {local_path} uploaded to sandbox at {file_path}")
|
||||||
|
|
||||||
|
return f"File uploaded successfully to {file_path}"
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error uploading file {local_path}: {e}")
|
||||||
|
return f"Error uploading file: {str(e)}"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class FileDownloadTool(FunctionTool):
|
||||||
|
name: str = "astrbot_download_file"
|
||||||
|
description: str = "Download a file from the sandbox. Only call this when user explicitly need you to download a file."
|
||||||
|
parameters: dict = field(
|
||||||
|
default_factory=lambda: {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"remote_path": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "The path of the file in the sandbox to download.",
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": ["remote_path"],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
async def call(
|
||||||
|
self,
|
||||||
|
context: ContextWrapper[AstrAgentContext],
|
||||||
|
remote_path: str,
|
||||||
|
) -> ToolExecResult:
|
||||||
|
sb = await get_booter(
|
||||||
|
context.context.context,
|
||||||
|
context.context.event.unified_msg_origin,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
name = os.path.basename(remote_path)
|
||||||
|
|
||||||
|
local_path = os.path.join(get_astrbot_temp_path(), name)
|
||||||
|
|
||||||
|
# Download file from sandbox
|
||||||
|
await sb.download_file(remote_path, local_path)
|
||||||
|
logger.info(f"File {remote_path} downloaded from sandbox to {local_path}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
name = os.path.basename(local_path)
|
||||||
|
await context.context.event.send(
|
||||||
|
MessageChain(chain=[File(name=name, file=local_path)])
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error sending file message: {e}")
|
||||||
|
|
||||||
|
# remove
|
||||||
|
try:
|
||||||
|
os.remove(local_path)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error removing temp file {local_path}: {e}")
|
||||||
|
|
||||||
|
return f"File downloaded successfully to {local_path}"
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error downloading file {remote_path}: {e}")
|
||||||
|
return f"Error downloading file: {str(e)}"
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
from dataclasses import dataclass, field
|
||||||
|
|
||||||
|
import mcp
|
||||||
|
|
||||||
|
from astrbot.api import FunctionTool
|
||||||
|
from astrbot.core.agent.run_context import ContextWrapper
|
||||||
|
from astrbot.core.agent.tool import ToolExecResult
|
||||||
|
from astrbot.core.astr_agent_context import AstrAgentContext
|
||||||
|
from astrbot.core.sandbox.sandbox_client import get_booter
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class PythonTool(FunctionTool):
|
||||||
|
name: str = "astrbot_execute_ipython"
|
||||||
|
description: str = "Execute a command in an IPython shell."
|
||||||
|
parameters: dict = field(
|
||||||
|
default_factory=lambda: {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"code": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "The Python code to execute.",
|
||||||
|
},
|
||||||
|
"silent": {
|
||||||
|
"type": "boolean",
|
||||||
|
"description": "Whether to suppress the output of the code execution.",
|
||||||
|
"default": False,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"required": ["code"],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
async def call(
|
||||||
|
self, context: ContextWrapper[AstrAgentContext], code: str, silent: bool = False
|
||||||
|
) -> ToolExecResult:
|
||||||
|
sb = await get_booter(
|
||||||
|
context.context.context,
|
||||||
|
context.context.event.unified_msg_origin,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
result = await sb.python.exec(code, silent=silent)
|
||||||
|
data = result.get("data", {})
|
||||||
|
output = data.get("output", {})
|
||||||
|
error = data.get("error", "")
|
||||||
|
images: list[dict] = output.get("images", [])
|
||||||
|
text: str = output.get("text", "")
|
||||||
|
|
||||||
|
resp = mcp.types.CallToolResult(content=[])
|
||||||
|
|
||||||
|
if error:
|
||||||
|
resp.content.append(
|
||||||
|
mcp.types.TextContent(type="text", text=f"error: {error}")
|
||||||
|
)
|
||||||
|
|
||||||
|
if images:
|
||||||
|
for img in images:
|
||||||
|
resp.content.append(
|
||||||
|
mcp.types.ImageContent(
|
||||||
|
type="image", data=img["image/png"], mimeType="image/png"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if text:
|
||||||
|
resp.content.append(mcp.types.TextContent(type="text", text=text))
|
||||||
|
|
||||||
|
if not resp.content:
|
||||||
|
resp.content.append(
|
||||||
|
mcp.types.TextContent(type="text", text="No output.")
|
||||||
|
)
|
||||||
|
|
||||||
|
return resp
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
return f"Error executing code: {str(e)}"
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import json
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
|
||||||
|
from astrbot.api import FunctionTool
|
||||||
|
from astrbot.core.agent.run_context import ContextWrapper
|
||||||
|
from astrbot.core.agent.tool import ToolExecResult
|
||||||
|
from astrbot.core.astr_agent_context import AstrAgentContext
|
||||||
|
|
||||||
|
from ..sandbox_client import get_booter
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ExecuteShellTool(FunctionTool):
|
||||||
|
name: str = "astrbot_execute_shell"
|
||||||
|
description: str = "Execute a command in the shell."
|
||||||
|
parameters: dict = field(
|
||||||
|
default_factory=lambda: {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"command": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "The bash command to execute. Equal to 'cd {working_dir} && {your_command}'.",
|
||||||
|
},
|
||||||
|
"background": {
|
||||||
|
"type": "boolean",
|
||||||
|
"description": "Whether to run the command in the background.",
|
||||||
|
"default": False,
|
||||||
|
},
|
||||||
|
"env": {
|
||||||
|
"type": "object",
|
||||||
|
"description": "Optional environment variables to set for the file creation process.",
|
||||||
|
"additionalProperties": {"type": "string"},
|
||||||
|
"default": {},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"required": ["command"],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
async def call(
|
||||||
|
self,
|
||||||
|
context: ContextWrapper[AstrAgentContext],
|
||||||
|
command: str,
|
||||||
|
background: bool = False,
|
||||||
|
env: dict = {},
|
||||||
|
) -> ToolExecResult:
|
||||||
|
sb = await get_booter(
|
||||||
|
context.context.context,
|
||||||
|
context.context.event.unified_msg_origin,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
result = await sb.shell.exec(command, background=background, env=env)
|
||||||
|
return json.dumps(result)
|
||||||
|
except Exception as e:
|
||||||
|
return f"Error executing command: {str(e)}"
|
||||||
@@ -296,6 +296,8 @@ class ChatRoute(Route):
|
|||||||
# 构建用户消息段(包含 path 用于传递给 adapter)
|
# 构建用户消息段(包含 path 用于传递给 adapter)
|
||||||
message_parts = await self._build_user_message_parts(message)
|
message_parts = await self._build_user_message_parts(message)
|
||||||
|
|
||||||
|
message_id = str(uuid.uuid4())
|
||||||
|
|
||||||
async def stream():
|
async def stream():
|
||||||
client_disconnected = False
|
client_disconnected = False
|
||||||
accumulated_parts = []
|
accumulated_parts = []
|
||||||
@@ -319,6 +321,13 @@ class ChatRoute(Route):
|
|||||||
if not result:
|
if not result:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
if (
|
||||||
|
"message_id" in result
|
||||||
|
and result["message_id"] != message_id
|
||||||
|
):
|
||||||
|
logger.warning("webchat stream message_id mismatch")
|
||||||
|
continue
|
||||||
|
|
||||||
result_text = result["data"]
|
result_text = result["data"]
|
||||||
msg_type = result.get("type")
|
msg_type = result.get("type")
|
||||||
streaming = result.get("streaming", False)
|
streaming = result.get("streaming", False)
|
||||||
@@ -456,6 +465,7 @@ class ChatRoute(Route):
|
|||||||
"selected_provider": selected_provider,
|
"selected_provider": selected_provider,
|
||||||
"selected_model": selected_model,
|
"selected_model": selected_model,
|
||||||
"enable_streaming": enable_streaming,
|
"enable_streaming": enable_streaming,
|
||||||
|
"message_id": message_id,
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -28,7 +28,7 @@
|
|||||||
"katex": "^0.16.27",
|
"katex": "^0.16.27",
|
||||||
"lodash": "4.17.21",
|
"lodash": "4.17.21",
|
||||||
"markdown-it": "^14.1.0",
|
"markdown-it": "^14.1.0",
|
||||||
"markstream-vue": "0.0.3-beta.7",
|
"markstream-vue": "^0.0.6-beta.1",
|
||||||
"mermaid": "^11.12.2",
|
"mermaid": "^11.12.2",
|
||||||
"pinia": "2.1.6",
|
"pinia": "2.1.6",
|
||||||
"pinyin-pro": "^3.26.0",
|
"pinyin-pro": "^3.26.0",
|
||||||
|
|||||||
@@ -28,7 +28,7 @@
|
|||||||
<div v-else-if="part.type === 'image' && part.embedded_url" class="image-attachments">
|
<div v-else-if="part.type === 'image' && part.embedded_url" class="image-attachments">
|
||||||
<div class="image-attachment">
|
<div class="image-attachment">
|
||||||
<img :src="part.embedded_url" class="attached-image"
|
<img :src="part.embedded_url" class="attached-image"
|
||||||
@click="$emit('openImagePreview', part.embedded_url)" />
|
@click="openImagePreview(part.embedded_url)" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -147,6 +147,25 @@
|
|||||||
borderTopColor: 'rgba(100, 140, 200, 0.3)',
|
borderTopColor: 'rgba(100, 140, 200, 0.3)',
|
||||||
backgroundColor: 'rgba(30, 45, 70, 0.5)'
|
backgroundColor: 'rgba(30, 45, 70, 0.5)'
|
||||||
} : {}">
|
} : {}">
|
||||||
|
<!-- Special rendering for iPython tool -->
|
||||||
|
<template v-if="isIPythonTool(toolCall)">
|
||||||
|
<div class="ipython-code-container">
|
||||||
|
<!-- <div class="detail-label ipython-label">Code:</div> -->
|
||||||
|
<div v-if="shikiReady && getIPythonCode(toolCall)"
|
||||||
|
class="ipython-code-highlighted"
|
||||||
|
v-html="highlightIPythonCode(getIPythonCode(toolCall))"></div>
|
||||||
|
<pre v-else class="detail-value detail-json"
|
||||||
|
:style="isDark ? { backgroundColor: 'transparent' } : {}">{{ getIPythonCode(toolCall) || 'No code available' }}</pre>
|
||||||
|
</div>
|
||||||
|
<div v-if="toolCall.result" class="tool-call-detail-row">
|
||||||
|
<span class="detail-label">Result:</span>
|
||||||
|
<pre class="detail-value detail-json detail-result"
|
||||||
|
:style="isDark ? { backgroundColor: 'transparent' } : {}">{{ formatToolResult(toolCall.result) }}</pre>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- Default rendering for other tools -->
|
||||||
|
<template v-else>
|
||||||
<div class="tool-call-detail-row">
|
<div class="tool-call-detail-row">
|
||||||
<span class="detail-label">ID:</span>
|
<span class="detail-label">ID:</span>
|
||||||
<code class="detail-value"
|
<code class="detail-value"
|
||||||
@@ -165,6 +184,7 @@
|
|||||||
:style="isDark ? { backgroundColor: 'transparent' } : {}">{{ formatToolResult(toolCall.result) }}
|
:style="isDark ? { backgroundColor: 'transparent' } : {}">{{ formatToolResult(toolCall.result) }}
|
||||||
</pre>
|
</pre>
|
||||||
</div>
|
</div>
|
||||||
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -178,7 +198,7 @@
|
|||||||
<div v-else-if="part.type === 'image' && part.embedded_url" class="embedded-images">
|
<div v-else-if="part.type === 'image' && part.embedded_url" class="embedded-images">
|
||||||
<div class="embedded-image">
|
<div class="embedded-image">
|
||||||
<img :src="part.embedded_url" class="bot-embedded-image"
|
<img :src="part.embedded_url" class="bot-embedded-image"
|
||||||
@click="$emit('openImagePreview', part.embedded_url)" />
|
@click="openImagePreview(part.embedded_url)" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -289,6 +309,13 @@
|
|||||||
</v-btn>
|
</v-btn>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- 图片预览 Overlay -->
|
||||||
|
<v-overlay v-model="imagePreview.show" class="image-preview-overlay" @click="closeImagePreview">
|
||||||
|
<div class="image-preview-container" @click.stop>
|
||||||
|
<img :src="imagePreview.url" class="preview-image" @click="closeImagePreview" />
|
||||||
|
</div>
|
||||||
|
</v-overlay>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
@@ -298,6 +325,7 @@ import 'markstream-vue/index.css'
|
|||||||
import 'katex/dist/katex.min.css'
|
import 'katex/dist/katex.min.css'
|
||||||
import 'highlight.js/styles/github.css';
|
import 'highlight.js/styles/github.css';
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
|
import { createHighlighter } from 'shiki';
|
||||||
|
|
||||||
enableKatex();
|
enableKatex();
|
||||||
enableMermaid();
|
enableMermaid();
|
||||||
@@ -351,15 +379,24 @@ export default {
|
|||||||
content: '',
|
content: '',
|
||||||
messageIndex: null,
|
messageIndex: null,
|
||||||
position: { top: 0, left: 0 }
|
position: { top: 0, left: 0 }
|
||||||
}
|
},
|
||||||
|
// 图片预览
|
||||||
|
imagePreview: {
|
||||||
|
show: false,
|
||||||
|
url: ''
|
||||||
|
},
|
||||||
|
// Shiki highlighter
|
||||||
|
shikiHighlighter: null,
|
||||||
|
shikiReady: false
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
mounted() {
|
async mounted() {
|
||||||
this.initCodeCopyButtons();
|
this.initCodeCopyButtons();
|
||||||
this.initImageClickEvents();
|
this.initImageClickEvents();
|
||||||
this.addScrollListener();
|
this.addScrollListener();
|
||||||
this.scrollToBottom();
|
this.scrollToBottom();
|
||||||
this.startElapsedTimeTimer();
|
this.startElapsedTimeTimer();
|
||||||
|
await this.initShiki();
|
||||||
},
|
},
|
||||||
updated() {
|
updated() {
|
||||||
this.initCodeCopyButtons();
|
this.initCodeCopyButtons();
|
||||||
@@ -676,7 +713,7 @@ export default {
|
|||||||
if (!img.hasAttribute('data-click-enabled')) {
|
if (!img.hasAttribute('data-click-enabled')) {
|
||||||
img.style.cursor = 'pointer';
|
img.style.cursor = 'pointer';
|
||||||
img.setAttribute('data-click-enabled', 'true');
|
img.setAttribute('data-click-enabled', 'true');
|
||||||
img.onclick = () => this.$emit('openImagePreview', img.src);
|
img.onclick = () => this.openImagePreview(img.src);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -877,6 +914,66 @@ export default {
|
|||||||
formatTTFT(ttft) {
|
formatTTFT(ttft) {
|
||||||
if (!ttft || ttft <= 0) return '';
|
if (!ttft || ttft <= 0) return '';
|
||||||
return this.formatDuration(ttft);
|
return this.formatDuration(ttft);
|
||||||
|
},
|
||||||
|
|
||||||
|
// 打开图片预览
|
||||||
|
openImagePreview(url) {
|
||||||
|
this.imagePreview.url = url;
|
||||||
|
this.imagePreview.show = true;
|
||||||
|
},
|
||||||
|
|
||||||
|
// 关闭图片预览
|
||||||
|
closeImagePreview() {
|
||||||
|
this.imagePreview.show = false;
|
||||||
|
setTimeout(() => {
|
||||||
|
this.imagePreview.url = '';
|
||||||
|
}, 300);
|
||||||
|
},
|
||||||
|
|
||||||
|
// Initialize Shiki highlighter
|
||||||
|
async initShiki() {
|
||||||
|
try {
|
||||||
|
this.shikiHighlighter = await createHighlighter({
|
||||||
|
themes: ['nord', 'github-light'],
|
||||||
|
langs: ['python']
|
||||||
|
});
|
||||||
|
this.shikiReady = true;
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to initialize Shiki:', err);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Check if tool is iPython executor
|
||||||
|
isIPythonTool(toolCall) {
|
||||||
|
return toolCall.name === 'astrbot_execute_ipython';
|
||||||
|
},
|
||||||
|
|
||||||
|
// Get iPython code from tool args
|
||||||
|
getIPythonCode(toolCall) {
|
||||||
|
try {
|
||||||
|
if (toolCall.args && toolCall.args.code) {
|
||||||
|
return toolCall.args.code;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to get iPython code:', err);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
|
||||||
|
// Highlight iPython code with Shiki
|
||||||
|
highlightIPythonCode(code) {
|
||||||
|
if (!this.shikiReady || !this.shikiHighlighter || !code) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return this.shikiHighlighter.codeToHtml(code, {
|
||||||
|
lang: 'python',
|
||||||
|
theme: this.isDark ? 'nord' : 'github-light'
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to highlight code:', err);
|
||||||
|
return `<pre><code>${code}</code></pre>`;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1268,10 +1365,10 @@ export default {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.bot-embedded-image {
|
.bot-embedded-image {
|
||||||
max-width: 40%;
|
max-width: 55%;
|
||||||
width: auto;
|
width: auto;
|
||||||
height: auto;
|
height: auto;
|
||||||
border-radius: 8px;
|
border-radius: 4px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: transform 0.2s ease;
|
transition: transform 0.2s ease;
|
||||||
}
|
}
|
||||||
@@ -1423,12 +1520,14 @@ export default {
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
background-color: #eff3f6;
|
background-color: #eff3f6;
|
||||||
margin: 8px 0px;
|
margin: 8px 0px;
|
||||||
max-width: 300px;
|
width: fit-content;
|
||||||
transition: max-width 0.1s ease;
|
min-width: 320px;
|
||||||
|
max-width: 100%;
|
||||||
|
transition: all 0.1s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tool-call-card.expanded {
|
.tool-call-card.expanded {
|
||||||
max-width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tool-call-header {
|
.tool-call-header {
|
||||||
@@ -1595,6 +1694,34 @@ export default {
|
|||||||
max-height: 300px;
|
max-height: 300px;
|
||||||
background-color: transparent;
|
background-color: transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* iPython Tool Special Styles */
|
||||||
|
.ipython-code-container {
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ipython-label {
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ipython-code-highlighted {
|
||||||
|
border-radius: 6px;
|
||||||
|
overflow-x: auto;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ipython-code-highlighted :deep(pre) {
|
||||||
|
margin: 0;
|
||||||
|
padding: 12px;
|
||||||
|
border-radius: 6px;
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ipython-code-highlighted :deep(code) {
|
||||||
|
font-family: 'Fira Code', 'Consolas', monospace;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
@@ -1635,4 +1762,36 @@ export default {
|
|||||||
font-family: 'Fira Code', 'Consolas', monospace;
|
font-family: 'Fira Code', 'Consolas', monospace;
|
||||||
color: var(--v-theme-primaryText);
|
color: var(--v-theme-primaryText);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 图片预览样式 */
|
||||||
|
.image-preview-overlay {
|
||||||
|
z-index: 9999;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-preview-container {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-image {
|
||||||
|
max-width: 90vw;
|
||||||
|
max-height: 90vh;
|
||||||
|
object-fit: contain;
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.close-preview-btn {
|
||||||
|
position: fixed;
|
||||||
|
top: 20px;
|
||||||
|
right: 20px;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -14,6 +14,12 @@
|
|||||||
<v-expand-transition>
|
<v-expand-transition>
|
||||||
<div v-show="expanded" style="padding: 0 8px;">
|
<div v-show="expanded" style="padding: 0 8px;">
|
||||||
<v-list density="compact" nav class="project-list" style="background-color: transparent;">
|
<v-list density="compact" nav class="project-list" style="background-color: transparent;">
|
||||||
|
<v-list-item @click="$emit('createProject')" class="create-project-item" rounded="lg">
|
||||||
|
<template v-slot:prepend>
|
||||||
|
<span class="project-emoji"><v-icon size="small">mdi-plus</v-icon></span>
|
||||||
|
</template>
|
||||||
|
<v-list-item-title style="font-size: 13px;">{{ tm('project.create') }}</v-list-item-title>
|
||||||
|
</v-list-item>
|
||||||
<v-list-item v-for="project in projects" :key="project.project_id"
|
<v-list-item v-for="project in projects" :key="project.project_id"
|
||||||
@click="$emit('selectProject', project.project_id)" rounded="lg" class="project-item">
|
@click="$emit('selectProject', project.project_id)" rounded="lg" class="project-item">
|
||||||
<template v-slot:prepend>
|
<template v-slot:prepend>
|
||||||
@@ -29,12 +35,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</v-list-item>
|
</v-list-item>
|
||||||
<v-list-item @click="$emit('createProject')" class="create-project-item" rounded="lg">
|
|
||||||
<template v-slot:prepend>
|
|
||||||
<v-icon size="small">mdi-plus</v-icon>
|
|
||||||
</template>
|
|
||||||
<v-list-item-title style="font-size: 13px;">{{ tm('project.create') }}</v-list-item-title>
|
|
||||||
</v-list-item>
|
|
||||||
</v-list>
|
</v-list>
|
||||||
</div>
|
</div>
|
||||||
</v-expand-transition>
|
</v-expand-transition>
|
||||||
|
|||||||
@@ -133,6 +133,36 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"sandbox": {
|
||||||
|
"description": "Agent Sandbox Env(Beta)",
|
||||||
|
"provider_settings": {
|
||||||
|
"sandbox": {
|
||||||
|
"enable": {
|
||||||
|
"description": "Enable Sandbox Env",
|
||||||
|
"hint": "When enabled, Agent can use tools and resources in the sandbox environment, such as Python tool, Shell, etc."
|
||||||
|
},
|
||||||
|
"booter": {
|
||||||
|
"description": "Sandbox Environment Driver"
|
||||||
|
},
|
||||||
|
"shipyard_endpoint": {
|
||||||
|
"description": "Shipyard API Endpoint",
|
||||||
|
"hint": "API access address for Shipyard service."
|
||||||
|
},
|
||||||
|
"shipyard_access_token": {
|
||||||
|
"description": "Shipyard Access Token",
|
||||||
|
"hint": "Access token for accessing Shipyard service."
|
||||||
|
},
|
||||||
|
"shipyard_ttl": {
|
||||||
|
"description": "Shipyard Session TTL",
|
||||||
|
"hint": "Session time-to-live in seconds."
|
||||||
|
},
|
||||||
|
"shipyard_max_sessions": {
|
||||||
|
"description": "Shipyard Max Sessions",
|
||||||
|
"hint": "Maximum number of Shipyard sessions an instance can handle."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"truncate_and_compress": {
|
"truncate_and_compress": {
|
||||||
"description": "Context Management Strategy",
|
"description": "Context Management Strategy",
|
||||||
"provider_settings": {
|
"provider_settings": {
|
||||||
|
|||||||
@@ -133,6 +133,36 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"sandbox": {
|
||||||
|
"description": "Agent 沙箱环境(Beta)",
|
||||||
|
"provider_settings": {
|
||||||
|
"sandbox": {
|
||||||
|
"enable": {
|
||||||
|
"description": "启用沙箱环境",
|
||||||
|
"hint": "启用后,Agent 可以使用沙箱环境中的工具和资源,如 Python 代码执行、Shell 等。"
|
||||||
|
},
|
||||||
|
"booter": {
|
||||||
|
"description": "沙箱环境驱动器"
|
||||||
|
},
|
||||||
|
"shipyard_endpoint": {
|
||||||
|
"description": "Shipyard API Endpoint",
|
||||||
|
"hint": "Shipyard 服务的 API 访问地址。"
|
||||||
|
},
|
||||||
|
"shipyard_access_token": {
|
||||||
|
"description": "Shipyard 访问令牌",
|
||||||
|
"hint": "用于访问 Shipyard 服务的访问令牌。"
|
||||||
|
},
|
||||||
|
"shipyard_ttl": {
|
||||||
|
"description": "Shipyard Ship 存活时间(秒)",
|
||||||
|
"hint": "Shipyard 会话的生存时间(秒)。"
|
||||||
|
},
|
||||||
|
"shipyard_max_sessions": {
|
||||||
|
"description": "Shipyard Ship 会话复用上限",
|
||||||
|
"hint": "决定了一个实例承载的最大会话数量。"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"truncate_and_compress": {
|
"truncate_and_compress": {
|
||||||
"description": "上下文管理策略",
|
"description": "上下文管理策略",
|
||||||
"provider_settings": {
|
"provider_settings": {
|
||||||
|
|||||||
@@ -60,6 +60,7 @@ dependencies = [
|
|||||||
"markitdown-no-magika[docx,xls,xlsx]>=0.1.2",
|
"markitdown-no-magika[docx,xls,xlsx]>=0.1.2",
|
||||||
"xinference-client",
|
"xinference-client",
|
||||||
"tenacity>=9.1.2",
|
"tenacity>=9.1.2",
|
||||||
|
"shipyard-python-sdk>=0.2.4",
|
||||||
]
|
]
|
||||||
|
|
||||||
[dependency-groups]
|
[dependency-groups]
|
||||||
|
|||||||
Reference in New Issue
Block a user