Compare commits
46 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 71d4357ca7 | |||
| 97081bf543 | |||
| 9c9239073e | |||
| c78ac6acd7 | |||
| ac427af3c8 | |||
| b7160c9c58 | |||
| 8aeb2229ce | |||
| 86715813ad | |||
| a363a2ddcd | |||
| f2af8e58e2 | |||
| 937f0b7f32 | |||
| c52ab1346b | |||
| c8fca4e6a0 | |||
| 45397e941d | |||
| ce0a024757 | |||
| 179f3e6426 | |||
| 561741d43d | |||
| 63e8d0634f | |||
| 350667b60f | |||
| 792e348076 | |||
| 068094708e | |||
| 661bcfd890 | |||
| 0a58eaecdd | |||
| d564926e6b | |||
| 7d1709667e | |||
| ebdecf8dce | |||
| 3698b771dd | |||
| 12b4ee0a2b | |||
| 9de0fe304c | |||
| e5cac2684f | |||
| 1646547cb4 | |||
| dca88d8ab8 | |||
| 2e2da4b4ce | |||
| 6df966e9a2 | |||
| 6a86dae76e | |||
| a7eca40fe7 | |||
| ef28dc5001 | |||
| a89e7b3f55 | |||
| ea7c387fcb | |||
| 350c18b741 | |||
| d29ac4023a | |||
| fdbed75ce4 | |||
| 9fec29c1a3 | |||
| 972b5ffb86 | |||
| 33e67bf925 | |||
| 185501d1b5 |
@@ -2,7 +2,7 @@
|
||||
|
||||
> 我们热爱开源软件,并始终致力于为所有用户提供健康、安全、可靠的使用体验。 ❤️
|
||||
|
||||
For Enlish edition, please refer to the section below the Chinese version.
|
||||
For English edition, please refer to the section below the Chinese version.
|
||||
|
||||
**最后更新:** 2026-01-12
|
||||
|
||||
@@ -15,8 +15,7 @@ For Enlish edition, please refer to the section below the Chinese version.
|
||||
|
||||
AstrBot 是一个遵循 **GNU Affero General Public License v3(AGPLv3)** 协议发布的**免费开源软件项目**。
|
||||
|
||||
* AstrBot 项目不构成任何形式的商业服务;
|
||||
* AstrBot 团队不通过本项目提供任何收费服务。
|
||||
* 截至目前,AstrBot 项目未开展任何形式的商业化服务,AstrBot 团队也未通过本项目向用户提供任何收费服务。若您因使用 AstrBot 被要求付费,请务必提高警惕,谨防诈骗行为。
|
||||
* AstrBot 的代码实现未对任何第三方系统进行逆向工程、破解、反编译或绕过安全机制等行为。AstrBot 仅使用并支持各即时通讯(IM)平台官方公开提供的机器人接入接口、开放平台能力或相关通信协议进行集成与通信。
|
||||
|
||||
## 2. 无担保声明
|
||||
|
||||
@@ -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",
|
||||
"moonshotai_api_key": "",
|
||||
},
|
||||
"sandbox": {
|
||||
"enable": False,
|
||||
"booter": "shipyard",
|
||||
"shipyard_endpoint": "",
|
||||
"shipyard_access_token": "",
|
||||
"shipyard_ttl": 3600,
|
||||
"shipyard_max_sessions": 10,
|
||||
},
|
||||
},
|
||||
"provider_stt_settings": {
|
||||
"enable": False,
|
||||
@@ -242,7 +250,7 @@ CONFIG_METADATA_2 = {
|
||||
"callback_server_host": "0.0.0.0",
|
||||
"port": 6196,
|
||||
},
|
||||
"OneBot v11 (QQ 个人号等)": {
|
||||
"OneBot v11": {
|
||||
"id": "default",
|
||||
"type": "aiocqhttp",
|
||||
"enable": False,
|
||||
@@ -2539,6 +2547,62 @@ CONFIG_METADATA_3 = {
|
||||
# "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": {
|
||||
"description": "上下文管理策略",
|
||||
"type": "object",
|
||||
|
||||
@@ -9,6 +9,7 @@ from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_asyn
|
||||
|
||||
from astrbot.core.db.po import (
|
||||
Attachment,
|
||||
ChatUIProject,
|
||||
CommandConfig,
|
||||
CommandConflict,
|
||||
ConversationV2,
|
||||
@@ -17,6 +18,7 @@ from astrbot.core.db.po import (
|
||||
PlatformSession,
|
||||
PlatformStat,
|
||||
Preference,
|
||||
SessionProjectRelation,
|
||||
Stats,
|
||||
)
|
||||
|
||||
@@ -446,8 +448,11 @@ class BaseDatabase(abc.ABC):
|
||||
platform_id: str | None = None,
|
||||
page: int = 1,
|
||||
page_size: int = 20,
|
||||
) -> list[PlatformSession]:
|
||||
"""Get all Platform sessions for a specific creator (username) and optionally platform."""
|
||||
) -> list[dict]:
|
||||
"""Get all Platform sessions for a specific creator (username) and optionally platform.
|
||||
|
||||
Returns a list of dicts containing session info and project info (if session belongs to a project).
|
||||
"""
|
||||
...
|
||||
|
||||
@abc.abstractmethod
|
||||
@@ -463,3 +468,80 @@ class BaseDatabase(abc.ABC):
|
||||
async def delete_platform_session(self, session_id: str) -> None:
|
||||
"""Delete a Platform session by its ID."""
|
||||
...
|
||||
|
||||
# ====
|
||||
# ChatUI Project Management
|
||||
# ====
|
||||
|
||||
@abc.abstractmethod
|
||||
async def create_chatui_project(
|
||||
self,
|
||||
creator: str,
|
||||
title: str,
|
||||
emoji: str | None = "📁",
|
||||
description: str | None = None,
|
||||
) -> ChatUIProject:
|
||||
"""Create a new ChatUI project."""
|
||||
...
|
||||
|
||||
@abc.abstractmethod
|
||||
async def get_chatui_project_by_id(self, project_id: str) -> ChatUIProject | None:
|
||||
"""Get a ChatUI project by its ID."""
|
||||
...
|
||||
|
||||
@abc.abstractmethod
|
||||
async def get_chatui_projects_by_creator(
|
||||
self,
|
||||
creator: str,
|
||||
page: int = 1,
|
||||
page_size: int = 100,
|
||||
) -> list[ChatUIProject]:
|
||||
"""Get all ChatUI projects for a specific creator."""
|
||||
...
|
||||
|
||||
@abc.abstractmethod
|
||||
async def update_chatui_project(
|
||||
self,
|
||||
project_id: str,
|
||||
title: str | None = None,
|
||||
emoji: str | None = None,
|
||||
description: str | None = None,
|
||||
) -> None:
|
||||
"""Update a ChatUI project."""
|
||||
...
|
||||
|
||||
@abc.abstractmethod
|
||||
async def delete_chatui_project(self, project_id: str) -> None:
|
||||
"""Delete a ChatUI project by its ID."""
|
||||
...
|
||||
|
||||
@abc.abstractmethod
|
||||
async def add_session_to_project(
|
||||
self,
|
||||
session_id: str,
|
||||
project_id: str,
|
||||
) -> SessionProjectRelation:
|
||||
"""Add a session to a project."""
|
||||
...
|
||||
|
||||
@abc.abstractmethod
|
||||
async def remove_session_from_project(self, session_id: str) -> None:
|
||||
"""Remove a session from its project."""
|
||||
...
|
||||
|
||||
@abc.abstractmethod
|
||||
async def get_project_sessions(
|
||||
self,
|
||||
project_id: str,
|
||||
page: int = 1,
|
||||
page_size: int = 100,
|
||||
) -> list[PlatformSession]:
|
||||
"""Get all sessions in a project."""
|
||||
...
|
||||
|
||||
@abc.abstractmethod
|
||||
async def get_project_by_session(
|
||||
self, session_id: str, creator: str
|
||||
) -> ChatUIProject | None:
|
||||
"""Get the project that a session belongs to."""
|
||||
...
|
||||
|
||||
@@ -239,6 +239,71 @@ class Attachment(SQLModel, table=True):
|
||||
)
|
||||
|
||||
|
||||
class ChatUIProject(SQLModel, table=True):
|
||||
"""This class represents projects for organizing ChatUI conversations.
|
||||
|
||||
Projects allow users to group related conversations together.
|
||||
"""
|
||||
|
||||
__tablename__: str = "chatui_projects"
|
||||
|
||||
inner_id: int | None = Field(
|
||||
primary_key=True,
|
||||
sa_column_kwargs={"autoincrement": True},
|
||||
default=None,
|
||||
)
|
||||
project_id: str = Field(
|
||||
max_length=36,
|
||||
nullable=False,
|
||||
unique=True,
|
||||
default_factory=lambda: str(uuid.uuid4()),
|
||||
)
|
||||
creator: str = Field(nullable=False)
|
||||
"""Username of the project creator"""
|
||||
emoji: str | None = Field(default="📁", max_length=10)
|
||||
"""Emoji icon for the project"""
|
||||
title: str = Field(nullable=False, max_length=255)
|
||||
"""Title of the project"""
|
||||
description: str | None = Field(default=None, max_length=1000)
|
||||
"""Description of the project"""
|
||||
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
|
||||
updated_at: datetime = Field(
|
||||
default_factory=lambda: datetime.now(timezone.utc),
|
||||
sa_column_kwargs={"onupdate": datetime.now(timezone.utc)},
|
||||
)
|
||||
|
||||
__table_args__ = (
|
||||
UniqueConstraint(
|
||||
"project_id",
|
||||
name="uix_chatui_project_id",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class SessionProjectRelation(SQLModel, table=True):
|
||||
"""This class represents the relationship between platform sessions and ChatUI projects."""
|
||||
|
||||
__tablename__: str = "session_project_relations"
|
||||
|
||||
id: int | None = Field(
|
||||
primary_key=True,
|
||||
sa_column_kwargs={"autoincrement": True},
|
||||
default=None,
|
||||
)
|
||||
session_id: str = Field(nullable=False, max_length=100)
|
||||
"""Session ID from PlatformSession"""
|
||||
project_id: str = Field(nullable=False, max_length=36)
|
||||
"""Project ID from ChatUIProject"""
|
||||
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
|
||||
|
||||
__table_args__ = (
|
||||
UniqueConstraint(
|
||||
"session_id",
|
||||
name="uix_session_project_relation",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class CommandConfig(SQLModel, table=True):
|
||||
"""Per-command configuration overrides for dashboard management."""
|
||||
|
||||
|
||||
+225
-4
@@ -11,6 +11,7 @@ from sqlmodel import col, delete, desc, func, or_, select, text, update
|
||||
from astrbot.core.db import BaseDatabase
|
||||
from astrbot.core.db.po import (
|
||||
Attachment,
|
||||
ChatUIProject,
|
||||
CommandConfig,
|
||||
CommandConflict,
|
||||
ConversationV2,
|
||||
@@ -19,6 +20,7 @@ from astrbot.core.db.po import (
|
||||
PlatformSession,
|
||||
PlatformStat,
|
||||
Preference,
|
||||
SessionProjectRelation,
|
||||
SQLModel,
|
||||
)
|
||||
from astrbot.core.db.po import (
|
||||
@@ -1060,12 +1062,35 @@ class SQLiteDatabase(BaseDatabase):
|
||||
platform_id: str | None = None,
|
||||
page: int = 1,
|
||||
page_size: int = 20,
|
||||
) -> list[PlatformSession]:
|
||||
"""Get all Platform sessions for a specific creator (username) and optionally platform."""
|
||||
) -> list[dict]:
|
||||
"""Get all Platform sessions for a specific creator (username) and optionally platform.
|
||||
|
||||
Returns a list of dicts containing session info and project info (if session belongs to a project).
|
||||
"""
|
||||
async with self.get_db() as session:
|
||||
session: AsyncSession
|
||||
offset = (page - 1) * page_size
|
||||
query = select(PlatformSession).where(PlatformSession.creator == creator)
|
||||
|
||||
# LEFT JOIN with SessionProjectRelation and ChatUIProject to get project info
|
||||
query = (
|
||||
select(
|
||||
PlatformSession,
|
||||
col(ChatUIProject.project_id),
|
||||
col(ChatUIProject.title).label("project_title"),
|
||||
col(ChatUIProject.emoji).label("project_emoji"),
|
||||
)
|
||||
.outerjoin(
|
||||
SessionProjectRelation,
|
||||
col(PlatformSession.session_id)
|
||||
== col(SessionProjectRelation.session_id),
|
||||
)
|
||||
.outerjoin(
|
||||
ChatUIProject,
|
||||
col(SessionProjectRelation.project_id)
|
||||
== col(ChatUIProject.project_id),
|
||||
)
|
||||
.where(col(PlatformSession.creator) == creator)
|
||||
)
|
||||
|
||||
if platform_id:
|
||||
query = query.where(PlatformSession.platform_id == platform_id)
|
||||
@@ -1076,7 +1101,24 @@ class SQLiteDatabase(BaseDatabase):
|
||||
.limit(page_size)
|
||||
)
|
||||
result = await session.execute(query)
|
||||
return list(result.scalars().all())
|
||||
|
||||
# Convert to list of dicts with session and project info
|
||||
sessions_with_projects = []
|
||||
for row in result.all():
|
||||
platform_session = row[0]
|
||||
project_id = row[1]
|
||||
project_title = row[2]
|
||||
project_emoji = row[3]
|
||||
|
||||
session_dict = {
|
||||
"session": platform_session,
|
||||
"project_id": project_id,
|
||||
"project_title": project_title,
|
||||
"project_emoji": project_emoji,
|
||||
}
|
||||
sessions_with_projects.append(session_dict)
|
||||
|
||||
return sessions_with_projects
|
||||
|
||||
async def update_platform_session(
|
||||
self,
|
||||
@@ -1107,3 +1149,182 @@ class SQLiteDatabase(BaseDatabase):
|
||||
col(PlatformSession.session_id) == session_id,
|
||||
),
|
||||
)
|
||||
|
||||
# ====
|
||||
# ChatUI Project Management
|
||||
# ====
|
||||
|
||||
async def create_chatui_project(
|
||||
self,
|
||||
creator: str,
|
||||
title: str,
|
||||
emoji: str | None = "📁",
|
||||
description: str | None = None,
|
||||
) -> ChatUIProject:
|
||||
"""Create a new ChatUI project."""
|
||||
async with self.get_db() as session:
|
||||
session: AsyncSession
|
||||
async with session.begin():
|
||||
project = ChatUIProject(
|
||||
creator=creator,
|
||||
title=title,
|
||||
emoji=emoji,
|
||||
description=description,
|
||||
)
|
||||
session.add(project)
|
||||
await session.flush()
|
||||
await session.refresh(project)
|
||||
return project
|
||||
|
||||
async def get_chatui_project_by_id(self, project_id: str) -> ChatUIProject | None:
|
||||
"""Get a ChatUI project by its ID."""
|
||||
async with self.get_db() as session:
|
||||
session: AsyncSession
|
||||
result = await session.execute(
|
||||
select(ChatUIProject).where(
|
||||
col(ChatUIProject.project_id) == project_id,
|
||||
),
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
async def get_chatui_projects_by_creator(
|
||||
self,
|
||||
creator: str,
|
||||
page: int = 1,
|
||||
page_size: int = 100,
|
||||
) -> list[ChatUIProject]:
|
||||
"""Get all ChatUI projects for a specific creator."""
|
||||
async with self.get_db() as session:
|
||||
session: AsyncSession
|
||||
offset = (page - 1) * page_size
|
||||
result = await session.execute(
|
||||
select(ChatUIProject)
|
||||
.where(col(ChatUIProject.creator) == creator)
|
||||
.order_by(desc(ChatUIProject.updated_at))
|
||||
.limit(page_size)
|
||||
.offset(offset),
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
|
||||
async def update_chatui_project(
|
||||
self,
|
||||
project_id: str,
|
||||
title: str | None = None,
|
||||
emoji: str | None = None,
|
||||
description: str | None = None,
|
||||
) -> None:
|
||||
"""Update a ChatUI project."""
|
||||
async with self.get_db() as session:
|
||||
session: AsyncSession
|
||||
async with session.begin():
|
||||
values: dict[str, T.Any] = {"updated_at": datetime.now(timezone.utc)}
|
||||
if title is not None:
|
||||
values["title"] = title
|
||||
if emoji is not None:
|
||||
values["emoji"] = emoji
|
||||
if description is not None:
|
||||
values["description"] = description
|
||||
|
||||
await session.execute(
|
||||
update(ChatUIProject)
|
||||
.where(col(ChatUIProject.project_id) == project_id)
|
||||
.values(**values),
|
||||
)
|
||||
|
||||
async def delete_chatui_project(self, project_id: str) -> None:
|
||||
"""Delete a ChatUI project by its ID."""
|
||||
async with self.get_db() as session:
|
||||
session: AsyncSession
|
||||
async with session.begin():
|
||||
# First remove all session relations
|
||||
await session.execute(
|
||||
delete(SessionProjectRelation).where(
|
||||
col(SessionProjectRelation.project_id) == project_id,
|
||||
),
|
||||
)
|
||||
# Then delete the project
|
||||
await session.execute(
|
||||
delete(ChatUIProject).where(
|
||||
col(ChatUIProject.project_id) == project_id,
|
||||
),
|
||||
)
|
||||
|
||||
async def add_session_to_project(
|
||||
self,
|
||||
session_id: str,
|
||||
project_id: str,
|
||||
) -> SessionProjectRelation:
|
||||
"""Add a session to a project."""
|
||||
async with self.get_db() as session:
|
||||
session: AsyncSession
|
||||
async with session.begin():
|
||||
# First remove existing relation if any
|
||||
await session.execute(
|
||||
delete(SessionProjectRelation).where(
|
||||
col(SessionProjectRelation.session_id) == session_id,
|
||||
),
|
||||
)
|
||||
# Then create new relation
|
||||
relation = SessionProjectRelation(
|
||||
session_id=session_id,
|
||||
project_id=project_id,
|
||||
)
|
||||
session.add(relation)
|
||||
await session.flush()
|
||||
await session.refresh(relation)
|
||||
return relation
|
||||
|
||||
async def remove_session_from_project(self, session_id: str) -> None:
|
||||
"""Remove a session from its project."""
|
||||
async with self.get_db() as session:
|
||||
session: AsyncSession
|
||||
async with session.begin():
|
||||
await session.execute(
|
||||
delete(SessionProjectRelation).where(
|
||||
col(SessionProjectRelation.session_id) == session_id,
|
||||
),
|
||||
)
|
||||
|
||||
async def get_project_sessions(
|
||||
self,
|
||||
project_id: str,
|
||||
page: int = 1,
|
||||
page_size: int = 100,
|
||||
) -> list[PlatformSession]:
|
||||
"""Get all sessions in a project."""
|
||||
async with self.get_db() as session:
|
||||
session: AsyncSession
|
||||
offset = (page - 1) * page_size
|
||||
result = await session.execute(
|
||||
select(PlatformSession)
|
||||
.join(
|
||||
SessionProjectRelation,
|
||||
col(PlatformSession.session_id)
|
||||
== col(SessionProjectRelation.session_id),
|
||||
)
|
||||
.where(col(SessionProjectRelation.project_id) == project_id)
|
||||
.order_by(desc(PlatformSession.updated_at))
|
||||
.limit(page_size)
|
||||
.offset(offset),
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
|
||||
async def get_project_by_session(
|
||||
self, session_id: str, creator: str
|
||||
) -> ChatUIProject | None:
|
||||
"""Get the project that a session belongs to."""
|
||||
async with self.get_db() as session:
|
||||
session: AsyncSession
|
||||
result = await session.execute(
|
||||
select(ChatUIProject)
|
||||
.join(
|
||||
SessionProjectRelation,
|
||||
col(ChatUIProject.project_id)
|
||||
== col(SessionProjectRelation.project_id),
|
||||
)
|
||||
.where(
|
||||
col(SessionProjectRelation.session_id) == session_id,
|
||||
col(ChatUIProject.creator) == creator,
|
||||
),
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
@@ -2,10 +2,11 @@
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
from collections.abc import AsyncGenerator
|
||||
|
||||
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.tool import ToolSet
|
||||
from astrbot.core.astr_agent_context import AstrAgentContext
|
||||
@@ -35,8 +36,14 @@ from .....astr_agent_tool_exec import FunctionToolExecutor
|
||||
from ....context import PipelineContext, call_event_hook
|
||||
from ...stage import Stage
|
||||
from ...utils import (
|
||||
EXECUTE_SHELL_TOOL,
|
||||
FILE_DOWNLOAD_TOOL,
|
||||
FILE_UPLOAD_TOOL,
|
||||
KNOWLEDGE_BASE_QUERY_TOOL,
|
||||
LLM_SAFETY_MODE_SYSTEM_PROMPT,
|
||||
PYTHON_TOOL,
|
||||
SANDBOX_MODE_PROMPT,
|
||||
decoded_blocked,
|
||||
retrieve_knowledge_base,
|
||||
)
|
||||
|
||||
@@ -93,6 +100,8 @@ class InternalAgentSubStage(Stage):
|
||||
"safety_mode_strategy", "system_prompt"
|
||||
)
|
||||
|
||||
self.sandbox_cfg = settings.get("sandbox", {})
|
||||
|
||||
self.conv_manager = ctx.plugin_manager.context.conversation_manager
|
||||
|
||||
def _select_provider(self, event: AstrMessageEvent):
|
||||
@@ -341,54 +350,45 @@ class InternalAgentSubStage(Stage):
|
||||
prov: Provider,
|
||||
):
|
||||
"""处理 WebChat 平台的特殊情况,包括第一次 LLM 对话时总结对话内容生成 title"""
|
||||
if not req.conversation:
|
||||
from astrbot.core import db_helper
|
||||
|
||||
chatui_session_id = event.session_id.split("!")[-1]
|
||||
user_prompt = req.prompt
|
||||
|
||||
session = await db_helper.get_platform_session_by_id(chatui_session_id)
|
||||
|
||||
if (
|
||||
not user_prompt
|
||||
or not chatui_session_id
|
||||
or not session
|
||||
or session.display_name
|
||||
):
|
||||
return
|
||||
conversation = await self.conv_manager.get_conversation(
|
||||
event.unified_msg_origin,
|
||||
req.conversation.cid,
|
||||
|
||||
llm_resp = await prov.text_chat(
|
||||
system_prompt=(
|
||||
"You are a conversation title generator. "
|
||||
"Generate a concise title in the same language as the user’s input, "
|
||||
"no more than 10 words, capturing only the core topic."
|
||||
"If the input is a greeting, small talk, or has no clear topic, "
|
||||
"(e.g., “hi”, “hello”, “haha”), return <None>. "
|
||||
"Output only the title itself or <None>, with no explanations."
|
||||
),
|
||||
prompt=(
|
||||
f"Generate a concise title for the following user query:\n{user_prompt}"
|
||||
),
|
||||
)
|
||||
if conversation and not req.conversation.title:
|
||||
messages = json.loads(conversation.history)
|
||||
latest_pair = messages[-2:]
|
||||
if not latest_pair:
|
||||
if llm_resp and llm_resp.completion_text:
|
||||
title = llm_resp.completion_text.strip()
|
||||
if not title or "<None>" in title:
|
||||
return
|
||||
content = latest_pair[0].get("content", "")
|
||||
if isinstance(content, list):
|
||||
# 多模态
|
||||
text_parts = []
|
||||
for item in content:
|
||||
if isinstance(item, dict):
|
||||
if item.get("type") == "text":
|
||||
text_parts.append(item.get("text", ""))
|
||||
elif item.get("type") == "image":
|
||||
text_parts.append("[图片]")
|
||||
elif isinstance(item, str):
|
||||
text_parts.append(item)
|
||||
cleaned_text = "User: " + " ".join(text_parts).strip()
|
||||
elif isinstance(content, str):
|
||||
cleaned_text = "User: " + content.strip()
|
||||
else:
|
||||
return
|
||||
logger.debug(f"WebChat 对话标题生成请求,清理后的文本: {cleaned_text}")
|
||||
llm_resp = await prov.text_chat(
|
||||
system_prompt="You are expert in summarizing user's query.",
|
||||
prompt=(
|
||||
f"Please summarize the following query of user:\n"
|
||||
f"{cleaned_text}\n"
|
||||
"Only output the summary within 10 words, DO NOT INCLUDE any other text."
|
||||
"You must use the same language as the user."
|
||||
"If you think the dialog is too short to summarize, only output a special mark: `<None>`"
|
||||
),
|
||||
logger.info(
|
||||
f"Generated chatui title for session {chatui_session_id}: {title}"
|
||||
)
|
||||
await db_helper.update_platform_session(
|
||||
session_id=chatui_session_id,
|
||||
display_name=title,
|
||||
)
|
||||
if llm_resp and llm_resp.completion_text:
|
||||
title = llm_resp.completion_text.strip()
|
||||
if not title or "<None>" in title:
|
||||
return
|
||||
await self.conv_manager.update_conversation_title(
|
||||
unified_msg_origin=event.unified_msg_origin,
|
||||
title=title,
|
||||
conversation_id=req.conversation.cid,
|
||||
)
|
||||
|
||||
async def _save_to_history(
|
||||
self,
|
||||
@@ -466,6 +466,24 @@ class InternalAgentSubStage(Stage):
|
||||
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(
|
||||
self, event: AstrMessageEvent, provider_wake_prefix: str
|
||||
) -> AsyncGenerator[None, None]:
|
||||
@@ -501,6 +519,14 @@ class InternalAgentSubStage(Stage):
|
||||
logger.debug("skip llm request: empty message and no provider_request")
|
||||
return
|
||||
|
||||
api_base = provider.provider_config.get("api_base", "")
|
||||
for host in decoded_blocked:
|
||||
if host in api_base:
|
||||
logger.error(
|
||||
f"Provider API base {api_base} is blocked due to security reasons. Please use another ai provider."
|
||||
)
|
||||
return
|
||||
|
||||
logger.debug("ready to request llm provider")
|
||||
|
||||
# 通知等待调用 LLM(在获取锁之前)
|
||||
@@ -536,6 +562,20 @@ class InternalAgentSubStage(Stage):
|
||||
image_path = await comp.convert_to_file_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)
|
||||
req.conversation = conversation
|
||||
req.contexts = json.loads(conversation.history)
|
||||
@@ -586,6 +626,10 @@ class InternalAgentSubStage(Stage):
|
||||
if self.llm_safety_mode:
|
||||
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 = (
|
||||
self.unsupported_streaming_strategy == "turn_off"
|
||||
and not event.platform_meta.support_streaming_message
|
||||
@@ -609,6 +653,10 @@ class InternalAgentSubStage(Stage):
|
||||
"limit"
|
||||
]["context"]
|
||||
|
||||
# ChatUI 对话的标题生成
|
||||
if event.get_platform_name() == "webchat":
|
||||
asyncio.create_task(self._handle_webchat(event, req, provider))
|
||||
|
||||
await agent_runner.reset(
|
||||
provider=provider,
|
||||
request=req,
|
||||
@@ -679,10 +727,6 @@ class InternalAgentSubStage(Stage):
|
||||
agent_runner.stats,
|
||||
)
|
||||
|
||||
# 异步处理 WebChat 特殊情况
|
||||
if event.get_platform_name() == "webchat":
|
||||
asyncio.create_task(self._handle_webchat(event, req, provider))
|
||||
|
||||
asyncio.create_task(
|
||||
Metric.upload(
|
||||
llm_tick=1,
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import base64
|
||||
|
||||
from pydantic import Field
|
||||
from pydantic.dataclasses import dataclass
|
||||
|
||||
@@ -5,6 +7,12 @@ from astrbot.api import logger, sp
|
||||
from astrbot.core.agent.run_context import ContextWrapper
|
||||
from astrbot.core.agent.tool import FunctionTool, ToolExecResult
|
||||
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
|
||||
|
||||
LLM_SAFETY_MODE_SYSTEM_PROMPT = """You are running in Safe Mode.
|
||||
@@ -19,6 +27,20 @@ Rules:
|
||||
- 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
|
||||
class KnowledgeBaseQueryTool(FunctionTool[AstrAgentContext]):
|
||||
@@ -135,3 +157,13 @@ async def retrieve_knowledge_base(
|
||||
|
||||
|
||||
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
|
||||
# these hosts are base64 encoded
|
||||
BLOCKED = {"dGZid2h2d3IuY2xvdWQuc2VhbG9zLmlv", "a291cmljaGF0"}
|
||||
decoded_blocked = [base64.b64decode(b).decode("utf-8") for b in BLOCKED]
|
||||
|
||||
@@ -93,7 +93,8 @@ class WebChatAdapter(Platform):
|
||||
session: MessageSesion,
|
||||
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)
|
||||
|
||||
async def _get_message_history(
|
||||
@@ -196,7 +197,7 @@ class WebChatAdapter(Platform):
|
||||
|
||||
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", [])
|
||||
|
||||
@@ -21,7 +21,10 @@ class WebChatMessageEvent(AstrMessageEvent):
|
||||
|
||||
@staticmethod
|
||||
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:
|
||||
cid = session_id.split("!")[-1]
|
||||
web_chat_back_queue = webchat_queue_mgr.get_or_create_back_queue(cid)
|
||||
@@ -31,6 +34,7 @@ class WebChatMessageEvent(AstrMessageEvent):
|
||||
"type": "end",
|
||||
"data": "",
|
||||
"streaming": False,
|
||||
"message_id": message_id,
|
||||
}, # end means this request is finished
|
||||
)
|
||||
return
|
||||
@@ -45,6 +49,7 @@ class WebChatMessageEvent(AstrMessageEvent):
|
||||
"data": data,
|
||||
"streaming": streaming,
|
||||
"chain_type": message.type,
|
||||
"message_id": message_id,
|
||||
},
|
||||
)
|
||||
elif isinstance(comp, Json):
|
||||
@@ -54,6 +59,7 @@ class WebChatMessageEvent(AstrMessageEvent):
|
||||
"data": json.dumps(comp.data, ensure_ascii=False),
|
||||
"streaming": streaming,
|
||||
"chain_type": message.type,
|
||||
"message_id": message_id,
|
||||
},
|
||||
)
|
||||
elif isinstance(comp, Image):
|
||||
@@ -69,6 +75,7 @@ class WebChatMessageEvent(AstrMessageEvent):
|
||||
"type": "image",
|
||||
"data": data,
|
||||
"streaming": streaming,
|
||||
"message_id": message_id,
|
||||
},
|
||||
)
|
||||
elif isinstance(comp, Record):
|
||||
@@ -84,6 +91,7 @@ class WebChatMessageEvent(AstrMessageEvent):
|
||||
"type": "record",
|
||||
"data": data,
|
||||
"streaming": streaming,
|
||||
"message_id": message_id,
|
||||
},
|
||||
)
|
||||
elif isinstance(comp, File):
|
||||
@@ -94,12 +102,13 @@ class WebChatMessageEvent(AstrMessageEvent):
|
||||
filename = f"{uuid.uuid4()!s}{ext}"
|
||||
dest_path = os.path.join(imgs_dir, filename)
|
||||
shutil.copy2(file_path, dest_path)
|
||||
data = f"[FILE]{filename}|{original_name}"
|
||||
data = f"[FILE]{filename}"
|
||||
await web_chat_back_queue.put(
|
||||
{
|
||||
"type": "file",
|
||||
"data": data,
|
||||
"streaming": streaming,
|
||||
"message_id": message_id,
|
||||
},
|
||||
)
|
||||
else:
|
||||
@@ -108,7 +117,8 @@ class WebChatMessageEvent(AstrMessageEvent):
|
||||
return data
|
||||
|
||||
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([]))
|
||||
|
||||
async def send_streaming(self, generator, use_fallback: bool = False):
|
||||
@@ -116,6 +126,7 @@ class WebChatMessageEvent(AstrMessageEvent):
|
||||
reasoning_content = ""
|
||||
cid = self.session_id.split("!")[-1]
|
||||
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:
|
||||
# if chain.type == "break" and final_data:
|
||||
# # 分割符
|
||||
@@ -130,7 +141,8 @@ class WebChatMessageEvent(AstrMessageEvent):
|
||||
# continue
|
||||
|
||||
r = await WebChatMessageEvent._send(
|
||||
chain,
|
||||
message_id=message_id,
|
||||
message=chain,
|
||||
session_id=self.session_id,
|
||||
streaming=True,
|
||||
)
|
||||
@@ -147,6 +159,7 @@ class WebChatMessageEvent(AstrMessageEvent):
|
||||
"data": final_data,
|
||||
"reasoning": reasoning_content,
|
||||
"streaming": True,
|
||||
"message_id": message_id,
|
||||
},
|
||||
)
|
||||
await super().send_streaming(generator, use_fallback)
|
||||
|
||||
@@ -127,6 +127,50 @@ class ProviderAnthropic(Provider):
|
||||
],
|
||||
},
|
||||
)
|
||||
elif message["role"] == "user":
|
||||
if isinstance(message.get("content"), list):
|
||||
converted_content = []
|
||||
for part in message["content"]:
|
||||
if part.get("type") == "image_url":
|
||||
# Convert OpenAI image_url format to Anthropic image format
|
||||
image_url_data = part.get("image_url", {})
|
||||
url = image_url_data.get("url", "")
|
||||
if url.startswith("data:"):
|
||||
try:
|
||||
_, base64_data = url.split(",", 1)
|
||||
# Detect actual image format from binary data
|
||||
image_bytes = base64.b64decode(base64_data)
|
||||
media_type = self._detect_image_mime_type(
|
||||
image_bytes
|
||||
)
|
||||
converted_content.append(
|
||||
{
|
||||
"type": "image",
|
||||
"source": {
|
||||
"type": "base64",
|
||||
"media_type": media_type,
|
||||
"data": base64_data,
|
||||
},
|
||||
}
|
||||
)
|
||||
except ValueError:
|
||||
logger.warning(
|
||||
f"Failed to parse image data URI: {url[:50]}..."
|
||||
)
|
||||
else:
|
||||
logger.warning(
|
||||
f"Unsupported image URL format for Anthropic: {url[:50]}..."
|
||||
)
|
||||
else:
|
||||
converted_content.append(part)
|
||||
new_messages.append(
|
||||
{
|
||||
"role": "user",
|
||||
"content": converted_content,
|
||||
}
|
||||
)
|
||||
else:
|
||||
new_messages.append(message)
|
||||
else:
|
||||
new_messages.append(message)
|
||||
|
||||
|
||||
@@ -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)}"
|
||||
+160
-42
@@ -49,7 +49,7 @@ class Context:
|
||||
|
||||
registered_web_apis: list = []
|
||||
|
||||
# back compatibility
|
||||
# 向后兼容的变量
|
||||
_register_tasks: list[Awaitable] = []
|
||||
_star_manager = None
|
||||
|
||||
@@ -73,12 +73,19 @@ class Context:
|
||||
self._db = db
|
||||
"""AstrBot 数据库"""
|
||||
self.provider_manager = provider_manager
|
||||
"""模型提供商管理器"""
|
||||
self.platform_manager = platform_manager
|
||||
"""平台适配器管理器"""
|
||||
self.conversation_manager = conversation_manager
|
||||
"""会话管理器"""
|
||||
self.message_history_manager = message_history_manager
|
||||
"""平台消息历史管理器"""
|
||||
self.persona_manager = persona_manager
|
||||
"""人格角色设定管理器"""
|
||||
self.astrbot_config_mgr = astrbot_config_mgr
|
||||
"""配置文件管理器(非webui)"""
|
||||
self.kb_manager = knowledge_base_manager
|
||||
"""知识库管理器"""
|
||||
|
||||
async def llm_generate(
|
||||
self,
|
||||
@@ -226,14 +233,16 @@ class Context:
|
||||
return llm_resp
|
||||
|
||||
async def get_current_chat_provider_id(self, umo: str) -> str:
|
||||
"""Get the ID of the currently used chat provider.
|
||||
"""获取当前使用的聊天模型 Provider ID。
|
||||
|
||||
Args:
|
||||
umo(str): unified_message_origin value, if provided and user has enabled provider session isolation, the provider preferred by that session will be used.
|
||||
umo: unified_message_origin。消息会话来源 ID。
|
||||
|
||||
Returns:
|
||||
指定消息会话来源当前使用的聊天模型 Provider ID。
|
||||
|
||||
Raises:
|
||||
ProviderNotFoundError: If the specified chat provider is not found
|
||||
|
||||
ProviderNotFoundError: 未找到。
|
||||
"""
|
||||
prov = self.get_using_provider(umo)
|
||||
if not prov:
|
||||
@@ -255,20 +264,27 @@ class Context:
|
||||
return self.provider_manager.llm_tools
|
||||
|
||||
def activate_llm_tool(self, name: str) -> bool:
|
||||
"""激活一个已经注册的函数调用工具。注册的工具默认是激活状态。
|
||||
"""激活一个已经注册的函数调用工具。
|
||||
|
||||
Args:
|
||||
name: 工具名称。
|
||||
|
||||
Returns:
|
||||
如果没找到,会返回 False
|
||||
如果成功激活返回 True,如果没找到工具返回 False。
|
||||
|
||||
Note:
|
||||
注册的工具默认是激活状态。
|
||||
"""
|
||||
return self.provider_manager.llm_tools.activate_llm_tool(name, star_map)
|
||||
|
||||
def deactivate_llm_tool(self, name: str) -> bool:
|
||||
"""停用一个已经注册的函数调用工具。
|
||||
|
||||
Returns:
|
||||
如果没找到,会返回 False
|
||||
Args:
|
||||
name: 工具名称。
|
||||
|
||||
Returns:
|
||||
如果成功停用返回 True,如果没找到工具返回 False。
|
||||
"""
|
||||
return self.provider_manager.llm_tools.deactivate_llm_tool(name)
|
||||
|
||||
@@ -278,7 +294,17 @@ class Context:
|
||||
) -> (
|
||||
Provider | TTSProvider | STTProvider | EmbeddingProvider | RerankProvider | None
|
||||
):
|
||||
"""通过 ID 获取对应的 LLM Provider。"""
|
||||
"""通过 ID 获取对应的 LLM Provider。
|
||||
|
||||
Args:
|
||||
provider_id: 提供者 ID。
|
||||
|
||||
Returns:
|
||||
提供者实例,如果未找到则返回 None。
|
||||
|
||||
Note:
|
||||
如果提供者 ID 存在但未找到提供者,会记录警告日志。
|
||||
"""
|
||||
prov = self.provider_manager.inst_map.get(provider_id)
|
||||
if provider_id and not prov:
|
||||
logger.warning(
|
||||
@@ -303,11 +329,20 @@ class Context:
|
||||
return self.provider_manager.embedding_provider_insts
|
||||
|
||||
def get_using_provider(self, umo: str | None = None) -> Provider:
|
||||
"""获取当前使用的用于文本生成任务的 LLM Provider(Chat_Completion 类型)。通过 /provider 指令切换。
|
||||
"""获取当前使用的用于文本生成任务的 LLM Provider(Chat_Completion 类型)。
|
||||
|
||||
Args:
|
||||
umo(str): unified_message_origin 值,如果传入并且用户启用了提供商会话隔离,则使用该会话偏好的提供商。
|
||||
umo: unified_message_origin 值,如果传入并且用户启用了提供商会话隔离,
|
||||
则使用该会话偏好的提供商。
|
||||
|
||||
Returns:
|
||||
当前使用的文本生成提供者。
|
||||
|
||||
Raises:
|
||||
ValueError: 返回的提供者不是 Provider 类型。
|
||||
|
||||
Note:
|
||||
通过 /provider 指令可以切换提供者。
|
||||
"""
|
||||
prov = self.provider_manager.get_using_provider(
|
||||
provider_type=ProviderType.CHAT_COMPLETION,
|
||||
@@ -321,8 +356,13 @@ class Context:
|
||||
"""获取当前使用的用于 TTS 任务的 Provider。
|
||||
|
||||
Args:
|
||||
umo(str): unified_message_origin 值,如果传入,则使用该会话偏好的提供商。
|
||||
umo: unified_message_origin 值,如果传入,则使用该会话偏好的提供商。
|
||||
|
||||
Returns:
|
||||
当前使用的 TTS 提供者,如果未设置则返回 None。
|
||||
|
||||
Raises:
|
||||
ValueError: 返回的提供者不是 TTSProvider 类型。
|
||||
"""
|
||||
prov = self.provider_manager.get_using_provider(
|
||||
provider_type=ProviderType.TEXT_TO_SPEECH,
|
||||
@@ -336,8 +376,13 @@ class Context:
|
||||
"""获取当前使用的用于 STT 任务的 Provider。
|
||||
|
||||
Args:
|
||||
umo(str): unified_message_origin 值,如果传入,则使用该会话偏好的提供商。
|
||||
umo: unified_message_origin 值,如果传入,则使用该会话偏好的提供商。
|
||||
|
||||
Returns:
|
||||
当前使用的 STT 提供者,如果未设置则返回 None。
|
||||
|
||||
Raises:
|
||||
ValueError: 返回的提供者不是 STTProvider 类型。
|
||||
"""
|
||||
prov = self.provider_manager.get_using_provider(
|
||||
provider_type=ProviderType.SPEECH_TO_TEXT,
|
||||
@@ -348,9 +393,19 @@ class Context:
|
||||
return prov
|
||||
|
||||
def get_config(self, umo: str | None = None) -> AstrBotConfig:
|
||||
"""获取 AstrBot 的配置。"""
|
||||
"""获取 AstrBot 的配置。
|
||||
|
||||
Args:
|
||||
umo: unified_message_origin 值,用于获取特定会话的配置。
|
||||
|
||||
Returns:
|
||||
AstrBot 配置对象。
|
||||
|
||||
Note:
|
||||
如果不提供 umo 参数,将返回默认配置。
|
||||
"""
|
||||
if not umo:
|
||||
# using default config
|
||||
# 使用默认配置
|
||||
return self._config
|
||||
return self.astrbot_config_mgr.get_conf(umo)
|
||||
|
||||
@@ -361,14 +416,19 @@ class Context:
|
||||
) -> bool:
|
||||
"""根据 session(unified_msg_origin) 主动发送消息。
|
||||
|
||||
@param session: 消息会话。通过 event.session 或者 event.unified_msg_origin 获取。
|
||||
@param message_chain: 消息链。
|
||||
Args:
|
||||
session: 消息会话。通过 event.session 或者 event.unified_msg_origin 获取。
|
||||
message_chain: 消息链。
|
||||
|
||||
@return: 是否找到匹配的平台。
|
||||
Returns:
|
||||
是否找到匹配的平台。
|
||||
|
||||
当 session 为字符串时,会尝试解析为 MessageSesion 对象,如果解析失败,会抛出 ValueError 异常。
|
||||
Raises:
|
||||
ValueError: session 字符串不合法时抛出。
|
||||
|
||||
NOTE: qq_official(QQ 官方 API 平台) 不支持此方法
|
||||
Note:
|
||||
当 session 为字符串时,会尝试解析为 MessageSession 对象。(类名为MessageSesion是因为历史遗留拼写错误)
|
||||
qq_official(QQ 官方 API 平台) 不支持此方法。
|
||||
"""
|
||||
if isinstance(session, str):
|
||||
try:
|
||||
@@ -383,7 +443,14 @@ class Context:
|
||||
return False
|
||||
|
||||
def add_llm_tools(self, *tools: FunctionTool) -> None:
|
||||
"""添加 LLM 工具。"""
|
||||
"""添加 LLM 工具。
|
||||
|
||||
Args:
|
||||
*tools: 要添加的函数工具对象。
|
||||
|
||||
Note:
|
||||
如果工具已存在,会替换已存在的工具。
|
||||
"""
|
||||
tool_name = {tool.name for tool in self.provider_manager.llm_tools.func_list}
|
||||
module_path = ""
|
||||
for tool in tools:
|
||||
@@ -416,6 +483,17 @@ class Context:
|
||||
methods: list,
|
||||
desc: str,
|
||||
):
|
||||
"""注册 Web API。
|
||||
|
||||
Args:
|
||||
route: API 路由路径。
|
||||
view_handler: 异步视图处理函数。
|
||||
methods: HTTP 方法列表。
|
||||
desc: API 描述。
|
||||
|
||||
Note:
|
||||
如果相同路由和方法已注册,会替换现有的 API。
|
||||
"""
|
||||
for idx, api in enumerate(self.registered_web_apis):
|
||||
if api[0] == route and methods == api[2]:
|
||||
self.registered_web_apis[idx] = (route, view_handler, methods, desc)
|
||||
@@ -434,7 +512,14 @@ class Context:
|
||||
def get_platform(self, platform_type: PlatformAdapterType | str) -> Platform | None:
|
||||
"""获取指定类型的平台适配器。
|
||||
|
||||
该方法已经过时,请使用 get_platform_inst 方法。(>= AstrBot v4.0.0)
|
||||
Args:
|
||||
platform_type: 平台类型或平台名称。
|
||||
|
||||
Returns:
|
||||
平台适配器实例,如果未找到则返回 None。
|
||||
|
||||
Note:
|
||||
该方法已经过时,请使用 get_platform_inst 方法。(>= AstrBot v4.0.0)
|
||||
"""
|
||||
for platform in self.platform_manager.platform_insts:
|
||||
name = platform.meta().name
|
||||
@@ -451,22 +536,32 @@ class Context:
|
||||
"""获取指定 ID 的平台适配器实例。
|
||||
|
||||
Args:
|
||||
platform_id (str): 平台适配器的唯一标识符。你可以通过 event.get_platform_id() 获取。
|
||||
platform_id: 平台适配器的唯一标识符。
|
||||
|
||||
Returns:
|
||||
Platform: 平台适配器实例,如果未找到则返回 None。
|
||||
平台适配器实例,如果未找到则返回 None。
|
||||
|
||||
Note:
|
||||
可以通过 event.get_platform_id() 获取平台 ID。
|
||||
"""
|
||||
for platform in self.platform_manager.platform_insts:
|
||||
if platform.meta().id == platform_id:
|
||||
return platform
|
||||
|
||||
def get_db(self) -> BaseDatabase:
|
||||
"""获取 AstrBot 数据库。"""
|
||||
"""获取 AstrBot 数据库。
|
||||
|
||||
Returns:
|
||||
数据库实例。
|
||||
"""
|
||||
return self._db
|
||||
|
||||
def register_provider(self, provider: Provider):
|
||||
"""注册一个 LLM Provider(Chat_Completion 类型)。"""
|
||||
"""注册一个 LLM Provider(Chat_Completion 类型)。
|
||||
|
||||
Args:
|
||||
provider: 提供者实例。
|
||||
"""
|
||||
self.provider_manager.provider_insts.append(provider)
|
||||
|
||||
def register_llm_tool(
|
||||
@@ -478,12 +573,16 @@ class Context:
|
||||
) -> None:
|
||||
"""[DEPRECATED]为函数调用(function-calling / tools-use)添加工具。
|
||||
|
||||
@param name: 函数名
|
||||
@param func_args: 函数参数列表,格式为 [{"type": "string", "name": "arg_name", "description": "arg_description"}, ...]
|
||||
@param desc: 函数描述
|
||||
@param func_obj: 异步处理函数。
|
||||
Args:
|
||||
name: 函数名。
|
||||
func_args: 函数参数列表,格式为
|
||||
[{"type": "string", "name": "arg_name", "description": "arg_description"}, ...]。
|
||||
desc: 函数描述。
|
||||
func_obj: 异步处理函数。
|
||||
|
||||
异步处理函数会接收到额外的的关键词参数:event: AstrMessageEvent, context: Context。
|
||||
Note:
|
||||
异步处理函数会接收到额外的关键词参数:event: AstrMessageEvent, context: Context。
|
||||
该方法已弃用,请使用新的注册方式。
|
||||
"""
|
||||
md = StarHandlerMetadata(
|
||||
event_type=EventType.OnLLMRequestEvent,
|
||||
@@ -498,7 +597,15 @@ class Context:
|
||||
self.provider_manager.llm_tools.add_func(name, func_args, desc, func_obj)
|
||||
|
||||
def unregister_llm_tool(self, name: str) -> None:
|
||||
"""[DEPRECATED]删除一个函数调用工具。如果再要启用,需要重新注册。"""
|
||||
"""[DEPRECATED]删除一个函数调用工具。
|
||||
|
||||
Args:
|
||||
name: 工具名称。
|
||||
|
||||
Note:
|
||||
如果再要启用,需要重新注册。
|
||||
该方法已弃用。
|
||||
"""
|
||||
self.provider_manager.llm_tools.remove_func(name)
|
||||
|
||||
def register_commands(
|
||||
@@ -511,16 +618,19 @@ class Context:
|
||||
use_regex=False,
|
||||
ignore_prefix=False,
|
||||
):
|
||||
"""注册一个命令。
|
||||
"""[DEPRECATED]注册一个命令。
|
||||
|
||||
[Deprecated] 推荐使用装饰器注册指令。该方法将在未来的版本中被移除。
|
||||
|
||||
@param star_name: 插件(Star)名称。
|
||||
@param command_name: 命令名称。
|
||||
@param desc: 命令描述。
|
||||
@param priority: 优先级。1-10。
|
||||
@param awaitable: 异步处理函数。
|
||||
Args:
|
||||
star_name: 插件(Star)名称。
|
||||
command_name: 命令名称。
|
||||
desc: 命令描述。
|
||||
priority: 优先级。1-10。
|
||||
awaitable: 异步处理函数。
|
||||
use_regex: 是否使用正则表达式匹配命令。
|
||||
ignore_prefix: 是否忽略命令前缀。
|
||||
|
||||
Note:
|
||||
推荐使用装饰器注册指令。该方法将在未来的版本中被移除。
|
||||
"""
|
||||
md = StarHandlerMetadata(
|
||||
event_type=EventType.AdapterMessageEvent,
|
||||
@@ -540,5 +650,13 @@ class Context:
|
||||
star_handlers_registry.append(md)
|
||||
|
||||
def register_task(self, task: Awaitable, desc: str):
|
||||
"""[DEPRECATED]注册一个异步任务。"""
|
||||
"""[DEPRECATED]注册一个异步任务。
|
||||
|
||||
Args:
|
||||
task: 异步任务。
|
||||
desc: 任务描述。
|
||||
|
||||
Note:
|
||||
该方法已弃用。
|
||||
"""
|
||||
self._register_tasks.append(task)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
from .auth import AuthRoute
|
||||
from .backup import BackupRoute
|
||||
from .chat import ChatRoute
|
||||
from .chatui_project import ChatUIProjectRoute
|
||||
from .command import CommandRoute
|
||||
from .config import ConfigRoute
|
||||
from .conversation import ConversationRoute
|
||||
@@ -20,6 +21,7 @@ __all__ = [
|
||||
"AuthRoute",
|
||||
"BackupRoute",
|
||||
"ChatRoute",
|
||||
"ChatUIProjectRoute",
|
||||
"CommandRoute",
|
||||
"ConfigRoute",
|
||||
"ConversationRoute",
|
||||
|
||||
@@ -296,6 +296,8 @@ class ChatRoute(Route):
|
||||
# 构建用户消息段(包含 path 用于传递给 adapter)
|
||||
message_parts = await self._build_user_message_parts(message)
|
||||
|
||||
message_id = str(uuid.uuid4())
|
||||
|
||||
async def stream():
|
||||
client_disconnected = False
|
||||
accumulated_parts = []
|
||||
@@ -319,6 +321,13 @@ class ChatRoute(Route):
|
||||
if not result:
|
||||
continue
|
||||
|
||||
if (
|
||||
"message_id" in result
|
||||
and result["message_id"] != message_id
|
||||
):
|
||||
logger.warning("webchat stream message_id mismatch")
|
||||
continue
|
||||
|
||||
result_text = result["data"]
|
||||
msg_type = result.get("type")
|
||||
streaming = result.get("streaming", False)
|
||||
@@ -456,6 +465,7 @@ class ChatRoute(Route):
|
||||
"selected_provider": selected_provider,
|
||||
"selected_model": selected_model,
|
||||
"enable_streaming": enable_streaming,
|
||||
"message_id": message_id,
|
||||
},
|
||||
),
|
||||
)
|
||||
@@ -618,9 +628,17 @@ class ChatRoute(Route):
|
||||
page_size=100, # 暂时返回前100个
|
||||
)
|
||||
|
||||
# 转换为字典格式,并添加额外信息
|
||||
# 转换为字典格式,并添加项目信息
|
||||
# get_platform_sessions_by_creator 现在返回 list[dict] 包含 session 和项目字段
|
||||
sessions_data = []
|
||||
for session in sessions:
|
||||
for item in sessions:
|
||||
session = item["session"]
|
||||
project_id = item["project_id"]
|
||||
|
||||
# 跳过属于项目的会话(在侧边栏对话列表中不显示)
|
||||
if project_id is not None:
|
||||
continue
|
||||
|
||||
sessions_data.append(
|
||||
{
|
||||
"session_id": session.session_id,
|
||||
@@ -645,6 +663,12 @@ class ChatRoute(Route):
|
||||
session = await self.db.get_platform_session_by_id(session_id)
|
||||
platform_id = session.platform_id if session else "webchat"
|
||||
|
||||
# 获取项目信息(如果会话属于某个项目)
|
||||
username = g.get("username", "guest")
|
||||
project_info = await self.db.get_project_by_session(
|
||||
session_id=session_id, creator=username
|
||||
)
|
||||
|
||||
# Get platform message history using session_id
|
||||
history_ls = await self.platform_history_mgr.get(
|
||||
platform_id=platform_id,
|
||||
@@ -655,16 +679,20 @@ class ChatRoute(Route):
|
||||
|
||||
history_res = [history.model_dump() for history in history_ls]
|
||||
|
||||
return (
|
||||
Response()
|
||||
.ok(
|
||||
data={
|
||||
"history": history_res,
|
||||
"is_running": self.running_convs.get(session_id, False),
|
||||
},
|
||||
)
|
||||
.__dict__
|
||||
)
|
||||
response_data = {
|
||||
"history": history_res,
|
||||
"is_running": self.running_convs.get(session_id, False),
|
||||
}
|
||||
|
||||
# 如果会话属于项目,添加项目信息
|
||||
if project_info:
|
||||
response_data["project"] = {
|
||||
"project_id": project_info.project_id,
|
||||
"title": project_info.title,
|
||||
"emoji": project_info.emoji,
|
||||
}
|
||||
|
||||
return Response().ok(data=response_data).__dict__
|
||||
|
||||
async def update_session_display_name(self):
|
||||
"""Update a Platform session's display name."""
|
||||
|
||||
@@ -0,0 +1,245 @@
|
||||
from quart import g, request
|
||||
|
||||
from astrbot.core.db import BaseDatabase
|
||||
|
||||
from .route import Response, Route, RouteContext
|
||||
|
||||
|
||||
class ChatUIProjectRoute(Route):
|
||||
def __init__(self, context: RouteContext, db: BaseDatabase) -> None:
|
||||
super().__init__(context)
|
||||
self.routes = {
|
||||
"/chatui_project/create": ("POST", self.create_project),
|
||||
"/chatui_project/list": ("GET", self.list_projects),
|
||||
"/chatui_project/get": ("GET", self.get_project),
|
||||
"/chatui_project/update": ("POST", self.update_chatui_project),
|
||||
"/chatui_project/delete": ("GET", self.delete_project),
|
||||
"/chatui_project/add_session": ("POST", self.add_session_to_project),
|
||||
"/chatui_project/remove_session": (
|
||||
"POST",
|
||||
self.remove_session_from_project,
|
||||
),
|
||||
"/chatui_project/get_sessions": ("GET", self.get_project_sessions),
|
||||
}
|
||||
self.db = db
|
||||
self.register_routes()
|
||||
|
||||
async def create_project(self):
|
||||
"""Create a new ChatUI project."""
|
||||
username = g.get("username", "guest")
|
||||
post_data = await request.json
|
||||
|
||||
title = post_data.get("title")
|
||||
emoji = post_data.get("emoji", "📁")
|
||||
description = post_data.get("description")
|
||||
|
||||
if not title:
|
||||
return Response().error("Missing key: title").__dict__
|
||||
|
||||
project = await self.db.create_chatui_project(
|
||||
creator=username,
|
||||
title=title,
|
||||
emoji=emoji,
|
||||
description=description,
|
||||
)
|
||||
|
||||
return (
|
||||
Response()
|
||||
.ok(
|
||||
data={
|
||||
"project_id": project.project_id,
|
||||
"title": project.title,
|
||||
"emoji": project.emoji,
|
||||
"description": project.description,
|
||||
"created_at": project.created_at.astimezone().isoformat(),
|
||||
"updated_at": project.updated_at.astimezone().isoformat(),
|
||||
}
|
||||
)
|
||||
.__dict__
|
||||
)
|
||||
|
||||
async def list_projects(self):
|
||||
"""Get all ChatUI projects for the current user."""
|
||||
username = g.get("username", "guest")
|
||||
|
||||
projects = await self.db.get_chatui_projects_by_creator(creator=username)
|
||||
|
||||
projects_data = [
|
||||
{
|
||||
"project_id": project.project_id,
|
||||
"title": project.title,
|
||||
"emoji": project.emoji,
|
||||
"description": project.description,
|
||||
"created_at": project.created_at.astimezone().isoformat(),
|
||||
"updated_at": project.updated_at.astimezone().isoformat(),
|
||||
}
|
||||
for project in projects
|
||||
]
|
||||
|
||||
return Response().ok(data=projects_data).__dict__
|
||||
|
||||
async def get_project(self):
|
||||
"""Get a specific ChatUI project."""
|
||||
project_id = request.args.get("project_id")
|
||||
if not project_id:
|
||||
return Response().error("Missing key: project_id").__dict__
|
||||
|
||||
username = g.get("username", "guest")
|
||||
|
||||
project = await self.db.get_chatui_project_by_id(project_id)
|
||||
if not project:
|
||||
return Response().error(f"Project {project_id} not found").__dict__
|
||||
|
||||
# Verify ownership
|
||||
if project.creator != username:
|
||||
return Response().error("Permission denied").__dict__
|
||||
|
||||
return (
|
||||
Response()
|
||||
.ok(
|
||||
data={
|
||||
"project_id": project.project_id,
|
||||
"title": project.title,
|
||||
"emoji": project.emoji,
|
||||
"description": project.description,
|
||||
"created_at": project.created_at.astimezone().isoformat(),
|
||||
"updated_at": project.updated_at.astimezone().isoformat(),
|
||||
}
|
||||
)
|
||||
.__dict__
|
||||
)
|
||||
|
||||
async def update_chatui_project(self):
|
||||
"""Update a ChatUI project."""
|
||||
post_data = await request.json
|
||||
|
||||
project_id = post_data.get("project_id")
|
||||
title = post_data.get("title")
|
||||
emoji = post_data.get("emoji")
|
||||
description = post_data.get("description")
|
||||
|
||||
if not project_id:
|
||||
return Response().error("Missing key: project_id").__dict__
|
||||
|
||||
username = g.get("username", "guest")
|
||||
|
||||
# Verify ownership
|
||||
project = await self.db.get_chatui_project_by_id(project_id)
|
||||
if not project:
|
||||
return Response().error(f"Project {project_id} not found").__dict__
|
||||
if project.creator != username:
|
||||
return Response().error("Permission denied").__dict__
|
||||
|
||||
await self.db.update_chatui_project(
|
||||
project_id=project_id,
|
||||
title=title,
|
||||
emoji=emoji,
|
||||
description=description,
|
||||
)
|
||||
|
||||
return Response().ok().__dict__
|
||||
|
||||
async def delete_project(self):
|
||||
"""Delete a ChatUI project."""
|
||||
project_id = request.args.get("project_id")
|
||||
if not project_id:
|
||||
return Response().error("Missing key: project_id").__dict__
|
||||
|
||||
username = g.get("username", "guest")
|
||||
|
||||
# Verify ownership
|
||||
project = await self.db.get_chatui_project_by_id(project_id)
|
||||
if not project:
|
||||
return Response().error(f"Project {project_id} not found").__dict__
|
||||
if project.creator != username:
|
||||
return Response().error("Permission denied").__dict__
|
||||
|
||||
await self.db.delete_chatui_project(project_id)
|
||||
|
||||
return Response().ok().__dict__
|
||||
|
||||
async def add_session_to_project(self):
|
||||
"""Add a session to a project."""
|
||||
post_data = await request.json
|
||||
|
||||
session_id = post_data.get("session_id")
|
||||
project_id = post_data.get("project_id")
|
||||
|
||||
if not session_id:
|
||||
return Response().error("Missing key: session_id").__dict__
|
||||
if not project_id:
|
||||
return Response().error("Missing key: project_id").__dict__
|
||||
|
||||
username = g.get("username", "guest")
|
||||
|
||||
# Verify project ownership
|
||||
project = await self.db.get_chatui_project_by_id(project_id)
|
||||
if not project:
|
||||
return Response().error(f"Project {project_id} not found").__dict__
|
||||
if project.creator != username:
|
||||
return Response().error("Permission denied").__dict__
|
||||
|
||||
# Verify session ownership
|
||||
session = await self.db.get_platform_session_by_id(session_id)
|
||||
if not session:
|
||||
return Response().error(f"Session {session_id} not found").__dict__
|
||||
if session.creator != username:
|
||||
return Response().error("Permission denied").__dict__
|
||||
|
||||
await self.db.add_session_to_project(session_id, project_id)
|
||||
|
||||
return Response().ok().__dict__
|
||||
|
||||
async def remove_session_from_project(self):
|
||||
"""Remove a session from its project."""
|
||||
post_data = await request.json
|
||||
|
||||
session_id = post_data.get("session_id")
|
||||
|
||||
if not session_id:
|
||||
return Response().error("Missing key: session_id").__dict__
|
||||
|
||||
username = g.get("username", "guest")
|
||||
|
||||
# Verify session ownership
|
||||
session = await self.db.get_platform_session_by_id(session_id)
|
||||
if not session:
|
||||
return Response().error(f"Session {session_id} not found").__dict__
|
||||
if session.creator != username:
|
||||
return Response().error("Permission denied").__dict__
|
||||
|
||||
await self.db.remove_session_from_project(session_id)
|
||||
|
||||
return Response().ok().__dict__
|
||||
|
||||
async def get_project_sessions(self):
|
||||
"""Get all sessions in a project."""
|
||||
project_id = request.args.get("project_id")
|
||||
if not project_id:
|
||||
return Response().error("Missing key: project_id").__dict__
|
||||
|
||||
username = g.get("username", "guest")
|
||||
|
||||
# Verify project ownership
|
||||
project = await self.db.get_chatui_project_by_id(project_id)
|
||||
if not project:
|
||||
return Response().error(f"Project {project_id} not found").__dict__
|
||||
if project.creator != username:
|
||||
return Response().error("Permission denied").__dict__
|
||||
|
||||
sessions = await self.db.get_project_sessions(project_id)
|
||||
|
||||
sessions_data = [
|
||||
{
|
||||
"session_id": session.session_id,
|
||||
"platform_id": session.platform_id,
|
||||
"creator": session.creator,
|
||||
"display_name": session.display_name,
|
||||
"is_group": session.is_group,
|
||||
"created_at": session.created_at.astimezone().isoformat(),
|
||||
"updated_at": session.updated_at.astimezone().isoformat(),
|
||||
}
|
||||
for session in sessions
|
||||
]
|
||||
|
||||
return Response().ok(data=sessions_data).__dict__
|
||||
@@ -74,6 +74,7 @@ class AstrBotDashboard:
|
||||
self.sfr = StaticFileRoute(self.context)
|
||||
self.ar = AuthRoute(self.context)
|
||||
self.chat_route = ChatRoute(self.context, db, core_lifecycle)
|
||||
self.chatui_project_route = ChatUIProjectRoute(self.context, db)
|
||||
self.tools_root = ToolsRoute(self.context, core_lifecycle)
|
||||
self.conversation_route = ConversationRoute(self.context, db, core_lifecycle)
|
||||
self.file_route = FileRoute(self.context)
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
"katex": "^0.16.27",
|
||||
"lodash": "4.17.21",
|
||||
"markdown-it": "^14.1.0",
|
||||
"markstream-vue": "0.0.3-beta.7",
|
||||
"markstream-vue": "^0.0.6-beta.1",
|
||||
"mermaid": "^11.12.2",
|
||||
"pinia": "2.1.6",
|
||||
"pinyin-pro": "^3.26.0",
|
||||
|
||||
@@ -9,10 +9,12 @@
|
||||
:sessions="sessions"
|
||||
:selectedSessions="selectedSessions"
|
||||
:currSessionId="currSessionId"
|
||||
:selectedProjectId="selectedProjectId"
|
||||
:isDark="isDark"
|
||||
:chatboxMode="chatboxMode"
|
||||
:isMobile="isMobile"
|
||||
:mobileMenuOpen="mobileMenuOpen"
|
||||
:projects="projects"
|
||||
@newChat="handleNewChat"
|
||||
@selectConversation="handleSelectConversation"
|
||||
@editTitle="showEditTitleDialog"
|
||||
@@ -20,6 +22,10 @@
|
||||
@closeMobileSidebar="closeMobileSidebar"
|
||||
@toggleTheme="toggleTheme"
|
||||
@toggleFullscreen="toggleFullscreen"
|
||||
@selectProject="handleSelectProject"
|
||||
@createProject="showCreateProjectDialog"
|
||||
@editProject="showEditProjectDialog"
|
||||
@deleteProject="handleDeleteProject"
|
||||
/>
|
||||
|
||||
<!-- 右侧聊天内容区域 -->
|
||||
@@ -32,7 +38,17 @@
|
||||
</v-btn>
|
||||
</div>
|
||||
|
||||
<div class="message-list-wrapper" v-if="messages && messages.length > 0">
|
||||
<!-- 面包屑导航 -->
|
||||
<div v-if="currentSessionProject && messages && messages.length > 0" class="breadcrumb-container">
|
||||
<div class="breadcrumb-content">
|
||||
<span class="breadcrumb-emoji">{{ currentSessionProject.emoji || '📁' }}</span>
|
||||
<span class="breadcrumb-project" @click="handleSelectProject(currentSessionProject.project_id)">{{ currentSessionProject.title }}</span>
|
||||
<v-icon size="small" class="breadcrumb-separator">mdi-chevron-right</v-icon>
|
||||
<span class="breadcrumb-session">{{ getCurrentSession?.display_name || tm('conversation.newConversation') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="message-list-wrapper" v-if="currSessionId && !selectedProjectId">
|
||||
<MessageList :messages="messages" :isDark="isDark"
|
||||
:isStreaming="isStreaming || isConvRunning"
|
||||
:isLoadingMessages="isLoadingMessages"
|
||||
@@ -42,23 +58,70 @@
|
||||
ref="messageList" />
|
||||
<div class="message-list-fade" :class="{ 'fade-dark': isDark }"></div>
|
||||
</div>
|
||||
<div class="welcome-container fade-in" v-else>
|
||||
<div v-if="isLoadingMessages" class="loading-overlay-welcome">
|
||||
<v-progress-circular
|
||||
indeterminate
|
||||
size="48"
|
||||
width="4"
|
||||
color="primary"
|
||||
></v-progress-circular>
|
||||
</div>
|
||||
<div v-else class="welcome-title">
|
||||
<span>Hello, I'm</span>
|
||||
<span class="bot-name">AstrBot ⭐</span>
|
||||
</div>
|
||||
</div>
|
||||
<ProjectView
|
||||
v-else-if="selectedProjectId"
|
||||
:project="currentProject"
|
||||
:sessions="projectSessions"
|
||||
@selectSession="(sessionId) => handleSelectConversation([sessionId])"
|
||||
@editSessionTitle="showEditTitleDialog"
|
||||
@deleteSession="handleDeleteConversation"
|
||||
>
|
||||
<ChatInput
|
||||
v-model:prompt="prompt"
|
||||
:stagedImagesUrl="stagedImagesUrl"
|
||||
:stagedAudioUrl="stagedAudioUrl"
|
||||
:stagedFiles="stagedNonImageFiles"
|
||||
:disabled="isStreaming"
|
||||
:enableStreaming="enableStreaming"
|
||||
:isRecording="isRecording"
|
||||
:session-id="currSessionId || null"
|
||||
:current-session="getCurrentSession"
|
||||
:replyTo="replyTo"
|
||||
@send="handleSendMessage"
|
||||
@toggleStreaming="toggleStreaming"
|
||||
@removeImage="removeImage"
|
||||
@removeAudio="removeAudio"
|
||||
@removeFile="removeFile"
|
||||
@startRecording="handleStartRecording"
|
||||
@stopRecording="handleStopRecording"
|
||||
@pasteImage="handlePaste"
|
||||
@fileSelect="handleFileSelect"
|
||||
@clearReply="clearReply"
|
||||
ref="chatInputRef"
|
||||
/>
|
||||
</ProjectView>
|
||||
<WelcomeView
|
||||
v-else
|
||||
:isLoading="isLoadingMessages"
|
||||
>
|
||||
<ChatInput
|
||||
v-model:prompt="prompt"
|
||||
:stagedImagesUrl="stagedImagesUrl"
|
||||
:stagedAudioUrl="stagedAudioUrl"
|
||||
:stagedFiles="stagedNonImageFiles"
|
||||
:disabled="isStreaming"
|
||||
:enableStreaming="enableStreaming"
|
||||
:isRecording="isRecording"
|
||||
:session-id="currSessionId || null"
|
||||
:current-session="getCurrentSession"
|
||||
:replyTo="replyTo"
|
||||
@send="handleSendMessage"
|
||||
@toggleStreaming="toggleStreaming"
|
||||
@removeImage="removeImage"
|
||||
@removeAudio="removeAudio"
|
||||
@removeFile="removeFile"
|
||||
@startRecording="handleStartRecording"
|
||||
@stopRecording="handleStopRecording"
|
||||
@pasteImage="handlePaste"
|
||||
@fileSelect="handleFileSelect"
|
||||
@clearReply="clearReply"
|
||||
ref="chatInputRef"
|
||||
/>
|
||||
</WelcomeView>
|
||||
|
||||
<!-- 输入区域 -->
|
||||
<ChatInput
|
||||
v-if="currSessionId && !selectedProjectId"
|
||||
v-model:prompt="prompt"
|
||||
:stagedImagesUrl="stagedImagesUrl"
|
||||
:stagedAudioUrl="stagedAudioUrl"
|
||||
@@ -92,12 +155,12 @@
|
||||
<v-card-title class="dialog-title">{{ tm('actions.editTitle') }}</v-card-title>
|
||||
<v-card-text>
|
||||
<v-text-field v-model="editingTitle" :label="tm('conversation.newConversation')" variant="outlined"
|
||||
hide-details class="mt-2" @keyup.enter="saveTitle" autofocus />
|
||||
hide-details class="mt-2" @keyup.enter="handleSaveTitle" autofocus />
|
||||
</v-card-text>
|
||||
<v-card-actions>
|
||||
<v-spacer></v-spacer>
|
||||
<v-btn variant="text" @click="editTitleDialog = false" color="grey-darken-1">{{ t('core.common.cancel') }}</v-btn>
|
||||
<v-btn variant="text" @click="saveTitle" color="primary">{{ t('core.common.save') }}</v-btn>
|
||||
<v-btn variant="text" @click="handleSaveTitle" color="primary">{{ t('core.common.save') }}</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
@@ -114,6 +177,13 @@
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
|
||||
<!-- 创建/编辑项目对话框 -->
|
||||
<ProjectDialog
|
||||
v-model="projectDialog"
|
||||
:project="editingProject"
|
||||
@save="handleSaveProject"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
@@ -122,14 +192,19 @@ import { useRouter, useRoute } from 'vue-router';
|
||||
import { useCustomizerStore } from '@/stores/customizer';
|
||||
import { useI18n, useModuleI18n } from '@/i18n/composables';
|
||||
import { useTheme } from 'vuetify';
|
||||
import LanguageSwitcher from '@/components/shared/LanguageSwitcher.vue';
|
||||
import MessageList from '@/components/chat/MessageList.vue';
|
||||
import ConversationSidebar from '@/components/chat/ConversationSidebar.vue';
|
||||
import ChatInput from '@/components/chat/ChatInput.vue';
|
||||
import ProjectDialog from '@/components/chat/ProjectDialog.vue';
|
||||
import ProjectView from '@/components/chat/ProjectView.vue';
|
||||
import WelcomeView from '@/components/chat/WelcomeView.vue';
|
||||
import type { ProjectFormData } from '@/components/chat/ProjectDialog.vue';
|
||||
import { useSessions } from '@/composables/useSessions';
|
||||
import { useMessages } from '@/composables/useMessages';
|
||||
import { useMediaHandling } from '@/composables/useMediaHandling';
|
||||
import { useRecording } from '@/composables/useRecording';
|
||||
import { useProjects } from '@/composables/useProjects';
|
||||
import type { Project } from '@/components/chat/ProjectList.vue';
|
||||
|
||||
interface Props {
|
||||
chatboxMode?: boolean;
|
||||
@@ -189,11 +264,23 @@ const {
|
||||
|
||||
const { isRecording, startRecording: startRec, stopRecording: stopRec } = useRecording();
|
||||
|
||||
const {
|
||||
projects,
|
||||
selectedProjectId,
|
||||
getProjects,
|
||||
createProject,
|
||||
updateProject,
|
||||
deleteProject,
|
||||
addSessionToProject,
|
||||
getProjectSessions
|
||||
} = useProjects();
|
||||
|
||||
const {
|
||||
messages,
|
||||
isStreaming,
|
||||
isConvRunning,
|
||||
enableStreaming,
|
||||
currentSessionProject,
|
||||
getSessionMessages: getSessionMsg,
|
||||
sendMessage: sendMsg,
|
||||
toggleStreaming
|
||||
@@ -206,6 +293,14 @@ const chatInputRef = ref<InstanceType<typeof ChatInput> | null>(null);
|
||||
// 输入状态
|
||||
const prompt = ref('');
|
||||
|
||||
// 项目状态
|
||||
const projectDialog = ref(false);
|
||||
const editingProject = ref<Project | null>(null);
|
||||
const projectSessions = ref<any[]>([]);
|
||||
const currentProject = computed(() =>
|
||||
projects.value.find(p => p.project_id === selectedProjectId.value)
|
||||
);
|
||||
|
||||
// 引用消息状态
|
||||
interface ReplyInfo {
|
||||
messageId: number; // PlatformSessionHistoryMessage 的 id
|
||||
@@ -251,6 +346,16 @@ function openImagePreview(imageUrl: string) {
|
||||
imagePreviewDialog.value = true;
|
||||
}
|
||||
|
||||
async function handleSaveTitle() {
|
||||
await saveTitle();
|
||||
|
||||
// 如果在项目视图中,刷新项目会话列表
|
||||
if (selectedProjectId.value) {
|
||||
const sessions = await getProjectSessions(selectedProjectId.value);
|
||||
projectSessions.value = sessions;
|
||||
}
|
||||
}
|
||||
|
||||
function handleReplyMessage(msg: any, index: number) {
|
||||
// 从消息中获取 id (PlatformSessionHistoryMessage 的 id)
|
||||
const messageId = msg.id;
|
||||
@@ -304,6 +409,10 @@ function handleReplyWithText(replyData: any) {
|
||||
async function handleSelectConversation(sessionIds: string[]) {
|
||||
if (!sessionIds[0]) return;
|
||||
|
||||
// 退出项目视图
|
||||
selectedProjectId.value = null;
|
||||
projectSessions.value = [];
|
||||
|
||||
// 立即更新选中状态,避免需要点击两次
|
||||
currSessionId.value = sessionIds[0];
|
||||
selectedSessions.value = [sessionIds[0]];
|
||||
@@ -340,11 +449,67 @@ function handleNewChat() {
|
||||
newChat(closeMobileSidebar);
|
||||
messages.value = [];
|
||||
clearReply();
|
||||
// 退出项目视图
|
||||
selectedProjectId.value = null;
|
||||
projectSessions.value = [];
|
||||
}
|
||||
|
||||
async function handleDeleteConversation(sessionId: string) {
|
||||
await deleteSessionFn(sessionId);
|
||||
messages.value = [];
|
||||
|
||||
// 如果在项目视图中,刷新项目会话列表
|
||||
if (selectedProjectId.value) {
|
||||
const sessions = await getProjectSessions(selectedProjectId.value);
|
||||
projectSessions.value = sessions;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSelectProject(projectId: string) {
|
||||
selectedProjectId.value = projectId;
|
||||
const sessions = await getProjectSessions(projectId);
|
||||
projectSessions.value = sessions;
|
||||
messages.value = [];
|
||||
|
||||
// 清空当前会话ID,准备在项目中创建新对话
|
||||
currSessionId.value = '';
|
||||
selectedSessions.value = [];
|
||||
|
||||
// 手机端关闭侧边栏
|
||||
if (isMobile.value) {
|
||||
closeMobileSidebar();
|
||||
}
|
||||
}
|
||||
|
||||
function showCreateProjectDialog() {
|
||||
editingProject.value = null;
|
||||
projectDialog.value = true;
|
||||
}
|
||||
|
||||
function showEditProjectDialog(project: Project) {
|
||||
editingProject.value = project;
|
||||
projectDialog.value = true;
|
||||
}
|
||||
|
||||
async function handleSaveProject(formData: ProjectFormData, projectId?: string) {
|
||||
if (projectId) {
|
||||
await updateProject(
|
||||
projectId,
|
||||
formData.title,
|
||||
formData.emoji,
|
||||
formData.description
|
||||
);
|
||||
} else {
|
||||
await createProject(
|
||||
formData.title,
|
||||
formData.emoji,
|
||||
formData.description
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDeleteProject(projectId: string) {
|
||||
await deleteProject(projectId);
|
||||
}
|
||||
|
||||
async function handleStartRecording() {
|
||||
@@ -373,8 +538,17 @@ async function handleSendMessage() {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!currSessionId.value) {
|
||||
const isCreatingNewSession = !currSessionId.value;
|
||||
const currentProjectId = selectedProjectId.value; // 保存当前项目ID
|
||||
|
||||
if (isCreatingNewSession) {
|
||||
await newSession();
|
||||
|
||||
// 如果在项目视图中创建新会话,立即退出项目视图
|
||||
if (currentProjectId) {
|
||||
selectedProjectId.value = null;
|
||||
projectSessions.value = [];
|
||||
}
|
||||
}
|
||||
|
||||
const promptToSend = prompt.value.trim();
|
||||
@@ -405,6 +579,15 @@ async function handleSendMessage() {
|
||||
selectedModelName,
|
||||
replyToSend
|
||||
);
|
||||
|
||||
// 如果在项目中创建了新会话,将其添加到项目
|
||||
if (isCreatingNewSession && currentProjectId && currSessionId.value) {
|
||||
await addSessionToProject(currSessionId.value, currentProjectId);
|
||||
// 刷新会话列表,移除已添加到项目的会话
|
||||
await getSessions();
|
||||
// 重新获取会话消息以更新项目信息(用于面包屑显示)
|
||||
await getSessionMsg(currSessionId.value);
|
||||
}
|
||||
}
|
||||
|
||||
// 路由变化监听
|
||||
@@ -454,6 +637,7 @@ onMounted(() => {
|
||||
checkMobile();
|
||||
window.addEventListener('resize', checkMobile);
|
||||
getSessions();
|
||||
getProjects();
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
@@ -568,30 +752,39 @@ onBeforeUnmount(() => {
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
.welcome-container {
|
||||
height: 100%;
|
||||
.breadcrumb-container {
|
||||
padding: 8px 16px;
|
||||
border-bottom: 1px solid var(--v-theme-border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.breadcrumb-content {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
flex-direction: column;
|
||||
position: relative;
|
||||
gap: 8px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.welcome-title {
|
||||
font-size: 28px;
|
||||
margin-bottom: 16px;
|
||||
.breadcrumb-emoji {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.loading-overlay-welcome {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
.breadcrumb-project {
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
.bot-name {
|
||||
font-weight: 700;
|
||||
margin-left: 8px;
|
||||
color: var(--v-theme-secondary);
|
||||
.breadcrumb-project:hover {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.breadcrumb-separator {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.breadcrumb-session {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.fade-in {
|
||||
|
||||
@@ -29,32 +29,62 @@
|
||||
style="width: 100%; resize: none; outline: none; border: 1px solid var(--v-theme-border); border-radius: 12px; padding: 12px 16px; min-height: 40px; font-family: inherit; font-size: 16px; background-color: var(--v-theme-surface);"></textarea>
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; padding: 6px 14px;">
|
||||
<div style="display: flex; justify-content: flex-start; margin-top: 4px; align-items: center; gap: 8px;">
|
||||
<ConfigSelector
|
||||
:session-id="sessionId || null"
|
||||
:platform-id="sessionPlatformId"
|
||||
:is-group="sessionIsGroup"
|
||||
:initial-config-id="props.configId"
|
||||
@config-changed="handleConfigChange"
|
||||
/>
|
||||
<!-- Settings Menu -->
|
||||
<StyledMenu offset="8" location="top start" :close-on-content-click="false">
|
||||
<template v-slot:activator="{ props: activatorProps }">
|
||||
<v-btn
|
||||
v-bind="activatorProps"
|
||||
icon="mdi-plus"
|
||||
variant="text"
|
||||
color="deep-purple"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<!-- Upload Files -->
|
||||
<v-list-item
|
||||
class="styled-menu-item"
|
||||
rounded="md"
|
||||
@click="triggerImageInput"
|
||||
>
|
||||
<template v-slot:prepend>
|
||||
<v-icon icon="mdi-file-upload-outline" size="small"></v-icon>
|
||||
</template>
|
||||
<v-list-item-title>
|
||||
{{ tm('input.upload') }}
|
||||
</v-list-item-title>
|
||||
</v-list-item>
|
||||
|
||||
<!-- Config Selector in Menu -->
|
||||
<ConfigSelector
|
||||
:session-id="sessionId || null"
|
||||
:platform-id="sessionPlatformId"
|
||||
:is-group="sessionIsGroup"
|
||||
:initial-config-id="props.configId"
|
||||
@config-changed="handleConfigChange"
|
||||
/>
|
||||
|
||||
<!-- Streaming Toggle in Menu -->
|
||||
<v-list-item
|
||||
class="styled-menu-item"
|
||||
rounded="md"
|
||||
@click="$emit('toggleStreaming')"
|
||||
>
|
||||
<template v-slot:prepend>
|
||||
<v-icon :icon="enableStreaming ? 'mdi-flash' : 'mdi-flash-off'" size="small"></v-icon>
|
||||
</template>
|
||||
<v-list-item-title>
|
||||
{{ enableStreaming ? tm('streaming.enabled') : tm('streaming.disabled') }}
|
||||
</v-list-item-title>
|
||||
</v-list-item>
|
||||
</StyledMenu>
|
||||
|
||||
<!-- Provider/Model Selector Menu -->
|
||||
<ProviderModelMenu v-if="showProviderSelector" ref="providerModelMenuRef" />
|
||||
|
||||
<v-tooltip :text="enableStreaming ? tm('streaming.enabled') : tm('streaming.disabled')" location="top">
|
||||
<template v-slot:activator="{ props }">
|
||||
<v-chip v-bind="props" @click="$emit('toggleStreaming')" size="x-small" class="streaming-toggle-chip">
|
||||
<v-icon start :icon="enableStreaming ? 'mdi-flash' : 'mdi-flash-off'" size="small"></v-icon>
|
||||
{{ enableStreaming ? tm('streaming.on') : tm('streaming.off') }}
|
||||
</v-chip>
|
||||
</template>
|
||||
</v-tooltip>
|
||||
</div>
|
||||
<div style="display: flex; justify-content: flex-end; margin-top: 8px; align-items: center;">
|
||||
<input type="file" ref="imageInputRef" @change="handleFileSelect"
|
||||
style="display: none" multiple />
|
||||
<v-progress-circular v-if="disabled" indeterminate size="16" class="mr-1" width="1.5" />
|
||||
<v-btn @click="triggerImageInput" icon="mdi-plus" variant="text" color="deep-purple"
|
||||
class="add-btn" size="small" />
|
||||
<v-btn @click="handleRecordClick"
|
||||
:icon="isRecording ? 'mdi-stop-circle' : 'mdi-microphone'" variant="text"
|
||||
:color="isRecording ? 'error' : 'deep-purple'" class="record-btn" size="small" />
|
||||
@@ -99,6 +129,7 @@ import { useModuleI18n } from '@/i18n/composables';
|
||||
import { useCustomizerStore } from '@/stores/customizer';
|
||||
import ConfigSelector from './ConfigSelector.vue';
|
||||
import ProviderModelMenu from './ProviderModelMenu.vue';
|
||||
import StyledMenu from '@/components/shared/StyledMenu.vue';
|
||||
import type { Session } from '@/composables/useSessions';
|
||||
|
||||
interface StagedFileInfo {
|
||||
@@ -425,16 +456,6 @@ defineExpose({
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.streaming-toggle-chip {
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.streaming-toggle-chip:hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.fade-in {
|
||||
animation: fadeIn 0.3s ease-in-out;
|
||||
}
|
||||
@@ -458,11 +479,6 @@ defineExpose({
|
||||
.input-container {
|
||||
width: 100% !important;
|
||||
max-width: 100% !important;
|
||||
margin: 0 !important;
|
||||
border-radius: 0 !important;
|
||||
border-left: none !important;
|
||||
border-right: none !important;
|
||||
border-bottom: none !important;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,21 +1,24 @@
|
||||
<template>
|
||||
<div>
|
||||
<v-tooltip text="选择用于当前会话的配置文件" location="top">
|
||||
<template #activator="{ props: tooltipProps }">
|
||||
<v-chip
|
||||
v-bind="tooltipProps"
|
||||
class="text-none config-chip"
|
||||
variant="tonal"
|
||||
size="x-small"
|
||||
rounded="lg"
|
||||
@click="openDialog"
|
||||
:disabled="loadingConfigs || saving"
|
||||
>
|
||||
<v-icon start size="14">mdi-cog</v-icon>
|
||||
{{ selectedConfigLabel }}
|
||||
</v-chip>
|
||||
<v-list-item
|
||||
class="styled-menu-item"
|
||||
rounded="md"
|
||||
@click="openDialog"
|
||||
:disabled="loadingConfigs || saving"
|
||||
>
|
||||
<template v-slot:prepend>
|
||||
<v-icon icon="mdi-cog-outline" size="small"></v-icon>
|
||||
</template>
|
||||
</v-tooltip>
|
||||
<v-list-item-title>
|
||||
{{ tm('config.title') }}
|
||||
</v-list-item-title>
|
||||
<v-list-item-subtitle class="text-caption">
|
||||
{{ selectedConfigLabel }}
|
||||
</v-list-item-subtitle>
|
||||
<template v-slot:append>
|
||||
<v-icon icon="mdi-chevron-right" size="small" class="text-medium-emphasis"></v-icon>
|
||||
</template>
|
||||
</v-list-item>
|
||||
|
||||
<v-dialog v-model="dialog" max-width="480">
|
||||
<v-card>
|
||||
@@ -73,6 +76,7 @@
|
||||
import { computed, onMounted, ref, watch } from 'vue';
|
||||
import axios from 'axios';
|
||||
import { useToast } from '@/utils/toast';
|
||||
import { useModuleI18n } from '@/i18n/composables';
|
||||
|
||||
interface ConfigInfo {
|
||||
id: string;
|
||||
@@ -100,6 +104,8 @@ const props = withDefaults(defineProps<{
|
||||
|
||||
const emit = defineEmits<{ 'config-changed': [ConfigChangedPayload] }>();
|
||||
|
||||
const { tm } = useModuleI18n('features/chat');
|
||||
|
||||
const configOptions = ref<ConfigInfo[]>([]);
|
||||
const loadingConfigs = ref(false);
|
||||
const dialog = ref(false);
|
||||
@@ -301,11 +307,6 @@ onMounted(async () => {
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.config-chip {
|
||||
cursor: pointer;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.config-list {
|
||||
max-height: 360px;
|
||||
overflow-y: auto;
|
||||
|
||||
@@ -21,12 +21,22 @@
|
||||
</div>
|
||||
|
||||
<div style="padding: 8px; opacity: 0.6;">
|
||||
<v-btn block variant="text" class="new-chat-btn" @click="$emit('newChat')" :disabled="!currSessionId"
|
||||
<v-btn block variant="text" class="new-chat-btn" @click="$emit('newChat')" :disabled="!currSessionId && !selectedProjectId"
|
||||
v-if="!sidebarCollapsed || isMobile" prepend-icon="mdi-square-edit-outline">{{ tm('actions.newChat') }}</v-btn>
|
||||
<v-btn icon="mdi-square-edit-outline" rounded="xl" @click="$emit('newChat')" :disabled="!currSessionId"
|
||||
<v-btn icon="mdi-square-edit-outline" rounded="xl" @click="$emit('newChat')" :disabled="!currSessionId && !selectedProjectId"
|
||||
v-if="sidebarCollapsed && !isMobile" elevation="0"></v-btn>
|
||||
</div>
|
||||
|
||||
<!-- 项目列表组件 -->
|
||||
<ProjectList
|
||||
v-if="!sidebarCollapsed || isMobile"
|
||||
:projects="projects"
|
||||
@selectProject="$emit('selectProject', $event)"
|
||||
@createProject="$emit('createProject')"
|
||||
@editProject="$emit('editProject', $event)"
|
||||
@deleteProject="$emit('deleteProject', $event)"
|
||||
/>
|
||||
|
||||
<div style="overflow-y: auto; flex-grow: 1;"
|
||||
v-if="!sidebarCollapsed || isMobile">
|
||||
<v-card v-if="sessions.length > 0" flat style="background-color: transparent;">
|
||||
@@ -137,18 +147,24 @@ import type { Session } from '@/composables/useSessions';
|
||||
import LanguageSwitcher from '@/components/shared/LanguageSwitcher.vue';
|
||||
import StyledMenu from '@/components/shared/StyledMenu.vue';
|
||||
import ProviderConfigDialog from '@/components/chat/ProviderConfigDialog.vue';
|
||||
import ProjectList from '@/components/chat/ProjectList.vue';
|
||||
import type { Project } from '@/components/chat/ProjectList.vue';
|
||||
|
||||
interface Props {
|
||||
sessions: Session[];
|
||||
selectedSessions: string[];
|
||||
currSessionId: string;
|
||||
selectedProjectId?: string | null;
|
||||
isDark: boolean;
|
||||
chatboxMode: boolean;
|
||||
isMobile: boolean;
|
||||
mobileMenuOpen: boolean;
|
||||
projects?: Project[];
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
projects: () => []
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
newChat: [];
|
||||
@@ -158,6 +174,10 @@ const emit = defineEmits<{
|
||||
closeMobileSidebar: [];
|
||||
toggleTheme: [];
|
||||
toggleFullscreen: [];
|
||||
selectProject: [projectId: string];
|
||||
createProject: [];
|
||||
editProject: [project: Project];
|
||||
deleteProject: [projectId: string];
|
||||
}>();
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
<div v-else-if="part.type === 'image' && part.embedded_url" class="image-attachments">
|
||||
<div class="image-attachment">
|
||||
<img :src="part.embedded_url" class="attached-image"
|
||||
@click="$emit('openImagePreview', part.embedded_url)" />
|
||||
@click="openImagePreview(part.embedded_url)" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -147,24 +147,44 @@
|
||||
borderTopColor: 'rgba(100, 140, 200, 0.3)',
|
||||
backgroundColor: 'rgba(30, 45, 70, 0.5)'
|
||||
} : {}">
|
||||
<div class="tool-call-detail-row">
|
||||
<span class="detail-label">ID:</span>
|
||||
<code class="detail-value"
|
||||
:style="isDark ? { backgroundColor: 'transparent' } : {}">{{ toolCall.id
|
||||
}}</code>
|
||||
</div>
|
||||
<div class="tool-call-detail-row">
|
||||
<span class="detail-label">Args:</span>
|
||||
<pre class="detail-value detail-json"
|
||||
:style="isDark ? { backgroundColor: 'transparent' } : {}">{{
|
||||
JSON.stringify(toolCall.args, null, 2) }}</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) }}
|
||||
<!-- 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">
|
||||
<span class="detail-label">ID:</span>
|
||||
<code class="detail-value"
|
||||
:style="isDark ? { backgroundColor: 'transparent' } : {}">{{ toolCall.id
|
||||
}}</code>
|
||||
</div>
|
||||
<div class="tool-call-detail-row">
|
||||
<span class="detail-label">Args:</span>
|
||||
<pre class="detail-value detail-json"
|
||||
:style="isDark ? { backgroundColor: 'transparent' } : {}">{{
|
||||
JSON.stringify(toolCall.args, null, 2) }}</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>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -178,7 +198,7 @@
|
||||
<div v-else-if="part.type === 'image' && part.embedded_url" class="embedded-images">
|
||||
<div class="embedded-image">
|
||||
<img :src="part.embedded_url" class="bot-embedded-image"
|
||||
@click="$emit('openImagePreview', part.embedded_url)" />
|
||||
@click="openImagePreview(part.embedded_url)" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -289,6 +309,13 @@
|
||||
</v-btn>
|
||||
</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>
|
||||
|
||||
<script>
|
||||
@@ -298,6 +325,7 @@ import 'markstream-vue/index.css'
|
||||
import 'katex/dist/katex.min.css'
|
||||
import 'highlight.js/styles/github.css';
|
||||
import axios from 'axios';
|
||||
import { createHighlighter } from 'shiki';
|
||||
|
||||
enableKatex();
|
||||
enableMermaid();
|
||||
@@ -351,15 +379,24 @@ export default {
|
||||
content: '',
|
||||
messageIndex: null,
|
||||
position: { top: 0, left: 0 }
|
||||
}
|
||||
},
|
||||
// 图片预览
|
||||
imagePreview: {
|
||||
show: false,
|
||||
url: ''
|
||||
},
|
||||
// Shiki highlighter
|
||||
shikiHighlighter: null,
|
||||
shikiReady: false
|
||||
};
|
||||
},
|
||||
mounted() {
|
||||
async mounted() {
|
||||
this.initCodeCopyButtons();
|
||||
this.initImageClickEvents();
|
||||
this.addScrollListener();
|
||||
this.scrollToBottom();
|
||||
this.startElapsedTimeTimer();
|
||||
await this.initShiki();
|
||||
},
|
||||
updated() {
|
||||
this.initCodeCopyButtons();
|
||||
@@ -676,7 +713,7 @@ export default {
|
||||
if (!img.hasAttribute('data-click-enabled')) {
|
||||
img.style.cursor = 'pointer';
|
||||
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) {
|
||||
if (!ttft || ttft <= 0) return '';
|
||||
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 {
|
||||
max-width: 40%;
|
||||
max-width: 55%;
|
||||
width: auto;
|
||||
height: auto;
|
||||
border-radius: 8px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
@@ -1423,12 +1520,14 @@ export default {
|
||||
overflow: hidden;
|
||||
background-color: #eff3f6;
|
||||
margin: 8px 0px;
|
||||
max-width: 300px;
|
||||
transition: max-width 0.1s ease;
|
||||
width: fit-content;
|
||||
min-width: 320px;
|
||||
max-width: 100%;
|
||||
transition: all 0.1s ease;
|
||||
}
|
||||
|
||||
.tool-call-card.expanded {
|
||||
max-width: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.tool-call-header {
|
||||
@@ -1595,6 +1694,34 @@ export default {
|
||||
max-height: 300px;
|
||||
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>
|
||||
@@ -1635,4 +1762,36 @@ export default {
|
||||
font-family: 'Fira Code', 'Consolas', monospace;
|
||||
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>
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
<template>
|
||||
<v-dialog v-model="isOpen" max-width="500" @update:model-value="handleDialogChange">
|
||||
<v-card>
|
||||
<v-card-title class="dialog-title">
|
||||
{{ isEditing ? tm('project.edit') : tm('project.create') }}
|
||||
</v-card-title>
|
||||
<v-card-text>
|
||||
<v-text-field v-model="form.emoji" :label="tm('project.emoji')" flat variant="solo-filled" hide-details class="mb-3" />
|
||||
<v-text-field v-model="form.title" :label="tm('project.name')" flat variant="solo-filled" hide-details class="mb-3" autofocus
|
||||
@keyup.enter="handleSave" />
|
||||
<v-textarea v-model="form.description" :label="tm('project.description')" flat variant="solo-filled" hide-details rows="3" rounded="lg" />
|
||||
</v-card-text>
|
||||
<v-card-actions>
|
||||
<v-spacer></v-spacer>
|
||||
<v-btn variant="text" @click="handleCancel" color="grey-darken-1">{{ t('core.common.cancel') }}</v-btn>
|
||||
<v-btn variant="text" @click="handleSave" color="primary" :disabled="!form.title.trim()">{{ t('core.common.save') }}</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue';
|
||||
import { useI18n, useModuleI18n } from '@/i18n/composables';
|
||||
|
||||
export interface Project {
|
||||
project_id: string;
|
||||
title: string;
|
||||
emoji?: string;
|
||||
description?: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface ProjectFormData {
|
||||
emoji: string;
|
||||
title: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
modelValue: boolean;
|
||||
project?: Project | null;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
modelValue: false,
|
||||
project: null
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: boolean];
|
||||
save: [formData: ProjectFormData, projectId?: string];
|
||||
}>();
|
||||
|
||||
const { t } = useI18n();
|
||||
const { tm } = useModuleI18n('features/chat');
|
||||
|
||||
const isOpen = ref(props.modelValue);
|
||||
const isEditing = ref(false);
|
||||
const form = ref<ProjectFormData>({
|
||||
emoji: '📁',
|
||||
title: '',
|
||||
description: ''
|
||||
});
|
||||
|
||||
watch(() => props.modelValue, (newVal) => {
|
||||
isOpen.value = newVal;
|
||||
if (newVal) {
|
||||
// 打开对话框时初始化表单
|
||||
if (props.project) {
|
||||
isEditing.value = true;
|
||||
form.value = {
|
||||
emoji: props.project.emoji || '📁',
|
||||
title: props.project.title,
|
||||
description: props.project.description || ''
|
||||
};
|
||||
} else {
|
||||
isEditing.value = false;
|
||||
form.value = {
|
||||
emoji: '📁',
|
||||
title: '',
|
||||
description: ''
|
||||
};
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
function handleDialogChange(value: boolean) {
|
||||
emit('update:modelValue', value);
|
||||
}
|
||||
|
||||
function handleCancel() {
|
||||
isOpen.value = false;
|
||||
emit('update:modelValue', false);
|
||||
}
|
||||
|
||||
function handleSave() {
|
||||
if (!form.value.title.trim()) {
|
||||
return;
|
||||
}
|
||||
|
||||
emit('save', { ...form.value }, props.project?.project_id);
|
||||
isOpen.value = false;
|
||||
emit('update:modelValue', false);
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.dialog-title {
|
||||
font-size: 22px;
|
||||
font-weight: 500;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,159 @@
|
||||
<template>
|
||||
<div>
|
||||
<!-- 项目按钮 -->
|
||||
<div style="padding: 0 8px 0px 8px; opacity: 0.6;">
|
||||
<v-btn block variant="text" class="project-btn" @click="toggleExpanded" prepend-icon="mdi-folder-outline">
|
||||
{{ tm('project.title') }}
|
||||
<template v-slot:append>
|
||||
<v-icon size="small">{{ expanded ? 'mdi-chevron-up' : 'mdi-chevron-down' }}</v-icon>
|
||||
</template>
|
||||
</v-btn>
|
||||
</div>
|
||||
|
||||
<!-- 项目列表 -->
|
||||
<v-expand-transition>
|
||||
<div v-show="expanded" style="padding: 0 8px;">
|
||||
<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"
|
||||
@click="$emit('selectProject', project.project_id)" rounded="lg" class="project-item">
|
||||
<template v-slot:prepend>
|
||||
<span class="project-emoji">{{ project.emoji || '📁' }}</span>
|
||||
</template>
|
||||
<v-list-item-title class="project-title">{{ project.title }}</v-list-item-title>
|
||||
<template v-slot:append>
|
||||
<div class="project-actions">
|
||||
<v-btn icon="mdi-pencil" size="x-small" variant="text" class="edit-project-btn"
|
||||
@click.stop="$emit('editProject', project)" />
|
||||
<v-btn icon="mdi-delete" size="x-small" variant="text" class="delete-project-btn"
|
||||
color="error" @click.stop="handleDeleteProject(project)" />
|
||||
</div>
|
||||
</template>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
</div>
|
||||
</v-expand-transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue';
|
||||
import { useModuleI18n } from '@/i18n/composables';
|
||||
|
||||
export interface Project {
|
||||
project_id: string;
|
||||
title: string;
|
||||
emoji?: string;
|
||||
description?: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
projects: Project[];
|
||||
initialExpanded?: boolean;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
initialExpanded: false
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
selectProject: [projectId: string];
|
||||
createProject: [];
|
||||
editProject: [project: Project];
|
||||
deleteProject: [projectId: string];
|
||||
}>();
|
||||
|
||||
const { tm } = useModuleI18n('features/chat');
|
||||
|
||||
const expanded = ref(props.initialExpanded);
|
||||
|
||||
// 从 localStorage 读取项目展开状态
|
||||
const savedProjectsExpandedState = localStorage.getItem('projectsExpanded');
|
||||
if (savedProjectsExpandedState !== null) {
|
||||
expanded.value = JSON.parse(savedProjectsExpandedState);
|
||||
}
|
||||
|
||||
function toggleExpanded() {
|
||||
expanded.value = !expanded.value;
|
||||
localStorage.setItem('projectsExpanded', JSON.stringify(expanded.value));
|
||||
}
|
||||
|
||||
function handleDeleteProject(project: Project) {
|
||||
const message = tm('project.confirmDelete', { title: project.title });
|
||||
if (window.confirm(message)) {
|
||||
emit('deleteProject', project.project_id);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.project-btn {
|
||||
justify-content: flex-start;
|
||||
background-color: transparent !important;
|
||||
border-radius: 20px;
|
||||
padding: 8px 16px !important;
|
||||
text-transform: none;
|
||||
}
|
||||
|
||||
.project-item {
|
||||
border-radius: 16px !important;
|
||||
padding: 4px 12px !important;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.project-item:hover {
|
||||
background-color: rgba(103, 58, 183, 0.05);
|
||||
}
|
||||
|
||||
.project-item:hover .project-actions {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
}
|
||||
|
||||
.project-emoji {
|
||||
font-size: 16px;
|
||||
margin-right: 6px;
|
||||
}
|
||||
|
||||
.project-title {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.project-actions {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.edit-project-btn,
|
||||
.delete-project-btn {
|
||||
opacity: 0.7;
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.edit-project-btn:hover,
|
||||
.delete-project-btn:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.create-project-item {
|
||||
border-radius: 16px !important;
|
||||
padding: 4px 12px !important;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.create-project-item:hover {
|
||||
background-color: rgba(103, 58, 183, 0.08);
|
||||
opacity: 1;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,186 @@
|
||||
<template>
|
||||
<div class="project-sessions-container fade-in">
|
||||
<div class="project-header">
|
||||
<div class="project-header-info">
|
||||
<span class="project-header-emoji">{{ project?.emoji || '📁' }}</span>
|
||||
<h2 class="project-header-title">{{ project?.title }}</h2>
|
||||
</div>
|
||||
<p class="project-header-description" v-if="project?.description">
|
||||
{{ project.description }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="project-input-slot">
|
||||
<slot></slot>
|
||||
</div>
|
||||
|
||||
<v-card flat class="project-sessions-list">
|
||||
<v-list v-if="sessions.length > 0">
|
||||
<v-list-item v-for="session in sessions" :key="session.session_id"
|
||||
@click="$emit('selectSession', session.session_id)" class="project-session-item" rounded="lg">
|
||||
<v-list-item-title>
|
||||
{{ session.display_name || tm('conversation.newConversation') }}
|
||||
</v-list-item-title>
|
||||
<v-list-item-subtitle>
|
||||
{{ formatDate(session.updated_at) }}
|
||||
</v-list-item-subtitle>
|
||||
<template v-slot:append>
|
||||
<div class="session-actions">
|
||||
<v-btn icon="mdi-pencil" size="x-small" variant="text"
|
||||
class="edit-session-btn"
|
||||
@click.stop="$emit('editSessionTitle', session.session_id, session.display_name ?? '')" />
|
||||
<v-btn icon="mdi-delete" size="x-small" variant="text"
|
||||
class="delete-session-btn" color="error"
|
||||
@click.stop="handleDeleteSession(session)" />
|
||||
</div>
|
||||
</template>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
<div v-else class="no-sessions-in-project">
|
||||
<v-icon icon="mdi-message-off-outline" size="large" color="grey-lighten-1"></v-icon>
|
||||
<p>{{ tm('project.noSessions') }}</p>
|
||||
</div>
|
||||
</v-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useModuleI18n } from '@/i18n/composables';
|
||||
import type { Project } from '@/components/chat/ProjectList.vue';
|
||||
|
||||
interface Session {
|
||||
session_id: string;
|
||||
display_name?: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
project?: Project | null;
|
||||
sessions: Session[];
|
||||
}
|
||||
|
||||
defineProps<Props>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
selectSession: [sessionId: string];
|
||||
editSessionTitle: [sessionId: string, title: string];
|
||||
deleteSession: [sessionId: string];
|
||||
}>();
|
||||
|
||||
const { tm } = useModuleI18n('features/chat');
|
||||
|
||||
function formatDate(dateString: string): string {
|
||||
return new Date(dateString).toLocaleString();
|
||||
}
|
||||
|
||||
function handleDeleteSession(session: Session) {
|
||||
const sessionTitle = session.display_name || tm('conversation.newConversation');
|
||||
const message = tm('conversation.confirmDelete', { name: sessionTitle });
|
||||
if (window.confirm(message)) {
|
||||
emit('deleteSession', session.session_id);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.project-sessions-container {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 32px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.project-header {
|
||||
text-align: center;
|
||||
margin-bottom: 32px;
|
||||
max-width: 600px;
|
||||
}
|
||||
|
||||
.project-header-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.project-header-emoji {
|
||||
font-size: 48px;
|
||||
}
|
||||
|
||||
.project-header-title {
|
||||
font-size: 32px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.project-header-description {
|
||||
font-size: 14px;
|
||||
color: var(--v-theme-secondaryText);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.project-input-slot {
|
||||
width: 100%;
|
||||
max-width: 800px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.project-sessions-list {
|
||||
width: 100%;
|
||||
max-width: 680px;
|
||||
background-color: transparent !important;
|
||||
}
|
||||
|
||||
.project-session-item {
|
||||
margin-bottom: 8px;
|
||||
border-radius: 12px !important;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.project-session-item:hover {
|
||||
background-color: rgba(103, 58, 183, 0.05);
|
||||
}
|
||||
|
||||
.project-session-item:hover .session-actions {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
}
|
||||
|
||||
.session-actions {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.no-sessions-in-project {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 48px;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.no-sessions-in-project p {
|
||||
margin-top: 12px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.fade-in {
|
||||
animation: fadeIn 0.3s ease-in-out;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<v-menu v-model="menuOpen" :close-on-content-click="false" location="top" @update:model-value="handleMenuToggle">
|
||||
<template v-slot:activator="{ props: menuProps }">
|
||||
<v-chip v-bind="menuProps" class="text-none provider-chip" variant="tonal" size="x-small">
|
||||
<v-chip v-bind="menuProps" class="text-none provider-chip" variant="tonal" :size="chipSize">
|
||||
<v-icon start size="14">mdi-creation</v-icon>
|
||||
<span v-if="selectedProviderId">
|
||||
{{ selectedProviderId }}
|
||||
@@ -59,6 +59,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue';
|
||||
import { useDisplay } from 'vuetify';
|
||||
import axios from 'axios';
|
||||
|
||||
interface ModelMetadata {
|
||||
@@ -75,11 +76,15 @@ interface ProviderConfig {
|
||||
enable?: boolean;
|
||||
}
|
||||
|
||||
const { mobile } = useDisplay();
|
||||
|
||||
const providerConfigs = ref<ProviderConfig[]>([]);
|
||||
const selectedProviderId = ref('');
|
||||
const searchQuery = ref('');
|
||||
const menuOpen = ref(false);
|
||||
|
||||
const chipSize = computed(() => mobile.value ? 'x-small' : 'small');
|
||||
|
||||
const filteredProviders = computed(() => {
|
||||
if (!searchQuery.value) {
|
||||
return providerConfigs.value;
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
<template>
|
||||
<div class="welcome-container fade-in">
|
||||
<div v-if="isLoading" class="loading-overlay-welcome">
|
||||
<v-progress-circular
|
||||
indeterminate
|
||||
size="48"
|
||||
width="4"
|
||||
color="primary"
|
||||
></v-progress-circular>
|
||||
</div>
|
||||
<template v-else>
|
||||
<div class="welcome-content">
|
||||
<div class="welcome-title">
|
||||
<span class="bot-name-container">
|
||||
<span class="bot-name-text">
|
||||
Hello, I'm <span class="highlight-name">AstrBot</span>
|
||||
</span>
|
||||
<span class="bot-name-star">⭐</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="welcome-input">
|
||||
<slot></slot>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
interface Props {
|
||||
isLoading?: boolean;
|
||||
}
|
||||
|
||||
withDefaults(defineProps<Props>(), {
|
||||
isLoading: false
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.welcome-container {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-direction: column;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.welcome-content {
|
||||
padding: 24px 0px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.welcome-title {
|
||||
font-size: 28px;
|
||||
text-align: center;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.welcome-input {
|
||||
width: 75%;
|
||||
}
|
||||
|
||||
.loading-overlay-welcome {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.bot-name-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.highlight-name {
|
||||
color: var(--v-theme-secondary);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.bot-name-text {
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
width: 0;
|
||||
opacity: 0;
|
||||
animation: revealText 1.2s cubic-bezier(0.34, 1.56, 0.64, 1) forwards;
|
||||
animation-delay: 0.2s;
|
||||
}
|
||||
|
||||
.bot-name-star {
|
||||
margin-left: 0;
|
||||
display: inline-block;
|
||||
transform-origin: center;
|
||||
animation: rotateStar 1.2s cubic-bezier(0.34, 1, 0.64, 1) forwards;
|
||||
animation-delay: 0.2s;
|
||||
padding-left: 4px;
|
||||
}
|
||||
|
||||
@keyframes revealText {
|
||||
from {
|
||||
width: 0;
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
width: 9.2em;
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes rotateStar {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.fade-in {
|
||||
animation: fadeIn 0.3s ease-in-out;
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.welcome-input {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -82,6 +82,9 @@ export function useMessages(
|
||||
const activeSSECount = ref(0);
|
||||
const enableStreaming = ref(true);
|
||||
const attachmentCache = new Map<string, string>(); // attachment_id -> blob URL
|
||||
|
||||
// 当前会话的项目信息
|
||||
const currentSessionProject = ref<{ project_id: string; title: string; emoji: string } | null>(null);
|
||||
|
||||
// 从 localStorage 读取流式响应开关状态
|
||||
const savedStreamingState = localStorage.getItem('enableStreaming');
|
||||
@@ -179,6 +182,9 @@ export function useMessages(
|
||||
const response = await axios.get('/api/chat/get_session?session_id=' + sessionId);
|
||||
isConvRunning.value = response.data.data.is_running || false;
|
||||
let history = response.data.data.history;
|
||||
|
||||
// 保存项目信息(如果存在)
|
||||
currentSessionProject.value = response.data.data.project || null;
|
||||
|
||||
if (isConvRunning.value) {
|
||||
if (!isToastedRunningInfo.value) {
|
||||
@@ -579,6 +585,7 @@ export function useMessages(
|
||||
isStreaming,
|
||||
isConvRunning,
|
||||
enableStreaming,
|
||||
currentSessionProject,
|
||||
getSessionMessages,
|
||||
sendMessage,
|
||||
toggleStreaming,
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
import { ref } from 'vue';
|
||||
import axios from 'axios';
|
||||
import type { Project } from '@/components/chat/ProjectList.vue';
|
||||
|
||||
export function useProjects() {
|
||||
const projects = ref<Project[]>([]);
|
||||
const selectedProjectId = ref<string | null>(null);
|
||||
|
||||
async function getProjects() {
|
||||
try {
|
||||
const res = await axios.get('/api/chatui_project/list');
|
||||
if (res.data.status === 'ok') {
|
||||
projects.value = res.data.data || [];
|
||||
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch projects:', error);
|
||||
}
|
||||
}
|
||||
|
||||
async function createProject(title: string, emoji?: string, description?: string) {
|
||||
try {
|
||||
const res = await axios.post('/api/chatui_project/create', {
|
||||
title,
|
||||
emoji: emoji || '📁',
|
||||
description
|
||||
});
|
||||
if (res.data.status === 'ok') {
|
||||
await getProjects();
|
||||
return res.data.data;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to create project:', error);
|
||||
}
|
||||
}
|
||||
|
||||
async function updateProject(projectId: string, title?: string, emoji?: string, description?: string) {
|
||||
try {
|
||||
const res = await axios.post('/api/chatui_project/update', {
|
||||
project_id: projectId,
|
||||
title,
|
||||
emoji,
|
||||
description
|
||||
});
|
||||
if (res.data.status === 'ok') {
|
||||
await getProjects();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to update project:', error);
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteProject(projectId: string) {
|
||||
try {
|
||||
const res = await axios.get('/api/chatui_project/delete', {
|
||||
params: { project_id: projectId }
|
||||
});
|
||||
if (res.data.status === 'ok') {
|
||||
await getProjects();
|
||||
if (selectedProjectId.value === projectId) {
|
||||
selectedProjectId.value = null;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to delete project:', error);
|
||||
}
|
||||
}
|
||||
|
||||
async function addSessionToProject(sessionId: string, projectId: string) {
|
||||
try {
|
||||
const res = await axios.post('/api/chatui_project/add_session', {
|
||||
session_id: sessionId,
|
||||
project_id: projectId
|
||||
});
|
||||
return res.data.status === 'ok';
|
||||
} catch (error) {
|
||||
console.error('Failed to add session to project:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function removeSessionFromProject(sessionId: string) {
|
||||
try {
|
||||
const res = await axios.post('/api/chatui_project/remove_session', {
|
||||
session_id: sessionId
|
||||
});
|
||||
return res.data.status === 'ok';
|
||||
} catch (error) {
|
||||
console.error('Failed to remove session from project:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function getProjectSessions(projectId: string) {
|
||||
try {
|
||||
const res = await axios.get('/api/chatui_project/get_sessions', {
|
||||
params: { project_id: projectId }
|
||||
});
|
||||
if (res.data.status === 'ok') {
|
||||
return res.data.data || [];
|
||||
}
|
||||
return [];
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch project sessions:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
projects,
|
||||
selectedProjectId,
|
||||
getProjects,
|
||||
createProject,
|
||||
updateProject,
|
||||
deleteProject,
|
||||
addSessionToProject,
|
||||
removeSessionFromProject,
|
||||
getProjectSessions
|
||||
};
|
||||
}
|
||||
@@ -70,14 +70,25 @@
|
||||
"disabled": "Streaming disabled",
|
||||
"on": "Stream",
|
||||
"off": "Normal"
|
||||
},
|
||||
"reasoning": {
|
||||
}, "config": {
|
||||
"title": "Config"
|
||||
}, "reasoning": {
|
||||
"thinking": "Thinking Process"
|
||||
},
|
||||
"reply": {
|
||||
"replyTo": "Reply to",
|
||||
"notFound": "Message not found"
|
||||
},
|
||||
"project": {
|
||||
"title": "Projects",
|
||||
"create": "Create Project",
|
||||
"edit": "Edit Project",
|
||||
"name": "Project Name",
|
||||
"emoji": "Icon (Emoji)",
|
||||
"description": "Description (Optional)",
|
||||
"noSessions": "No conversations in this project",
|
||||
"confirmDelete": "Are you sure you want to delete project \"{title}\"? Conversations in this project will not be deleted."
|
||||
},
|
||||
"time": {
|
||||
"today": "Today",
|
||||
"yesterday": "Yesterday"
|
||||
|
||||
@@ -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": {
|
||||
"description": "Context Management Strategy",
|
||||
"provider_settings": {
|
||||
|
||||
@@ -71,6 +71,9 @@
|
||||
"on": "流式",
|
||||
"off": "普通"
|
||||
},
|
||||
"config": {
|
||||
"title": "配置文件"
|
||||
},
|
||||
"reasoning": {
|
||||
"thinking": "思考过程"
|
||||
},
|
||||
@@ -78,6 +81,16 @@
|
||||
"replyTo": "引用",
|
||||
"notFound": "无法定位消息"
|
||||
},
|
||||
"project": {
|
||||
"title": "项目",
|
||||
"create": "创建项目",
|
||||
"edit": "编辑项目",
|
||||
"name": "项目名称",
|
||||
"emoji": "图标 (Emoji)",
|
||||
"description": "项目描述(可选)",
|
||||
"noSessions": "该项目暂无对话",
|
||||
"confirmDelete": "确定要删除项目 \"{title}\" 吗?项目中的对话不会被删除。"
|
||||
},
|
||||
"time": {
|
||||
"today": "今天",
|
||||
"yesterday": "昨天"
|
||||
|
||||
@@ -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": {
|
||||
"description": "上下文管理策略",
|
||||
"provider_settings": {
|
||||
|
||||
@@ -50,7 +50,16 @@ const extension_data = reactive({
|
||||
data: [],
|
||||
message: "",
|
||||
});
|
||||
const showReserved = ref(false);
|
||||
|
||||
// 从 localStorage 恢复显示系统插件的状态,默认为 false(隐藏)
|
||||
const getInitialShowReserved = () => {
|
||||
if (typeof window !== "undefined" && window.localStorage) {
|
||||
const saved = localStorage.getItem("showReservedPlugins");
|
||||
return saved === "true";
|
||||
}
|
||||
return false;
|
||||
};
|
||||
const showReserved = ref(getInitialShowReserved());
|
||||
const snack_message = ref("");
|
||||
const snack_show = ref(false);
|
||||
const snack_success = ref("success");
|
||||
@@ -290,6 +299,10 @@ const updatableExtensions = computed(() => {
|
||||
// 方法
|
||||
const toggleShowReserved = () => {
|
||||
showReserved.value = !showReserved.value;
|
||||
// 保存到 localStorage
|
||||
if (typeof window !== "undefined" && window.localStorage) {
|
||||
localStorage.setItem("showReservedPlugins", showReserved.value.toString());
|
||||
}
|
||||
};
|
||||
|
||||
const toast = (message, success) => {
|
||||
|
||||
@@ -60,6 +60,7 @@ dependencies = [
|
||||
"markitdown-no-magika[docx,xls,xlsx]>=0.1.2",
|
||||
"xinference-client",
|
||||
"tenacity>=9.1.2",
|
||||
"shipyard-python-sdk>=0.2.4",
|
||||
]
|
||||
|
||||
[dependency-groups]
|
||||
|
||||
Reference in New Issue
Block a user