Compare commits

..

2 Commits

152 changed files with 1188 additions and 8752 deletions
+5 -12
View File
@@ -3,8 +3,8 @@
### Modifications / 改动点
<!--Please summarize your changes: What core files were modified? What functionality was implemented?-->
<!--请总结你的改动:哪些核心文件被修改了?实现了什么功能?-->
<!--Please summarize your changes: What core files were modified? What functionality was implemented?-->
- [x] This is NOT a breaking change. / 这不是一个破坏性变更。
<!-- If your changes is a breaking change, please uncheck the checkbox above -->
@@ -21,14 +21,7 @@
<!--If merged, your code will serve tens of thousands of users! Please double-check the following items before submitting.-->
<!--如果分支被合并,您的代码将服务于数万名用户!在提交前,请核查一下几点内容。-->
- [ ] 😊 If there are new features added in the PR, I have discussed it with the authors through issues/emails, etc.
/ 如果 PR 中有新加入的功能,已经通过 Issue / 邮件等方式和作者讨论过。
- [ ] 👀 My changes have been well-tested, **and "Verification Steps" and "Screenshots" have been provided above**.
/ 我的更改经过了良好的测试,**并已在上方提供了“验证步骤”和“运行截图”**。
- [ ] 🤓 I have ensured that no new dependencies are introduced, OR if new dependencies are introduced, they have been added to the appropriate locations in `requirements.txt` and `pyproject.toml`.
/ 我确保没有引入新依赖库,或者引入了新依赖库的同时将其添加到 `requirements.txt``pyproject.toml` 文件相应位置。
- [ ] 😮 My changes do not introduce malicious code.
/ 我的更改没有引入恶意代码。
- [ ] 😊 如果 PR 中有新加入的功能,已经通过 Issue / 邮件等方式和作者讨论过。/ If there are new features added in the PR, I have discussed it with the authors through issues/emails, etc.
- [ ] 👀 我的更改经过了良好的测试,**并已在上方提供了“验证步骤”和“运行截图”**。/ My changes have been well-tested, **and "Verification Steps" and "Screenshots" have been provided above**.
- [ ] 🤓 我确保没有引入新依赖库,或者引入了新依赖库的同时将其添加到了 `requirements.txt``pyproject.toml` 文件相应位置。/ I have ensured that no new dependencies are introduced, OR if new dependencies are introduced, they have been added to the appropriate locations in `requirements.txt` and `pyproject.toml`.
- [ ] 😮 我的更改没有引入恶意代码。/ My changes do not introduce malicious code.
+3 -3
View File
@@ -11,7 +11,7 @@ jobs:
runs-on: ubuntu-latest # 运行环境
steps:
- name: checkout
uses: actions/checkout@v6
uses: actions/checkout@master
- 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@v1.0.0
uses: appleboy/scp-action@master
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@v1.2.5
uses: appleboy/ssh-action@master
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.20.0
uses: ncipollo/release-action@v1
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.0.0
uses: docker/setup-qemu-action@v4
- name: Set Docker Buildx
uses: docker/setup-buildx-action@v4.0.0
uses: docker/setup-buildx-action@v4
- name: Log in to DockerHub
uses: docker/login-action@v4.0.0
uses: docker/login-action@v4
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.0.0
uses: docker/login-action@v4
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.0.0
uses: docker/build-push-action@v7
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.0.0
uses: docker/setup-qemu-action@v4
- name: Set Docker Buildx
uses: docker/setup-buildx-action@v4.0.0
uses: docker/setup-buildx-action@v4
- name: Log in to DockerHub
uses: docker/login-action@v4.0.0
uses: docker/login-action@v4
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.0.0
uses: docker/login-action@v4
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.0.0
uses: docker/build-push-action@v7
with:
context: .
platforms: linux/amd64,linux/arm64
-45
View File
@@ -1,45 +0,0 @@
name: PR Checklist Check
on:
pull_request_target:
types: [opened, edited, reopened, synchronize]
jobs:
check:
runs-on: ubuntu-latest
permissions:
pull-requests: write
issues: write
steps:
- name: Check checklist
id: check
uses: actions/github-script@v7
with:
script: |
const body = context.payload.pull_request.body || "";
const regex = /-\s*\[\s*x\s*\].*没有.*认真阅读/i;
const bad = regex.test(body);
core.setOutput("bad", bad);
- name: Close PR
if: steps.check.outputs.bad == 'true'
uses: actions/github-script@v7
with:
script: |
const pr = context.payload.pull_request;
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: pr.number,
body: `检测到你勾选了“我没有认真阅读”,PR 已关闭。`
});
await github.rest.pulls.update({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: pr.number,
state: "closed"
});
+1 -1
View File
@@ -50,7 +50,7 @@ jobs:
echo "tag=$tag" >> "$GITHUB_OUTPUT"
- name: Setup pnpm
uses: pnpm/action-setup@v4.4.0
uses: pnpm/action-setup@v4
with:
version: 10.28.2
+1 -1
View File
@@ -62,4 +62,4 @@ GenieData/
.opencode/
.kilocode/
.worktrees/
docs/plans/
+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 run
astrbot
```
> 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 run
astrbot
```
> [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 run
astrbot
```
> [uv](https://docs.astral.sh/uv/) のインストールが必要です。
+1 -1
View File
@@ -78,7 +78,7 @@ AstrBot — это универсальная платформа Agent-чатб
```bash
uv tool install astrbot
astrbot init # Выполните эту команду только при первом запуске для инициализации окружения
astrbot run
astrbot
```
> Требуется установленный [uv](https://docs.astral.sh/uv/).
+1 -1
View File
@@ -78,7 +78,7 @@ AstrBot 是一個開源的一站式 Agent 聊天機器人平台,可接入主
```bash
uv tool install astrbot
astrbot init # 僅首次執行此命令以初始化環境
astrbot run
astrbot
```
> 需要安裝 [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 run
astrbot
```
> 需要安装 [uv](https://docs.astral.sh/uv/)。
+1 -1
View File
@@ -1 +1 @@
__version__ = "4.20.1"
__version__ = "4.19.5"
-5
View File
@@ -778,14 +778,9 @@ 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 -6
View File
@@ -188,12 +188,7 @@ class KnowledgeBaseQueryTool(FunctionTool[AstrAgentContext]):
@dataclass
class SendMessageToUserTool(FunctionTool[AstrAgentContext]):
name: str = "send_message_to_user"
description: str = (
"Send message to the user. "
"Supports various message types including `plain`, `image`, `record`, `video`, `file`, and `mention_user`. "
"Use this tool to send media files (`image`, `record`, `video`, `file`), "
"or when you need to proactively message the user(such as cron job). For normal text replies, you can output directly."
)
description: str = "Directly send message to the user. Only use this tool when you need to proactively message the user. Otherwise you can directly output the reply in the conversation."
parameters: dict = Field(
default_factory=lambda: {
+8 -38
View File
@@ -1,7 +1,6 @@
from __future__ import annotations
import asyncio
import locale
import os
import shutil
import subprocess
@@ -53,31 +52,6 @@ def _ensure_safe_path(path: str) -> str:
return abs_path
def _decode_shell_output(output: bytes | None) -> str:
if output is None:
return ""
preferred = locale.getpreferredencoding(False) or "utf-8"
try:
return output.decode("utf-8")
except (LookupError, UnicodeDecodeError):
pass
if os.name == "nt":
for encoding in ("mbcs", "cp936", "gbk", "gb18030"):
try:
return output.decode(encoding)
except (LookupError, UnicodeDecodeError):
continue
try:
return output.decode(preferred)
except (LookupError, UnicodeDecodeError):
pass
return output.decode("utf-8", errors="replace")
@dataclass
class LocalShellComponent(ShellComponent):
async def exec(
@@ -98,32 +72,28 @@ class LocalShellComponent(ShellComponent):
run_env.update({str(k): str(v) for k, v in env.items()})
working_dir = _ensure_safe_path(cwd) if cwd else get_astrbot_root()
if background:
# `command` is intentionally executed through the current shell so
# local computer-use behavior matches existing tool semantics.
# Safety relies on `_is_safe_command()` and the allowed-root checks.
proc = subprocess.Popen( # noqa: S602 # nosemgrep: python.lang.security.audit.dangerous-subprocess-use-audit
proc = subprocess.Popen(
command,
shell=shell,
cwd=working_dir,
env=run_env,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
)
return {"pid": proc.pid, "stdout": "", "stderr": "", "exit_code": None}
# `command` is intentionally executed through the current shell so
# local computer-use behavior matches existing tool semantics.
# Safety relies on `_is_safe_command()` and the allowed-root checks.
result = subprocess.run( # noqa: S602 # nosemgrep: python.lang.security.audit.dangerous-subprocess-use-audit
result = subprocess.run(
command,
shell=shell,
cwd=working_dir,
env=run_env,
timeout=timeout,
capture_output=True,
text=True,
)
return {
"stdout": _decode_shell_output(result.stdout),
"stderr": _decode_shell_output(result.stderr),
"stdout": result.stdout,
"stderr": result.stderr,
"exit_code": result.returncode,
}
+1 -4
View File
@@ -164,10 +164,7 @@ class CreateSkillPayloadTool(NeoSkillToolBase):
"type": "object",
"properties": {
"payload": {
"anyOf": [
{"type": "object"},
{"type": "array", "items": {"type": "object"}},
],
"anyOf": [{"type": "object"}, {"type": "array"}],
"description": (
"Skill payload JSON. Typical schema: {skill_markdown, inputs, outputs, meta}. "
"This only stores content and returns payload_ref; it does not create a candidate or release."
+1 -13
View File
@@ -5,7 +5,7 @@ from typing import Any, TypedDict
from astrbot.core.utils.astrbot_path import get_astrbot_data_path
VERSION = "4.20.1"
VERSION = "4.19.5"
DB_PATH = os.path.join(get_astrbot_data_path(), "data_v4.db")
WEBHOOK_SUPPORTED_PLATFORMS = [
@@ -1132,18 +1132,6 @@ CONFIG_METADATA_2 = {
"proxy": "",
"custom_headers": {},
},
"MiniMax": {
"id": "minimax",
"provider": "minimax",
"type": "openai_chat_completion",
"provider_type": "chat_completion",
"enable": True,
"key": [],
"api_base": "https://api.minimaxi.com/v1",
"timeout": 120,
"proxy": "",
"custom_headers": {},
},
"xAI": {
"id": "xai",
"provider": "xai",
+2 -2
View File
@@ -332,9 +332,9 @@ class CronJobManager:
cron_job=cron_job_str
)
req.prompt = (
"You are now responding to a scheduled task. "
"You are now responding to a scheduled task"
"Proceed according to your system instructions. "
"Output using same language as previous conversation. "
"Output using same language as previous conversation."
"After completing your task, summarize and output your actions and results."
)
if not req.func_tool:
-7
View File
@@ -647,13 +647,6 @@ 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,21 +1417,6 @@ 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) -> dict:
return {"type": "text", "data": {"text": self.text}}
def toDict(self):
return {"type": "text", "data": {"text": self.text.strip()}}
async def to_dict(self) -> dict:
async def to_dict(self):
return {"type": "text", "data": {"text": self.text}}
@@ -6,7 +6,6 @@ from aiocqhttp import CQHttp, Event
from astrbot.api.event import AstrMessageEvent, MessageChain
from astrbot.api.message_components import (
At,
BaseMessageComponent,
File,
Image,
@@ -71,19 +70,11 @@ class AiocqhttpMessageEvent(AstrMessageEvent):
"""解析成 OneBot json 格式"""
ret = []
for segment in message_chain.chain:
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 isinstance(segment, Plain):
if not segment.text.strip():
continue
d = await AiocqhttpMessageEvent._from_segment_to_dict(segment)
ret.append(d)
else:
d = await AiocqhttpMessageEvent._from_segment_to_dict(segment)
ret.append(d)
d = await AiocqhttpMessageEvent._from_segment_to_dict(segment)
ret.append(d)
return ret
@classmethod
@@ -18,7 +18,7 @@ from botpy.types.message import MarkdownPayload, Media
from astrbot.api import logger
from astrbot.api.event import AstrMessageEvent, MessageChain
from astrbot.api.message_components import File, Image, Plain, Record, Video
from astrbot.api.message_components import Image, Plain, Record
from astrbot.api.platform import AstrBotMessage, PlatformMetadata
from astrbot.core.utils.astrbot_path import get_astrbot_temp_path
from astrbot.core.utils.io import download_image_by_url, file_to_base64
@@ -47,11 +47,6 @@ _patch_qq_botpy_formdata()
class QQOfficialMessageEvent(AstrMessageEvent):
MARKDOWN_NOT_ALLOWED_ERROR = "不允许发送原生 markdown"
IMAGE_FILE_TYPE = 1
VIDEO_FILE_TYPE = 2
VOICE_FILE_TYPE = 3
FILE_FILE_TYPE = 4
STREAM_MARKDOWN_NEWLINE_ERROR = "流式消息md分片需要\\n结束"
def __init__(
self,
@@ -70,71 +65,35 @@ 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)
# 节流:按时间间隔发送中间分片
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):
# 真流式传输
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()
if isinstance(source, botpy.message.C2CMessage):
# 结束流式对话,发送 buffer 中剩余内容
# 结束流式对话,并且传输 buffer 中剩余的消息
stream_payload["state"] = 10
ret = await self._post_send(stream=stream_payload)
else:
@@ -142,22 +101,9 @@ class QQOfficialMessageEvent(AstrMessageEvent):
except Exception as e:
logger.error(f"发送流式消息时出错: {e}", exc_info=True)
# 避免累计内容在异常后被整包重复发送:仅清理缓存,不做非流式整包兜底
# 如需兜底,应该只发送未发送 delta(后续可继续优化)
self.send_buffer = None
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
return await super().send_streaming(generator, use_fallback)
async def _post_send(self, stream: dict | None = None):
if not self.send_buffer:
@@ -180,37 +126,16 @@ class QQOfficialMessageEvent(AstrMessageEvent):
image_base64,
image_path,
record_file_path,
video_file_source,
file_source,
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
and not image_path
and not record_file_path
and not video_file_source
and not file_source
):
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,
@@ -232,7 +157,7 @@ class QQOfficialMessageEvent(AstrMessageEvent):
if image_base64:
media = await self.upload_group_and_c2c_image(
image_base64,
self.IMAGE_FILE_TYPE,
1,
group_openid=source.group_openid,
)
payload["media"] = media
@@ -240,39 +165,15 @@ class QQOfficialMessageEvent(AstrMessageEvent):
payload.pop("markdown", None)
payload["content"] = plain_text or None
if record_file_path: # group record msg
media = await self.upload_group_and_c2c_media(
media = await self.upload_group_and_c2c_record(
record_file_path,
self.VOICE_FILE_TYPE,
3,
group_openid=source.group_openid,
)
if media:
payload["media"] = media
payload["msg_type"] = 7
payload.pop("markdown", None)
payload["content"] = plain_text or None
if video_file_source:
media = await self.upload_group_and_c2c_media(
video_file_source,
self.VIDEO_FILE_TYPE,
group_openid=source.group_openid,
)
if media:
payload["media"] = media
payload["msg_type"] = 7
payload.pop("markdown", None)
payload["content"] = plain_text or None
if file_source:
media = await self.upload_group_and_c2c_media(
file_source,
self.FILE_FILE_TYPE,
file_name=file_name,
group_openid=source.group_openid,
)
if media:
payload["media"] = media
payload["msg_type"] = 7
payload.pop("markdown", None)
payload["content"] = plain_text or None
payload["media"] = media
payload["msg_type"] = 7
payload.pop("markdown", None)
payload["content"] = plain_text or None
ret = await self._send_with_markdown_fallback(
send_func=lambda retry_payload: self.bot.api.post_group_message(
group_openid=source.group_openid, # type: ignore
@@ -280,14 +181,13 @@ class QQOfficialMessageEvent(AstrMessageEvent):
),
payload=payload,
plain_text=plain_text,
stream=stream,
)
case botpy.message.C2CMessage():
if image_base64:
media = await self.upload_group_and_c2c_image(
image_base64,
self.IMAGE_FILE_TYPE,
1,
openid=source.author.user_openid,
)
payload["media"] = media
@@ -295,39 +195,15 @@ class QQOfficialMessageEvent(AstrMessageEvent):
payload.pop("markdown", None)
payload["content"] = plain_text or None
if record_file_path: # c2c record
media = await self.upload_group_and_c2c_media(
media = await self.upload_group_and_c2c_record(
record_file_path,
self.VOICE_FILE_TYPE,
3,
openid=source.author.user_openid,
)
if media:
payload["media"] = media
payload["msg_type"] = 7
payload.pop("markdown", None)
payload["content"] = plain_text or None
if video_file_source:
media = await self.upload_group_and_c2c_media(
video_file_source,
self.VIDEO_FILE_TYPE,
openid=source.author.user_openid,
)
if media:
payload["media"] = media
payload["msg_type"] = 7
payload.pop("markdown", None)
payload["content"] = plain_text or None
if file_source:
media = await self.upload_group_and_c2c_media(
file_source,
self.FILE_FILE_TYPE,
file_name=file_name,
openid=source.author.user_openid,
)
if media:
payload["media"] = media
payload["msg_type"] = 7
payload.pop("markdown", None)
payload["content"] = plain_text or None
payload["media"] = media
payload["msg_type"] = 7
payload.pop("markdown", None)
payload["content"] = plain_text or None
if stream:
ret = await self._send_with_markdown_fallback(
send_func=lambda retry_payload: self.post_c2c_message(
@@ -337,7 +213,6 @@ class QQOfficialMessageEvent(AstrMessageEvent):
),
payload=payload,
plain_text=plain_text,
stream=stream,
)
else:
ret = await self._send_with_markdown_fallback(
@@ -347,7 +222,6 @@ class QQOfficialMessageEvent(AstrMessageEvent):
),
payload=payload,
plain_text=plain_text,
stream=stream,
)
logger.debug(f"Message sent to C2C: {ret}")
@@ -363,7 +237,6 @@ class QQOfficialMessageEvent(AstrMessageEvent):
),
payload=payload,
plain_text=plain_text,
stream=stream,
)
case botpy.message.DirectMessage():
@@ -378,7 +251,6 @@ class QQOfficialMessageEvent(AstrMessageEvent):
),
payload=payload,
plain_text=plain_text,
stream=stream,
)
case _:
@@ -395,31 +267,10 @@ 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")
@@ -431,14 +282,10 @@ class QQOfficialMessageEvent(AstrMessageEvent):
"[QQOfficial] markdown 发送被拒绝,回退到 content 模式重试。"
)
fallback_payload = payload.copy()
fallback_payload.pop("markdown", None)
fallback_payload["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(
@@ -480,19 +327,16 @@ class QQOfficialMessageEvent(AstrMessageEvent):
ttl=result.get("ttl", 0),
)
async def upload_group_and_c2c_media(
async def upload_group_and_c2c_record(
self,
file_source: str,
file_type: int,
srv_send_msg: bool = False,
file_name: str | None = None,
**kwargs,
) -> Media | None:
"""上传媒体文件"""
# 构建基础payload
payload = {"file_type": file_type, "srv_send_msg": srv_send_msg}
if file_name:
payload["file_name"] = file_name
# 处理文件数据
if os.path.exists(file_source):
@@ -556,21 +400,13 @@ 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):
logger.error(f"[QQOfficial] post_c2c_message: 响应不是 dict: {result}")
return None
raise RuntimeError(
f"Failed to post c2c message, response is not dict: {result}"
)
return message.Message(**result)
@@ -580,9 +416,6 @@ class QQOfficialMessageEvent(AstrMessageEvent):
image_base64 = None # only one img supported
image_file_path = None
record_file_path = None
video_file_source = None
file_source = None
file_name = None
for i in message.chain:
if isinstance(i, Plain):
plain_text += i.text
@@ -621,30 +454,6 @@ class QQOfficialMessageEvent(AstrMessageEvent):
except Exception as e:
logger.error(f"处理语音时出错: {e}")
record_file_path = None
elif isinstance(i, Video) and not video_file_source:
if i.file.startswith("file:///"):
video_file_source = i.file[8:]
else:
video_file_source = i.file
elif isinstance(i, File) and not file_source:
file_name = i.name
if i.file_:
file_path = i.file_
if file_path.startswith("file:///"):
file_path = file_path[8:]
elif file_path.startswith("file://"):
file_path = file_path[7:]
file_source = file_path
elif i.url:
file_source = i.url
else:
logger.debug(f"qq_official 忽略 {i.type}")
return (
plain_text,
image_base64,
image_file_path,
record_file_path,
video_file_source,
file_source,
file_name,
)
return plain_text, image_base64, image_file_path, record_file_path
@@ -3,10 +3,8 @@ from __future__ import annotations
import asyncio
import logging
import os
import random
import time
from types import SimpleNamespace
from typing import Any, cast
from typing import cast
import botpy
import botpy.message
@@ -14,7 +12,7 @@ from botpy import Client
from astrbot import logger
from astrbot.api.event import MessageChain
from astrbot.api.message_components import At, File, Image, Plain, Record, Video
from astrbot.api.message_components import At, File, Image, Plain
from astrbot.api.platform import (
AstrBotMessage,
MessageMember,
@@ -48,7 +46,6 @@ class botClient(Client):
)
abm.group_id = cast(str, message.group_openid)
abm.session_id = abm.group_id
self.platform.remember_session_scene(abm.session_id, "group")
self._commit(abm)
# 收到频道消息
@@ -59,7 +56,6 @@ class botClient(Client):
)
abm.group_id = message.channel_id
abm.session_id = abm.group_id
self.platform.remember_session_scene(abm.session_id, "channel")
self._commit(abm)
# 收到私聊消息
@@ -71,7 +67,6 @@ class botClient(Client):
MessageType.FRIEND_MESSAGE,
)
abm.session_id = abm.sender.user_id
self.platform.remember_session_scene(abm.session_id, "friend")
self._commit(abm)
# 收到 C2C 消息
@@ -81,11 +76,9 @@ class botClient(Client):
MessageType.FRIEND_MESSAGE,
)
abm.session_id = abm.sender.user_id
self.platform.remember_session_scene(abm.session_id, "friend")
self._commit(abm)
def _commit(self, abm: AstrBotMessage) -> None:
self.platform.remember_session_message_id(abm.session_id, abm.message_id)
self.platform.commit_event(
QQOfficialMessageEvent(
abm.message_str,
@@ -131,9 +124,6 @@ class QQOfficialPlatformAdapter(Platform):
self.client.set_platform(self)
self._session_last_message_id: dict[str, str] = {}
self._session_scene: dict[str, str] = {}
self.test_mode = os.environ.get("TEST_MODE", "off") == "on"
async def send_by_session(
@@ -141,191 +131,14 @@ class QQOfficialPlatformAdapter(Platform):
session: MessageSesion,
message_chain: MessageChain,
) -> None:
await self._send_by_session_common(session, message_chain)
async def _send_by_session_common(
self,
session: MessageSesion,
message_chain: MessageChain,
) -> None:
(
plain_text,
image_base64,
image_path,
record_file_path,
video_file_source,
file_source,
file_name,
) = await QQOfficialMessageEvent._parse_to_qqofficial(message_chain)
if (
not plain_text
and not image_path
and not image_base64
and not record_file_path
and not video_file_source
and not file_source
):
return
msg_id = self._session_last_message_id.get(session.session_id)
if not msg_id:
logger.warning(
"[QQOfficial] No cached msg_id for session: %s, skip send_by_session",
session.session_id,
)
return
payload: dict[str, Any] = {"content": plain_text, "msg_id": msg_id}
ret: Any = None
send_helper = SimpleNamespace(bot=self.client)
if session.message_type == MessageType.GROUP_MESSAGE:
scene = self._session_scene.get(session.session_id)
if scene == "group":
payload["msg_seq"] = random.randint(1, 10000)
if image_base64:
media = await QQOfficialMessageEvent.upload_group_and_c2c_image(
send_helper, # type: ignore
image_base64,
QQOfficialMessageEvent.IMAGE_FILE_TYPE,
group_openid=session.session_id,
)
payload["media"] = media
payload["msg_type"] = 7
if record_file_path:
media = await QQOfficialMessageEvent.upload_group_and_c2c_media(
send_helper, # type: ignore
record_file_path,
QQOfficialMessageEvent.VOICE_FILE_TYPE,
group_openid=session.session_id,
)
if media:
payload["media"] = media
payload["msg_type"] = 7
if video_file_source:
media = await QQOfficialMessageEvent.upload_group_and_c2c_media(
send_helper, # type: ignore
video_file_source,
QQOfficialMessageEvent.VIDEO_FILE_TYPE,
group_openid=session.session_id,
)
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
file_source,
QQOfficialMessageEvent.FILE_FILE_TYPE,
file_name=file_name,
group_openid=session.session_id,
)
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,
)
else:
if image_path:
payload["file_image"] = image_path
ret = await self.client.api.post_message(
channel_id=session.session_id,
**payload,
)
elif session.message_type == MessageType.FRIEND_MESSAGE:
payload["msg_seq"] = random.randint(1, 10000)
if image_base64:
media = await QQOfficialMessageEvent.upload_group_and_c2c_image(
send_helper, # type: ignore
image_base64,
QQOfficialMessageEvent.IMAGE_FILE_TYPE,
openid=session.session_id,
)
payload["media"] = media
payload["msg_type"] = 7
if record_file_path:
media = await QQOfficialMessageEvent.upload_group_and_c2c_media(
send_helper, # type: ignore
record_file_path,
QQOfficialMessageEvent.VOICE_FILE_TYPE,
openid=session.session_id,
)
if media:
payload["media"] = media
payload["msg_type"] = 7
if video_file_source:
media = await QQOfficialMessageEvent.upload_group_and_c2c_media(
send_helper, # type: ignore
video_file_source,
QQOfficialMessageEvent.VIDEO_FILE_TYPE,
openid=session.session_id,
)
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
file_source,
QQOfficialMessageEvent.FILE_FILE_TYPE,
file_name=file_name,
openid=session.session_id,
)
if media:
payload["media"] = media
payload["msg_type"] = 7
payload.pop("msg_id", None)
ret = await QQOfficialMessageEvent.post_c2c_message(
send_helper, # type: ignore
openid=session.session_id,
**payload,
)
else:
logger.warning(
"[QQOfficial] Unsupported message type for send_by_session: %s",
session.message_type,
)
return
sent_message_id = self._extract_message_id(ret)
if sent_message_id:
self.remember_session_message_id(session.session_id, sent_message_id)
await super().send_by_session(session, message_chain)
def remember_session_message_id(self, session_id: str, message_id: str) -> None:
if not session_id or not message_id:
return
self._session_last_message_id[session_id] = message_id
def remember_session_scene(self, session_id: str, scene: str) -> None:
if not session_id or not scene:
return
self._session_scene[session_id] = scene
def _extract_message_id(self, ret: Any) -> str | None:
if isinstance(ret, dict):
message_id = ret.get("id")
return str(message_id) if message_id else None
message_id = getattr(ret, "id", None)
if message_id:
return str(message_id)
return None
raise NotImplementedError("QQ 机器人官方 API 适配器不支持 send_by_session")
def meta(self) -> PlatformMetadata:
return PlatformMetadata(
name="qq_official",
description="QQ 机器人官方 API 适配器",
id=cast(str, self.config.get("id")),
support_proactive_message=True,
support_proactive_message=False,
)
@staticmethod
@@ -345,10 +158,7 @@ class QQOfficialPlatformAdapter(Platform):
return
for attachment in attachments:
content_type = cast(
str,
getattr(attachment, "content_type", "") or "",
).lower()
content_type = cast(str, getattr(attachment, "content_type", "") or "")
url = QQOfficialPlatformAdapter._normalize_attachment_url(
cast(str | None, getattr(attachment, "url", None))
)
@@ -364,73 +174,7 @@ class QQOfficialPlatformAdapter(Platform):
or getattr(attachment, "name", None)
or "attachment",
)
ext = os.path.splitext(filename)[1].lower()
image_exts = {".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp"}
audio_exts = {
".mp3",
".wav",
".ogg",
".m4a",
".amr",
".silk",
}
video_exts = {
".mp4",
".mov",
".avi",
".mkv",
".webm",
}
if content_type.startswith("audio") or ext in audio_exts:
msg.append(Record.fromURL(url))
elif content_type.startswith("video") or ext in video_exts:
msg.append(Video.fromURL(url))
elif content_type.startswith("image") or ext in image_exts:
msg.append(Image.fromURL(url))
else:
msg.append(File(name=filename, file=url, url=url))
@staticmethod
def _parse_face_message(content: str) -> str:
"""Parse QQ official face message format and convert to readable text.
QQ official face message format:
<faceType=4,faceId="",ext="eyJ0ZXh0IjoiW+a7oeWktOmXruWPt10ifQ==">
The ext field contains base64-encoded JSON with a 'text' field
describing the emoji (e.g., '[满头问号]').
Args:
content: The message content that may contain face tags.
Returns:
Content with face tags replaced by readable emoji descriptions.
"""
import base64
import json
import re
def replace_face(match):
face_tag = match.group(0)
# Extract ext field from the face tag
ext_match = re.search(r'ext="([^"]*)"', face_tag)
if ext_match:
try:
ext_encoded = ext_match.group(1)
# Decode base64 and parse JSON
ext_decoded = base64.b64decode(ext_encoded).decode("utf-8")
ext_data = json.loads(ext_decoded)
emoji_text = ext_data.get("text", "")
if emoji_text:
return f"[表情:{emoji_text}]"
except Exception:
pass
# Fallback if parsing fails
return "[表情]"
# Match face tags: <faceType=...>
return re.sub(r"<faceType=\d+[^>]*>", replace_face, content)
msg.append(File(name=filename, file=url, url=url))
@staticmethod
def _parse_from_qqofficial(
@@ -457,10 +201,7 @@ class QQOfficialPlatformAdapter(Platform):
abm.group_id = message.group_openid
else:
abm.sender = MessageMember(message.author.user_openid, "")
# Parse face messages to readable text
abm.message_str = QQOfficialPlatformAdapter._parse_face_message(
message.content.strip()
)
abm.message_str = message.content.strip()
abm.self_id = "unknown_selfid"
msg.append(At(qq="qq_official"))
msg.append(Plain(abm.message_str))
@@ -476,12 +217,10 @@ class QQOfficialPlatformAdapter(Platform):
else:
abm.self_id = ""
plain_content = QQOfficialPlatformAdapter._parse_face_message(
message.content.replace(
"<@!" + str(abm.self_id) + ">",
"",
).strip()
)
plain_content = message.content.replace(
"<@!" + str(abm.self_id) + ">",
"",
).strip()
QQOfficialPlatformAdapter._append_attachments(msg, message.attachments)
abm.message = msg
@@ -1,5 +1,7 @@
import asyncio
import logging
import random
from types import SimpleNamespace
from typing import Any, cast
import botpy
@@ -13,6 +15,7 @@ from astrbot.core.platform.astr_message_event import MessageSesion
from astrbot.core.utils.webhook_utils import log_webhook_info
from ...register import register_platform_adapter
from ..qqofficial.qqofficial_message_event import QQOfficialMessageEvent
from ..qqofficial.qqofficial_platform_adapter import QQOfficialPlatformAdapter
from .qo_webhook_event import QQOfficialWebhookMessageEvent
from .qo_webhook_server import QQOfficialWebhook
@@ -120,11 +123,95 @@ class QQOfficialWebhookPlatformAdapter(Platform):
session: MessageSesion,
message_chain: MessageChain,
) -> None:
await QQOfficialPlatformAdapter._send_by_session_common(
cast(Any, self),
session,
message_chain,
)
(
plain_text,
image_base64,
image_path,
record_file_path,
) = await QQOfficialMessageEvent._parse_to_qqofficial(message_chain)
if not plain_text and not image_path:
return
msg_id = self._session_last_message_id.get(session.session_id)
if not msg_id:
logger.warning(
"[QQOfficialWebhook] No cached msg_id for session: %s, skip send_by_session",
session.session_id,
)
return
payload: dict[str, Any] = {"content": plain_text, "msg_id": msg_id}
ret: Any = None
send_helper = SimpleNamespace(bot=self.client)
if session.message_type == MessageType.GROUP_MESSAGE:
scene = self._session_scene.get(session.session_id)
if scene == "group":
payload["msg_seq"] = random.randint(1, 10000)
if image_base64:
media = await QQOfficialMessageEvent.upload_group_and_c2c_image(
send_helper, # type: ignore
image_base64,
1,
group_openid=session.session_id,
)
payload["media"] = media
payload["msg_type"] = 7
if record_file_path:
media = await QQOfficialMessageEvent.upload_group_and_c2c_record(
send_helper, # type: ignore
record_file_path,
3,
group_openid=session.session_id,
)
payload["media"] = media
payload["msg_type"] = 7
ret = await self.client.api.post_group_message(
group_openid=session.session_id,
**payload,
)
else:
if image_path:
payload["file_image"] = image_path
ret = await self.client.api.post_message(
channel_id=session.session_id,
**payload,
)
elif session.message_type == MessageType.FRIEND_MESSAGE:
payload["msg_seq"] = random.randint(1, 10000)
if image_base64:
media = await QQOfficialMessageEvent.upload_group_and_c2c_image(
send_helper, # type: ignore
image_base64,
1,
openid=session.session_id,
)
payload["media"] = media
payload["msg_type"] = 7
if record_file_path:
media = await QQOfficialMessageEvent.upload_group_and_c2c_record(
send_helper, # type: ignore
record_file_path,
3,
openid=session.session_id,
)
payload["media"] = media
payload["msg_type"] = 7
ret = await QQOfficialMessageEvent.post_c2c_message(
send_helper, # type: ignore
openid=session.session_id,
**payload,
)
else:
logger.warning(
"[QQOfficialWebhook] Unsupported message type for send_by_session: %s",
session.message_type,
)
return
sent_message_id = self._extract_message_id(ret)
if sent_message_id:
self.remember_session_message_id(session.session_id, sent_message_id)
await super().send_by_session(session, message_chain)
def remember_session_message_id(self, session_id: str, message_id: str) -> None:
if not session_id or not message_id:
@@ -1,6 +1,5 @@
import asyncio
import logging
import time
from typing import cast
import quart
@@ -40,9 +39,6 @@ class QQOfficialWebhook:
self.client = botpy_client
self.event_queue = event_queue
self.shutdown_event = asyncio.Event()
# Deduplication cache for webhook retry callbacks.
self._seen_event_ids: dict[str, float] = {}
self._dedup_ttl: int = 60 # seconds
async def initialize(self) -> None:
logger.info("正在登录到 QQ 官方机器人...")
@@ -110,22 +106,6 @@ class QQOfficialWebhook:
print(signed)
return signed
event_id = msg.get("id")
if event_id:
now = time.monotonic()
# Lazily evict expired entries to prevent unbounded growth.
expired = [
k
for k, ts in self._seen_event_ids.items()
if now - ts > self._dedup_ttl
]
for k in expired:
del self._seen_event_ids[k]
if event_id in self._seen_event_ids:
logger.debug(f"Duplicate webhook event {event_id!r}, skipping.")
return {"opcode": 12}
self._seen_event_ids[event_id] = now
if event and opcode == BotWebSocket.WS_DISPATCH_EVENT:
event = msg["t"].lower()
try:
@@ -289,8 +289,8 @@ class TelegramPlatformAdapter(Platform):
else:
message.type = MessageType.GROUP_MESSAGE
message.group_id = str(update.message.chat.id)
if update.message.is_topic_message and update.message.message_thread_id:
# Telegram Topic Group: include thread id to isolate per-topic sessions.
if update.message.message_thread_id:
# Topic Group
message.group_id += "#" + str(update.message.message_thread_id)
message.session_id = message.group_id
message.message_id = str(update.message.message_id)
@@ -25,16 +25,6 @@ from astrbot.api.platform import AstrBotMessage, MessageType, PlatformMetadata
from astrbot.core.utils.metrics import Metric
def _is_gif(path: str) -> bool:
if path.lower().endswith(".gif"):
return True
try:
with open(path, "rb") as f:
return f.read(6) in (b"GIF87a", b"GIF89a")
except OSError:
return False
class TelegramPlatformEvent(AstrMessageEvent):
# Telegram 的最大消息长度限制
MAX_MESSAGE_LENGTH = 4096
@@ -288,6 +278,7 @@ class TelegramPlatformEvent(AstrMessageEvent):
try:
md_text = telegramify_markdown.markdownify(
chunk,
normalize_whitespace=False,
)
await client.send_message(
text=md_text,
@@ -301,13 +292,7 @@ class TelegramPlatformEvent(AstrMessageEvent):
await client.send_message(text=chunk, **cast(Any, payload))
elif isinstance(i, Image):
image_path = await i.convert_to_file_path()
if _is_gif(image_path):
send_coro = client.send_animation
media_kwarg = {"animation": image_path}
else:
send_coro = client.send_photo
media_kwarg = {"photo": image_path}
await send_coro(**media_kwarg, **cast(Any, payload))
await client.send_photo(photo=image_path, **cast(Any, payload))
elif isinstance(i, File):
path = await i.get_file()
name = i.name or os.path.basename(path)
@@ -422,20 +407,12 @@ class TelegramPlatformEvent(AstrMessageEvent):
on_text(i.text)
elif isinstance(i, Image):
image_path = await i.convert_to_file_path()
if _is_gif(image_path):
action = ChatAction.UPLOAD_VIDEO
send_coro = self.client.send_animation
media_kwarg = {"animation": image_path}
else:
action = ChatAction.UPLOAD_PHOTO
send_coro = self.client.send_photo
media_kwarg = {"photo": image_path}
await self._send_media_with_action(
self.client,
action,
send_coro,
ChatAction.UPLOAD_PHOTO,
self.client.send_photo,
user_name=user_name,
**media_kwarg,
photo=image_path,
**cast(Any, payload),
)
elif isinstance(i, File):
@@ -479,6 +456,7 @@ class TelegramPlatformEvent(AstrMessageEvent):
try:
markdown_text = telegramify_markdown.markdownify(
delta,
normalize_whitespace=False,
)
await self.client.send_message(
text=markdown_text,
@@ -559,6 +537,7 @@ class TelegramPlatformEvent(AstrMessageEvent):
try:
md = telegramify_markdown.markdownify(
draft_text,
normalize_whitespace=False,
)
await self._send_message_draft(
user_name,
@@ -716,6 +695,7 @@ class TelegramPlatformEvent(AstrMessageEvent):
try:
markdown_text = telegramify_markdown.markdownify(
delta,
normalize_whitespace=False,
)
await self.client.edit_message_text(
text=markdown_text,
@@ -440,16 +440,9 @@ class WecomAIBotAdapter(Platform):
)
def _extract_session_id(self, message_data: dict[str, Any]) -> str:
"""从消息数据中提取会话ID
群聊使用 chatid单聊使用 userid
"""
chattype = message_data.get("chattype", "single")
if chattype == "group":
chat_id = message_data.get("chatid", "default_group")
return format_session_id("wecomai", chat_id)
else:
user_id = message_data.get("from", {}).get("userid", "default_user")
return format_session_id("wecomai", user_id)
"""从消息数据中提取会话ID"""
user_id = message_data.get("from", {}).get("userid", "default_user")
return format_session_id("wecomai", user_id)
async def _enqueue_message(
self,
-2
View File
@@ -808,8 +808,6 @@ class ProviderManager:
config.save_config()
# load instance
await self.load_provider(new_config)
# sync in-memory config for API queries (e.g., embedding provider list)
self.providers_config = astrbot_config["provider"]
async def terminate(self) -> None:
if self._mcp_init_task and not self._mcp_init_task.done():
@@ -13,11 +13,3 @@ class ProviderGroq(ProviderOpenAIOfficial):
) -> None:
super().__init__(provider_config, provider_settings)
self.reasoning_key = "reasoning"
def _finally_convert_payload(self, payloads: dict) -> None:
"""Groq rejects assistant history items that include reasoning_content."""
super()._finally_convert_payload(payloads)
for message in payloads.get("messages", []):
if message.get("role") == "assistant":
message.pop("reasoning_content", None)
message.pop("reasoning", None)
@@ -311,7 +311,7 @@ class ProviderOpenAIOfficial(Provider):
state.handle_chunk(chunk)
except Exception as e:
logger.warning("Saving chunk state error: " + str(e))
if not chunk.choices:
if len(chunk.choices) == 0:
continue
delta = chunk.choices[0].delta
# logger.debug(f"chunk delta: {delta}")
@@ -322,7 +322,7 @@ class ProviderOpenAIOfficial(Provider):
if reasoning:
llm_response.reasoning_content = reasoning
_y = True
if delta and delta.content:
if delta.content:
# Don't strip streaming chunks to preserve spaces between words
completion_text = self._normalize_content(delta.content, strip=False)
llm_response.result_chain = MessageChain(
@@ -345,7 +345,7 @@ class ProviderOpenAIOfficial(Provider):
) -> str:
"""Extract reasoning content from OpenAI ChatCompletion if available."""
reasoning_text = ""
if not completion.choices:
if len(completion.choices) == 0:
return reasoning_text
if isinstance(completion, ChatCompletion):
choice = completion.choices[0]
@@ -468,7 +468,7 @@ class ProviderOpenAIOfficial(Provider):
"""Parse OpenAI ChatCompletion into LLMResponse"""
llm_response = LLMResponse("assistant")
if not completion.choices:
if len(completion.choices) == 0:
raise Exception("API 返回的 completion 为空。")
choice = completion.choices[0]
@@ -629,8 +629,7 @@ class ProviderOpenAIOfficial(Provider):
# 最后一次不等待
if retry_cnt < max_retries - 1:
await asyncio.sleep(1)
if chosen_key in available_api_keys:
available_api_keys.remove(chosen_key)
available_api_keys.remove(chosen_key)
if len(available_api_keys) > 0:
chosen_key = random.choice(available_api_keys)
return (
@@ -16,7 +16,4 @@ class ProviderOpenRouter(ProviderOpenAIOfficial):
self.client._custom_headers["HTTP-Referer"] = ( # type: ignore
"https://github.com/AstrBotDevs/AstrBot"
)
self.client._custom_headers["X-OpenRouter-Title"] = "AstrBot" # type: ignore
self.client._custom_headers["X-OpenRouter-Categories"] = (
"general-chat,personal-agent" # type: ignore
)
self.client._custom_headers["X-TITLE"] = "AstrBot" # type: ignore
+7 -82
View File
@@ -3,7 +3,6 @@ from __future__ import annotations
import json
import os
import re
import shlex
import shutil
import tempfile
import zipfile
@@ -80,59 +79,7 @@ def _parse_frontmatter_description(text: str) -> str:
# Regex for sanitizing paths used in prompt examples — only allow
# safe path characters to prevent prompt injection via crafted skill paths.
_SAFE_PATH_RE = re.compile(r"[^\w./ ,()'\-]", re.UNICODE)
_WINDOWS_DRIVE_PATH_RE = re.compile(r"^[A-Za-z]:(?:/|\\)")
_WINDOWS_UNC_PATH_RE = re.compile(r"^(//|\\\\)[^/\\]+[/\\][^/\\]+")
_CONTROL_CHARS_RE = re.compile(r"[\x00-\x1F\x7F]")
def _is_windows_prompt_path(path: str) -> bool:
if os.name != "nt":
return False
return bool(_WINDOWS_DRIVE_PATH_RE.match(path) or _WINDOWS_UNC_PATH_RE.match(path))
def _sanitize_prompt_path_for_prompt(path: str) -> str:
if not path:
return ""
if _WINDOWS_DRIVE_PATH_RE.match(path) or _WINDOWS_UNC_PATH_RE.match(path):
path = path.replace("\\", "/")
drive_prefix = ""
if _WINDOWS_DRIVE_PATH_RE.match(path):
drive_prefix = path[:2]
path = path[2:]
path = path.replace("`", "")
path = _CONTROL_CHARS_RE.sub("", path)
sanitized = _SAFE_PATH_RE.sub("", path)
return f"{drive_prefix}{sanitized}"
def _sanitize_prompt_description(description: str) -> str:
description = description.replace("`", "")
description = _CONTROL_CHARS_RE.sub(" ", description)
description = " ".join(description.split())
return description
def _sanitize_skill_display_name(name: str) -> str:
if _SKILL_NAME_RE.fullmatch(name):
return name
return "<invalid_skill_name>"
def _build_skill_read_command_example(path: str) -> str:
if path == "<skills_root>/<skill_name>/SKILL.md":
return f"cat {path}"
if _is_windows_prompt_path(path):
command = "type"
path_arg = f'"{path}"'
else:
command = "cat"
path_arg = shlex.quote(path)
return f"{command} {path_arg}"
_SAFE_PATH_RE = re.compile(r"[^A-Za-z0-9_./ -]")
def build_skills_prompt(skills: list[SkillInfo]) -> str:
@@ -145,37 +92,16 @@ def build_skills_prompt(skills: list[SkillInfo]) -> str:
skills_lines: list[str] = []
example_path = ""
for skill in skills:
display_name = _sanitize_skill_display_name(skill.name)
description = skill.description or "No description"
if skill.source_type == "sandbox_only":
description = _sanitize_prompt_description(description)
if not description:
description = "Read SKILL.md for details."
if skill.source_type == "sandbox_only":
rendered_path = (
f"{str(SANDBOX_WORKSPACE_ROOT)}/{str(SANDBOX_SKILLS_ROOT)}/"
f"{display_name}/SKILL.md"
)
else:
rendered_path = _sanitize_prompt_path_for_prompt(skill.path)
if not rendered_path:
rendered_path = "<skills_root>/<skill_name>/SKILL.md"
skills_lines.append(
f"- **{display_name}**: {description}\n File: `{rendered_path}`"
f"- **{skill.name}**: {description}\n File: `{skill.path}`"
)
if not example_path:
example_path = rendered_path
example_path = skill.path
skills_block = "\n".join(skills_lines)
# Sanitize example_path — it may originate from sandbox cache (untrusted)
if example_path == "<skills_root>/<skill_name>/SKILL.md":
example_path = "<skills_root>/<skill_name>/SKILL.md"
else:
example_path = _sanitize_prompt_path_for_prompt(example_path)
example_path = example_path or "<skills_root>/<skill_name>/SKILL.md"
example_command = _build_skill_read_command_example(example_path)
example_path = _SAFE_PATH_RE.sub("", example_path) if example_path else ""
example_path = example_path or "<skills_root>/<skill_name>/SKILL.md"
return (
"## Skills\n\n"
@@ -193,9 +119,8 @@ def build_skills_prompt(skills: list[SkillInfo]) -> str:
"*Never silently skip a matching skill* — either use it or briefly "
"explain why you chose not to.\n"
"3. **Mandatory grounding** — Before executing any skill you MUST "
"first read its `SKILL.md` by running a shell command compatible "
"with the current runtime shell and using the **absolute path** "
f"shown above (e.g. `{example_command}`). "
"first read its `SKILL.md` by running a shell command with the "
f"**absolute path** shown above (e.g. `cat {example_path}`). "
"Never rely on memory or assumptions about a skill's content.\n"
"4. **Progressive disclosure** — Load only what is directly "
"referenced from `SKILL.md`:\n"
+9 -59
View File
@@ -1,14 +1,12 @@
"""插件的重载、启停、安装、卸载等操作。"""
import asyncio
import contextlib
import functools
import inspect
import json
import logging
import os
import sys
import tempfile
import traceback
from types import ModuleType
@@ -31,12 +29,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 (
plan_missing_requirements_install,
RequirementsPrecheckFailed,
find_missing_requirements_or_raise,
)
from . import StarMetadata
@@ -76,78 +74,30 @@ 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:
install_plan = plan_missing_requirements_install(requirements_path)
if install_plan is None:
try:
missing = find_missing_requirements_or_raise(requirements_path)
except RequirementsPrecheckFailed:
logger.info(
f"正在安装插件 {plugin_label} 的依赖库(缺失依赖预检查不可裁剪,回退到完整安装): "
f"正在安装插件 {plugin_label} 的依赖库(预检查失败,回退到完整安装): "
f"{requirements_path}"
)
await pip_installer.install(requirements_path=requirements_path)
return
if not install_plan.missing_names:
if not missing:
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(install_plan.missing_names)}"
f"{requirements_path} -> {sorted(missing)}"
)
with _temporary_filtered_requirements_file(
install_lines=install_plan.install_lines,
) as filtered_requirements_path:
await pip_installer.install(requirements_path=filtered_requirements_path)
await pip_installer.install(requirements_path=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 * * *' 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.",
"description": "Cron expression defining recurring schedule (e.g., '0 8 * * *').",
},
"run_at": {
"type": "string",
+6 -16
View File
@@ -25,22 +25,12 @@ class UmopConfigRouter:
)
self.umop_to_conf_id = sp_data
@staticmethod
def _split_umo(umo: str) -> tuple[str, str, str] | None:
"""将 UMO 拆分为 3 个部分,同时保留 session_id 中的 ':'"""
if not isinstance(umo, str):
return None
parts = umo.split(":", 2)
if len(parts) != 3:
return None
return parts[0], parts[1], parts[2]
def _is_umo_match(self, p1: str, p2: str) -> bool:
"""判断 p2 umo 是否逻辑包含于 p1 umo"""
p1_ls = self._split_umo(p1)
p2_ls = self._split_umo(p2)
p1_ls = p1.split(":")
p2_ls = p2.split(":")
if p1_ls is None or p2_ls is None:
if len(p1_ls) != 3 or len(p2_ls) != 3:
return False # 非法格式
return all(p == "" or fnmatch.fnmatchcase(t, p) for p, t in zip(p1_ls, p2_ls))
@@ -72,7 +62,7 @@ class UmopConfigRouter:
"""
for part in new_routing:
if self._split_umo(part) is None:
if not isinstance(part, str) or len(part.split(":")) != 3:
raise ValueError(
"umop keys must be strings in the format [platform_id]:[message_type]:[session_id], with optional wildcards * or empty for all",
)
@@ -91,7 +81,7 @@ class UmopConfigRouter:
ValueError: 如果 umo 格式不正确
"""
if self._split_umo(umo) is None:
if not isinstance(umo, str) or len(umo.split(":")) != 3:
raise ValueError(
"umop must be a string in the format [platform_id]:[message_type]:[session_id], with optional wildcards * or empty for all",
)
@@ -109,7 +99,7 @@ class UmopConfigRouter:
ValueError: umo 格式不正确时抛出
"""
if self._split_umo(umo) is None:
if not isinstance(umo, str) or len(umo.split(":")) != 3:
raise ValueError(
"umop must be a string in the format [platform_id]:[message_type]:[session_id], with optional wildcards * or empty for all",
)
+3 -81
View File
@@ -4,7 +4,7 @@ import os
import re
import shlex
import sys
from collections.abc import Iterable, Iterator, Sequence
from collections.abc import Iterable, Iterator
from dataclasses import dataclass
from packaging.requirements import InvalidRequirement, Requirement
@@ -29,13 +29,6 @@ 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()
@@ -371,8 +364,8 @@ def _load_requirement_lines_for_precheck(
None,
)
if fallback_line is not None:
logger.info(
"缺失依赖预检查发现无法安全裁剪的 option/direct-reference 行,将回退到完整安装: %s (%s)",
logger.warning(
"预检查缺失依赖失败,将回退到完整安装: unresolved direct reference in %s: %s",
requirements_path,
fallback_line,
)
@@ -388,13 +381,6 @@ 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()
@@ -415,70 +401,6 @@ def find_missing_requirements_from_lines(
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:
+1 -2
View File
@@ -82,8 +82,7 @@ class AuthRoute(Route):
def generate_jwt(self, username):
payload = {
"username": username,
"exp": datetime.datetime.now(datetime.timezone.utc)
+ datetime.timedelta(days=7),
"exp": datetime.datetime.utcnow() + datetime.timedelta(days=7),
}
jwt_token = self.config["dashboard"].get("jwt_secret", None)
if not jwt_token:
+1 -11
View File
@@ -977,17 +977,7 @@ class BackupRoute(Route):
if not jwt_secret:
return Response().error("服务器配置错误").__dict__
# 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
},
)
jwt.decode(token, jwt_secret, algorithms=["HS256"])
except jwt.ExpiredSignatureError:
return Response().error("Token 已过期,请刷新页面后重试").__dict__
except jwt.InvalidTokenError:
+22 -85
View File
@@ -36,20 +36,6 @@ async def track_conversation(convs: dict, conv_id: str):
convs.pop(conv_id, None)
async def _poll_webchat_stream_result(back_queue, username: str):
try:
result = await asyncio.wait_for(back_queue.get(), timeout=1)
except asyncio.TimeoutError:
return None, False
except asyncio.CancelledError:
logger.debug(f"[WebChat] 用户 {username} 断开聊天长连接。")
return None, True
except Exception as e:
logger.error(f"WebChat stream error: {e}")
return None, False
return result, False
class ChatRoute(Route):
def __init__(
self,
@@ -65,7 +51,6 @@ 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,
@@ -357,12 +342,16 @@ class ChatRoute(Route):
async with track_conversation(self.running_convs, webchat_conv_id):
while True:
result, should_break = await _poll_webchat_stream_result(
back_queue, username
)
if should_break:
try:
result = await asyncio.wait_for(back_queue.get(), timeout=1)
except asyncio.TimeoutError:
continue
except asyncio.CancelledError:
logger.debug(f"[WebChat] 用户 {username} 断开聊天长连接。")
client_disconnected = True
break
except Exception as e:
logger.error(f"WebChat stream error: {e}")
if not result:
continue
@@ -589,9 +578,19 @@ class ChatRoute(Route):
return Response().ok(data={"stopped_count": stopped_count}).__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
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__
# 删除该会话下的所有对话
message_type = "GroupMessage" if session.is_group else "FriendMessage"
@@ -633,70 +632,8 @@ 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 = []
-64
View File
@@ -1,64 +0,0 @@
## 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.
-93
View File
@@ -1,93 +0,0 @@
## What's Changed
### 新增
- 补充 MiniMax Provider。([#6318](https://github.com/AstrBotDevs/AstrBot/pull/6318)
- 新增 WebUI ChatUI 页面的会话批量删除功能。([#6160](https://github.com/AstrBotDevs/AstrBot/pull/6160)
- 新增 WebUI ChatUI 配置发送快捷键。([#6272](https://github.com/AstrBotDevs/AstrBot/pull/6272)
### 优化
- 优化 UMO 处理兼容性。([#5996](https://github.com/AstrBotDevs/AstrBot/pull/5996)
- 重构 `_extract_session_id`,改进聊天类型分支处理。(#5775
- 优化聊天组件行为,使用 `shiki` 进行代码块渲染。([#6286](https://github.com/AstrBotDevs/AstrBot/pull/6286)
- 优化 WebUI 主题配色与视觉体验。([#6263](https://github.com/AstrBotDevs/AstrBot/pull/6263)
- 优化 OneBot @ 组件后处理,避免消息文本解析空格问题。([#6238](https://github.com/AstrBotDevs/AstrBot/pull/6238)
### 修复
- 修复创建新 Provider 后未同步 `providers_config` 的问题。([#6388](https://github.com/AstrBotDevs/AstrBot/pull/6388)
- 修复 API 返回 `null choices` 时的 `TypeError`。([#6313](https://github.com/AstrBotDevs/AstrBot/pull/6313)
- 修复 QQ Webhook 重试回调重复触发的问题。([#6320](https://github.com/AstrBotDevs/AstrBot/pull/6320)
- 修复流式模式下 `delta``None` 导致工具调用时报错的问题。([#6365](https://github.com/AstrBotDevs/AstrBot/pull/6365)
- 修复模型服务链接说明文字错误。([#6296](https://github.com/AstrBotDevs/AstrBot/pull/6296)
- 修复 AI 在 tool-calling 模式设为 `skills-like` 时发送媒体失败的问题。([#6317](https://github.com/AstrBotDevs/AstrBot/pull/6317)
- 修复 Telegram 适配器中 GIF 被错误转成静态图的问题。([#6329](https://github.com/AstrBotDevs/AstrBot/pull/6329)
- 将 Provider 图标来源替换为 jsDelivr CDN 地址,修复部分环境下图标加载问题。([#6340](https://github.com/AstrBotDevs/AstrBot/pull/6340)
- 修复 QQ 官方表情消息未解析为可读文本的问题。([#6355](https://github.com/AstrBotDevs/AstrBot/pull/6355)
- 修复 WebChat 队列异常时流式结果页面崩溃的问题。([#6123](https://github.com/AstrBotDevs/AstrBot/pull/6123)
- 修复子代理 handoff 工具在插件过滤时丢失的问题。([#6155](https://github.com/AstrBotDevs/AstrBot/pull/6155)
- 修复 Cron 提示文案缺少空格及 `utcnow()` 的弃用警告问题。([#6192](https://github.com/AstrBotDevs/AstrBot/pull/6192)
- 修复 WebUI 启动时 Sidebar hash 导航抖动/定位问题。([#6159](https://github.com/AstrBotDevs/AstrBot/pull/6159)
- 修复启动重试过程中移除已移除 API Key 的 `ValueError` 报错。([#6193](https://github.com/AstrBotDevs/AstrBot/pull/6193)
- 修复 README 启动命令引用更新为 `astrbot run`。([#6189](https://github.com/AstrBotDevs/AstrBot/pull/6189)
- 修复 `Plain.toDict()``@` 提及场景下空白字符丢失的问题。([#6244](https://github.com/AstrBotDevs/AstrBot/pull/6244)
- 修复 provider 依赖重复定义问题。([#6247](https://github.com/AstrBotDevs/AstrBot/pull/6247)
- 修复 Telegram 中普通回复被误判为线程的处理问题。([#6174](https://github.com/AstrBotDevs/AstrBot/pull/6174)
### 其他
- 调整 `astrbot.service` 及 CI 配置,升级 GitHub Actions 版本。
---
## What's Changed (EN)
### New Features
- Added OpenRouter chat completion provider adapter with support for custom headers ([#6436](https://github.com/AstrBotDevs/AstrBot/pull/6436)).
- Added MiniMax provider ([#6318](https://github.com/AstrBotDevs/AstrBot/pull/6318)).
- Added batch conversation deletion in WebChat ([#6160](https://github.com/AstrBotDevs/AstrBot/pull/6160)).
- Added send shortcut settings and localization support for WebChat input ([#6272](https://github.com/AstrBotDevs/AstrBot/pull/6272)).
- Added local temporary directory binding in YAML config ([#6191](https://github.com/AstrBotDevs/AstrBot/pull/6191)).
### Improvements
- Improved UMO processing compatibility ([#5996](https://github.com/AstrBotDevs/AstrBot/pull/5996)).
- Refactored `_extract_session_id` for chat type handling (#5775).
- Improved chat component behavior and uses `shiki` for code-block rendering ([#6286](https://github.com/AstrBotDevs/AstrBot/pull/6286)).
- Improved WebUI theme color and visual behavior ([#6263](https://github.com/AstrBotDevs/AstrBot/pull/6263)).
- Improved OneBot `@` component spacing handling ([#6238](https://github.com/AstrBotDevs/AstrBot/pull/6238)).
- Improved PR checklist validation and closure messaging.
### Bug Fixes
- Fixed missing `providers_config` sync after creating new providers ([#6388](https://github.com/AstrBotDevs/AstrBot/pull/6388)).
- Fixed `TypeError` when API returns null choices ([#6313](https://github.com/AstrBotDevs/AstrBot/pull/6313)).
- Fixed repeated QQ webhook retry callbacks ([#6320](https://github.com/AstrBotDevs/AstrBot/pull/6320)).
- Fixed tool-calling streaming null `delta` handling to prevent `AttributeError` ([#6365](https://github.com/AstrBotDevs/AstrBot/pull/6365)).
- Fixed model service link wording in docs/config ([#6296](https://github.com/AstrBotDevs/AstrBot/pull/6296)).
- Fixed AI media sending failure when tool-calling mode is set to `skills-like` ([#6317](https://github.com/AstrBotDevs/AstrBot/pull/6317)).
- Fixed GIF being sent as static image in Telegram adapter ([#6329](https://github.com/AstrBotDevs/AstrBot/pull/6329)).
- Replaced npm registry URLs with jsDelivr CDN for provider icons ([#6340](https://github.com/AstrBotDevs/AstrBot/pull/6340)).
- Fixed QQ official face message parsing to readable text ([#6355](https://github.com/AstrBotDevs/AstrBot/pull/6355)).
- Fixed WebChat stream-result crash on queue errors ([#6123](https://github.com/AstrBotDevs/AstrBot/pull/6123)).
- Preserved subagent handoff tools during plugin filtering ([#6155](https://github.com/AstrBotDevs/AstrBot/pull/6155)).
- Fixed cron prompt spacing and deprecated `utcnow()` usage ([#6192](https://github.com/AstrBotDevs/AstrBot/pull/6192)).
- Fixed unstable sidebar hash navigation on startup ([#6159](https://github.com/AstrBotDevs/AstrBot/pull/6159)).
- Fixed `ValueError` in retry loop when removing an already removed API key ([#6193](https://github.com/AstrBotDevs/AstrBot/pull/6193)).
- Updated startup command to `astrbot run` across READMEs ([#6189](https://github.com/AstrBotDevs/AstrBot/pull/6189)).
- Preserved whitespace in `Plain.toDict()` for @ mentions ([#6244](https://github.com/AstrBotDevs/AstrBot/pull/6244)).
- Removed duplicate dependencies entries ([#6247](https://github.com/AstrBotDevs/AstrBot/pull/6247)).
- Fixed Telegram normal reply being treated as topic thread ([#6174](https://github.com/AstrBotDevs/AstrBot/pull/6174)).
### Documentation
- Updated `rainyun` backup/access documentation ([#6427](https://github.com/AstrBotDevs/AstrBot/pull/6427)).
- Updated `package.md` and platform docs, including Matrix and Wecom AI bot documentation.
- Fixed Discord invite link in community docs.
### Chores
- Updated PR templates/checklist workflow, repository service config, and automated checks.
- Refreshed repository automation and formatting maintenance, and removed obsolete changelog scripts.
-1
View File
@@ -37,7 +37,6 @@ 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
+8 -13
View File
@@ -17,17 +17,17 @@
"@tiptap/starter-kit": "2.1.7",
"@tiptap/vue-3": "2.1.7",
"apexcharts": "3.42.0",
"axios": "1.13.5",
"axios": ">=1.6.2 <1.10.0 || >1.10.0 <2.0.0",
"axios-mock-adapter": "^1.22.0",
"chance": "1.1.11",
"date-fns": "2.30.0",
"dompurify": "^3.3.2",
"dompurify": "^3.3.1",
"event-source-polyfill": "^1.0.31",
"highlight.js": "^11.11.1",
"js-md5": "^0.8.3",
"katex": "^0.16.27",
"lodash": "4.17.23",
"markdown-it": "^14.1.1",
"lodash": "4.17.21",
"markdown-it": "^14.1.0",
"markstream-vue": "^0.0.6",
"mermaid": "^11.12.2",
"monaco-editor": "^0.52.2",
@@ -36,8 +36,9 @@
"remixicon": "3.5.0",
"shiki": "^3.20.0",
"stream-markdown": "^0.0.13",
"stream-monaco": "^0.0.17",
"vee-validate": "4.11.3",
"vite-plugin-vuetify": "2.1.3",
"vite-plugin-vuetify": "1.0.2",
"vue": "3.3.4",
"vue-i18n": "^11.1.5",
"vue-router": "4.2.4",
@@ -53,7 +54,7 @@
"@types/dompurify": "^3.0.5",
"@types/markdown-it": "^14.1.2",
"@types/node": "^20.5.7",
"@vitejs/plugin-vue": "5.2.4",
"@vitejs/plugin-vue": "4.3.3",
"@vue/eslint-config-prettier": "8.0.0",
"@vue/eslint-config-typescript": "11.0.3",
"@vue/tsconfig": "^0.4.0",
@@ -63,15 +64,9 @@
"sass": "1.66.1",
"sass-loader": "13.3.2",
"typescript": "5.1.6",
"vite": "6.4.1",
"vite": "4.4.9",
"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"
}
}
}
+271 -601
View File
File diff suppressed because it is too large Load Diff
+3 -69
View File
@@ -11,7 +11,6 @@
:currSessionId="currSessionId"
:selectedProjectId="selectedProjectId"
:transportMode="transportMode"
:sendShortcut="sendShortcut"
:isDark="isDark"
:chatboxMode="chatboxMode"
:isMobile="isMobile"
@@ -21,7 +20,6 @@
@selectConversation="handleSelectConversation"
@editTitle="showEditTitleDialog"
@deleteConversation="handleDeleteConversation"
@batchDeleteConversations="handleBatchDeleteConversations"
@closeMobileSidebar="closeMobileSidebar"
@toggleTheme="toggleTheme"
@toggleFullscreen="toggleFullscreen"
@@ -30,7 +28,6 @@
@editProject="showEditProjectDialog"
@deleteProject="handleDeleteProject"
@updateTransportMode="setTransportMode"
@updateSendShortcut="setSendShortcut"
/>
<!-- 右侧聊天内容区域 -->
@@ -74,14 +71,13 @@
:stagedImagesUrl="stagedImagesUrl"
:stagedAudioUrl="stagedAudioUrl"
:stagedFiles="stagedNonImageFiles"
:disabled="false"
:disabled="isStreaming"
:is-running="isStreaming || isConvRunning"
:enableStreaming="enableStreaming"
:isRecording="isRecording"
:session-id="currSessionId || null"
:current-session="getCurrentSession"
:replyTo="replyTo"
:send-shortcut="sendShortcut"
@send="handleSendMessage"
@stop="handleStopMessage"
@toggleStreaming="toggleStreaming"
@@ -106,14 +102,13 @@
:stagedImagesUrl="stagedImagesUrl"
:stagedAudioUrl="stagedAudioUrl"
:stagedFiles="stagedNonImageFiles"
:disabled="false"
:disabled="isStreaming"
:is-running="isStreaming || isConvRunning"
:enableStreaming="enableStreaming"
:isRecording="isRecording"
:session-id="currSessionId || null"
:current-session="getCurrentSession"
:replyTo="replyTo"
:send-shortcut="sendShortcut"
@send="handleSendMessage"
@stop="handleStopMessage"
@toggleStreaming="toggleStreaming"
@@ -137,14 +132,13 @@
:stagedImagesUrl="stagedImagesUrl"
:stagedAudioUrl="stagedAudioUrl"
:stagedFiles="stagedNonImageFiles"
:disabled="false"
:disabled="isStreaming"
:is-running="isStreaming || isConvRunning"
:enableStreaming="enableStreaming"
:isRecording="isRecording"
:session-id="currSessionId || null"
:current-session="getCurrentSession"
:replyTo="replyTo"
:send-shortcut="sendShortcut"
@send="handleSendMessage"
@stop="handleStopMessage"
@toggleStreaming="toggleStreaming"
@@ -226,13 +220,10 @@ 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
@@ -242,7 +233,6 @@ const router = useRouter();
const route = useRoute();
const { t } = useI18n();
const { tm } = useModuleI18n('features/chat');
const { warning: toastWarning } = useToast();
const theme = useTheme();
const customizer = useCustomizerStore();
@@ -267,7 +257,6 @@ const {
getSessions,
newSession,
deleteSession: deleteSessionFn,
batchDeleteSessions,
showEditTitleDialog,
saveTitle,
updateSessionTitle,
@@ -341,18 +330,6 @@ 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 focusChatInput() {
nextTick(() => {
chatInputRef.value?.focusInput?.();
});
}
//
function checkMobile() {
@@ -511,7 +488,6 @@ async function handleSelectConversation(sessionIds: string[]) {
nextTick(() => {
messageList.value?.scrollToBottom();
});
focusChatInput();
}
function handleNewChat() {
@@ -521,7 +497,6 @@ function handleNewChat() {
// 退
selectedProjectId.value = null;
projectSessions.value = [];
focusChatInput();
}
async function handleDeleteConversation(sessionId: string) {
@@ -535,33 +510,6 @@ 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);
@@ -679,11 +627,6 @@ async function handleSendMessage() {
const selectedProviderId = selection?.providerId || '';
const selectedModelName = selection?.modelName || '';
//
nextTick(() => {
messageList.value?.scrollToBottom();
});
await sendMsg(
promptToSend,
filesToSend,
@@ -693,11 +636,6 @@ async function handleSendMessage() {
replyToSend
);
//
nextTick(() => {
messageList.value?.scrollToBottom();
});
//
if (isCreatingNewSession && currentProjectId && currSessionId.value) {
await addSessionToProject(currSessionId.value, currentProjectId);
@@ -756,10 +694,6 @@ 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();
+29 -44
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="primary">mdi-cloud-upload</v-icon>
<v-icon size="48" color="deep-purple">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="primary" />
<v-btn v-bind="activatorProps" icon="mdi-plus" variant="text" color="deep-purple" />
</template>
<!-- Upload Files -->
@@ -87,7 +87,7 @@
{{ tm('voice.liveMode') }}
</v-tooltip>
</v-btn> -->
<v-btn @click="handleRecordClick" icon variant="text" :color="isRecording ? 'error' : 'primary'"
<v-btn @click="handleRecordClick" icon variant="text" :color="isRecording ? 'error' : 'deep-purple'"
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 && !canSend" @click="$emit('stop')" variant="tonal" color="primary" class="send-btn">
<v-btn icon v-if="isRunning" @click="$emit('stop')" variant="tonal" color="deep-purple" 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="primary"
<v-btn v-else @click="$emit('send')" icon="mdi-send" variant="tonal" color="deep-purple"
:disabled="!canSend" class="send-btn" />
</div>
</div>
@@ -117,7 +117,7 @@
</div>
<div v-if="stagedAudioUrl" class="audio-preview">
<v-chip color="primary" variant="tonal" class="audio-chip">
<v-chip color="deep-purple-lighten-4" 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="primary" variant="tonal" class="file-chip">
<v-chip color="blue-grey-lighten-4" 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,7 +173,6 @@ interface Props {
currentSession?: Session | null;
configId?: string | null;
replyTo?: ReplyInfo | null;
sendShortcut?: 'enter' | 'shift_enter';
}
const props = withDefaults(defineProps<Props>(), {
@@ -181,8 +180,7 @@ const props = withDefaults(defineProps<Props>(), {
currentSession: null,
configId: null,
stagedFiles: () => [],
replyTo: null,
sendShortcut: 'shift_enter'
replyTo: null
});
const emit = defineEmits<{
@@ -255,29 +253,9 @@ watch(localPrompt, () => {
});
function handleKeyDown(e: KeyboardEvent) {
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) {
// Enter
// Shift+Enter Ctrl+Enter / Cmd+Enter
if (e.keyCode === 13 && (e.shiftKey || e.ctrlKey || e.metaKey)) {
e.preventDefault();
if (localPrompt.value.trim() === '/astr_live_dev') {
emit('openLiveMode');
@@ -289,6 +267,19 @@ 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) {
@@ -373,11 +364,6 @@ function getCurrentSelection() {
return providerModelMenuRef.value?.getCurrentSelection();
}
function focusInput() {
if (!inputField.value) return;
inputField.value.focus();
}
onMounted(() => {
if (inputField.value) {
inputField.value.addEventListener('paste', handlePaste);
@@ -393,8 +379,7 @@ onBeforeUnmount(() => {
});
defineExpose({
getCurrentSelection,
focusInput
getCurrentSelection
});
</script>
@@ -414,8 +399,8 @@ defineExpose({
left: 0;
right: 0;
bottom: 0;
background-color: rgba(var(--v-theme-primary), 0.12);
border: 2px dashed rgba(var(--v-theme-primary), 0.45);
background-color: rgba(103, 58, 183, 0.15);
border: 2px dashed rgba(103, 58, 183, 0.5);
border-radius: 24px;
display: flex;
align-items: center;
@@ -434,7 +419,7 @@ defineExpose({
.drop-text {
font-size: 16px;
font-weight: 500;
color: rgb(var(--v-theme-primary));
color: #673ab7;
}
/* Fade transition for drop overlay */
@@ -454,7 +439,7 @@ defineExpose({
justify-content: space-between;
padding: 8px 16px;
margin: 8px 8px 0 8px;
background-color: rgba(var(--v-theme-primary), 0.06);
background-color: rgba(103, 58, 183, 0.06);
border-radius: 12px;
gap: 8px;
max-height: 500px;
@@ -5,7 +5,7 @@
'mobile-sidebar-open': isMobile && mobileMenuOpen,
'mobile-sidebar': isMobile
}"
:style="{ backgroundColor: sidebarCollapsed && !isMobile ? 'rgb(var(--v-theme-surface))' : 'rgb(var(--v-theme-mcpCardBg))' }">
:style="{ 'background-color': isDark ? sidebarCollapsed ? '#1e1e1e' : '#2d2d2d' : sidebarCollapsed ? '#ffffff' : '#f1f4f9' }">
<div class="sidebar-collapse-btn-container" v-if="!isMobile">
<v-btn icon class="sidebar-collapse-btn" @click="toggleSidebar" variant="text" color="deep-purple">
@@ -21,31 +21,12 @@
</div>
<div style="padding: 8px; opacity: 0.6;">
<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-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"
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"
@@ -60,34 +41,19 @@
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="batchMode ? [] : selectedSessions"
@update:selected="handleListSelect">
style="background-color: transparent;" :selected="selectedSessions"
@update:selected="$emit('selectConversation', $event)">
<v-list-item v-for="item in sessions" :key="item.session_id" :value="item.session_id"
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>
rounded="lg" class="conversation-item" active-color="secondary">
<v-list-item-title v-if="!sidebarCollapsed || isMobile" class="conversation-title"
:style="{ color: 'rgb(var(--v-theme-primaryText))' }">
:style="{ color: isDark ? '#ffffff' : '#000000' }">
{{ 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="!batchMode && (!sidebarCollapsed || isMobile)" v-slot:append>
<template v-if="!sidebarCollapsed || isMobile" v-slot:append>
<div class="conversation-actions">
<v-btn icon="mdi-pencil" size="x-small" variant="text"
class="edit-title-btn"
@@ -132,52 +98,16 @@
</v-btn>
</template>
<!-- 语言切换分组 -->
<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>
<!-- 语言切换 -->
<v-list-item class="styled-menu-item">
<template v-slot:prepend>
<v-icon>mdi-translate</v-icon>
</template>
<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-title>{{ t('core.common.language') }}</v-list-item-title>
<template v-slot:append>
<LanguageSwitcher variant="chatbox" />
</template>
</v-list-item>
<!-- 主题切换 -->
<v-list-item class="styled-menu-item" @click="$emit('toggleTheme')">
@@ -187,93 +117,26 @@
<v-list-item-title>{{ isDark ? tm('modes.lightMode') : tm('modes.darkMode') }}</v-list-item-title>
</v-list-item>
<!-- 通信传输模式分组 -->
<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>
<!-- 通信传输模式 -->
<v-list-item class="styled-menu-item">
<template v-slot:prepend>
<v-icon>mdi-lan-connect</v-icon>
</template>
<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>
<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"
/>
</template>
<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>
<!-- 全屏/退出全屏 -->
<v-list-item class="styled-menu-item" @click="$emit('toggleFullscreen')">
@@ -299,16 +162,15 @@
</template>
<script setup lang="ts">
import { ref, computed } from 'vue';
import { ref } 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[];
@@ -321,7 +183,6 @@ interface Props {
isMobile: boolean;
mobileMenuOpen: boolean;
projects?: Project[];
sendShortcut: 'enter' | 'shift_enter';
}
const props = withDefaults(defineProps<Props>(), {
@@ -333,7 +194,6 @@ const emit = defineEmits<{
selectConversation: [sessionIds: string[]];
editTitle: [sessionId: string, title: string];
deleteConversation: [sessionId: string];
batchDeleteConversations: [sessionIds: string[]];
closeMobileSidebar: [];
toggleTheme: [];
toggleFullscreen: [];
@@ -342,7 +202,6 @@ const emit = defineEmits<{
editProject: [project: Project];
deleteProject: [projectId: string];
updateTransportMode: [mode: 'sse' | 'websocket'];
updateSendShortcut: [mode: 'enter' | 'shift_enter'];
}>();
const { t } = useI18n();
@@ -352,84 +211,10 @@ 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');
@@ -457,12 +242,6 @@ 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>
@@ -531,7 +310,7 @@ function handleSendShortcutChange(mode: string | null) {
}
.conversation-item:hover {
background-color: rgba(var(--v-theme-primary), 0.05);
background-color: rgba(103, 58, 183, 0.05);
}
.conversation-item:hover .conversation-actions {
@@ -623,74 +402,7 @@ function handleSendShortcutChange(mode: string | null) {
justify-content: center;
}
.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);
.transport-mode-select {
min-width: 120px;
}
</style>
@@ -180,7 +180,7 @@
<script>
import { useI18n, useModuleI18n } from '@/i18n/composables';
import { enableKatex, enableMermaid, MarkdownCodeBlockNode, setCustomComponents } from 'markstream-vue'
import { enableKatex, enableMermaid, setCustomComponents } from 'markstream-vue'
import 'markstream-vue/index.css'
import 'katex/dist/katex.min.css'
import 'highlight.js/styles/github.css';
@@ -194,11 +194,8 @@ import ActionRef from './message_list_comps/ActionRef.vue';
enableKatex();
enableMermaid();
// message-list + Shiki
setCustomComponents('message-list', {
ref: RefNode,
code_block: MarkdownCodeBlockNode
});
// ref
setCustomComponents('message-list', { ref: RefNode });
export default {
name: 'MessageList',
@@ -22,7 +22,7 @@
v-model:prompt="prompt"
:stagedImagesUrl="stagedImagesUrl"
:stagedAudioUrl="stagedAudioUrl"
:disabled="false"
:disabled="isStreaming"
:is-running="isStreaming || isConvRunning"
:enableStreaming="enableStreaming"
:isRecording="isRecording"
@@ -63,9 +63,8 @@
<!-- Text (Markdown) -->
<MarkdownRender
v-else-if="renderPart.part.type === 'plain' && renderPart.part.text && renderPart.part.text.trim()"
:key="`${renderPart.key}-${isDark ? 'dark' : 'light'}`"
custom-id="message-list" :custom-html-tags="['ref']" :content="renderPart.part.text" :typewriter="false"
class="markdown-content" :is-dark="isDark" />
class="markdown-content" :is-dark="isDark" :monacoOptions="{ theme: isDark ? 'vs-dark' : 'vs-light' }" />
<!-- Image -->
<div v-else-if="renderPart.part.type === 'image' && renderPart.part.embedded_url" class="embedded-images">
@@ -9,7 +9,7 @@
</span>
</div>
<div v-if="isExpanded" class="reasoning-content animate-fade-in">
<MarkdownRender :key="`reasoning-${isDark ? 'dark' : 'light'}`" :content="reasoning" class="reasoning-text markdown-content"
<MarkdownRender :content="reasoning" class="reasoning-text markdown-content"
:typewriter="false" :is-dark="isDark" :style="isDark ? { opacity: '0.85' } : {}" />
</div>
</div>
@@ -1,12 +1,12 @@
<template>
<v-chip v-if="domain" class="ref-chip" size="x-small" variant="flat"
:style="chipStyle" :href="url"
:style="{ backgroundColor: isDark ? '#303030' : '#f4f4f4', color: isDark ? '#999' : '#666' }" :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="fallbackStyle">{{ 'site' }}</span>
<span v-else class="ref-fallback" :style="{ color: isDark ? '#999' : '#666' }">{{ 'site' }}</span>
</template>
<script setup>
@@ -46,15 +46,6 @@ 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' ? 'rgb(var(--v-theme-primary))' : undefined"
:color="props.variant === 'default' ? (useCustomizerStore().uiTheme === 'PurpleTheme' ? '#5e35b1' : '#d7c5fa') : undefined"
>
mdi-translate
</v-icon>
@@ -42,6 +42,7 @@
<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'
@@ -89,7 +90,7 @@ const changeLanguage = async (langCode: string) => {
.language-switcher--default:hover {
transform: scale(1.05);
background: rgba(var(--v-theme-primary), 0.08) !important;
background: rgba(94, 53, 177, 0.08) !important;
}
/* Header变体样式 - 完全继承Vuetify和action-btn的默认样式 */
@@ -102,4 +103,8 @@ 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>
+3 -2
View File
@@ -6,11 +6,11 @@
</div>
<div class="logo-text">
<h2
:style="{ color: 'rgb(var(--v-theme-primary))' }"
:style="{color: useCustomizerStore().uiTheme === 'PurpleTheme' ? '#5e35b1' : '#d7c5fa'}"
v-html="formatTitle(title || t('core.header.logoTitle'))"
></h2>
<!-- 父子组件传递css变量可能会出错暂时使用十六进制颜色值 -->
<h4 :style="{ color: 'rgba(var(--v-theme-on-surface), 0.72)' }"
<h4 :style="{color: useCustomizerStore().uiTheme === 'PurpleTheme' ? '#000000aa' : '#ffffffcc'}"
class="hint-text">{{ subtitle || t('core.header.accountDialog.title') }}</h4>
</div>
</div>
@@ -18,6 +18,7 @@
</template>
<script setup lang="ts">
import { useCustomizerStore } from "@/stores/customizer";
import { useI18n } from '@/i18n/composables';
const { t } = useI18n();
@@ -48,24 +48,6 @@ 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);
@@ -171,18 +153,6 @@ 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
@@ -281,35 +251,18 @@ watch(
function handleContainerClick(event) {
const btn = event.target.closest(".copy-code-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);
}
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);
}
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) {
@@ -373,7 +326,7 @@ const showActionArea = computed(() => {
<v-icon>mdi-close</v-icon>
</v-btn>
</v-card-title>
<v-card-text ref="scrollContainer" style="overflow-y: auto">
<v-card-text style="overflow-y: auto">
<div v-if="showActionArea" class="d-flex justify-space-between mb-4">
<v-btn
v-if="modeConfig.showGithubButton && repoUrl"
@@ -483,7 +436,6 @@ const showActionArea = computed(() => {
margin-bottom: 16px;
font-weight: 600;
line-height: 1.25;
scroll-margin-top: 12px;
}
:deep(.markdown-body h1) {
+17 -15
View File
@@ -24,12 +24,12 @@ withDefaults(defineProps<{
})
</script>
<style>
<style scoped>
.styled-menu-card {
min-width: 100px;
width: fit-content;
border: 1px solid rgba(var(--v-theme-primary), 0.15) !important;
background: rgba(var(--v-theme-surface), 0.98) !important;
border: 1px solid rgba(94, 53, 177, 0.15) !important;
background: #f8f6fc !important;
backdrop-filter: blur(10px);
}
@@ -37,41 +37,43 @@ withDefaults(defineProps<{
background: transparent !important;
}
.styled-menu-item {
:deep(.styled-menu-item) {
margin: 2px 0;
transition: all 0.2s ease;
border-radius: 6px;
}
.styled-menu-item:hover {
background: rgba(var(--v-theme-primary), 0.08) !important;
:deep(.styled-menu-item:hover) {
background: rgba(94, 53, 177, 0.08) !important;
}
.styled-menu-item-active {
background: rgba(var(--v-theme-primary), 0.15) !important;
:deep(.styled-menu-item-active) {
background: rgba(94, 53, 177, 0.15) !important;
font-weight: 500;
}
.styled-menu-item-active:hover {
background: rgba(var(--v-theme-primary), 0.2) !important;
:deep(.styled-menu-item-active:hover) {
background: rgba(94, 53, 177, 0.2) !important;
}
</style>
<style>
/* 深色模式下的下拉框样式 - 需要全局样式才能检测主题 */
.v-theme--PurpleThemeDark .styled-menu-card {
background: rgba(var(--v-theme-surface), 0.98) !important;
border: 1px solid rgba(var(--v-theme-primary), 0.2) !important;
background: #2a2733 !important;
border: 1px solid rgba(110, 60, 180, 0.692) !important;
}
/* 深色模式下的列表项悬停效果 */
.v-theme--PurpleThemeDark .styled-menu-item:hover {
background: rgba(var(--v-theme-primary), 0.12) !important;
background: rgba(114, 46, 209, 0.12) !important;
}
.v-theme--PurpleThemeDark .styled-menu-item-active {
background: rgba(var(--v-theme-primary), 0.2) !important;
background: rgba(114, 46, 209, 0.2) !important;
}
.v-theme--PurpleThemeDark .styled-menu-item-active:hover {
background: rgba(var(--v-theme-primary), 0.25) !important;
background: rgba(114, 46, 209, 0.25) !important;
}
</style>
@@ -590,11 +590,9 @@ 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) {
const latency = Math.max(0, Math.round(performance.now() - startTime))
showMessage(tm('models.testSuccessWithLatency', { id: provider.id, latency }))
showMessage(tm('models.testSuccess', { id: provider.id }))
} else {
throw new Error(response.data.data.error || tm('models.testError'))
}
-68
View File
@@ -109,73 +109,6 @@ 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 || '';
@@ -234,7 +167,6 @@ export function useSessions(chatboxMode: boolean = false) {
getSessions,
newSession,
deleteSession,
batchDeleteSessions,
showEditTitleDialog,
saveTitle,
updateSessionTitle,
+26 -27
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', 'ru-RU'];
const availableLocales: Locale[] = ['zh-CN', 'en-US'];
// 检查是否已加载
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,21 +155,20 @@ 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: 'ru-RU', label: 'Русский', flag: '🇷🇺' }
{ value: 'en-US', label: 'English', flag: '🇺🇸' }
]);
const currentLanguage = computed(() => {
return languageOptions.value.find(lang => lang.value === locale.value);
});
const switchLanguage = async (newLocale: Locale) => {
await setLocale(newLocale);
};
return {
locale,
languageOptions,
@@ -221,9 +220,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', 'ru-RU'].includes(savedLocale)
? savedLocale
const initialLocale = savedLocale && ['zh-CN', 'en-US'].includes(savedLocale)
? savedLocale
: 'zh-CN';
await initI18n(initialLocale);
}
@@ -96,7 +96,6 @@
"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",
@@ -71,16 +71,10 @@
"modes": {
"darkMode": "Switch to Dark Mode",
"lightMode": "Switch to Light Mode"
},
"shortcuts": {
}, "shortcuts": {
"help": "Get Help",
"voiceRecord": "Record Voice",
"pasteImage": "Paste Image",
"sendKey": {
"title": "Send Shortcut",
"enterToSend": "Enter to send",
"shiftEnterToSend": "Shift+Enter to send"
}
"pasteImage": "Paste Image"
},
"streaming": {
"enabled": "Streaming enabled",
@@ -147,15 +141,5 @@
"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,7 +78,6 @@
},
"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,7 +132,6 @@
"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,24 +0,0 @@
{
"create": "Создать",
"read": "Чтение",
"update": "Обновить",
"delete": "Удалить",
"search": "Поиск",
"filter": "Фильтр",
"sort": "Сортировка",
"export": "Экспорт",
"import": "Импорт",
"backup": "Резервное копирование",
"restore": "Восстановление",
"copy": "Копировать",
"paste": "Вставить",
"cut": "Вырезать",
"undo": "Отменить",
"redo": "Повторить",
"refresh": "Обновить",
"submit": "Отправить",
"reset": "Сбросить",
"clear": "Очистить",
"save": "Сохранить",
"close": "Закрыть"
}
@@ -1,133 +0,0 @@
{
"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 не найден или пуст."
}
}
}
@@ -1,108 +0,0 @@
{
"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": "Ошибка обновления, попробуйте еще раз"
}
}
}
@@ -1,49 +0,0 @@
{
"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": "Системная конфигурация"
}
}
@@ -1,112 +0,0 @@
{
"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}' и сделать его активным шаблоном?"
}
}
@@ -1,22 +0,0 @@
{
"loading": "Загрузка",
"success": "Успешно",
"error": "Ошибка",
"warning": "Внимание",
"info": "Информация",
"pending": "В ожидании",
"processing": "В процессе",
"completed": "Завершено",
"failed": "Ошибка",
"cancelled": "Отменено",
"timeout": "Тайм-аут",
"connecting": "Подключение",
"connected": "Подключено",
"disconnected": "Отключено",
"online": "В сети",
"offline": "Не в сети",
"active": "Активен",
"inactive": "Неактивен",
"ready": "Готов",
"busy": "Занят"
}
@@ -1,17 +0,0 @@
{
"hero": {
"title": "AstrBot",
"subtitle": "Проект, рожденный из интереса и любви ❤️",
"starButton": "Star этот проект! 🌟",
"issueButton": "Сообщить об ошибке"
},
"contributors": {
"title": "Контрибьюторы",
"description": "Этот проект поддерживается участниками open-source сообщества. Спасибо каждому за вклад!",
"viewLink": "Посмотреть всех участников"
},
"stats": {
"title": "Глобальное развертывание",
"license": "AstrBot распространяется по лицензии AGPL v3"
}
}
@@ -1,44 +0,0 @@
{
"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": "В разработке"
}
}
@@ -1,155 +0,0 @@
{
"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": "Все фрагменты успешно загружены"
}
}
@@ -1,97 +0,0 @@
{
"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": "У этой связи нет ассоциированных данных"
}
}
@@ -1,14 +0,0 @@
{
"login": "Вход",
"username": "Имя пользователя",
"password": "Пароль",
"defaultHint": "Логин и пароль по умолчанию: astrbot",
"logo": {
"title": "Панель управления AstrBot",
"subtitle": "Добро пожаловать"
},
"theme": {
"switchToDark": "Перейти на темную тему",
"switchToLight": "Перейти на светлую тему"
}
}
@@ -1,4 +0,0 @@
{
"messageCount": "Количество сообщений",
"time": "Время"
}
@@ -1,151 +0,0 @@
{
"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": "Ошибка создания сессии, обновите страницу"
}
}
@@ -1,95 +0,0 @@
{
"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
@@ -1,129 +0,0 @@
{
"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": "Отмена"
}
}
}
@@ -1,18 +0,0 @@
{
"title": "Логи платформы",
"autoScroll": {
"enabled": "Автопрокрутка включена",
"disabled": "Автопрокрутка выключена"
},
"pipInstall": {
"button": "Установить pip-пакет",
"dialogTitle": "Установка Pip-пакета",
"packageLabel": "*Имя пакета, например: llmtuner",
"mirrorLabel": "Использовать зеркало PyPI (опционально)",
"mirrorHint": "Приоритет зеркала PyPI > настройки «Зеркало репозитория PyPI»",
"installButton": "Установить"
},
"debugHint": {
"text": "Для отображения Debug-логов необходимо установить соответствующий уровень в «Конфигурация → Система → Уровень логирования»"
}
}
@@ -1,102 +0,0 @@
{
"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": "Сначала выберите диалоги для экспорта"
}
}
@@ -1,66 +0,0 @@
{
"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": "Ошибка создания"
}
}
@@ -1,65 +0,0 @@
{
"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": "Доля от общего числа"
}
}
}
@@ -1,358 +0,0 @@
{
"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": "Журнал изменений"
}
}
@@ -1,118 +0,0 @@
{
"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": "Внимание! Изменение этих параметров повлияет на будущую выдачу базы знаний."
}
}
@@ -1,55 +0,0 @@
{
"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": "Закрыть"
}
}
@@ -1,67 +0,0 @@
{
"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": "Не удалось загрузить список"
}
}
@@ -1,18 +0,0 @@
{
"dialog": {
"title": "Помощник по миграции",
"warning": "👋 Добро пожаловать в v4.0.0! В этой версии мы оптимизировали формат хранения данных. Обнаружена необходимость миграции базы данных.",
"loading": "Загрузка списка платформ...",
"loadError": "Ошибка загрузки, попробуйте еще раз",
"noPlatforms": "Конфигурации платформ не найдены",
"retry": "Повторить",
"startMigration": "Начать миграцию",
"migrating": "Выполняется миграция...",
"migratingSubtitle": "Пожалуйста, подождите. Не закрывайте это окно до завершения процесса.",
"migrationError": "Ошибка миграции",
"success": "Миграция успешно завершена!",
"completed": "Миграция выполнена",
"restartRecommended": "Рекомендуется перезапустить приложение, чтобы все изменения вступили в силу.",
"restartNow": "Перезапустить сейчас"
}
}
@@ -1,146 +0,0 @@
{
"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": "Ошибка перемещения"
}
}
@@ -1,135 +0,0 @@
{
"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": "Закрыть"
}
}
@@ -1,151 +0,0 @@
{
"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 модели"
}
}
}
@@ -1,130 +0,0 @@
{
"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": "Пакетное обновление успешно выполнено"
}
}
@@ -1,180 +0,0 @@
{
"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": "Ошибка копирования"
}
}
}
@@ -1,65 +0,0 @@
{
"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": "Создать первого агента"
}
}
@@ -1,195 +0,0 @@
{
"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? Нажмите кнопку справа для получения инструкций"
}
}
}
@@ -1,10 +0,0 @@
{
"title": "Трассировка (Trace)",
"autoScroll": {
"enabled": "Автопрокрутка: ВКЛ",
"disabled": "Автопрокрутка: ВЫКЛ"
},
"hint": "В данный момент записываются только вызовы моделей основного агента AstrBot. Система будет совершенствоваться.",
"recording": "Запись...",
"paused": "Пауза"
}

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