Merge branch 'dev' into agent-fix-clean

This commit is contained in:
Gao Jinzhe
2026-03-14 23:17:10 +08:00
committed by GitHub
142 changed files with 10976 additions and 2799 deletions
+3 -3
View File
@@ -11,7 +11,7 @@ jobs:
runs-on: ubuntu-latest # 运行环境
steps:
- name: checkout
uses: actions/checkout@master
uses: actions/checkout@v6
- name: nodejs installation
uses: actions/setup-node@v6
with:
@@ -23,7 +23,7 @@ jobs:
run: npm run docs:build
working-directory: './docs'
- name: scp
uses: appleboy/scp-action@master
uses: appleboy/scp-action@v1.0.0
with:
host: ${{ secrets.HOST_NEKO }}
username: ${{ secrets.USERNAME }}
@@ -31,7 +31,7 @@ jobs:
source: 'docs/.vitepress/dist/*'
target: '/tmp/'
- name: script
uses: appleboy/ssh-action@master
uses: appleboy/ssh-action@v1.2.5
with:
host: ${{ secrets.HOST_NEKO }}
username: ${{ secrets.USERNAME }}
+1 -1
View File
@@ -45,7 +45,7 @@ jobs:
- name: Create GitHub Release
if: github.event_name == 'push'
uses: ncipollo/release-action@v1
uses: ncipollo/release-action@v1.20.0
with:
tag: release-${{ github.sha }}
owner: AstrBotDevs
+10 -10
View File
@@ -64,20 +64,20 @@ jobs:
echo "build_date=$build_date" >> $GITHUB_OUTPUT
- name: Set QEMU
uses: docker/setup-qemu-action@v4
uses: docker/setup-qemu-action@v4.0.0
- name: Set Docker Buildx
uses: docker/setup-buildx-action@v4
uses: docker/setup-buildx-action@v4.0.0
- name: Log in to DockerHub
uses: docker/login-action@v4
uses: docker/login-action@v4.0.0
with:
username: ${{ secrets.DOCKER_HUB_USERNAME }}
password: ${{ secrets.DOCKER_HUB_PASSWORD }}
- name: Login to GitHub Container Registry
if: env.HAS_GHCR_TOKEN == 'true'
uses: docker/login-action@v4
uses: docker/login-action@v4.0.0
with:
registry: ghcr.io
username: ${{ env.GHCR_OWNER }}
@@ -98,7 +98,7 @@ jobs:
echo "EOF" >> $GITHUB_OUTPUT
- name: Build and Push Nightly Image
uses: docker/build-push-action@v7
uses: docker/build-push-action@v7.0.0
with:
context: .
platforms: linux/amd64,linux/arm64
@@ -163,27 +163,27 @@ jobs:
cp -r dashboard/dist data/
- name: Set QEMU
uses: docker/setup-qemu-action@v4
uses: docker/setup-qemu-action@v4.0.0
- name: Set Docker Buildx
uses: docker/setup-buildx-action@v4
uses: docker/setup-buildx-action@v4.0.0
- name: Log in to DockerHub
uses: docker/login-action@v4
uses: docker/login-action@v4.0.0
with:
username: ${{ secrets.DOCKER_HUB_USERNAME }}
password: ${{ secrets.DOCKER_HUB_PASSWORD }}
- name: Login to GitHub Container Registry
if: env.HAS_GHCR_TOKEN == 'true'
uses: docker/login-action@v4
uses: docker/login-action@v4.0.0
with:
registry: ghcr.io
username: ${{ env.GHCR_OWNER }}
password: ${{ secrets.GHCR_GITHUB_TOKEN }}
- name: Build and Push Release Image
uses: docker/build-push-action@v7
uses: docker/build-push-action@v7.0.0
with:
context: .
platforms: linux/amd64,linux/arm64
+1 -1
View File
@@ -50,7 +50,7 @@ jobs:
echo "tag=$tag" >> "$GITHUB_OUTPUT"
- name: Setup pnpm
uses: pnpm/action-setup@v4
uses: pnpm/action-setup@v4.4.0
with:
version: 10.28.2
+1 -1
View File
@@ -1 +1 @@
3.12
3.12
+1 -1
View File
@@ -78,7 +78,7 @@ For users who want to quickly experience AstrBot, are familiar with command-line
```bash
uv tool install astrbot
astrbot init # Only execute this command for the first time to initialize the environment
astrbot
astrbot run
```
> Requires [uv](https://docs.astral.sh/uv/) to be installed.
+1 -1
View File
@@ -78,7 +78,7 @@ Pour les utilisateurs qui veulent découvrir AstrBot rapidement, qui sont famili
```bash
uv tool install astrbot
astrbot init # Exécutez cette commande uniquement la première fois pour initialiser l'environnement
astrbot
astrbot run
```
> [uv](https://docs.astral.sh/uv/) doit être installé.
+1 -1
View File
@@ -78,7 +78,7 @@ AstrBot を素早く試したいユーザーで、コマンドラインに慣れ
```bash
uv tool install astrbot
astrbot init # 初回のみ実行して環境を初期化します
astrbot
astrbot run
```
> [uv](https://docs.astral.sh/uv/) のインストールが必要です。
+1 -1
View File
@@ -78,7 +78,7 @@ AstrBot — это универсальная платформа Agent-чатб
```bash
uv tool install astrbot
astrbot init # Выполните эту команду только при первом запуске для инициализации окружения
astrbot
astrbot run
```
> Требуется установленный [uv](https://docs.astral.sh/uv/).
+1 -1
View File
@@ -78,7 +78,7 @@ AstrBot 是一個開源的一站式 Agent 聊天機器人平台,可接入主
```bash
uv tool install astrbot
astrbot init # 僅首次執行此命令以初始化環境
astrbot
astrbot run
```
> 需要安裝 [uv](https://docs.astral.sh/uv/)。
+1 -1
View File
@@ -78,7 +78,7 @@ AstrBot 是一个开源的一站式 Agentic 个人和群聊助手,可在 QQ、
```bash
uv tool install astrbot
astrbot init # 仅首次执行此命令以初始化环境
astrbot
astrbot run
```
> 需要安装 [uv](https://docs.astral.sh/uv/)。
+1 -1
View File
@@ -1 +1 @@
__version__ = "4.19.5"
__version__ = "4.20.0"
+7 -2
View File
@@ -30,8 +30,13 @@ async def initialize_astrbot(astrbot_root: Path) -> None:
for name, path in paths.items():
path.mkdir(parents=True, exist_ok=True)
click.echo(f"{'Created' if not path.exists() else 'Directory exists'}: {path}")
await check_dashboard(astrbot_root / "data")
if click.confirm(
"是否需要集成式 WebUI?(个人电脑推荐,服务器不推荐)",
default=True,
):
await check_dashboard(astrbot_root)
else:
click.echo("你可以使用在线面版(v4.14.4+),填写后端地址的方式来控制。")
@click.command()
+14 -3
View File
@@ -15,7 +15,8 @@ async def run_astrbot(astrbot_root: Path) -> None:
from astrbot.core import LogBroker, LogManager, db_helper, logger
from astrbot.core.initial_loader import InitialLoader
await check_dashboard(astrbot_root / "data")
if os.environ.get("DASHBOARD_ENABLE") == "True":
await check_dashboard(astrbot_root)
log_broker = LogBroker()
LogManager.set_queue_handler(logger, log_broker)
@@ -27,9 +28,16 @@ async def run_astrbot(astrbot_root: Path) -> None:
@click.option("--reload", "-r", is_flag=True, help="Auto-reload plugins")
@click.option("--host", "-H", help="AstrBot Dashboard Host", required=False, type=str)
@click.option("--port", "-p", help="AstrBot Dashboard port", required=False, type=str)
@click.option(
"--backend-only",
is_flag=True,
default=False,
help="Disable WebUI, run backend only",
)
@click.command()
def run(reload: bool, port: str) -> None:
def run(reload: bool, host: str, port: str, backend_only: bool) -> None:
"""Run AstrBot"""
try:
os.environ["ASTRBOT_CLI"] = "1"
@@ -43,8 +51,11 @@ def run(reload: bool, port: str) -> None:
os.environ["ASTRBOT_ROOT"] = str(astrbot_root)
sys.path.insert(0, str(astrbot_root))
if port:
if port is not None:
os.environ["DASHBOARD_PORT"] = port
if host is not None:
os.environ["DASHBOARD_HOST"] = host
os.environ["DASHBOARD_ENABLE"] = str(not backend_only)
if reload:
click.echo("Plugin auto-reload enabled")
+4 -4
View File
@@ -47,7 +47,7 @@ async def check_dashboard(astrbot_root: Path) -> None:
click.echo("Installing dashboard...")
await download_dashboard(
path="data/dashboard.zip",
extract_path=str(astrbot_root),
extract_path=str(astrbot_root / "data"),
version=f"v{VERSION}",
latest=False,
)
@@ -62,7 +62,7 @@ async def check_dashboard(astrbot_root: Path) -> None:
click.echo(f"Dashboard version: {version}")
await download_dashboard(
path="data/dashboard.zip",
extract_path=str(astrbot_root),
extract_path=str(astrbot_root / "data"),
version=f"v{VERSION}",
latest=False,
)
@@ -73,8 +73,8 @@ async def check_dashboard(astrbot_root: Path) -> None:
click.echo("Initializing dashboard directory...")
try:
await download_dashboard(
path=str(astrbot_root / "dashboard.zip"),
extract_path=str(astrbot_root),
path=str(astrbot_root / "data" / "dashboard.zip"),
extract_path=str(astrbot_root / "data"),
version=f"v{VERSION}",
latest=False,
)
+5
View File
@@ -739,9 +739,14 @@ def _plugin_tool_fix(event: AstrMessageEvent, req: ProviderRequest) -> None:
continue
mp = tool.handler_module_path
if not mp:
# 没有 plugin 归属信息的工具(如 subagent transfer_to_*
# 不应受到会话插件过滤影响。
new_tool_set.add_tool(tool)
continue
plugin = star_map.get(mp)
if not plugin:
# 无法解析插件归属时,保守保留工具,避免误过滤。
new_tool_set.add_tool(tool)
continue
if plugin.name in event.plugins_name or plugin.reserved:
new_tool_set.add_tool(tool)
+1 -1
View File
@@ -5,7 +5,7 @@ from typing import Any, TypedDict
from astrbot.core.utils.astrbot_path import get_astrbot_data_path
VERSION = "4.19.5"
VERSION = "4.20.0"
DB_PATH = os.path.join(get_astrbot_data_path(), "data_v4.db")
WEBHOOK_SUPPORTED_PLATFORMS = [
+7
View File
@@ -647,6 +647,13 @@ class BaseDatabase(abc.ABC):
"""Get a Platform session by its ID."""
...
@abc.abstractmethod
async def get_platform_sessions_by_ids(
self, session_ids: list[str]
) -> list[PlatformSession]:
"""Get platform sessions by IDs."""
...
@abc.abstractmethod
async def get_platform_sessions_by_creator(
self,
+15
View File
@@ -1417,6 +1417,21 @@ class SQLiteDatabase(BaseDatabase):
result = await session.execute(query)
return result.scalar_one_or_none()
async def get_platform_sessions_by_ids(
self, session_ids: list[str]
) -> list[PlatformSession]:
"""Get platform sessions by IDs."""
if not session_ids:
return []
async with self.get_db() as session:
session: AsyncSession
query = select(PlatformSession).where(
col(PlatformSession.session_id).in_(session_ids)
)
result = await session.execute(query)
return list(result.scalars().all())
async def get_platform_sessions_by_creator(
self,
creator: str,
+3 -3
View File
@@ -96,10 +96,10 @@ class Plain(BaseMessageComponent):
def __init__(self, text: str, convert: bool = True, **_) -> None:
super().__init__(text=text, convert=convert, **_)
def toDict(self):
return {"type": "text", "data": {"text": self.text.strip()}}
def toDict(self) -> dict:
return {"type": "text", "data": {"text": self.text}}
async def to_dict(self):
async def to_dict(self) -> dict:
return {"type": "text", "data": {"text": self.text}}
@@ -6,6 +6,7 @@ from aiocqhttp import CQHttp, Event
from astrbot.api.event import AstrMessageEvent, MessageChain
from astrbot.api.message_components import (
At,
BaseMessageComponent,
File,
Image,
@@ -70,11 +71,19 @@ class AiocqhttpMessageEvent(AstrMessageEvent):
"""解析成 OneBot json 格式"""
ret = []
for segment in message_chain.chain:
if isinstance(segment, Plain):
if isinstance(segment, At):
# At 组件后插入一个空格,避免与后续文本粘连
d = await AiocqhttpMessageEvent._from_segment_to_dict(segment)
ret.append(d)
ret.append({"type": "text", "data": {"text": " "}})
elif isinstance(segment, Plain):
if not segment.text.strip():
continue
d = await AiocqhttpMessageEvent._from_segment_to_dict(segment)
ret.append(d)
d = await AiocqhttpMessageEvent._from_segment_to_dict(segment)
ret.append(d)
else:
d = await AiocqhttpMessageEvent._from_segment_to_dict(segment)
ret.append(d)
return ret
@classmethod
@@ -51,6 +51,7 @@ class QQOfficialMessageEvent(AstrMessageEvent):
VIDEO_FILE_TYPE = 2
VOICE_FILE_TYPE = 3
FILE_FILE_TYPE = 4
STREAM_MARKDOWN_NEWLINE_ERROR = "流式消息md分片需要\\n结束"
def __init__(
self,
@@ -69,35 +70,71 @@ class QQOfficialMessageEvent(AstrMessageEvent):
await self._post_send()
async def send_streaming(self, generator, use_fallback: bool = False):
"""流式输出仅支持消息列表私聊"""
"""流式输出仅支持消息列表私聊C2C),其他消息源退化为普通发送"""
# 先标记事件层“已执行发送操作”,避免异常路径遗漏
await super().send_streaming(generator, use_fallback)
# QQ C2C 流式协议:开始/中间分片使用 state=1,结束分片使用 state=10
stream_payload = {"state": 1, "id": None, "index": 0, "reset": False}
last_edit_time = 0 # 上次编辑消息的时间
throttle_interval = 1 # 编辑消息的间隔时间 (秒)
last_edit_time = 0 # 上次发送分片的时间
throttle_interval = 1 # 分片间最短间隔 (秒)
ret = None
source = (
self.message_obj.raw_message
) # 提前获取,避免 generator 为空时 NameError
try:
async for chain in generator:
source = self.message_obj.raw_message
if not isinstance(source, botpy.message.C2CMessage):
# 非 C2C 场景:直接累积,最后统一发
if not self.send_buffer:
self.send_buffer = chain
else:
self.send_buffer.chain.extend(chain.chain)
continue
# ---- C2C 流式场景 ----
# tool_call break 信号:工具开始执行,先把已有 buffer 以 state=10 结束当前流式段
if chain.type == "break":
if self.send_buffer:
stream_payload["state"] = 10
ret = await self._post_send(stream=stream_payload)
ret_id = self._extract_response_message_id(ret)
if ret_id is not None:
stream_payload["id"] = ret_id
# 重置 stream_payload,为下一段流式做准备
stream_payload = {
"state": 1,
"id": None,
"index": 0,
"reset": False,
}
last_edit_time = 0
continue
# 累积内容
if not self.send_buffer:
self.send_buffer = chain
else:
self.send_buffer.chain.extend(chain.chain)
if isinstance(source, botpy.message.C2CMessage):
# 真流式传输
current_time = asyncio.get_running_loop().time()
time_since_last_edit = current_time - last_edit_time
if time_since_last_edit >= throttle_interval:
ret = cast(
message.Message,
await self._post_send(stream=stream_payload),
)
stream_payload["index"] += 1
stream_payload["id"] = ret["id"]
last_edit_time = asyncio.get_running_loop().time()
# 节流:按时间间隔发送中间分片
current_time = asyncio.get_running_loop().time()
if current_time - last_edit_time >= throttle_interval:
ret = cast(
message.Message,
await self._post_send(stream=stream_payload),
)
stream_payload["index"] += 1
ret_id = self._extract_response_message_id(ret)
if ret_id is not None:
stream_payload["id"] = ret_id
last_edit_time = asyncio.get_running_loop().time()
self.send_buffer = None # 清空已发送的分片,避免下次重复发送旧内容
if isinstance(source, botpy.message.C2CMessage):
# 结束流式对话,并且传输 buffer 中剩余的消息
# 结束流式对话,发送 buffer 中剩余内容
stream_payload["state"] = 10
ret = await self._post_send(stream=stream_payload)
else:
@@ -105,9 +142,22 @@ class QQOfficialMessageEvent(AstrMessageEvent):
except Exception as e:
logger.error(f"发送流式消息时出错: {e}", exc_info=True)
# 避免累计内容在异常后被整包重复发送:仅清理缓存,不做非流式整包兜底
# 如需兜底,应该只发送未发送 delta(后续可继续优化)
self.send_buffer = None
return await super().send_streaming(generator, use_fallback)
return None
@staticmethod
def _extract_response_message_id(ret) -> str | None:
"""兼容 qq-botpy 返回 Message 对象或 dict 两种形态。"""
if ret is None:
return None
if isinstance(ret, dict):
ret_id = ret.get("id")
return str(ret_id) if ret_id is not None else None
ret_id = getattr(ret, "id", None)
return str(ret_id) if ret_id is not None else None
async def _post_send(self, stream: dict | None = None):
if not self.send_buffer:
@@ -135,6 +185,11 @@ class QQOfficialMessageEvent(AstrMessageEvent):
file_name,
) = await QQOfficialMessageEvent._parse_to_qqofficial(self.send_buffer)
# C2C 流式仅用于文本分片,富媒体时降级为普通发送,避免平台侧流式校验报错。
if stream and (image_base64 or record_file_path):
logger.debug("[QQOfficial] 检测到富媒体,降级为非流式发送。")
stream = None
if (
not plain_text
and not image_base64
@@ -145,6 +200,17 @@ class QQOfficialMessageEvent(AstrMessageEvent):
):
return None
# QQ C2C 流式 API 说明:
# - 开始/中间分片(state=1):增量追加内容,不需要 \n(加了会导致强制换行)
# - 最终分片(state=10):结束流,content 必须以 \n 结尾(QQ API 要求)
if (
stream
and stream.get("state") == 10
and plain_text
and not plain_text.endswith("\n")
):
plain_text = plain_text + "\n"
payload: dict = {
# "content": plain_text,
"markdown": MarkdownPayload(content=plain_text) if plain_text else None,
@@ -214,6 +280,7 @@ class QQOfficialMessageEvent(AstrMessageEvent):
),
payload=payload,
plain_text=plain_text,
stream=stream,
)
case botpy.message.C2CMessage():
@@ -270,6 +337,7 @@ class QQOfficialMessageEvent(AstrMessageEvent):
),
payload=payload,
plain_text=plain_text,
stream=stream,
)
else:
ret = await self._send_with_markdown_fallback(
@@ -279,6 +347,7 @@ class QQOfficialMessageEvent(AstrMessageEvent):
),
payload=payload,
plain_text=plain_text,
stream=stream,
)
logger.debug(f"Message sent to C2C: {ret}")
@@ -294,6 +363,7 @@ class QQOfficialMessageEvent(AstrMessageEvent):
),
payload=payload,
plain_text=plain_text,
stream=stream,
)
case botpy.message.DirectMessage():
@@ -308,6 +378,7 @@ class QQOfficialMessageEvent(AstrMessageEvent):
),
payload=payload,
plain_text=plain_text,
stream=stream,
)
case _:
@@ -324,10 +395,31 @@ class QQOfficialMessageEvent(AstrMessageEvent):
send_func,
payload: dict,
plain_text: str,
stream: dict | None = None,
):
try:
return await send_func(payload)
except botpy.errors.ServerError as err:
# QQ 流式 markdown 分片校验:内容必须以换行结尾。
# 某些边界场景服务端仍可能判定失败,这里做一次修正重试。
if stream and self.STREAM_MARKDOWN_NEWLINE_ERROR in str(err):
retry_payload = payload.copy()
markdown_payload = retry_payload.get("markdown")
if isinstance(markdown_payload, dict):
md_content = cast(str, markdown_payload.get("content", "") or "")
if md_content and not md_content.endswith("\n"):
retry_payload["markdown"] = {"content": md_content + "\n"}
content = cast(str | None, retry_payload.get("content"))
if content and not content.endswith("\n"):
retry_payload["content"] = content + "\n"
logger.warning(
"[QQOfficial] 流式 markdown 分片换行校验失败,已修正后重试一次。"
)
return await send_func(retry_payload)
if (
self.MARKDOWN_NOT_ALLOWED_ERROR not in str(err)
or not payload.get("markdown")
@@ -339,10 +431,14 @@ class QQOfficialMessageEvent(AstrMessageEvent):
"[QQOfficial] markdown 发送被拒绝,回退到 content 模式重试。"
)
fallback_payload = payload.copy()
fallback_payload["markdown"] = None
fallback_payload.pop("markdown", None)
fallback_payload["content"] = plain_text
if fallback_payload.get("msg_type") == 2:
fallback_payload["msg_type"] = 0
if stream:
fallback_content = cast(str, fallback_payload.get("content") or "")
if fallback_content and not fallback_content.endswith("\n"):
fallback_payload["content"] = fallback_content + "\n"
return await send_func(fallback_payload)
async def upload_group_and_c2c_image(
@@ -460,13 +556,21 @@ class QQOfficialMessageEvent(AstrMessageEvent):
) -> message.Message:
payload = locals()
payload.pop("self", None)
# QQ API does not accept stream.id=None; remove it when not yet assigned
if "stream" in payload and payload["stream"] is not None:
stream_data = dict(payload["stream"])
if stream_data.get("id") is None:
stream_data.pop("id", None)
payload["stream"] = stream_data
route = Route("POST", "/v2/users/{openid}/messages", openid=openid)
result = await self.bot.api._http.request(route, json=payload)
if result is None:
logger.warning("[QQOfficial] post_c2c_message: API 返回 None,跳过本次发送")
return None
if not isinstance(result, dict):
raise RuntimeError(
f"Failed to post c2c message, response is not dict: {result}"
)
logger.error(f"[QQOfficial] post_c2c_message: 响应不是 dict: {result}")
return None
return message.Message(**result)
@@ -212,6 +212,7 @@ class QQOfficialPlatformAdapter(Platform):
if media:
payload["media"] = media
payload["msg_type"] = 7
payload.pop("msg_id", None)
if file_source:
media = await QQOfficialMessageEvent.upload_group_and_c2c_media(
send_helper, # type: ignore
@@ -223,6 +224,7 @@ class QQOfficialPlatformAdapter(Platform):
if media:
payload["media"] = media
payload["msg_type"] = 7
payload.pop("msg_id", None)
ret = await self.client.api.post_group_message(
group_openid=session.session_id,
**payload,
@@ -266,6 +268,9 @@ class QQOfficialPlatformAdapter(Platform):
if media:
payload["media"] = media
payload["msg_type"] = 7
# QQ API rejects msg_id for media (video/file) messages sent
# via the proactive tool-call path; remove it to avoid 越权 error.
payload.pop("msg_id", None)
if file_source:
media = await QQOfficialMessageEvent.upload_group_and_c2c_media(
send_helper, # type: ignore
@@ -277,6 +282,7 @@ class QQOfficialPlatformAdapter(Platform):
if media:
payload["media"] = media
payload["msg_type"] = 7
payload.pop("msg_id", None)
ret = await QQOfficialMessageEvent.post_c2c_message(
send_helper, # type: ignore
@@ -289,8 +289,8 @@ class TelegramPlatformAdapter(Platform):
else:
message.type = MessageType.GROUP_MESSAGE
message.group_id = str(update.message.chat.id)
if update.message.message_thread_id:
# Topic Group
if update.message.is_topic_message and update.message.message_thread_id:
# Telegram Topic Group: include thread id to isolate per-topic sessions.
message.group_id += "#" + str(update.message.message_thread_id)
message.session_id = message.group_id
message.message_id = str(update.message.message_id)
@@ -629,7 +629,8 @@ class ProviderOpenAIOfficial(Provider):
# 最后一次不等待
if retry_cnt < max_retries - 1:
await asyncio.sleep(1)
available_api_keys.remove(chosen_key)
if chosen_key in available_api_keys:
available_api_keys.remove(chosen_key)
if len(available_api_keys) > 0:
chosen_key = random.choice(available_api_keys)
return (
+59 -9
View File
@@ -1,12 +1,14 @@
"""插件的重载、启停、安装、卸载等操作。"""
import asyncio
import contextlib
import functools
import inspect
import json
import logging
import os
import sys
import tempfile
import traceback
from types import ModuleType
@@ -29,12 +31,12 @@ from astrbot.core.utils.astrbot_path import (
get_astrbot_config_path,
get_astrbot_path,
get_astrbot_plugin_path,
get_astrbot_temp_path,
)
from astrbot.core.utils.io import remove_dir
from astrbot.core.utils.metrics import Metric
from astrbot.core.utils.requirements_utils import (
RequirementsPrecheckFailed,
find_missing_requirements_or_raise,
plan_missing_requirements_install,
)
from . import StarMetadata
@@ -74,30 +76,78 @@ class PluginDependencyInstallError(Exception):
self.error = error
@contextlib.contextmanager
def _temporary_filtered_requirements_file(
*,
install_lines: tuple[str, ...],
):
filtered_requirements_path: str | None = None
temp_dir = get_astrbot_temp_path()
try:
os.makedirs(temp_dir, exist_ok=True)
with tempfile.NamedTemporaryFile(
mode="w",
suffix="_plugin_requirements.txt",
delete=False,
dir=temp_dir,
encoding="utf-8",
) as filtered_requirements_file:
filtered_requirements_file.write("\n".join(install_lines) + "\n")
filtered_requirements_path = filtered_requirements_file.name
yield filtered_requirements_path
finally:
if filtered_requirements_path and os.path.exists(filtered_requirements_path):
try:
os.remove(filtered_requirements_path)
except OSError as exc:
logger.warning(
"删除临时插件依赖文件失败:%s(路径:%s",
exc,
filtered_requirements_path,
)
async def _install_requirements_with_precheck(
*,
plugin_label: str,
requirements_path: str,
) -> None:
try:
missing = find_missing_requirements_or_raise(requirements_path)
except RequirementsPrecheckFailed:
install_plan = plan_missing_requirements_install(requirements_path)
if install_plan is None:
logger.info(
f"正在安装插件 {plugin_label} 的依赖库(预检查失败,回退到完整安装): "
f"正在安装插件 {plugin_label} 的依赖库(缺失依赖预检查不可裁剪,回退到完整安装): "
f"{requirements_path}"
)
await pip_installer.install(requirements_path=requirements_path)
return
if not missing:
if not install_plan.missing_names:
logger.info(f"插件 {plugin_label} 的依赖已满足,跳过安装。")
return
if not install_plan.install_lines:
fallback_reason = install_plan.fallback_reason or "unknown reason"
logger.info(
"检测到插件 %s 缺失依赖,但无法安全裁剪 requirements,回退到完整安装: %s (%s)",
plugin_label,
requirements_path,
fallback_reason,
)
await pip_installer.install(requirements_path=requirements_path)
return
logger.info(
f"检测到插件 {plugin_label} 缺失依赖,正在按 requirements.txt 安装: "
f"{requirements_path} -> {sorted(missing)}"
f"{requirements_path} -> {sorted(install_plan.missing_names)}"
)
await pip_installer.install(requirements_path=requirements_path)
with _temporary_filtered_requirements_file(
install_lines=install_plan.install_lines,
) as filtered_requirements_path:
await pip_installer.install(requirements_path=filtered_requirements_path)
class PluginManager:
+1 -1
View File
@@ -30,7 +30,7 @@ class CreateActiveCronTool(FunctionTool[AstrAgentContext]):
"properties": {
"cron_expression": {
"type": "string",
"description": "Cron expression defining recurring schedule (e.g., '0 8 * * *').",
"description": "Cron expression defining recurring schedule (e.g., '0 8 * * *' or '0 23 * * mon-fri'). Prefer named weekdays like 'mon-fri' or 'sat,sun' instead of numeric day-of-week ranges such as '1-5' to avoid ambiguity across cron implementations.",
},
"run_at": {
"type": "string",
+42 -5
View File
@@ -1,3 +1,4 @@
import asyncio
import base64
import logging
import os
@@ -7,6 +8,7 @@ import ssl
import time
import uuid
import zipfile
from ipaddress import IPv4Address, IPv6Address, ip_address
from pathlib import Path
import aiohttp
@@ -206,18 +208,53 @@ def file_to_base64(file_path: str) -> str:
return "base64://" + base64_str
def get_local_ip_addresses():
def get_local_ip_addresses() -> list[IPv4Address | IPv6Address]:
net_interfaces = psutil.net_if_addrs()
network_ips = []
network_ips: list[IPv4Address | IPv6Address] = []
for interface, addrs in net_interfaces.items():
for _, addrs in net_interfaces.items():
for addr in addrs:
if addr.family == socket.AF_INET: # 使用 socket.AF_INET 代替 psutil.AF_INET
network_ips.append(addr.address)
if addr.family == socket.AF_INET:
network_ips.append(ip_address(addr.address))
elif addr.family == socket.AF_INET6:
# 过滤掉 IPv6 的 link-local 地址(fe80:...
ip = ip_address(addr.address.split("%")[0]) # 处理带 zone index 的情况
if not ip.is_link_local:
network_ips.append(ip)
return network_ips
async def get_public_ip_address() -> list[IPv4Address | IPv6Address]:
urls = [
"https://api64.ipify.org",
"https://ident.me",
"https://ifconfig.me",
"https://icanhazip.com",
]
found_ips: dict[int, IPv4Address | IPv6Address] = {}
async def fetch(session: aiohttp.ClientSession, url: str):
try:
async with session.get(url, timeout=3) as resp:
if resp.status == 200:
raw_ip = (await resp.text()).strip()
ip = ip_address(raw_ip)
if ip.version not in found_ips:
found_ips[ip.version] = ip
except Exception as e:
# Ignore errors from individual services so that a single failing
# endpoint does not prevent discovering the public IP from others.
logger.debug("Failed to fetch public IP from %s: %s", url, e)
async with aiohttp.ClientSession() as session:
tasks = [fetch(session, url) for url in urls]
await asyncio.gather(*tasks)
# 返回找到的所有 IP 对象列表
return list(found_ips.values())
async def get_dashboard_version():
# First check user data directory (manually updated / downloaded dashboard).
dist_dir = os.path.join(get_astrbot_data_path(), "dist")
+81 -3
View File
@@ -4,7 +4,7 @@ import os
import re
import shlex
import sys
from collections.abc import Iterable, Iterator
from collections.abc import Iterable, Iterator, Sequence
from dataclasses import dataclass
from packaging.requirements import InvalidRequirement, Requirement
@@ -29,6 +29,13 @@ class ParsedPackageInput:
requirement_names: frozenset[str]
@dataclass(frozen=True)
class MissingRequirementsPlan:
missing_names: frozenset[str]
install_lines: tuple[str, ...]
fallback_reason: str | None = None
def canonicalize_distribution_name(name: str) -> str:
return re.sub(r"[-_.]+", "-", name).strip("-").lower()
@@ -364,8 +371,8 @@ def _load_requirement_lines_for_precheck(
None,
)
if fallback_line is not None:
logger.warning(
"预检查缺失依赖失败,将回退到完整安装: unresolved direct reference in %s: %s",
logger.info(
"缺失依赖预检查发现无法安全裁剪的 option/direct-reference 行,将回退到完整安装: %s (%s)",
requirements_path,
fallback_line,
)
@@ -381,6 +388,13 @@ def find_missing_requirements(requirements_path: str) -> set[str] | None:
if not can_precheck or requirement_lines is None:
return None
return find_missing_requirements_from_lines(requirement_lines)
def find_missing_requirements_from_lines(
requirement_lines: Sequence[str],
) -> set[str] | None:
required = list(iter_requirements(lines=requirement_lines))
if not required:
return set()
@@ -401,6 +415,70 @@ def find_missing_requirements(requirements_path: str) -> set[str] | None:
return missing
def build_missing_requirements_install_lines(
requirements_path: str,
requirement_lines: Sequence[str],
missing_names: set[str] | frozenset[str],
) -> tuple[str, ...] | None:
wanted_names = set(missing_names)
install_lines: list[str] = []
for line in requirement_lines:
parsed = _parse_requirement_line(line)
if parsed is None:
if looks_like_direct_reference(line) or line.startswith(("-", "--")):
logger.debug(
"缺失依赖行筛选回退到完整安装:requirements 中包含无法安全裁剪的 option/direct-reference 行: %s (%s)",
requirements_path,
line,
)
return None
continue
name, _specifier = parsed
if name in wanted_names:
install_lines.append(line)
return tuple(install_lines)
def plan_missing_requirements_install(
requirements_path: str,
) -> MissingRequirementsPlan | None:
can_precheck, requirement_lines = _load_requirement_lines_for_precheck(
requirements_path
)
if not can_precheck or requirement_lines is None:
return None
missing = find_missing_requirements_from_lines(requirement_lines)
if missing is None:
return None
install_lines = build_missing_requirements_install_lines(
requirements_path,
requirement_lines,
missing,
)
if install_lines is None:
return None
if missing and not install_lines:
logger.warning(
"预检查缺失依赖成功,但无法映射到可安装 requirement 行,将回退到完整安装: %s -> %s",
requirements_path,
sorted(missing),
)
return MissingRequirementsPlan(
missing_names=frozenset(missing),
install_lines=(),
fallback_reason="unmapped missing requirement names",
)
return MissingRequirementsPlan(
missing_names=frozenset(missing),
install_lines=install_lines,
)
def find_missing_requirements_or_raise(requirements_path: str) -> set[str]:
missing = find_missing_requirements(requirements_path)
if missing is None:
+7
View File
@@ -9,16 +9,19 @@ from .conversation import ConversationRoute
from .cron import CronRoute
from .file import FileRoute
from .knowledge_base import KnowledgeBaseRoute
from .live_chat import LiveChatRoute
from .log import LogRoute
from .open_api import OpenApiRoute
from .persona import PersonaRoute
from .platform import PlatformRoute
from .plugin import PluginRoute
from .route import Response, RouteContext
from .session_management import SessionManagementRoute
from .skills import SkillsRoute
from .stat import StatRoute
from .static_file import StaticFileRoute
from .subagent import SubAgentRoute
from .t2i import T2iRoute
from .tools import ToolsRoute
from .update import UpdateRoute
@@ -46,4 +49,8 @@ __all__ = [
"ToolsRoute",
"SkillsRoute",
"UpdateRoute",
"T2iRoute",
"LiveChatRoute",
"Response",
"RouteContext",
]
+2 -1
View File
@@ -82,7 +82,8 @@ class AuthRoute(Route):
def generate_jwt(self, username):
payload = {
"username": username,
"exp": datetime.datetime.utcnow() + datetime.timedelta(days=7),
"exp": datetime.datetime.now(datetime.timezone.utc)
+ datetime.timedelta(days=7),
}
jwt_token = self.config["dashboard"].get("jwt_secret", None)
if not jwt_token:
+11 -1
View File
@@ -977,7 +977,17 @@ class BackupRoute(Route):
if not jwt_secret:
return Response().error("服务器配置错误").__dict__
jwt.decode(token, jwt_secret, algorithms=["HS256"])
# Verify JWT token with strict security options
jwt.decode(
token,
jwt_secret,
algorithms=["HS256"],
options={
"require": ["exp"], # Require expiration claim
"verify_signature": True, # Explicitly verify signature
"verify_exp": True, # Verify expiration
},
)
except jwt.ExpiredSignatureError:
return Response().error("Token 已过期,请刷新页面后重试").__dict__
except jwt.InvalidTokenError:
+66 -13
View File
@@ -51,6 +51,7 @@ class ChatRoute(Route):
"/chat/get_session": ("GET", self.get_session),
"/chat/stop": ("POST", self.stop_session),
"/chat/delete_session": ("GET", self.delete_webchat_session),
"/chat/batch_delete_sessions": ("POST", self.batch_delete_sessions),
"/chat/update_session_display_name": (
"POST",
self.update_session_display_name,
@@ -578,19 +579,9 @@ class ChatRoute(Route):
return Response().ok(data={"stopped_count": stopped_count}).__dict__
async def delete_webchat_session(self):
"""Delete a Platform session and all its related data."""
session_id = request.args.get("session_id")
if not session_id:
return Response().error("Missing key: session_id").__dict__
username = g.get("username", "guest")
# 验证会话是否存在且属于当前用户
session = await self.db.get_platform_session_by_id(session_id)
if not session:
return Response().error(f"Session {session_id} not found").__dict__
if session.creator != username:
return Response().error("Permission denied").__dict__
async def _delete_session_internal(self, session, username: str) -> None:
"""Delete a single session and all its related data."""
session_id = session.session_id
# 删除该会话下的所有对话
message_type = "GroupMessage" if session.is_group else "FriendMessage"
@@ -632,8 +623,70 @@ class ChatRoute(Route):
# 删除会话
await self.db.delete_platform_session(session_id)
async def delete_webchat_session(self):
"""Delete a Platform session and all its related data."""
session_id = request.args.get("session_id")
if not session_id:
return Response().error("Missing key: session_id").__dict__
username = g.get("username", "guest")
session = await self.db.get_platform_session_by_id(session_id)
if not session:
return Response().error(f"Session {session_id} not found").__dict__
if session.creator != username:
return Response().error("Permission denied").__dict__
await self._delete_session_internal(session, username)
return Response().ok().__dict__
async def batch_delete_sessions(self):
"""Batch delete multiple Platform sessions."""
post_data = await request.json
if post_data is None:
return Response().error("Missing JSON body").__dict__
if not isinstance(post_data, dict):
return Response().error("Invalid JSON body: expected object").__dict__
session_ids = post_data.get("session_ids")
if not session_ids or not isinstance(session_ids, list):
return Response().error("Missing or invalid key: session_ids").__dict__
username = g.get("username", "guest")
sessions = await self.db.get_platform_sessions_by_ids(session_ids)
sessions_by_id = {session.session_id: session for session in sessions}
deleted_count = 0
failed_items = []
for sid in session_ids:
session = sessions_by_id.get(sid)
if not session:
failed_items.append({"session_id": sid, "reason": "not found"})
continue
if session.creator != username:
failed_items.append({"session_id": sid, "reason": "permission denied"})
continue
try:
await self._delete_session_internal(session, username)
deleted_count += 1
sessions_by_id.pop(sid, None)
except Exception:
logger.warning("Failed to delete session %s", sid)
failed_items.append({"session_id": sid, "reason": "internal_error"})
return (
Response()
.ok(
data={
"deleted_count": deleted_count,
"failed_count": len(failed_items),
"failed_items": failed_items,
}
)
.__dict__
)
def _extract_attachment_ids(self, history_list) -> list[str]:
"""从消息历史中提取所有 attachment_id"""
attachment_ids = []
+5 -1
View File
@@ -1,4 +1,4 @@
from dataclasses import dataclass
from dataclasses import asdict, dataclass
from quart import Quart
@@ -57,3 +57,7 @@ class Response:
self.data = data
self.message = message
return self
def to_json(self):
# Return a plain dict so callers can safely wrap with jsonify()
return asdict(self)
+3
View File
@@ -5,6 +5,9 @@ class StaticFileRoute(Route):
def __init__(self, context: RouteContext) -> None:
super().__init__(context)
if "index" in self.app.view_functions:
return
index_ = [
"/",
"/auth/login",
+254 -174
View File
@@ -2,10 +2,13 @@ import asyncio
import hashlib
import logging
import os
import platform
import socket
from collections.abc import Callable
from datetime import datetime
from ipaddress import IPv4Address, IPv6Address, ip_address
from pathlib import Path
from typing import Protocol, cast
from typing import Protocol
import jwt
import psutil
@@ -14,6 +17,7 @@ from hypercorn.asyncio import serve
from hypercorn.config import Config as HyperConfig
from quart import Quart, g, jsonify, request
from quart.logging import default_handler
from quart_cors import cors
from astrbot.core import logger
from astrbot.core.config.default import VERSION
@@ -25,13 +29,6 @@ from astrbot.core.utils.io import get_local_ip_addresses
from .routes import *
from .routes.api_key import ALL_OPEN_API_SCOPES
from .routes.backup import BackupRoute
from .routes.live_chat import LiveChatRoute
from .routes.platform import PlatformRoute
from .routes.route import Response, RouteContext
from .routes.session_management import SessionManagementRoute
from .routes.subagent import SubAgentRoute
from .routes.t2i import T2iRoute
# Static assets shipped inside the wheel (built during `hatch build`).
_BUNDLED_DIST = Path(__file__).parent / "dist"
@@ -58,6 +55,16 @@ class AstrBotJSONProvider(DefaultJSONProvider):
class AstrBotDashboard:
"""AstrBot Web Dashboard"""
ALLOWED_ENDPOINT_PREFIXES = (
"/api/auth/login",
"/api/file",
"/api/platform/webhook",
"/api/stat/start-time",
"/api/backup/download",
)
def __init__(
self,
core_lifecycle: AstrBotCoreLifecycle,
@@ -68,7 +75,26 @@ class AstrBotDashboard:
self.core_lifecycle = core_lifecycle
self.config = core_lifecycle.astrbot_config
self.db = db
self.shutdown_event = shutdown_event
self.enable_webui = self._check_webui_enabled()
self._init_paths(webui_dir)
self._init_app()
self.context = RouteContext(self.config, self.app)
self._init_routes(db)
self._init_plugin_route_index()
self._init_jwt_secret()
def _check_webui_enabled(self) -> bool:
cfg = self.config.get("dashboard", {})
_env = os.environ.get("DASHBOARD_ENABLE")
if _env is not None:
return _env.lower() in ("true", "1", "yes")
return cfg.get("enable", True)
def _init_paths(self, webui_dir: str | None):
# Path priority:
# 1. Explicit webui_dir argument
# 2. data/dist/ (user-installed / manually updated dashboard)
@@ -83,62 +109,96 @@ class AstrBotDashboard:
self.data_path = str(_BUNDLED_DIST)
logger.info("Using bundled dashboard dist: %s", self.data_path)
else:
# Fall back to expected user path (will fail gracefully later)
self.data_path = os.path.abspath(user_dist)
self.app = Quart("dashboard", static_folder=self.data_path, static_url_path="/")
APP = self.app # noqa
self.app.config["MAX_CONTENT_LENGTH"] = (
128 * 1024 * 1024
) # 将 Flask 允许的最大上传文件体大小设置为 128 MB
def _init_app(self):
"""初始化 Quart 应用"""
global APP
self.app = Quart(
"AstrBotDashboard",
static_folder=self.data_path,
static_url_path="/",
)
APP = self.app
self.app.json_provider_class = DefaultJSONProvider
self.app.config["MAX_CONTENT_LENGTH"] = 128 * 1024 * 1024 # 128MB
self.app.json = AstrBotJSONProvider(self.app)
self.app.json.sort_keys = False
self.app.before_request(self.auth_middleware)
# token 用于验证请求
logging.getLogger(self.app.name).removeHandler(default_handler)
self.context = RouteContext(self.config, self.app)
self.ur = UpdateRoute(
self.context,
core_lifecycle.astrbot_updator,
core_lifecycle,
# 配置 CORS
self.app = cors(
self.app,
allow_origin="*",
allow_headers=["Authorization", "Content-Type", "X-API-Key"],
allow_methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"],
)
self.sr = StatRoute(self.context, db, core_lifecycle)
self.pr = PluginRoute(
self.context,
core_lifecycle,
core_lifecycle.plugin_manager,
@self.app.route("/")
async def index():
if not self.enable_webui:
return "WebUI is disabled."
return await self.app.send_static_file("index.html")
@self.app.errorhandler(404)
async def not_found(e):
if not self.enable_webui:
return "WebUI is disabled."
if request.path.startswith("/api/"):
return jsonify(Response().error("Not Found").to_json()), 404
return await self.app.send_static_file("index.html")
@self.app.before_serving
async def startup():
pass
@self.app.after_serving
async def shutdown():
pass
self.app.before_request(self.auth_middleware)
logging.getLogger(self.app.name).removeHandler(default_handler)
def _init_routes(self, db: BaseDatabase):
UpdateRoute(
self.context, self.core_lifecycle.astrbot_updator, self.core_lifecycle
)
StatRoute(self.context, db, self.core_lifecycle)
PluginRoute(
self.context, self.core_lifecycle, self.core_lifecycle.plugin_manager
)
self.command_route = CommandRoute(self.context)
self.cr = ConfigRoute(self.context, core_lifecycle)
self.lr = LogRoute(self.context, core_lifecycle.log_broker)
self.cr = ConfigRoute(self.context, self.core_lifecycle)
self.lr = LogRoute(self.context, self.core_lifecycle.log_broker)
self.sfr = StaticFileRoute(self.context)
self.ar = AuthRoute(self.context)
self.api_key_route = ApiKeyRoute(self.context, db)
self.chat_route = ChatRoute(self.context, db, core_lifecycle)
self.chat_route = ChatRoute(self.context, db, self.core_lifecycle)
self.open_api_route = OpenApiRoute(
self.context,
db,
core_lifecycle,
self.core_lifecycle,
self.chat_route,
)
self.chatui_project_route = ChatUIProjectRoute(self.context, db)
self.tools_root = ToolsRoute(self.context, core_lifecycle)
self.subagent_route = SubAgentRoute(self.context, core_lifecycle)
self.skills_route = SkillsRoute(self.context, core_lifecycle)
self.conversation_route = ConversationRoute(self.context, db, core_lifecycle)
self.tools_root = ToolsRoute(self.context, self.core_lifecycle)
self.subagent_route = SubAgentRoute(self.context, self.core_lifecycle)
self.skills_route = SkillsRoute(self.context, self.core_lifecycle)
self.conversation_route = ConversationRoute(
self.context, db, self.core_lifecycle
)
self.file_route = FileRoute(self.context)
self.session_management_route = SessionManagementRoute(
self.context,
db,
core_lifecycle,
self.core_lifecycle,
)
self.persona_route = PersonaRoute(self.context, db, core_lifecycle)
self.cron_route = CronRoute(self.context, core_lifecycle)
self.t2i_route = T2iRoute(self.context, core_lifecycle)
self.kb_route = KnowledgeBaseRoute(self.context, core_lifecycle)
self.platform_route = PlatformRoute(self.context, core_lifecycle)
self.backup_route = BackupRoute(self.context, db, core_lifecycle)
self.live_chat_route = LiveChatRoute(self.context, db, core_lifecycle)
self.persona_route = PersonaRoute(self.context, db, self.core_lifecycle)
self.cron_route = CronRoute(self.context, self.core_lifecycle)
self.t2i_route = T2iRoute(self.context, self.core_lifecycle)
self.kb_route = KnowledgeBaseRoute(self.context, self.core_lifecycle)
self.platform_route = PlatformRoute(self.context, self.core_lifecycle)
self.backup_route = BackupRoute(self.context, db, self.core_lifecycle)
self.live_chat_route = LiveChatRoute(self.context, db, self.core_lifecycle)
self.app.add_url_rule(
"/api/plug/<path:subpath>",
@@ -146,20 +206,31 @@ class AstrBotDashboard:
methods=["GET", "POST"],
)
self.shutdown_event = shutdown_event
def _init_plugin_route_index(self):
"""将插件路由索引,避免 O(n) 查找"""
self._plugin_route_map: dict[tuple[str, str], Callable] = {}
self._init_jwt_secret()
for (
route,
handler,
methods,
_,
) in self.core_lifecycle.star_context.registered_web_apis:
for method in methods:
self._plugin_route_map[(route, method)] = handler
async def srv_plug_route(self, subpath, *args, **kwargs):
"""插件路由"""
registered_web_apis = self.core_lifecycle.star_context.registered_web_apis
for api in registered_web_apis:
route, view_handler, methods, _ = api
if route == f"/{subpath}" and request.method in methods:
return await view_handler(*args, **kwargs)
return jsonify(Response().error("未找到该路由").__dict__)
def _init_jwt_secret(self):
dashboard_cfg = self.config.setdefault("dashboard", {})
if not dashboard_cfg.get("jwt_secret"):
dashboard_cfg["jwt_secret"] = os.urandom(32).hex()
self.config.save_config()
logger.info("Initialized random JWT secret for dashboard.")
self._jwt_secret = dashboard_cfg["jwt_secret"]
async def auth_middleware(self):
# 放行CORS预检请求
if request.method == "OPTIONS":
return None
if not request.path.startswith("/api"):
return None
if request.path.startswith("/api/v1"):
@@ -196,33 +267,42 @@ class AstrBotDashboard:
await self.db.touch_api_key(api_key.key_id)
return None
allowed_endpoints = [
"/api/auth/login",
"/api/file",
"/api/platform/webhook",
"/api/stat/start-time",
"/api/backup/download", # 备份下载使用 URL 参数传递 token
]
if any(request.path.startswith(prefix) for prefix in allowed_endpoints):
if any(request.path.startswith(p) for p in self.ALLOWED_ENDPOINT_PREFIXES):
return None
# 声明 JWT
token = request.headers.get("Authorization")
if not token:
r = jsonify(Response().error("未授权").__dict__)
r.status_code = 401
return r
token = token.removeprefix("Bearer ")
return self._unauthorized("未授权")
try:
payload = jwt.decode(token, self._jwt_secret, algorithms=["HS256"])
payload = jwt.decode(
token.removeprefix("Bearer "),
self._jwt_secret,
algorithms=["HS256"],
options={"require": ["username"]},
)
g.username = payload["username"]
except jwt.ExpiredSignatureError:
r = jsonify(Response().error("Token 过期").__dict__)
r.status_code = 401
return r
except jwt.InvalidTokenError:
r = jsonify(Response().error("Token 无效").__dict__)
r.status_code = 401
return r
return self._unauthorized("Token 过期")
except jwt.PyJWTError:
return self._unauthorized("Token 无效")
@staticmethod
def _unauthorized(msg: str):
r = jsonify(Response().error(msg).to_json())
r.status_code = 401
return r
async def srv_plug_route(self, subpath: str, *args, **kwargs):
handler = self._plugin_route_map.get((f"/{subpath}", request.method))
if not handler:
return jsonify(Response().error("未找到该路由").to_json())
try:
return await handler(*args, **kwargs)
except Exception:
logger.exception("插件 Web API 执行异常")
return jsonify(Response().error("插件 Web API 执行异常").to_json())
@staticmethod
def _extract_raw_api_key() -> str | None:
@@ -252,126 +332,92 @@ class AstrBotDashboard:
}
return scope_map.get(path)
def check_port_in_use(self, port: int) -> bool:
def check_port_in_use(self, host: str, port: int) -> bool:
"""跨平台检测端口是否被占用"""
family = socket.AF_INET6 if ":" in host else socket.AF_INET
try:
# 创建 IPv4 TCP Socket
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
# 设置超时时间
sock.settimeout(2)
result = sock.connect_ex(("127.0.0.1", port))
sock.close()
# result 为 0 表示端口被占用
return result == 0
except Exception as e:
logger.warning(f"检查端口 {port} 时发生错误: {e!s}")
# 如果出现异常,保守起见认为端口可能被占用
with socket.socket(family, socket.SOCK_STREAM) as s:
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
s.bind((host, port))
return False
except OSError:
return True
def get_process_using_port(self, port: int) -> str:
"""获取占用端口的进程详细信息"""
"""获取占用端口的进程信息"""
try:
for conn in psutil.net_connections(kind="inet"):
if cast(_AddrWithPort, conn.laddr).port == port:
try:
process = psutil.Process(conn.pid)
# 获取详细信息
proc_info = [
f"进程名: {process.name()}",
f"PID: {process.pid}",
f"执行路径: {process.exe()}",
f"工作目录: {process.cwd()}",
f"启动命令: {' '.join(process.cmdline())}",
]
return "\n ".join(proc_info)
except (psutil.NoSuchProcess, psutil.AccessDenied) as e:
return f"无法获取进程详细信息(可能需要管理员权限): {e!s}"
return "未找到占用进程"
for proc in psutil.process_iter(["pid", "name"]):
try:
connections = proc.net_connections()
for conn in connections:
if conn.laddr.port == port:
return f"PID: {proc.info['pid']}, Name: {proc.info['name']}"
except (
psutil.NoSuchProcess,
psutil.AccessDenied,
psutil.ZombieProcess,
):
pass
except Exception as e:
return f"获取进程信息失败: {e!s}"
return "未知进程"
def _init_jwt_secret(self) -> None:
if not self.config.get("dashboard", {}).get("jwt_secret", None):
# 如果没有设置 JWT 密钥,则生成一个新的密钥
jwt_secret = os.urandom(32).hex()
self.config["dashboard"]["jwt_secret"] = jwt_secret
self.config.save_config()
logger.info("Initialized random JWT secret for dashboard.")
self._jwt_secret = self.config["dashboard"]["jwt_secret"]
async def run(self) -> None:
"""Run dashboard server (blocking)"""
if not self.enable_webui:
logger.warning(
"WebUI 已禁用 (dashboard.enable=false or DASHBOARD_ENABLE=false)"
)
def run(self):
ip_addr = []
dashboard_config = self.core_lifecycle.astrbot_config.get("dashboard", {})
port = (
os.environ.get("DASHBOARD_PORT")
or os.environ.get("ASTRBOT_DASHBOARD_PORT")
or dashboard_config.get("port", 6185)
dashboard_config = self.config.get("dashboard", {})
host = os.environ.get("DASHBOARD_HOST") or dashboard_config.get(
"host", "0.0.0.0"
)
host = (
os.environ.get("DASHBOARD_HOST")
or os.environ.get("ASTRBOT_DASHBOARD_HOST")
or dashboard_config.get("host", "0.0.0.0")
port = int(
os.environ.get("DASHBOARD_PORT") or dashboard_config.get("port", 6185)
)
enable = dashboard_config.get("enable", True)
ssl_config = dashboard_config.get("ssl", {})
if not isinstance(ssl_config, dict):
ssl_config = {}
ssl_enable = _parse_env_bool(
os.environ.get("DASHBOARD_SSL_ENABLE")
or os.environ.get("ASTRBOT_DASHBOARD_SSL_ENABLE"),
bool(ssl_config.get("enable", False)),
os.environ.get("DASHBOARD_SSL_ENABLE"),
ssl_config.get("enable", False),
)
scheme = "https" if ssl_enable else "http"
display_host = f"[{host}]" if ":" in host else host
if not enable:
logger.info("WebUI 已被禁用")
return None
logger.info(f"正在启动 WebUI, 监听地址: {scheme}://{host}:{port}")
if host == "0.0.0.0":
if self.enable_webui:
logger.info(
"提示: WebUI 将监听所有网络接口,请注意安全。(可在 data/cmd_config.json 中配置 dashboard.host 以修改 host",
"正在启动 WebUI + API, 监听地址: %s://%s:%s",
scheme,
display_host,
port,
)
else:
logger.info(
"正在启动 API Server (WebUI 已分离), 监听地址: %s://%s:%s",
scheme,
display_host,
port,
)
if host not in ["localhost", "127.0.0.1"]:
try:
ip_addr = get_local_ip_addresses()
except Exception as _:
pass
if isinstance(port, str):
port = int(port)
check_hosts = {host}
if host not in ("127.0.0.1", "localhost", "::1"):
check_hosts.add("127.0.0.1")
for check_host in check_hosts:
if self.check_port_in_use(check_host, port):
info = self.get_process_using_port(port)
raise RuntimeError(f"端口 {port} 已被占用\n{info}")
if self.check_port_in_use(port):
process_info = self.get_process_using_port(port)
logger.error(
f"错误:端口 {port} 已被占用\n"
f"占用信息: \n {process_info}\n"
f"请确保:\n"
f"1. 没有其他 AstrBot 实例正在运行\n"
f"2. 端口 {port} 没有被其他程序占用\n"
f"3. 如需使用其他端口,请修改配置文件",
)
raise Exception(f"端口 {port} 已被占用")
parts = [f"\n ✨✨✨\n AstrBot v{VERSION} WebUI 已启动,可访问\n\n"]
parts.append(f" ➜ 本地: {scheme}://localhost:{port}\n")
for ip in ip_addr:
parts.append(f" ➜ 网络: {scheme}://{ip}:{port}\n")
parts.append(" ➜ 默认用户名和密码: astrbot\n ✨✨✨\n")
display = "".join(parts)
if not ip_addr:
display += (
"可在 data/cmd_config.json 中配置 dashboard.host 以便远程访问。\n"
)
logger.info(display)
if self.enable_webui:
self._print_access_urls(host, port, scheme)
# 配置 Hypercorn
config = HyperConfig()
config.bind = [f"{host}:{port}"]
binds: list[str] = [self._build_bind(host, port)]
if host == "::" and platform.system() in ("Windows", "Darwin"):
binds.append(self._build_bind("0.0.0.0", port))
config.bind = binds
if ssl_enable:
cert_file = (
os.environ.get("DASHBOARD_SSL_CERT")
@@ -414,12 +460,46 @@ class AstrBotDashboard:
if disable_access_log:
config.accesslog = None
else:
# 启用访问日志,使用简洁格式
config.accesslog = "-"
config.access_log_format = "%(h)s %(r)s %(s)s %(b)s %(D)s"
return serve(self.app, config, shutdown_trigger=self.shutdown_trigger)
await serve(self.app, config, shutdown_trigger=self.shutdown_trigger)
async def shutdown_trigger(self) -> None:
@staticmethod
def _build_bind(host: str, port: int) -> str:
try:
ip: IPv4Address | IPv6Address = ip_address(host)
return f"[{ip}]:{port}" if ip.version == 6 else f"{ip}:{port}"
except ValueError:
return f"{host}:{port}"
def _print_access_urls(self, host: str, port: int, scheme: str = "http") -> None:
local_ips: list[IPv4Address | IPv6Address] = get_local_ip_addresses()
parts = [f"\n ✨✨✨\n AstrBot v{VERSION} WebUI 已启动\n\n"]
parts.append(f" ➜ 本地: {scheme}://localhost:{port}\n")
if host in ("::", "0.0.0.0"):
for ip in local_ips:
if ip.is_loopback:
continue
if ip.version == 6:
display_url = f"{scheme}://[{ip}]:{port}"
else:
display_url = f"{scheme}://{ip}:{port}"
parts.append(f" ➜ 网络: {display_url}\n")
parts.append(" ➜ 默认用户名和密码: astrbot\n ✨✨✨\n")
if not local_ips:
parts.append(
"可在 data/cmd_config.json 中配置 dashboard.host 以便远程访问。\n"
)
logger.info("".join(parts))
async def shutdown_trigger(self):
await self.shutdown_event.wait()
logger.info("AstrBot WebUI 已经被优雅地关闭")
+64
View File
@@ -0,0 +1,64 @@
## What's Changed
### 新增
- 新增俄语翻译([#6081](https://github.com/AstrBotDevs/AstrBot/pull/6081))。
- QQ 官方 Bot 新增文件、语音、视频消息支持(含 WebSocket 模式)([#6063](https://github.com/AstrBotDevs/AstrBot/pull/6063))。
### 优化
- 优化 QQ 官方 Bot 的流式消息投递可靠性与主动媒体发送能力([#6131](https://github.com/AstrBotDevs/AstrBot/pull/6131))。
- 优化边界场景下 booter 选择逻辑与消息发送工具([#6064](https://github.com/AstrBotDevs/AstrBot/pull/6064))。
### 修复
- 修复 Dashboard README 对话框锚点导航失效([#6083](https://github.com/AstrBotDevs/AstrBot/pull/6083))。
- 优先使用具名 weekday 的 cron 示例,避免歧义([#6091](https://github.com/AstrBotDevs/AstrBot/pull/6091))。
- 修复插件市场安装后状态未及时刷新的问题([#6124](https://github.com/AstrBotDevs/AstrBot/pull/6124))。
- 修复插件依赖安装逻辑:仅安装缺失依赖([#6088](https://github.com/AstrBotDevs/AstrBot/pull/6088))。
- 移除 Telegram 适配器中已废弃的 `normalize_whitespace` 参数([#6044](https://github.com/AstrBotDevs/AstrBot/pull/6044))。
- 修复 Windows 本地 skill 文件读取问题([#6028](https://github.com/AstrBotDevs/AstrBot/pull/6028))。
- 修复 Discord pre-ack emoji 配置重启后不持久化的问题([#6031](https://github.com/AstrBotDevs/AstrBot/pull/6031))。
- 统一 WebUI 搜索框清空行为([#6017](https://github.com/AstrBotDevs/AstrBot/pull/6017))。
- 优化插件依赖自动安装流程与 Dashboard 安装体验([#5954](https://github.com/AstrBotDevs/AstrBot/pull/5954))。
### 文档
- 新增 Astrbook 和玖帕喵社区链接([#6135](https://github.com/AstrBotDevs/AstrBot/pull/6135))。
- 修正文档 `docker.md``napcat.md` 中的拼写错误([#6048](https://github.com/AstrBotDevs/AstrBot/pull/6048))。
- 在多语言 README 中补充官方开发群号,并改进配置元数据中的正则说明。
- 更新编辑链接模式并移除过时仓库引用。
---
## What's Changed (EN)
### New Features
- Added Russian translation support ([#6081](https://github.com/AstrBotDevs/AstrBot/pull/6081)).
- Added file, voice, and video message support for QQ Official Bot (including WebSocket mode) ([#6063](https://github.com/AstrBotDevs/AstrBot/pull/6063)).
### Improvements
- Improved streaming message delivery reliability and proactive media sending for QQ Official API ([#6131](https://github.com/AstrBotDevs/AstrBot/pull/6131)).
- Optimized booter selection logic in edge cases and message sending tooling ([#6064](https://github.com/AstrBotDevs/AstrBot/pull/6064)).
### Bug Fixes
- Fixed broken README dialog anchor navigation in the Dashboard ([#6083](https://github.com/AstrBotDevs/AstrBot/pull/6083)).
- Preferred named weekday cron examples to reduce ambiguity ([#6091](https://github.com/AstrBotDevs/AstrBot/pull/6091)).
- Fixed plugin market install-state refresh after installation ([#6124](https://github.com/AstrBotDevs/AstrBot/pull/6124)).
- Fixed plugin dependency installation logic to install only missing packages ([#6088](https://github.com/AstrBotDevs/AstrBot/pull/6088)).
- Removed deprecated `normalize_whitespace` parameter in the Telegram adapter ([#6044](https://github.com/AstrBotDevs/AstrBot/pull/6044)).
- Fixed local skill file reading issues on Windows ([#6028](https://github.com/AstrBotDevs/AstrBot/pull/6028)).
- Fixed Discord pre-ack emoji config not being persisted across restarts ([#6031](https://github.com/AstrBotDevs/AstrBot/pull/6031)).
- Unified WebUI search input clear behavior ([#6017](https://github.com/AstrBotDevs/AstrBot/pull/6017)).
- Improved plugin dependency auto-install flow and Dashboard installation experience ([#5954](https://github.com/AstrBotDevs/AstrBot/pull/5954)).
### Documentation
- Added Astrbook and Jiupa Miao community links ([#6135](https://github.com/AstrBotDevs/AstrBot/pull/6135)).
- Fixed typos in `docker.md` and `napcat.md` ([#6048](https://github.com/AstrBotDevs/AstrBot/pull/6048)).
- Added official developer group IDs to multilingual READMEs and improved regex description in config metadata.
- Updated edit-link patterns and removed obsolete repository references.
+1
View File
@@ -37,6 +37,7 @@ services:
- DEFAULT_SHIP_MEMORY=512m
volumes:
- ${PWD}/data/shipyard/bay_data:/app/data
- ${PWD}/data/temp:/AstrBot/data/temp # Bind the local temp directory to the sandbox so that the uploaded file can be accessed in the sandbox
- /var/run/docker.sock:/var/run/docker.sock:ro
networks:
- astrbot_network
+3 -1
View File
@@ -1,3 +1,5 @@
node_modules/
.DS_Store
dist/
dist/
bun.lock
pmpm-lock.yaml
+6
View File
@@ -7,3 +7,9 @@ interface ImportMetaEnv {
interface ImportMeta {
readonly env: ImportMetaEnv;
}
declare module "*.vue" {
import type { DefineComponent } from "vue";
const component: DefineComponent<{}, {}, any>;
export default component;
}
+13 -7
View File
@@ -17,17 +17,17 @@
"@tiptap/starter-kit": "2.1.7",
"@tiptap/vue-3": "2.1.7",
"apexcharts": "3.42.0",
"axios": ">=1.6.2 <1.10.0 || >1.10.0 <2.0.0",
"axios": "1.13.5",
"axios-mock-adapter": "^1.22.0",
"chance": "1.1.11",
"date-fns": "2.30.0",
"dompurify": "^3.3.1",
"dompurify": "^3.3.2",
"event-source-polyfill": "^1.0.31",
"highlight.js": "^11.11.1",
"js-md5": "^0.8.3",
"katex": "^0.16.27",
"lodash": "4.17.21",
"markdown-it": "^14.1.0",
"lodash": "4.17.23",
"markdown-it": "^14.1.1",
"markstream-vue": "^0.0.6",
"mermaid": "^11.12.2",
"monaco-editor": "^0.52.2",
@@ -38,7 +38,7 @@
"stream-markdown": "^0.0.13",
"stream-monaco": "^0.0.17",
"vee-validate": "4.11.3",
"vite-plugin-vuetify": "1.0.2",
"vite-plugin-vuetify": "2.1.3",
"vue": "3.3.4",
"vue-i18n": "^11.1.5",
"vue-router": "4.2.4",
@@ -54,7 +54,7 @@
"@types/dompurify": "^3.0.5",
"@types/markdown-it": "^14.1.2",
"@types/node": "^20.5.7",
"@vitejs/plugin-vue": "4.3.3",
"@vitejs/plugin-vue": "5.2.4",
"@vue/eslint-config-prettier": "8.0.0",
"@vue/eslint-config-typescript": "11.0.3",
"@vue/tsconfig": "^0.4.0",
@@ -64,9 +64,15 @@
"sass": "1.66.1",
"sass-loader": "13.3.2",
"typescript": "5.1.6",
"vite": "4.4.9",
"vite": "5.4.1",
"vue-cli-plugin-vuetify": "2.5.8",
"vue-tsc": "1.8.8",
"vuetify-loader": "^2.0.0-alpha.9"
},
"pnpm": {
"overrides": {
"immutable": "4.3.8",
"lodash-es": "4.17.23"
}
}
}
+597 -267
View File
File diff suppressed because it is too large Load Diff
+13
View File
@@ -0,0 +1,13 @@
{
"apiBaseUrl": "",
"presets": [
{
"name": "Default (Auto)",
"url": ""
},
{
"name": "Localhost",
"url": "http://localhost:6185"
}
]
}
+48
View File
@@ -11,6 +11,7 @@
:currSessionId="currSessionId"
:selectedProjectId="selectedProjectId"
:transportMode="transportMode"
:sendShortcut="sendShortcut"
:isDark="isDark"
:chatboxMode="chatboxMode"
:isMobile="isMobile"
@@ -20,6 +21,7 @@
@selectConversation="handleSelectConversation"
@editTitle="showEditTitleDialog"
@deleteConversation="handleDeleteConversation"
@batchDeleteConversations="handleBatchDeleteConversations"
@closeMobileSidebar="closeMobileSidebar"
@toggleTheme="toggleTheme"
@toggleFullscreen="toggleFullscreen"
@@ -28,6 +30,7 @@
@editProject="showEditProjectDialog"
@deleteProject="handleDeleteProject"
@updateTransportMode="setTransportMode"
@updateSendShortcut="setSendShortcut"
/>
<!-- 右侧聊天内容区域 -->
@@ -78,6 +81,7 @@
:session-id="currSessionId || null"
:current-session="getCurrentSession"
:replyTo="replyTo"
:send-shortcut="sendShortcut"
@send="handleSendMessage"
@stop="handleStopMessage"
@toggleStreaming="toggleStreaming"
@@ -109,6 +113,7 @@
:session-id="currSessionId || null"
:current-session="getCurrentSession"
:replyTo="replyTo"
:send-shortcut="sendShortcut"
@send="handleSendMessage"
@stop="handleStopMessage"
@toggleStreaming="toggleStreaming"
@@ -139,6 +144,7 @@
:session-id="currSessionId || null"
:current-session="getCurrentSession"
:replyTo="replyTo"
:send-shortcut="sendShortcut"
@send="handleSendMessage"
@stop="handleStopMessage"
@toggleStreaming="toggleStreaming"
@@ -220,10 +226,13 @@ import { useMediaHandling } from '@/composables/useMediaHandling';
import { useProjects } from '@/composables/useProjects';
import type { Project } from '@/components/chat/ProjectList.vue';
import { useRecording } from '@/composables/useRecording';
import { useToast } from '@/utils/toast';
interface Props {
chatboxMode?: boolean;
}
type SendShortcut = 'enter' | 'shift_enter';
const SEND_SHORTCUT_STORAGE_KEY = 'chat_send_shortcut';
const props = withDefaults(defineProps<Props>(), {
chatboxMode: false
@@ -233,6 +242,7 @@ const router = useRouter();
const route = useRoute();
const { t } = useI18n();
const { tm } = useModuleI18n('features/chat');
const { warning: toastWarning } = useToast();
const theme = useTheme();
const customizer = useCustomizerStore();
@@ -257,6 +267,7 @@ const {
getSessions,
newSession,
deleteSession: deleteSessionFn,
batchDeleteSessions,
showEditTitleDialog,
saveTitle,
updateSessionTitle,
@@ -330,6 +341,12 @@ interface ReplyInfo {
const replyTo = ref<ReplyInfo | null>(null);
const isDark = computed(() => useCustomizerStore().uiTheme === 'PurpleThemeDark');
const sendShortcut = ref<SendShortcut>('shift_enter');
function setSendShortcut(mode: SendShortcut) {
sendShortcut.value = mode;
localStorage.setItem(SEND_SHORTCUT_STORAGE_KEY, mode);
}
//
function checkMobile() {
@@ -510,6 +527,33 @@ async function handleDeleteConversation(sessionId: string) {
}
}
async function handleBatchDeleteConversations(sessionIds: string[]) {
try {
const result = await batchDeleteSessions(sessionIds);
//
if (result.currentSessionDeleted) {
messages.value = [];
}
//
if (result.failed_count > 0) {
toastWarning(
tm('batch.partialFailure', { failed: result.failed_count, total: sessionIds.length })
);
}
//
if (selectedProjectId.value) {
const sessions = await getProjectSessions(selectedProjectId.value);
projectSessions.value = sessions;
}
} catch (err) {
console.error('Batch delete sessions failed:', err);
toastWarning(tm('batch.requestFailed'));
}
}
async function handleSelectProject(projectId: string) {
selectedProjectId.value = projectId;
const sessions = await getProjectSessions(projectId);
@@ -694,6 +738,10 @@ watch(sessions, (newSessions) => {
});
onMounted(() => {
const storedShortcut = localStorage.getItem(SEND_SHORTCUT_STORAGE_KEY);
if (storedShortcut === 'enter' || storedShortcut === 'shift_enter') {
sendShortcut.value = storedShortcut;
}
checkMobile();
window.addEventListener('resize', checkMobile);
getSessions();
+37 -28
View File
@@ -15,7 +15,7 @@
<transition name="fade">
<div v-if="isDragging" class="drop-overlay">
<div class="drop-overlay-content">
<v-icon size="48" color="deep-purple">mdi-cloud-upload</v-icon>
<v-icon size="48" color="primary">mdi-cloud-upload</v-icon>
<span class="drop-text">{{ tm('input.dropToUpload') }}</span>
</div>
</div>
@@ -41,7 +41,7 @@
<!-- Settings Menu -->
<StyledMenu offset="8" location="top start" :close-on-content-click="false">
<template v-slot:activator="{ props: activatorProps }">
<v-btn v-bind="activatorProps" icon="mdi-plus" variant="text" color="deep-purple" />
<v-btn v-bind="activatorProps" icon="mdi-plus" variant="text" color="primary" />
</template>
<!-- Upload Files -->
@@ -87,7 +87,7 @@
{{ tm('voice.liveMode') }}
</v-tooltip>
</v-btn> -->
<v-btn @click="handleRecordClick" icon variant="text" :color="isRecording ? 'error' : 'deep-purple'"
<v-btn @click="handleRecordClick" icon variant="text" :color="isRecording ? 'error' : 'primary'"
class="record-btn">
<v-icon :icon="isRecording ? 'mdi-stop-circle' : 'mdi-microphone'" variant="text"
plain></v-icon>
@@ -95,13 +95,13 @@
{{ isRecording ? tm('voice.speaking') : tm('voice.startRecording') }}
</v-tooltip>
</v-btn>
<v-btn icon v-if="isRunning" @click="$emit('stop')" variant="tonal" color="deep-purple" class="send-btn">
<v-btn icon v-if="isRunning" @click="$emit('stop')" variant="tonal" color="primary" class="send-btn">
<v-icon icon="mdi-stop" variant="text" plain></v-icon>
<v-tooltip activator="parent" location="top">
{{ tm('input.stopGenerating') }}
</v-tooltip>
</v-btn>
<v-btn v-else @click="$emit('send')" icon="mdi-send" variant="tonal" color="deep-purple"
<v-btn v-else @click="$emit('send')" icon="mdi-send" variant="tonal" color="primary"
:disabled="!canSend" class="send-btn" />
</div>
</div>
@@ -117,7 +117,7 @@
</div>
<div v-if="stagedAudioUrl" class="audio-preview">
<v-chip color="deep-purple-lighten-4" class="audio-chip">
<v-chip color="primary" variant="tonal" class="audio-chip">
<v-icon start icon="mdi-microphone" size="small"></v-icon>
{{ tm('voice.recording') }}
</v-chip>
@@ -126,7 +126,7 @@
</div>
<div v-for="(file, index) in stagedFiles" :key="'file-' + index" class="file-preview">
<v-chip color="blue-grey-lighten-4" class="file-chip">
<v-chip color="primary" variant="tonal" class="file-chip">
<v-icon start icon="mdi-file-document-outline" size="small"></v-icon>
<span class="file-name-preview">{{ file.original_name }}</span>
</v-chip>
@@ -173,6 +173,7 @@ interface Props {
currentSession?: Session | null;
configId?: string | null;
replyTo?: ReplyInfo | null;
sendShortcut?: 'enter' | 'shift_enter';
}
const props = withDefaults(defineProps<Props>(), {
@@ -180,7 +181,8 @@ const props = withDefaults(defineProps<Props>(), {
currentSession: null,
configId: null,
stagedFiles: () => [],
replyTo: null
replyTo: null,
sendShortcut: 'shift_enter'
});
const emit = defineEmits<{
@@ -253,9 +255,29 @@ watch(localPrompt, () => {
});
function handleKeyDown(e: KeyboardEvent) {
// Enter
// Shift+Enter Ctrl+Enter / Cmd+Enter
if (e.keyCode === 13 && (e.shiftKey || e.ctrlKey || e.metaKey)) {
const isEnter = e.key === 'Enter';
if (!isEnter) {
// Ctrl+B
if (e.ctrlKey && e.keyCode === 66) {
e.preventDefault();
if (ctrlKeyDown.value) return;
ctrlKeyDown.value = true;
ctrlKeyTimer.value = window.setTimeout(() => {
if (ctrlKeyDown.value && !props.isRecording) {
emit('startRecording');
}
}, ctrlKeyLongPressThreshold);
}
return;
}
const isSendHotkey =
e.ctrlKey ||
e.metaKey ||
(props.sendShortcut === 'enter' ? !e.shiftKey : e.shiftKey);
if (isSendHotkey) {
e.preventDefault();
if (localPrompt.value.trim() === '/astr_live_dev') {
emit('openLiveMode');
@@ -267,19 +289,6 @@ function handleKeyDown(e: KeyboardEvent) {
}
return;
}
// Ctrl+B
if (e.ctrlKey && e.keyCode === 66) {
e.preventDefault();
if (ctrlKeyDown.value) return;
ctrlKeyDown.value = true;
ctrlKeyTimer.value = window.setTimeout(() => {
if (ctrlKeyDown.value && !props.isRecording) {
emit('startRecording');
}
}, ctrlKeyLongPressThreshold);
}
}
function handleKeyUp(e: KeyboardEvent) {
@@ -399,8 +408,8 @@ defineExpose({
left: 0;
right: 0;
bottom: 0;
background-color: rgba(103, 58, 183, 0.15);
border: 2px dashed rgba(103, 58, 183, 0.5);
background-color: rgba(var(--v-theme-primary), 0.12);
border: 2px dashed rgba(var(--v-theme-primary), 0.45);
border-radius: 24px;
display: flex;
align-items: center;
@@ -419,7 +428,7 @@ defineExpose({
.drop-text {
font-size: 16px;
font-weight: 500;
color: #673ab7;
color: rgb(var(--v-theme-primary));
}
/* Fade transition for drop overlay */
@@ -439,7 +448,7 @@ defineExpose({
justify-content: space-between;
padding: 8px 16px;
margin: 8px 8px 0 8px;
background-color: rgba(103, 58, 183, 0.06);
background-color: rgba(var(--v-theme-primary), 0.06);
border-radius: 12px;
gap: 8px;
max-height: 500px;
@@ -5,7 +5,7 @@
'mobile-sidebar-open': isMobile && mobileMenuOpen,
'mobile-sidebar': isMobile
}"
:style="{ 'background-color': isDark ? sidebarCollapsed ? '#1e1e1e' : '#2d2d2d' : sidebarCollapsed ? '#ffffff' : '#f1f4f9' }">
:style="{ backgroundColor: sidebarCollapsed && !isMobile ? 'rgb(var(--v-theme-surface))' : 'rgb(var(--v-theme-mcpCardBg))' }">
<div class="sidebar-collapse-btn-container" v-if="!isMobile">
<v-btn icon class="sidebar-collapse-btn" @click="toggleSidebar" variant="text" color="deep-purple">
@@ -21,12 +21,31 @@
</div>
<div style="padding: 8px; opacity: 0.6;">
<v-btn block variant="text" class="new-chat-btn" @click="$emit('newChat')" :disabled="!currSessionId && !selectedProjectId"
v-if="!sidebarCollapsed || isMobile" prepend-icon="mdi-square-edit-outline">{{ tm('actions.newChat') }}</v-btn>
<v-btn icon="mdi-square-edit-outline" rounded="xl" @click="$emit('newChat')" :disabled="!currSessionId && !selectedProjectId"
<div class="new-chat-row" v-if="!sidebarCollapsed || isMobile">
<v-btn block variant="text" class="new-chat-btn" @click="$emit('newChat')" :disabled="!currSessionId && !selectedProjectId"
prepend-icon="mdi-square-edit-outline">{{ tm('actions.newChat') }}</v-btn>
<v-btn v-if="sessions.length > 0" icon size="small" variant="text" @click="toggleBatchMode"
:color="batchMode ? 'primary' : undefined">
<v-icon>mdi-checkbox-multiple-marked-outline</v-icon>
</v-btn>
</div>
<v-btn icon="mdi-square-edit-outline" rounded="xl" @click="$emit('newChat')" :disabled="!currSessionId && !selectedProjectId"
v-if="sidebarCollapsed && !isMobile" elevation="0"></v-btn>
</div>
<!-- Batch action bar -->
<div v-if="batchMode && (!sidebarCollapsed || isMobile)" class="batch-action-bar">
<v-btn size="x-small" variant="text" @click="toggleSelectAll">
{{ isAllSelected ? tm('batch.deselectAll') : tm('batch.selectAll') }}
</v-btn>
<span class="batch-selected-count">{{ tm('batch.selected', { count: batchSelected.length }) }}</span>
<v-spacer />
<v-btn size="x-small" variant="text" color="error" :disabled="batchSelected.length === 0"
@click="handleBatchDelete">
{{ tm('batch.delete') }}
</v-btn>
</div>
<!-- 项目列表组件 -->
<ProjectList
v-if="!sidebarCollapsed || isMobile"
@@ -41,19 +60,34 @@
v-if="!sidebarCollapsed || isMobile">
<v-card v-if="sessions.length > 0" flat style="background-color: transparent;">
<v-list density="compact" nav class="conversation-list"
style="background-color: transparent;" :selected="selectedSessions"
@update:selected="$emit('selectConversation', $event)">
style="background-color: transparent;" :selected="batchMode ? [] : selectedSessions"
@update:selected="handleListSelect">
<v-list-item v-for="item in sessions" :key="item.session_id" :value="item.session_id"
rounded="lg" class="conversation-item" active-color="secondary">
rounded="lg" class="conversation-item" active-color="secondary"
@click="batchMode ? toggleBatchItem(item.session_id) : undefined">
<template v-slot:prepend>
<div class="batch-checkbox-slot" :class="{ 'batch-checkbox-slot--active': batchMode }">
<v-checkbox-btn
:model-value="batchSelected.includes(item.session_id)"
@update:model-value="toggleBatchItem(item.session_id)"
@click.stop
density="compact"
hide-details
class="batch-checkbox"
/>
</div>
</template>
<v-list-item-title v-if="!sidebarCollapsed || isMobile" class="conversation-title"
:style="{ color: isDark ? '#ffffff' : '#000000' }">
:style="{ color: 'rgb(var(--v-theme-primaryText))' }">
{{ item.display_name || tm('conversation.newConversation') }}
</v-list-item-title>
<!-- <v-list-item-subtitle v-if="!sidebarCollapsed || isMobile" class="timestamp">
{{ new Date(item.updated_at).toLocaleString() }}
</v-list-item-subtitle> -->
<template v-if="!sidebarCollapsed || isMobile" v-slot:append>
<template v-if="!batchMode && (!sidebarCollapsed || isMobile)" v-slot:append>
<div class="conversation-actions">
<v-btn icon="mdi-pencil" size="x-small" variant="text"
class="edit-title-btn"
@@ -98,16 +132,52 @@
</v-btn>
</template>
<!-- 语言切换 -->
<v-list-item class="styled-menu-item">
<template v-slot:prepend>
<v-icon>mdi-translate</v-icon>
<!-- 语言切换分组 -->
<v-menu
:open-on-hover="!isMobile"
:open-on-click="isMobile"
:open-delay="!isMobile ? 60 : 0"
:close-delay="!isMobile ? 120 : 0"
:location="isMobile ? 'bottom' : 'end center'"
offset="8"
close-on-content-click
>
<template v-slot:activator="{ props: languageMenuProps }">
<v-list-item
v-bind="languageMenuProps"
class="styled-menu-item chat-settings-group-trigger"
rounded="md"
>
<template v-slot:prepend>
<v-icon>mdi-translate</v-icon>
</template>
<v-list-item-title>{{ t('core.common.language') }}</v-list-item-title>
<template v-slot:append>
<span class="chat-settings-group-current">{{ currentLanguage?.flag }}</span>
<v-icon size="18" class="chat-settings-group-arrow">mdi-chevron-right</v-icon>
</template>
</v-list-item>
</template>
<v-list-item-title>{{ t('core.common.language') }}</v-list-item-title>
<template v-slot:append>
<LanguageSwitcher variant="chatbox" />
</template>
</v-list-item>
<v-card class="styled-menu-card" style="min-width: 180px;" elevation="8" rounded="lg">
<v-list density="compact" class="styled-menu-list pa-1">
<v-list-item
v-for="lang in languages"
:key="lang.code"
:value="lang.code"
@click="changeLanguage(lang.code)"
:class="{ 'styled-menu-item-active': currentLocale === lang.code }"
class="styled-menu-item"
rounded="md"
>
<template v-slot:prepend>
<span class="language-flag">{{ lang.flag }}</span>
</template>
<v-list-item-title>{{ lang.name }}</v-list-item-title>
</v-list-item>
</v-list>
</v-card>
</v-menu>
<!-- 主题切换 -->
<v-list-item class="styled-menu-item" @click="$emit('toggleTheme')">
@@ -117,26 +187,93 @@
<v-list-item-title>{{ isDark ? tm('modes.lightMode') : tm('modes.darkMode') }}</v-list-item-title>
</v-list-item>
<!-- 通信传输模式 -->
<v-list-item class="styled-menu-item">
<template v-slot:prepend>
<v-icon>mdi-lan-connect</v-icon>
<!-- 通信传输模式分组 -->
<v-menu
:open-on-hover="!isMobile"
:open-on-click="isMobile"
:open-delay="!isMobile ? 60 : 0"
:close-delay="!isMobile ? 120 : 0"
:location="isMobile ? 'bottom' : 'end center'"
offset="8"
close-on-content-click
>
<template v-slot:activator="{ props: transportMenuProps }">
<v-list-item
v-bind="transportMenuProps"
class="styled-menu-item chat-settings-group-trigger"
rounded="md"
>
<template v-slot:prepend>
<v-icon>mdi-lan-connect</v-icon>
</template>
<v-list-item-title>{{ tm('transport.title') }}</v-list-item-title>
<template v-slot:append>
<span class="chat-settings-group-current chat-settings-transport-current">{{ currentTransportLabel }}</span>
<v-icon size="18" class="chat-settings-group-arrow">mdi-chevron-right</v-icon>
</template>
</v-list-item>
</template>
<v-list-item-title>{{ tm('transport.title') }}</v-list-item-title>
<template v-slot:append>
<v-select
:model-value="transportMode"
:items="transportOptions"
item-title="label"
item-value="value"
density="compact"
variant="underlined"
hide-details
class="transport-mode-select"
@update:model-value="handleTransportModeChange"
/>
<v-card class="styled-menu-card" style="min-width: 220px;" elevation="8" rounded="lg">
<v-list density="compact" class="styled-menu-list pa-1">
<v-list-item
v-for="opt in transportOptions"
:key="opt.value"
:value="opt.value"
@click="handleTransportModeChange(opt.value)"
:class="{ 'styled-menu-item-active': transportMode === opt.value }"
class="styled-menu-item"
rounded="md"
>
<v-list-item-title>{{ opt.label }}</v-list-item-title>
</v-list-item>
</v-list>
</v-card>
</v-menu>
<!-- 发送快捷键分组 -->
<v-menu
:open-on-hover="!isMobile"
:open-on-click="isMobile"
:open-delay="!isMobile ? 60 : 0"
:close-delay="!isMobile ? 120 : 0"
:location="isMobile ? 'bottom' : 'end center'"
offset="8"
close-on-content-click
>
<template v-slot:activator="{ props: sendShortcutMenuProps }">
<v-list-item
v-bind="sendShortcutMenuProps"
class="styled-menu-item chat-settings-group-trigger"
rounded="md"
>
<template v-slot:prepend>
<v-icon>mdi-keyboard-outline</v-icon>
</template>
<v-list-item-title>{{ tm('shortcuts.sendKey.title') }}</v-list-item-title>
<template v-slot:append>
<span class="chat-settings-group-current chat-settings-transport-current">{{ currentSendShortcutLabel }}</span>
<v-icon size="18" class="chat-settings-group-arrow">mdi-chevron-right</v-icon>
</template>
</v-list-item>
</template>
</v-list-item>
<v-card class="styled-menu-card" style="min-width: 220px;" elevation="8" rounded="lg">
<v-list density="compact" class="styled-menu-list pa-1">
<v-list-item
v-for="opt in sendShortcutOptions"
:key="opt.value"
:value="opt.value"
@click="handleSendShortcutChange(opt.value)"
:class="{ 'styled-menu-item-active': props.sendShortcut === opt.value }"
class="styled-menu-item"
rounded="md"
>
<v-list-item-title>{{ opt.label }}</v-list-item-title>
</v-list-item>
</v-list>
</v-card>
</v-menu>
<!-- 全屏/退出全屏 -->
<v-list-item class="styled-menu-item" @click="$emit('toggleFullscreen')">
@@ -162,15 +299,16 @@
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { ref, computed } from 'vue';
import { useI18n, useModuleI18n } from '@/i18n/composables';
import type { Session } from '@/composables/useSessions';
import { askForConfirmation, useConfirmDialog } from '@/utils/confirmDialog';
import LanguageSwitcher from '@/components/shared/LanguageSwitcher.vue';
import StyledMenu from '@/components/shared/StyledMenu.vue';
import ProviderConfigDialog from '@/components/chat/ProviderConfigDialog.vue';
import ProjectList from '@/components/chat/ProjectList.vue';
import type { Project } from '@/components/chat/ProjectList.vue';
import { useLanguageSwitcher } from '@/i18n/composables';
import type { Locale } from '@/i18n/types';
interface Props {
sessions: Session[];
@@ -183,6 +321,7 @@ interface Props {
isMobile: boolean;
mobileMenuOpen: boolean;
projects?: Project[];
sendShortcut: 'enter' | 'shift_enter';
}
const props = withDefaults(defineProps<Props>(), {
@@ -194,6 +333,7 @@ const emit = defineEmits<{
selectConversation: [sessionIds: string[]];
editTitle: [sessionId: string, title: string];
deleteConversation: [sessionId: string];
batchDeleteConversations: [sessionIds: string[]];
closeMobileSidebar: [];
toggleTheme: [];
toggleFullscreen: [];
@@ -202,6 +342,7 @@ const emit = defineEmits<{
editProject: [project: Project];
deleteProject: [projectId: string];
updateTransportMode: [mode: 'sse' | 'websocket'];
updateSendShortcut: [mode: 'enter' | 'shift_enter'];
}>();
const { t } = useI18n();
@@ -211,10 +352,84 @@ const confirmDialog = useConfirmDialog();
const sidebarCollapsed = ref(true);
const showProviderConfigDialog = ref(false);
// Batch mode state
const batchMode = ref(false);
const batchSelected = ref<string[]>([]);
const isAllSelected = computed(() =>
props.sessions.length > 0 && batchSelected.value.length === props.sessions.length
);
function toggleBatchMode() {
batchMode.value = !batchMode.value;
batchSelected.value = [];
}
function toggleBatchItem(sessionId: string) {
const idx = batchSelected.value.indexOf(sessionId);
if (idx >= 0) {
batchSelected.value.splice(idx, 1);
} else {
batchSelected.value.push(sessionId);
}
}
function toggleSelectAll() {
if (isAllSelected.value) {
batchSelected.value = [];
} else {
batchSelected.value = props.sessions.map(s => s.session_id);
}
}
async function handleBatchDelete() {
const count = batchSelected.value.length;
if (count === 0) return;
const message = tm('batch.confirmDelete', { count });
if (await askForConfirmation(message, confirmDialog)) {
emit('batchDeleteConversations', [...batchSelected.value]);
batchSelected.value = [];
batchMode.value = false;
}
}
function handleListSelect(sessionIds: string[]) {
if (!batchMode.value) {
emit('selectConversation', sessionIds);
}
}
const transportOptions = [
{ label: tm('transport.sse'), value: 'sse' as const },
{ label: tm('transport.websocket'), value: 'websocket' as const }
];
const sendShortcutOptions = [
{ label: tm('shortcuts.sendKey.enterToSend'), value: 'enter' as const },
{ label: tm('shortcuts.sendKey.shiftEnterToSend'), value: 'shift_enter' as const }
];
// Language switcher
const { languageOptions, currentLanguage, switchLanguage, locale } = useLanguageSwitcher();
const languages = computed(() =>
languageOptions.value.map(lang => ({
code: lang.value,
name: lang.label,
flag: lang.flag
}))
);
const currentLocale = computed(() => locale.value);
const changeLanguage = async (langCode: string) => {
await switchLanguage(langCode as Locale);
};
const currentTransportLabel = computed(() => {
const found = transportOptions.find(opt => opt.value === props.transportMode);
return found?.label ?? '';
});
const currentSendShortcutLabel = computed(() => {
const found = sendShortcutOptions.find(opt => opt.value === props.sendShortcut);
return found?.label ?? '';
});
// localStorage
const savedCollapsedState = localStorage.getItem('sidebarCollapsed');
@@ -242,6 +457,12 @@ function handleTransportModeChange(mode: string | null) {
emit('updateTransportMode', mode);
}
}
function handleSendShortcutChange(mode: string | null) {
if (mode === 'enter' || mode === 'shift_enter') {
emit('updateSendShortcut', mode);
}
}
</script>
<style scoped>
@@ -310,7 +531,7 @@ function handleTransportModeChange(mode: string | null) {
}
.conversation-item:hover {
background-color: rgba(103, 58, 183, 0.05);
background-color: rgba(var(--v-theme-primary), 0.05);
}
.conversation-item:hover .conversation-actions {
@@ -402,7 +623,74 @@ function handleTransportModeChange(mode: string | null) {
justify-content: center;
}
.transport-mode-select {
min-width: 120px;
.chat-settings-group-trigger :deep(.v-list-item__append) {
display: flex;
align-items: center;
gap: 6px;
}
.chat-settings-group-current {
font-size: 14px;
line-height: 1;
opacity: 0.8;
}
.chat-settings-transport-current {
font-size: 12px;
}
.chat-settings-group-arrow {
opacity: 0.7;
}
.language-flag {
font-size: 16px;
margin-right: 8px;
}
.new-chat-row {
display: flex;
align-items: center;
gap: 4px;
}
.new-chat-row .new-chat-btn {
flex: 1;
min-width: 0;
}
.batch-action-bar {
display: flex;
align-items: center;
padding: 4px 12px;
gap: 4px;
flex-shrink: 0;
}
.batch-selected-count {
font-size: 12px;
opacity: 0.7;
white-space: nowrap;
}
.batch-checkbox {
flex: none;
transition: opacity 0.2s ease, transform 0.2s ease;
}
.batch-checkbox-slot {
width: 0;
opacity: 0;
overflow: hidden;
pointer-events: none;
transform: translateX(-8px);
transition: width 0.2s ease, opacity 0.2s ease, transform 0.2s ease;
}
.batch-checkbox-slot--active {
width: 28px;
opacity: 1;
pointer-events: auto;
transform: translateX(0);
}
</style>
File diff suppressed because it is too large Load Diff
+249 -231
View File
@@ -1,165 +1,177 @@
<template>
<v-card class="standalone-chat-card" elevation="0" rounded="0">
<v-card-text class="standalone-chat-container">
<div class="chat-layout">
<!-- 聊天内容区域 -->
<div class="chat-content-panel">
<MessageList v-if="messages && messages.length > 0" :messages="messages" :isDark="isDark"
:isStreaming="isStreaming || isConvRunning" @openImagePreview="openImagePreview"
ref="messageList" />
<div class="welcome-container fade-in" v-else>
<div class="welcome-title">
<span>Hello, I'm</span>
<span class="bot-name">AstrBot </span>
</div>
<p class="text-caption text-medium-emphasis mt-2">
测试配置: {{ configId || 'default' }}
</p>
</div>
<!-- 输入区域 -->
<ChatInput
v-model:prompt="prompt"
:stagedImagesUrl="stagedImagesUrl"
:stagedAudioUrl="stagedAudioUrl"
:disabled="isStreaming"
:is-running="isStreaming || isConvRunning"
:enableStreaming="enableStreaming"
:isRecording="isRecording"
:session-id="currSessionId || null"
:current-session="getCurrentSession"
:config-id="configId"
@send="handleSendMessage"
@stop="handleStopMessage"
@toggleStreaming="toggleStreaming"
@removeImage="removeImage"
@removeAudio="removeAudio"
@startRecording="handleStartRecording"
@stopRecording="handleStopRecording"
@pasteImage="handlePaste"
@fileSelect="handleFileSelect"
@openLiveMode=""
ref="chatInputRef"
/>
</div>
<v-card class="standalone-chat-card" elevation="0" rounded="0">
<v-card-text class="standalone-chat-container">
<div class="chat-layout">
<!-- 聊天内容区域 -->
<div class="chat-content-panel">
<MessageList
v-if="messages && messages.length > 0"
:messages="messages"
:isDark="isDark"
:isStreaming="isStreaming || isConvRunning"
@openImagePreview="openImagePreview"
ref="messageList"
/>
<div class="welcome-container fade-in" v-else>
<div class="welcome-title">
<span>Hello, I'm</span>
<span class="bot-name">AstrBot </span>
</div>
</v-card-text>
</v-card>
<p class="text-caption text-medium-emphasis mt-2">
测试配置: {{ configId || "default" }}
</p>
</div>
<!-- 图片预览对话框 -->
<v-dialog v-model="imagePreviewDialog" max-width="90vw" max-height="90vh">
<v-card class="image-preview-card" elevation="8">
<v-card-title class="d-flex justify-space-between align-center pa-4">
<span>{{ t('core.common.imagePreview') }}</span>
<v-btn icon="mdi-close" variant="text" @click="imagePreviewDialog = false" />
</v-card-title>
<v-card-text class="text-center pa-4">
<img :src="previewImageUrl" class="preview-image-large" />
</v-card-text>
</v-card>
</v-dialog>
<!-- 输入区域 -->
<ChatInput
v-model:prompt="prompt"
:stagedImagesUrl="stagedImagesUrl"
:stagedAudioUrl="stagedAudioUrl"
:disabled="isStreaming"
:is-running="isStreaming || isConvRunning"
:enableStreaming="enableStreaming"
:isRecording="isRecording"
:session-id="currSessionId || null"
:current-session="getCurrentSession"
:config-id="configId"
@send="handleSendMessage"
@stop="handleStopMessage"
@toggleStreaming="toggleStreaming"
@removeImage="removeImage"
@removeAudio="removeAudio"
@startRecording="handleStartRecording"
@stopRecording="handleStopRecording"
@pasteImage="handlePaste"
@fileSelect="handleFileSelect"
@openLiveMode=""
ref="chatInputRef"
/>
</div>
</div>
</v-card-text>
</v-card>
<!-- 图片预览对话框 -->
<v-dialog v-model="imagePreviewDialog" max-width="90vw" max-height="90vh">
<v-card class="image-preview-card" elevation="8">
<v-card-title class="d-flex justify-space-between align-center pa-4">
<span>{{ t("core.common.imagePreview") }}</span>
<v-btn
icon="mdi-close"
variant="text"
@click="imagePreviewDialog = false"
/>
</v-card-title>
<v-card-text class="text-center pa-4">
<img :src="previewImageUrl" class="preview-image-large" />
</v-card-text>
</v-card>
</v-dialog>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onBeforeUnmount, nextTick } from 'vue';
import axios from 'axios';
import { useCustomizerStore } from '@/stores/customizer';
import { useI18n, useModuleI18n } from '@/i18n/composables';
import { useTheme } from 'vuetify';
import MessageList from '@/components/chat/MessageList.vue';
import ChatInput from '@/components/chat/ChatInput.vue';
import { useMessages } from '@/composables/useMessages';
import { useMediaHandling } from '@/composables/useMediaHandling';
import { useRecording } from '@/composables/useRecording';
import { useToast } from '@/utils/toast';
import { buildWebchatUmoDetails } from '@/utils/chatConfigBinding';
import { ref, computed, onMounted, onBeforeUnmount, nextTick } from "vue";
import axios from "axios";
import { useCustomizerStore } from "@/stores/customizer";
import { useI18n, useModuleI18n } from "@/i18n/composables";
import { useTheme } from "vuetify";
import MessageList from "@/components/chat/MessageList.vue";
import ChatInput from "@/components/chat/ChatInput.vue";
import { useMessages } from "@/composables/useMessages";
import { useMediaHandling } from "@/composables/useMediaHandling";
import { useRecording } from "@/composables/useRecording";
import { useToast } from "@/utils/toast";
import { buildWebchatUmoDetails } from "@/utils/chatConfigBinding";
interface Props {
configId?: string | null;
configId?: string | null;
}
const props = withDefaults(defineProps<Props>(), {
configId: null
configId: null,
});
const { t } = useI18n();
const { error: showError } = useToast();
// UI
const imagePreviewDialog = ref(false);
const previewImageUrl = ref('');
const previewImageUrl = ref("");
// 使 useSessions
const currSessionId = ref('');
const currSessionId = ref("");
const getCurrentSession = computed(() => null); //
async function bindConfigToSession(sessionId: string) {
const confId = (props.configId || '').trim();
if (!confId || confId === 'default') {
return;
}
const confId = (props.configId || "").trim();
if (!confId || confId === "default") {
return;
}
const umoDetails = buildWebchatUmoDetails(sessionId, false);
const umoDetails = buildWebchatUmoDetails(sessionId, false);
await axios.post('/api/config/umo_abconf_route/update', {
umo: umoDetails.umo,
conf_id: confId
});
await axios.post("/api/config/umo_abconf_route/update", {
umo: umoDetails.umo,
conf_id: confId,
});
}
async function newSession() {
try {
const response = await axios.get("/api/chat/new_session");
const sessionId = response.data.data.session_id;
try {
const response = await axios.get('/api/chat/new_session');
const sessionId = response.data.data.session_id;
try {
await bindConfigToSession(sessionId);
} catch (err) {
console.error('Failed to bind config to session', err);
}
currSessionId.value = sessionId;
return sessionId;
await bindConfigToSession(sessionId);
} catch (err) {
console.error(err);
throw err;
console.error("Failed to bind config to session", err);
}
currSessionId.value = sessionId;
return sessionId;
} catch (err) {
console.error(err);
throw err;
}
}
function updateSessionTitle(sessionId: string, title: string) {
//
//
}
function getSessions() {
//
//
}
const {
stagedImagesUrl,
stagedAudioUrl,
stagedFiles,
getMediaFile,
processAndUploadImage,
handlePaste,
removeImage,
removeAudio,
clearStaged,
cleanupMediaCache
stagedImagesUrl,
stagedAudioUrl,
stagedFiles,
getMediaFile,
processAndUploadImage,
handlePaste,
removeImage,
removeAudio,
clearStaged,
cleanupMediaCache,
} = useMediaHandling();
const { isRecording, startRecording: startRec, stopRecording: stopRec } = useRecording();
const {
isRecording,
startRecording: startRec,
stopRecording: stopRec,
} = useRecording();
const {
messages,
isStreaming,
isConvRunning,
enableStreaming,
getSessionMessages: getSessionMsg,
sendMessage: sendMsg,
stopMessage: stopMsg,
toggleStreaming
messages,
isStreaming,
isConvRunning,
enableStreaming,
getSessionMessages: getSessionMsg,
sendMessage: sendMsg,
stopMessage: stopMsg,
toggleStreaming,
} = useMessages(currSessionId, getMediaFile, updateSessionTitle, getSessions);
//
@@ -167,190 +179,196 @@ const messageList = ref<InstanceType<typeof MessageList> | null>(null);
const chatInputRef = ref<InstanceType<typeof ChatInput> | null>(null);
//
const prompt = ref('');
const prompt = ref("");
const isDark = computed(() => useCustomizerStore().uiTheme === 'PurpleThemeDark');
const isDark = computed(
() => useCustomizerStore().uiTheme === "PurpleThemeDark",
);
function openImagePreview(imageUrl: string) {
previewImageUrl.value = imageUrl;
imagePreviewDialog.value = true;
previewImageUrl.value = imageUrl;
imagePreviewDialog.value = true;
}
async function handleStartRecording() {
await startRec();
await startRec();
}
async function handleStopRecording() {
const audioFilename = await stopRec();
stagedAudioUrl.value = audioFilename;
const audioFilename = await stopRec();
stagedAudioUrl.value = audioFilename;
}
async function handleFileSelect(files: FileList) {
for (const file of files) {
await processAndUploadImage(file);
}
for (const file of Array.from(files)) {
await processAndUploadImage(file);
}
}
async function handleSendMessage() {
if (!prompt.value.trim() && stagedFiles.value.length === 0 && !stagedAudioUrl.value) {
return;
if (
!prompt.value.trim() &&
stagedFiles.value.length === 0 &&
!stagedAudioUrl.value
) {
return;
}
try {
if (!currSessionId.value) {
await newSession();
}
try {
if (!currSessionId.value) {
await newSession();
}
const promptToSend = prompt.value.trim();
const audioNameToSend = stagedAudioUrl.value;
const filesToSend = stagedFiles.value.map((f) => ({
attachment_id: f.attachment_id,
url: f.url,
original_name: f.original_name,
type: f.type,
}));
const promptToSend = prompt.value.trim();
const audioNameToSend = stagedAudioUrl.value;
const filesToSend = stagedFiles.value.map(f => ({
attachment_id: f.attachment_id,
url: f.url,
original_name: f.original_name,
type: f.type
}));
//
prompt.value = "";
clearStaged();
//
prompt.value = '';
clearStaged();
//
const selection = chatInputRef.value?.getCurrentSelection();
const selectedProviderId = selection?.providerId || "";
const selectedModelName = selection?.modelName || "";
//
const selection = chatInputRef.value?.getCurrentSelection();
const selectedProviderId = selection?.providerId || '';
const selectedModelName = selection?.modelName || '';
await sendMsg(
promptToSend,
filesToSend,
audioNameToSend,
selectedProviderId,
selectedModelName,
);
await sendMsg(
promptToSend,
filesToSend,
audioNameToSend,
selectedProviderId,
selectedModelName
);
//
nextTick(() => {
messageList.value?.scrollToBottom();
});
} catch (err) {
console.error('Failed to send message:', err);
showError(t('features.chat.errors.sendMessageFailed'));
//
//
}
//
nextTick(() => {
messageList.value?.scrollToBottom();
});
} catch (err) {
console.error("Failed to send message:", err);
showError(t("features.chat.errors.sendMessageFailed"));
//
//
}
}
async function handleStopMessage() {
await stopMsg();
await stopMsg();
}
onMounted(async () => {
//
try {
await newSession();
} catch (err) {
console.error('Failed to create initial session:', err);
showError(t('features.chat.errors.createSessionFailed'));
}
//
try {
await newSession();
} catch (err) {
console.error("Failed to create initial session:", err);
showError(t("features.chat.errors.createSessionFailed"));
}
});
onBeforeUnmount(() => {
cleanupMediaCache();
cleanupMediaCache();
});
</script>
<style scoped>
/* 基础动画 */
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.standalone-chat-card {
width: 100%;
height: 100%;
max-height: 100%;
overflow: hidden;
width: 100%;
height: 100%;
max-height: 100%;
overflow: hidden;
}
.standalone-chat-container {
width: 100%;
height: 100%;
max-height: 100%;
padding: 0;
overflow: hidden;
width: 100%;
height: 100%;
max-height: 100%;
padding: 0;
overflow: hidden;
}
.chat-layout {
height: 100%;
max-height: 100%;
display: flex;
overflow: hidden;
height: 100%;
max-height: 100%;
display: flex;
overflow: hidden;
}
.chat-content-panel {
height: 100%;
max-height: 100%;
width: 100%;
display: flex;
flex-direction: column;
overflow: hidden;
height: 100%;
max-height: 100%;
width: 100%;
display: flex;
flex-direction: column;
overflow: hidden;
}
.conversation-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px;
padding-left: 16px;
border-bottom: 1px solid var(--v-theme-border);
width: 100%;
padding-right: 32px;
flex-shrink: 0;
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px;
padding-left: 16px;
border-bottom: 1px solid var(--v-theme-border);
width: 100%;
padding-right: 32px;
flex-shrink: 0;
}
.conversation-header-info h4 {
margin: 0;
font-weight: 500;
margin: 0;
font-weight: 500;
}
.conversation-header-actions {
display: flex;
gap: 8px;
align-items: center;
display: flex;
gap: 8px;
align-items: center;
}
.welcome-container {
height: 100%;
display: flex;
justify-content: center;
align-items: center;
flex-direction: column;
height: 100%;
display: flex;
justify-content: center;
align-items: center;
flex-direction: column;
}
.welcome-title {
font-size: 28px;
margin-bottom: 8px;
font-size: 28px;
margin-bottom: 8px;
}
.bot-name {
font-weight: 700;
margin-left: 8px;
color: var(--v-theme-secondary);
font-weight: 700;
margin-left: 8px;
color: var(--v-theme-secondary);
}
.fade-in {
animation: fadeIn 0.3s ease-in-out;
animation: fadeIn 0.3s ease-in-out;
}
.preview-image-large {
max-width: 100%;
max-height: 70vh;
object-fit: contain;
max-width: 100%;
max-height: 70vh;
object-fit: contain;
}
</style>
@@ -1,12 +1,12 @@
<template>
<v-chip v-if="domain" class="ref-chip" size="x-small" variant="flat"
:style="{ backgroundColor: isDark ? '#303030' : '#f4f4f4', color: isDark ? '#999' : '#666' }" :href="url"
:style="chipStyle" :href="url"
target="_blank" clickable>
<v-icon start size="x-small" color>mdi-link-variant</v-icon>
<span>{{ domain }}</span>
</v-chip>
<span v-else class="ref-fallback" :style="{ color: isDark ? '#999' : '#666' }">{{ 'site' }}</span>
<span v-else class="ref-fallback" :style="fallbackStyle">{{ 'site' }}</span>
</template>
<script setup>
@@ -46,6 +46,15 @@ const domain = computed(() => {
return ''
}
})
const chipStyle = computed(() => ({
backgroundColor: isDark ? 'rgba(var(--v-theme-on-surface), 0.08)' : 'rgba(var(--v-theme-on-surface), 0.04)',
color: isDark ? 'rgba(var(--v-theme-on-surface), 0.62)' : 'rgba(var(--v-theme-on-surface), 0.72)'
}))
const fallbackStyle = computed(() => ({
color: isDark ? 'rgba(var(--v-theme-on-surface), 0.62)' : 'rgba(var(--v-theme-on-surface), 0.72)'
}))
</script>
<style scoped>
@@ -12,7 +12,7 @@
>
<v-icon
size="18"
:color="props.variant === 'default' ? (useCustomizerStore().uiTheme === 'PurpleTheme' ? '#5e35b1' : '#d7c5fa') : undefined"
:color="props.variant === 'default' ? 'rgb(var(--v-theme-primary))' : undefined"
>
mdi-translate
</v-icon>
@@ -42,7 +42,6 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n, useLanguageSwitcher } from '@/i18n/composables'
import { useCustomizerStore } from '@/stores/customizer'
import type { Locale } from '@/i18n/types'
import StyledMenu from '@/components/shared/StyledMenu.vue'
@@ -90,7 +89,7 @@ const changeLanguage = async (langCode: string) => {
.language-switcher--default:hover {
transform: scale(1.05);
background: rgba(94, 53, 177, 0.08) !important;
background: rgba(var(--v-theme-primary), 0.08) !important;
}
/* Header变体样式 - 完全继承Vuetify和action-btn的默认样式 */
@@ -103,8 +102,4 @@ const changeLanguage = async (langCode: string) => {
/* 继承action-btn样式,与工具栏主题按钮保持一致 */
}
/* 深色模式下的悬停效果(仅对default变体) */
:deep(.v-theme--PurpleThemeDark) .language-switcher--default:hover {
background: rgba(114, 46, 209, 0.12) !important;
}
</style>
+2 -3
View File
@@ -6,11 +6,11 @@
</div>
<div class="logo-text">
<h2
:style="{color: useCustomizerStore().uiTheme === 'PurpleTheme' ? '#5e35b1' : '#d7c5fa'}"
:style="{ color: 'rgb(var(--v-theme-primary))' }"
v-html="formatTitle(title || t('core.header.logoTitle'))"
></h2>
<!-- 父子组件传递css变量可能会出错暂时使用十六进制颜色值 -->
<h4 :style="{color: useCustomizerStore().uiTheme === 'PurpleTheme' ? '#000000aa' : '#ffffffcc'}"
<h4 :style="{ color: 'rgba(var(--v-theme-on-surface), 0.72)' }"
class="hint-text">{{ subtitle || t('core.header.accountDialog.title') }}</h4>
</div>
</div>
@@ -18,7 +18,6 @@
</template>
<script setup lang="ts">
import { useCustomizerStore } from "@/stores/customizer";
import { useI18n } from '@/i18n/composables';
const { t } = useI18n();
@@ -48,6 +48,24 @@ const loading = ref(false);
const isEmpty = ref(false);
const copyFeedbackTimer = ref(null);
const lastRequestId = ref(0);
const scrollContainer = ref(null);
function slugifyHeading(text, slugCounts) {
const base = (text || "")
.trim()
.toLowerCase()
.normalize("NFKD")
.replace(/[\u0300-\u036f]/g, "")
.replace(/[^\p{Letter}\p{Number}\s-]/gu, "")
.replace(/\s+/g, "-")
.replace(/-+/g, "-");
if (!base) return "";
const count = slugCounts.get(base) || 0;
slugCounts.set(base, count + 1);
return count === 0 ? base : `${base}-${count}`;
}
onUnmounted(() => {
if (copyFeedbackTimer.value) clearTimeout(copyFeedbackTimer.value);
@@ -153,6 +171,18 @@ const renderedHtml = computed(() => {
// 3.
const tempDiv = document.createElement("div");
tempDiv.innerHTML = cleanHtml;
const slugCounts = new Map();
tempDiv.querySelectorAll("h1, h2, h3, h4, h5, h6").forEach((heading) => {
if (heading.id) {
slugCounts.set(heading.id, (slugCounts.get(heading.id) || 0) + 1);
return;
}
const slug = slugifyHeading(heading.textContent, slugCounts);
if (slug) heading.id = slug;
});
tempDiv.querySelectorAll("a").forEach((link) => {
const href = link.getAttribute("href");
// 使 _blank
@@ -251,18 +281,35 @@ watch(
function handleContainerClick(event) {
const btn = event.target.closest(".copy-code-btn");
if (!btn) return;
const code = btn.closest(".code-block-wrapper")?.querySelector("code");
if (code) {
if (navigator.clipboard?.writeText) {
navigator.clipboard
.writeText(code.textContent)
.then(() => showCopyFeedback(btn, true))
.catch(() => tryFallbackCopy(code.textContent, btn));
} else {
tryFallbackCopy(code.textContent, btn);
if (btn) {
const code = btn.closest(".code-block-wrapper")?.querySelector("code");
if (code) {
if (navigator.clipboard?.writeText) {
navigator.clipboard
.writeText(code.textContent)
.then(() => showCopyFeedback(btn, true))
.catch(() => tryFallbackCopy(code.textContent, btn));
} else {
tryFallbackCopy(code.textContent, btn);
}
}
return;
}
const anchor = event.target.closest('a[href^="#"]');
if (!anchor) return;
const rawHref = anchor.getAttribute("href");
const targetId = rawHref ? decodeURIComponent(rawHref.slice(1)) : "";
if (!targetId) return;
const target = scrollContainer.value?.querySelector(
`#${CSS.escape(targetId)}`,
);
if (!target) return;
event.preventDefault();
target.scrollIntoView({ behavior: "smooth", block: "start" });
}
function tryFallbackCopy(text, btn) {
@@ -326,7 +373,7 @@ const showActionArea = computed(() => {
<v-icon>mdi-close</v-icon>
</v-btn>
</v-card-title>
<v-card-text style="overflow-y: auto">
<v-card-text ref="scrollContainer" style="overflow-y: auto">
<div v-if="showActionArea" class="d-flex justify-space-between mb-4">
<v-btn
v-if="modeConfig.showGithubButton && repoUrl"
@@ -436,6 +483,7 @@ const showActionArea = computed(() => {
margin-bottom: 16px;
font-weight: 600;
line-height: 1.25;
scroll-margin-top: 12px;
}
:deep(.markdown-body h1) {
+15 -17
View File
@@ -24,12 +24,12 @@ withDefaults(defineProps<{
})
</script>
<style scoped>
<style>
.styled-menu-card {
min-width: 100px;
width: fit-content;
border: 1px solid rgba(94, 53, 177, 0.15) !important;
background: #f8f6fc !important;
border: 1px solid rgba(var(--v-theme-primary), 0.15) !important;
background: rgba(var(--v-theme-surface), 0.98) !important;
backdrop-filter: blur(10px);
}
@@ -37,43 +37,41 @@ withDefaults(defineProps<{
background: transparent !important;
}
:deep(.styled-menu-item) {
.styled-menu-item {
margin: 2px 0;
transition: all 0.2s ease;
border-radius: 6px;
}
:deep(.styled-menu-item:hover) {
background: rgba(94, 53, 177, 0.08) !important;
.styled-menu-item:hover {
background: rgba(var(--v-theme-primary), 0.08) !important;
}
:deep(.styled-menu-item-active) {
background: rgba(94, 53, 177, 0.15) !important;
.styled-menu-item-active {
background: rgba(var(--v-theme-primary), 0.15) !important;
font-weight: 500;
}
:deep(.styled-menu-item-active:hover) {
background: rgba(94, 53, 177, 0.2) !important;
.styled-menu-item-active:hover {
background: rgba(var(--v-theme-primary), 0.2) !important;
}
</style>
<style>
/* 深色模式下的下拉框样式 - 需要全局样式才能检测主题 */
.v-theme--PurpleThemeDark .styled-menu-card {
background: #2a2733 !important;
border: 1px solid rgba(110, 60, 180, 0.692) !important;
background: rgba(var(--v-theme-surface), 0.98) !important;
border: 1px solid rgba(var(--v-theme-primary), 0.2) !important;
}
/* 深色模式下的列表项悬停效果 */
.v-theme--PurpleThemeDark .styled-menu-item:hover {
background: rgba(114, 46, 209, 0.12) !important;
background: rgba(var(--v-theme-primary), 0.12) !important;
}
.v-theme--PurpleThemeDark .styled-menu-item-active {
background: rgba(114, 46, 209, 0.2) !important;
background: rgba(var(--v-theme-primary), 0.2) !important;
}
.v-theme--PurpleThemeDark .styled-menu-item-active:hover {
background: rgba(114, 46, 209, 0.25) !important;
background: rgba(var(--v-theme-primary), 0.25) !important;
}
</style>
@@ -590,9 +590,11 @@ export function useProviderSources(options: UseProviderSourcesOptions) {
async function testProvider(provider: any) {
testingProviders.value.push(provider.id)
try {
const startTime = performance.now()
const response = await axios.get('/api/config/provider/check_one', { params: { id: provider.id } })
if (response.data.status === 'ok' && response.data.data.error === null) {
showMessage(tm('models.testSuccess', { id: provider.id }))
const latency = Math.max(0, Math.round(performance.now() - startTime))
showMessage(tm('models.testSuccessWithLatency', { id: provider.id, latency }))
} else {
throw new Error(response.data.data.error || tm('models.testError'))
}
+68
View File
@@ -109,6 +109,73 @@ export function useSessions(chatboxMode: boolean = false) {
}
}
interface BatchDeleteFailedItem {
session_id: string;
reason: string;
}
interface BatchDeleteResult {
deleted_count: number;
failed_count: number;
failed_items: BatchDeleteFailedItem[];
currentSessionDeleted: boolean;
}
function isBatchDeleteResponseData(data: unknown): data is {
deleted_count: number;
failed_count: number;
failed_items: BatchDeleteFailedItem[];
} {
if (!data || typeof data !== 'object') {
return false;
}
const payload = data as Record<string, unknown>;
return (
typeof payload.deleted_count === 'number' &&
typeof payload.failed_count === 'number' &&
Array.isArray(payload.failed_items)
);
}
async function batchDeleteSessions(sessionIds: string[]): Promise<BatchDeleteResult> {
try {
const currentSessionId = currSessionId.value;
const response = await axios.post('/api/chat/batch_delete_sessions', { session_ids: sessionIds });
if (response.data?.status !== 'ok') {
throw new Error(response.data?.message || 'Failed to batch delete sessions');
}
const data = response.data?.data;
if (!isBatchDeleteResponseData(data)) {
throw new Error('Invalid batch delete response payload');
}
const failedItems = data.failed_items;
const failedSessionIds = new Set(failedItems.map(item => item.session_id));
const currentSessionDeleted = Boolean(
currentSessionId &&
sessionIds.includes(currentSessionId) &&
!failedSessionIds.has(currentSessionId)
);
if (currentSessionDeleted) {
currSessionId.value = '';
selectedSessions.value = [];
}
await getSessions();
return {
deleted_count: data.deleted_count,
failed_count: data.failed_count,
failed_items: failedItems,
currentSessionDeleted,
};
} catch (err) {
console.error(err);
throw err;
}
}
function showEditTitleDialog(sessionId: string, title: string) {
editingSessionId.value = sessionId;
editingTitle.value = title || '';
@@ -167,6 +234,7 @@ export function useSessions(chatboxMode: boolean = false) {
getSessions,
newSession,
deleteSession,
batchDeleteSessions,
showEditTitleDialog,
saveTitle,
updateSessionTitle,
+27 -26
View File
@@ -11,7 +11,7 @@ const translations = ref<Record<string, any>>({});
*/
export async function initI18n(locale: Locale = 'zh-CN') {
currentLocale.value = locale;
// 加载静态翻译数据
loadTranslations(locale);
}
@@ -50,7 +50,7 @@ export function useI18n() {
const t = (key: string, params?: Record<string, string | number>): string => {
const keys = key.split('.');
let value: any = translations.value;
// 遍历键路径
for (const k of keys) {
if (value && typeof value === 'object' && k in value) {
@@ -61,35 +61,35 @@ export function useI18n() {
return `[MISSING: ${key}]`;
}
}
if (typeof value !== 'string') {
console.warn(`Translation value is not string: ${key}`, value);
// 返回带括号的键名,便于在开发时识别类型错误的翻译
return `[INVALID: ${key}]`;
}
// 此时value确定是string类型
let result: string = value;
// 处理参数插值
if (params) {
result = result.replace(/\{(\w+)\}/g, (match: string, paramKey: string) => {
return params[paramKey]?.toString() || match;
});
}
return result;
};
// 切换语言
const setLocale = async (newLocale: Locale) => {
if (newLocale !== currentLocale.value) {
currentLocale.value = newLocale;
loadTranslations(newLocale);
// 保存到localStorage
localStorage.setItem('astrbot-locale', newLocale);
// 触发自定义事件,通知相关页面重新加载配置数据
// 这是因为插件适配器的 i18n 数据是通过后端 API 注入的,
// 需要根据 Accept-Language 头重新获取
@@ -98,16 +98,16 @@ export function useI18n() {
}));
}
};
// 获取当前语言
const locale = computed(() => currentLocale.value);
// 获取可用语言列表
const availableLocales: Locale[] = ['zh-CN', 'en-US'];
const availableLocales: Locale[] = ['zh-CN', 'en-US', 'ru-RU'];
// 检查是否已加载
const isLoaded = computed(() => Object.keys(translations.value).length > 0);
return {
t,
locale,
@@ -122,13 +122,13 @@ export function useI18n() {
*/
export function useModuleI18n(moduleName: string) {
const { t } = useI18n();
const tm = (key: string, params?: Record<string, string | number>): string => {
// 将斜杠转换为点号以匹配嵌套对象结构
const normalizedModuleName = moduleName.replace(/\//g, '.');
return t(`${normalizedModuleName}.${key}`, params);
};
// 获取原始翻译值(可能是字符串、数组或对象)
const getRaw = (key: string): any => {
const normalizedModuleName = moduleName.replace(/\//g, '.');
@@ -143,10 +143,10 @@ export function useModuleI18n(moduleName: string) {
return null;
}
}
return value;
};
return { tm, getRaw };
}
@@ -155,20 +155,21 @@ export function useModuleI18n(moduleName: string) {
*/
export function useLanguageSwitcher() {
const { locale, setLocale, availableLocales } = useI18n();
const languageOptions = computed(() => [
{ value: 'zh-CN', label: '简体中文', flag: '🇨🇳' },
{ value: 'en-US', label: 'English', flag: '🇺🇸' }
{ value: 'en-US', label: 'English', flag: '🇺🇸' },
{ value: 'ru-RU', label: 'Русский', flag: '🇷🇺' }
]);
const currentLanguage = computed(() => {
return languageOptions.value.find(lang => lang.value === locale.value);
});
const switchLanguage = async (newLocale: Locale) => {
await setLocale(newLocale);
};
return {
locale,
languageOptions,
@@ -220,9 +221,9 @@ function deepMerge(target: Record<string, any>, source: Record<string, any>) {
export async function setupI18n() {
// 从localStorage获取保存的语言设置
const savedLocale = localStorage.getItem('astrbot-locale') as Locale;
const initialLocale = savedLocale && ['zh-CN', 'en-US'].includes(savedLocale)
? savedLocale
const initialLocale = savedLocale && ['zh-CN', 'en-US', 'ru-RU'].includes(savedLocale)
? savedLocale
: 'zh-CN';
await initI18n(initialLocale);
}
@@ -10,7 +10,8 @@
"theme": {
"light": "Light Mode",
"dark": "Dark Mode"
}
},
"logout": "Log Out"
},
"updateDialog": {
"title": "Update AstrBot",
@@ -96,6 +96,7 @@
"save": "Save",
"livePreview": "Live Preview (may differ)",
"refreshPreview": "Refresh Preview",
"previewText": "This is a sample text used to preview the template output.\n\nIt can contain multiple lines and various formatting.",
"syntaxHint": "Supports jinja2 syntax. Available variables: text | safe (text to render), version (AstrBot version)",
"saveAndApply": "Save and Apply Current Template",
"confirmReset": "Confirm Reset",
@@ -10,5 +10,16 @@
"theme": {
"switchToDark": "Switch to Dark Theme",
"switchToLight": "Switch to Light Theme"
},
"serverConfig": {
"title": "Server Configuration",
"description": "If the backend is not on the same origin (host/port), please specify the full URL here.",
"label": "API Base URL",
"placeholder": "e.g. http://localhost:6185",
"hint": "Empty for default (relative path)",
"presetLabel": "Quick Select Preset",
"save": "Save & Reload",
"cancel": "Cancel",
"tooltip": "Server Configuration"
}
}
}
@@ -71,10 +71,16 @@
"modes": {
"darkMode": "Switch to Dark Mode",
"lightMode": "Switch to Light Mode"
}, "shortcuts": {
},
"shortcuts": {
"help": "Get Help",
"voiceRecord": "Record Voice",
"pasteImage": "Paste Image"
"pasteImage": "Paste Image",
"sendKey": {
"title": "Send Shortcut",
"enterToSend": "Enter to send",
"shiftEnterToSend": "Shift+Enter to send"
}
},
"streaming": {
"enabled": "Streaming enabled",
@@ -141,5 +147,15 @@
"errors": {
"sendMessageFailed": "Failed to send message, please try again",
"createSessionFailed": "Failed to create session, please refresh the page"
},
"batch": {
"selected": "{count} selected",
"confirmDelete": "Are you sure you want to delete {count} conversation(s)? This action cannot be undone.",
"selectAll": "Select All",
"deselectAll": "Deselect All",
"delete": "Delete",
"exit": "Exit",
"partialFailure": "{failed} of {total} conversations failed to delete",
"requestFailed": "Failed to delete conversations. Please try again."
}
}
@@ -78,6 +78,7 @@
},
"persona": {
"description": "Persona",
"hint": "Set the default persona for AI conversations. Personas can be managed in the Persona tab.",
"provider_settings": {
"default_personality": {
"description": "Default Persona"
@@ -132,6 +132,7 @@
"deleteSuccess": "Model deleted successfully",
"deleteError": "Failed to delete model",
"testSuccess": "Model {id} test passed",
"testSuccessWithLatency": "Model {id} test passed, latency {latency} ms",
"testError": "Model test failed",
"searchPlaceholder": "Search models or ID",
"manualAddButton": "Custom Model",
@@ -1,6 +1,24 @@
{
"network": {
"title": "Network",
"proxy": {
"title": "Proxy",
"subtitle": "Configure proxy for network requests"
},
"server": {
"title": "Server Address",
"subtitle": "Configure backend API URL",
"label": "API Base URL",
"placeholder": "e.g. http://localhost:6185",
"hint": "Empty for default (relative path)",
"save": "Save & Reload",
"presets": "Presets",
"preset": {
"add": "Add Preset",
"name": "Name",
"url": "URL"
}
},
"githubProxy": {
"title": "GitHub Proxy Address",
"subtitle": "Set the GitHub proxy address used when downloading plugins or updating AstrBot. This is effective in mainland China's network environment. Can be customized, input takes effect in real time. All addresses do not guarantee stability. If errors occur when updating plugins/projects, please first check if the proxy address is working properly.",
@@ -26,6 +44,20 @@
"reset": "Reset to Default"
}
},
"style": {
"title": "Theme",
"color": {
"title": "Theme Colors",
"subtitle": "Customize theme primary and secondary colors. Changes apply immediately and are stored locally in your browser.",
"primary": "Primary Color",
"secondary": "Secondary Color"
}
},
"reset": {
"title": "Reset to Default",
"subtitle": "Reset theme colors to default settings",
"button": "Reset"
},
"system": {
"title": "System",
"restart": {
@@ -33,6 +65,11 @@
"subtitle": "Restart AstrBot",
"button": "Restart"
},
"logout": {
"title": "Log Out",
"subtitle": "Log out of the current account",
"button": "Log Out"
},
"migration": {
"title": "Data Migration to v4.0.0",
"subtitle": "If you encounter data compatibility issues, you can manually start the database migration assistant",
@@ -55,6 +92,10 @@
}
},
"backup": {
"title": "Backup",
"subtitle": "Manage data backups",
"operate": "Backup Operations",
"open": "Open Backup Manager",
"dialog": {
"title": "Backup Manager"
},
@@ -135,11 +176,12 @@
"subtitle": "Create API keys for external developers to call open HTTP APIs.",
"name": "Key Name",
"expiresInDays": "Expiration",
"expiryOptions": {
"day1": "1 day",
"day7": "7 days",
"day30": "30 days",
"day90": "90 days",
"expiry": {
"7days": "7 days",
"30days": "30 days",
"90days": "90 days",
"180days": "180 days",
"365days": "365 days",
"permanent": "Permanent"
},
"permanentWarning": "Permanent API keys are high risk. Store them securely and use only when necessary.",
@@ -12,6 +12,8 @@
"onboard": {
"title": "Quick Onboarding",
"subtitle": "Complete initialization directly on the welcome page.",
"step0Title": "Configure Backend URL",
"step0Desc": "Configure the backend API URL for AstrBot.",
"step1Title": "Configure Platform Bot",
"step1Desc": "Connect AstrBot to IM platforms like QQ, Lark, Slack, Telegram, etc.",
"step2Title": "Configure AI Model",
@@ -0,0 +1,24 @@
{
"create": "Создать",
"read": "Чтение",
"update": "Обновить",
"delete": "Удалить",
"search": "Поиск",
"filter": "Фильтр",
"sort": "Сортировка",
"export": "Экспорт",
"import": "Импорт",
"backup": "Резервное копирование",
"restore": "Восстановление",
"copy": "Копировать",
"paste": "Вставить",
"cut": "Вырезать",
"undo": "Отменить",
"redo": "Повторить",
"refresh": "Обновить",
"submit": "Отправить",
"reset": "Сбросить",
"clear": "Очистить",
"save": "Сохранить",
"close": "Закрыть"
}
@@ -0,0 +1,133 @@
{
"save": "Сохранить",
"cancel": "Отмена",
"close": "Закрыть",
"copy": "Копировать",
"copied": "Скопировано",
"copyFailed": "Ошибка копирования",
"delete": "Удалить",
"edit": "Редактировать",
"add": "Добавить",
"confirm": "Подтвердить",
"loading": "Загрузка...",
"success": "Успешно",
"error": "Ошибка",
"warning": "Внимание",
"info": "Информация",
"name": "Имя",
"description": "Описание",
"author": "Автор",
"status": "Статус",
"actions": "Действия",
"enable": "Включить",
"disable": "Выключить",
"enabled": "Включено",
"disabled": "Выключено",
"reload": "Перезагрузить",
"configure": "Настроить",
"install": "Установить",
"uninstall": "Удалить",
"update": "Обновить",
"language": "Язык",
"settings": "Настройки",
"locale": "JSON",
"type": "Тип",
"press": "Нажмите",
"longPress": "Долгое нажатие",
"yes": "Да",
"no": "Нет",
"imagePreview": "Предпросмотр изображения",
"autoDetect": "Автоопределение",
"dialog": {
"confirmTitle": "Подтверждение",
"confirmMessage": "Вы уверены, что хотите выполнить это действие?",
"confirmButton": "ОК",
"cancelButton": "Отмена"
},
"restart": {
"waiting": "Ожидание перезагрузки AstrBot...",
"maxRetriesReached": "Превышено количество попыток проверки статуса. Пожалуйста, проверьте вручную."
},
"readme": {
"title": "Документация плагина",
"buttons": {
"viewOnGithub": "Открыть репозиторий на GitHub",
"refresh": "Обновить"
},
"loading": "Загрузка README...",
"errors": {
"fetchFailed": "Не удалось загрузить README",
"fetchError": "Произошла ошибка при загрузке README"
},
"empty": {
"title": "У этого плагина нет ссылки на документацию или репозиторий GitHub.",
"subtitle": "Пожалуйста, посетите магазин плагинов или свяжитесь с автором для получения дополнительной информации."
}
},
"changelog": {
"title": "Журнал изменений",
"loading": "Загрузка журнала изменений...",
"empty": {
"title": "У этого плагина нет журнала изменений",
"subtitle": "Разработчики могут добавить файл CHANGELOG.md в директорию плагина"
}
},
"editor": {
"fullscreen": "На весь экран",
"editingTitle": "Редактирование содержимого"
},
"templateList": {
"addEntry": "Добавить запись",
"empty": "Записей нет, выберите шаблон для добавления",
"missingTemplate": "Шаблон не найден, пожалуйста, удалите и добавьте заново.",
"unknownTemplate": "Неизвестный шаблон"
},
"list": {
"addItemPlaceholder": "Добавьте новый элемент и нажмите Enter",
"addButton": "Добавить",
"addMore": "Добавить еще",
"batchImport": "Массовый импорт",
"batchImportTitle": "Массовый импорт",
"batchImportLabel": "Один элемент на строку",
"batchImportPlaceholder": "Например:\nЭлемент 1\nЭлемент 2\nЭлемент 3",
"batchImportHint": "Каждая строка будет считаться отдельным элементом. Пустые строки игнорируются.",
"batchImportButton": "Импортировать {count} эл.",
"noItems": "Список пуст",
"noItemsHint": "Элементов нет. Напишите что-нибудь выше и нажмите Enter.",
"inputPlaceholder": "Введите текст и нажмите Enter",
"editTitle": "Изменить элемент",
"modifyButton": "Изменить"
},
"itemCard": {
"enabled": "Включено",
"disabled": "Выключено",
"delete": "Удалить",
"edit": "Изменить",
"copy": "Копировать",
"noData": "Нет данных"
},
"objectEditor": {
"dialogTitle": "Изменение пар ключ-значение",
"noItems": "Нет элементов",
"noParams": "Нет параметров",
"presets": "Пресеты",
"newKeyLabel": "Имя ключа",
"valueTypeLabel": "Тип значения",
"keyExists": "Ключ уже существует",
"invalidJson": "Некорректный формат JSON",
"placeholders": {
"keyName": "Ключ",
"stringValue": "Строка",
"numberValue": "Число",
"jsonValue": "JSON"
}
},
"firstNotice": {
"title": "Первичная информация",
"loading": "Загрузка информации...",
"empty": {
"title": "Нет информации для отображения",
"subtitle": "Файл FIRST_NOTICE.md не найден или пуст."
}
}
}
@@ -0,0 +1,108 @@
{
"logoTitle": "Панель управления AstrBot",
"version": {
"hasNewVersion": "Доступна новая версия AstrBot!",
"dashboardHasNewVersion": "Доступна новая версия WebUI!"
},
"buttons": {
"update": "Обновить",
"account": "Аккаунт",
"theme": {
"light": "Светлая тема",
"dark": "Темная тема"
}
},
"updateDialog": {
"title": "Обновить AstrBot",
"currentVersion": "Текущая версия",
"status": {
"checking": "Проверка обновлений...",
"switching": "Переключение версии...",
"updating": "Обновление..."
},
"tabs": {
"release": "😊 Релиз"
},
"updateToLatest": "Обновить до последней версии",
"preRelease": "Предварительная версия",
"preReleaseWarning": {
"title": "Внимание: предварительная версия",
"description": "Версии с меткой Pre-release могут содержать неизвестные ошибки. Не рекомендуется использовать в рабочих средах. Если вы обнаружили ошибку, пожалуйста, сообщите о ней в ",
"issueLink": "GitHub Issues"
},
"tip": "💡 ПОДСКАЗКА: ",
"tipContinue": "По умолчанию при переключении версии загружаются соответствующие файлы WebUI. Код WebUI находится в директории dashboard, вы можете собрать его самостоятельно с помощью npm.",
"dockerTip": "При переключении версии будет предпринята попытка обновить как основной процесс бота, так и панель управления. Если вы используете Docker, вы также можете обновить образ или использовать",
"dockerTipLink": "watchtower",
"dockerTipContinue": "для автоматического мониторинга и обновления.",
"table": {
"tag": "Тег",
"publishDate": "Дата публикации",
"content": "Содержание",
"sourceUrl": "Исходный код",
"actions": "Действия",
"view": "Просмотр",
"switch": "Переключить"
},
"releaseNotes": {
"title": "Журнал изменений"
},
"redirectConfirm": {
"title": "Переход по ссылке",
"message": "Вы будете перенаправлены на страницу GitHub Releases. Продолжить?",
"latestLabel": "Последняя версия",
"targetVersion": "Целевая версия:",
"currentVersion": "Текущая версия:",
"guideTitle": "Рекомендации после перехода:",
"guideStep1": "Загрузите пакет, соответствующий архитектуре вашей системы.",
"guideStep2": "После завершения установки перезапустите AstrBot.",
"guideStep3": "Если вы используете Docker, отдайте приоритет обновлению через образ."
},
"desktopApp": {
"title": "Обновить десктопное приложение",
"message": "Проверка и обновление десктопной версии AstrBot.",
"currentVersion": "Текущая версия:",
"latestVersion": "Последняя версия:",
"checking": "Проверка обновлений десктопного приложения...",
"hasNewVersion": "Найдена новая версия. Нажмите для подтверждения обновления.",
"isLatest": "Установлена последняя версия",
"installing": "Загрузка и установка обновления... Приложение будет перезапущено автоматически.",
"checkFailed": "Ошибка проверки обновлений. Попробуйте позже.",
"installFailed": "Ошибка обновления. Попробуйте позже."
},
"dashboardUpdate": {
"title": "Обновить только панель управления",
"currentVersion": "Текущая версия",
"hasNewVersion": "Доступна новая версия!",
"isLatest": "Установлена последняя версия.",
"downloadAndUpdate": "Скачать и обновить"
}
},
"accountDialog": {
"title": "Изменить аккаунт",
"securityWarning": "Безопасность: Пожалуйста, смените пароль по умолчанию для защиты аккаунта",
"form": {
"currentPassword": "Текущий пароль",
"newPassword": "Новый пароль",
"confirmPassword": "Подтвердите новый пароль",
"newUsername": "Новое имя пользователя (опционально)",
"passwordHint": "Пароль должен быть не менее 8 символов",
"confirmPasswordHint": "Введите новый пароль еще раз",
"usernameHint": "Оставьте пустым, если не хотите менять имя пользователя",
"defaultCredentials": "Логин и пароль по умолчанию: astrbot"
},
"validation": {
"passwordRequired": "Введите пароль",
"passwordMinLength": "Пароль должен быть не менее 8 символов",
"passwordMatch": "Паролы не совпадают",
"usernameMinLength": "Имя пользователя должно быть не менее 3 символов"
},
"actions": {
"save": "Сохранить изменения",
"cancel": "Отмена"
},
"messages": {
"updateFailed": "Ошибка обновления, попробуйте еще раз"
}
}
}
@@ -0,0 +1,49 @@
{
"welcome": "Добро пожаловать",
"dashboard": "Статистика",
"platforms": "Боты",
"providers": "Провайдеры моделей",
"commands": "Команды",
"persona": "Персонажи",
"subagent": "Субагенты",
"toolUse": "Инструменты MCP",
"extension": "Плагины",
"extensionTabs": {
"installed": "Плагины AstrBot",
"market": "Магазин плагинов",
"mcp": "Серверы MCP",
"skills": "Навыки",
"components": "Управление поведением"
},
"config": "Конфигурация",
"chat": "Чат",
"cron": "Запланированные задачи",
"conversation": "Данные диалогов",
"sessionManagement": "Пользовательские правила",
"console": "Логи платформы",
"trace": "Трассировка",
"alkaid": "Alkaid Lab",
"knowledgeBase": "База знаний",
"about": "О программе",
"settings": "Настройки",
"changelog": "Журнал изменений",
"documentation": "Документация",
"faq": "FAQ",
"github": "GitHub",
"drag": "Перетащить",
"groups": {
"more": "Дополнительно"
},
"changelogDialog": {
"title": "Журнал изменений",
"loading": "Загрузка...",
"error": "Ошибка загрузки",
"notFound": "Журнал изменений для этой версии не найден",
"selectVersion": "Выберите версию",
"current": "Текущая"
},
"configTabs": {
"normal": "Обычная конфигурация",
"system": "Системная конфигурация"
}
}
@@ -0,0 +1,112 @@
{
"knowledgeBaseSelector": {
"notSelected": "Не выбрано",
"buttonText": "Выбрать базу знаний...",
"dialogTitle": "Выбор базы знаний",
"loading": "Загрузка...",
"noKnowledgeBases": "Базы знаний не найдены",
"createKnowledgeBase": "Создать базу знаний",
"selectedCount": "Выбрано баз знаний: {count}",
"confirmSelection": "ОК",
"cancelSelection": "Отмена",
"noDescription": "Нет описания",
"documentCount": "Документов: {count}",
"chunkCount": "Фрагментов: {count}"
},
"pluginSetSelector": {
"notSelected": "Плагины не включены",
"allPlugins": "Включить все плагины (*)",
"selectedCount": "Выбрано плагинов: {count}",
"buttonText": "Выбрать набор плагинов...",
"dialogTitle": "Выбор набора плагинов",
"loading": "Загрузка...",
"enableAll": "Включить все",
"enableNone": "Ничего не включать",
"customSelect": "Настроить выбор",
"noPlugins": "Доступных плагинов нет",
"confirmSelection": "ОК",
"cancelSelection": "Отмена",
"noDescription": "Нет описания",
"notActivated": "Не активирован",
"note": "*Системные и уже выключенные в настройках плагины не отображаются.",
"selectedPluginsLabel": "Выбранные плагины:",
"allPluginsLabel": "Все плагины"
},
"providerSelector": {
"notSelected": "Не выбрано",
"buttonText": "Выбрать провайдера...",
"dialogTitle": "Выбор провайдера",
"loading": "Загрузка...",
"noProviders": "Доступных провайдеров нет",
"confirmSelection": "ОК",
"cancelSelection": "Отмена",
"clearSelection": "Сбросить выбор",
"clearSelectionSubtitle": "Очистить текущий выбор",
"unknownType": "Неизвестный тип",
"createProvider": "Создать провайдера",
"manageProviders": "Управление провайдерами",
"selectProviderPool": "Выбрать пул провайдеров...",
"selectedCount": "Выбрано провайдеров: {count}"
},
"personaSelector": {
"notSelected": "Не выбрано",
"defaultPersona": "Персонаж по умолчанию",
"buttonText": "Выбрать персонажа...",
"editPersona": "Изменить текущего персонажа",
"dialogTitle": "Выбор персонажа",
"noDescription": "Нет описания",
"noPersonas": "Доступных персонажей нет",
"createPersona": "Создать персонажа",
"cancelSelection": "Отмена",
"confirmSelection": "ОК",
"selectPersonaPool": "Выбрать пул персонажей...",
"rootFolder": "Все персонажи",
"emptyFolder": "Папка пуста"
},
"personaQuickPreview": {
"title": "Быстрый просмотр",
"loading": "Загрузка...",
"noPersonaSelected": "Персонаж не выбран",
"personaNotFound": "Информация о персонаже не найдена",
"systemPromptLabel": "Системный промпт",
"toolsLabel": "Инструменты",
"skillsLabel": "Навыки (Skills)",
"originLabel": "Источник",
"originNameLabel": "Имя источника",
"toolInactive": "Выключено",
"toolInactiveTooltip": "Этот инструмент выключен. Включите его в Плагины -> Управление поведением -> Функции.",
"allTools": "Доступны все инструменты",
"allToolsWithCount": "Доступны все инструменты ({count})",
"noTools": "Инструменты не настроены",
"allSkills": "Доступны все навыки (Skills)",
"allSkillsWithCount": "Доступны все навыки ({count})",
"noSkills": "Навыки (Skills) не настроены"
},
"t2iTemplateEditor": {
"buttonText": "Настроить T2I шаблон",
"dialogTitle": "Настройка HTML шаблона Text-to-Image",
"newTemplateNameLabel": "Введите имя нового шаблона",
"nameRequired": "Имя обязательно для заполнения",
"selectTemplateLabel": "Выбрать шаблон",
"applied": "Применено",
"apply": "Применить",
"templateEditor": "Редактор шаблона",
"new": "Создать",
"resetBase": "Сбросить 'base'",
"delete": "Удалить",
"save": "Сохранить",
"livePreview": "Предпросмотр (может отличаться)",
"refreshPreview": "Обновить",
"previewText": "Это пример текста для предпросмотра результата шаблона.\n\nОн может содержать несколько строк и различные форматы.",
"syntaxHint": "Поддерживается синтаксис jinja2. Переменные: text | safe (текст для рендеринга), version (версия AstrBot)",
"saveAndApply": "Сохранить и применить текущий шаблон",
"confirmReset": "Подтверждение сброса",
"confirmResetMessage": "Вы уверены, что хотите сбросить шаблон 'base' до значений по умолчанию? Все несохраненные изменения будут потеряны. Это действие необратимо.",
"confirmResetButton": "Сбросить",
"confirmDelete": "Подтверждение удаления",
"confirmDeleteMessage": "Вы уверены, что хотите удалить шаблон '{name}'? Это действие необратимо.",
"confirmDeleteButton": "Удалить",
"confirmAction": "Подтверждение действия",
"confirmApplyMessage": "Вы уверены, что хотите сохранить изменения в '{name}' и сделать его активным шаблоном?"
}
}
@@ -0,0 +1,22 @@
{
"loading": "Загрузка",
"success": "Успешно",
"error": "Ошибка",
"warning": "Внимание",
"info": "Информация",
"pending": "В ожидании",
"processing": "В процессе",
"completed": "Завершено",
"failed": "Ошибка",
"cancelled": "Отменено",
"timeout": "Тайм-аут",
"connecting": "Подключение",
"connected": "Подключено",
"disconnected": "Отключено",
"online": "В сети",
"offline": "Не в сети",
"active": "Активен",
"inactive": "Неактивен",
"ready": "Готов",
"busy": "Занят"
}
@@ -0,0 +1,17 @@
{
"hero": {
"title": "AstrBot",
"subtitle": "Проект, рожденный из интереса и любви ❤️",
"starButton": "Star этот проект! 🌟",
"issueButton": "Сообщить об ошибке"
},
"contributors": {
"title": "Контрибьюторы",
"description": "Этот проект поддерживается участниками open-source сообщества. Спасибо каждому за вклад!",
"viewLink": "Посмотреть всех участников"
},
"stats": {
"title": "Глобальное развертывание",
"license": "AstrBot распространяется по лицензии AGPL v3"
}
}
@@ -0,0 +1,44 @@
{
"title": "Лаборатория Alkaid",
"subtitle": "Исследуйте передовые возможности AI",
"comingSoon": "Этот мир еще впереди, заходите позже!",
"page": {
"title": "Проект Alkaid.",
"subtitle": "AstrBot Alpha Project",
"navigation": {
"knowledgeBase": "База знаний (Плагин)",
"longTermMemory": "Долгосрочная память",
"other": "..."
}
},
"features": {
"knowledgeBase": "База знаний",
"longTermMemory": "Долгосрочная память",
"advancedChat": "Продвинутый чат",
"multiModal": "Мультимодальность"
},
"status": {
"experimental": "Экспериментально",
"beta": "Бета",
"stable": "Стабильно",
"deprecated": "Устарело"
},
"sigma": {
"subtitle": "Экспериментальный проект AstrBot",
"visualization": "Визуализация",
"filterUserId": "Фильтр по User ID",
"filter": "Фильтр",
"resetFilter": "Сброс",
"refreshGraph": "Обновить граф",
"nodeDetails": "Детали узла",
"id": "ID",
"type": "Тип",
"name": "Имя",
"userId": "ID пользователя",
"timestamp": "Метка времени",
"graphStats": "Статистика графа",
"nodeCount": "Узлов",
"edgeCount": "Связей",
"inDevelopment": "В разработке"
}
}
@@ -0,0 +1,155 @@
{
"title": "База знаний",
"subtitle": "Управление контентом базы знаний и поиск",
"documents": {
"title": "Список документов",
"name": "Имя файла",
"size": "Размер",
"uploadTime": "Дата загрузки",
"status": "Статус",
"actions": "Действия"
},
"management": {
"delete": "Удалить",
"preview": "Предпросмотр",
"download": "Скачать",
"reindex": "Переиндексировать"
},
"notInstalled": {
"title": "Плагин базы знаний не установлен",
"install": "Установить сейчас"
},
"empty": {
"title": "База знаний пуста. Создайте свою первую базу! 🙂",
"create": "Создать базу знаний"
},
"list": {
"title": "Список баз знаний",
"create": "Создать базу знаний",
"config": "Настройка",
"checkUpdate": "Проверить обновления плагина",
"updatePlugin": "Обновить плагин до версии {version}",
"knowledgeCount": "записей",
"tips": "Совет: используйте команду /kb в чате, чтобы узнать, как пользоваться базой!"
},
"createDialog": {
"title": "Создание базы знаний",
"nameLabel": "Название",
"descriptionLabel": "Описание",
"descriptionPlaceholder": "Краткое описание...",
"embeddingModelLabel": "Embedding модель",
"rerankModelLabel": "Rerank модель",
"providerInfo": "Провайдер: {id} | Размерность: {dimensions}",
"rerankProviderInfo": "Провайдер: {id}",
"tips": "Совет: после выбора Embedding модели не рекомендуется менять провайдера или размерность векторов, так как это сделает текущий индекс нечитаемым.",
"cancel": "Отмена",
"create": "Создать"
},
"emojiPicker": {
"title": "Выберите иконку",
"close": "Закрыть",
"categories": {
"emotions": "Смайлы",
"animals": "Животные и природа",
"food": "Еда и напитки",
"activities": "Занятия и вещи",
"travel": "Места и путешествия",
"symbols": "Символы и флаги"
}
},
"contentDialog": {
"title": "Управление базой знаний",
"embeddingModel": "Embedding модель",
"vectorDimension": "Размерность",
"usage": "Использование: введите «/kb use {name}» в чате",
"tabs": {
"upload": "Загрузка файлов",
"search": "Поиск",
"fromURL": "Импорт из URL"
}
},
"upload": {
"title": "Загрузка файлов",
"subtitle": "Поддерживаются форматы txt, pdf, word, excel и др.",
"dropzone": "Перетащите файлы сюда или нажмите для выбора",
"chunkSettings": {
"title": "Настройка фрагментации (Chunking)",
"tooltip": "Размер фрагмента определяет объем текста в одном блоке. Перекрытие позволяет сохранить контекст между соседними блоками.\nМаленькие фрагменты точнее, но увеличивают объем базы.",
"chunkSizeLabel": "Размер фрагмента",
"chunkSizeHint": "Длина текста в одном блоке (пусто = по умолчанию)",
"overlapLabel": "Перекрытие",
"overlapHint": "Нахлест между соседними блоками (пусто = по умолчанию)"
},
"upload": "Начать загрузку",
"uploading": "Загрузка..."
},
"search": {
"queryLabel": "Поиск по базе знаний",
"queryPlaceholder": "Введите ключевые слова...",
"resultCountLabel": "Количество результатов",
"searching": "Поиск...",
"resultsTitle": "Результаты поиска",
"relevance": "Релевантность",
"noResults": "Совпадений не найдено"
},
"deleteDialog": {
"title": "Подтверждение удаления",
"confirmText": "Вы уверены, что хотите удалить базу знаний «{name}»?",
"warning": "Это действие необратимо. Весь контент базы знаний будет навсегда удален.",
"cancel": "Отмена",
"delete": "Удалить"
},
"messages": {
"pluginNotAvailable": "Плагин не установлен или недоступен",
"pluginNotActivated": "Плагин astrbot_plugin_knowledge_base не включен. Пожалуйста, активируйте его в разделе плагинов и перезапустите AstrBot.",
"checkPluginFailed": "Не удалось проверить плагин",
"installFailed": "Ошибка установки",
"installPluginFailed": "Не удалось установить плагин",
"getKnowledgeBaseListFailed": "Ошибка получения списка баз знаний",
"knowledgeBaseCreated": "База знаний создана",
"createFailed": "Ошибка создания",
"createKnowledgeBaseFailed": "Не удалось создать базу знаний",
"pleaseEnterKnowledgeBaseName": "Укажите название базы знаний",
"pleaseSelectFile": "Пожалуйста, сначала выберите файл",
"operationSuccess": "Успешно: {message}",
"uploadFailed": "Ошибка загрузки",
"fileUploadFailed": "Не удалось загрузить файл",
"pleaseEnterSearchContent": "Введите текст для поиска",
"noMatchingContent": "Ничего не найдено",
"searchFailed": "Ошибка поиска",
"searchKnowledgeBaseFailed": "Не удалось выполнить поиск",
"deleteTargetNotExists": "Объект для удаления не найден",
"knowledgeBaseDeleted": "База знаний удалена",
"deleteFailed": "Ошибка удаления",
"deleteKnowledgeBaseFailed": "Не удалось удалить базу знаний",
"getEmbeddingModelListFailed": "Не удалось загрузить список Embedding моделей",
"updateAvailable": "Доступна новая версия: {current} -> {latest}",
"pluginUpToDate": "У вас последняя версия плагина",
"pluginNotFoundInMarket": "Плагин не найден в магазине",
"checkUpdateFailed": "Ошибка проверки обновлений",
"updateSuccess": "Плагин успешно обновлен",
"updateFailed": "Ошибка обновления",
"updatePluginFailed": "Не удалось обновить плагин"
},
"importFromUrl": {
"title": "Импорт из URL",
"urlLabel": "Адрес страницы",
"urlPlaceholder": "Введите URL для извлечения знаний",
"optionsTitle": "Настройки импорта",
"tooltip": "Эти параметры управляют извлечением текста из URL.\nЕсли оставить пустыми, будут использованы настройки по умолчанию.\nТекстовая очистка через LLM может занять время.",
"useLlmRepairLabel": "Исправление текста через LLM",
"useClusteringSummaryLabel": "Кластеризация и суммаризация",
"repairLlmProviderIdLabel": "Модель для очистки",
"summarizeLlmProviderIdLabel": "Модель для суммаризации",
"embeddingProviderIdLabel": "Embedding модель",
"chunkSizeLabel": "Размер фрагмента",
"chunkOverlapLabel": "Перекрытие",
"startImport": "Начать импорт",
"importing": "Импорт...",
"importSuccess": "Импортировано успешно",
"importFailed": "Ошибка импорта",
"uploadingChunks": "Текст извлечен, загрузка фрагментов...",
"preRequisite": "Примечание: сначала установите плагин astrbot_plugin_url_2_knowledge_base и выполните установку playwright согласно документации.",
"allChunksUploaded": "Все фрагменты успешно загружены"
}
}
@@ -0,0 +1,97 @@
{
"title": "Долгосрочная память",
"subtitle": "Управление памятью вашего AI-помощника",
"memories": {
"title": "Список воспоминаний",
"content": "Содержание",
"importance": "Важность",
"createTime": "Дата создания",
"lastAccess": "Последнее обращение",
"category": "Категория"
},
"categories": {
"personal": "Личное",
"preferences": "Предпочтения",
"conversations": "История диалогов",
"facts": "Факты",
"skills": "Навыки"
},
"importance": {
"high": "Высокая",
"medium": "Средняя",
"low": "Низкая"
},
"actions": {
"view": "Детали",
"edit": "Изменить",
"delete": "Удалить",
"pin": "Закрепить",
"unpin": "Открепить"
},
"filters": {
"all": "Все",
"category": "По категории",
"importance": "По важности",
"dateRange": "По периоду",
"title": "Фильтр",
"userIdLabel": "Фильтр по User ID",
"filterButton": "Применить",
"resetButton": "Сбросить",
"refreshButton": "Обновить граф"
},
"search": {
"title": "Поиск по памяти",
"userIdLabel": "ID пользователя",
"queryLabel": "Ключевое слово",
"searchButton": "Поиск",
"resultsTitle": "Результаты поиска",
"noResults": "Ничего не найдено",
"similarity": "Сходство",
"noTextContent": "Нет текста"
},
"addMemory": {
"title": "Добавить данные в память",
"textLabel": "Текст воспоминания",
"userIdLabel": "ID пользователя",
"summarizeLabel": "Нужна суммаризация",
"addButton": "Добавить"
},
"nodeDetails": {
"title": "Детали узла",
"id": "ID",
"type": "Тип",
"name": "Имя",
"userId": "ID пользователя",
"timestamp": "Метка времени"
},
"graphStats": {
"title": "Статистика графа",
"nodeCount": "Узлов",
"edgeCount": "Связей"
},
"factDialog": {
"title": "Факт из памяти",
"id": "ID",
"docId": "ID документа",
"createdAt": "Создано",
"updatedAt": "Обновлено",
"metadata": "Метаданные",
"metadataKey": "Ключ",
"metadataValue": "Значение",
"loading": "Загрузка...",
"close": "Закрыть",
"noValue": "нет",
"unknown": "неизвестно"
},
"messages": {
"searchQueryRequired": "Пожалуйста, введите запрос",
"searchSuccess": "Найдено записей: {count}",
"searchNoResults": "В памяти ничего не найдено",
"searchError": "Ошибка поиска",
"addSuccess": "Данные успешно добавлены в память!",
"addError": "Не удалось добавить данные",
"factDetailsError": "Ошибка загрузки деталей",
"metadataParseError": "Не удалось разобрать метаданные",
"relationNoMemoryData": "У этой связи нет ассоциированных данных"
}
}
@@ -0,0 +1,14 @@
{
"login": "Вход",
"username": "Имя пользователя",
"password": "Пароль",
"defaultHint": "Логин и пароль по умолчанию: astrbot",
"logo": {
"title": "Панель управления AstrBot",
"subtitle": "Добро пожаловать"
},
"theme": {
"switchToDark": "Перейти на темную тему",
"switchToLight": "Перейти на светлую тему"
}
}
@@ -0,0 +1,4 @@
{
"messageCount": "Количество сообщений",
"time": "Время"
}
@@ -0,0 +1,151 @@
{
"title": "Давай пообщаемся!",
"subtitle": "Общение с AI-помощником",
"input": {
"placeholder": "Введите сообщение...",
"send": "Отправить",
"clear": "Очистить",
"upload": "Загрузить файл",
"voice": "Голосовой ввод",
"recordingPrompt": "Запись... говорите",
"chatPrompt": "Давай пообщаемся!",
"dropToUpload": "Отпустите, чтобы загрузить файл",
"stopGenerating": "Остановить генерацию"
},
"message": {
"user": "Вы",
"assistant": "Ассистент",
"system": "Система",
"error": "Ошибка в сообщении",
"loading": "Думаю..."
},
"voice": {
"start": "Начать запись",
"stop": "Стоп",
"recording": "Запись",
"processing": "Обработка...",
"error": "Ошибка записи",
"listening": "Слушаю...",
"speaking": "Говорю",
"startRecording": "Начать голосовой ввод",
"liveMode": "Общение в реальном времени"
},
"welcome": {
"title": "Добро пожаловать в AstrBot",
"subtitle": "Ваш умный помощник",
"quickActions": "Быстрые действия",
"examples": "Примеры вопросов"
},
"actions": {
"copy": "Копировать",
"regenerate": "Перегенерировать",
"like": "Нравится",
"dislike": "Не нравится",
"share": "Поделиться",
"newChat": "Новый чат",
"deleteChat": "Удалить чат",
"editTitle": "Изменить заголовок",
"fullscreen": "На весь экран",
"exitFullscreen": "Выход из полноэкранного режима",
"reply": "Ответить",
"providerConfig": "Настройки AI",
"toolsUsed": "Использованные инструменты",
"toolCallUsed": "Использован инструмент {name}",
"pythonCodeAnalysis": "Использован анализ кода Python"
},
"ipython": {
"output": "Вывод"
},
"conversation": {
"newConversation": "Новый чат",
"noHistory": "История диалогов пуста",
"systemStatus": "Статус системы",
"llmService": "Сервис LLM",
"speechToText": "Преобразование речи",
"editDisplayName": "Изменить имя чата",
"displayName": "Имя чата",
"displayNameUpdated": "Имя чата обновлено",
"displayNameUpdateFailed": "Не удалось обновить имя чата",
"confirmDelete": "Вы уверены, что хотите удалить «{name}»? Это действие необратимо."
},
"modes": {
"darkMode": "Темная тема",
"lightMode": "Светлая тема"
},
"shortcuts": {
"help": "Справка",
"voiceRecord": "Запись голоса",
"pasteImage": "Вставить изображение",
"sendKey": {
"title": "Клавиша отправки",
"enterToSend": "Enter для отправки",
"shiftEnterToSend": "Shift+Enter для отправки"
}
},
"streaming": {
"enabled": "Потоковый ответ включен",
"disabled": "Потоковый ответ выключен",
"on": "Поток",
"off": "Обычный"
},
"transport": {
"title": "Протокол передачи",
"sse": "SSE",
"websocket": "WebSocket"
},
"config": {
"title": "Конфигурация"
},
"reasoning": {
"thinking": "Рассуждение"
},
"reply": {
"replyTo": "В ответ на",
"notFound": "Сообщение не найдено"
},
"project": {
"title": "Проект",
"create": "Создать проект",
"edit": "Изменить проект",
"name": "Имя проекта",
"emoji": "Иконка (Emoji)",
"description": "Описание проекта (опционально)",
"noSessions": "В этом проекте пока нет диалогов",
"confirmDelete": "Вы уверены, что хотите удалить проект «{title}»? Диалоги внутри проекта не будут удалены."
},
"time": {
"today": "Сегодня",
"yesterday": "Вчера"
},
"stats": {
"tokens": "Токены",
"inputTokens": "Входящие",
"outputTokens": "Исходящие",
"cachedTokens": "Кэшированные",
"duration": "Время",
"ttft": "Время до первого токена"
},
"refs": {
"title": "Ссылки",
"sources": "Источники"
},
"connection": {
"title": "Статус подключения",
"message": "Системе необходимо переустановить соединение с чатом.",
"reasons": "Это может быть вызвано следующими причинами:",
"reasonWindowResize": "Изменение размера окна (нормально)",
"reasonMultipleTabs": "Страница чата открыта в другой вкладке",
"reasonNetworkIssue": "Временная проблема с сетью",
"notice": "Примечание: для стабильной работы допускается только одно активное соединение. Если вы используете чат в нескольких вкладках, рекомендуем оставить только одну.",
"understand": "Понятно",
"status": {
"reconnecting": "Переподключение...",
"reconnected": "Соединение восстановлено",
"failed": "Ошибка подключения, обновите страницу"
}
},
"errors": {
"sendMessageFailed": "Ошибка отправки сообщения, попробуйте еще раз",
"createSessionFailed": "Ошибка создания сессии, обновите страницу"
}
}
@@ -0,0 +1,95 @@
{
"title": "Управление командами",
"summary": {
"total": "Всего команд",
"disabled": "Отключено",
"conflicts": "Конфликты"
},
"conflictAlert": {
"title": "Обнаружены конфликты команд",
"description": "Сейчас конфликтуют {count} пары команд. Это может привести к одновременному срабатыванию нескольких плагинов и непредсказуемому поведению.",
"hint": "Нажмите «Переименовать», чтобы изменить название конфликтующей команды."
},
"table": {
"headers": {
"command": "Команда",
"type": "Тип",
"plugin": "Плагин",
"description": "Описание",
"permission": "Доступ",
"status": "Статус",
"actions": "Действия"
}
},
"type": {
"command": "Команда",
"group": "Группа команд",
"subCommand": "Под-команда"
},
"status": {
"enabled": "Активна",
"disabled": "Отключена",
"conflict": "Конфликт"
},
"permission": {
"everyone": "Все",
"admin": "Админ"
},
"tooltips": {
"enable": "Включить",
"disable": "Выключить",
"rename": "Переименовать",
"viewDetails": "Подробности"
},
"dialogs": {
"rename": {
"title": "Переименование команды",
"newName": "Новое название",
"aliases": "Управление алиасами",
"addAlias": "Добавить алиас",
"cancel": "Отмена",
"confirm": "Подтвердить"
},
"details": {
"title": "Детали команды",
"type": "Тип команды",
"handler": "Обработчик (Handler)",
"module": "Путь к модулю",
"originalCommand": "Исходная команда",
"effectiveCommand": "Действующая команда",
"parentGroup": "Родительская группа",
"subCommands": "Под-команды",
"aliases": "Алиасы (Синонимы)",
"permission": "Требования прав",
"conflictStatus": "Статус конфликта"
}
},
"messages": {
"toggleSuccess": "Статус команды обновлен",
"toggleFailed": "Не удалось изменить статус команды",
"renameSuccess": "Команда переименована",
"renameFailed": "Ошибка переименования",
"loadFailed": "Ошибка загрузки списка команд",
"updateSuccess": "Обновлено успешно",
"updateFailed": "Ошибка обновления"
},
"search": {
"placeholder": "Поиск команд..."
},
"empty": {
"noCommands": "Команд не найдено",
"noCommandsDesc": "По вашему запросу не найдено ни одной команды"
},
"filters": {
"all": "Все",
"enabled": "Активные",
"disabled": "Отключенные",
"conflict": "Конфликтующие",
"byPlugin": "По плагину",
"byType": "По типу",
"byPermission": "По правам",
"byStatus": "По статусу",
"showSystemPlugins": "Показывать системные плагины",
"systemPluginConflictHint": "Конфликт затрагивает системный плагин, его нельзя скрыть до разрешения конфликта"
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,129 @@
{
"title": "Конфигурация",
"subtitle": "Управление системными настройками",
"editor": {
"visual": "Визуальный редактор",
"code": "Редактор кода",
"revertCode": "Отменить изменения",
"applyConfig": "Применить",
"applyTip": "Кнопка «Применить» временно фиксирует изменения в визуальном редакторе. Чтобы сохранить их на постоянной основе, нажмите кнопку «Сохранить» в правом нижнем углу."
},
"actions": {
"save": "Сохранить",
"delete": "Удалить",
"add": "Добавить",
"reset": "Сбросить настройки",
"export": "Экспорт",
"import": "Импорт",
"validate": "Проверить"
},
"help": {
"documentation": "Документация",
"support": "Поддержка",
"helpText": "Нужна помощь? См. {documentation} или обратитесь в {support}.",
"helpPrefix": "Нужна помощь? См.",
"helpMiddle": "или обратитесь в",
"helpSuffix": "."
},
"messages": {
"configApplied": "Настройки применены образно. Нажмите «Сохранить» для окончательной записи.",
"configApplyError": "Ошибка применения: некорректный формат JSON.",
"unsavedChangesNotice": "Есть несохраненные изменения. Пожалуйста, нажмите «Сохранить», чтобы они вступили в силу.",
"saveSuccess": "Настройки успешно сохранены",
"saveError": "Ошибка при сохранении",
"loadError": "Ошибка при загрузке настроек",
"deleteSuccess": "Удалено",
"deleteError": "Ошибка удаления",
"updateSuccess": "Обновлено",
"updateError": "Ошибка обновления"
},
"sections": {
"general": "Основные",
"advanced": "Расширенные",
"security": "Безопасность",
"appearance": "Внешний вид",
"notification": "Уведомления"
},
"general": {
"botName": "Имя бота",
"language": "Язык интерфейса",
"timezone": "Часовой пояс",
"autoSave": "Автосохранение",
"debugMode": "Режим отладки"
},
"advanced": {
"logLevel": "Уровень логирования",
"maxConnections": "Макс. соединений",
"timeout": "Тайм-аут",
"retryAttempts": "Попытки повтора",
"cacheSize": "Размер кэша"
},
"security": {
"apiKey": "Ключ API",
"allowedHosts": "Разрешенные хосты",
"rateLimit": "Лимит запросов",
"encryption": "Шифрование"
},
"configSelection": {
"selectConfig": "Выбор конфигурации",
"normalConfig": "Обычная",
"systemConfig": "Системная"
},
"search": {
"placeholder": "Поиск по настройкам (поле/описание/подсказка)",
"noResult": "Совпадений не найдено"
},
"configManagement": {
"title": "Управление конфигурациями",
"description": "AstrBot поддерживает несколько конфигураций для разных ботов. По умолчанию используется «default».",
"newConfig": "Новая конфигурация",
"editConfig": "Изменить конфигурацию",
"manageConfigs": "Управление файлами...",
"configName": "Имя",
"fillConfigName": "Введите имя конфигурации",
"confirmDelete": "Вы уверены, что хотите удалить конфигурацию «{name}»? Это действие необратимо.",
"pleaseEnterName": "Пожалуйста, введите имя",
"createFailed": "Ошибка создания конфигурации",
"deleteFailed": "Ошибка удаления",
"updateFailed": "Ошибка обновления"
},
"buttons": {
"cancel": "Отмена",
"create": "Создать",
"update": "Обновить"
},
"codeEditor": {
"title": "Редактирование файла"
},
"fileUpload": {
"button": "Файлы",
"dialogTitle": "Загруженные файлы",
"dropzone": "Загрузить файлы",
"allowedTypes": "Разрешенные типы: {types}",
"empty": "Файлов нет",
"statusMissing": "Файл отсутствует",
"statusUnconfigured": "Не в конфиге",
"uploadSuccess": "Загружено файлов: {count}",
"uploadFailed": "Ошибка загрузки",
"loadFailed": "Ошибка получения списка файлов",
"fileTooLarge": "Файл слишком велик (макс. {max} МБ): {name}",
"deleteSuccess": "Файл удален",
"deleteFailed": "Ошибка удаления",
"addToConfig": "Добавлено в конфигурацию",
"fileCount": "Файлов: {count}",
"done": "Готово"
},
"unsavedChangesWarning": {
"dialogTitle": "Несохраненные изменения",
"leavePage": "У вас есть несохраненные изменения. Сохранить перед уходом?",
"switchConfig": "Переключение конфигурации приведет к потере несохраненных изменений. Сохранить?",
"options": {
"save": "Сохранить",
"saveAndSwitch": "Сохранить и переключить",
"discardAndSwitch": "Сбросить и переключить",
"closeCard": "Закрыть",
"confirm": "ОК",
"cancel": "Отмена"
}
}
}
@@ -0,0 +1,18 @@
{
"title": "Логи платформы",
"autoScroll": {
"enabled": "Автопрокрутка включена",
"disabled": "Автопрокрутка выключена"
},
"pipInstall": {
"button": "Установить pip-пакет",
"dialogTitle": "Установка Pip-пакета",
"packageLabel": "*Имя пакета, например: llmtuner",
"mirrorLabel": "Использовать зеркало PyPI (опционально)",
"mirrorHint": "Приоритет зеркала PyPI > настройки «Зеркало репозитория PyPI»",
"installButton": "Установить"
},
"debugHint": {
"text": "Для отображения Debug-логов необходимо установить соответствующий уровень в «Конфигурация → Система → Уровень логирования»"
}
}
@@ -0,0 +1,102 @@
{
"title": "Управление диалогами",
"subtitle": "Просмотр и управление историей сообщений",
"filters": {
"title": "Фильтры",
"platform": "ID бота",
"type": "Тип",
"search": "Поиск по ключевым словам",
"reset": "Сбросить"
},
"history": {
"title": "История",
"refresh": "Обновить"
},
"batch": {
"deleteSelected": "Удалить выбранные ({count})",
"exportSelected": "Экспорт выбранных ({count})"
},
"pagination": {
"itemsPerPage": "на странице",
"showingItems": "Показано {start}-{end} из {total}"
},
"table": {
"headers": {
"title": "Заголовок диалога",
"platform": "ID бота",
"type": "Тип сообщения",
"cid": "ID диалога",
"umo": "Источник сообщения",
"sessionId": "ID сессии",
"createdAt": "Создан",
"updatedAt": "Обновлен",
"actions": "Действия"
}
},
"actions": {
"view": "Просмотр",
"edit": "Редактировать",
"delete": "Удалить"
},
"messageTypes": {
"group": "Группа",
"friend": "ЛС",
"unknown": "Неизвестно"
},
"status": {
"noTitle": "Без заголовка",
"unknown": "Неизвестно",
"noData": "История диалогов пуста",
"emptyContent": "Содержимое диалога пусто",
"audioNotSupported": "Ваш браузер не поддерживает воспроизведение аудио."
},
"dialogs": {
"view": {
"title": "Детали диалога",
"editMode": "Режим редактирования",
"previewMode": "Режим просмотра",
"saveChanges": "Сохранить изменения",
"close": "Закрыть",
"confirmClose": "У вас есть несохраненные изменения. Вы уверены, что хотите закрыть?"
},
"edit": {
"title": "Изменить информацию",
"titleLabel": "Заголовок диалога",
"titlePlaceholder": "Введите заголовок",
"cancel": "Отмена",
"save": "Сохранить"
},
"delete": {
"title": "Подтверждение удаления",
"message": "Вы уверены, что хотите удалить диалог «{title}»? Это действие необратимо.",
"cancel": "Отмена",
"confirm": "Удалить"
},
"batchDelete": {
"title": "Массовое удаление",
"message": "Вы уверены, что хотите удалить {count} выбранных диалогов? Это действие необратимо!",
"andMore": "и еще {count}",
"cancel": "Отмена",
"confirm": "Удалить всё",
"warning": "Внимание: удаление нельзя будет отменить!"
}
},
"messages": {
"fetchError": "Не удалось загрузить список диалогов",
"saveSuccess": "Сохранено",
"saveError": "Ошибка сохранения",
"deleteSuccess": "Удалено",
"deleteError": "Ошибка удаления",
"historyError": "Не удалось загрузить историю диалога",
"historySaveSuccess": "История сохранена",
"historySaveError": "Ошибка сохранения истории",
"invalidJson": "Некорректный формат JSON",
"noItemSelected": "Сначала выберите диалоги для удаления",
"batchDeleteSuccess": "Успешно удалено {count} диалогов",
"batchDeleteError": "Ошибка массового удаления",
"batchDeletePartial": "Удаление завершено: успешно {deleted}, ошибок {failed}",
"exportSuccess": "Экспорт завершен",
"exportError": "Ошибка экспорта",
"noItemSelectedForExport": "Сначала выберите диалоги для экспорта"
}
}
@@ -0,0 +1,66 @@
{
"page": {
"title": "Запланированные задачи",
"beta": "Экспериментальные функции",
"subtitle": "Управление будущими задачами AstrBot. Бот автоматически проснется, выполнит задачу и отправит результат. Требуется включить «Проактивные способности» в конфигурации.",
"proactive": {
"supported": "Отправка результатов поддерживается на платформах: {platforms}",
"unsupported": "Нет платформ, поддерживающих проактивные сообщения. Включите их в настройках платформ."
}
},
"actions": {
"create": "Новая задача",
"refresh": "Обновить",
"delete": "Удалить",
"cancel": "Отмена",
"submit": "Создать"
},
"table": {
"title": "Список задач",
"empty": "Задач пока нет.",
"headers": {
"name": "Имя",
"type": "Тип",
"cron": "Cron",
"session": "ID сессии",
"nextRun": "Следующий запуск",
"lastRun": "Последний запуск",
"note": "Описание",
"actions": "Действия"
},
"type": {
"once": "Разовая",
"recurring": "Повторяющаяся",
"activeAgent": "Активный агент",
"workflow": "Рабочий процесс",
"unknown": "{type}"
},
"timezoneLocal": "Местное время",
"notAvailable": "—"
},
"form": {
"title": "Создать задачу",
"chatHint": "Вы можете ставить задачи прямо в чате, AstrBot создаст их автоматически без заполнения этой формы.",
"runOnce": "Разовая задача",
"name": "Имя задачи",
"note": "Описание",
"cron": "Cron-выражения",
"cronPlaceholder": "0 9 * * *",
"runAt": "Время запуска",
"session": "Целевая сессия (platform_id:message_type:session_id)",
"timezone": "Часовой пояс (опционально, напр. Europe/Moscow)",
"enabled": "Включено"
},
"messages": {
"loadFailed": "Ошибка загрузки задач",
"updateFailed": "Ошибка обновления",
"deleteSuccess": "Удалено",
"deleteFailed": "Ошибка удаления",
"sessionRequired": "Укажите сессию",
"noteRequired": "Заполните описание",
"cronRequired": "Укажите Cron-выражение",
"runAtRequired": "Выберите время запуска",
"createSuccess": "Задача создана",
"createFailed": "Ошибка создания"
}
}
@@ -0,0 +1,65 @@
{
"title": "Логи платформы",
"subtitle": "Мониторинг и статистика в реальном времени",
"lastUpdate": "Последнее обновление",
"status": {
"loading": "Загрузка...",
"dataError": "Ошибка получения данных",
"noticeError": "Ошибка получения объявлений",
"online": "В сети",
"uptime": "Время работы",
"memoryUsage": "Память"
},
"stats": {
"totalMessage": {
"title": "Всего сообщений",
"subtitle": "Все сообщения со всех платформ"
},
"onlinePlatform": {
"title": "Платформы",
"subtitle": "Количество подключенных платформ"
},
"runningTime": {
"title": "Время работы",
"subtitle": "Общее время работы системы",
"format": "{hours} ч. {minutes} мин. {seconds} сек."
},
"memoryUsage": {
"title": "Память",
"subtitle": "Использование оперативной памяти",
"cpuLoad": "Загрузка CPU",
"status": {
"good": "Отлично",
"normal": "Нормально",
"high": "Высокая"
}
}
},
"charts": {
"messageTrend": {
"title": "Тренды сообщений",
"subtitle": "Изменение количества сообщений во времени",
"totalMessages": "Всего сообщений",
"dailyAverage": "В среднем за день",
"growthRate": "Скорость роста",
"timeLabel": "Время",
"messageCount": "Кол-во сообщений",
"timeRanges": {
"1day": "За 1 день",
"3days": "За 3 дня",
"1week": "За 7 дней",
"1month": "За 30 дней"
}
},
"platformStat": {
"title": "Статистика по платформам",
"subtitle": "Распределение сообщений по платформам",
"total": "Всего",
"noData": "Нет данных по платформам",
"messageUnit": "шт.",
"platformCount": "Кол-во платформ",
"mostActive": "Самый активный",
"totalPercentage": "Доля от общего числа"
}
}
}
@@ -0,0 +1,358 @@
{
"title": "Плагины",
"subtitle": "Управление и настройка расширений системы",
"tabs": {
"installedPlugins": "Плагины AstrBot",
"market": "Магазин плагинов",
"installedMcpServers": "MCP",
"skills": "Навыки",
"handlersOperation": "Управление поведением"
},
"titles": {
"installedAstrBotPlugins": "Установленные плагины AstrBot"
},
"failedPlugins": {
"title": "Ошибка загрузки ({count})",
"hint": "Эти плагины не удалось загрузить. Вы можете попробовать перезагрузить их или удалить.",
"columns": {
"plugin": "Плагин",
"error": "Ошибка"
}
},
"search": {
"placeholder": "Поиск плагинов...",
"marketPlaceholder": "Поиск в магазине..."
},
"filters": {
"all": "Все"
},
"views": {
"card": "Плитка",
"list": "Список"
},
"buttons": {
"showSystemPlugins": "Показать системные",
"hideSystemPlugins": "Скрыть системные",
"install": "Установить",
"uninstall": "Удалить",
"update": "Обновить",
"reload": "Перезагрузить",
"enable": "Включить",
"disable": "Выключить",
"configure": "Настроить",
"viewInfo": "Детали",
"viewDocs": "Документация",
"viewRepo": "Репозиторий",
"close": "Закрыть",
"save": "Сохранить",
"saveAndClose": "Сохранить и закрыть",
"cancel": "Отмена",
"actions": "Действия",
"back": "Назад",
"selectFile": "Выбрать файл",
"refresh": "Обновить",
"updateAll": "Обновить все",
"deleteSource": "Удалить источник",
"reshuffle": "Мне повезет!"
},
"status": {
"enabled": "Включен",
"disabled": "Выключен",
"system": "Системный",
"loading": "Загрузка...",
"installed": "Установлен",
"unknown": "Неизвестно"
},
"tooltips": {
"enable": "Включить",
"disable": "Выключить",
"reload": "Перезагрузить",
"configure": "Настроить",
"viewInfo": "Просмотр поведения",
"viewDocs": "Документация",
"update": "Обновить",
"uninstall": "Удалить"
},
"table": {
"headers": {
"name": "Имя",
"description": "Описание",
"version": "Версия",
"author": "Автор",
"status": "Статус",
"actions": "Действия",
"stars": "Звезды",
"lastUpdate": "Обновлен",
"tags": "Теги",
"eventType": "Тип события",
"specificType": "Тип",
"trigger": "Триггер"
}
},
"empty": {
"noPlugins": "Плагины не найдены",
"noPluginsDesc": "Попробуйте установить новые плагины или включите отображение системных."
},
"market": {
"recommended": "🥳 Рекомендуем",
"allPlugins": "📦 Все плагины",
"showFullName": "Полное имя",
"devDocs": "Документация для разработчиков",
"submitRepo": "Добавить репозиторий",
"customSource": "Свои источники",
"source": "Источник",
"availableSources": "Доступные источники",
"sourceManagement": "Управление источниками",
"addSource": "Добавить источник",
"sourceName": "Имя",
"sourceUrl": "Исходный URL",
"defaultSource": "Источник по умолчанию",
"removeSource": "Удалить источник",
"confirmRemoveSource": "Вы уверены, что хотите удалить этот источник плагинов?",
"sourceAdded": "Источник успешно добавлен",
"sourceRemoved": "Источник удален",
"sourceError": "Ошибка операции",
"selectSource": "Выбрать источник",
"currentSource": "Текущий источник",
"editSource": "Изменить источник",
"sourceUpdated": "Источник обновлен",
"defaultOfficialSource": "Официальный источник",
"sourceExists": "Этот источник уже есть в списке",
"installPlugin": "Установить плагин",
"randomPlugins": "🎲 Случайные плагины",
"showRandomPlugins": "Показать случайные",
"hideRandomPlugins": "Скрыть случайные",
"sourceSafetyWarning": "Даже при использовании источников по умолчанию мы не можем гарантировать 100% безопасность и стабильность сторонних плагинов. Пожалуйста, будьте внимательны."
},
"sort": {
"by": "Сортировать по",
"default": "По умолчанию",
"installTime": "Дате установки",
"name": "Имени",
"stars": "Звездам",
"author": "Автору",
"updated": "Дате обновления",
"updateStatus": "Статусу обновления",
"ascending": "По возрастанию",
"descending": "По убыванию"
},
"tags": {
"danger": "Опасно"
},
"dialogs": {
"error": {
"title": "Ошибка",
"checkConsole": "Подробности смотрите в логах платформы"
},
"config": {
"title": "Настройка плагина",
"noConfig": "У этого плагина нет настраиваемых параметров"
},
"loading": {
"title": "Загрузка...",
"logs": "Логи"
},
"uninstall": {
"title": "Подтверждение удаления",
"message": "Вы уверены, что хотите удалить этот плагин?",
"deleteConfig": "Удалить файл конфигурации плагина",
"deleteData": "Удалить сохраненные данные плагина",
"configHint": "Конфиг находится в data/config",
"dataHint": "Данные находятся в data/plugin_data и data/plugins_data"
},
"install": {
"title": "Установка плагина",
"fromFile": "Из файла",
"fromUrl": "По ссылке",
"supportPlatformsCount": "Поддерживает платформ: {count}"
},
"danger_warning": {
"title": "Внимание!",
"message": "Этот плагин может содержать небезопасный код или функции, которые могут привести к нестабильности системы или потере данных. Вы уверены, что хотите продолжить установку?",
"confirm": "Продолжить",
"cancel": "Отмена"
},
"versionCompatibility": {
"title": "Предупреждение о версии",
"message": "Требуемая плагином версия AstrBot не совпадает с вашей текущей версией. Вы можете продолжить установку на свой страх и риск.",
"confirm": "Игнорировать и установить",
"cancel": "Отмена"
},
"forceUpdate": {
"title": "Новых версий не найдено",
"message": "Новых версий не обнаружено. Выполнить принудительную переустановку из удаленного репозитория?",
"confirm": "Принудительно"
},
"updateAllConfirm": {
"title": "Обновить всё",
"message": "Обновить все плагины ({count} шт.)? Это может занять некоторое время.",
"confirm": "Подтвердить"
}
},
"messages": {
"uninstalling": "Удаление",
"refreshing": "Обновление списка плагинов...",
"refreshSuccess": "Список плагинов обновлен",
"refreshFailed": "Ошибка при обновлении списка",
"operationFailed": "Ошибка операции",
"reloadSuccess": "Перезагрузка завершена",
"reloadFailed": "Ошибка перезагрузки",
"updateSuccess": "Обновление завершено",
"addSuccess": "Успешно добавлено",
"saveSuccess": "Сохранено",
"deleteSuccess": "Удалено",
"installing": "Установка из файла...",
"installingFromUrl": "Установка по ссылке...",
"installFailed": "Ошибка установки:",
"getMarketDataFailed": "Ошибка получения данных магазина:",
"hasUpdate": "Доступно обновление:",
"confirmDelete": "Вы уверены, что хотите удалить плагин?",
"fillUrlOrFile": "Укажите ссылку или выберите файл",
"dontFillBoth": "Пожалуйста, используйте либо ссылку, либо файл, но не оба сразу",
"supportedFormats": "Поддерживаются файлы плагинов в формате .zip",
"updateAllSuccess": "Все плагины успешно обновлены",
"updateAllFailed": "Ошибок при обновлении: {failed} из {total}:",
"fillSourceNameAndUrl": "Пожалуйста, введите имя и адрес источника",
"invalidUrl": "Введите корректный URL",
"enterJsonUrl": "Введите URL, возвращающий список плагинов в формате JSON"
},
"upload": {
"fromFile": "Загрузить файл",
"fromUrl": "Указать ссылку",
"selectFile": "Выбрать файл",
"enterUrl": "Ссылка на репозиторий"
},
"skills": {
"modeLocal": "Локальные навыки",
"modeNeo": "Навыки Neo",
"actions": "Действия",
"upload": "Загрузить навыки",
"refresh": "Обновить",
"empty": "Навыки не найдены",
"emptyHint": "Пожалуйста, загрузите архив с навыками",
"uploadDialogTitle": "Загрузка навыков",
"uploadHint": "Поддерживается массовая загрузка zip-архивов. Вы также можете перетащить файлы в это окно. Система автоматически проверит структуру каждого архива.",
"structureRequirement": "Архив должен содержать одну корневую папку (например, `skillname/`), внутри которой обязательно должен находиться файл `SKILL.md`.",
"abilityMultiple": "Поддержка массовой загрузки",
"abilityValidate": "Автопроверка `SKILL.md`",
"abilitySkip": "Пропуск дубликатов",
"selectFile": "Выбрать файл",
"selectFiles": "Выбрать файлы",
"dropzoneTitle": "Перетащите zip-файлы сюда",
"dropzoneAction": "или нажмите, чтобы выбрать файлы на компьютере",
"dropzoneHint": "Система проверит структуру архивов перед загрузкой",
"fileListTitle": "Очередь загрузки",
"fileListEmpty": "Здесь будет отображаться статус проверки и загрузки файлов",
"uploading": "Загрузка...",
"batchResultTitle": "Результаты загрузки",
"batchResultSummary": "Всего: {total}, успешно: {success}",
"batchSuccessList": "Успешно загружено",
"batchFailedList": "Ошибка загрузки",
"confirm": "ОК",
"confirmUpload": "Начать загрузку",
"cancel": "Отмена",
"statusWaiting": "В очереди",
"statusUploading": "Загрузка...",
"statusSuccess": "Готово",
"statusError": "Ошибка структуры",
"statusSkipped": "Пропущено",
"summaryTotal": "Всего: {count}",
"summaryReady": "Готовы: {count}",
"summarySuccess": "Успешно: {count}",
"summaryFailed": "Ошибок: {count}",
"summarySkipped": "Дубликатов: {count}",
"validationReady": "Ожидает загрузки (проверка структуры будет выполнена автоматически)",
"validationZipOnly": "Допускаются только zip-архивы",
"validationDuplicate": "Файл уже есть в списке, пропуск",
"validationUploading": "Проверка и загрузка...",
"validationUploadFailed": "Ошибка загрузки, попробуйте еще раз",
"validationUploadedAs": "Установлено как {name}",
"validationNoResult": "Результат не получен, проверьте логи платформы",
"noDescription": "Нет описания",
"path": "Путь",
"uploadSuccess": "Успешно загружено",
"uploadFailed": "Ошибка загрузки",
"download": "Скачать",
"downloadSuccess": "Скачивание начато",
"downloadFailed": "Ошибка скачивания",
"loadFailed": "Не удалось загрузить навыки",
"updateSuccess": "Обновлено",
"updateFailed": "Ошибка обновления",
"deleteTitle": "Подтверждение удаления",
"deleteMessage": "Вы уверены, что хотите удалить этот навык?",
"deleteSuccess": "Удалено",
"deleteFailed": "Ошибка удаления",
"neoSkillKey": "Фильтр по ключу",
"neoStatus": "Статус кандидата",
"neoStage": "Этап публикации",
"neoFilterHint": "Фильтрация записей о публикации",
"neoAll": "Все",
"neoCandidates": "Кандидаты Neo",
"neoReleases": "Релизы Neo",
"neoLoadFailed": "Ошибка загрузки данных Neo Skills",
"neoPass": "Одобрить",
"neoReject": "Отклонить",
"neoEvaluateSuccess": "Оценка обновлена",
"neoEvaluateFailed": "Ошибка обновления оценки",
"neoPromoteSuccess": "Опубликовано",
"neoPromoteFailed": "Ошибка публикации",
"neoRollback": "Откат",
"neoRollbackSuccess": "Откат выполнен",
"neoRollbackFailed": "Ошибка отката",
"neoDeactivate": "Деактивация",
"neoDeactivateSuccess": "Деактивировано",
"neoDeactivateFailed": "Ошибка деактивации",
"neoSync": "Синхронизация",
"neoSyncSuccess": "Синхронизировано",
"neoSyncFailed": "Ошибка синхронизации",
"neoDelete": "Удалить",
"neoDeleteSuccess": "Удалено",
"neoDeleteFailed": "Ошибка удаления",
"neoPayloadTitle": "Детали Neo Payload",
"neoPayloadFailed": "Ошибка чтения Payload",
"runtimeNoneWarning": "Среда выполнения Computer Use не задана. Навыки могут не работать, так как нет активного окружения.",
"runtimeHint": "Установите среду выполнения в «local» или «sandbox» в настройках способностей использования компьютера.",
"neoRuntimeRequired": "Neo Skills доступны только в среде sandbox с драйвером shipyard_neo.",
"sourceLocalOnly": "Локальный навык",
"sourceSandboxOnly": "Предустановленный Sandbox навык",
"sourceBoth": "Локальный + Sandbox",
"sandboxDiscoveryPending": "Предустановленные Sandbox навыки не найдены. Запустите сессию Sandbox хотя бы один раз.",
"sandboxPresetReadonly": "Предустановленные навыки Sandbox доступны только для чтения и не могут быть удалены здесь."
},
"card": {
"actions": {
"pluginConfig": "Настройки",
"uninstallPlugin": "Удалить",
"reloadPlugin": "Перезагрузить",
"togglePlugin": "Плагин",
"viewHandlers": "Действия",
"updateTo": "Обновить до",
"reinstall": "Переустановить"
},
"status": {
"hasUpdate": "Доступно обновление",
"disabled": "Плагин выключен",
"handlersCount": "действий",
"supportPlatform": "Платформы",
"supportPlatformsCount": "Платформ: {count}",
"astrbotVersion": "Требуемая версия AstrBot"
},
"alt": {
"logo": "логотип",
"extensionIcon": "иконка расширения"
},
"errors": {
"confirmNotRegistered": "$confirm не зарегистрирован"
}
},
"conflicts": {
"title": "Конфликт команд",
"message": "Обнаружены конфликтующие команды. Это может привести к некорректной работе. Рекомендуется разрешить конфликты в панели «Управление командами».",
"pairs": "конфликтующих пар",
"goToManage": "Управление",
"later": "Позже"
},
"pluginChangelog": {
"menuTitle": "Журнал изменений"
}
}
@@ -0,0 +1,118 @@
{
"title": "Детали базы знаний",
"backToList": "К списку",
"tabs": {
"overview": "Обзор",
"documents": "Документы",
"retrieval": "Поиск",
"sessions": "Сессии",
"settings": "Настройки"
},
"overview": {
"title": "Информация",
"name": "Название",
"description": "Описание",
"emoji": "Иконка",
"createdAt": "Создана",
"updatedAt": "Обновлена",
"stats": "Статистика",
"docCount": "Количество документов",
"chunkCount": "Количество фрагментов",
"embeddingModel": "Embedding модель",
"rerankModel": "Rerank модель",
"notSet": "не выбрано"
},
"documents": {
"title": "Список документов",
"upload": "Загрузить",
"empty": "Документов нет",
"name": "Имя файла",
"type": "Тип",
"size": "Размер",
"chunks": "Фрагменты",
"createdAt": "Дата загрузки",
"actions": "Действия",
"view": "Смотреть",
"delete": "Удалить",
"deleteConfirm": "Вы уверены, что хотите удалить «{name}»?",
"deleteWarning": "Это удалит файл и все его фрагменты из индекса.",
"uploading": "Загрузка...",
"uploadSuccess": "Файл успешно загружен",
"uploadFailed": "Ошибка загрузки",
"deleteSuccess": "Файл удален",
"deleteFailed": "Ошибка удаления"
},
"upload": {
"title": "Добавление контента",
"selectFile": "Файл",
"dropzone": "Нажмите или перетащите файл сюда",
"supportedFormats": "Форматы: ",
"maxSize": "Максимум: 128MB",
"chunkSettings": "Фрагментация",
"batchSettings": "Пакетная обработка",
"cleaningSettings": "Очистка данных",
"enableCleaning": "Включить очистку контента",
"cleaningProvider": "Сервис для очистки",
"cleaningProviderHint": "LLM провайдер для суммаризации и извлечения смыслов из веб-страниц",
"chunkSize": "Размер чанка",
"chunkSizeHint": "Символов в блоке (по умолчанию: 512)",
"chunkOverlap": "Перекрытие",
"chunkOverlapHint": "Перекрытие между блоками (по умолчанию: 50)",
"batchSize": "Размер пакета",
"batchSizeHint": "Блоков за один запрос (по умолчанию: 32)",
"tasksLimit": "Лимит задач",
"tasksLimitHint": "Макс. параллельных потоков (по умолчанию: 3)",
"maxRetries": "Попытки",
"maxRetriesHint": "Повторов при сбое (по умолчанию: 3)",
"cancel": "Отмена",
"submit": "Загрузить",
"fileRequired": "Пожалуйста, выберите файл",
"fileUpload": "Загрузка файла",
"fromUrl": "Из URL",
"urlPlaceholder": "Ссылка на веб-страницу",
"urlRequired": "Введите URL",
"urlHint": "Контент будет автоматически извлечен со страницы. Убедитесь, что сайт разрешает доступ роботам.",
"beta": "Бета-версия"
},
"retrieval": {
"title": "Поиск и проверка",
"subtitle": "Проверьте качество поиска (Dense & Sparse) по вашей базе знаний",
"query": "Тестовый запрос",
"queryPlaceholder": "Что вы хотите найти?",
"search": "Найти",
"searching": "Ищем...",
"results": "Результаты поиска",
"noResults": "Релевантный контент не найден",
"tryDifferentQuery": "Попробуйте изменить формулировку запроса",
"settings": "Параметры поиска",
"topK": "Количество результатов",
"topKHint": "Сколько фрагментов возвращать",
"enableRerank": "Включить Rerank",
"enableRerankHint": "Применить переранжирование для повышения точности",
"score": "Вес (Score)",
"document": "Документ",
"chunk": "Фрагмент #{index}",
"content": "Текст",
"charCount": "{count} симв.",
"searchSuccess": "Поиск завершен, найдено: {count}",
"searchFailed": "Ошибка выполнения поиска",
"queryRequired": "Введите поисковый запрос"
},
"settings": {
"title": "Общие настройки базы",
"basic": "Основные",
"retrieval": "Поиск",
"chunkSize": "Размер чанка",
"chunkOverlap": "Перекрытие",
"topKDense": "Вернуть (Dense)",
"topKSparse": "Вернуть (Sparse)",
"topMFinal": "Итоговый результат",
"enableRerank": "Включить Rerank",
"embeddingProvider": "Провайдер Embedding",
"rerankProvider": "Провайдер Rerank",
"save": "Сохранить",
"saveSuccess": "Настройки сохранены",
"saveFailed": "Ошибка сохранения",
"tips": "Внимание! Изменение этих параметров повлияет на будущую выдачу базы знаний."
}
}
@@ -0,0 +1,55 @@
{
"title": "Просмотр документа",
"backToKB": "К базе знаний",
"info": {
"title": "Информация о документе",
"name": "Имя файла",
"type": "Формат",
"size": "Размер",
"chunkCount": "Количество фрагментов",
"createdAt": "Загружен"
},
"chunks": {
"title": "Фрагменты текста",
"empty": "Фрагменты не найдены",
"index": "Индекс",
"content": "Текст",
"charCount": "Символов",
"actions": "Действия",
"view": "Детали",
"edit": "Изменить",
"delete": "Удалить",
"preview": "Обзор",
"search": "Поиск по документу",
"searchPlaceholder": "Найти во фрагментах...",
"showing": "Показано",
"deleteConfirm": "Удалить этот фрагмент?",
"deleteSuccess": "Фрагмент удален",
"deleteFailed": "Ошибка удаления"
},
"edit": {
"title": "Редактирование фрагмента",
"content": "Текст",
"cancel": "Отмена",
"save": "Сохранить",
"saveSuccess": "Фрагмент обновлен",
"saveFailed": "Ошибка сохранения"
},
"delete": {
"title": "Удаление",
"confirmText": "Вы уверены?",
"warning": "Удаление фрагмента может ухудшить качество ответов AI по этой теме.",
"cancel": "Отмена",
"confirm": "Удалить",
"deleteSuccess": "Удаление выполнено",
"deleteFailed": "Ошибка удаления"
},
"view": {
"title": "Детальный просмотр",
"index": "Индекс",
"content": "Текст",
"charCount": "Символов",
"vecDocId": "ID вектора",
"close": "Закрыть"
}
}
@@ -0,0 +1,67 @@
{
"title": "Управление базами знаний",
"subtitle": "Централизованное управление всеми знаниями AstrBot",
"list": {
"title": "Мои базы знаний",
"subtitle": "Все доступные коллекции знаний",
"create": "Создать базу",
"refresh": "Обновить",
"empty": "Баз знаний пока нет",
"loading": "Загрузка...",
"documents": "док.",
"chunks": "фрагм.",
"sessionConfig": "Профиль"
},
"card": {
"edit": "Изменить",
"delete": "Удалить",
"open": "Открыть",
"docCount": "Документов: {count}",
"chunkCount": "Фрагментов: {count}"
},
"create": {
"title": "Создание базы знаний",
"nameLabel": "Название",
"namePlaceholder": "Придумайте имя для базы",
"descriptionLabel": "Описание",
"descriptionPlaceholder": "Для чего нужна эта база?",
"emojiLabel": "Иконка",
"embeddingModelLabel": "Embedding модель",
"rerankModelLabel": "Rerank модель (опционально)",
"providerInfo": "Провайдер: {id} | Размерность: {dimensions}",
"rerankProviderInfo": "Провайдер: {id}",
"cancel": "Отмена",
"submit": "Создать",
"nameRequired": "Введите название базы знаний"
},
"edit": {
"title": "Редактирование",
"submit": "Сохранить"
},
"delete": {
"title": "Удаление",
"confirmText": "Вы уверены, что хотите удалить базу знаний «{name}»?",
"warning": "Это действие необратимо. Все документы, фрагменты и настройки будут навсегда удалены.",
"cancel": "Отмена",
"confirm": "Удалить"
},
"emoji": {
"title": "Выберите иконку",
"close": "Закрыть",
"categories": {
"books": "Книги и документы",
"emotions": "Эмоции",
"objects": "Вещи",
"symbols": "Символы"
}
},
"messages": {
"createSuccess": "База знаний создана",
"createFailed": "Ошибка создания",
"updateSuccess": "Обновлено успешно",
"updateFailed": "Ошибка обновления",
"deleteSuccess": "Удалено успешно",
"deleteFailed": "Ошибка удаления",
"loadError": "Не удалось загрузить список"
}
}
@@ -0,0 +1,18 @@
{
"dialog": {
"title": "Помощник по миграции",
"warning": "👋 Добро пожаловать в v4.0.0! В этой версии мы оптимизировали формат хранения данных. Обнаружена необходимость миграции базы данных.",
"loading": "Загрузка списка платформ...",
"loadError": "Ошибка загрузки, попробуйте еще раз",
"noPlatforms": "Конфигурации платформ не найдены",
"retry": "Повторить",
"startMigration": "Начать миграцию",
"migrating": "Выполняется миграция...",
"migratingSubtitle": "Пожалуйста, подождите. Не закрывайте это окно до завершения процесса.",
"migrationError": "Ошибка миграции",
"success": "Миграция успешно завершена!",
"completed": "Миграция выполнена",
"restartRecommended": "Рекомендуется перезапустить приложение, чтобы все изменения вступили в силу.",
"restartNow": "Перезапустить сейчас"
}
}
@@ -0,0 +1,146 @@
{
"page": {
"description": "Управление настройками и поведением персонажей"
},
"buttons": {
"create": "Создать персонажа",
"createFirst": "Создать первого персонажа",
"edit": "Изменить",
"delete": "Удалить",
"cancel": "Отмена",
"save": "Сохранить",
"move": "Переместить",
"addDialogPair": "Добавить пример диалога"
},
"labels": {
"presetDialogs": "Примеры диалогов ({count})",
"createdAt": "Создан",
"updatedAt": "Обновлен"
},
"form": {
"personaId": "ID персонажа",
"systemPrompt": "Системный промпт",
"customErrorMessage": "Свое сообщение об ошибке (опционально)",
"customErrorMessageHelp": "Это сообщение будет отправлено пользователю при сбое запроса к LLM. Если оставить пустым, будет использовано системное сообщение по умолчанию.",
"presetDialogs": "Примеры диалогов",
"presetDialogsHelp": "Добавьте примеры взаимодействия, чтобы помочь AI лучше понять свою роль и стиль общения.",
"userMessage": "Сообщение пользователя",
"assistantMessage": "Ответ AI",
"tools": "Инструменты / MCP серверы",
"toolsHelp": "Выберите инструменты, доступные этому персонажу. Инструменты позволяют AI взаимодействовать с внешним миром: искать в интернете, выполнять расчеты и т.д.",
"toolsSelection": "Выбор инструментов",
"selectAllTools": "Выбрать все",
"clearAllTools": "Очистить всё",
"allSelected": "Выбрано всё",
"mcpServersQuickSelect": "Быстрый выбор MCP серверов",
"searchTools": "Поиск инструментов",
"selectedTools": "Выбранные инструменты",
"noToolsAvailable": "Нет доступных инструментов",
"noToolsFound": "Инструменты не найдены",
"loadingTools": "Загрузка инструментов...",
"allToolsAvailable": "Использовать все доступные инструменты",
"noToolsSelected": "Инструменты не выбраны",
"skills": "Навыки (Skills)",
"skillsHelp": "Выберите навыки, доступные этому персонажу. Навыки предоставляют AI готовые сценарии и правила работы.",
"skillsAllAvailable": "По умолчанию использовать все навыки",
"skillsSelectSpecific": "Выбрать определенные навыки",
"searchSkills": "Поиск навыков",
"selectedSkills": "Выбранные навыки",
"noSkillsAvailable": "Нет доступных навыков",
"noSkillsFound": "Навыки не найдены",
"loadingSkills": "Загрузка навыков...",
"allSkillsAvailable": "Использовать все доступные навыки",
"noSkillsSelected": "Навыки не выбраны",
"skillsRuntimeNoneWarning": "Среда выполнения Computer Use не задана. Навыки могут не работать, так как нет активного окружения.",
"createInFolder": "Будет создан в папке «{folder}»",
"rootFolder": "Все персонажи"
},
"dialog": {
"create": {
"title": "Создание персонажа"
},
"edit": {
"title": "Редактирование персонажа"
}
},
"empty": {
"title": "Персонажи не настроены",
"description": "Самое время создать одного!",
"folderEmpty": "Папка пуста",
"folderEmptyDescription": "Создайте нового персонажа или папку, чтобы начать"
},
"validation": {
"required": "Это поле обязательно для заполнения",
"minLength": "Минимум {min} символов",
"alphanumeric": "Разрешены только латинские буквы, цифры, подчёркивания и дефисы",
"dialogRequired": "{type} не может быть пустым",
"personaIdExists": "Персонаж с таким ID уже существует"
},
"messages": {
"loadError": "Не удалось загрузить список персонажей",
"saveSuccess": "Сохранено",
"saveError": "Ошибка сохранения",
"deleteConfirm": "Вы уверены, что хотите удалить персонажа «{id}»? Это действие необратимо.",
"deleteSuccess": "Удалено",
"deleteError": "Ошибка удаления"
},
"persona": {
"personasTitle": "Персонаж",
"toolsCount": "инстр.",
"skillsCount": "навыков",
"contextMenu": {
"moveTo": "Переместить в..."
},
"messages": {
"moveSuccess": "Персонаж перемещен",
"moveError": "Не удалось переместить персонажа"
}
},
"folder": {
"sidebarTitle": "Папки",
"rootFolder": "Корень",
"foldersTitle": "Папки",
"noFolders": "Папок нет",
"createButton": "Новая папка",
"searchPlaceholder": "Поиск папок...",
"form": {
"name": "Имя папки",
"description": "Описание (опционально)"
},
"validation": {
"nameRequired": "Имя папки не может быть пустым"
},
"contextMenu": {
"open": "Открыть",
"rename": "Переименовать",
"moveTo": "Переместить в...",
"delete": "Удалить"
},
"createDialog": {
"title": "Создать папку",
"createButton": "Создать"
},
"renameDialog": {
"title": "Переименовать папку"
},
"deleteDialog": {
"title": "Удаление папки",
"message": "Вы уверены, что хотите удалить папку «{name}»?",
"warning": "Все персонажи из этой папки будут перемещены в корневой каталог."
},
"messages": {
"createSuccess": "Папка создана",
"createError": "Ошибка создания папки",
"renameSuccess": "Папка переименована",
"renameError": "Ошибка переименования папки",
"deleteSuccess": "Папка удалена",
"deleteError": "Ошибка удаления папки"
}
},
"moveDialog": {
"title": "Перемещение",
"description": "Выберите папку для «{name}»",
"success": "Объект перемещен",
"error": "Ошибка перемещения"
}
}
@@ -0,0 +1,135 @@
{
"title": "Боты",
"subtitle": "Управление адаптерами платформ для подключения к мессенджерам",
"adapters": "Адаптеры платформ",
"addAdapter": "Создать бота",
"emptyText": "Боты не настроены. Нажмите «Создать бота», чтобы начать.",
"viewWebhook": "Показать Webhook",
"webhookCopied": "URL скопирован в буфер обмена",
"webhookCopyFailed": "Не удалось скопировать, сделайте это вручную",
"webhookDialog": {
"title": "Адрес Webhook",
"description": "Используйте этот адрес для обратных вызовов. Убедитесь, что ваш AstrBot доступен из интернета. Рекомендуется указать «Внешний URL для Webhook» в Конфигурация -> Система.",
"close": "Закрыть"
},
"details": {
"adapterType": "Тип адаптера",
"token": "Токен",
"description": "Описание"
},
"logs": {
"title": "Логи платформы",
"expand": "Развернуть",
"collapse": "Свернуть"
},
"dialog": {
"add": "Добавить",
"edit": "Изменить",
"adapter": "Бот",
"refresh": "Обновить",
"cancel": "Отмена",
"save": "Сохранить",
"addPlatform": "Создать бота",
"connectTitle": "Подключение к {name}",
"viewTutorial": "Открыть руководство",
"noTemplates": "Шаблоны не найдены",
"idConflict": {
"title": "Конфликт ID",
"message": "Бот с ID «{id}» уже существует. Пожалуйста, используйте уникальный ID.",
"confirm": "Понятно"
},
"securityWarning": {
"title": "Безопасность",
"aiocqhttpTokenMissing": "Для защиты соединения крайне рекомендуется установить ws_reverse_token. Работа без токена небезопасна.",
"learnMore": "Подробнее"
},
"invalidPlatformId": "ID платформы не может содержать символы ':' или '!'."
},
"createDialog": {
"step1Title": "Выберите мессенджер",
"step1Hint": "Куда вы хотите подключить бота? (QQ, Telegram, Discord, WeChat и др.)",
"platformTypeLabel": "Платформа",
"configFileTitle": "Файл конфигурации",
"optional": "опционально",
"configHint": "Как настроить бота? Конфиг содержит модель, персонажа, базу знаний и набор плагинов.",
"configDefaultHint": "По умолчанию используется профиль «default». Вы сможете изменить его позже.",
"useExistingConfig": "Использовать существующий конфиг",
"selectConfigLabel": "Выберите профиль",
"createNewConfig": "Создать новый профиль",
"newConfigNameLabel": "Имя нового профиля",
"newConfigTitle": "Создание нового профиля",
"newConfigLoadFailed": "Не удалось загрузить шаблон конфигурации",
"addRouteRule": "Добавить правило маршрутизации",
"viewMode": "Просмотр",
"editMode": "Редактирование",
"noRouteRules": "Правила маршрутизации не заданы, будет использоваться профиль по умолчанию",
"sessionIdPlaceholder": "ID сессии или *",
"allSessions": "Все сессии",
"configMissing": "Файл конфигурации не найден",
"routeHint": "* При получении сообщения AstrBot ищет первое совпадение в списке сверху вниз. Используйте слэш-команду /sid, чтобы узнать ID текущей сессии. Если совпадений нет, используется профиль по умолчанию.",
"warningContinue": "Игнорировать и создать",
"warningEditAgain": "Вернуться к редактированию",
"configDrawerTitle": "Управление профилями",
"configDrawerIdLabel": "ID",
"configTableHeaders": {
"configId": "ID связанного профиля",
"scope": "Область применения"
},
"routeTableHeaders": {
"source": "Источник (тип:ID)",
"config": "Файл конфига",
"actions": "Действия"
},
"messageTypeOptions": {
"all": "Все сообщения",
"group": "Групповые (GroupMessage)",
"friend": "Личные (FriendMessage)"
},
"messageTypeLabels": {
"all": "Все",
"group": "Группа",
"friend": "ЛС"
}
},
"messages": {
"updateSuccess": "Обновлено!",
"addSuccess": "Добавлено!",
"deleteSuccess": "Удалено!",
"statusUpdateSuccess": "Статус обновлен!",
"deleteConfirm": "Вы уверены, что хотите удалить этого бота?",
"configNotFoundOpenConfig": "Целевой конфиг не найден. Открыта страница настроек для проверки.",
"updateMissingPlatformId": "Ошибка обновления: отсутствует ID платформы.",
"platformUpdateFailed": "Не удалось обновить платформу.",
"addSuccessWithConfig": "Бот успешно добавлен, профиль обновлен",
"configIdMissing": "Не удалось получить ID конфигурации.",
"routingUpdateFailed": "Ошибка обновления маршрутов: {message}",
"createConfigFailed": "Ошибка создания профиля: {message}",
"platformIdMissing": "Не удалось получить ID платформы.",
"routingSaveFailed": "Ошибка сохранения маршрутов: {message}"
},
"status": {
"enabled": "Включен",
"disabled": "Выключен",
"connecting": "Подключение",
"connected": "Подключен",
"disconnected": "Отключен",
"error": "Ошибка"
},
"runtimeStatus": {
"running": "Работает",
"error": "Ошибка",
"pending": "Ожидание",
"stopped": "Остановлен",
"unknown": "Неизвестно",
"errors": "ошибок"
},
"errorDialog": {
"title": "Детали ошибки",
"platformId": "ID платформы",
"errorCount": "Кол-во ошибок",
"lastError": "Последняя ошибка",
"occurredAt": "Время",
"traceback": "Стек вызовов",
"close": "Закрыть"
}
}
@@ -0,0 +1,151 @@
{
"title": "Провайдеры моделей",
"subtitle": "Настройка AI моделей для диалогов. Также поддерживает Dify, Coze, а также внешние Agent-сервисы.",
"providers": {
"title": "Сервис-провайдеры",
"settings": "Настройки",
"addProvider": "Добавить провайдера",
"providerType": "Тип провайдера",
"tabs": {
"all": "Все",
"chatCompletion": "Диалоги",
"agentRunner": "Агенты",
"speechToText": "STT (Речь -> Текст)",
"textToSpeech": "TTS (Текст -> Речь)",
"embedding": "Эмбеддинги",
"rerank": "Rerank (Ранжирование)"
},
"empty": {
"all": "Провайдеры не добавлены. Нажмите «Добавить провайдера», чтобы начать.",
"typed": "Провайдеры типа «{type}» не найдены."
},
"description": {
"openai": "Поддерживаются все провайдеры, совместимые с OpenAI API.",
"vllm_rerank": "Также поддерживает Jina AI, Cohere, PPIO и другие.",
"default": "Преобразование речи в текст"
}
},
"availability": {
"title": "Доступность провайдеров",
"subtitle": "Статус определяется путем выполнения тестового запроса. Может взиматься плата согласно тарифу API.",
"refresh": "Проверить статус",
"noData": "Нажмите «Проверить статус», чтобы узнать доступность моделей",
"available": "Доступен",
"unavailable": "Недоступен",
"pending": "Проверка...",
"errorMessage": "Ошибка",
"test": "Тест"
},
"logs": {
"title": "Логи сервиса",
"expand": "Развернуть",
"collapse": "Свернуть"
},
"dialogs": {
"addProvider": {
"title": "Новый провайдер",
"tabs": {
"basic": "Диалоги",
"agentRunner": "Агенты",
"speechToText": "Преобразование текста в речь",
"textToSpeech": "Переранжирование",
"embedding": "Эмбеддинги",
"rerank": "API Key"
},
"noTemplates": "Шаблоны для этого типа не найдены"
},
"config": {
"addTitle": "Добавить",
"editTitle": "Изменить",
"provider": "Провайдер",
"cancel": "Отмена",
"save": "Сохранить"
},
"settings": {
"title": "Общие настройки провайдеров",
"sessionSeparation": {
"title": "Изоляция провайдеров по сессиям",
"description": "Позволяет выбирать независимых провайдеров для генерации текста, TTS и STT в каждой конкретной сессии."
},
"close": "Закрыть"
}
},
"messages": {
"success": {
"update": "Обновлено!",
"add": "Добавлено!",
"delete": "Удалено!",
"statusUpdate": "Статус обновлен!",
"sessionSeparation": "Настройки изоляции сохранены"
},
"error": {
"sessionSeparation": "Не удалось загрузить настройки изоляции",
"fetchStatus": "Не удалось получить статус провайдеров",
"testError": "Тест {id} провален: {error}"
},
"confirm": {
"delete": "Вы уверены, что хотите удалить провайдера «{id}»?"
}
},
"providerTypes": {
"title": "Тип провайдера"
},
"providerSources": {
"title": "Источник провайдера",
"add": "Добавить",
"empty": "Источники не найдены",
"selectHint": "Пожалуйста, выберите источник провайдера",
"selectCreated": "Выбрать существующий источник",
"save": "Сохранить конфиг",
"saveAndFetchModels": "Сохранить и загрузить модели",
"fetchModels": "Загрузить список моделей",
"saveSuccess": "Источник успешно сохранен",
"saveError": "Ошибка сохранения источника",
"deleteConfirm": "Вы уверены, что хотите удалить источник «{id}»? Все связанные конфигурации моделей будут удалены.",
"deleteSuccess": "Источник удален",
"deleteError": "Ошибка удаления",
"enabled": "Включен",
"disabled": "Выключен",
"advancedConfig": "Расширенные настройки...",
"fields": {
"name": "Имя",
"apiKey": "Base URL",
"baseUrl": "Base URL"
},
"hints": {
"id": "Уникальный ID источника",
"key": "Ваш серетный API-ключ",
"apiBase": "Адрес API точки входа (Endpoint URL)",
"proxy": "Прокси сервер (HTTP/HTTPS), напр. http://127.0.0.1:7890. Используется только для запросов к этому провайдеру."
},
"labels": {
"proxy": "Прокси"
}
},
"models": {
"available": "Доступные модели",
"configured": "Настроенные модели",
"empty": "Модели не настроены. Нажмите «Загрузить список моделей» выше.",
"noModelsFound": "Модели не найдены",
"fetchError": "Не удалось получить список моделей",
"addSuccess": "Модель {model} успешно добавлена",
"deleteConfirm": "Вы уверены, что хотите удалить модель «{id}»?",
"deleteSuccess": "Модель удалена",
"deleteError": "Ошибка удаления модели",
"testSuccess": "Тест модели «{id}» пройден успешно",
"testError": "Тест модели провален",
"searchPlaceholder": "Поиск по имени или ID",
"manualAddButton": "Добавить вручную",
"manualDialogTitle": "Произвольная модель",
"manualDialogModelLabel": "Код модели (напр. gpt-4o-mini)",
"manualDialogPreviewLabel": "Отображаемый ID (авто)",
"manualDialogPreviewHint": "Будет выглядеть как: SourceID/ModelID",
"manualModelRequired": "Укажите ID модели",
"manualModelExists": "Эта модель уже добавлена",
"configure": "Настроить",
"tooltips": {
"providerId": "ID провайдера",
"modelId": "ID модели"
}
}
}
@@ -0,0 +1,130 @@
{
"title": "Управление сессиями",
"subtitle": "Настройка индивидуальных правил для конкретных диалогов. Эти правила имеют приоритет над глобальной конфигурацией.",
"buttons": {
"refresh": "Обновить",
"edit": "Изменить",
"editRule": "Редактировать правило",
"deleteAllRules": "Удалить все правила",
"addRule": "Добавить правило",
"save": "Сохранить",
"cancel": "Отмена",
"delete": "Удалить",
"clear": "Очистить",
"next": "Далее",
"editCustomName": "Изменить заметку",
"batchDelete": "Массовое удаление"
},
"customRules": {
"title": "Пользовательские правила",
"rulesCount": "правил",
"hasRules": "Настроено",
"noRules": "Индивидуальных правил нет",
"noRulesDesc": "Нажмите «Добавить правило», чтобы задать настройки для конкретного диалога",
"serviceConfig": "Сервис",
"pluginConfig": "Плагины",
"kbConfig": "База знаний",
"providerConfig": "Модель",
"configured": "Настроено",
"noCustomName": "Без заметки"
},
"quickEditName": {
"title": "Редактирование заметки"
},
"search": {
"placeholder": "Поиск сессии..."
},
"table": {
"headers": {
"umoInfo": "Источник (UMO)",
"rulesOverview": "Обзор правил",
"actions": "Действия"
}
},
"persona": {
"none": "Из конфигурации"
},
"provider": {
"followConfig": "Из конфигурации"
},
"addRule": {
"title": "Добавление правила",
"description": "Выберите источник сообщения (UMO) для настройки. Индивидуальные правила приоритетнее глобальных. Используйте команду /sid в чате, чтобы узнать информацию об источнике.",
"selectUmo": "Выберите сессию",
"noUmos": "Нет доступных сессий"
},
"ruleEditor": {
"title": "Редактор правил",
"description": "Настройте поведение для этой сессии. Настройки ниже перекроют глобальный конфиг.",
"serviceConfig": {
"title": "Сервисные настройки",
"sessionEnabled": "Обрабатывать сообщения",
"llmEnabled": "Использовать LLM",
"ttsEnabled": "Использовать TTS",
"customName": "Заметка для сессии"
},
"providerConfig": {
"title": "Выбор моделей",
"chatProvider": "Чат-модель",
"sttProvider": "STT (Распознавание)",
"ttsProvider": "TTS (Озвучка)"
},
"personaConfig": {
"title": "Персона",
"selectPersona": "Выберите Persona",
"hint": "При выборе Persona все диалоги из этого источника будут использовать именно её."
},
"pluginConfig": {
"title": "Плагины",
"disabledPlugins": "Отключенные плагины",
"hint": "Выберите плагины, которые нужно ОТКЛЮЧИТЬ в этой сессии. Остальные останутся активными."
},
"kbConfig": {
"title": "База знаний",
"selectKbs": "Выбор баз знаний",
"topK": "Количество результатов (Top K)",
"enableRerank": "Использовать Rerank"
}
},
"deleteConfirm": {
"title": "Подтверждение",
"message": "Удалить все настройки для этой сессии? Будут применены глобальные настройки."
},
"batchDeleteConfirm": {
"title": "Массовое удаление",
"message": "Удалить {count} выбранных правил? Будут применены глобальные настройки."
},
"batchOperations": {
"title": "Массовые операции",
"hint": "Быстрое изменение настроек для группы сессий",
"scope": "Область применения",
"scopeSelected": "Выбранные",
"scopeAll": "Все сессии",
"scopeGroup": "Все группы",
"scopePrivate": "Личные диалоги",
"llmStatus": "Статус LLM",
"ttsStatus": "Статус TTS",
"chatProvider": "Чат-модель",
"ttsProvider": "TTS-модель",
"apply": "Применить"
},
"status": {
"enabled": "Включено",
"disabled": "Выключено"
},
"messages": {
"refreshSuccess": "Данные обновлены",
"loadError": "Ошибка загрузки",
"saveSuccess": "Настройки сохранены",
"saveError": "Ошибка сохранения",
"clearSuccess": "Очищено",
"clearError": "Ошибка очистки",
"deleteSuccess": "Удалено",
"deleteError": "Ошибка удаления",
"noChanges": "Изменений не обнаружено",
"batchDeleteSuccess": "Массовое удаление выполнено",
"batchDeleteError": "Ошибка массового удаления",
"batchUpdateError": "Ошибка пакетного обновления",
"batchUpdateSuccess": "Пакетное обновление успешно выполнено"
}
}
@@ -0,0 +1,180 @@
{
"network": {
"title": "Сеть",
"githubProxy": {
"title": "Зеркало GitHub",
"subtitle": "Адрес для ускорения загрузки плагинов и обновлений AstrBot. Особенно актуально для пользователей из Китая. Все адреса предоставляются как есть, если обновление не удается — проверьте доступность выбранного зеркала.",
"label": "Выбрать ускоритель GitHub"
},
"proxySelector": {
"title": "Ускорение GitHub",
"noProxy": "Не использовать",
"useProxy": "Включить",
"testConnection": "Проверить соединение",
"available": "Доступен",
"unavailable": "Недоступен",
"custom": "Свой вариант"
}
},
"theme": {
"title": "Тема оформления",
"subtitle": "Настройка основных и дополнительных цветов. Изменения вступают в силу немедленно и сохраняются в браузере.",
"customize": {
"title": "Цвета темы",
"primary": "Основной",
"secondary": "Дополнительный",
"reset": "Сбросить"
}
},
"system": {
"title": "Система",
"restart": {
"title": "Перезапуск",
"subtitle": "Выполнить мягкий перезапуск AstrBot",
"button": "Перезагрузить"
},
"migration": {
"title": "Миграция данных в v4.0.0",
"subtitle": "Если у вас возникли проблемы с совместимостью данных после обновления, запустите помощник вручную.",
"button": "Запустить миграцию"
},
"backup": {
"title": "Резервное копирование",
"subtitle": "Важнейший инструмент для безопасного переноса данных между серверами.",
"button": "Управление бэкапами"
}
},
"sidebar": {
"title": "Боковая панель",
"customize": {
"title": "Настройка меню",
"subtitle": "Перетаскивайте элементы, чтобы изменить их порядок или скрыть в группе «Дополнительно». Настройки сохраняются локально в браузере.",
"reset": "Сбросить порядок",
"mainItems": "Основные разделы",
"moreItems": "Дополнительно"
}
},
"backup": {
"dialog": {
"title": "Резервное копирование"
},
"tabs": {
"export": "Экспорт",
"import": "Импорт",
"list": "Список копий"
},
"export": {
"title": "Создать резервную копию",
"description": "Экспорт всех данных в ZIP-архив, включая базы данных, базу знаний, конфигурации и вложения.",
"includes": "Включает: основную БД, векторные индексы знаний, файлы конфигурации, медиа-вложения.",
"button": "Начать экспорт",
"processing": "Экспорт...",
"wait": "Пожалуйста, подождите, мы упаковываем данные...",
"completed": "Готово!",
"download": "Скачать архив",
"another": "Создать новый",
"failed": "Ошибка экспорта",
"retry": "Повторить"
},
"import": {
"title": "Восстановление из копии",
"warning": "⚠️ Внимание! Импорт полностью удалит и перезапишет текущие данные! Убедитесь, что у вас есть копия текущего состояния.",
"selectFile": "Выберите ZIP-архив",
"uploadAndCheck": "Загрузить и проверить",
"uploading": "Загрузка...",
"uploadWait": "Файл передается на сервер...",
"uploadInit": "Инициализация...",
"uploadingChunks": "Передача фрагментов...",
"uploadComplete": "Загружено, идет сборка...",
"checking": "Проверка структуры...",
"invalidBackup": "Некорректный файл резервной копии",
"backupContents": "Состав архива",
"tables": "таблиц БД",
"knowledgeBases": "баз знаний",
"configFiles": "конфигов",
"confirmImport": "Подтвердите импорт",
"button": "Начать восстановление",
"processing": "Восстановление...",
"wait": "Идет процесс развертывания данных...",
"completed": "Восстановление успешно завершено!",
"restartRequired": "Данные восстановлены. Необходимо немедленно перезапустить AstrBot для вступления изменений в силу.",
"restartNow": "Перезапустить сейчас",
"failed": "Ошибка импорта",
"retry": "Повторить",
"version": {
"backupVersion": "Версия бэкапа",
"currentVersion": "Текущая версия",
"backupTime": "Дата создания",
"matchTitle": "✅ Версии совпадают",
"matchMessage": "Импорт перезапишет все текущие данные, включая:\n• Основную БД (чаты, настройки)\n• Базы знаний\n• Плагины и их данные\n• Файлы конфигурации\n\nЭто действие необратимо! Продолжить?",
"minorDiffTitle": "⚠️ Разница в минорной версии",
"minorDiffMessage": "Разница в минорных версиях обычно допустима, но структура данных могла немного измениться. Все текущие данные будут удалены!\n\nПродолжить импорт?",
"majorDiffTitle": "⛔ Импорт невозможен",
"majorDiffMessage": "Версии основного выпуска различаются. Импорт между мажорными версиями может привести к фатальному повреждению данных.\nИспользуйте AstrBot той же основной версии."
}
},
"list": {
"empty": "Резервные копии не найдены",
"refresh": "Обновить список",
"confirmDelete": "Вы уверены, что хотите безвозвратно удалить эту копию?",
"uploaded": "Загружено",
"restore": "Восстановить из этого файла",
"rename": "Переименовать",
"renameTitle": "Переименование файла",
"newName": "Новое имя",
"renameHint": "Разрешены буквы, цифры, точки, дефисы и подчеркивания",
"renameRequired": "Введите имя файла",
"renameInvalidChars": "Имя содержит недопустимые символы",
"renameFailed": "Ошибка переименования",
"ftpHint": "Для больших архивов вы можете загружать их напрямую в папку data/backups через FTP/SFTP."
}
},
"apiKey": {
"title": "API Keys",
"manageTitle": "Ключи доступа разработчика",
"subtitle": "Управление токенами для доступа к открытому HTTP API AstrBot.",
"name": "Имя ключа",
"expiresInDays": "Срок действия",
"expiryOptions": {
"day1": "1 день",
"day7": "7 дней",
"day30": "30 дней",
"day90": "90 дней",
"permanent": "Бессрочно"
},
"permanentWarning": "Бессрочные ключи менее безопасны. Пожалуйста, храните их в надежном месте.",
"scopes": "Область доступа (Scopes)",
"create": "Создать API Key",
"revoke": "Отозвать",
"delete": "Удалить",
"copy": "Копировать",
"docsLink": "Документация API",
"plaintextHint": "Обязательно сохраните ключ сейчас. После закрытия окна вы больше не сможете увидеть его значение.",
"empty": "Ключи не созданы",
"status": {
"active": "Активен",
"inactive": "Неактивен"
},
"table": {
"name": "Имя",
"prefix": "Префикс",
"scopes": "Права",
"status": "Статус",
"lastUsed": "Использован",
"createdAt": "Создан",
"actions": "Действия"
},
"messages": {
"loadFailed": "Не удалось загрузить ключи",
"scopeRequired": "Выберите хотя бы одну область доступа",
"createSuccess": "API Key создан",
"createFailed": "Ошибка создания ключа",
"revokeSuccess": "Ключ отозван",
"revokeFailed": "Ошибка отзыва ключа",
"deleteSuccess": "Ключ удален",
"deleteFailed": "Ошибка удаления ключа",
"copySuccess": "Ключ скопирован",
"copyFailed": "Ошибка копирования"
}
}
}
@@ -0,0 +1,65 @@
{
"page": {
"title": "Оркестрация SubAgent",
"beta": "Экспериментально",
"subtitle": "Основной LLM может напрямую использовать свои инструменты или делегировать задачи SubAgent через handoff."
},
"actions": {
"refresh": "Обновить",
"save": "Сохранить",
"add": "Добавить SubAgent",
"delete": "Удалить",
"close": "Закрыть"
},
"switches": {
"enable": "Включить оркестрацию SubAgent",
"enableHint": "Включить функциональность под-агентов",
"dedupe": "Дедупликация инструментов основного LLM (скрывать инструменты, дублируемые SubAgent)",
"dedupeHint": "Удалить дублирующиеся инструменты из основного агента"
},
"description": {
"disabled": "Выключено: SubAgent отключен; основной LLM подключает инструменты согласно правилам персонажа (все по умолчанию) и вызывает их напрямую.",
"enabled": "Включено: основной LLM сохраняет свои инструменты и подключает инструменты делегирования transfer_to_*. При дедупликации инструменты, пересекающиеся с SubAgent, удаляются из основного набора."
},
"section": {
"title": "Субагенты",
"globalSettings": "Глобальные настройки"
},
"cards": {
"statusEnabled": "Включено",
"statusDisabled": "Отключено",
"unnamed": "Безымянный SubAgent",
"transferPrefix": "передать_{name}",
"switchLabel": "Включить",
"previewTitle": "Предпросмотр: инструмент handoff, видимый основному LLM",
"personaChip": "Персонаж: {id}",
"personaPreview": "ПРЕДПРОСМОТР ПЕРСОНАЖА"
},
"form": {
"nameLabel": "Имя агента (используется для transfer_to_{name})",
"nameHint": "Используйте строчные латинские буквы и подчеркивания; имя должно быть глобально уникальным.",
"providerLabel": "Chat Provider (опционально)",
"providerHint": "Оставьте пустым, чтобы использовать глобальный провайдер по умолчанию.",
"personaLabel": "Выберите персонажа",
"personaHint": "SubAgent наследует системные настройки и инструменты выбранного персонажа.",
"descriptionLabel": "Описание для основного LLM (используется для принятия решения о handoff)",
"descriptionHint": "Отображается как описание инструмента transfer_to_* — будьте кратки и ясны."
},
"messages": {
"loadConfigFailed": "Не удалось загрузить конфигурацию",
"loadPersonaFailed": "Не удалось загрузить список персонажей",
"nameMissing": "У SubAgent отсутствует имя",
"nameInvalid": "Недопустимое имя SubAgent: только строчные латинские буквы/цифры/подчеркивания, должно начинаться с буквы",
"nameDuplicate": "Дублирующееся имя SubAgent: {name}",
"personaMissing": "У SubAgent {name} не выбран персонаж",
"saveSuccess": "Успешно сохранено",
"saveFailed": "Ошибка сохранения",
"nameRequired": "Имя обязательно",
"namePattern": "Только строчные буквы, цифры и подчеркивание"
},
"empty": {
"title": "Агенты не настроены",
"subtitle": "Добавьте первого под-агента, чтобы начать",
"action": "Создать первого агента"
}
}
@@ -0,0 +1,195 @@
{
"title": "Инструменты и функции",
"subtitle": "Управление MCP-серверами и доступными функциями",
"tooltip": {
"info": "Что такое Function Calling и MCP?",
"marketplace": "Обзор и установка MCP-серверов от сообщества",
"serverConfig": "Конфигурация MCP-серверов (stdio) поддерживает следующие поля:\ncommand: имя команды (например, python или uv)\nargs: массив аргументов (например, [\"run\", \"server.py\"])\nenv: объект переменных окружения (например, {\"api_key\": \"abc\"})\ncwd: рабочий каталог (например, /path/to/server)\nencoding: кодировка вывода (по умолчанию utf-8)\nПодробности см. в документации MCP.\n⚠️ Если вы используете Docker, устанавливайте сервера в смонтированную директорию data."
},
"tabs": {
"local": "Локальные сервера",
"marketplace": "Магазин MCP"
},
"mcpServers": {
"title": "MCP Сервера",
"buttons": {
"refresh": "Обновить",
"add": "Добавить сервер",
"useTemplateStdio": "Шаблон Stdio",
"useTemplateStreamableHttp": "Шаблон Streamable HTTP",
"useTemplateSse": "Шаблон SSE",
"sync": "Синхронизировать"
},
"empty": "MCP-сервера не найдены. Нажмите «Добавить сервер».",
"status": {
"noTools": "Нет доступных инструментов",
"availableTools": "Доступные инструменты",
"configSummary": "Конфигурация: {keys}",
"noConfig": "Конфигурация не задана"
}
},
"functionTools": {
"title": "Функции (Tools)",
"buttons": {
"view": "Показать инструменты"
},
"search": "Поиск по функциям",
"empty": "Доступные инструменты не найдены",
"description": "Описание функции",
"parameters": "Параметры",
"noParameters": "У этого инструмента нет параметров",
"table": {
"paramName": "Параметр",
"type": "Тип",
"description": "Описание",
"required": "Обяз.",
"origin": "Источник",
"originName": "Имя источника",
"actions": "Действия"
}
},
"marketplace": {
"title": "Магазин MCP-серверов",
"search": "Поиск по магазину",
"buttons": {
"refresh": "Обновить",
"detail": "Инфо",
"import": "Импорт"
},
"loading": "Загрузка списка серверов...",
"empty": "Доступных MCP-серверов не найдено",
"status": {
"availableTools": "Инструментов: {count}",
"noToolsInfo": "Нет данных об инструментах"
}
},
"dialogs": {
"addServer": {
"title": "Добавление MCP-сервера",
"editTitle": "Редактирование MCP-сервера",
"fields": {
"name": "Название сервера",
"nameRequired": "Название обязательно",
"enable": "Включить сервер",
"config": "Конфигурация сервера"
},
"errors": {
"configEmpty": "Конфигурация не может быть пустой",
"jsonFormat": "Ошибка формата JSON: {error}",
"jsonParse": "Ошибка разбора JSON: {error}"
},
"buttons": {
"cancel": "Отмена",
"save": "Сохранить",
"testConnection": "Тест связи",
"sync": "Синхронизировать"
},
"tips": {
"timeoutConfig": "Тайм-аут вызова инструментов настраивается отдельно на странице конфигурации"
}
},
"serverDetail": {
"title": "Детали сервера",
"installConfig": "Конфигурация установки",
"availableTools": "Список инструментов",
"buttons": {
"close": "Закрыть",
"importConfig": "Импортировать конфиг"
}
},
"confirmDelete": "Вы уверены, что хотите удалить сервер «{name}»?",
"syncProvider": {
"title": "Синхронизация MCP",
"subtitle": "Загрузка конфигурации MCP-серверов от провайдера",
"steps": {
"selectProvider": "Шаг 1: Провайдер",
"configureAuth": "Шаг 2: Авторизация",
"syncServers": "Шаг 3: Синхронизация"
},
"providers": {
"modelscope": "ModelScope",
"description": "ModelScope — это сообщество моделей с открытым исходным кодом, предоставляющее различные MCP-сервера для AI-сервисов"
},
"fields": {
"provider": "Выберите провайдера",
"accessToken": "Токен доступа",
"tokenRequired": "Токен обязателен",
"tokenHint": "Введите ваш токен доступа ModelScope"
},
"buttons": {
"cancel": "Отмена",
"previous": "Назад",
"next": "Далее",
"sync": "Начать",
"getToken": "Получить токен"
},
"status": {
"selectProvider": "Пожалуйста, выберите провайдера MCP-серверов",
"enterToken": "Введите токен для продолжения",
"readyToSync": "Готов к синхронизации"
},
"messages": {
"syncSuccess": "MCP-сервера успешно синхронизированы!",
"syncError": "Ошибка синхронизации: {error}",
"tokenHelp": "Как получить токен ModelScope? Нажмите кнопку справа для инструкции"
}
}
},
"messages": {
"getServersError": "Ошибка получения списка серверов: {error}",
"getToolsError": "Ошибка получения списка инструментов: {error}",
"saveSuccess": "Настройки сохранены!",
"saveError": "Ошибка сохранения: {error}",
"deleteSuccess": "Сервер удален успешно!",
"deleteError": "Ошибка удаления: {error}",
"updateSuccess": "Обновлено успешно!",
"updateError": "Ошибка обновления: {error}",
"getMarketError": "Не удалось загрузить магазин MCP: {error}",
"importError": {
"noConfig": "У этого сервера нет доступной конфигурации",
"invalidFormat": "Неверный формат конфигурации",
"failed": "Импорт не удался: {error}"
},
"configParseError": "Ошибка разбора конфигурации: {error}",
"noAvailableConfig": "Конфигурация отсутствует",
"toggleToolSuccess": "Статус инструмента изменен!",
"toggleToolError": "Не удалось изменить статус: {error}",
"testError": "Ошибка теста связи: {error}"
},
"syncProvider": {
"title": "Синхронизация серверов MCP",
"subtitle": "Синхронизировать конфигурации серверов MCP от провайдеров с локальными",
"steps": {
"selectProvider": "Шаг 1: Выберите провайдер",
"configureAuth": "Шаг 2: Настройте аутентификацию",
"syncServers": "Шаг 3: Синхронизируйте серверы"
},
"providers": {
"modelscope": "ModelScope",
"description": "ModelScope — это сообщество открытых моделей, предоставляющее серверы MCP для различных сервисов машинного обучения и ИИ"
},
"fields": {
"provider": "Выберите провайдер",
"accessToken": "Токен доступа",
"tokenRequired": "Требуется токен доступа",
"tokenHint": "Введите ваш токен доступа ModelScope"
},
"buttons": {
"cancel": "Отмена",
"previous": "Назад",
"next": "Далее",
"sync": "Начать синхронизацию",
"getToken": "Получить токен"
},
"status": {
"selectProvider": "Пожалуйста, выберите провайдер сервера MCP",
"enterToken": "Введите токен доступа для продолжения",
"readyToSync": "Готово к синхронизации конфигураций серверов"
},
"messages": {
"syncSuccess": "Серверы MCP успешно синхронизированы!",
"syncError": "Ошибка синхронизации: {error}",
"tokenHelp": "Как получить токен доступа ModelScope? Нажмите кнопку справа для получения инструкций"
}
}
}
@@ -0,0 +1,10 @@
{
"title": "Трассировка (Trace)",
"autoScroll": {
"enabled": "Автопрокрутка: ВКЛ",
"disabled": "Автопрокрутка: ВЫКЛ"
},
"hint": "В данный момент записываются только вызовы моделей основного агента AstrBot. Система будет совершенствоваться.",
"recording": "Запись...",
"paused": "Пауза"
}
@@ -0,0 +1,37 @@
{
"greeting": {
"morning": "Доброе утро, добро пожаловать в AstrBot",
"afternoon": "Добрый день, добро пожаловать в AstrBot",
"evening": "Добрый вечер, добро пожаловать в AstrBot",
"newYear": "С Новым Годом!"
},
"subtitle": "Сначала пройдите базовое руководство. Настройку платформ и провайдеров моделей можно завершить позже.",
"announcement": {
"title": "Объявление"
},
"onboard": {
"title": "Быстрый старт",
"subtitle": "Вы можете выполнить первичную настройку прямо здесь.",
"step1Title": "Настройка платформ",
"step1Desc": "Подключите AstrBot к QQ, Lark, WeChat, Telegram и другим мессенджерам.",
"step2Title": "Настройка AI моделей",
"step2Desc": "Выберите и настройте AI провайдеров для AstrBot.",
"configure": "Настроить",
"skip": "Пропустить",
"pending": "Ожидает",
"completed": "Готово",
"skipped": "Пропущено",
"platformLoadFailed": "Ошибка загрузки конфигурации платформ",
"providerLoadFailed": "Ошибка загрузки конфигурации провайдеров",
"providerUpdateFailed": "Ошибка обновления провайдера по умолчанию в файле default",
"providerDefaultUpdated": "Провайдер {id} установлен по умолчанию в файле default"
},
"resources": {
"title": "Ресурсы",
"githubDesc": "Поставьте нам звезду на GitHub!",
"docsTitle": "Документация",
"docsDesc": "Официальная документация AstrBot.",
"afdianTitle": "Afdian",
"afdianDesc": "Поддержите команду AstrBot через Afdian."
}
}
@@ -0,0 +1,39 @@
{
"network": {
"timeout": "Время ожидания запроса истекло, попробуйте позже",
"connection": "Ошибка сетевого соединения. Проверьте интернет",
"server": "Внутренняя ошибка сервера. Обратитесь в поддержку",
"unavailable": "Сервис временно недоступен",
"forbidden": "Доступ запрещен"
},
"validation": {
"required": "Это поле обязательно для заполнения",
"invalid": "Неверный формат ввода",
"tooLong": "Введено слишком много символов",
"tooShort": "Введено слишком мало символов",
"email": "Укажите корректный email",
"url": "Укажите корректный URL",
"number": "Введите числовое значение"
},
"auth": {
"unauthorized": "Авторизация не выполнена, войдите снова",
"forbidden": "Недостаточно прав для выполнения операции",
"tokenExpired": "Сессия истекла, пожалуйста, войдите заново",
"invalidCredentials": "Неверное имя пользователя или пароль"
},
"file": {
"uploadFailed": "Загрузка файла не удалась",
"invalidFormat": "Неподдерживаемый формат файла",
"tooLarge": "Файл слишком большой",
"notFound": "Файл не найден"
},
"operation": {
"failed": "Операция не удалась",
"cancelled": "Операция отменена",
"notSupported": "Действие не поддерживается",
"conflict": "Конфликт операций, попробуйте позже"
},
"browser": {
"audioNotSupported": "Ваш браузер не поддерживает воспроизведение аудио."
}
}
@@ -0,0 +1,23 @@
{
"operation": {
"saved": "Сохранено",
"created": "Создано",
"updated": "Обновлено успешно",
"deleted": "Удалено",
"uploaded": "Загружено",
"downloaded": "Скачано",
"imported": "Импорт завершен",
"exported": "Экспорт завершен",
"copied": "Скопировано в буфер",
"sent": "Отправлено"
},
"connection": {
"connected": "Подключено",
"authenticated": "Вход выполнен",
"synchronized": "Синхронизация завершена"
},
"validation": {
"valid": "Проверка пройдена",
"completed": "Готово"
}
}

Some files were not shown because too many files have changed in this diff Show More