Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f57a3bb6d0 |
@@ -0,0 +1,536 @@
|
|||||||
|
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()
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
name: astrbot-python-interpreter
|
||||||
|
desc: Python 代码执行器
|
||||||
|
author: Soulter
|
||||||
|
version: 0.0.1
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
aiodocker
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
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}")
|
||||||
@@ -1 +1 @@
|
|||||||
__version__ = "4.12.1"
|
__version__ = "4.11.4"
|
||||||
|
|||||||
@@ -227,8 +227,7 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
|
|||||||
encrypted=llm_resp.reasoning_signature,
|
encrypted=llm_resp.reasoning_signature,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
if llm_resp.completion_text:
|
parts.append(TextPart(text=llm_resp.completion_text or "*No response*"))
|
||||||
parts.append(TextPart(text=llm_resp.completion_text))
|
|
||||||
self.run_context.messages.append(Message(role="assistant", content=parts))
|
self.run_context.messages.append(Message(role="assistant", content=parts))
|
||||||
|
|
||||||
# call the on_agent_done hook
|
# call the on_agent_done hook
|
||||||
@@ -278,8 +277,7 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
|
|||||||
encrypted=llm_resp.reasoning_signature,
|
encrypted=llm_resp.reasoning_signature,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
if llm_resp.completion_text:
|
parts.append(TextPart(text=llm_resp.completion_text or "*No response*"))
|
||||||
parts.append(TextPart(text=llm_resp.completion_text))
|
|
||||||
tool_calls_result = ToolCallsResult(
|
tool_calls_result = ToolCallsResult(
|
||||||
tool_calls_info=AssistantMessageSegment(
|
tool_calls_info=AssistantMessageSegment(
|
||||||
tool_calls=llm_resp.to_openai_to_calls_model(),
|
tool_calls=llm_resp.to_openai_to_calls_model(),
|
||||||
@@ -363,7 +361,7 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
|
|||||||
ToolCallMessageSegment(
|
ToolCallMessageSegment(
|
||||||
role="tool",
|
role="tool",
|
||||||
tool_call_id=func_tool_id,
|
tool_call_id=func_tool_id,
|
||||||
content=f"error: Tool {func_tool_name} not found.",
|
content=f"error: 未找到工具 {func_tool_name}",
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
continue
|
continue
|
||||||
@@ -429,7 +427,7 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
|
|||||||
ToolCallMessageSegment(
|
ToolCallMessageSegment(
|
||||||
role="tool",
|
role="tool",
|
||||||
tool_call_id=func_tool_id,
|
tool_call_id=func_tool_id,
|
||||||
content="The tool has successfully returned an image and sent directly to the user. You can describe it in your next response.",
|
content="返回了图片(已直接发送给用户)",
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
yield MessageChain(type="tool_direct_result").base64_image(
|
yield MessageChain(type="tool_direct_result").base64_image(
|
||||||
@@ -454,7 +452,7 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
|
|||||||
ToolCallMessageSegment(
|
ToolCallMessageSegment(
|
||||||
role="tool",
|
role="tool",
|
||||||
tool_call_id=func_tool_id,
|
tool_call_id=func_tool_id,
|
||||||
content="The tool has successfully returned an image and sent directly to the user. You can describe it in your next response.",
|
content="返回了图片(已直接发送给用户)",
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
yield MessageChain(
|
yield MessageChain(
|
||||||
@@ -465,7 +463,7 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
|
|||||||
ToolCallMessageSegment(
|
ToolCallMessageSegment(
|
||||||
role="tool",
|
role="tool",
|
||||||
tool_call_id=func_tool_id,
|
tool_call_id=func_tool_id,
|
||||||
content="The tool has returned a data type that is not supported.",
|
content="返回的数据类型不受支持",
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -482,7 +480,7 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
|
|||||||
ToolCallMessageSegment(
|
ToolCallMessageSegment(
|
||||||
role="tool",
|
role="tool",
|
||||||
tool_call_id=func_tool_id,
|
tool_call_id=func_tool_id,
|
||||||
content="The tool has no return value, or has sent the result directly to the user.",
|
content="*工具没有返回值或者将结果直接发送给了用户*",
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
@@ -494,7 +492,7 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
|
|||||||
ToolCallMessageSegment(
|
ToolCallMessageSegment(
|
||||||
role="tool",
|
role="tool",
|
||||||
tool_call_id=func_tool_id,
|
tool_call_id=func_tool_id,
|
||||||
content="*The tool has returned an unsupported type. Please tell the user to check the definition and implementation of this tool.*",
|
content="*工具返回了不支持的类型,请告诉用户检查这个工具的定义和实现。*",
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ from typing import Any, TypedDict
|
|||||||
|
|
||||||
from astrbot.core.utils.astrbot_path import get_astrbot_data_path
|
from astrbot.core.utils.astrbot_path import get_astrbot_data_path
|
||||||
|
|
||||||
VERSION = "4.12.1"
|
VERSION = "4.11.4"
|
||||||
DB_PATH = os.path.join(get_astrbot_data_path(), "data_v4.db")
|
DB_PATH = os.path.join(get_astrbot_data_path(), "data_v4.db")
|
||||||
|
|
||||||
WEBHOOK_SUPPORTED_PLATFORMS = [
|
WEBHOOK_SUPPORTED_PLATFORMS = [
|
||||||
@@ -113,14 +113,6 @@ DEFAULT_CONFIG = {
|
|||||||
"provider": "moonshotai",
|
"provider": "moonshotai",
|
||||||
"moonshotai_api_key": "",
|
"moonshotai_api_key": "",
|
||||||
},
|
},
|
||||||
"sandbox": {
|
|
||||||
"enable": False,
|
|
||||||
"booter": "shipyard",
|
|
||||||
"shipyard_endpoint": "",
|
|
||||||
"shipyard_access_token": "",
|
|
||||||
"shipyard_ttl": 3600,
|
|
||||||
"shipyard_max_sessions": 10,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
"provider_stt_settings": {
|
"provider_stt_settings": {
|
||||||
"enable": False,
|
"enable": False,
|
||||||
@@ -2547,62 +2539,6 @@ CONFIG_METADATA_3 = {
|
|||||||
# "provider_settings.enable": True,
|
# "provider_settings.enable": True,
|
||||||
# },
|
# },
|
||||||
# },
|
# },
|
||||||
"sandbox": {
|
|
||||||
"description": "Agent 沙箱环境",
|
|
||||||
"type": "object",
|
|
||||||
"items": {
|
|
||||||
"provider_settings.sandbox.enable": {
|
|
||||||
"description": "启用沙箱环境",
|
|
||||||
"type": "bool",
|
|
||||||
"hint": "启用后,Agent 可以使用沙箱环境中的工具和资源,如 Python 代码执行、Shell 等。",
|
|
||||||
},
|
|
||||||
"provider_settings.sandbox.booter": {
|
|
||||||
"description": "沙箱环境驱动器",
|
|
||||||
"type": "string",
|
|
||||||
"options": ["shipyard"],
|
|
||||||
"condition": {
|
|
||||||
"provider_settings.sandbox.enable": True,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
"provider_settings.sandbox.shipyard_endpoint": {
|
|
||||||
"description": "Shipyard API Endpoint",
|
|
||||||
"type": "string",
|
|
||||||
"hint": "Shipyard 服务的 API 访问地址。",
|
|
||||||
"condition": {
|
|
||||||
"provider_settings.sandbox.enable": True,
|
|
||||||
"provider_settings.sandbox.booter": "shipyard",
|
|
||||||
},
|
|
||||||
"_special": "check_shipyard_connection",
|
|
||||||
},
|
|
||||||
"provider_settings.sandbox.shipyard_access_token": {
|
|
||||||
"description": "Shipyard Access Token",
|
|
||||||
"type": "string",
|
|
||||||
"hint": "用于访问 Shipyard 服务的访问令牌。",
|
|
||||||
"condition": {
|
|
||||||
"provider_settings.sandbox.enable": True,
|
|
||||||
"provider_settings.sandbox.booter": "shipyard",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
"provider_settings.sandbox.shipyard_ttl": {
|
|
||||||
"description": "Shipyard Session TTL",
|
|
||||||
"type": "int",
|
|
||||||
"hint": "Shipyard 会话的生存时间(秒)。",
|
|
||||||
"condition": {
|
|
||||||
"provider_settings.sandbox.enable": True,
|
|
||||||
"provider_settings.sandbox.booter": "shipyard",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
"provider_settings.sandbox.shipyard_max_sessions": {
|
|
||||||
"description": "Shipyard Max Sessions",
|
|
||||||
"type": "int",
|
|
||||||
"hint": "Shipyard 最大会话数量。",
|
|
||||||
"condition": {
|
|
||||||
"provider_settings.sandbox.enable": True,
|
|
||||||
"provider_settings.sandbox.booter": "shipyard",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
"truncate_and_compress": {
|
"truncate_and_compress": {
|
||||||
"description": "上下文管理策略",
|
"description": "上下文管理策略",
|
||||||
"type": "object",
|
"type": "object",
|
||||||
|
|||||||
@@ -2,11 +2,10 @@
|
|||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import json
|
import json
|
||||||
import os
|
|
||||||
from collections.abc import AsyncGenerator
|
from collections.abc import AsyncGenerator
|
||||||
|
|
||||||
from astrbot.core import logger
|
from astrbot.core import logger
|
||||||
from astrbot.core.agent.message import Message, TextPart
|
from astrbot.core.agent.message import Message
|
||||||
from astrbot.core.agent.response import AgentStats
|
from astrbot.core.agent.response import AgentStats
|
||||||
from astrbot.core.agent.tool import ToolSet
|
from astrbot.core.agent.tool import ToolSet
|
||||||
from astrbot.core.astr_agent_context import AstrAgentContext
|
from astrbot.core.astr_agent_context import AstrAgentContext
|
||||||
@@ -36,15 +35,8 @@ from .....astr_agent_tool_exec import FunctionToolExecutor
|
|||||||
from ....context import PipelineContext, call_event_hook
|
from ....context import PipelineContext, call_event_hook
|
||||||
from ...stage import Stage
|
from ...stage import Stage
|
||||||
from ...utils import (
|
from ...utils import (
|
||||||
CHATUI_EXTRA_PROMPT,
|
|
||||||
EXECUTE_SHELL_TOOL,
|
|
||||||
FILE_DOWNLOAD_TOOL,
|
|
||||||
FILE_UPLOAD_TOOL,
|
|
||||||
KNOWLEDGE_BASE_QUERY_TOOL,
|
KNOWLEDGE_BASE_QUERY_TOOL,
|
||||||
LLM_SAFETY_MODE_SYSTEM_PROMPT,
|
LLM_SAFETY_MODE_SYSTEM_PROMPT,
|
||||||
PYTHON_TOOL,
|
|
||||||
SANDBOX_MODE_PROMPT,
|
|
||||||
TOOL_CALL_PROMPT,
|
|
||||||
decoded_blocked,
|
decoded_blocked,
|
||||||
retrieve_knowledge_base,
|
retrieve_knowledge_base,
|
||||||
)
|
)
|
||||||
@@ -102,8 +94,6 @@ class InternalAgentSubStage(Stage):
|
|||||||
"safety_mode_strategy", "system_prompt"
|
"safety_mode_strategy", "system_prompt"
|
||||||
)
|
)
|
||||||
|
|
||||||
self.sandbox_cfg = settings.get("sandbox", {})
|
|
||||||
|
|
||||||
self.conv_manager = ctx.plugin_manager.context.conversation_manager
|
self.conv_manager = ctx.plugin_manager.context.conversation_manager
|
||||||
|
|
||||||
def _select_provider(self, event: AstrMessageEvent):
|
def _select_provider(self, event: AstrMessageEvent):
|
||||||
@@ -352,45 +342,54 @@ class InternalAgentSubStage(Stage):
|
|||||||
prov: Provider,
|
prov: Provider,
|
||||||
):
|
):
|
||||||
"""处理 WebChat 平台的特殊情况,包括第一次 LLM 对话时总结对话内容生成 title"""
|
"""处理 WebChat 平台的特殊情况,包括第一次 LLM 对话时总结对话内容生成 title"""
|
||||||
from astrbot.core import db_helper
|
if not req.conversation:
|
||||||
|
|
||||||
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
|
return
|
||||||
|
conversation = await self.conv_manager.get_conversation(
|
||||||
llm_resp = await prov.text_chat(
|
event.unified_msg_origin,
|
||||||
system_prompt=(
|
req.conversation.cid,
|
||||||
"You are a conversation title generator. "
|
|
||||||
"Generate a concise title in the same language as the user’s input, "
|
|
||||||
"no more than 10 words, capturing only the core topic."
|
|
||||||
"If the input is a greeting, small talk, or has no clear topic, "
|
|
||||||
"(e.g., “hi”, “hello”, “haha”), return <None>. "
|
|
||||||
"Output only the title itself or <None>, with no explanations."
|
|
||||||
),
|
|
||||||
prompt=(
|
|
||||||
f"Generate a concise title for the following user query:\n{user_prompt}"
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
if llm_resp and llm_resp.completion_text:
|
if conversation and not req.conversation.title:
|
||||||
title = llm_resp.completion_text.strip()
|
messages = json.loads(conversation.history)
|
||||||
if not title or "<None>" in title:
|
latest_pair = messages[-2:]
|
||||||
|
if not latest_pair:
|
||||||
return
|
return
|
||||||
logger.info(
|
content = latest_pair[0].get("content", "")
|
||||||
f"Generated chatui title for session {chatui_session_id}: {title}"
|
if isinstance(content, list):
|
||||||
)
|
# 多模态
|
||||||
await db_helper.update_platform_session(
|
text_parts = []
|
||||||
session_id=chatui_session_id,
|
for item in content:
|
||||||
display_name=title,
|
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>`"
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
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(
|
async def _save_to_history(
|
||||||
self,
|
self,
|
||||||
@@ -468,24 +467,6 @@ class InternalAgentSubStage(Stage):
|
|||||||
f"Unsupported llm_safety_mode strategy: {self.safety_mode_strategy}.",
|
f"Unsupported llm_safety_mode strategy: {self.safety_mode_strategy}.",
|
||||||
)
|
)
|
||||||
|
|
||||||
def _apply_sandbox_tools(self, req: ProviderRequest, session_id: str) -> None:
|
|
||||||
"""Add sandbox tools to the provider request."""
|
|
||||||
if req.func_tool is None:
|
|
||||||
req.func_tool = ToolSet()
|
|
||||||
if self.sandbox_cfg.get("booter") == "shipyard":
|
|
||||||
ep = self.sandbox_cfg.get("shipyard_endpoint", "")
|
|
||||||
at = self.sandbox_cfg.get("shipyard_access_token", "")
|
|
||||||
if not ep or not at:
|
|
||||||
logger.error("Shipyard sandbox configuration is incomplete.")
|
|
||||||
return
|
|
||||||
os.environ["SHIPYARD_ENDPOINT"] = ep
|
|
||||||
os.environ["SHIPYARD_ACCESS_TOKEN"] = at
|
|
||||||
req.func_tool.add_tool(EXECUTE_SHELL_TOOL)
|
|
||||||
req.func_tool.add_tool(PYTHON_TOOL)
|
|
||||||
req.func_tool.add_tool(FILE_UPLOAD_TOOL)
|
|
||||||
req.func_tool.add_tool(FILE_DOWNLOAD_TOOL)
|
|
||||||
req.system_prompt += f"\n{SANDBOX_MODE_PROMPT}\n"
|
|
||||||
|
|
||||||
async def process(
|
async def process(
|
||||||
self, event: AstrMessageEvent, provider_wake_prefix: str
|
self, event: AstrMessageEvent, provider_wake_prefix: str
|
||||||
) -> AsyncGenerator[None, None]:
|
) -> AsyncGenerator[None, None]:
|
||||||
@@ -564,20 +545,6 @@ class InternalAgentSubStage(Stage):
|
|||||||
image_path = await comp.convert_to_file_path()
|
image_path = await comp.convert_to_file_path()
|
||||||
req.image_urls.append(image_path)
|
req.image_urls.append(image_path)
|
||||||
|
|
||||||
req.extra_user_content_parts.append(
|
|
||||||
TextPart(text=f"[Image Attachment: path {image_path}]")
|
|
||||||
)
|
|
||||||
elif isinstance(comp, File) and self.sandbox_cfg.get(
|
|
||||||
"enable", False
|
|
||||||
):
|
|
||||||
file_path = await comp.get_file()
|
|
||||||
file_name = comp.name or os.path.basename(file_path)
|
|
||||||
req.extra_user_content_parts.append(
|
|
||||||
TextPart(
|
|
||||||
text=f"[File Attachment: name {file_name}, path {file_path}]"
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
conversation = await self._get_session_conv(event)
|
conversation = await self._get_session_conv(event)
|
||||||
req.conversation = conversation
|
req.conversation = conversation
|
||||||
req.contexts = json.loads(conversation.history)
|
req.contexts = json.loads(conversation.history)
|
||||||
@@ -628,10 +595,6 @@ class InternalAgentSubStage(Stage):
|
|||||||
if self.llm_safety_mode:
|
if self.llm_safety_mode:
|
||||||
self._apply_llm_safety_mode(req)
|
self._apply_llm_safety_mode(req)
|
||||||
|
|
||||||
# apply sandbox tools
|
|
||||||
if self.sandbox_cfg.get("enable", False):
|
|
||||||
self._apply_sandbox_tools(req, req.session_id)
|
|
||||||
|
|
||||||
stream_to_general = (
|
stream_to_general = (
|
||||||
self.unsupported_streaming_strategy == "turn_off"
|
self.unsupported_streaming_strategy == "turn_off"
|
||||||
and not event.platform_meta.support_streaming_message
|
and not event.platform_meta.support_streaming_message
|
||||||
@@ -655,18 +618,6 @@ class InternalAgentSubStage(Stage):
|
|||||||
"limit"
|
"limit"
|
||||||
]["context"]
|
]["context"]
|
||||||
|
|
||||||
# ChatUI 对话的标题生成
|
|
||||||
if event.get_platform_name() == "webchat":
|
|
||||||
asyncio.create_task(self._handle_webchat(event, req, provider))
|
|
||||||
|
|
||||||
# 注入 ChatUI 额外 prompt
|
|
||||||
# 比如 follow-up questions 提示等
|
|
||||||
req.system_prompt += f"\n{CHATUI_EXTRA_PROMPT}\n"
|
|
||||||
|
|
||||||
# 注入基本 prompt
|
|
||||||
if req.func_tool and req.func_tool.tools:
|
|
||||||
req.system_prompt += f"\n{TOOL_CALL_PROMPT}\n"
|
|
||||||
|
|
||||||
await agent_runner.reset(
|
await agent_runner.reset(
|
||||||
provider=provider,
|
provider=provider,
|
||||||
request=req,
|
request=req,
|
||||||
@@ -737,6 +688,10 @@ class InternalAgentSubStage(Stage):
|
|||||||
agent_runner.stats,
|
agent_runner.stats,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# 异步处理 WebChat 特殊情况
|
||||||
|
if event.get_platform_name() == "webchat":
|
||||||
|
asyncio.create_task(self._handle_webchat(event, req, provider))
|
||||||
|
|
||||||
asyncio.create_task(
|
asyncio.create_task(
|
||||||
Metric.upload(
|
Metric.upload(
|
||||||
llm_tick=1,
|
llm_tick=1,
|
||||||
|
|||||||
@@ -7,12 +7,6 @@ from astrbot.api import logger, sp
|
|||||||
from astrbot.core.agent.run_context import ContextWrapper
|
from astrbot.core.agent.run_context import ContextWrapper
|
||||||
from astrbot.core.agent.tool import FunctionTool, ToolExecResult
|
from astrbot.core.agent.tool import FunctionTool, ToolExecResult
|
||||||
from astrbot.core.astr_agent_context import AstrAgentContext
|
from astrbot.core.astr_agent_context import AstrAgentContext
|
||||||
from astrbot.core.sandbox.tools import (
|
|
||||||
ExecuteShellTool,
|
|
||||||
FileDownloadTool,
|
|
||||||
FileUploadTool,
|
|
||||||
PythonTool,
|
|
||||||
)
|
|
||||||
from astrbot.core.star.context import Context
|
from astrbot.core.star.context import Context
|
||||||
|
|
||||||
LLM_SAFETY_MODE_SYSTEM_PROMPT = """You are running in Safe Mode.
|
LLM_SAFETY_MODE_SYSTEM_PROMPT = """You are running in Safe Mode.
|
||||||
@@ -27,28 +21,6 @@ Rules:
|
|||||||
- Output same language as the user's input.
|
- Output same language as the user's input.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
SANDBOX_MODE_PROMPT = (
|
|
||||||
"You have access to a sandboxed environment and can execute shell commands and Python code securely."
|
|
||||||
# "Your have extended skills library, such as PDF processing, image generation, data analysis, etc. "
|
|
||||||
# "Before handling complex tasks, please retrieve and review the documentation in the in /app/skills/ directory. "
|
|
||||||
# "If the current task matches the description of a specific skill, prioritize following the workflow defined by that skill."
|
|
||||||
# "Use `ls /app/skills/` to list all available skills. "
|
|
||||||
# "Use `cat /app/skills/{skill_name}/SKILL.md` to read the documentation of a specific skill."
|
|
||||||
# "SKILL.md might be large, you can read the description first, which is located in the YAML frontmatter of the file."
|
|
||||||
# "Use shell commands such as grep, sed, awk to extract relevant information from the documentation as needed.\n"
|
|
||||||
)
|
|
||||||
|
|
||||||
TOOL_CALL_PROMPT = (
|
|
||||||
"You MUST NOT return an empty response, especially after invoking a tool."
|
|
||||||
"Before calling any tool, provide a brief explanatory message to the user stating the purpose of the tool call."
|
|
||||||
"After the tool call is completed, you must briefly summarize the results returned by the tool for the user."
|
|
||||||
)
|
|
||||||
|
|
||||||
CHATUI_EXTRA_PROMPT = (
|
|
||||||
'When you answered, you need to add a follow up question / summarization but do not add "Follow up" words. '
|
|
||||||
"Such as, user asked you to generate codes, you can add: Do you need me to run these codes for you?"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class KnowledgeBaseQueryTool(FunctionTool[AstrAgentContext]):
|
class KnowledgeBaseQueryTool(FunctionTool[AstrAgentContext]):
|
||||||
@@ -166,11 +138,6 @@ async def retrieve_knowledge_base(
|
|||||||
|
|
||||||
KNOWLEDGE_BASE_QUERY_TOOL = KnowledgeBaseQueryTool()
|
KNOWLEDGE_BASE_QUERY_TOOL = KnowledgeBaseQueryTool()
|
||||||
|
|
||||||
EXECUTE_SHELL_TOOL = ExecuteShellTool()
|
|
||||||
PYTHON_TOOL = PythonTool()
|
|
||||||
FILE_UPLOAD_TOOL = FileUploadTool()
|
|
||||||
FILE_DOWNLOAD_TOOL = FileDownloadTool()
|
|
||||||
|
|
||||||
# we prevent astrbot from connecting to known malicious hosts
|
# we prevent astrbot from connecting to known malicious hosts
|
||||||
# these hosts are base64 encoded
|
# these hosts are base64 encoded
|
||||||
BLOCKED = {"dGZid2h2d3IuY2xvdWQuc2VhbG9zLmlv", "a291cmljaGF0"}
|
BLOCKED = {"dGZid2h2d3IuY2xvdWQuc2VhbG9zLmlv", "a291cmljaGF0"}
|
||||||
|
|||||||
@@ -42,6 +42,8 @@ class AstrMessageEvent(abc.ABC):
|
|||||||
"""消息对象, AstrBotMessage。带有完整的消息结构。"""
|
"""消息对象, AstrBotMessage。带有完整的消息结构。"""
|
||||||
self.platform_meta = platform_meta
|
self.platform_meta = platform_meta
|
||||||
"""消息平台的信息, 其中 name 是平台的类型,如 aiocqhttp"""
|
"""消息平台的信息, 其中 name 是平台的类型,如 aiocqhttp"""
|
||||||
|
self.session_id = session_id
|
||||||
|
"""用户的会话 ID。可以直接使用下面的 unified_msg_origin"""
|
||||||
self.role = "member"
|
self.role = "member"
|
||||||
"""用户是否是管理员。如果是管理员,这里是 admin"""
|
"""用户是否是管理员。如果是管理员,这里是 admin"""
|
||||||
self.is_wake = False
|
self.is_wake = False
|
||||||
@@ -49,12 +51,12 @@ class AstrMessageEvent(abc.ABC):
|
|||||||
self.is_at_or_wake_command = False
|
self.is_at_or_wake_command = False
|
||||||
"""是否是 At 机器人或者带有唤醒词或者是私聊(插件注册的事件监听器会让 is_wake 设为 True, 但是不会让这个属性置为 True)"""
|
"""是否是 At 机器人或者带有唤醒词或者是私聊(插件注册的事件监听器会让 is_wake 设为 True, 但是不会让这个属性置为 True)"""
|
||||||
self._extras: dict[str, Any] = {}
|
self._extras: dict[str, Any] = {}
|
||||||
self.session = MessageSession(
|
self.session = MessageSesion(
|
||||||
platform_name=platform_meta.id,
|
platform_name=platform_meta.id,
|
||||||
message_type=message_obj.type,
|
message_type=message_obj.type,
|
||||||
session_id=session_id,
|
session_id=session_id,
|
||||||
)
|
)
|
||||||
# self.unified_msg_origin = str(self.session)
|
self.unified_msg_origin = str(self.session)
|
||||||
"""统一的消息来源字符串。格式为 platform_name:message_type:session_id"""
|
"""统一的消息来源字符串。格式为 platform_name:message_type:session_id"""
|
||||||
self._result: MessageEventResult | None = None
|
self._result: MessageEventResult | None = None
|
||||||
"""消息事件的结果"""
|
"""消息事件的结果"""
|
||||||
@@ -70,27 +72,6 @@ class AstrMessageEvent(abc.ABC):
|
|||||||
# back_compability
|
# back_compability
|
||||||
self.platform = platform_meta
|
self.platform = platform_meta
|
||||||
|
|
||||||
@property
|
|
||||||
def unified_msg_origin(self) -> str:
|
|
||||||
"""统一的消息来源字符串。格式为 platform_name:message_type:session_id"""
|
|
||||||
return str(self.session)
|
|
||||||
|
|
||||||
@unified_msg_origin.setter
|
|
||||||
def unified_msg_origin(self, value: str):
|
|
||||||
"""设置统一的消息来源字符串。格式为 platform_name:message_type:session_id"""
|
|
||||||
self.new_session = MessageSession.from_str(value)
|
|
||||||
self.session = self.new_session
|
|
||||||
|
|
||||||
@property
|
|
||||||
def session_id(self) -> str:
|
|
||||||
"""用户的会话 ID。可以直接使用下面的 unified_msg_origin"""
|
|
||||||
return self.session.session_id
|
|
||||||
|
|
||||||
@session_id.setter
|
|
||||||
def session_id(self, value: str):
|
|
||||||
"""设置用户的会话 ID。可以直接使用下面的 unified_msg_origin"""
|
|
||||||
self.session.session_id = value
|
|
||||||
|
|
||||||
def get_platform_name(self):
|
def get_platform_name(self):
|
||||||
"""获取这个事件所属的平台的类型(如 aiocqhttp, slack, discord 等)。
|
"""获取这个事件所属的平台的类型(如 aiocqhttp, slack, discord 等)。
|
||||||
|
|
||||||
|
|||||||
@@ -370,8 +370,6 @@ class DiscordPlatformAdapter(Platform):
|
|||||||
for handler_md in star_handlers_registry:
|
for handler_md in star_handlers_registry:
|
||||||
if not star_map[handler_md.handler_module_path].activated:
|
if not star_map[handler_md.handler_module_path].activated:
|
||||||
continue
|
continue
|
||||||
if not handler_md.enabled:
|
|
||||||
continue
|
|
||||||
for event_filter in handler_md.event_filters:
|
for event_filter in handler_md.event_filters:
|
||||||
cmd_info = self._extract_command_info(event_filter, handler_md)
|
cmd_info = self._extract_command_info(event_filter, handler_md)
|
||||||
if not cmd_info:
|
if not cmd_info:
|
||||||
|
|||||||
@@ -161,8 +161,6 @@ class TelegramPlatformAdapter(Platform):
|
|||||||
handler_metadata = handler_md
|
handler_metadata = handler_md
|
||||||
if not star_map[handler_metadata.handler_module_path].activated:
|
if not star_map[handler_metadata.handler_module_path].activated:
|
||||||
continue
|
continue
|
||||||
if not handler_metadata.enabled:
|
|
||||||
continue
|
|
||||||
for event_filter in handler_metadata.event_filters:
|
for event_filter in handler_metadata.event_filters:
|
||||||
cmd_info = self._extract_command_info(
|
cmd_info = self._extract_command_info(
|
||||||
event_filter,
|
event_filter,
|
||||||
|
|||||||
@@ -93,8 +93,7 @@ class WebChatAdapter(Platform):
|
|||||||
session: MessageSesion,
|
session: MessageSesion,
|
||||||
message_chain: MessageChain,
|
message_chain: MessageChain,
|
||||||
):
|
):
|
||||||
message_id = f"active_{str(uuid.uuid4())}"
|
await WebChatMessageEvent._send(message_chain, session.session_id)
|
||||||
await WebChatMessageEvent._send(message_id, message_chain, session.session_id)
|
|
||||||
await super().send_by_session(session, message_chain)
|
await super().send_by_session(session, message_chain)
|
||||||
|
|
||||||
async def _get_message_history(
|
async def _get_message_history(
|
||||||
@@ -197,7 +196,7 @@ class WebChatAdapter(Platform):
|
|||||||
|
|
||||||
abm.session_id = f"webchat!{username}!{cid}"
|
abm.session_id = f"webchat!{username}!{cid}"
|
||||||
|
|
||||||
abm.message_id = payload.get("message_id")
|
abm.message_id = str(uuid.uuid4())
|
||||||
|
|
||||||
# 处理消息段列表
|
# 处理消息段列表
|
||||||
message_parts = payload.get("message", [])
|
message_parts = payload.get("message", [])
|
||||||
|
|||||||
@@ -21,10 +21,7 @@ class WebChatMessageEvent(AstrMessageEvent):
|
|||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
async def _send(
|
async def _send(
|
||||||
message_id: str,
|
message: MessageChain | None, session_id: str, streaming: bool = False
|
||||||
message: MessageChain | None,
|
|
||||||
session_id: str,
|
|
||||||
streaming: bool = False,
|
|
||||||
) -> str | None:
|
) -> str | None:
|
||||||
cid = session_id.split("!")[-1]
|
cid = session_id.split("!")[-1]
|
||||||
web_chat_back_queue = webchat_queue_mgr.get_or_create_back_queue(cid)
|
web_chat_back_queue = webchat_queue_mgr.get_or_create_back_queue(cid)
|
||||||
@@ -34,7 +31,6 @@ class WebChatMessageEvent(AstrMessageEvent):
|
|||||||
"type": "end",
|
"type": "end",
|
||||||
"data": "",
|
"data": "",
|
||||||
"streaming": False,
|
"streaming": False,
|
||||||
"message_id": message_id,
|
|
||||||
}, # end means this request is finished
|
}, # end means this request is finished
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
@@ -49,7 +45,6 @@ class WebChatMessageEvent(AstrMessageEvent):
|
|||||||
"data": data,
|
"data": data,
|
||||||
"streaming": streaming,
|
"streaming": streaming,
|
||||||
"chain_type": message.type,
|
"chain_type": message.type,
|
||||||
"message_id": message_id,
|
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
elif isinstance(comp, Json):
|
elif isinstance(comp, Json):
|
||||||
@@ -59,7 +54,6 @@ class WebChatMessageEvent(AstrMessageEvent):
|
|||||||
"data": json.dumps(comp.data, ensure_ascii=False),
|
"data": json.dumps(comp.data, ensure_ascii=False),
|
||||||
"streaming": streaming,
|
"streaming": streaming,
|
||||||
"chain_type": message.type,
|
"chain_type": message.type,
|
||||||
"message_id": message_id,
|
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
elif isinstance(comp, Image):
|
elif isinstance(comp, Image):
|
||||||
@@ -75,7 +69,6 @@ class WebChatMessageEvent(AstrMessageEvent):
|
|||||||
"type": "image",
|
"type": "image",
|
||||||
"data": data,
|
"data": data,
|
||||||
"streaming": streaming,
|
"streaming": streaming,
|
||||||
"message_id": message_id,
|
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
elif isinstance(comp, Record):
|
elif isinstance(comp, Record):
|
||||||
@@ -91,7 +84,6 @@ class WebChatMessageEvent(AstrMessageEvent):
|
|||||||
"type": "record",
|
"type": "record",
|
||||||
"data": data,
|
"data": data,
|
||||||
"streaming": streaming,
|
"streaming": streaming,
|
||||||
"message_id": message_id,
|
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
elif isinstance(comp, File):
|
elif isinstance(comp, File):
|
||||||
@@ -102,13 +94,12 @@ class WebChatMessageEvent(AstrMessageEvent):
|
|||||||
filename = f"{uuid.uuid4()!s}{ext}"
|
filename = f"{uuid.uuid4()!s}{ext}"
|
||||||
dest_path = os.path.join(imgs_dir, filename)
|
dest_path = os.path.join(imgs_dir, filename)
|
||||||
shutil.copy2(file_path, dest_path)
|
shutil.copy2(file_path, dest_path)
|
||||||
data = f"[FILE]{filename}"
|
data = f"[FILE]{filename}|{original_name}"
|
||||||
await web_chat_back_queue.put(
|
await web_chat_back_queue.put(
|
||||||
{
|
{
|
||||||
"type": "file",
|
"type": "file",
|
||||||
"data": data,
|
"data": data,
|
||||||
"streaming": streaming,
|
"streaming": streaming,
|
||||||
"message_id": message_id,
|
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
@@ -117,8 +108,7 @@ class WebChatMessageEvent(AstrMessageEvent):
|
|||||||
return data
|
return data
|
||||||
|
|
||||||
async def send(self, message: MessageChain | None):
|
async def send(self, message: MessageChain | None):
|
||||||
message_id = self.message_obj.message_id
|
await WebChatMessageEvent._send(message, session_id=self.session_id)
|
||||||
await WebChatMessageEvent._send(message_id, message, session_id=self.session_id)
|
|
||||||
await super().send(MessageChain([]))
|
await super().send(MessageChain([]))
|
||||||
|
|
||||||
async def send_streaming(self, generator, use_fallback: bool = False):
|
async def send_streaming(self, generator, use_fallback: bool = False):
|
||||||
@@ -126,7 +116,6 @@ class WebChatMessageEvent(AstrMessageEvent):
|
|||||||
reasoning_content = ""
|
reasoning_content = ""
|
||||||
cid = self.session_id.split("!")[-1]
|
cid = self.session_id.split("!")[-1]
|
||||||
web_chat_back_queue = webchat_queue_mgr.get_or_create_back_queue(cid)
|
web_chat_back_queue = webchat_queue_mgr.get_or_create_back_queue(cid)
|
||||||
message_id = self.message_obj.message_id
|
|
||||||
async for chain in generator:
|
async for chain in generator:
|
||||||
# if chain.type == "break" and final_data:
|
# if chain.type == "break" and final_data:
|
||||||
# # 分割符
|
# # 分割符
|
||||||
@@ -141,8 +130,7 @@ class WebChatMessageEvent(AstrMessageEvent):
|
|||||||
# continue
|
# continue
|
||||||
|
|
||||||
r = await WebChatMessageEvent._send(
|
r = await WebChatMessageEvent._send(
|
||||||
message_id=message_id,
|
chain,
|
||||||
message=chain,
|
|
||||||
session_id=self.session_id,
|
session_id=self.session_id,
|
||||||
streaming=True,
|
streaming=True,
|
||||||
)
|
)
|
||||||
@@ -159,7 +147,6 @@ class WebChatMessageEvent(AstrMessageEvent):
|
|||||||
"data": final_data,
|
"data": final_data,
|
||||||
"reasoning": reasoning_content,
|
"reasoning": reasoning_content,
|
||||||
"streaming": True,
|
"streaming": True,
|
||||||
"message_id": message_id,
|
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
await super().send_streaming(generator, use_fallback)
|
await super().send_streaming(generator, use_fallback)
|
||||||
|
|||||||
@@ -1,31 +0,0 @@
|
|||||||
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."""
|
|
||||||
...
|
|
||||||
@@ -1,186 +0,0 @@
|
|||||||
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)
|
|
||||||
@@ -1,67 +0,0 @@
|
|||||||
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
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
from .filesystem import FileSystemComponent
|
|
||||||
from .python import PythonComponent
|
|
||||||
from .shell import ShellComponent
|
|
||||||
|
|
||||||
__all__ = ["PythonComponent", "ShellComponent", "FileSystemComponent"]
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
"""
|
|
||||||
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"""
|
|
||||||
...
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
"""
|
|
||||||
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"""
|
|
||||||
...
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
"""
|
|
||||||
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"""
|
|
||||||
...
|
|
||||||
@@ -1,52 +0,0 @@
|
|||||||
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]
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
from .fs import FileDownloadTool, FileUploadTool
|
|
||||||
from .python import PythonTool
|
|
||||||
from .shell import ExecuteShellTool
|
|
||||||
|
|
||||||
__all__ = [
|
|
||||||
"FileUploadTool",
|
|
||||||
"PythonTool",
|
|
||||||
"ExecuteShellTool",
|
|
||||||
"FileDownloadTool",
|
|
||||||
]
|
|
||||||
@@ -1,188 +0,0 @@
|
|||||||
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)}"
|
|
||||||
@@ -1,74 +0,0 @@
|
|||||||
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)}"
|
|
||||||
@@ -1,55 +0,0 @@
|
|||||||
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)}"
|
|
||||||
+42
-160
@@ -49,7 +49,7 @@ class Context:
|
|||||||
|
|
||||||
registered_web_apis: list = []
|
registered_web_apis: list = []
|
||||||
|
|
||||||
# 向后兼容的变量
|
# back compatibility
|
||||||
_register_tasks: list[Awaitable] = []
|
_register_tasks: list[Awaitable] = []
|
||||||
_star_manager = None
|
_star_manager = None
|
||||||
|
|
||||||
@@ -73,19 +73,12 @@ class Context:
|
|||||||
self._db = db
|
self._db = db
|
||||||
"""AstrBot 数据库"""
|
"""AstrBot 数据库"""
|
||||||
self.provider_manager = provider_manager
|
self.provider_manager = provider_manager
|
||||||
"""模型提供商管理器"""
|
|
||||||
self.platform_manager = platform_manager
|
self.platform_manager = platform_manager
|
||||||
"""平台适配器管理器"""
|
|
||||||
self.conversation_manager = conversation_manager
|
self.conversation_manager = conversation_manager
|
||||||
"""会话管理器"""
|
|
||||||
self.message_history_manager = message_history_manager
|
self.message_history_manager = message_history_manager
|
||||||
"""平台消息历史管理器"""
|
|
||||||
self.persona_manager = persona_manager
|
self.persona_manager = persona_manager
|
||||||
"""人格角色设定管理器"""
|
|
||||||
self.astrbot_config_mgr = astrbot_config_mgr
|
self.astrbot_config_mgr = astrbot_config_mgr
|
||||||
"""配置文件管理器(非webui)"""
|
|
||||||
self.kb_manager = knowledge_base_manager
|
self.kb_manager = knowledge_base_manager
|
||||||
"""知识库管理器"""
|
|
||||||
|
|
||||||
async def llm_generate(
|
async def llm_generate(
|
||||||
self,
|
self,
|
||||||
@@ -233,16 +226,14 @@ class Context:
|
|||||||
return llm_resp
|
return llm_resp
|
||||||
|
|
||||||
async def get_current_chat_provider_id(self, umo: str) -> str:
|
async def get_current_chat_provider_id(self, umo: str) -> str:
|
||||||
"""获取当前使用的聊天模型 Provider ID。
|
"""Get the ID of the currently used chat provider.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
umo: unified_message_origin。消息会话来源 ID。
|
umo(str): unified_message_origin value, if provided and user has enabled provider session isolation, the provider preferred by that session will be used.
|
||||||
|
|
||||||
Returns:
|
|
||||||
指定消息会话来源当前使用的聊天模型 Provider ID。
|
|
||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
ProviderNotFoundError: 未找到。
|
ProviderNotFoundError: If the specified chat provider is not found
|
||||||
|
|
||||||
"""
|
"""
|
||||||
prov = self.get_using_provider(umo)
|
prov = self.get_using_provider(umo)
|
||||||
if not prov:
|
if not prov:
|
||||||
@@ -264,27 +255,20 @@ class Context:
|
|||||||
return self.provider_manager.llm_tools
|
return self.provider_manager.llm_tools
|
||||||
|
|
||||||
def activate_llm_tool(self, name: str) -> bool:
|
def activate_llm_tool(self, name: str) -> bool:
|
||||||
"""激活一个已经注册的函数调用工具。
|
"""激活一个已经注册的函数调用工具。注册的工具默认是激活状态。
|
||||||
|
|
||||||
Args:
|
|
||||||
name: 工具名称。
|
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
如果成功激活返回 True,如果没找到工具返回 False。
|
如果没找到,会返回 False
|
||||||
|
|
||||||
Note:
|
|
||||||
注册的工具默认是激活状态。
|
|
||||||
"""
|
"""
|
||||||
return self.provider_manager.llm_tools.activate_llm_tool(name, star_map)
|
return self.provider_manager.llm_tools.activate_llm_tool(name, star_map)
|
||||||
|
|
||||||
def deactivate_llm_tool(self, name: str) -> bool:
|
def deactivate_llm_tool(self, name: str) -> bool:
|
||||||
"""停用一个已经注册的函数调用工具。
|
"""停用一个已经注册的函数调用工具。
|
||||||
|
|
||||||
Args:
|
|
||||||
name: 工具名称。
|
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
如果成功停用返回 True,如果没找到工具返回 False。
|
如果没找到,会返回 False
|
||||||
|
|
||||||
"""
|
"""
|
||||||
return self.provider_manager.llm_tools.deactivate_llm_tool(name)
|
return self.provider_manager.llm_tools.deactivate_llm_tool(name)
|
||||||
|
|
||||||
@@ -294,17 +278,7 @@ class Context:
|
|||||||
) -> (
|
) -> (
|
||||||
Provider | TTSProvider | STTProvider | EmbeddingProvider | RerankProvider | None
|
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)
|
prov = self.provider_manager.inst_map.get(provider_id)
|
||||||
if provider_id and not prov:
|
if provider_id and not prov:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
@@ -329,20 +303,11 @@ class Context:
|
|||||||
return self.provider_manager.embedding_provider_insts
|
return self.provider_manager.embedding_provider_insts
|
||||||
|
|
||||||
def get_using_provider(self, umo: str | None = None) -> Provider:
|
def get_using_provider(self, umo: str | None = None) -> Provider:
|
||||||
"""获取当前使用的用于文本生成任务的 LLM Provider(Chat_Completion 类型)。
|
"""获取当前使用的用于文本生成任务的 LLM Provider(Chat_Completion 类型)。通过 /provider 指令切换。
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
umo: unified_message_origin 值,如果传入并且用户启用了提供商会话隔离,
|
umo(str): unified_message_origin 值,如果传入并且用户启用了提供商会话隔离,则使用该会话偏好的提供商。
|
||||||
则使用该会话偏好的提供商。
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
当前使用的文本生成提供者。
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
ValueError: 返回的提供者不是 Provider 类型。
|
|
||||||
|
|
||||||
Note:
|
|
||||||
通过 /provider 指令可以切换提供者。
|
|
||||||
"""
|
"""
|
||||||
prov = self.provider_manager.get_using_provider(
|
prov = self.provider_manager.get_using_provider(
|
||||||
provider_type=ProviderType.CHAT_COMPLETION,
|
provider_type=ProviderType.CHAT_COMPLETION,
|
||||||
@@ -356,13 +321,8 @@ class Context:
|
|||||||
"""获取当前使用的用于 TTS 任务的 Provider。
|
"""获取当前使用的用于 TTS 任务的 Provider。
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
umo: unified_message_origin 值,如果传入,则使用该会话偏好的提供商。
|
umo(str): unified_message_origin 值,如果传入,则使用该会话偏好的提供商。
|
||||||
|
|
||||||
Returns:
|
|
||||||
当前使用的 TTS 提供者,如果未设置则返回 None。
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
ValueError: 返回的提供者不是 TTSProvider 类型。
|
|
||||||
"""
|
"""
|
||||||
prov = self.provider_manager.get_using_provider(
|
prov = self.provider_manager.get_using_provider(
|
||||||
provider_type=ProviderType.TEXT_TO_SPEECH,
|
provider_type=ProviderType.TEXT_TO_SPEECH,
|
||||||
@@ -376,13 +336,8 @@ class Context:
|
|||||||
"""获取当前使用的用于 STT 任务的 Provider。
|
"""获取当前使用的用于 STT 任务的 Provider。
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
umo: unified_message_origin 值,如果传入,则使用该会话偏好的提供商。
|
umo(str): unified_message_origin 值,如果传入,则使用该会话偏好的提供商。
|
||||||
|
|
||||||
Returns:
|
|
||||||
当前使用的 STT 提供者,如果未设置则返回 None。
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
ValueError: 返回的提供者不是 STTProvider 类型。
|
|
||||||
"""
|
"""
|
||||||
prov = self.provider_manager.get_using_provider(
|
prov = self.provider_manager.get_using_provider(
|
||||||
provider_type=ProviderType.SPEECH_TO_TEXT,
|
provider_type=ProviderType.SPEECH_TO_TEXT,
|
||||||
@@ -393,19 +348,9 @@ class Context:
|
|||||||
return prov
|
return prov
|
||||||
|
|
||||||
def get_config(self, umo: str | None = None) -> AstrBotConfig:
|
def get_config(self, umo: str | None = None) -> AstrBotConfig:
|
||||||
"""获取 AstrBot 的配置。
|
"""获取 AstrBot 的配置。"""
|
||||||
|
|
||||||
Args:
|
|
||||||
umo: unified_message_origin 值,用于获取特定会话的配置。
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
AstrBot 配置对象。
|
|
||||||
|
|
||||||
Note:
|
|
||||||
如果不提供 umo 参数,将返回默认配置。
|
|
||||||
"""
|
|
||||||
if not umo:
|
if not umo:
|
||||||
# 使用默认配置
|
# using default config
|
||||||
return self._config
|
return self._config
|
||||||
return self.astrbot_config_mgr.get_conf(umo)
|
return self.astrbot_config_mgr.get_conf(umo)
|
||||||
|
|
||||||
@@ -416,19 +361,14 @@ class Context:
|
|||||||
) -> bool:
|
) -> bool:
|
||||||
"""根据 session(unified_msg_origin) 主动发送消息。
|
"""根据 session(unified_msg_origin) 主动发送消息。
|
||||||
|
|
||||||
Args:
|
@param session: 消息会话。通过 event.session 或者 event.unified_msg_origin 获取。
|
||||||
session: 消息会话。通过 event.session 或者 event.unified_msg_origin 获取。
|
@param message_chain: 消息链。
|
||||||
message_chain: 消息链。
|
|
||||||
|
|
||||||
Returns:
|
@return: 是否找到匹配的平台。
|
||||||
是否找到匹配的平台。
|
|
||||||
|
|
||||||
Raises:
|
当 session 为字符串时,会尝试解析为 MessageSesion 对象,如果解析失败,会抛出 ValueError 异常。
|
||||||
ValueError: session 字符串不合法时抛出。
|
|
||||||
|
|
||||||
Note:
|
NOTE: qq_official(QQ 官方 API 平台) 不支持此方法
|
||||||
当 session 为字符串时,会尝试解析为 MessageSession 对象。(类名为MessageSesion是因为历史遗留拼写错误)
|
|
||||||
qq_official(QQ 官方 API 平台) 不支持此方法。
|
|
||||||
"""
|
"""
|
||||||
if isinstance(session, str):
|
if isinstance(session, str):
|
||||||
try:
|
try:
|
||||||
@@ -443,14 +383,7 @@ class Context:
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
def add_llm_tools(self, *tools: FunctionTool) -> None:
|
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}
|
tool_name = {tool.name for tool in self.provider_manager.llm_tools.func_list}
|
||||||
module_path = ""
|
module_path = ""
|
||||||
for tool in tools:
|
for tool in tools:
|
||||||
@@ -483,17 +416,6 @@ class Context:
|
|||||||
methods: list,
|
methods: list,
|
||||||
desc: str,
|
desc: str,
|
||||||
):
|
):
|
||||||
"""注册 Web API。
|
|
||||||
|
|
||||||
Args:
|
|
||||||
route: API 路由路径。
|
|
||||||
view_handler: 异步视图处理函数。
|
|
||||||
methods: HTTP 方法列表。
|
|
||||||
desc: API 描述。
|
|
||||||
|
|
||||||
Note:
|
|
||||||
如果相同路由和方法已注册,会替换现有的 API。
|
|
||||||
"""
|
|
||||||
for idx, api in enumerate(self.registered_web_apis):
|
for idx, api in enumerate(self.registered_web_apis):
|
||||||
if api[0] == route and methods == api[2]:
|
if api[0] == route and methods == api[2]:
|
||||||
self.registered_web_apis[idx] = (route, view_handler, methods, desc)
|
self.registered_web_apis[idx] = (route, view_handler, methods, desc)
|
||||||
@@ -512,14 +434,7 @@ class Context:
|
|||||||
def get_platform(self, platform_type: PlatformAdapterType | str) -> Platform | None:
|
def get_platform(self, platform_type: PlatformAdapterType | str) -> Platform | None:
|
||||||
"""获取指定类型的平台适配器。
|
"""获取指定类型的平台适配器。
|
||||||
|
|
||||||
Args:
|
该方法已经过时,请使用 get_platform_inst 方法。(>= AstrBot v4.0.0)
|
||||||
platform_type: 平台类型或平台名称。
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
平台适配器实例,如果未找到则返回 None。
|
|
||||||
|
|
||||||
Note:
|
|
||||||
该方法已经过时,请使用 get_platform_inst 方法。(>= AstrBot v4.0.0)
|
|
||||||
"""
|
"""
|
||||||
for platform in self.platform_manager.platform_insts:
|
for platform in self.platform_manager.platform_insts:
|
||||||
name = platform.meta().name
|
name = platform.meta().name
|
||||||
@@ -536,32 +451,22 @@ class Context:
|
|||||||
"""获取指定 ID 的平台适配器实例。
|
"""获取指定 ID 的平台适配器实例。
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
platform_id: 平台适配器的唯一标识符。
|
platform_id (str): 平台适配器的唯一标识符。你可以通过 event.get_platform_id() 获取。
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
平台适配器实例,如果未找到则返回 None。
|
Platform: 平台适配器实例,如果未找到则返回 None。
|
||||||
|
|
||||||
Note:
|
|
||||||
可以通过 event.get_platform_id() 获取平台 ID。
|
|
||||||
"""
|
"""
|
||||||
for platform in self.platform_manager.platform_insts:
|
for platform in self.platform_manager.platform_insts:
|
||||||
if platform.meta().id == platform_id:
|
if platform.meta().id == platform_id:
|
||||||
return platform
|
return platform
|
||||||
|
|
||||||
def get_db(self) -> BaseDatabase:
|
def get_db(self) -> BaseDatabase:
|
||||||
"""获取 AstrBot 数据库。
|
"""获取 AstrBot 数据库。"""
|
||||||
|
|
||||||
Returns:
|
|
||||||
数据库实例。
|
|
||||||
"""
|
|
||||||
return self._db
|
return self._db
|
||||||
|
|
||||||
def register_provider(self, provider: Provider):
|
def register_provider(self, provider: Provider):
|
||||||
"""注册一个 LLM Provider(Chat_Completion 类型)。
|
"""注册一个 LLM Provider(Chat_Completion 类型)。"""
|
||||||
|
|
||||||
Args:
|
|
||||||
provider: 提供者实例。
|
|
||||||
"""
|
|
||||||
self.provider_manager.provider_insts.append(provider)
|
self.provider_manager.provider_insts.append(provider)
|
||||||
|
|
||||||
def register_llm_tool(
|
def register_llm_tool(
|
||||||
@@ -573,16 +478,12 @@ class Context:
|
|||||||
) -> None:
|
) -> None:
|
||||||
"""[DEPRECATED]为函数调用(function-calling / tools-use)添加工具。
|
"""[DEPRECATED]为函数调用(function-calling / tools-use)添加工具。
|
||||||
|
|
||||||
Args:
|
@param name: 函数名
|
||||||
name: 函数名。
|
@param func_args: 函数参数列表,格式为 [{"type": "string", "name": "arg_name", "description": "arg_description"}, ...]
|
||||||
func_args: 函数参数列表,格式为
|
@param desc: 函数描述
|
||||||
[{"type": "string", "name": "arg_name", "description": "arg_description"}, ...]。
|
@param func_obj: 异步处理函数。
|
||||||
desc: 函数描述。
|
|
||||||
func_obj: 异步处理函数。
|
|
||||||
|
|
||||||
Note:
|
异步处理函数会接收到额外的的关键词参数:event: AstrMessageEvent, context: Context。
|
||||||
异步处理函数会接收到额外的关键词参数:event: AstrMessageEvent, context: Context。
|
|
||||||
该方法已弃用,请使用新的注册方式。
|
|
||||||
"""
|
"""
|
||||||
md = StarHandlerMetadata(
|
md = StarHandlerMetadata(
|
||||||
event_type=EventType.OnLLMRequestEvent,
|
event_type=EventType.OnLLMRequestEvent,
|
||||||
@@ -597,15 +498,7 @@ class Context:
|
|||||||
self.provider_manager.llm_tools.add_func(name, func_args, desc, func_obj)
|
self.provider_manager.llm_tools.add_func(name, func_args, desc, func_obj)
|
||||||
|
|
||||||
def unregister_llm_tool(self, name: str) -> None:
|
def unregister_llm_tool(self, name: str) -> None:
|
||||||
"""[DEPRECATED]删除一个函数调用工具。
|
"""[DEPRECATED]删除一个函数调用工具。如果再要启用,需要重新注册。"""
|
||||||
|
|
||||||
Args:
|
|
||||||
name: 工具名称。
|
|
||||||
|
|
||||||
Note:
|
|
||||||
如果再要启用,需要重新注册。
|
|
||||||
该方法已弃用。
|
|
||||||
"""
|
|
||||||
self.provider_manager.llm_tools.remove_func(name)
|
self.provider_manager.llm_tools.remove_func(name)
|
||||||
|
|
||||||
def register_commands(
|
def register_commands(
|
||||||
@@ -618,19 +511,16 @@ class Context:
|
|||||||
use_regex=False,
|
use_regex=False,
|
||||||
ignore_prefix=False,
|
ignore_prefix=False,
|
||||||
):
|
):
|
||||||
"""[DEPRECATED]注册一个命令。
|
"""注册一个命令。
|
||||||
|
|
||||||
Args:
|
[Deprecated] 推荐使用装饰器注册指令。该方法将在未来的版本中被移除。
|
||||||
star_name: 插件(Star)名称。
|
|
||||||
command_name: 命令名称。
|
@param star_name: 插件(Star)名称。
|
||||||
desc: 命令描述。
|
@param command_name: 命令名称。
|
||||||
priority: 优先级。1-10。
|
@param desc: 命令描述。
|
||||||
awaitable: 异步处理函数。
|
@param priority: 优先级。1-10。
|
||||||
use_regex: 是否使用正则表达式匹配命令。
|
@param awaitable: 异步处理函数。
|
||||||
ignore_prefix: 是否忽略命令前缀。
|
|
||||||
|
|
||||||
Note:
|
|
||||||
推荐使用装饰器注册指令。该方法将在未来的版本中被移除。
|
|
||||||
"""
|
"""
|
||||||
md = StarHandlerMetadata(
|
md = StarHandlerMetadata(
|
||||||
event_type=EventType.AdapterMessageEvent,
|
event_type=EventType.AdapterMessageEvent,
|
||||||
@@ -650,13 +540,5 @@ class Context:
|
|||||||
star_handlers_registry.append(md)
|
star_handlers_registry.append(md)
|
||||||
|
|
||||||
def register_task(self, task: Awaitable, desc: str):
|
def register_task(self, task: Awaitable, desc: str):
|
||||||
"""[DEPRECATED]注册一个异步任务。
|
"""[DEPRECATED]注册一个异步任务。"""
|
||||||
|
|
||||||
Args:
|
|
||||||
task: 异步任务。
|
|
||||||
desc: 任务描述。
|
|
||||||
|
|
||||||
Note:
|
|
||||||
该方法已弃用。
|
|
||||||
"""
|
|
||||||
self._register_tasks.append(task)
|
self._register_tasks.append(task)
|
||||||
|
|||||||
@@ -296,8 +296,6 @@ class ChatRoute(Route):
|
|||||||
# 构建用户消息段(包含 path 用于传递给 adapter)
|
# 构建用户消息段(包含 path 用于传递给 adapter)
|
||||||
message_parts = await self._build_user_message_parts(message)
|
message_parts = await self._build_user_message_parts(message)
|
||||||
|
|
||||||
message_id = str(uuid.uuid4())
|
|
||||||
|
|
||||||
async def stream():
|
async def stream():
|
||||||
client_disconnected = False
|
client_disconnected = False
|
||||||
accumulated_parts = []
|
accumulated_parts = []
|
||||||
@@ -321,13 +319,6 @@ class ChatRoute(Route):
|
|||||||
if not result:
|
if not result:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
if (
|
|
||||||
"message_id" in result
|
|
||||||
and result["message_id"] != message_id
|
|
||||||
):
|
|
||||||
logger.warning("webchat stream message_id mismatch")
|
|
||||||
continue
|
|
||||||
|
|
||||||
result_text = result["data"]
|
result_text = result["data"]
|
||||||
msg_type = result.get("type")
|
msg_type = result.get("type")
|
||||||
streaming = result.get("streaming", False)
|
streaming = result.get("streaming", False)
|
||||||
@@ -465,7 +456,6 @@ class ChatRoute(Route):
|
|||||||
"selected_provider": selected_provider,
|
"selected_provider": selected_provider,
|
||||||
"selected_model": selected_model,
|
"selected_model": selected_model,
|
||||||
"enable_streaming": enable_streaming,
|
"enable_streaming": enable_streaming,
|
||||||
"message_id": message_id,
|
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -35,14 +35,6 @@ class SessionManagementRoute(Route):
|
|||||||
"/session/delete-rule": ("POST", self.delete_session_rule),
|
"/session/delete-rule": ("POST", self.delete_session_rule),
|
||||||
"/session/batch-delete-rule": ("POST", self.batch_delete_session_rule),
|
"/session/batch-delete-rule": ("POST", self.batch_delete_session_rule),
|
||||||
"/session/active-umos": ("GET", self.list_umos),
|
"/session/active-umos": ("GET", self.list_umos),
|
||||||
"/session/list-all-with-status": ("GET", self.list_all_umos_with_status),
|
|
||||||
"/session/batch-update-service": ("POST", self.batch_update_service),
|
|
||||||
"/session/batch-update-provider": ("POST", self.batch_update_provider),
|
|
||||||
# 分组管理 API
|
|
||||||
"/session/groups": ("GET", self.list_groups),
|
|
||||||
"/session/group/create": ("POST", self.create_group),
|
|
||||||
"/session/group/update": ("POST", self.update_group),
|
|
||||||
"/session/group/delete": ("POST", self.delete_group),
|
|
||||||
}
|
}
|
||||||
self.conv_mgr = core_lifecycle.conversation_manager
|
self.conv_mgr = core_lifecycle.conversation_manager
|
||||||
self.core_lifecycle = core_lifecycle
|
self.core_lifecycle = core_lifecycle
|
||||||
@@ -399,540 +391,3 @@ class SessionManagementRoute(Route):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"获取 UMO 列表失败: {e!s}")
|
logger.error(f"获取 UMO 列表失败: {e!s}")
|
||||||
return Response().error(f"获取 UMO 列表失败: {e!s}").__dict__
|
return Response().error(f"获取 UMO 列表失败: {e!s}").__dict__
|
||||||
|
|
||||||
async def list_all_umos_with_status(self):
|
|
||||||
"""获取所有有对话记录的 UMO 及其服务状态(支持分页、搜索、筛选)
|
|
||||||
|
|
||||||
Query 参数:
|
|
||||||
page: 页码,默认为 1
|
|
||||||
page_size: 每页数量,默认为 20
|
|
||||||
search: 搜索关键词
|
|
||||||
message_type: 筛选消息类型 (group/private/all)
|
|
||||||
platform: 筛选平台
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
page = request.args.get("page", 1, type=int)
|
|
||||||
page_size = request.args.get("page_size", 20, type=int)
|
|
||||||
search = request.args.get("search", "", type=str).strip()
|
|
||||||
message_type = request.args.get("message_type", "all", type=str)
|
|
||||||
platform = request.args.get("platform", "", type=str)
|
|
||||||
|
|
||||||
if page < 1:
|
|
||||||
page = 1
|
|
||||||
if page_size < 1:
|
|
||||||
page_size = 20
|
|
||||||
if page_size > 100:
|
|
||||||
page_size = 100
|
|
||||||
|
|
||||||
# 从 Conversation 表获取所有 distinct user_id (即 umo)
|
|
||||||
async with self.db_helper.get_db() as session:
|
|
||||||
session: AsyncSession
|
|
||||||
result = await session.execute(
|
|
||||||
select(ConversationV2.user_id)
|
|
||||||
.distinct()
|
|
||||||
.order_by(ConversationV2.user_id)
|
|
||||||
)
|
|
||||||
all_umos = [row[0] for row in result.fetchall()]
|
|
||||||
|
|
||||||
# 获取所有 umo 的规则配置
|
|
||||||
umo_rules, _ = await self._get_umo_rules(page=1, page_size=99999, search="")
|
|
||||||
|
|
||||||
# 构建带状态的 umo 列表
|
|
||||||
umos_with_status = []
|
|
||||||
for umo in all_umos:
|
|
||||||
parts = umo.split(":")
|
|
||||||
umo_platform = parts[0] if len(parts) >= 1 else "unknown"
|
|
||||||
umo_message_type = parts[1] if len(parts) >= 2 else "unknown"
|
|
||||||
umo_session_id = parts[2] if len(parts) >= 3 else umo
|
|
||||||
|
|
||||||
# 筛选消息类型
|
|
||||||
if message_type != "all":
|
|
||||||
if message_type == "group" and umo_message_type not in [
|
|
||||||
"group",
|
|
||||||
"GroupMessage",
|
|
||||||
]:
|
|
||||||
continue
|
|
||||||
if message_type == "private" and umo_message_type not in [
|
|
||||||
"private",
|
|
||||||
"FriendMessage",
|
|
||||||
"friend",
|
|
||||||
]:
|
|
||||||
continue
|
|
||||||
|
|
||||||
# 筛选平台
|
|
||||||
if platform and umo_platform != platform:
|
|
||||||
continue
|
|
||||||
|
|
||||||
# 获取服务配置
|
|
||||||
rules = umo_rules.get(umo, {})
|
|
||||||
svc_config = rules.get("session_service_config", {})
|
|
||||||
|
|
||||||
custom_name = svc_config.get("custom_name", "") if svc_config else ""
|
|
||||||
session_enabled = (
|
|
||||||
svc_config.get("session_enabled", True) if svc_config else True
|
|
||||||
)
|
|
||||||
llm_enabled = (
|
|
||||||
svc_config.get("llm_enabled", True) if svc_config else True
|
|
||||||
)
|
|
||||||
tts_enabled = (
|
|
||||||
svc_config.get("tts_enabled", True) if svc_config else True
|
|
||||||
)
|
|
||||||
|
|
||||||
# 搜索过滤
|
|
||||||
if search:
|
|
||||||
search_lower = search.lower()
|
|
||||||
if (
|
|
||||||
search_lower not in umo.lower()
|
|
||||||
and search_lower not in custom_name.lower()
|
|
||||||
):
|
|
||||||
continue
|
|
||||||
|
|
||||||
# 获取 provider 配置
|
|
||||||
chat_provider_key = (
|
|
||||||
f"provider_perf_{ProviderType.CHAT_COMPLETION.value}"
|
|
||||||
)
|
|
||||||
tts_provider_key = f"provider_perf_{ProviderType.TEXT_TO_SPEECH.value}"
|
|
||||||
stt_provider_key = f"provider_perf_{ProviderType.SPEECH_TO_TEXT.value}"
|
|
||||||
|
|
||||||
umos_with_status.append(
|
|
||||||
{
|
|
||||||
"umo": umo,
|
|
||||||
"platform": umo_platform,
|
|
||||||
"message_type": umo_message_type,
|
|
||||||
"session_id": umo_session_id,
|
|
||||||
"custom_name": custom_name,
|
|
||||||
"session_enabled": session_enabled,
|
|
||||||
"llm_enabled": llm_enabled,
|
|
||||||
"tts_enabled": tts_enabled,
|
|
||||||
"has_rules": umo in umo_rules,
|
|
||||||
"chat_provider": rules.get(chat_provider_key),
|
|
||||||
"tts_provider": rules.get(tts_provider_key),
|
|
||||||
"stt_provider": rules.get(stt_provider_key),
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
# 分页
|
|
||||||
total = len(umos_with_status)
|
|
||||||
start_idx = (page - 1) * page_size
|
|
||||||
end_idx = start_idx + page_size
|
|
||||||
paginated = umos_with_status[start_idx:end_idx]
|
|
||||||
|
|
||||||
# 获取可用的平台列表
|
|
||||||
platforms = list({u["platform"] for u in umos_with_status})
|
|
||||||
|
|
||||||
# 获取可用的 providers
|
|
||||||
provider_manager = self.core_lifecycle.provider_manager
|
|
||||||
available_chat_providers = [
|
|
||||||
{"id": p.meta().id, "name": p.meta().id, "model": p.meta().model}
|
|
||||||
for p in provider_manager.provider_insts
|
|
||||||
]
|
|
||||||
available_tts_providers = [
|
|
||||||
{"id": p.meta().id, "name": p.meta().id, "model": p.meta().model}
|
|
||||||
for p in provider_manager.tts_provider_insts
|
|
||||||
]
|
|
||||||
available_stt_providers = [
|
|
||||||
{"id": p.meta().id, "name": p.meta().id, "model": p.meta().model}
|
|
||||||
for p in provider_manager.stt_provider_insts
|
|
||||||
]
|
|
||||||
|
|
||||||
return (
|
|
||||||
Response()
|
|
||||||
.ok(
|
|
||||||
{
|
|
||||||
"sessions": paginated,
|
|
||||||
"total": total,
|
|
||||||
"page": page,
|
|
||||||
"page_size": page_size,
|
|
||||||
"platforms": platforms,
|
|
||||||
"available_chat_providers": available_chat_providers,
|
|
||||||
"available_tts_providers": available_tts_providers,
|
|
||||||
"available_stt_providers": available_stt_providers,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
.__dict__
|
|
||||||
)
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"获取会话状态列表失败: {e!s}")
|
|
||||||
return Response().error(f"获取会话状态列表失败: {e!s}").__dict__
|
|
||||||
|
|
||||||
async def batch_update_service(self):
|
|
||||||
"""批量更新多个 UMO 的服务状态 (LLM/TTS/Session)
|
|
||||||
|
|
||||||
请求体:
|
|
||||||
{
|
|
||||||
"umos": ["平台:消息类型:会话ID", ...], // 可选,如果不传则根据 scope 筛选
|
|
||||||
"scope": "all" | "group" | "private" | "custom_group", // 可选,批量范围
|
|
||||||
"group_id": "分组ID", // 当 scope 为 custom_group 时必填
|
|
||||||
"llm_enabled": true/false/null, // 可选,null表示不修改
|
|
||||||
"tts_enabled": true/false/null, // 可选
|
|
||||||
"session_enabled": true/false/null // 可选
|
|
||||||
}
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
data = await request.get_json()
|
|
||||||
umos = data.get("umos", [])
|
|
||||||
scope = data.get("scope", "")
|
|
||||||
group_id = data.get("group_id", "")
|
|
||||||
llm_enabled = data.get("llm_enabled")
|
|
||||||
tts_enabled = data.get("tts_enabled")
|
|
||||||
session_enabled = data.get("session_enabled")
|
|
||||||
|
|
||||||
# 如果没有任何修改
|
|
||||||
if llm_enabled is None and tts_enabled is None and session_enabled is None:
|
|
||||||
return Response().error("至少需要指定一个要修改的状态").__dict__
|
|
||||||
|
|
||||||
# 如果指定了 scope,获取符合条件的所有 umo
|
|
||||||
if scope and not umos:
|
|
||||||
# 如果是自定义分组
|
|
||||||
if scope == "custom_group":
|
|
||||||
if not group_id:
|
|
||||||
return Response().error("请指定分组 ID").__dict__
|
|
||||||
groups = self._get_groups()
|
|
||||||
if group_id not in groups:
|
|
||||||
return Response().error(f"分组 '{group_id}' 不存在").__dict__
|
|
||||||
umos = groups[group_id].get("umos", [])
|
|
||||||
else:
|
|
||||||
async with self.db_helper.get_db() as session:
|
|
||||||
session: AsyncSession
|
|
||||||
result = await session.execute(
|
|
||||||
select(ConversationV2.user_id).distinct()
|
|
||||||
)
|
|
||||||
all_umos = [row[0] for row in result.fetchall()]
|
|
||||||
|
|
||||||
if scope == "group":
|
|
||||||
umos = [
|
|
||||||
u
|
|
||||||
for u in all_umos
|
|
||||||
if ":group:" in u.lower() or ":groupmessage:" in u.lower()
|
|
||||||
]
|
|
||||||
elif scope == "private":
|
|
||||||
umos = [
|
|
||||||
u
|
|
||||||
for u in all_umos
|
|
||||||
if ":private:" in u.lower() or ":friend" in u.lower()
|
|
||||||
]
|
|
||||||
elif scope == "all":
|
|
||||||
umos = all_umos
|
|
||||||
|
|
||||||
if not umos:
|
|
||||||
return Response().error("没有找到符合条件的会话").__dict__
|
|
||||||
|
|
||||||
# 批量更新
|
|
||||||
success_count = 0
|
|
||||||
failed_umos = []
|
|
||||||
|
|
||||||
for umo in umos:
|
|
||||||
try:
|
|
||||||
# 获取现有配置
|
|
||||||
session_config = (
|
|
||||||
sp.get("session_service_config", {}, scope="umo", scope_id=umo)
|
|
||||||
or {}
|
|
||||||
)
|
|
||||||
|
|
||||||
# 更新状态
|
|
||||||
if llm_enabled is not None:
|
|
||||||
session_config["llm_enabled"] = llm_enabled
|
|
||||||
if tts_enabled is not None:
|
|
||||||
session_config["tts_enabled"] = tts_enabled
|
|
||||||
if session_enabled is not None:
|
|
||||||
session_config["session_enabled"] = session_enabled
|
|
||||||
|
|
||||||
# 保存
|
|
||||||
sp.put(
|
|
||||||
"session_service_config",
|
|
||||||
session_config,
|
|
||||||
scope="umo",
|
|
||||||
scope_id=umo,
|
|
||||||
)
|
|
||||||
success_count += 1
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"更新 {umo} 服务状态失败: {e!s}")
|
|
||||||
failed_umos.append(umo)
|
|
||||||
|
|
||||||
status_changes = []
|
|
||||||
if llm_enabled is not None:
|
|
||||||
status_changes.append(f"LLM={'启用' if llm_enabled else '禁用'}")
|
|
||||||
if tts_enabled is not None:
|
|
||||||
status_changes.append(f"TTS={'启用' if tts_enabled else '禁用'}")
|
|
||||||
if session_enabled is not None:
|
|
||||||
status_changes.append(f"会话={'启用' if session_enabled else '禁用'}")
|
|
||||||
|
|
||||||
return (
|
|
||||||
Response()
|
|
||||||
.ok(
|
|
||||||
{
|
|
||||||
"message": f"已更新 {success_count} 个会话 ({', '.join(status_changes)})",
|
|
||||||
"success_count": success_count,
|
|
||||||
"failed_count": len(failed_umos),
|
|
||||||
"failed_umos": failed_umos,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
.__dict__
|
|
||||||
)
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"批量更新服务状态失败: {e!s}")
|
|
||||||
return Response().error(f"批量更新服务状态失败: {e!s}").__dict__
|
|
||||||
|
|
||||||
async def batch_update_provider(self):
|
|
||||||
"""批量更新多个 UMO 的 Provider 配置
|
|
||||||
|
|
||||||
请求体:
|
|
||||||
{
|
|
||||||
"umos": ["平台:消息类型:会话ID", ...], // 可选
|
|
||||||
"scope": "all" | "group" | "private", // 可选
|
|
||||||
"provider_type": "chat_completion" | "text_to_speech" | "speech_to_text",
|
|
||||||
"provider_id": "provider_id"
|
|
||||||
}
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
data = await request.get_json()
|
|
||||||
umos = data.get("umos", [])
|
|
||||||
scope = data.get("scope", "")
|
|
||||||
provider_type = data.get("provider_type")
|
|
||||||
provider_id = data.get("provider_id")
|
|
||||||
|
|
||||||
if not provider_type or not provider_id:
|
|
||||||
return (
|
|
||||||
Response()
|
|
||||||
.error("缺少必要参数: provider_type, provider_id")
|
|
||||||
.__dict__
|
|
||||||
)
|
|
||||||
|
|
||||||
# 转换 provider_type
|
|
||||||
provider_type_map = {
|
|
||||||
"chat_completion": ProviderType.CHAT_COMPLETION,
|
|
||||||
"text_to_speech": ProviderType.TEXT_TO_SPEECH,
|
|
||||||
"speech_to_text": ProviderType.SPEECH_TO_TEXT,
|
|
||||||
}
|
|
||||||
if provider_type not in provider_type_map:
|
|
||||||
return (
|
|
||||||
Response()
|
|
||||||
.error(f"不支持的 provider_type: {provider_type}")
|
|
||||||
.__dict__
|
|
||||||
)
|
|
||||||
|
|
||||||
provider_type_enum = provider_type_map[provider_type]
|
|
||||||
|
|
||||||
# 如果指定了 scope,获取符合条件的所有 umo
|
|
||||||
group_id = data.get("group_id", "")
|
|
||||||
if scope and not umos:
|
|
||||||
# 如果是自定义分组
|
|
||||||
if scope == "custom_group":
|
|
||||||
if not group_id:
|
|
||||||
return Response().error("请指定分组 ID").__dict__
|
|
||||||
groups = self._get_groups()
|
|
||||||
if group_id not in groups:
|
|
||||||
return Response().error(f"分组 '{group_id}' 不存在").__dict__
|
|
||||||
umos = groups[group_id].get("umos", [])
|
|
||||||
else:
|
|
||||||
async with self.db_helper.get_db() as session:
|
|
||||||
session: AsyncSession
|
|
||||||
result = await session.execute(
|
|
||||||
select(ConversationV2.user_id).distinct()
|
|
||||||
)
|
|
||||||
all_umos = [row[0] for row in result.fetchall()]
|
|
||||||
|
|
||||||
if scope == "group":
|
|
||||||
umos = [
|
|
||||||
u
|
|
||||||
for u in all_umos
|
|
||||||
if ":group:" in u.lower() or ":groupmessage:" in u.lower()
|
|
||||||
]
|
|
||||||
elif scope == "private":
|
|
||||||
umos = [
|
|
||||||
u
|
|
||||||
for u in all_umos
|
|
||||||
if ":private:" in u.lower() or ":friend" in u.lower()
|
|
||||||
]
|
|
||||||
elif scope == "all":
|
|
||||||
umos = all_umos
|
|
||||||
|
|
||||||
if not umos:
|
|
||||||
return Response().error("没有找到符合条件的会话").__dict__
|
|
||||||
|
|
||||||
# 批量更新
|
|
||||||
success_count = 0
|
|
||||||
failed_umos = []
|
|
||||||
provider_manager = self.core_lifecycle.provider_manager
|
|
||||||
|
|
||||||
for umo in umos:
|
|
||||||
try:
|
|
||||||
await provider_manager.set_provider(
|
|
||||||
provider_id=provider_id,
|
|
||||||
provider_type=provider_type_enum,
|
|
||||||
umo=umo,
|
|
||||||
)
|
|
||||||
success_count += 1
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"更新 {umo} Provider 失败: {e!s}")
|
|
||||||
failed_umos.append(umo)
|
|
||||||
|
|
||||||
return (
|
|
||||||
Response()
|
|
||||||
.ok(
|
|
||||||
{
|
|
||||||
"message": f"已更新 {success_count} 个会话的 {provider_type} 为 {provider_id}",
|
|
||||||
"success_count": success_count,
|
|
||||||
"failed_count": len(failed_umos),
|
|
||||||
"failed_umos": failed_umos,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
.__dict__
|
|
||||||
)
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"批量更新 Provider 失败: {e!s}")
|
|
||||||
return Response().error(f"批量更新 Provider 失败: {e!s}").__dict__
|
|
||||||
|
|
||||||
# ==================== 分组管理 API ====================
|
|
||||||
|
|
||||||
def _get_groups(self) -> dict:
|
|
||||||
"""获取所有分组"""
|
|
||||||
return sp.get("session_groups", {})
|
|
||||||
|
|
||||||
def _save_groups(self, groups: dict) -> None:
|
|
||||||
"""保存分组"""
|
|
||||||
sp.put("session_groups", groups)
|
|
||||||
|
|
||||||
async def list_groups(self):
|
|
||||||
"""获取所有分组列表"""
|
|
||||||
try:
|
|
||||||
groups = self._get_groups()
|
|
||||||
# 转换为列表格式,方便前端使用
|
|
||||||
groups_list = []
|
|
||||||
for group_id, group_data in groups.items():
|
|
||||||
groups_list.append(
|
|
||||||
{
|
|
||||||
"id": group_id,
|
|
||||||
"name": group_data.get("name", ""),
|
|
||||||
"umos": group_data.get("umos", []),
|
|
||||||
"umo_count": len(group_data.get("umos", [])),
|
|
||||||
}
|
|
||||||
)
|
|
||||||
return Response().ok({"groups": groups_list}).__dict__
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"获取分组列表失败: {e!s}")
|
|
||||||
return Response().error(f"获取分组列表失败: {e!s}").__dict__
|
|
||||||
|
|
||||||
async def create_group(self):
|
|
||||||
"""创建新分组"""
|
|
||||||
try:
|
|
||||||
data = await request.json
|
|
||||||
name = data.get("name", "").strip()
|
|
||||||
umos = data.get("umos", [])
|
|
||||||
|
|
||||||
if not name:
|
|
||||||
return Response().error("分组名称不能为空").__dict__
|
|
||||||
|
|
||||||
groups = self._get_groups()
|
|
||||||
|
|
||||||
# 生成唯一 ID
|
|
||||||
import uuid
|
|
||||||
|
|
||||||
group_id = str(uuid.uuid4())[:8]
|
|
||||||
|
|
||||||
groups[group_id] = {
|
|
||||||
"name": name,
|
|
||||||
"umos": umos,
|
|
||||||
}
|
|
||||||
|
|
||||||
self._save_groups(groups)
|
|
||||||
|
|
||||||
return (
|
|
||||||
Response()
|
|
||||||
.ok(
|
|
||||||
{
|
|
||||||
"message": f"分组 '{name}' 创建成功",
|
|
||||||
"group": {
|
|
||||||
"id": group_id,
|
|
||||||
"name": name,
|
|
||||||
"umos": umos,
|
|
||||||
"umo_count": len(umos),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
)
|
|
||||||
.__dict__
|
|
||||||
)
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"创建分组失败: {e!s}")
|
|
||||||
return Response().error(f"创建分组失败: {e!s}").__dict__
|
|
||||||
|
|
||||||
async def update_group(self):
|
|
||||||
"""更新分组(改名、增删成员)"""
|
|
||||||
try:
|
|
||||||
data = await request.json
|
|
||||||
group_id = data.get("id")
|
|
||||||
name = data.get("name")
|
|
||||||
umos = data.get("umos")
|
|
||||||
add_umos = data.get("add_umos", [])
|
|
||||||
remove_umos = data.get("remove_umos", [])
|
|
||||||
|
|
||||||
if not group_id:
|
|
||||||
return Response().error("分组 ID 不能为空").__dict__
|
|
||||||
|
|
||||||
groups = self._get_groups()
|
|
||||||
|
|
||||||
if group_id not in groups:
|
|
||||||
return Response().error(f"分组 '{group_id}' 不存在").__dict__
|
|
||||||
|
|
||||||
group = groups[group_id]
|
|
||||||
|
|
||||||
# 更新名称
|
|
||||||
if name is not None:
|
|
||||||
group["name"] = name.strip()
|
|
||||||
|
|
||||||
# 直接设置 umos 列表
|
|
||||||
if umos is not None:
|
|
||||||
group["umos"] = umos
|
|
||||||
else:
|
|
||||||
# 增量更新
|
|
||||||
current_umos = set(group.get("umos", []))
|
|
||||||
if add_umos:
|
|
||||||
current_umos.update(add_umos)
|
|
||||||
if remove_umos:
|
|
||||||
current_umos.difference_update(remove_umos)
|
|
||||||
group["umos"] = list(current_umos)
|
|
||||||
|
|
||||||
self._save_groups(groups)
|
|
||||||
|
|
||||||
return (
|
|
||||||
Response()
|
|
||||||
.ok(
|
|
||||||
{
|
|
||||||
"message": f"分组 '{group['name']}' 更新成功",
|
|
||||||
"group": {
|
|
||||||
"id": group_id,
|
|
||||||
"name": group["name"],
|
|
||||||
"umos": group["umos"],
|
|
||||||
"umo_count": len(group["umos"]),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
)
|
|
||||||
.__dict__
|
|
||||||
)
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"更新分组失败: {e!s}")
|
|
||||||
return Response().error(f"更新分组失败: {e!s}").__dict__
|
|
||||||
|
|
||||||
async def delete_group(self):
|
|
||||||
"""删除分组"""
|
|
||||||
try:
|
|
||||||
data = await request.json
|
|
||||||
group_id = data.get("id")
|
|
||||||
|
|
||||||
if not group_id:
|
|
||||||
return Response().error("分组 ID 不能为空").__dict__
|
|
||||||
|
|
||||||
groups = self._get_groups()
|
|
||||||
|
|
||||||
if group_id not in groups:
|
|
||||||
return Response().error(f"分组 '{group_id}' 不存在").__dict__
|
|
||||||
|
|
||||||
group_name = groups[group_id].get("name", group_id)
|
|
||||||
del groups[group_id]
|
|
||||||
|
|
||||||
self._save_groups(groups)
|
|
||||||
|
|
||||||
return Response().ok({"message": f"分组 '{group_name}' 已删除"}).__dict__
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"删除分组失败: {e!s}")
|
|
||||||
return Response().error(f"删除分组失败: {e!s}").__dict__
|
|
||||||
|
|||||||
@@ -1,19 +0,0 @@
|
|||||||
## What's Changed
|
|
||||||
|
|
||||||
### 新增
|
|
||||||
|
|
||||||
- AstrBot 代理沙箱环境(改进的代码解释器) ([#4449](https://github.com/AstrBotDevs/AstrBot/issues/4449)),详见[文档](https://docs.astrbot.app/use/astrbot-agent-sandbox.html)
|
|
||||||
- ChatUI 支持项目管理 ([#4477](https://github.com/AstrBotDevs/AstrBot/issues/4477))
|
|
||||||
- 自定义规则支持批量处理。
|
|
||||||
|
|
||||||
### 修复
|
|
||||||
|
|
||||||
- 发送 OpenAI 风格的 image_url 导致 Anthropic 返回 400 无效标签错误 ([#4444](https://github.com/AstrBotDevs/AstrBot/issues/4444))
|
|
||||||
- ChatUI 标题显示问题 ([#4486](https://github.com/AstrBotDevs/AstrBot/issues/4486))
|
|
||||||
- 确保 ChatUI 消息流顺序正确 ([#4487](https://github.com/AstrBotDevs/AstrBot/issues/4487))
|
|
||||||
- 从 Telegram 和 Discord 平台命令注册中排除已禁用的命令 ([#4485](https://github.com/AstrBotDevs/AstrBot/issues/4485))
|
|
||||||
|
|
||||||
### 优化
|
|
||||||
|
|
||||||
- 优化工具调用相关的提示词
|
|
||||||
- 标准化 Context 类文档格式 ([#4436](https://github.com/AstrBotDevs/AstrBot/issues/4436))
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
## What's Changed
|
|
||||||
|
|
||||||
hotfix of v4.12.0
|
|
||||||
|
|
||||||
fix: 修复会话隔离功能失效的问题。
|
|
||||||
|
|
||||||
### 新增
|
|
||||||
|
|
||||||
- AstrBot 代理沙箱环境(改进的代码解释器) ([#4449](https://github.com/AstrBotDevs/AstrBot/issues/4449)),详见[文档](https://docs.astrbot.app/use/astrbot-agent-sandbox.html)
|
|
||||||
- ChatUI 支持项目管理 ([#4477](https://github.com/AstrBotDevs/AstrBot/issues/4477))
|
|
||||||
- 自定义规则支持批量处理。
|
|
||||||
|
|
||||||
### 修复
|
|
||||||
|
|
||||||
- 发送 OpenAI 风格的 image_url 导致 Anthropic 返回 400 无效标签错误 ([#4444](https://github.com/AstrBotDevs/AstrBot/issues/4444))
|
|
||||||
- ChatUI 标题显示问题 ([#4486](https://github.com/AstrBotDevs/AstrBot/issues/4486))
|
|
||||||
- 确保 ChatUI 消息流顺序正确 ([#4487](https://github.com/AstrBotDevs/AstrBot/issues/4487))
|
|
||||||
- 从 Telegram 和 Discord 平台命令注册中排除已禁用的命令 ([#4485](https://github.com/AstrBotDevs/AstrBot/issues/4485))
|
|
||||||
|
|
||||||
### 优化
|
|
||||||
|
|
||||||
- 优化工具调用相关的提示词
|
|
||||||
- 标准化 Context 类文档格式 ([#4436](https://github.com/AstrBotDevs/AstrBot/issues/4436))
|
|
||||||
@@ -1,47 +0,0 @@
|
|||||||
version: '3.8'
|
|
||||||
|
|
||||||
# 当接入 QQ NapCat 时,请使用这个 compose 文件一键部署: https://github.com/NapNeko/NapCat-Docker/blob/main/compose/astrbot.yml
|
|
||||||
|
|
||||||
services:
|
|
||||||
astrbot:
|
|
||||||
image: soulter/astrbot:latest
|
|
||||||
container_name: astrbot
|
|
||||||
restart: always
|
|
||||||
ports: # mappings description: https://github.com/AstrBotDevs/AstrBot/issues/497
|
|
||||||
- "6185:6185" # 必选,AstrBot WebUI 端口
|
|
||||||
- "6199:6199" # 可选, QQ 个人号 WebSocket 端口
|
|
||||||
environment:
|
|
||||||
- TZ=Asia/Shanghai
|
|
||||||
volumes:
|
|
||||||
- ${PWD}/data:/AstrBot/data
|
|
||||||
# - /etc/timezone:/etc/timezone:ro
|
|
||||||
- /etc/localtime:/etc/localtime:ro
|
|
||||||
networks:
|
|
||||||
- astrbot_network
|
|
||||||
|
|
||||||
shipyard:
|
|
||||||
image: soulter/shipyard-bay:latest
|
|
||||||
container_name: astrbot_shipyard
|
|
||||||
# ports:
|
|
||||||
# - "8156:8156"
|
|
||||||
environment:
|
|
||||||
- PORT=8156
|
|
||||||
- DATABASE_URL=sqlite+aiosqlite:///./data/bay.db
|
|
||||||
- ACCESS_TOKEN=secret-token
|
|
||||||
- MAX_SHIP_NUM=10
|
|
||||||
- BEHAVIOR_AFTER_MAX_SHIP=reject
|
|
||||||
- DOCKER_IMAGE=soulter/shipyard-ship:latest
|
|
||||||
- DOCKER_NETWORK=astrbot_network
|
|
||||||
- SHIP_DATA_DIR=${PWD}/data/shipyard/ship_mnt_data
|
|
||||||
- DEFAULT_SHIP_CPUS=1.0
|
|
||||||
- DEFAULT_SHIP_MEMORY=512m
|
|
||||||
volumes:
|
|
||||||
- ${PWD}/data/shipyard/bay_data:/app/data
|
|
||||||
- /var/run/docker.sock:/var/run/docker.sock:ro
|
|
||||||
networks:
|
|
||||||
- astrbot_network
|
|
||||||
|
|
||||||
networks:
|
|
||||||
astrbot_network:
|
|
||||||
name: astrbot_network
|
|
||||||
driver: bridge
|
|
||||||
@@ -28,14 +28,14 @@
|
|||||||
"katex": "^0.16.27",
|
"katex": "^0.16.27",
|
||||||
"lodash": "4.17.21",
|
"lodash": "4.17.21",
|
||||||
"markdown-it": "^14.1.0",
|
"markdown-it": "^14.1.0",
|
||||||
"markstream-vue": "^0.0.6-beta.1",
|
"markstream-vue": "0.0.3-beta.7",
|
||||||
"mermaid": "^11.12.2",
|
"mermaid": "^11.12.2",
|
||||||
"pinia": "2.1.6",
|
"pinia": "2.1.6",
|
||||||
"pinyin-pro": "^3.26.0",
|
"pinyin-pro": "^3.26.0",
|
||||||
"remixicon": "3.5.0",
|
"remixicon": "3.5.0",
|
||||||
"shiki": "^3.20.0",
|
"shiki": "^3.20.0",
|
||||||
"stream-markdown": "^0.0.13",
|
"stream-markdown": "^0.0.11",
|
||||||
"stream-monaco": "^0.0.15",
|
"stream-monaco": "^0.0.8",
|
||||||
"vee-validate": "4.11.3",
|
"vee-validate": "4.11.3",
|
||||||
"vite-plugin-vuetify": "1.0.2",
|
"vite-plugin-vuetify": "1.0.2",
|
||||||
"vue": "3.3.4",
|
"vue": "3.3.4",
|
||||||
|
|||||||
@@ -26,10 +26,11 @@
|
|||||||
@createProject="showCreateProjectDialog"
|
@createProject="showCreateProjectDialog"
|
||||||
@editProject="showEditProjectDialog"
|
@editProject="showEditProjectDialog"
|
||||||
@deleteProject="handleDeleteProject"
|
@deleteProject="handleDeleteProject"
|
||||||
|
@openMultiChatMode="openMultiChatDialog"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<!-- 右侧聊天内容区域 -->
|
<!-- 右侧聊天内容区域 -->
|
||||||
<div class="chat-content-panel">
|
<div class="chat-content-panel" v-if="!isMultiChatMode">
|
||||||
|
|
||||||
<div class="conversation-header fade-in" v-if="isMobile">
|
<div class="conversation-header fade-in" v-if="isMobile">
|
||||||
<!-- 手机端菜单按钮 -->
|
<!-- 手机端菜单按钮 -->
|
||||||
@@ -146,6 +147,27 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- 多对话模式视图 -->
|
||||||
|
<MultiChatView
|
||||||
|
v-if="isMultiChatMode"
|
||||||
|
:sessionIds="multiChatSessionIds"
|
||||||
|
:sessions="sessions"
|
||||||
|
:isDark="isDark"
|
||||||
|
:isStreaming="isStreaming"
|
||||||
|
:isConvRunning="isConvRunning"
|
||||||
|
:enableStreaming="enableStreaming"
|
||||||
|
:isRecording="isRecording"
|
||||||
|
:getSessionMessages="getMessagesForMultiChat"
|
||||||
|
@exitMultiMode="exitMultiChatMode"
|
||||||
|
@openImagePreview="openImagePreview"
|
||||||
|
@sendMessage="handleMultiChatSendMessage"
|
||||||
|
@toggleStreaming="toggleStreaming"
|
||||||
|
@startRecording="handleStartRecording"
|
||||||
|
@stopRecording="handleStopRecording"
|
||||||
|
@pasteImage="(sessionId, event) => handlePaste(event)"
|
||||||
|
@fileSelect="(sessionId, files) => handleFileSelect(files)"
|
||||||
|
/>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</v-card-text>
|
</v-card-text>
|
||||||
</v-card>
|
</v-card>
|
||||||
@@ -155,12 +177,12 @@
|
|||||||
<v-card-title class="dialog-title">{{ tm('actions.editTitle') }}</v-card-title>
|
<v-card-title class="dialog-title">{{ tm('actions.editTitle') }}</v-card-title>
|
||||||
<v-card-text>
|
<v-card-text>
|
||||||
<v-text-field v-model="editingTitle" :label="tm('conversation.newConversation')" variant="outlined"
|
<v-text-field v-model="editingTitle" :label="tm('conversation.newConversation')" variant="outlined"
|
||||||
hide-details class="mt-2" @keyup.enter="handleSaveTitle" autofocus />
|
hide-details class="mt-2" @keyup.enter="saveTitle" autofocus />
|
||||||
</v-card-text>
|
</v-card-text>
|
||||||
<v-card-actions>
|
<v-card-actions>
|
||||||
<v-spacer></v-spacer>
|
<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="editTitleDialog = false" color="grey-darken-1">{{ t('core.common.cancel') }}</v-btn>
|
||||||
<v-btn variant="text" @click="handleSaveTitle" color="primary">{{ t('core.common.save') }}</v-btn>
|
<v-btn variant="text" @click="saveTitle" color="primary">{{ t('core.common.save') }}</v-btn>
|
||||||
</v-card-actions>
|
</v-card-actions>
|
||||||
</v-card>
|
</v-card>
|
||||||
</v-dialog>
|
</v-dialog>
|
||||||
@@ -184,11 +206,19 @@
|
|||||||
:project="editingProject"
|
:project="editingProject"
|
||||||
@save="handleSaveProject"
|
@save="handleSaveProject"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<!-- 多对话模式选择对话框 -->
|
||||||
|
<SessionSelectDialog
|
||||||
|
v-model="multiChatDialog"
|
||||||
|
:sessions="sessions"
|
||||||
|
@confirm="enterMultiChatMode"
|
||||||
|
/>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, watch, onMounted, onBeforeUnmount, nextTick } from 'vue';
|
import { ref, computed, watch, onMounted, onBeforeUnmount, nextTick } from 'vue';
|
||||||
import { useRouter, useRoute } from 'vue-router';
|
import { useRouter, useRoute } from 'vue-router';
|
||||||
|
import axios from 'axios';
|
||||||
import { useCustomizerStore } from '@/stores/customizer';
|
import { useCustomizerStore } from '@/stores/customizer';
|
||||||
import { useI18n, useModuleI18n } from '@/i18n/composables';
|
import { useI18n, useModuleI18n } from '@/i18n/composables';
|
||||||
import { useTheme } from 'vuetify';
|
import { useTheme } from 'vuetify';
|
||||||
@@ -198,6 +228,8 @@ import ChatInput from '@/components/chat/ChatInput.vue';
|
|||||||
import ProjectDialog from '@/components/chat/ProjectDialog.vue';
|
import ProjectDialog from '@/components/chat/ProjectDialog.vue';
|
||||||
import ProjectView from '@/components/chat/ProjectView.vue';
|
import ProjectView from '@/components/chat/ProjectView.vue';
|
||||||
import WelcomeView from '@/components/chat/WelcomeView.vue';
|
import WelcomeView from '@/components/chat/WelcomeView.vue';
|
||||||
|
import SessionSelectDialog from '@/components/chat/SessionSelectDialog.vue';
|
||||||
|
import MultiChatView from '@/components/chat/MultiChatView.vue';
|
||||||
import type { ProjectFormData } from '@/components/chat/ProjectDialog.vue';
|
import type { ProjectFormData } from '@/components/chat/ProjectDialog.vue';
|
||||||
import { useSessions } from '@/composables/useSessions';
|
import { useSessions } from '@/composables/useSessions';
|
||||||
import { useMessages } from '@/composables/useMessages';
|
import { useMessages } from '@/composables/useMessages';
|
||||||
@@ -301,6 +333,11 @@ const currentProject = computed(() =>
|
|||||||
projects.value.find(p => p.project_id === selectedProjectId.value)
|
projects.value.find(p => p.project_id === selectedProjectId.value)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// 多对话模式状态
|
||||||
|
const multiChatDialog = ref(false);
|
||||||
|
const isMultiChatMode = ref(false);
|
||||||
|
const multiChatSessionIds = ref<string[]>([]);
|
||||||
|
|
||||||
// 引用消息状态
|
// 引用消息状态
|
||||||
interface ReplyInfo {
|
interface ReplyInfo {
|
||||||
messageId: number; // PlatformSessionHistoryMessage 的 id
|
messageId: number; // PlatformSessionHistoryMessage 的 id
|
||||||
@@ -346,16 +383,6 @@ function openImagePreview(imageUrl: string) {
|
|||||||
imagePreviewDialog.value = true;
|
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) {
|
function handleReplyMessage(msg: any, index: number) {
|
||||||
// 从消息中获取 id (PlatformSessionHistoryMessage 的 id)
|
// 从消息中获取 id (PlatformSessionHistoryMessage 的 id)
|
||||||
const messageId = msg.id;
|
const messageId = msg.id;
|
||||||
@@ -457,12 +484,6 @@ function handleNewChat() {
|
|||||||
async function handleDeleteConversation(sessionId: string) {
|
async function handleDeleteConversation(sessionId: string) {
|
||||||
await deleteSessionFn(sessionId);
|
await deleteSessionFn(sessionId);
|
||||||
messages.value = [];
|
messages.value = [];
|
||||||
|
|
||||||
// 如果在项目视图中,刷新项目会话列表
|
|
||||||
if (selectedProjectId.value) {
|
|
||||||
const sessions = await getProjectSessions(selectedProjectId.value);
|
|
||||||
projectSessions.value = sessions;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleSelectProject(projectId: string) {
|
async function handleSelectProject(projectId: string) {
|
||||||
@@ -512,6 +533,90 @@ async function handleDeleteProject(projectId: string) {
|
|||||||
await deleteProject(projectId);
|
await deleteProject(projectId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 多对话模式相关函数
|
||||||
|
function openMultiChatDialog() {
|
||||||
|
multiChatDialog.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function enterMultiChatMode(sessionIds: string[]) {
|
||||||
|
if (sessionIds.length < 2) return;
|
||||||
|
|
||||||
|
multiChatSessionIds.value = sessionIds;
|
||||||
|
isMultiChatMode.value = true;
|
||||||
|
|
||||||
|
// 手机端关闭侧边栏
|
||||||
|
if (isMobile.value) {
|
||||||
|
closeMobileSidebar();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function exitMultiChatMode() {
|
||||||
|
isMultiChatMode.value = false;
|
||||||
|
multiChatSessionIds.value = [];
|
||||||
|
|
||||||
|
// 恢复到第一个会话
|
||||||
|
if (sessions.value.length > 0) {
|
||||||
|
handleSelectConversation([sessions.value[0].session_id]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getMessagesForMultiChat(sessionId: string): Promise<any[]> {
|
||||||
|
try {
|
||||||
|
const response = await axios.get('/api/chat/get_session?session_id=' + sessionId);
|
||||||
|
let history = response.data.data.history || [];
|
||||||
|
|
||||||
|
// 处理历史消息(解析附件等)
|
||||||
|
for (let i = 0; i < history.length; i++) {
|
||||||
|
let content = history[i].content;
|
||||||
|
// 这里可以调用 parseMessageContent 如果需要
|
||||||
|
// 但为了简化,我们直接返回原始数据
|
||||||
|
}
|
||||||
|
|
||||||
|
return history;
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`获取会话 ${sessionId} 消息失败:`, error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleMultiChatSendMessage(sessionId: string, data: any) {
|
||||||
|
// 保存原始状态
|
||||||
|
const previousSessionId = currSessionId.value;
|
||||||
|
const previousPrompt = prompt.value;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 临时切换到目标会话
|
||||||
|
currSessionId.value = sessionId;
|
||||||
|
prompt.value = data.prompt;
|
||||||
|
|
||||||
|
// 获取选择的提供商和模型
|
||||||
|
const selection = chatInputRef.value?.getCurrentSelection();
|
||||||
|
const selectedProviderId = selection?.providerId || '';
|
||||||
|
const selectedModelName = selection?.modelName || '';
|
||||||
|
|
||||||
|
// 发送消息
|
||||||
|
await sendMsg(
|
||||||
|
data.prompt,
|
||||||
|
data.stagedFiles || [],
|
||||||
|
data.stagedAudios || '',
|
||||||
|
selectedProviderId,
|
||||||
|
selectedModelName,
|
||||||
|
data.replyTo || null
|
||||||
|
);
|
||||||
|
|
||||||
|
// 发送成功后,触发该会话消息列表刷新
|
||||||
|
// MultiChatView 会监听 sessions 的变化或者我们可以手动触发重新加载
|
||||||
|
// 由于 useMessages 已经处理了消息的更新,我们只需要等待下一个 tick
|
||||||
|
await nextTick();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('多对话模式发送消息失败:', error);
|
||||||
|
} finally {
|
||||||
|
// 恢复原始状态
|
||||||
|
currSessionId.value = previousSessionId;
|
||||||
|
prompt.value = previousPrompt;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function handleStartRecording() {
|
async function handleStartRecording() {
|
||||||
await startRec();
|
await startRec();
|
||||||
}
|
}
|
||||||
@@ -539,16 +644,8 @@ async function handleSendMessage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const isCreatingNewSession = !currSessionId.value;
|
const isCreatingNewSession = !currSessionId.value;
|
||||||
const currentProjectId = selectedProjectId.value; // 保存当前项目ID
|
|
||||||
|
|
||||||
if (isCreatingNewSession) {
|
if (isCreatingNewSession) {
|
||||||
await newSession();
|
await newSession();
|
||||||
|
|
||||||
// 如果在项目视图中创建新会话,立即退出项目视图
|
|
||||||
if (currentProjectId) {
|
|
||||||
selectedProjectId.value = null;
|
|
||||||
projectSessions.value = [];
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const promptToSend = prompt.value.trim();
|
const promptToSend = prompt.value.trim();
|
||||||
@@ -580,13 +677,12 @@ async function handleSendMessage() {
|
|||||||
replyToSend
|
replyToSend
|
||||||
);
|
);
|
||||||
|
|
||||||
// 如果在项目中创建了新会话,将其添加到项目
|
// 如果在项目视图中创建了新会话,自动添加到当前项目
|
||||||
if (isCreatingNewSession && currentProjectId && currSessionId.value) {
|
if (isCreatingNewSession && selectedProjectId.value && currSessionId.value) {
|
||||||
await addSessionToProject(currSessionId.value, currentProjectId);
|
await addSessionToProject(currSessionId.value, selectedProjectId.value);
|
||||||
// 刷新会话列表,移除已添加到项目的会话
|
// 刷新项目会话列表
|
||||||
await getSessions();
|
const sessions = await getProjectSessions(selectedProjectId.value);
|
||||||
// 重新获取会话消息以更新项目信息(用于面包屑显示)
|
projectSessions.value = sessions;
|
||||||
await getSessionMsg(currSessionId.value);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -27,6 +27,14 @@
|
|||||||
v-if="sidebarCollapsed && !isMobile" elevation="0"></v-btn>
|
v-if="sidebarCollapsed && !isMobile" elevation="0"></v-btn>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- 多对话模式入口 -->
|
||||||
|
<div style="padding: 0 8px 8px 8px; opacity: 0.6;">
|
||||||
|
<v-btn block variant="text" class="new-chat-btn" @click="$emit('openMultiChatMode')"
|
||||||
|
v-if="!sidebarCollapsed || isMobile" prepend-icon="mdi-view-carousel">{{ tm('multiChat.multiMode') }}</v-btn>
|
||||||
|
<v-btn icon="mdi-view-carousel" rounded="xl" @click="$emit('openMultiChatMode')"
|
||||||
|
v-if="sidebarCollapsed && !isMobile" elevation="0"></v-btn>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- 项目列表组件 -->
|
<!-- 项目列表组件 -->
|
||||||
<ProjectList
|
<ProjectList
|
||||||
v-if="!sidebarCollapsed || isMobile"
|
v-if="!sidebarCollapsed || isMobile"
|
||||||
@@ -178,6 +186,7 @@ const emit = defineEmits<{
|
|||||||
createProject: [];
|
createProject: [];
|
||||||
editProject: [project: Project];
|
editProject: [project: Project];
|
||||||
deleteProject: [projectId: string];
|
deleteProject: [projectId: string];
|
||||||
|
openMultiChatMode: [];
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
|
|||||||
@@ -28,7 +28,7 @@
|
|||||||
<div v-else-if="part.type === 'image' && part.embedded_url" class="image-attachments">
|
<div v-else-if="part.type === 'image' && part.embedded_url" class="image-attachments">
|
||||||
<div class="image-attachment">
|
<div class="image-attachment">
|
||||||
<img :src="part.embedded_url" class="attached-image"
|
<img :src="part.embedded_url" class="attached-image"
|
||||||
@click="openImagePreview(part.embedded_url)" />
|
@click="$emit('openImagePreview', part.embedded_url)" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -90,28 +90,83 @@
|
|||||||
|
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<!-- Reasoning Block (Collapsible) - 放在最前面 -->
|
<!-- Reasoning Block (Collapsible) - 放在最前面 -->
|
||||||
<ReasoningBlock v-if="msg.content.reasoning && msg.content.reasoning.trim()"
|
<div v-if="msg.content.reasoning && msg.content.reasoning.trim()"
|
||||||
:reasoning="msg.content.reasoning" :is-dark="isDark"
|
class="reasoning-container" :class="{ 'is-dark': isDark }"
|
||||||
:initial-expanded="isReasoningExpanded(index)" />
|
:style="isDark ? { backgroundColor: 'rgba(103, 58, 183, 0.08)' } : {}">
|
||||||
|
<div class="reasoning-header" :class="{ 'is-dark': isDark }"
|
||||||
|
@click="toggleReasoning(index)">
|
||||||
|
<v-icon size="small" class="reasoning-icon">
|
||||||
|
{{ isReasoningExpanded(index) ? 'mdi-chevron-down' : 'mdi-chevron-right' }}
|
||||||
|
</v-icon>
|
||||||
|
<span class="reasoning-label">{{ tm('reasoning.thinking') }}</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="isReasoningExpanded(index)" class="reasoning-content">
|
||||||
|
<MarkdownRender :content="msg.content.reasoning"
|
||||||
|
class="reasoning-text markdown-content" :typewriter="false"
|
||||||
|
:style="isDark ? { opacity: '0.85' } : {}" :is-dark="isDark" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- 遍历 message parts (保持顺序) -->
|
<!-- 遍历 message parts (保持顺序) -->
|
||||||
<template v-for="(part, partIndex) in msg.content.message" :key="partIndex">
|
<template v-for="(part, partIndex) in msg.content.message" :key="partIndex">
|
||||||
<!-- iPython Tool Special Block -->
|
<!-- Tool Calls Block -->
|
||||||
<template v-if="part.type === 'tool_call' && part.tool_calls && part.tool_calls.length > 0">
|
<div v-if="part.type === 'tool_call' && part.tool_calls && part.tool_calls.length > 0"
|
||||||
<template v-for="(toolCall, tcIndex) in part.tool_calls" :key="toolCall.id">
|
class="tool-calls-container">
|
||||||
<IPythonToolBlock v-if="isIPythonTool(toolCall)" :tool-call="toolCall" style="margin: 8px 0;"
|
<div class="tool-calls-label">{{ tm('actions.toolsUsed') }}</div>
|
||||||
:is-dark="isDark"
|
<div v-for="(toolCall, tcIndex) in part.tool_calls" :key="toolCall.id"
|
||||||
:initial-expanded="isIPythonToolExpanded(index, partIndex, tcIndex)" />
|
class="tool-call-card" :class="{ 'is-dark': isDark, 'expanded': isToolCallExpanded(index, partIndex, tcIndex) }" :style="isDark ? {
|
||||||
</template>
|
backgroundColor: 'rgba(40, 60, 100, 0.4)',
|
||||||
</template>
|
borderColor: 'rgba(100, 140, 200, 0.4)'
|
||||||
|
} : {}">
|
||||||
<!-- Regular Tool Calls Block (for non-iPython tools) -->
|
<div class="tool-call-header" :class="{ 'is-dark': isDark }"
|
||||||
<div v-if="part.type === 'tool_call' && part.tool_calls && part.tool_calls.some(tc => !isIPythonTool(tc))"
|
@click="toggleToolCall(index, partIndex, tcIndex)">
|
||||||
class="flex flex-col gap-2">
|
<v-icon size="small" class="tool-call-expand-icon">
|
||||||
<div class="font-medium opacity-70" style="font-size: 13px; margin-bottom: 16px;">{{ tm('actions.toolsUsed') }}</div>
|
{{ isToolCallExpanded(index, partIndex, tcIndex) ?
|
||||||
<ToolCallCard v-for="(toolCall, tcIndex) in part.tool_calls.filter(tc => !isIPythonTool(tc))"
|
'mdi-chevron-down' : 'mdi-chevron-right' }}
|
||||||
:key="toolCall.id" :tool-call="toolCall" :is-dark="isDark"
|
</v-icon>
|
||||||
:initial-expanded="isToolCallExpanded(index, partIndex, tcIndex)" />
|
<v-icon size="small" class="tool-call-icon">mdi-wrench-outline</v-icon>
|
||||||
|
<div class="tool-call-info">
|
||||||
|
<span class="tool-call-name">{{ toolCall.name }}</span>
|
||||||
|
</div>
|
||||||
|
<span class="tool-call-status"
|
||||||
|
:class="{ 'status-running': !toolCall.finished_ts, 'status-finished': toolCall.finished_ts }">
|
||||||
|
<template v-if="toolCall.finished_ts">
|
||||||
|
<v-icon size="x-small"
|
||||||
|
class="status-icon">mdi-check-circle</v-icon>
|
||||||
|
{{ formatDuration(toolCall.finished_ts - toolCall.ts) }}
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<v-icon size="x-small"
|
||||||
|
class="status-icon spinning">mdi-loading</v-icon>
|
||||||
|
{{ getElapsedTime(toolCall.ts) }}
|
||||||
|
</template>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="isToolCallExpanded(index, partIndex, tcIndex)"
|
||||||
|
class="tool-call-details" :style="isDark ? {
|
||||||
|
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) }}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Text (Markdown) -->
|
<!-- Text (Markdown) -->
|
||||||
@@ -123,7 +178,7 @@
|
|||||||
<div v-else-if="part.type === 'image' && part.embedded_url" class="embedded-images">
|
<div v-else-if="part.type === 'image' && part.embedded_url" class="embedded-images">
|
||||||
<div class="embedded-image">
|
<div class="embedded-image">
|
||||||
<img :src="part.embedded_url" class="bot-embedded-image"
|
<img :src="part.embedded_url" class="bot-embedded-image"
|
||||||
@click="openImagePreview(part.embedded_url)" />
|
@click="$emit('openImagePreview', part.embedded_url)" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -234,13 +289,6 @@
|
|||||||
</v-btn>
|
</v-btn>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 图片预览 Overlay -->
|
|
||||||
<v-overlay v-model="imagePreview.show" class="image-preview-overlay" @click="closeImagePreview">
|
|
||||||
<div class="image-preview-container" @click.stop>
|
|
||||||
<img :src="imagePreview.url" class="preview-image" @click="closeImagePreview" />
|
|
||||||
</div>
|
|
||||||
</v-overlay>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
@@ -250,9 +298,6 @@ import 'markstream-vue/index.css'
|
|||||||
import 'katex/dist/katex.min.css'
|
import 'katex/dist/katex.min.css'
|
||||||
import 'highlight.js/styles/github.css';
|
import 'highlight.js/styles/github.css';
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import ReasoningBlock from './message_list_comps/ReasoningBlock.vue';
|
|
||||||
import IPythonToolBlock from './message_list_comps/IPythonToolBlock.vue';
|
|
||||||
import ToolCallCard from './message_list_comps/ToolCallCard.vue';
|
|
||||||
|
|
||||||
enableKatex();
|
enableKatex();
|
||||||
enableMermaid();
|
enableMermaid();
|
||||||
@@ -260,10 +305,7 @@ enableMermaid();
|
|||||||
export default {
|
export default {
|
||||||
name: 'MessageList',
|
name: 'MessageList',
|
||||||
components: {
|
components: {
|
||||||
MarkdownRender,
|
MarkdownRender
|
||||||
ReasoningBlock,
|
|
||||||
IPythonToolBlock,
|
|
||||||
ToolCallCard
|
|
||||||
},
|
},
|
||||||
props: {
|
props: {
|
||||||
messages: {
|
messages: {
|
||||||
@@ -302,7 +344,6 @@ export default {
|
|||||||
expandedReasoning: new Set(), // Track which reasoning blocks are expanded
|
expandedReasoning: new Set(), // Track which reasoning blocks are expanded
|
||||||
downloadingFiles: new Set(), // Track which files are being downloaded
|
downloadingFiles: new Set(), // Track which files are being downloaded
|
||||||
expandedToolCalls: new Set(), // Track which tool call cards are expanded
|
expandedToolCalls: new Set(), // Track which tool call cards are expanded
|
||||||
expandedIPythonTools: new Set(), // Track which iPython tools are expanded
|
|
||||||
elapsedTimeTimer: null, // Timer for updating elapsed time
|
elapsedTimeTimer: null, // Timer for updating elapsed time
|
||||||
currentTime: Date.now() / 1000, // Current time for elapsed time calculation
|
currentTime: Date.now() / 1000, // Current time for elapsed time calculation
|
||||||
// 选中文本相关状态
|
// 选中文本相关状态
|
||||||
@@ -310,15 +351,10 @@ export default {
|
|||||||
content: '',
|
content: '',
|
||||||
messageIndex: null,
|
messageIndex: null,
|
||||||
position: { top: 0, left: 0 }
|
position: { top: 0, left: 0 }
|
||||||
},
|
|
||||||
// 图片预览
|
|
||||||
imagePreview: {
|
|
||||||
show: false,
|
|
||||||
url: ''
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
async mounted() {
|
mounted() {
|
||||||
this.initCodeCopyButtons();
|
this.initCodeCopyButtons();
|
||||||
this.initImageClickEvents();
|
this.initImageClickEvents();
|
||||||
this.addScrollListener();
|
this.addScrollListener();
|
||||||
@@ -472,23 +508,6 @@ export default {
|
|||||||
return this.expandedReasoning.has(messageIndex);
|
return this.expandedReasoning.has(messageIndex);
|
||||||
},
|
},
|
||||||
|
|
||||||
// Toggle iPython tool expansion state
|
|
||||||
toggleIPythonTool(messageIndex, partIndex, toolCallIndex) {
|
|
||||||
const key = `${messageIndex}-${partIndex}-${toolCallIndex}`;
|
|
||||||
if (this.expandedIPythonTools.has(key)) {
|
|
||||||
this.expandedIPythonTools.delete(key);
|
|
||||||
} else {
|
|
||||||
this.expandedIPythonTools.add(key);
|
|
||||||
}
|
|
||||||
// Force reactivity
|
|
||||||
this.expandedIPythonTools = new Set(this.expandedIPythonTools);
|
|
||||||
},
|
|
||||||
|
|
||||||
// Check if iPython tool is expanded
|
|
||||||
isIPythonToolExpanded(messageIndex, partIndex, toolCallIndex) {
|
|
||||||
return this.expandedIPythonTools.has(`${messageIndex}-${partIndex}-${toolCallIndex}`);
|
|
||||||
},
|
|
||||||
|
|
||||||
// 下载文件
|
// 下载文件
|
||||||
async downloadFile(file) {
|
async downloadFile(file) {
|
||||||
if (!file.attachment_id) return;
|
if (!file.attachment_id) return;
|
||||||
@@ -657,7 +676,7 @@ export default {
|
|||||||
if (!img.hasAttribute('data-click-enabled')) {
|
if (!img.hasAttribute('data-click-enabled')) {
|
||||||
img.style.cursor = 'pointer';
|
img.style.cursor = 'pointer';
|
||||||
img.setAttribute('data-click-enabled', 'true');
|
img.setAttribute('data-click-enabled', 'true');
|
||||||
img.onclick = () => this.openImagePreview(img.src);
|
img.onclick = () => this.$emit('openImagePreview', img.src);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -858,25 +877,6 @@ export default {
|
|||||||
formatTTFT(ttft) {
|
formatTTFT(ttft) {
|
||||||
if (!ttft || ttft <= 0) return '';
|
if (!ttft || ttft <= 0) return '';
|
||||||
return this.formatDuration(ttft);
|
return this.formatDuration(ttft);
|
||||||
},
|
|
||||||
|
|
||||||
// 打开图片预览
|
|
||||||
openImagePreview(url) {
|
|
||||||
this.imagePreview.url = url;
|
|
||||||
this.imagePreview.show = true;
|
|
||||||
},
|
|
||||||
|
|
||||||
// 关闭图片预览
|
|
||||||
closeImagePreview() {
|
|
||||||
this.imagePreview.show = false;
|
|
||||||
setTimeout(() => {
|
|
||||||
this.imagePreview.url = '';
|
|
||||||
}, 300);
|
|
||||||
},
|
|
||||||
|
|
||||||
// Check if tool is iPython executor
|
|
||||||
isIPythonTool(toolCall) {
|
|
||||||
return toolCall.name === 'astrbot_execute_ipython';
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1268,10 +1268,10 @@ export default {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.bot-embedded-image {
|
.bot-embedded-image {
|
||||||
max-width: 55%;
|
max-width: 40%;
|
||||||
width: auto;
|
width: auto;
|
||||||
height: auto;
|
height: auto;
|
||||||
border-radius: 4px;
|
border-radius: 8px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: transform 0.2s ease;
|
transition: transform 0.2s ease;
|
||||||
}
|
}
|
||||||
@@ -1346,6 +1346,165 @@ export default {
|
|||||||
animation: fadeIn 0.3s ease-in-out;
|
animation: fadeIn 0.3s ease-in-out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Reasoning 区块样式 */
|
||||||
|
.reasoning-container {
|
||||||
|
margin-bottom: 12px;
|
||||||
|
margin-top: 6px;
|
||||||
|
border: 1px solid var(--v-theme-border);
|
||||||
|
border-radius: 20px;
|
||||||
|
overflow: hidden;
|
||||||
|
width: fit-content;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reasoning-header {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 8px 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
transition: background-color 0.2s ease;
|
||||||
|
border-radius: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reasoning-header:hover {
|
||||||
|
background-color: rgba(103, 58, 183, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.reasoning-header.is-dark:hover {
|
||||||
|
background-color: rgba(103, 58, 183, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.reasoning-icon {
|
||||||
|
margin-right: 6px;
|
||||||
|
color: var(--v-theme-secondary);
|
||||||
|
transition: transform 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reasoning-label {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--v-theme-secondary);
|
||||||
|
letter-spacing: 0.3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reasoning-content {
|
||||||
|
padding: 0px 12px;
|
||||||
|
border-top: 1px solid var(--v-theme-border);
|
||||||
|
color: gray;
|
||||||
|
animation: fadeIn 0.2s ease-in-out;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reasoning-text {
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.6;
|
||||||
|
color: var(--v-theme-secondaryText);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tool Call Card Styles */
|
||||||
|
.tool-calls-container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
margin-top: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-calls-label {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--v-theme-secondaryText);
|
||||||
|
opacity: 0.7;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-call-card {
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
background-color: #eff3f6;
|
||||||
|
margin: 8px 0px;
|
||||||
|
max-width: 300px;
|
||||||
|
transition: max-width 0.1s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-call-card.expanded {
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-call-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 10px 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
transition: background-color 0.2s ease;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-call-header:hover {
|
||||||
|
background-color: rgba(169, 194, 219, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-call-header.is-dark:hover {
|
||||||
|
background-color: rgba(100, 150, 200, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-call-expand-icon {
|
||||||
|
color: var(--v-theme-secondary);
|
||||||
|
transition: transform 0.2s ease;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-call-icon {
|
||||||
|
color: var(--v-theme-secondary);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-call-info {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-call-name {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--v-theme-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-call-id {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--v-theme-secondaryText);
|
||||||
|
opacity: 0.7;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-call-status {
|
||||||
|
margin-left: 8px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-call-status.status-running {
|
||||||
|
color: #ff9800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-call-status.status-finished {
|
||||||
|
color: #4caf50;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-call-status .status-icon {
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
/* 浮动引用按钮样式 */
|
/* 浮动引用按钮样式 */
|
||||||
.selection-quote-button {
|
.selection-quote-button {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
@@ -1356,6 +1515,7 @@ export default {
|
|||||||
pointer-events: all;
|
pointer-events: all;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
.quote-btn {
|
.quote-btn {
|
||||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
@@ -1375,8 +1535,66 @@ export default {
|
|||||||
color: #ffffff !important;
|
color: #ffffff !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.tool-call-status .status-icon.spinning {
|
||||||
|
animation: spin 1s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
from {
|
||||||
|
transform: rotate(0deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-call-details {
|
||||||
|
padding: 12px;
|
||||||
|
background-color: rgba(255, 255, 255, 0.5);
|
||||||
|
animation: fadeIn 0.2s ease-in-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-call-detail-row {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-call-detail-row:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-label {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--v-theme-secondaryText);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-value {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--v-theme-primaryText);
|
||||||
|
background-color: transparent;
|
||||||
|
padding: 4px 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-json {
|
||||||
|
font-family: 'Fira Code', 'Consolas', monospace;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
max-height: 200px;
|
||||||
|
overflow-y: auto;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-result {
|
||||||
|
max-height: 300px;
|
||||||
|
background-color: transparent;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
@@ -1417,36 +1635,4 @@ export default {
|
|||||||
font-family: 'Fira Code', 'Consolas', monospace;
|
font-family: 'Fira Code', 'Consolas', monospace;
|
||||||
color: var(--v-theme-primaryText);
|
color: var(--v-theme-primaryText);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 图片预览样式 */
|
|
||||||
.image-preview-overlay {
|
|
||||||
z-index: 9999;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.image-preview-container {
|
|
||||||
position: relative;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.preview-image {
|
|
||||||
max-width: 90vw;
|
|
||||||
max-height: 90vh;
|
|
||||||
object-fit: contain;
|
|
||||||
border-radius: 8px;
|
|
||||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.close-preview-btn {
|
|
||||||
position: fixed;
|
|
||||||
top: 20px;
|
|
||||||
right: 20px;
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -0,0 +1,421 @@
|
|||||||
|
<template>
|
||||||
|
<div class="multi-chat-view">
|
||||||
|
<div class="multi-chat-header">
|
||||||
|
<v-btn icon="mdi-close" variant="text" @click="exitMultiMode" />
|
||||||
|
<span class="multi-chat-title">{{ tm('multiChat.multiMode') }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="chat-container-wrapper"
|
||||||
|
ref="containerRef"
|
||||||
|
@scroll="handleScroll"
|
||||||
|
>
|
||||||
|
<div class="chat-panels-track">
|
||||||
|
<div
|
||||||
|
v-for="(sessionId, index) in sessionIds"
|
||||||
|
:key="sessionId"
|
||||||
|
class="chat-panel"
|
||||||
|
:style="{
|
||||||
|
zIndex: index + 1,
|
||||||
|
left: `${index * 16}px`
|
||||||
|
}"
|
||||||
|
:ref="el => { if (el) panelRefs[index] = el }"
|
||||||
|
>
|
||||||
|
<div class="chat-panel-inner" :class="{ 'panel-stacked': shouldShowShadow(index) }">
|
||||||
|
<div class="session-header">
|
||||||
|
<span class="session-title">
|
||||||
|
{{ getSessionTitle(sessionId) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="message-list-container">
|
||||||
|
<MessageList
|
||||||
|
:messages="sessionMessages[sessionId] || []"
|
||||||
|
:isDark="isDark"
|
||||||
|
:isStreaming="activeSessionId === sessionId && (isStreaming || isConvRunning)"
|
||||||
|
:isLoadingMessages="loadingSessionIds.has(sessionId)"
|
||||||
|
@openImagePreview="(url) => $emit('openImagePreview', url)"
|
||||||
|
@replyMessage="(msg, idx) => handleReplyMessage(sessionId, msg, idx)"
|
||||||
|
@replyWithText="(data) => handleReplyWithText(sessionId, data)"
|
||||||
|
:ref="el => { if (el) messageListRefs[index] = el }"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ChatInput
|
||||||
|
v-model:prompt="prompts[sessionId]"
|
||||||
|
:stagedImagesUrl="stagedImages[sessionId] || []"
|
||||||
|
:stagedAudioUrl="stagedAudios[sessionId] || ''"
|
||||||
|
:stagedFiles="stagedFiles[sessionId] || []"
|
||||||
|
:disabled="isStreaming && activeSessionId === sessionId"
|
||||||
|
:enableStreaming="enableStreaming"
|
||||||
|
:isRecording="isRecording && activeSessionId === sessionId"
|
||||||
|
:session-id="sessionId"
|
||||||
|
:current-session="getSession(sessionId)"
|
||||||
|
:replyTo="replyToMap[sessionId]"
|
||||||
|
@send="handleSendMessage(sessionId)"
|
||||||
|
@toggleStreaming="$emit('toggleStreaming')"
|
||||||
|
@removeImage="(idx) => removeImage(sessionId, idx)"
|
||||||
|
@removeAudio="removeAudio(sessionId)"
|
||||||
|
@removeFile="(idx) => removeFile(sessionId, idx)"
|
||||||
|
@startRecording="handleStartRecording(sessionId)"
|
||||||
|
@stopRecording="handleStopRecording(sessionId)"
|
||||||
|
@pasteImage="(file) => handlePasteImage(sessionId, file)"
|
||||||
|
@fileSelect="(files) => handleFileSelect(sessionId, files)"
|
||||||
|
@clearReply="clearReply(sessionId)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, reactive, computed, onMounted, onBeforeUnmount, nextTick } from 'vue';
|
||||||
|
import { useModuleI18n } from '@/i18n/composables';
|
||||||
|
import MessageList from '@/components/chat/MessageList.vue';
|
||||||
|
import ChatInput from '@/components/chat/ChatInput.vue';
|
||||||
|
import type { Session } from '@/composables/useSessions';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
sessionIds: string[];
|
||||||
|
sessions: Session[];
|
||||||
|
isDark: boolean;
|
||||||
|
isStreaming: boolean;
|
||||||
|
isConvRunning: boolean;
|
||||||
|
enableStreaming: boolean;
|
||||||
|
isRecording: boolean;
|
||||||
|
getSessionMessages?: (sessionId: string) => Promise<any[]>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
|
getSessionMessages: undefined
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
exitMultiMode: [];
|
||||||
|
openImagePreview: [url: string];
|
||||||
|
sendMessage: [sessionId: string, data: any];
|
||||||
|
toggleStreaming: [];
|
||||||
|
startRecording: [sessionId: string];
|
||||||
|
stopRecording: [sessionId: string];
|
||||||
|
pasteImage: [sessionId: string, event: ClipboardEvent];
|
||||||
|
fileSelect: [sessionId: string, files: FileList];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const { tm } = useModuleI18n('features/chat');
|
||||||
|
|
||||||
|
// 状态管理
|
||||||
|
const currentIndex = ref(0);
|
||||||
|
const scrollLeft = ref(0);
|
||||||
|
const containerRef = ref<HTMLElement | null>(null);
|
||||||
|
const panelRefs = reactive<any[]>([]);
|
||||||
|
const messageListRefs = reactive<any[]>([]);
|
||||||
|
|
||||||
|
// 每个会话的独立状态
|
||||||
|
const sessionMessages = reactive<Record<string, any[]>>({});
|
||||||
|
const prompts = reactive<Record<string, string>>({});
|
||||||
|
const stagedImages = reactive<Record<string, string[]>>({});
|
||||||
|
const stagedAudios = reactive<Record<string, string>>({});
|
||||||
|
const stagedFiles = reactive<Record<string, any[]>>({});
|
||||||
|
const replyToMap = reactive<Record<string, any>>({});
|
||||||
|
const loadingSessionIds = reactive(new Set<string>());
|
||||||
|
const activeSessionId = ref('');
|
||||||
|
|
||||||
|
// 计算属性 - 每个面板宽度为650px和视口宽度的最小值
|
||||||
|
const panelWidth = computed(() => {
|
||||||
|
if (!containerRef.value) {
|
||||||
|
return Math.min(650, window.innerWidth);
|
||||||
|
}
|
||||||
|
return Math.min(650, containerRef.value.offsetWidth);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 计算面板是否应该显示阴影
|
||||||
|
function shouldShowShadow(index: number): boolean {
|
||||||
|
if (index === 0) return false;
|
||||||
|
// 当面板已经开始固定时(滚动超过它的位置)
|
||||||
|
const threshold = (index - 0.98) * panelWidth.value;
|
||||||
|
return scrollLeft.value >= threshold;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 初始化所有会话的状态
|
||||||
|
onMounted(async () => {
|
||||||
|
props.sessionIds.forEach(sessionId => {
|
||||||
|
if (!prompts[sessionId]) prompts[sessionId] = '';
|
||||||
|
if (!stagedImages[sessionId]) stagedImages[sessionId] = [];
|
||||||
|
if (!stagedAudios[sessionId]) stagedAudios[sessionId] = '';
|
||||||
|
if (!stagedFiles[sessionId]) stagedFiles[sessionId] = [];
|
||||||
|
if (!sessionMessages[sessionId]) sessionMessages[sessionId] = [];
|
||||||
|
});
|
||||||
|
|
||||||
|
// 加载初始会话消息
|
||||||
|
if (props.sessionIds.length > 0) {
|
||||||
|
activeSessionId.value = props.sessionIds[0];
|
||||||
|
// 并行加载前两个会话的消息
|
||||||
|
const loadPromises = props.sessionIds.slice(0, 2).map(id => loadSessionMessages(id));
|
||||||
|
await Promise.all(loadPromises);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 辅助函数
|
||||||
|
let scrollTimeout: number | null = null;
|
||||||
|
|
||||||
|
// 滚动处理
|
||||||
|
function handleScroll() {
|
||||||
|
if (!containerRef.value) return;
|
||||||
|
|
||||||
|
// 实时更新滚动位置
|
||||||
|
scrollLeft.value = containerRef.value.scrollLeft;
|
||||||
|
|
||||||
|
// 清除之前的定时器
|
||||||
|
if (scrollTimeout) {
|
||||||
|
clearTimeout(scrollTimeout);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 使用防抖,滚动停止150ms后才更新currentIndex用于预加载
|
||||||
|
scrollTimeout = window.setTimeout(() => {
|
||||||
|
if (!containerRef.value) return;
|
||||||
|
|
||||||
|
const scrollLeft = containerRef.value.scrollLeft;
|
||||||
|
const newIndex = Math.round(scrollLeft / panelWidth.value);
|
||||||
|
|
||||||
|
if (newIndex >= 0 && newIndex < props.sessionIds.length && newIndex !== currentIndex.value) {
|
||||||
|
currentIndex.value = newIndex;
|
||||||
|
activeSessionId.value = props.sessionIds[newIndex];
|
||||||
|
preloadAdjacentSessions();
|
||||||
|
}
|
||||||
|
}, 150);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 预加载相邻会话
|
||||||
|
function preloadAdjacentSessions() {
|
||||||
|
const indicesToLoad = [
|
||||||
|
currentIndex.value - 1,
|
||||||
|
currentIndex.value,
|
||||||
|
currentIndex.value + 1
|
||||||
|
].filter(i => i >= 0 && i < props.sessionIds.length);
|
||||||
|
|
||||||
|
indicesToLoad.forEach(i => {
|
||||||
|
const sessionId = props.sessionIds[i];
|
||||||
|
if (!sessionMessages[sessionId] || sessionMessages[sessionId].length === 0) {
|
||||||
|
loadSessionMessages(sessionId);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadSessionMessages(sessionId: string) {
|
||||||
|
if (loadingSessionIds.has(sessionId)) return;
|
||||||
|
|
||||||
|
loadingSessionIds.add(sessionId);
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (props.getSessionMessages) {
|
||||||
|
const messages = await props.getSessionMessages(sessionId);
|
||||||
|
sessionMessages[sessionId] = messages || [];
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`加载会话 ${sessionId} 消息失败:`, error);
|
||||||
|
sessionMessages[sessionId] = [];
|
||||||
|
} finally {
|
||||||
|
loadingSessionIds.delete(sessionId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSessionTitle(sessionId: string): string {
|
||||||
|
const session = props.sessions.find(s => s.session_id === sessionId);
|
||||||
|
return session?.display_name || tm('conversation.newConversation');
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSession(sessionId: string): Session | null {
|
||||||
|
return props.sessions.find(s => s.session_id === sessionId) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 消息处理
|
||||||
|
function handleReplyMessage(sessionId: string, msg: any, index: number) {
|
||||||
|
const messageId = msg.id;
|
||||||
|
if (!messageId) return;
|
||||||
|
|
||||||
|
let messageContent = '';
|
||||||
|
if (typeof msg.content.message === 'string') {
|
||||||
|
messageContent = msg.content.message;
|
||||||
|
} else if (Array.isArray(msg.content.message)) {
|
||||||
|
const textParts = msg.content.message
|
||||||
|
.filter((part: any) => part.type === 'plain' && part.text)
|
||||||
|
.map((part: any) => part.text);
|
||||||
|
messageContent = textParts.join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (messageContent.length > 100) {
|
||||||
|
messageContent = messageContent.substring(0, 100) + '...';
|
||||||
|
}
|
||||||
|
|
||||||
|
replyToMap[sessionId] = {
|
||||||
|
messageId,
|
||||||
|
selectedText: messageContent || '[媒体内容]'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleReplyWithText(sessionId: string, replyData: any) {
|
||||||
|
const { messageId, selectedText } = replyData;
|
||||||
|
if (!messageId) return;
|
||||||
|
|
||||||
|
replyToMap[sessionId] = {
|
||||||
|
messageId,
|
||||||
|
selectedText
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearReply(sessionId: string) {
|
||||||
|
delete replyToMap[sessionId];
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSendMessage(sessionId: string) {
|
||||||
|
const data = {
|
||||||
|
prompt: prompts[sessionId],
|
||||||
|
stagedImages: stagedImages[sessionId],
|
||||||
|
stagedAudios: stagedAudios[sessionId],
|
||||||
|
stagedFiles: stagedFiles[sessionId],
|
||||||
|
replyTo: replyToMap[sessionId]
|
||||||
|
};
|
||||||
|
|
||||||
|
emit('sendMessage', sessionId, data);
|
||||||
|
|
||||||
|
// 清空输入
|
||||||
|
prompts[sessionId] = '';
|
||||||
|
stagedImages[sessionId] = [];
|
||||||
|
stagedAudios[sessionId] = '';
|
||||||
|
stagedFiles[sessionId] = [];
|
||||||
|
clearReply(sessionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeImage(sessionId: string, index: number) {
|
||||||
|
stagedImages[sessionId].splice(index, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeAudio(sessionId: string) {
|
||||||
|
stagedAudios[sessionId] = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeFile(sessionId: string, index: number) {
|
||||||
|
stagedFiles[sessionId].splice(index, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleStartRecording(sessionId: string) {
|
||||||
|
activeSessionId.value = sessionId;
|
||||||
|
emit('startRecording', sessionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleStopRecording(sessionId: string) {
|
||||||
|
emit('stopRecording', sessionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handlePasteImage(sessionId: string, event: ClipboardEvent) {
|
||||||
|
emit('pasteImage', sessionId, event);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleFileSelect(sessionId: string, files: FileList) {
|
||||||
|
emit('fileSelect', sessionId, files);
|
||||||
|
}
|
||||||
|
|
||||||
|
function exitMultiMode() {
|
||||||
|
emit('exitMultiMode');
|
||||||
|
}
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
// 清理定时器
|
||||||
|
if (scrollTimeout) {
|
||||||
|
clearTimeout(scrollTimeout);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.multi-chat-view {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.multi-chat-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 12px 16px;
|
||||||
|
border-bottom: 1px solid var(--v-theme-border);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.multi-chat-title {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 500;
|
||||||
|
margin-left: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-indicator {
|
||||||
|
font-size: 14px;
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-container-wrapper {
|
||||||
|
flex: 1;
|
||||||
|
overflow-x: auto;
|
||||||
|
overflow-y: hidden;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-panels-track {
|
||||||
|
display: flex;
|
||||||
|
height: 100%;
|
||||||
|
width: fit-content;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-panel {
|
||||||
|
position: sticky;
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: min(650px, 100vw);
|
||||||
|
height: 100%;
|
||||||
|
background: rgb(var(--v-theme-surface));
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-panel-inner {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
background: rgb(var(--v-theme-surface));
|
||||||
|
border-right: 1px solid var(--v-theme-border);
|
||||||
|
transition: box-shadow 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-panel-inner.panel-stacked {
|
||||||
|
box-shadow: -4px 0 12px rgba(0, 0, 0, 0.10);
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-header {
|
||||||
|
padding: 12px 16px;
|
||||||
|
border-bottom: 1px solid var(--v-theme-border);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-title {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-list-container {
|
||||||
|
flex: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 隐藏滚动条但保持功能 */
|
||||||
|
.chat-container-wrapper::-webkit-scrollbar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-container-wrapper {
|
||||||
|
-ms-overflow-style: none;
|
||||||
|
scrollbar-width: none;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -14,12 +14,6 @@
|
|||||||
<v-expand-transition>
|
<v-expand-transition>
|
||||||
<div v-show="expanded" style="padding: 0 8px;">
|
<div v-show="expanded" style="padding: 0 8px;">
|
||||||
<v-list density="compact" nav class="project-list" style="background-color: transparent;">
|
<v-list density="compact" nav class="project-list" style="background-color: transparent;">
|
||||||
<v-list-item @click="$emit('createProject')" class="create-project-item" rounded="lg">
|
|
||||||
<template v-slot:prepend>
|
|
||||||
<span class="project-emoji"><v-icon size="small">mdi-plus</v-icon></span>
|
|
||||||
</template>
|
|
||||||
<v-list-item-title style="font-size: 13px;">{{ tm('project.create') }}</v-list-item-title>
|
|
||||||
</v-list-item>
|
|
||||||
<v-list-item v-for="project in projects" :key="project.project_id"
|
<v-list-item v-for="project in projects" :key="project.project_id"
|
||||||
@click="$emit('selectProject', project.project_id)" rounded="lg" class="project-item">
|
@click="$emit('selectProject', project.project_id)" rounded="lg" class="project-item">
|
||||||
<template v-slot:prepend>
|
<template v-slot:prepend>
|
||||||
@@ -35,6 +29,12 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</v-list-item>
|
</v-list-item>
|
||||||
|
<v-list-item @click="$emit('createProject')" class="create-project-item" rounded="lg">
|
||||||
|
<template v-slot:prepend>
|
||||||
|
<v-icon size="small">mdi-plus</v-icon>
|
||||||
|
</template>
|
||||||
|
<v-list-item-title style="font-size: 13px;">{{ tm('project.create') }}</v-list-item-title>
|
||||||
|
</v-list-item>
|
||||||
</v-list>
|
</v-list>
|
||||||
</div>
|
</div>
|
||||||
</v-expand-transition>
|
</v-expand-transition>
|
||||||
|
|||||||
@@ -0,0 +1,150 @@
|
|||||||
|
<template>
|
||||||
|
<v-dialog v-model="isOpen" max-width="600">
|
||||||
|
<v-card>
|
||||||
|
<v-card-title class="d-flex justify-space-between align-center">
|
||||||
|
<span>{{ tm('multiChat.selectSessions') }}</span>
|
||||||
|
<v-btn icon="mdi-close" variant="text" @click="close" />
|
||||||
|
</v-card-title>
|
||||||
|
|
||||||
|
<v-card-text>
|
||||||
|
<div class="mb-3 text-subtitle-2 text-medium-emphasis">
|
||||||
|
{{ tm('multiChat.selectTip') }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<v-list density="compact" class="session-select-list">
|
||||||
|
<v-list-item
|
||||||
|
v-for="session in sessions"
|
||||||
|
:key="session.session_id"
|
||||||
|
@click="toggleSession(session.session_id)"
|
||||||
|
:class="{ 'selected-session': isSelected(session.session_id) }"
|
||||||
|
class="session-item"
|
||||||
|
>
|
||||||
|
<template v-slot:prepend>
|
||||||
|
<v-checkbox
|
||||||
|
:model-value="isSelected(session.session_id)"
|
||||||
|
hide-details
|
||||||
|
class="session-checkbox"
|
||||||
|
@click.stop="toggleSession(session.session_id)"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<v-list-item-title>
|
||||||
|
{{ session.display_name || tm('conversation.newConversation') }}
|
||||||
|
</v-list-item-title>
|
||||||
|
|
||||||
|
<v-list-item-subtitle class="text-caption">
|
||||||
|
{{ new Date(session.updated_at).toLocaleString() }}
|
||||||
|
</v-list-item-subtitle>
|
||||||
|
</v-list-item>
|
||||||
|
</v-list>
|
||||||
|
|
||||||
|
<div v-if="sessions.length === 0" class="text-center py-8 text-medium-emphasis">
|
||||||
|
<v-icon size="48" color="grey-lighten-1">mdi-message-text-outline</v-icon>
|
||||||
|
<div class="mt-2">{{ tm('conversation.noHistory') }}</div>
|
||||||
|
</div>
|
||||||
|
</v-card-text>
|
||||||
|
|
||||||
|
<v-card-actions>
|
||||||
|
<v-spacer></v-spacer>
|
||||||
|
<v-btn variant="text" @click="close">{{ t('core.common.cancel') }}</v-btn>
|
||||||
|
<v-btn
|
||||||
|
variant="text"
|
||||||
|
color="primary"
|
||||||
|
@click="confirm"
|
||||||
|
:disabled="selectedSessionIds.length < 2"
|
||||||
|
>
|
||||||
|
{{ tm('multiChat.enterMultiMode') }} ({{ selectedSessionIds.length }})
|
||||||
|
</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';
|
||||||
|
import type { Session } from '@/composables/useSessions';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
modelValue: boolean;
|
||||||
|
sessions: Session[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<Props>();
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
'update:modelValue': [value: boolean];
|
||||||
|
'confirm': [sessionIds: string[]];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const { t } = useI18n();
|
||||||
|
const { tm } = useModuleI18n('features/chat');
|
||||||
|
|
||||||
|
const isOpen = ref(props.modelValue);
|
||||||
|
const selectedSessionIds = ref<string[]>([]);
|
||||||
|
|
||||||
|
watch(() => props.modelValue, (newVal) => {
|
||||||
|
isOpen.value = newVal;
|
||||||
|
if (newVal) {
|
||||||
|
selectedSessionIds.value = [];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
watch(isOpen, (newVal) => {
|
||||||
|
emit('update:modelValue', newVal);
|
||||||
|
});
|
||||||
|
|
||||||
|
function isSelected(sessionId: string): boolean {
|
||||||
|
return selectedSessionIds.value.includes(sessionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleSession(sessionId: string) {
|
||||||
|
const index = selectedSessionIds.value.indexOf(sessionId);
|
||||||
|
if (index > -1) {
|
||||||
|
selectedSessionIds.value.splice(index, 1);
|
||||||
|
} else {
|
||||||
|
selectedSessionIds.value.push(sessionId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function close() {
|
||||||
|
isOpen.value = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function confirm() {
|
||||||
|
if (selectedSessionIds.value.length >= 2) {
|
||||||
|
emit('confirm', [...selectedSessionIds.value]);
|
||||||
|
close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.session-select-list {
|
||||||
|
max-height: 400px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-item {
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color 0.2s;
|
||||||
|
border-radius: 8px;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-item:hover {
|
||||||
|
background-color: rgba(0, 0, 0, 0.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
.selected-session {
|
||||||
|
background-color: rgba(103, 58, 183, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.selected-session:hover {
|
||||||
|
background-color: rgba(103, 58, 183, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-checkbox {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,220 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="mb-3 mt-1.5">
|
|
||||||
<div class="ipython-header" :class="{ 'expanded': isExpanded }" @click="toggleExpanded">
|
|
||||||
<span class="ipython-label">
|
|
||||||
{{ tm('actions.pythonCodeAnalysis') }}
|
|
||||||
</span>
|
|
||||||
<v-icon size="small" class="ipython-icon" :class="{ 'rotated': isExpanded }">
|
|
||||||
mdi-chevron-right
|
|
||||||
</v-icon>
|
|
||||||
</div>
|
|
||||||
<div v-if="isExpanded" class="py-3 animate-fade-in">
|
|
||||||
<!-- Code Section -->
|
|
||||||
<div class="code-section">
|
|
||||||
<div v-if="shikiReady && code" class="code-highlighted"
|
|
||||||
v-html="highlightedCode"></div>
|
|
||||||
<pre v-else class="code-fallback"
|
|
||||||
:class="{ 'dark-theme': isDark }">{{ code || 'No code available' }}</pre>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Result Section -->
|
|
||||||
<div v-if="result" class="result-section">
|
|
||||||
<div class="result-label">
|
|
||||||
{{ tm('ipython.output') }}:
|
|
||||||
</div>
|
|
||||||
<pre class="result-content"
|
|
||||||
:class="{ 'dark-theme': isDark }">{{ formattedResult }}</pre>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup>
|
|
||||||
import { ref, computed, onMounted } from 'vue';
|
|
||||||
import { useModuleI18n } from '@/i18n/composables';
|
|
||||||
import { createHighlighter } from 'shiki';
|
|
||||||
|
|
||||||
const props = defineProps({
|
|
||||||
toolCall: {
|
|
||||||
type: Object,
|
|
||||||
required: true
|
|
||||||
},
|
|
||||||
isDark: {
|
|
||||||
type: Boolean,
|
|
||||||
default: false
|
|
||||||
},
|
|
||||||
initialExpanded: {
|
|
||||||
type: Boolean,
|
|
||||||
default: false
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const { tm } = useModuleI18n('features/chat');
|
|
||||||
const isExpanded = ref(props.initialExpanded);
|
|
||||||
const shikiHighlighter = ref(null);
|
|
||||||
const shikiReady = ref(false);
|
|
||||||
|
|
||||||
const code = computed(() => {
|
|
||||||
try {
|
|
||||||
if (props.toolCall.args && props.toolCall.args.code) {
|
|
||||||
return props.toolCall.args.code;
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Failed to get iPython code:', err);
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = computed(() => props.toolCall.result);
|
|
||||||
|
|
||||||
const formattedResult = computed(() => {
|
|
||||||
if (!result.value) return '';
|
|
||||||
try {
|
|
||||||
const parsed = JSON.parse(result.value);
|
|
||||||
return JSON.stringify(parsed, null, 2);
|
|
||||||
} catch {
|
|
||||||
return result.value;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const highlightedCode = computed(() => {
|
|
||||||
if (!shikiReady.value || !shikiHighlighter.value || !code.value) {
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
return shikiHighlighter.value.codeToHtml(code.value, {
|
|
||||||
lang: 'python',
|
|
||||||
theme: props.isDark ? 'min-dark' : 'github-light'
|
|
||||||
});
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Failed to highlight code:', err);
|
|
||||||
return `<pre><code>${code.value}</code></pre>`;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const toggleExpanded = () => {
|
|
||||||
isExpanded.value = !isExpanded.value;
|
|
||||||
};
|
|
||||||
|
|
||||||
onMounted(async () => {
|
|
||||||
try {
|
|
||||||
shikiHighlighter.value = await createHighlighter({
|
|
||||||
themes: ['min-dark', 'github-light'],
|
|
||||||
langs: ['python']
|
|
||||||
});
|
|
||||||
shikiReady.value = true;
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Failed to initialize Shiki:', err);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.mb-3 {
|
|
||||||
margin-bottom: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mt-1\.5 {
|
|
||||||
margin-top: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ipython-header {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
cursor: pointer;
|
|
||||||
user-select: none;
|
|
||||||
border-radius: 20px;
|
|
||||||
opacity: 0.7;
|
|
||||||
transition: opacity;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ipython-header:hover,
|
|
||||||
.ipython-header.expanded {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ipython-label {
|
|
||||||
font-size: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ipython-icon {
|
|
||||||
margin-left: 6px;
|
|
||||||
transition: transform 0.2s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ipython-icon.rotated {
|
|
||||||
transform: rotate(90deg);
|
|
||||||
}
|
|
||||||
|
|
||||||
.py-3 {
|
|
||||||
padding-top: 12px;
|
|
||||||
padding-bottom: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.code-section {
|
|
||||||
margin-bottom: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.code-highlighted {
|
|
||||||
border-radius: 6px;
|
|
||||||
overflow: hidden;
|
|
||||||
font-size: 14px;
|
|
||||||
line-height: 1.5;
|
|
||||||
}
|
|
||||||
|
|
||||||
.code-fallback {
|
|
||||||
margin: 0;
|
|
||||||
padding: 12px;
|
|
||||||
border-radius: 6px;
|
|
||||||
overflow-x: auto;
|
|
||||||
font-size: 13px;
|
|
||||||
line-height: 1.5;
|
|
||||||
background-color: #f5f5f5;
|
|
||||||
}
|
|
||||||
|
|
||||||
.code-fallback.dark-theme {
|
|
||||||
background-color: transparent;
|
|
||||||
}
|
|
||||||
|
|
||||||
.result-section {
|
|
||||||
margin-top: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.result-label {
|
|
||||||
font-size: 12px;
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--v-theme-secondaryText);
|
|
||||||
margin-bottom: 6px;
|
|
||||||
opacity: 0.8;
|
|
||||||
}
|
|
||||||
|
|
||||||
.result-content {
|
|
||||||
margin: 0;
|
|
||||||
padding: 12px;
|
|
||||||
border-radius: 6px;
|
|
||||||
overflow-x: auto;
|
|
||||||
font-size: 13px;
|
|
||||||
line-height: 1.5;
|
|
||||||
background-color: #f5f5f5;
|
|
||||||
max-height: 300px;
|
|
||||||
overflow-y: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.result-content.dark-theme {
|
|
||||||
background-color: transparent;
|
|
||||||
}
|
|
||||||
|
|
||||||
.animate-fade-in {
|
|
||||||
animation: fadeIn 0.2s ease-in-out;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes fadeIn {
|
|
||||||
from {
|
|
||||||
opacity: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
to {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,73 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="mb-3 mt-1.5 border border-gray-200 dark:border-gray-700 rounded-2xl overflow-hidden w-fit"
|
|
||||||
:class="{ 'dark:bg-purple-900/8': isDark, 'bg-purple-50/50': !isDark }">
|
|
||||||
<div class="inline-flex items-center px-2 py-2 cursor-pointer select-none rounded-2xl transition-colors hover:bg-purple-50/80 dark:hover:bg-purple-900/15"
|
|
||||||
@click="toggleExpanded">
|
|
||||||
<v-icon size="small" class="mr-1.5 text-purple-600 dark:text-purple-400 transition-transform"
|
|
||||||
:class="{ 'rotate-90': isExpanded }">
|
|
||||||
mdi-chevron-right
|
|
||||||
</v-icon>
|
|
||||||
<span class="text-sm font-medium text-purple-600 dark:text-purple-400 tracking-wide">
|
|
||||||
{{ tm('reasoning.thinking') }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div v-if="isExpanded" class="px-3 border-t border-gray-200 dark:border-gray-700 text-gray-600 dark:text-gray-400 animate-fade-in italic">
|
|
||||||
<MarkdownRender :content="reasoning" class="reasoning-text markdown-content text-sm leading-relaxed"
|
|
||||||
:typewriter="false" :is-dark="isDark" :style="isDark ? { opacity: '0.85' } : {}" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup>
|
|
||||||
import { ref } from 'vue';
|
|
||||||
import { useModuleI18n } from '@/i18n/composables';
|
|
||||||
import { MarkdownRender } from 'markstream-vue';
|
|
||||||
|
|
||||||
const props = defineProps({
|
|
||||||
reasoning: {
|
|
||||||
type: String,
|
|
||||||
required: true
|
|
||||||
},
|
|
||||||
isDark: {
|
|
||||||
type: Boolean,
|
|
||||||
default: false
|
|
||||||
},
|
|
||||||
initialExpanded: {
|
|
||||||
type: Boolean,
|
|
||||||
default: false
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const { tm } = useModuleI18n('features/chat');
|
|
||||||
const isExpanded = ref(props.initialExpanded);
|
|
||||||
|
|
||||||
const toggleExpanded = () => {
|
|
||||||
isExpanded.value = !isExpanded.value;
|
|
||||||
};
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.animate-fade-in {
|
|
||||||
animation: fadeIn 0.2s ease-in-out;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes fadeIn {
|
|
||||||
from {
|
|
||||||
opacity: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
to {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.rotate-90 {
|
|
||||||
transform: rotate(90deg);
|
|
||||||
}
|
|
||||||
|
|
||||||
.reasoning-text {
|
|
||||||
font-size: 14px;
|
|
||||||
line-height: 1.6;
|
|
||||||
color: var(--v-theme-secondaryText);
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,290 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="tool-call-card" :class="{ 'is-dark': isDark, 'expanded': isExpanded }" :style="isDark ? {
|
|
||||||
backgroundColor: 'rgba(40, 60, 100, 0.4)',
|
|
||||||
borderColor: 'rgba(100, 140, 200, 0.4)'
|
|
||||||
} : {}">
|
|
||||||
<!-- Header -->
|
|
||||||
<div class="tool-call-header" :class="{ 'is-dark': isDark }" @click="toggleExpanded">
|
|
||||||
<v-icon size="small" class="tool-call-expand-icon" :class="{ 'expanded': isExpanded }">
|
|
||||||
mdi-chevron-right
|
|
||||||
</v-icon>
|
|
||||||
<v-icon size="small" class="tool-call-icon">mdi-wrench-outline</v-icon>
|
|
||||||
<div class="tool-call-info">
|
|
||||||
<span class="tool-call-name">{{ toolCall.name }}</span>
|
|
||||||
</div>
|
|
||||||
<span class="tool-call-status"
|
|
||||||
:class="{ 'status-running': !toolCall.finished_ts, 'status-finished': toolCall.finished_ts }">
|
|
||||||
<template v-if="toolCall.finished_ts">
|
|
||||||
<v-icon size="x-small" class="status-icon">mdi-check-circle</v-icon>
|
|
||||||
{{ formatDuration(toolCall.finished_ts - toolCall.ts) }}
|
|
||||||
</template>
|
|
||||||
<template v-else>
|
|
||||||
<v-icon size="x-small" class="status-icon spinning">mdi-loading</v-icon>
|
|
||||||
{{ elapsedTime }}
|
|
||||||
</template>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Details -->
|
|
||||||
<div v-if="isExpanded" class="tool-call-details" :style="isDark ? {
|
|
||||||
borderTopColor: 'rgba(100, 140, 200, 0.3)',
|
|
||||||
backgroundColor: 'rgba(30, 45, 70, 0.5)'
|
|
||||||
} : {}">
|
|
||||||
<!-- ID -->
|
|
||||||
<div class="tool-call-detail-row">
|
|
||||||
<span class="detail-label">ID:</span>
|
|
||||||
<code class="detail-value" :style="isDark ? { backgroundColor: 'transparent' } : {}">
|
|
||||||
{{ toolCall.id }}
|
|
||||||
</code>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Args -->
|
|
||||||
<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>
|
|
||||||
|
|
||||||
<!-- Result -->
|
|
||||||
<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' } : {}">{{
|
|
||||||
formattedResult }}</pre>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup>
|
|
||||||
import { ref, computed, onMounted, onUnmounted } from 'vue';
|
|
||||||
|
|
||||||
const props = defineProps({
|
|
||||||
toolCall: {
|
|
||||||
type: Object,
|
|
||||||
required: true
|
|
||||||
},
|
|
||||||
isDark: {
|
|
||||||
type: Boolean,
|
|
||||||
default: false
|
|
||||||
},
|
|
||||||
initialExpanded: {
|
|
||||||
type: Boolean,
|
|
||||||
default: false
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const isExpanded = ref(props.initialExpanded);
|
|
||||||
const currentTime = ref(Date.now() / 1000);
|
|
||||||
let timer = null;
|
|
||||||
|
|
||||||
const elapsedTime = computed(() => {
|
|
||||||
if (props.toolCall.finished_ts) return '';
|
|
||||||
const elapsed = currentTime.value - props.toolCall.ts;
|
|
||||||
return formatDuration(elapsed);
|
|
||||||
});
|
|
||||||
|
|
||||||
const formattedResult = computed(() => {
|
|
||||||
if (!props.toolCall.result) return '';
|
|
||||||
try {
|
|
||||||
const parsed = JSON.parse(props.toolCall.result);
|
|
||||||
return JSON.stringify(parsed, null, 2);
|
|
||||||
} catch {
|
|
||||||
return props.toolCall.result;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const formatDuration = (seconds) => {
|
|
||||||
if (seconds < 1) {
|
|
||||||
return `${Math.round(seconds * 1000)}ms`;
|
|
||||||
} else if (seconds < 60) {
|
|
||||||
return `${seconds.toFixed(1)}s`;
|
|
||||||
} else {
|
|
||||||
const minutes = Math.floor(seconds / 60);
|
|
||||||
const secs = Math.round(seconds % 60);
|
|
||||||
return `${minutes}m ${secs}s`;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const toggleExpanded = () => {
|
|
||||||
isExpanded.value = !isExpanded.value;
|
|
||||||
};
|
|
||||||
|
|
||||||
const updateTime = () => {
|
|
||||||
currentTime.value = Date.now() / 1000;
|
|
||||||
};
|
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
// Update time periodically if tool call is running
|
|
||||||
if (!props.toolCall.finished_ts) {
|
|
||||||
timer = setInterval(updateTime, 100);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
onUnmounted(() => {
|
|
||||||
if (timer) {
|
|
||||||
clearInterval(timer);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.tool-call-card {
|
|
||||||
border-radius: 8px;
|
|
||||||
overflow: hidden;
|
|
||||||
background-color: #eff3f6;
|
|
||||||
margin: 8px 0px;
|
|
||||||
width: fit-content;
|
|
||||||
min-width: 320px;
|
|
||||||
max-width: 100%;
|
|
||||||
transition: all 0.1s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tool-call-card.expanded {
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tool-call-header {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
padding: 10px 12px;
|
|
||||||
cursor: pointer;
|
|
||||||
user-select: none;
|
|
||||||
transition: background-color 0.2s ease;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tool-call-header:hover {
|
|
||||||
background-color: rgba(169, 194, 219, 0.15);
|
|
||||||
}
|
|
||||||
|
|
||||||
.tool-call-header.is-dark:hover {
|
|
||||||
background-color: rgba(100, 150, 200, 0.2);
|
|
||||||
}
|
|
||||||
|
|
||||||
.tool-call-expand-icon {
|
|
||||||
color: var(--v-theme-secondary);
|
|
||||||
transition: transform 0.2s ease;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tool-call-expand-icon.expanded {
|
|
||||||
transform: rotate(90deg);
|
|
||||||
}
|
|
||||||
|
|
||||||
.tool-call-icon {
|
|
||||||
color: var(--v-theme-secondary);
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tool-call-info {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 2px;
|
|
||||||
flex: 1;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tool-call-name {
|
|
||||||
font-size: 13px;
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--v-theme-secondary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.tool-call-status {
|
|
||||||
margin-left: 8px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 4px;
|
|
||||||
font-size: 12px;
|
|
||||||
font-weight: 500;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tool-call-status.status-running {
|
|
||||||
color: #ff9800;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tool-call-status.status-finished {
|
|
||||||
color: #4caf50;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tool-call-status .status-icon {
|
|
||||||
font-size: 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tool-call-status .status-icon.spinning {
|
|
||||||
animation: spin 1s linear infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tool-call-details {
|
|
||||||
padding: 12px;
|
|
||||||
background-color: rgba(255, 255, 255, 0.5);
|
|
||||||
animation: fadeIn 0.2s ease-in-out;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tool-call-detail-row {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
margin-bottom: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tool-call-detail-row:last-child {
|
|
||||||
margin-bottom: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.detail-label {
|
|
||||||
font-size: 11px;
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--v-theme-secondaryText);
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.5px;
|
|
||||||
margin-bottom: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.detail-value {
|
|
||||||
font-size: 12px;
|
|
||||||
color: var(--v-theme-primaryText);
|
|
||||||
background-color: transparent;
|
|
||||||
padding: 4px 8px;
|
|
||||||
border-radius: 4px;
|
|
||||||
word-break: break-all;
|
|
||||||
}
|
|
||||||
|
|
||||||
.detail-json {
|
|
||||||
font-family: 'Fira Code', 'Consolas', monospace;
|
|
||||||
white-space: pre-wrap;
|
|
||||||
max-height: 200px;
|
|
||||||
overflow-y: auto;
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.detail-result {
|
|
||||||
max-height: 300px;
|
|
||||||
background-color: transparent;
|
|
||||||
}
|
|
||||||
|
|
||||||
.animate-fade-in {
|
|
||||||
animation: fadeIn 0.2s ease-in-out;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes fadeIn {
|
|
||||||
from {
|
|
||||||
opacity: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
to {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes spin {
|
|
||||||
from {
|
|
||||||
transform: rotate(0deg);
|
|
||||||
}
|
|
||||||
|
|
||||||
to {
|
|
||||||
transform: rotate(360deg);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -43,11 +43,7 @@
|
|||||||
"exitFullscreen": "Exit Fullscreen",
|
"exitFullscreen": "Exit Fullscreen",
|
||||||
"reply": "Reply",
|
"reply": "Reply",
|
||||||
"providerConfig": "AI Configuration",
|
"providerConfig": "AI Configuration",
|
||||||
"toolsUsed": "Tool Used",
|
"toolsUsed": "Tool Used"
|
||||||
"pythonCodeAnalysis": "Python Code Analysis Used"
|
|
||||||
},
|
|
||||||
"ipython": {
|
|
||||||
"output": "Output"
|
|
||||||
},
|
},
|
||||||
"conversation": {
|
"conversation": {
|
||||||
"newConversation": "New Conversation",
|
"newConversation": "New Conversation",
|
||||||
@@ -93,6 +89,12 @@
|
|||||||
"noSessions": "No conversations in this project",
|
"noSessions": "No conversations in this project",
|
||||||
"confirmDelete": "Are you sure you want to delete project \"{title}\"? Conversations in this project will not be deleted."
|
"confirmDelete": "Are you sure you want to delete project \"{title}\"? Conversations in this project will not be deleted."
|
||||||
},
|
},
|
||||||
|
"multiChat": {
|
||||||
|
"multiMode": "Multi-Chat Mode",
|
||||||
|
"selectSessions": "Select Conversations",
|
||||||
|
"selectTip": "Select at least 2 conversations to enter multi-chat mode",
|
||||||
|
"enterMultiMode": "Enter Multi-Chat Mode"
|
||||||
|
},
|
||||||
"time": {
|
"time": {
|
||||||
"today": "Today",
|
"today": "Today",
|
||||||
"yesterday": "Yesterday"
|
"yesterday": "Yesterday"
|
||||||
|
|||||||
@@ -133,36 +133,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"sandbox": {
|
|
||||||
"description": "Agent Sandbox Env(Beta)",
|
|
||||||
"provider_settings": {
|
|
||||||
"sandbox": {
|
|
||||||
"enable": {
|
|
||||||
"description": "Enable Sandbox Env",
|
|
||||||
"hint": "When enabled, Agent can use tools and resources in the sandbox environment, such as Python tool, Shell, etc."
|
|
||||||
},
|
|
||||||
"booter": {
|
|
||||||
"description": "Sandbox Environment Driver"
|
|
||||||
},
|
|
||||||
"shipyard_endpoint": {
|
|
||||||
"description": "Shipyard API Endpoint",
|
|
||||||
"hint": "API access address for Shipyard service."
|
|
||||||
},
|
|
||||||
"shipyard_access_token": {
|
|
||||||
"description": "Shipyard Access Token",
|
|
||||||
"hint": "Access token for accessing Shipyard service."
|
|
||||||
},
|
|
||||||
"shipyard_ttl": {
|
|
||||||
"description": "Shipyard Session TTL",
|
|
||||||
"hint": "Session time-to-live in seconds."
|
|
||||||
},
|
|
||||||
"shipyard_max_sessions": {
|
|
||||||
"description": "Shipyard Max Sessions",
|
|
||||||
"hint": "Maximum number of Shipyard sessions an instance can handle."
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"truncate_and_compress": {
|
"truncate_and_compress": {
|
||||||
"description": "Context Management Strategy",
|
"description": "Context Management Strategy",
|
||||||
"provider_settings": {
|
"provider_settings": {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
{
|
{
|
||||||
"title": "Custom Rules",
|
"title": "Custom Rules",
|
||||||
"subtitle": "Set custom rules for specific sessions, which take priority over global settings",
|
"subtitle": "Set custom rules for specific sessions, which take priority over global settings",
|
||||||
"buttons": {
|
"buttons": {
|
||||||
@@ -93,42 +93,6 @@
|
|||||||
"batchDeleteConfirm": {
|
"batchDeleteConfirm": {
|
||||||
"title": "Confirm Batch Delete",
|
"title": "Confirm Batch Delete",
|
||||||
"message": "Are you sure you want to delete {count} selected rules? Global settings will be used after deletion."
|
"message": "Are you sure you want to delete {count} selected rules? Global settings will be used after deletion."
|
||||||
},
|
|
||||||
"batchOperations": {
|
|
||||||
"title": "Batch Operations",
|
|
||||||
"hint": "Quick batch modify session settings",
|
|
||||||
"scope": "Apply to",
|
|
||||||
"scopeSelected": "Selected sessions",
|
|
||||||
"scopeAll": "All sessions",
|
|
||||||
"scopeGroup": "All groups",
|
|
||||||
"scopePrivate": "All private chats",
|
|
||||||
"llmStatus": "LLM Status",
|
|
||||||
"ttsStatus": "TTS Status",
|
|
||||||
"chatProvider": "Chat Model",
|
|
||||||
"ttsProvider": "TTS Model",
|
|
||||||
"apply": "Apply Changes"
|
|
||||||
},
|
|
||||||
"status": {
|
|
||||||
"enabled": "Enabled",
|
|
||||||
"disabled": "Disabled"
|
|
||||||
},
|
|
||||||
"batchOperations": {
|
|
||||||
"title": "Batch Operations",
|
|
||||||
"hint": "Quick batch modify session settings",
|
|
||||||
"scope": "Apply to",
|
|
||||||
"scopeSelected": "Selected sessions",
|
|
||||||
"scopeAll": "All sessions",
|
|
||||||
"scopeGroup": "All groups",
|
|
||||||
"scopePrivate": "All private chats",
|
|
||||||
"llmStatus": "LLM Status",
|
|
||||||
"ttsStatus": "TTS Status",
|
|
||||||
"chatProvider": "Chat Model",
|
|
||||||
"ttsProvider": "TTS Model",
|
|
||||||
"apply": "Apply Changes"
|
|
||||||
},
|
|
||||||
"status": {
|
|
||||||
"enabled": "Enabled",
|
|
||||||
"disabled": "Disabled"
|
|
||||||
},
|
},
|
||||||
"messages": {
|
"messages": {
|
||||||
"refreshSuccess": "Data refreshed",
|
"refreshSuccess": "Data refreshed",
|
||||||
@@ -141,8 +105,6 @@
|
|||||||
"deleteError": "Failed to delete",
|
"deleteError": "Failed to delete",
|
||||||
"noChanges": "No changes to save",
|
"noChanges": "No changes to save",
|
||||||
"batchDeleteSuccess": "Batch delete successful",
|
"batchDeleteSuccess": "Batch delete successful",
|
||||||
"batchDeleteError": "Batch delete failed",
|
"batchDeleteError": "Batch delete failed"
|
||||||
"batchUpdateError": "Batch update failed",
|
|
||||||
"batchUpdateSuccess": "Batch update success"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,11 +43,7 @@
|
|||||||
"exitFullscreen": "退出全屏",
|
"exitFullscreen": "退出全屏",
|
||||||
"reply": "引用回复",
|
"reply": "引用回复",
|
||||||
"providerConfig": "AI 配置",
|
"providerConfig": "AI 配置",
|
||||||
"toolsUsed": "已使用工具",
|
"toolsUsed": "已使用工具"
|
||||||
"pythonCodeAnalysis": "已使用 Python 代码分析"
|
|
||||||
},
|
|
||||||
"ipython": {
|
|
||||||
"output": "输出"
|
|
||||||
},
|
},
|
||||||
"conversation": {
|
"conversation": {
|
||||||
"newConversation": "新的聊天",
|
"newConversation": "新的聊天",
|
||||||
@@ -95,6 +91,12 @@
|
|||||||
"noSessions": "该项目暂无对话",
|
"noSessions": "该项目暂无对话",
|
||||||
"confirmDelete": "确定要删除项目 \"{title}\" 吗?项目中的对话不会被删除。"
|
"confirmDelete": "确定要删除项目 \"{title}\" 吗?项目中的对话不会被删除。"
|
||||||
},
|
},
|
||||||
|
"multiChat": {
|
||||||
|
"multiMode": "多对话模式",
|
||||||
|
"selectSessions": "选择对话",
|
||||||
|
"selectTip": "至少选择2个对话进入多对话模式",
|
||||||
|
"enterMultiMode": "进入多对话模式"
|
||||||
|
},
|
||||||
"time": {
|
"time": {
|
||||||
"today": "今天",
|
"today": "今天",
|
||||||
"yesterday": "昨天"
|
"yesterday": "昨天"
|
||||||
|
|||||||
@@ -133,36 +133,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"sandbox": {
|
|
||||||
"description": "Agent 沙箱环境(Beta)",
|
|
||||||
"provider_settings": {
|
|
||||||
"sandbox": {
|
|
||||||
"enable": {
|
|
||||||
"description": "启用沙箱环境",
|
|
||||||
"hint": "启用后,Agent 可以使用沙箱环境中的工具和资源,如 Python 代码执行、Shell 等。"
|
|
||||||
},
|
|
||||||
"booter": {
|
|
||||||
"description": "沙箱环境驱动器"
|
|
||||||
},
|
|
||||||
"shipyard_endpoint": {
|
|
||||||
"description": "Shipyard API Endpoint",
|
|
||||||
"hint": "Shipyard 服务的 API 访问地址。"
|
|
||||||
},
|
|
||||||
"shipyard_access_token": {
|
|
||||||
"description": "Shipyard 访问令牌",
|
|
||||||
"hint": "用于访问 Shipyard 服务的访问令牌。"
|
|
||||||
},
|
|
||||||
"shipyard_ttl": {
|
|
||||||
"description": "Shipyard Ship 存活时间(秒)",
|
|
||||||
"hint": "Shipyard 会话的生存时间(秒)。"
|
|
||||||
},
|
|
||||||
"shipyard_max_sessions": {
|
|
||||||
"description": "Shipyard Ship 会话复用上限",
|
|
||||||
"hint": "决定了一个实例承载的最大会话数量。"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"truncate_and_compress": {
|
"truncate_and_compress": {
|
||||||
"description": "上下文管理策略",
|
"description": "上下文管理策略",
|
||||||
"provider_settings": {
|
"provider_settings": {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
{
|
{
|
||||||
"title": "自定义规则",
|
"title": "自定义规则",
|
||||||
"subtitle": "为特定会话设置自定义规则,优先级高于全局配置",
|
"subtitle": "为特定会话设置自定义规则,优先级高于全局配置",
|
||||||
"buttons": {
|
"buttons": {
|
||||||
@@ -94,24 +94,6 @@
|
|||||||
"title": "确认批量删除",
|
"title": "确认批量删除",
|
||||||
"message": "确定要删除选中的 {count} 条规则吗?删除后将恢复使用全局配置。"
|
"message": "确定要删除选中的 {count} 条规则吗?删除后将恢复使用全局配置。"
|
||||||
},
|
},
|
||||||
"batchOperations": {
|
|
||||||
"title": "批量操作",
|
|
||||||
"hint": "快速批量修改会话配置",
|
|
||||||
"scope": "应用范围",
|
|
||||||
"scopeSelected": "选中的会话",
|
|
||||||
"scopeAll": "所有会话",
|
|
||||||
"scopeGroup": "所有群聊",
|
|
||||||
"scopePrivate": "所有私聊",
|
|
||||||
"llmStatus": "LLM 状态",
|
|
||||||
"ttsStatus": "TTS 状态",
|
|
||||||
"chatProvider": "聊天模型",
|
|
||||||
"ttsProvider": "TTS 模型",
|
|
||||||
"apply": "应用更改"
|
|
||||||
},
|
|
||||||
"status": {
|
|
||||||
"enabled": "启用",
|
|
||||||
"disabled": "禁用"
|
|
||||||
},
|
|
||||||
"messages": {
|
"messages": {
|
||||||
"refreshSuccess": "数据已刷新",
|
"refreshSuccess": "数据已刷新",
|
||||||
"loadError": "加载数据失败",
|
"loadError": "加载数据失败",
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="session-management-page">
|
<div class="session-management-page">
|
||||||
<v-container fluid class="pa-0">
|
<v-container fluid class="pa-0">
|
||||||
<v-card flat>
|
<v-card flat>
|
||||||
@@ -111,160 +111,6 @@
|
|||||||
</v-data-table-server>
|
</v-data-table-server>
|
||||||
</v-card-text>
|
</v-card-text>
|
||||||
</v-card>
|
</v-card>
|
||||||
<!-- 批量操作面板 -->
|
|
||||||
<v-card flat class="mt-4">
|
|
||||||
<v-card-title class="d-flex align-center py-3 px-4">
|
|
||||||
<span class="text-h6">{{ tm('batchOperations.title') }}</span>
|
|
||||||
<v-chip size="small" class="ml-2" color="info" variant="outlined">
|
|
||||||
{{ tm('batchOperations.hint') }}
|
|
||||||
</v-chip>
|
|
||||||
</v-card-title>
|
|
||||||
<v-card-text>
|
|
||||||
<v-row dense>
|
|
||||||
<v-col cols="12" md="6" lg="3">
|
|
||||||
<v-select v-model="batchScope" :items="batchScopeOptions" item-title="label" item-value="value"
|
|
||||||
:label="tm('batchOperations.scope')" hide-details variant="solo-filled" flat density="comfortable">
|
|
||||||
</v-select>
|
|
||||||
</v-col>
|
|
||||||
<v-col cols="12" md="6" lg="3">
|
|
||||||
<v-select v-model="batchLlmStatus" :items="statusOptions" item-title="label" item-value="value"
|
|
||||||
:label="tm('batchOperations.llmStatus')" hide-details clearable variant="solo-filled" flat density="comfortable">
|
|
||||||
</v-select>
|
|
||||||
</v-col>
|
|
||||||
<v-col cols="12" md="6" lg="3">
|
|
||||||
<v-select v-model="batchTtsStatus" :items="statusOptions" item-title="label" item-value="value"
|
|
||||||
:label="tm('batchOperations.ttsStatus')" hide-details clearable variant="solo-filled" flat density="comfortable">
|
|
||||||
</v-select>
|
|
||||||
</v-col>
|
|
||||||
<v-col cols="12" md="6" lg="3">
|
|
||||||
<v-select v-model="batchChatProvider" :items="chatProviderOptions" item-title="label" item-value="value"
|
|
||||||
:label="tm('batchOperations.chatProvider')" hide-details clearable variant="solo-filled" flat density="comfortable">
|
|
||||||
</v-select>
|
|
||||||
</v-col>
|
|
||||||
</v-row>
|
|
||||||
<v-row dense class="mt-3">
|
|
||||||
<v-col cols="12" class="d-flex justify-end">
|
|
||||||
<v-btn color="primary" variant="tonal" size="large" @click="applyBatchChanges"
|
|
||||||
:disabled="!canApplyBatch" :loading="batchUpdating" prepend-icon="mdi-check-all">
|
|
||||||
{{ tm('batchOperations.apply') }}
|
|
||||||
</v-btn>
|
|
||||||
</v-col>
|
|
||||||
</v-row>
|
|
||||||
</v-card-text>
|
|
||||||
</v-card>
|
|
||||||
|
|
||||||
<!-- 分组管理面板 -->
|
|
||||||
<v-card flat class="mt-4">
|
|
||||||
<v-card-title class="d-flex align-center py-3 px-4">
|
|
||||||
<span class="text-h6">分组管理</span>
|
|
||||||
<v-chip size="small" class="ml-2" color="secondary" variant="outlined">
|
|
||||||
{{ groups.length }} 个分组
|
|
||||||
</v-chip>
|
|
||||||
<v-spacer></v-spacer>
|
|
||||||
<v-btn v-if="selectedItems.length > 0 && groups.length > 0" color="info" variant="tonal" size="small" class="mr-2">
|
|
||||||
<v-icon start>mdi-folder-plus</v-icon>
|
|
||||||
添加到分组
|
|
||||||
<v-menu activator="parent">
|
|
||||||
<v-list density="compact">
|
|
||||||
<v-list-item v-for="g in groups" :key="g.id" @click="addSelectedToGroup(g.id)">
|
|
||||||
<v-list-item-title>{{ g.name }} ({{ g.umo_count }})</v-list-item-title>
|
|
||||||
</v-list-item>
|
|
||||||
</v-list>
|
|
||||||
</v-menu>
|
|
||||||
</v-btn>
|
|
||||||
<v-btn color="success" variant="tonal" size="small" @click="openCreateGroupDialog" prepend-icon="mdi-folder-plus">
|
|
||||||
新建分组
|
|
||||||
</v-btn>
|
|
||||||
</v-card-title>
|
|
||||||
<v-card-text v-if="groups.length > 0">
|
|
||||||
<v-row dense>
|
|
||||||
<v-col v-for="group in groups" :key="group.id" cols="12" sm="6" md="4" lg="3">
|
|
||||||
<v-card variant="outlined" class="pa-3">
|
|
||||||
<div class="d-flex align-center justify-space-between">
|
|
||||||
<div>
|
|
||||||
<div class="font-weight-bold">{{ group.name }}</div>
|
|
||||||
<div class="text-caption text-grey">{{ group.umo_count }} 个会话</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<v-btn icon size="small" variant="text" @click="openEditGroupDialog(group)">
|
|
||||||
<v-icon size="small">mdi-pencil</v-icon>
|
|
||||||
</v-btn>
|
|
||||||
<v-btn icon size="small" variant="text" color="error" @click="deleteGroup(group)">
|
|
||||||
<v-icon size="small">mdi-delete</v-icon>
|
|
||||||
</v-btn>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</v-card>
|
|
||||||
</v-col>
|
|
||||||
</v-row>
|
|
||||||
</v-card-text>
|
|
||||||
<v-card-text v-else class="text-center text-grey py-6">
|
|
||||||
暂无分组,点击「新建分组」创建
|
|
||||||
</v-card-text>
|
|
||||||
</v-card>
|
|
||||||
|
|
||||||
<!-- 分组编辑对话框 -->
|
|
||||||
<v-dialog v-model="groupDialog" max-width="800" @after-enter="loadAvailableUmos">
|
|
||||||
<v-card>
|
|
||||||
<v-card-title class="py-3 px-4">
|
|
||||||
{{ groupDialogMode === 'create' ? '新建分组' : '编辑分组' }}
|
|
||||||
</v-card-title>
|
|
||||||
<v-card-text>
|
|
||||||
<v-text-field v-model="editingGroup.name" label="分组名称" variant="outlined" hide-details class="mb-4"></v-text-field>
|
|
||||||
<v-row dense>
|
|
||||||
<!-- 左侧:可选会话 -->
|
|
||||||
<v-col cols="5">
|
|
||||||
<div class="text-subtitle-2 mb-2">可选会话 ({{ unselectedUmos.length }})</div>
|
|
||||||
<v-text-field v-model="groupMemberSearch" placeholder="搜索..." variant="outlined" density="compact" hide-details class="mb-2" clearable prepend-inner-icon="mdi-magnify"></v-text-field>
|
|
||||||
<v-list density="compact" class="transfer-list" lines="one">
|
|
||||||
<v-list-item v-for="umo in filteredUnselectedUmos" :key="umo" @click="addToGroup(umo)" class="transfer-item">
|
|
||||||
<template v-slot:prepend>
|
|
||||||
<v-icon size="small" color="grey">mdi-plus</v-icon>
|
|
||||||
</template>
|
|
||||||
<v-list-item-title class="text-caption">{{ formatUmoShort(umo) }}</v-list-item-title>
|
|
||||||
</v-list-item>
|
|
||||||
<v-list-item v-if="filteredUnselectedUmos.length === 0 && !loadingUmos">
|
|
||||||
<v-list-item-title class="text-caption text-grey text-center">无匹配项</v-list-item-title>
|
|
||||||
</v-list-item>
|
|
||||||
<v-list-item v-if="loadingUmos">
|
|
||||||
<v-list-item-title class="text-center"><v-progress-circular indeterminate size="20"></v-progress-circular></v-list-item-title>
|
|
||||||
</v-list-item>
|
|
||||||
</v-list>
|
|
||||||
</v-col>
|
|
||||||
<!-- 中间:操作按钮 -->
|
|
||||||
<v-col cols="2" class="d-flex flex-column align-center justify-center">
|
|
||||||
<v-btn icon size="small" variant="tonal" color="primary" class="mb-2" @click="addAllToGroup" :disabled="unselectedUmos.length === 0">
|
|
||||||
<v-icon>mdi-chevron-double-right</v-icon>
|
|
||||||
</v-btn>
|
|
||||||
<v-btn icon size="small" variant="tonal" color="error" @click="removeAllFromGroup" :disabled="editingGroup.umos.length === 0">
|
|
||||||
<v-icon>mdi-chevron-double-left</v-icon>
|
|
||||||
</v-btn>
|
|
||||||
</v-col>
|
|
||||||
<!-- 右侧:已选会话 -->
|
|
||||||
<v-col cols="5">
|
|
||||||
<div class="text-subtitle-2 mb-2">已选会话 ({{ editingGroup.umos.length }})</div>
|
|
||||||
<v-text-field v-model="groupSelectedSearch" placeholder="搜索..." variant="outlined" density="compact" hide-details class="mb-2" clearable prepend-inner-icon="mdi-magnify"></v-text-field>
|
|
||||||
<v-list density="compact" class="transfer-list" lines="one">
|
|
||||||
<v-list-item v-for="umo in filteredSelectedUmos" :key="umo" @click="removeFromGroup(umo)" class="transfer-item">
|
|
||||||
<template v-slot:prepend>
|
|
||||||
<v-icon size="small" color="error">mdi-minus</v-icon>
|
|
||||||
</template>
|
|
||||||
<v-list-item-title class="text-caption">{{ formatUmoShort(umo) }}</v-list-item-title>
|
|
||||||
</v-list-item>
|
|
||||||
<v-list-item v-if="editingGroup.umos.length === 0">
|
|
||||||
<v-list-item-title class="text-caption text-grey text-center">暂无成员</v-list-item-title>
|
|
||||||
</v-list-item>
|
|
||||||
</v-list>
|
|
||||||
</v-col>
|
|
||||||
</v-row>
|
|
||||||
</v-card-text>
|
|
||||||
<v-card-actions class="px-4 pb-4">
|
|
||||||
<v-spacer></v-spacer>
|
|
||||||
<v-btn variant="text" @click="groupDialog = false">取消</v-btn>
|
|
||||||
<v-btn color="primary" variant="tonal" @click="saveGroup">保存</v-btn>
|
|
||||||
</v-card-actions>
|
|
||||||
</v-card>
|
|
||||||
</v-dialog>
|
|
||||||
|
|
||||||
<!-- 添加规则对话框 - 选择 UMO -->
|
<!-- 添加规则对话框 - 选择 UMO -->
|
||||||
<v-dialog v-model="addRuleDialog" max-width="600">
|
<v-dialog v-model="addRuleDialog" max-width="600">
|
||||||
@@ -608,29 +454,6 @@ export default {
|
|||||||
quickEditNameDialog: false,
|
quickEditNameDialog: false,
|
||||||
quickEditNameTarget: null,
|
quickEditNameTarget: null,
|
||||||
quickEditNameValue: '',
|
quickEditNameValue: '',
|
||||||
// 批量操作
|
|
||||||
batchScope: 'selected',
|
|
||||||
batchGroupId: null,
|
|
||||||
batchLlmStatus: null,
|
|
||||||
batchTtsStatus: null,
|
|
||||||
batchChatProvider: null,
|
|
||||||
batchTtsProvider: null,
|
|
||||||
batchUpdating: false,
|
|
||||||
|
|
||||||
// 分组管理
|
|
||||||
groups: [],
|
|
||||||
groupsLoading: false,
|
|
||||||
groupDialog: false,
|
|
||||||
groupDialogMode: 'create',
|
|
||||||
editingGroup: {
|
|
||||||
id: null,
|
|
||||||
name: '',
|
|
||||||
umos: [],
|
|
||||||
},
|
|
||||||
groupMemberDialog: false,
|
|
||||||
groupMemberTarget: null,
|
|
||||||
groupMemberSearch: '',
|
|
||||||
groupSelectedSearch: '',
|
|
||||||
|
|
||||||
// 提示信息
|
// 提示信息
|
||||||
snackbar: false,
|
snackbar: false,
|
||||||
@@ -706,65 +529,6 @@ export default {
|
|||||||
value: kb.kb_id
|
value: kb.kb_id
|
||||||
}))
|
}))
|
||||||
},
|
},
|
||||||
batchScopeOptions() {
|
|
||||||
const options = [
|
|
||||||
{ label: this.tm('batchOperations.scopeSelected'), value: 'selected' },
|
|
||||||
{ label: this.tm('batchOperations.scopeAll'), value: 'all' },
|
|
||||||
{ label: this.tm('batchOperations.scopeGroup'), value: 'group' },
|
|
||||||
{ label: this.tm('batchOperations.scopePrivate'), value: 'private' },
|
|
||||||
]
|
|
||||||
// 添加自定义分组选项
|
|
||||||
if (this.groups.length > 0) {
|
|
||||||
options.push({ label: '── 自定义分组 ──', value: '_divider', disabled: true })
|
|
||||||
this.groups.forEach(g => {
|
|
||||||
options.push({ label: `📁 ${g.name} (${g.umo_count})`, value: `custom_group:${g.id}` })
|
|
||||||
})
|
|
||||||
}
|
|
||||||
return options
|
|
||||||
},
|
|
||||||
|
|
||||||
groupOptions() {
|
|
||||||
return this.groups.map(g => ({
|
|
||||||
label: `${g.name} (${g.umo_count} 个会话)`,
|
|
||||||
value: g.id
|
|
||||||
}))
|
|
||||||
},
|
|
||||||
|
|
||||||
statusOptions() {
|
|
||||||
return [
|
|
||||||
{ label: this.tm('status.enabled'), value: true },
|
|
||||||
{ label: this.tm('status.disabled'), value: false },
|
|
||||||
]
|
|
||||||
},
|
|
||||||
|
|
||||||
canApplyBatch() {
|
|
||||||
const hasChanges = this.batchLlmStatus !== null || this.batchTtsStatus !== null ||
|
|
||||||
this.batchChatProvider !== null || this.batchTtsProvider !== null
|
|
||||||
if (this.batchScope === 'selected') {
|
|
||||||
return hasChanges && this.selectedItems.length > 0
|
|
||||||
}
|
|
||||||
return hasChanges
|
|
||||||
},
|
|
||||||
|
|
||||||
// 穿梭框:未选中的UMO列表
|
|
||||||
unselectedUmos() {
|
|
||||||
const selected = new Set(this.editingGroup.umos || [])
|
|
||||||
return this.availableUmos.filter(u => !selected.has(u))
|
|
||||||
},
|
|
||||||
|
|
||||||
// 穿梭框:过滤后的未选中列表
|
|
||||||
filteredUnselectedUmos() {
|
|
||||||
if (!this.groupMemberSearch) return this.unselectedUmos
|
|
||||||
const search = this.groupMemberSearch.toLowerCase()
|
|
||||||
return this.unselectedUmos.filter(u => u.toLowerCase().includes(search))
|
|
||||||
},
|
|
||||||
|
|
||||||
// 穿梭框:过滤后的已选中列表
|
|
||||||
filteredSelectedUmos() {
|
|
||||||
if (!this.groupSelectedSearch) return this.editingGroup.umos || []
|
|
||||||
const search = this.groupSelectedSearch.toLowerCase()
|
|
||||||
return (this.editingGroup.umos || []).filter(u => u.toLowerCase().includes(search))
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
|
|
||||||
watch: {
|
watch: {
|
||||||
@@ -783,7 +547,6 @@ export default {
|
|||||||
|
|
||||||
mounted() {
|
mounted() {
|
||||||
this.loadData()
|
this.loadData()
|
||||||
this.loadGroups()
|
|
||||||
},
|
},
|
||||||
|
|
||||||
beforeUnmount() {
|
beforeUnmount() {
|
||||||
@@ -1308,242 +1071,6 @@ export default {
|
|||||||
}
|
}
|
||||||
this.saving = false
|
this.saving = false
|
||||||
},
|
},
|
||||||
|
|
||||||
async applyBatchChanges() {
|
|
||||||
this.batchUpdating = true
|
|
||||||
try {
|
|
||||||
let scope = this.batchScope
|
|
||||||
let groupId = null
|
|
||||||
let umos = []
|
|
||||||
|
|
||||||
// 处理自定义分组
|
|
||||||
if (scope.startsWith('custom_group:')) {
|
|
||||||
groupId = scope.split(':')[1]
|
|
||||||
scope = 'custom_group'
|
|
||||||
}
|
|
||||||
|
|
||||||
if (scope === 'selected') {
|
|
||||||
umos = this.selectedItems.map(item => item.umo)
|
|
||||||
if (umos.length === 0) {
|
|
||||||
this.showError('请先选择要操作的会话')
|
|
||||||
this.batchUpdating = false
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const tasks = []
|
|
||||||
|
|
||||||
if (this.batchLlmStatus !== null || this.batchTtsStatus !== null) {
|
|
||||||
const serviceData = { scope, umos, group_id: groupId }
|
|
||||||
if (this.batchLlmStatus !== null) {
|
|
||||||
serviceData.llm_enabled = this.batchLlmStatus
|
|
||||||
}
|
|
||||||
if (this.batchTtsStatus !== null) {
|
|
||||||
serviceData.tts_enabled = this.batchTtsStatus
|
|
||||||
}
|
|
||||||
tasks.push(axios.post('/api/session/batch-update-service', serviceData))
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.batchChatProvider !== null) {
|
|
||||||
tasks.push(axios.post('/api/session/batch-update-provider', {
|
|
||||||
scope,
|
|
||||||
umos,
|
|
||||||
group_id: groupId,
|
|
||||||
provider_type: 'chat_completion',
|
|
||||||
provider_id: this.batchChatProvider || null
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.batchTtsProvider !== null) {
|
|
||||||
tasks.push(axios.post('/api/session/batch-update-provider', {
|
|
||||||
scope,
|
|
||||||
umos,
|
|
||||||
group_id: groupId,
|
|
||||||
provider_type: 'text_to_speech',
|
|
||||||
provider_id: this.batchTtsProvider || null
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
if (tasks.length === 0) {
|
|
||||||
this.showError('请至少选择一项要修改的配置')
|
|
||||||
this.batchUpdating = false
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const results = await Promise.all(tasks)
|
|
||||||
const allOk = results.every(r => r.data.status === 'ok')
|
|
||||||
|
|
||||||
if (allOk) {
|
|
||||||
this.showSuccess('批量更新成功')
|
|
||||||
this.batchLlmStatus = null
|
|
||||||
this.batchTtsStatus = null
|
|
||||||
this.batchChatProvider = null
|
|
||||||
this.batchTtsProvider = null
|
|
||||||
await this.loadData()
|
|
||||||
} else {
|
|
||||||
this.showError('部分更新失败')
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
this.showError(error.response?.data?.message || '批量更新失败')
|
|
||||||
}
|
|
||||||
this.batchUpdating = false
|
|
||||||
},
|
|
||||||
|
|
||||||
// ==================== 分组管理方法 ====================
|
|
||||||
|
|
||||||
async loadGroups() {
|
|
||||||
this.groupsLoading = true
|
|
||||||
try {
|
|
||||||
const response = await axios.get('/api/session/groups')
|
|
||||||
if (response.data.status === 'ok') {
|
|
||||||
this.groups = response.data.data.groups || []
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('加载分组失败:', error)
|
|
||||||
}
|
|
||||||
this.groupsLoading = false
|
|
||||||
},
|
|
||||||
|
|
||||||
async loadAvailableUmos() {
|
|
||||||
if (this.availableUmos.length > 0) return
|
|
||||||
this.loadingUmos = true
|
|
||||||
try {
|
|
||||||
const response = await axios.get('/api/session/active-umos')
|
|
||||||
if (response.data.status === 'ok') {
|
|
||||||
this.availableUmos = response.data.data.umos || []
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('加载会话列表失败:', error)
|
|
||||||
}
|
|
||||||
this.loadingUmos = false
|
|
||||||
},
|
|
||||||
|
|
||||||
openCreateGroupDialog() {
|
|
||||||
this.groupDialogMode = 'create'
|
|
||||||
this.editingGroup = { id: null, name: '', umos: [] }
|
|
||||||
this.groupMemberSearch = ''
|
|
||||||
this.groupSelectedSearch = ''
|
|
||||||
this.groupDialog = true
|
|
||||||
},
|
|
||||||
|
|
||||||
openEditGroupDialog(group) {
|
|
||||||
this.groupDialogMode = 'edit'
|
|
||||||
this.editingGroup = { ...group, umos: [...(group.umos || [])] }
|
|
||||||
this.groupMemberSearch = ''
|
|
||||||
this.groupSelectedSearch = ''
|
|
||||||
this.groupDialog = true
|
|
||||||
},
|
|
||||||
|
|
||||||
// 穿梭框操作方法
|
|
||||||
addToGroup(umo) {
|
|
||||||
if (!this.editingGroup.umos.includes(umo)) {
|
|
||||||
this.editingGroup.umos.push(umo)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
removeFromGroup(umo) {
|
|
||||||
const idx = this.editingGroup.umos.indexOf(umo)
|
|
||||||
if (idx > -1) {
|
|
||||||
this.editingGroup.umos.splice(idx, 1)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
addAllToGroup() {
|
|
||||||
this.unselectedUmos.forEach(umo => {
|
|
||||||
if (!this.editingGroup.umos.includes(umo)) {
|
|
||||||
this.editingGroup.umos.push(umo)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
},
|
|
||||||
|
|
||||||
removeAllFromGroup() {
|
|
||||||
this.editingGroup.umos = []
|
|
||||||
},
|
|
||||||
|
|
||||||
formatUmoShort(umo) {
|
|
||||||
// 简化显示:平台:类型:ID -> 只显示ID部分
|
|
||||||
const parts = umo.split(':')
|
|
||||||
if (parts.length >= 3) {
|
|
||||||
return `${parts[0]}:${parts[2]}`
|
|
||||||
}
|
|
||||||
return umo
|
|
||||||
},
|
|
||||||
|
|
||||||
async saveGroup() {
|
|
||||||
if (!this.editingGroup.name.trim()) {
|
|
||||||
this.showError('分组名称不能为空')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
let response
|
|
||||||
if (this.groupDialogMode === 'create') {
|
|
||||||
response = await axios.post('/api/session/group/create', {
|
|
||||||
name: this.editingGroup.name,
|
|
||||||
umos: this.editingGroup.umos
|
|
||||||
})
|
|
||||||
} else {
|
|
||||||
response = await axios.post('/api/session/group/update', {
|
|
||||||
id: this.editingGroup.id,
|
|
||||||
name: this.editingGroup.name,
|
|
||||||
umos: this.editingGroup.umos
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
if (response.data.status === 'ok') {
|
|
||||||
this.showSuccess(response.data.data.message)
|
|
||||||
this.groupDialog = false
|
|
||||||
await this.loadGroups()
|
|
||||||
} else {
|
|
||||||
this.showError(response.data.message)
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
this.showError(error.response?.data?.message || '保存分组失败')
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
async deleteGroup(group) {
|
|
||||||
if (!confirm(`确定要删除分组 "${group.name}" 吗?`)) return
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await axios.post('/api/session/group/delete', { id: group.id })
|
|
||||||
if (response.data.status === 'ok') {
|
|
||||||
this.showSuccess(response.data.data.message)
|
|
||||||
await this.loadGroups()
|
|
||||||
} else {
|
|
||||||
this.showError(response.data.message)
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
this.showError(error.response?.data?.message || '删除分组失败')
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
openGroupMemberDialog(group) {
|
|
||||||
this.groupMemberTarget = { ...group }
|
|
||||||
this.groupMemberDialog = true
|
|
||||||
},
|
|
||||||
|
|
||||||
async addSelectedToGroup(groupId) {
|
|
||||||
if (this.selectedItems.length === 0) {
|
|
||||||
this.showError('请先选择要添加的会话')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await axios.post('/api/session/group/update', {
|
|
||||||
id: groupId,
|
|
||||||
add_umos: this.selectedItems.map(item => item.umo)
|
|
||||||
})
|
|
||||||
if (response.data.status === 'ok') {
|
|
||||||
this.showSuccess(`已添加 ${this.selectedItems.length} 个会话到分组`)
|
|
||||||
await this.loadGroups()
|
|
||||||
} else {
|
|
||||||
this.showError(response.data.message)
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
this.showError(error.response?.data?.message || '添加失败')
|
|
||||||
}
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
@@ -1560,20 +1087,4 @@ code {
|
|||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.transfer-list {
|
|
||||||
max-height: 280px;
|
|
||||||
overflow-y: auto;
|
|
||||||
border: 1px solid rgba(0, 0, 0, 0.12);
|
|
||||||
border-radius: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.transfer-item {
|
|
||||||
cursor: pointer;
|
|
||||||
transition: background-color 0.15s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.transfer-item:hover {
|
|
||||||
background-color: rgba(0, 0, 0, 0.04);
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
+1
-2
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "AstrBot"
|
name = "AstrBot"
|
||||||
version = "4.12.1"
|
version = "4.11.4"
|
||||||
description = "Easy-to-use multi-platform LLM chatbot and development framework"
|
description = "Easy-to-use multi-platform LLM chatbot and development framework"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.10"
|
requires-python = ">=3.10"
|
||||||
@@ -60,7 +60,6 @@ dependencies = [
|
|||||||
"markitdown-no-magika[docx,xls,xlsx]>=0.1.2",
|
"markitdown-no-magika[docx,xls,xlsx]>=0.1.2",
|
||||||
"xinference-client",
|
"xinference-client",
|
||||||
"tenacity>=9.1.2",
|
"tenacity>=9.1.2",
|
||||||
"shipyard-python-sdk>=0.2.4",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[dependency-groups]
|
[dependency-groups]
|
||||||
|
|||||||
+1
-2
@@ -52,5 +52,4 @@ rank-bm25>=0.2.2
|
|||||||
jieba>=0.42.1
|
jieba>=0.42.1
|
||||||
markitdown-no-magika[docx,xls,xlsx]>=0.1.2
|
markitdown-no-magika[docx,xls,xlsx]>=0.1.2
|
||||||
xinference-client
|
xinference-client
|
||||||
tenacity>=9.1.2
|
tenacity>=9.1.2
|
||||||
shipyard-python-sdk>=0.2.4
|
|
||||||
Reference in New Issue
Block a user