Compare commits

...

42 Commits

Author SHA1 Message Date
Soulter 71d4357ca7 chore: remove skills prompt 2026-01-15 16:08:58 +08:00
Soulter 97081bf543 feat: add sandbox mode prompt for enhanced user guidance in executing commands 2026-01-15 16:05:56 +08:00
Soulter 9c9239073e bugfixes 2026-01-15 14:29:46 +08:00
Soulter c78ac6acd7 feat: enhance iPython tool rendering with Shiki syntax highlighting 2026-01-15 14:01:43 +08:00
Soulter ac427af3c8 fix: ensure message stream order (#4487) 2026-01-15 13:11:49 +08:00
Soulter b7160c9c58 Merge remote-tracking branch 'origin/master' into feat/astrbot-agent-mode 2026-01-15 12:48:58 +08:00
Soulter 8aeb2229ce fix: chatui title (#4486) 2026-01-15 12:47:55 +08:00
Soulter 86715813ad project list 2026-01-15 12:46:22 +08:00
Soulter a363a2ddcd refactor: remove unused file tools and update PythonTool output handling 2026-01-15 01:50:48 +08:00
Soulter f2af8e58e2 fix: update description for command parameter in ExecuteShellTool 2026-01-15 00:58:34 +08:00
Soulter 937f0b7f32 fix: handle empty output case in PythonTool execution 2026-01-15 00:48:50 +08:00
Li-shi-ling c52ab1346b docs: standardize Context class documentation formatting (#4436)
* docs: standardize Context class documentation formatting

- Unified all method docstrings to standard format
- Fixed mixed language and formatting issues
- Added complete parameter and return descriptions
- Enhanced developer experience for plugin creators
- Fixes #4429

* docs: fix Context class documentation issues per review

- Restored Sphinx directives for versionadded notes
- Fixed MessageSesion typo to MessageSession throughout file
- Added clarification for kwargs propagation in tool_loop_agent
- Unified deprecation marker format
- Fixes #4429

* Convert developer API comments to English

* chore: revise comments

---------

Co-authored-by: Soulter <37870767+Soulter@users.noreply.github.com>
2026-01-14 23:56:18 +08:00
Soulter c8fca4e6a0 fix: title saving logic and update project sessions on changes 2026-01-14 23:56:18 +08:00
Soulter 45397e941d feat: chatui project (#4477)
* feat: chatui-project

* fix: remove console log from getProjects function
2026-01-14 23:56:18 +08:00
時壹 ce0a024757 fix: sending OpenAI-style image_url causes Anthropic 400 invalid tag error (#4444) 2026-01-14 23:56:18 +08:00
Li-shi-ling 179f3e6426 docs: standardize Context class documentation formatting (#4436)
* docs: standardize Context class documentation formatting

- Unified all method docstrings to standard format
- Fixed mixed language and formatting issues
- Added complete parameter and return descriptions
- Enhanced developer experience for plugin creators
- Fixes #4429

* docs: fix Context class documentation issues per review

- Restored Sphinx directives for versionadded notes
- Fixed MessageSesion typo to MessageSession throughout file
- Added clarification for kwargs propagation in tool_loop_agent
- Unified deprecation marker format
- Fixes #4429

* Convert developer API comments to English

* chore: revise comments

---------

Co-authored-by: Soulter <37870767+Soulter@users.noreply.github.com>
2026-01-14 23:46:27 +08:00
Soulter 561741d43d fix: title saving logic and update project sessions on changes 2026-01-14 20:51:54 +08:00
Soulter 63e8d0634f feat: chatui project (#4477)
* feat: chatui-project

* fix: remove console log from getProjects function
2026-01-14 19:15:48 +08:00
時壹 350667b60f fix: sending OpenAI-style image_url causes Anthropic 400 invalid tag error (#4444) 2026-01-14 10:50:56 +08:00
Soulter 792e348076 feat: add file download functionality and update shipyard SDK version 2026-01-13 21:28:56 +08:00
Soulter 068094708e feat: add shipyard session configuration options and update related tools 2026-01-13 20:32:31 +08:00
Soulter 661bcfd890 feat: add availability check for sandbox in Shipyard and base booters 2026-01-13 20:02:18 +08:00
Soulter 0a58eaecdd fix 2026-01-13 19:45:14 +08:00
Soulter d564926e6b Merge remote-tracking branch 'origin/master' into feat/astrbot-agent-mode 2026-01-13 19:40:09 +08:00
Soulter 7d1709667e docs: refine EULA 2026-01-13 19:39:13 +08:00
Soulter ebdecf8dce feat: implement localStorage persistence for showReservedPlugins state 2026-01-13 19:39:13 +08:00
Soulter 3698b771dd chore: makes world better 2026-01-13 19:39:13 +08:00
Soulter 12b4ee0a2b remove 2026-01-13 19:36:36 +08:00
Soulter 9de0fe304c uv lock 2026-01-13 19:35:58 +08:00
Soulter e5cac2684f beta 2026-01-13 19:35:12 +08:00
Soulter 1646547cb4 fix 2026-01-13 19:34:07 +08:00
Soulter dca88d8ab8 feat: sandbox 2026-01-13 19:29:22 +08:00
Soulter 2e2da4b4ce feat: implement singleton pattern for ShipyardSandboxClient and add FileUploadTool for file uploads 2026-01-13 18:26:39 +08:00
Soulter 6df966e9a2 fix: remove 'boxlite' option from booter and handle error in PythonTool execution 2026-01-13 16:38:04 +08:00
Soulter a89e7b3f55 update 2026-01-13 01:48:45 +08:00
Soulter ea7c387fcb fix 2026-01-12 22:22:35 +08:00
Soulter 350c18b741 Merge remote-tracking branch 'origin/master' into feat/astrbot-agent-mode 2026-01-12 22:19:26 +08:00
Soulter fdbed75ce4 Merge remote-tracking branch 'origin/master' into feat/astrbot-agent-mode 2025-10-29 15:56:54 +08:00
Soulter 9fec29c1a3 feat: file upload 2025-10-04 23:42:59 +08:00
Soulter 972b5ffb86 fix: update tool call logging to include tool call IDs and enhance sandbox ship creation parameters 2025-10-03 00:46:02 +08:00
Soulter 33e67bf925 Merge remote-tracking branch 'origin/master' into feat/astrbot-agent-mode 2025-10-03 00:45:30 +08:00
Soulter 185501d1b5 stage 2025-10-02 14:06:23 +08:00
48 changed files with 3131 additions and 800 deletions
@@ -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}")
+64
View File
@@ -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,
@@ -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",
+84 -2
View File
@@ -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."""
...
+65
View File
@@ -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
View File
@@ -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,13 @@ 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,
)
@@ -94,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):
@@ -342,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 users 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,
@@ -467,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]:
@@ -545,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)
@@ -595,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
@@ -618,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,
@@ -688,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,
@@ -7,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.
@@ -21,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]):
@@ -138,6 +158,11 @@ 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"}
@@ -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)
+31
View File
@@ -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."""
...
+186
View File
@@ -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)
+67
View File
@@ -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
+5
View File
@@ -0,0 +1,5 @@
from .filesystem import FileSystemComponent
from .python import PythonComponent
from .shell import ShellComponent
__all__ = ["PythonComponent", "ShellComponent", "FileSystemComponent"]
+33
View File
@@ -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"""
...
+19
View File
@@ -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"""
...
+21
View File
@@ -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"""
...
+52
View File
@@ -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]
+10
View File
@@ -0,0 +1,10 @@
from .fs import FileDownloadTool, FileUploadTool
from .python import PythonTool
from .shell import ExecuteShellTool
__all__ = [
"FileUploadTool",
"PythonTool",
"ExecuteShellTool",
"FileDownloadTool",
]
+188
View File
@@ -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)}"
+74
View File
@@ -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)}"
+55
View File
@@ -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
View File
@@ -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)
+2
View File
@@ -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",
+40 -12
View File
@@ -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."""
+245
View File
@@ -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__
+1
View File
@@ -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)
+1 -1
View File
@@ -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",
+228 -35
View File
@@ -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 {
+49 -33
View File
@@ -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();
+186 -27
View File
@@ -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>
+7
View File
@@ -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,
+120
View File
@@ -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": {
+1
View File
@@ -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]