Compare commits
36 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a21bb5b234 | |||
| 994d39241e | |||
| e6c1164755 | |||
| 89cc8a1a65 | |||
| c0e4f1e114 | |||
| 7b43448ce4 | |||
| bdac0b65f4 | |||
| cf9ee6f20c | |||
| 01eae72a64 | |||
| bca1476eab | |||
| fbcbde0a4b | |||
| 3914d766db | |||
| 3e2cb6a2ab | |||
| 25830524f3 | |||
| 304094630c | |||
| 5c3643c54c | |||
| 589cce18af | |||
| e254caf82d | |||
| 7efcd242d6 | |||
| 5d811d3949 | |||
| 8e6aaee10c | |||
| 6da59cfb07 | |||
| 10ceacfbb1 | |||
| 66f5ccd902 | |||
| 3379587223 | |||
| e25a1a42cf | |||
| 0c771e4a77 | |||
| ec21cb13d3 | |||
| 1d26b96d90 | |||
| be017c87f4 | |||
| 23fffa95c8 | |||
| 5b303e2e6d | |||
| fc33b3eb68 | |||
| 795aec9578 | |||
| 7d31140c14 | |||
| 654112ca86 |
@@ -0,0 +1,43 @@
|
||||
name: release
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest # 运行环境
|
||||
steps:
|
||||
- name: checkout
|
||||
uses: actions/checkout@v6
|
||||
- name: nodejs installation
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: "18"
|
||||
- name: npm install
|
||||
run: npm add -D vitepress
|
||||
working-directory: './docs' # working-directory 指定 shell 命令运行目录
|
||||
- name: npm run build
|
||||
run: npm run docs:build
|
||||
working-directory: './docs'
|
||||
- name: scp
|
||||
uses: appleboy/scp-action@v1.0.0
|
||||
with:
|
||||
host: ${{ secrets.HOST_NEKO }}
|
||||
username: ${{ secrets.USERNAME }}
|
||||
password: ${{ secrets.PASSWORDNEKO }}
|
||||
source: 'docs/.vitepress/dist/*'
|
||||
target: '/tmp/'
|
||||
- name: script
|
||||
uses: appleboy/ssh-action@v1.2.5
|
||||
with:
|
||||
host: ${{ secrets.HOST_NEKO }}
|
||||
username: ${{ secrets.USERNAME }}
|
||||
password: ${{ secrets.PASSWORDNEKO }}
|
||||
script: |
|
||||
mkdir -p /root/docker_data/caddy/caddy_data/static_site/abv4/
|
||||
rm -rf /root/docker_data/caddy/caddy_data/static_site/abv4/*
|
||||
mv /tmp/docs/.vitepress/dist/* /root/docker_data/caddy/caddy_data/static_site/abv4/
|
||||
rm -rf /tmp/docs/
|
||||
@@ -45,7 +45,7 @@ jobs:
|
||||
|
||||
- name: Create GitHub Release
|
||||
if: github.event_name == 'push'
|
||||
uses: ncipollo/release-action@v1
|
||||
uses: ncipollo/release-action@v1.20.0
|
||||
with:
|
||||
tag: release-${{ github.sha }}
|
||||
owner: AstrBotDevs
|
||||
|
||||
@@ -64,20 +64,20 @@ jobs:
|
||||
echo "build_date=$build_date" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Set QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
uses: docker/setup-qemu-action@v4.0.0
|
||||
|
||||
- name: Set Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
uses: docker/setup-buildx-action@v4.0.0
|
||||
|
||||
- name: Log in to DockerHub
|
||||
uses: docker/login-action@v3
|
||||
uses: docker/login-action@v4.0.0
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_HUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_HUB_PASSWORD }}
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
if: env.HAS_GHCR_TOKEN == 'true'
|
||||
uses: docker/login-action@v3
|
||||
uses: docker/login-action@v4.0.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ env.GHCR_OWNER }}
|
||||
@@ -98,7 +98,7 @@ jobs:
|
||||
echo "EOF" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Build and Push Nightly Image
|
||||
uses: docker/build-push-action@v6
|
||||
uses: docker/build-push-action@v7.0.0
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/amd64,linux/arm64
|
||||
@@ -163,27 +163,27 @@ jobs:
|
||||
cp -r dashboard/dist data/
|
||||
|
||||
- name: Set QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
uses: docker/setup-qemu-action@v4.0.0
|
||||
|
||||
- name: Set Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
uses: docker/setup-buildx-action@v4.0.0
|
||||
|
||||
- name: Log in to DockerHub
|
||||
uses: docker/login-action@v3
|
||||
uses: docker/login-action@v4.0.0
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_HUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_HUB_PASSWORD }}
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
if: env.HAS_GHCR_TOKEN == 'true'
|
||||
uses: docker/login-action@v3
|
||||
uses: docker/login-action@v4.0.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ env.GHCR_OWNER }}
|
||||
password: ${{ secrets.GHCR_GITHUB_TOKEN }}
|
||||
|
||||
- name: Build and Push Release Image
|
||||
uses: docker/build-push-action@v6
|
||||
uses: docker/build-push-action@v7.0.0
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/amd64,linux/arm64
|
||||
|
||||
@@ -50,7 +50,7 @@ jobs:
|
||||
echo "tag=$tag" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
uses: pnpm/action-setup@v4.3.0
|
||||
with:
|
||||
version: 10.28.2
|
||||
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
name: sync wiki
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
paths:
|
||||
- '.github/workflows/sync-wiki.yml'
|
||||
- 'docs/scripts/sync_docs_to_wiki.py'
|
||||
- 'docs/tests/test_sync_docs_to_wiki.py'
|
||||
- 'docs/zh/**'
|
||||
- 'docs/en/**'
|
||||
|
||||
concurrency:
|
||||
group: sync-wiki-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
sync:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
steps:
|
||||
- name: Validate manual ref
|
||||
if: github.event_name == 'workflow_dispatch' && github.ref != 'refs/heads/master'
|
||||
run: |
|
||||
echo "This workflow only publishes from refs/heads/master. Re-run it from the master branch."
|
||||
exit 1
|
||||
|
||||
- name: Check out docs repository
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: '3.11'
|
||||
|
||||
- name: Run sync unit tests
|
||||
working-directory: docs
|
||||
run: python -m unittest discover -s tests -p 'test_sync_docs_to_wiki.py' -v
|
||||
|
||||
- name: Validate internal doc links
|
||||
run: python docs/scripts/sync_docs_to_wiki.py --source-root docs --check-links-only
|
||||
|
||||
- name: Clone AstrBot wiki
|
||||
env:
|
||||
WIKI_TOKEN: ${{ secrets.ASTRBOT_WIKI_TOKEN }}
|
||||
run: |
|
||||
test -n "$WIKI_TOKEN"
|
||||
git clone "https://x-access-token:${WIKI_TOKEN}@github.com/AstrBotDevs/AstrBot.wiki.git" wiki
|
||||
|
||||
- name: Generate wiki pages
|
||||
run: python docs/scripts/sync_docs_to_wiki.py --source-root docs --wiki-root wiki
|
||||
|
||||
- name: Commit and push wiki changes
|
||||
working-directory: wiki
|
||||
run: |
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
||||
git add .
|
||||
if git diff --cached --quiet; then
|
||||
echo "No wiki changes to push"
|
||||
exit 0
|
||||
fi
|
||||
git commit -m "docs: sync wiki from AstrBot-1/docs"
|
||||
git push
|
||||
@@ -61,3 +61,5 @@ GenieData/
|
||||
.codex/
|
||||
.opencode/
|
||||
.kilocode/
|
||||
.worktrees/
|
||||
|
||||
|
||||
@@ -234,7 +234,8 @@ pre-commit install
|
||||
- Group 7: 743746109
|
||||
- Group 8: 1030353265
|
||||
|
||||
- Developer Group: 975206796
|
||||
- Developer Group(Chit-chat): 975206796
|
||||
- Developer Group(Formal): 1039761811
|
||||
|
||||
### Discord Server
|
||||
|
||||
|
||||
@@ -222,6 +222,7 @@ pre-commit install
|
||||
- Groupe 5 : 822130018
|
||||
- Groupe 6 : 753075035
|
||||
- Groupe développeurs : 975206796
|
||||
- Groupe développeurs (officiel) : 1039761811
|
||||
|
||||
### Serveur Discord
|
||||
|
||||
|
||||
@@ -223,6 +223,7 @@ pre-commit install
|
||||
- 5群: 822130018
|
||||
- 6群: 753075035
|
||||
- 開発者群: 975206796
|
||||
- 開発者群(正式): 1039761811
|
||||
|
||||
### Discord サーバー
|
||||
|
||||
|
||||
@@ -222,6 +222,7 @@ pre-commit install
|
||||
- Группа 5: 822130018
|
||||
- Группа 6: 753075035
|
||||
- Группа разработчиков: 975206796
|
||||
- Группа разработчиков (официальная): 1039761811
|
||||
|
||||
### Сервер Discord
|
||||
|
||||
|
||||
+2
-1
@@ -225,7 +225,8 @@ pre-commit install
|
||||
- 6 群:753075035
|
||||
- 7 群:743746109
|
||||
- 8 群:1030353265
|
||||
- 開發者群:975206796
|
||||
- 開發者群(闲聊吹水):975206796
|
||||
- 開發者群(正式):1039761811
|
||||
|
||||
### Discord 群組
|
||||
|
||||
|
||||
+2
-1
@@ -226,7 +226,8 @@ pre-commit install
|
||||
- 6 群:753075035
|
||||
- 7 群:743746109
|
||||
- 8 群:1030353265
|
||||
- 开发者群:975206796
|
||||
- 开发者群(偏闲聊吹水):975206796
|
||||
- 开发者群(正式):1039761811
|
||||
|
||||
### Discord 频道
|
||||
|
||||
|
||||
@@ -1 +1 @@
|
||||
__version__ = "4.19.3"
|
||||
__version__ = "4.20.0"
|
||||
|
||||
@@ -4,7 +4,21 @@ from astrbot.core.config import AstrBotConfig
|
||||
from astrbot.core.config.default import DB_PATH
|
||||
from astrbot.core.db.sqlite import SQLiteDatabase
|
||||
from astrbot.core.file_token_service import FileTokenService
|
||||
from astrbot.core.utils.pip_installer import PipInstaller
|
||||
from astrbot.core.utils.pip_installer import (
|
||||
DependencyConflictError as DependencyConflictError,
|
||||
)
|
||||
from astrbot.core.utils.pip_installer import (
|
||||
PipInstaller,
|
||||
)
|
||||
from astrbot.core.utils.requirements_utils import (
|
||||
RequirementsPrecheckFailed as RequirementsPrecheckFailed,
|
||||
)
|
||||
from astrbot.core.utils.requirements_utils import (
|
||||
find_missing_requirements as find_missing_requirements,
|
||||
)
|
||||
from astrbot.core.utils.requirements_utils import (
|
||||
find_missing_requirements_or_raise as find_missing_requirements_or_raise,
|
||||
)
|
||||
from astrbot.core.utils.shared_preferences import SharedPreferences
|
||||
from astrbot.core.utils.t2i.renderer import HtmlRenderer
|
||||
|
||||
|
||||
@@ -144,10 +144,14 @@ class MCPClient:
|
||||
|
||||
cfg = _prepare_config(mcp_server_config.copy())
|
||||
|
||||
def logging_callback(msg: str) -> None:
|
||||
def logging_callback(
|
||||
msg: str | mcp.types.LoggingMessageNotificationParams,
|
||||
) -> None:
|
||||
# Handle MCP service error logs
|
||||
print(f"MCP Server {name} Error: {msg}")
|
||||
self.server_errlogs.append(msg)
|
||||
if isinstance(msg, mcp.types.LoggingMessageNotificationParams):
|
||||
if msg.level in ("warning", "error", "critical", "alert", "emergency"):
|
||||
log_msg = f"[{msg.level.upper()}] {str(msg.data)}"
|
||||
self.server_errlogs.append(log_msg)
|
||||
|
||||
if "url" in cfg:
|
||||
success, error_msg = await _quick_test_mcp_connection(cfg)
|
||||
@@ -214,15 +218,24 @@ class MCPClient:
|
||||
**cfg,
|
||||
)
|
||||
|
||||
def callback(msg: str) -> None:
|
||||
def callback(msg: str | mcp.types.LoggingMessageNotificationParams) -> None:
|
||||
# Handle MCP service error logs
|
||||
self.server_errlogs.append(msg)
|
||||
if isinstance(msg, mcp.types.LoggingMessageNotificationParams):
|
||||
if msg.level in (
|
||||
"warning",
|
||||
"error",
|
||||
"critical",
|
||||
"alert",
|
||||
"emergency",
|
||||
):
|
||||
log_msg = f"[{msg.level.upper()}] {str(msg.data)}"
|
||||
self.server_errlogs.append(log_msg)
|
||||
|
||||
stdio_transport = await self.exit_stack.enter_async_context(
|
||||
mcp.stdio_client(
|
||||
server_params,
|
||||
errlog=LogPipe(
|
||||
level=logging.ERROR,
|
||||
level=logging.INFO,
|
||||
logger=logger,
|
||||
identifier=f"MCPServer-{name}",
|
||||
callback=callback,
|
||||
|
||||
@@ -204,7 +204,7 @@ class SendMessageToUserTool(FunctionTool[AstrAgentContext]):
|
||||
"type": "string",
|
||||
"description": (
|
||||
"Component type. One of: "
|
||||
"plain, image, record, file, mention_user"
|
||||
"plain, image, record, video, file, mention_user. Record is voice message."
|
||||
),
|
||||
},
|
||||
"text": {
|
||||
@@ -320,6 +320,19 @@ class SendMessageToUserTool(FunctionTool[AstrAgentContext]):
|
||||
components.append(Comp.Record.fromURL(url=url))
|
||||
else:
|
||||
return f"error: messages[{idx}] must include path or url for record component."
|
||||
elif msg_type == "video":
|
||||
path = msg.get("path")
|
||||
url = msg.get("url")
|
||||
if path:
|
||||
(
|
||||
local_path,
|
||||
file_from_sandbox,
|
||||
) = await self._resolve_path_from_sandbox(context, path)
|
||||
components.append(Comp.Video.fromFileSystem(path=local_path))
|
||||
elif url:
|
||||
components.append(Comp.Video.fromURL(url=url))
|
||||
else:
|
||||
return f"error: messages[{idx}] must include path or url for video component."
|
||||
elif msg_type == "file":
|
||||
path = msg.get("path")
|
||||
url = msg.get("url")
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import locale
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
@@ -52,6 +53,31 @@ 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(
|
||||
@@ -72,28 +98,32 @@ 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:
|
||||
proc = subprocess.Popen(
|
||||
# `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
|
||||
command,
|
||||
shell=shell,
|
||||
cwd=working_dir,
|
||||
env=run_env,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
text=True,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
)
|
||||
return {"pid": proc.pid, "stdout": "", "stderr": "", "exit_code": None}
|
||||
result = subprocess.run(
|
||||
# `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
|
||||
command,
|
||||
shell=shell,
|
||||
cwd=working_dir,
|
||||
env=run_env,
|
||||
timeout=timeout,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
return {
|
||||
"stdout": result.stdout,
|
||||
"stderr": result.stderr,
|
||||
"stdout": _decode_shell_output(result.stdout),
|
||||
"stderr": _decode_shell_output(result.stderr),
|
||||
"exit_code": result.returncode,
|
||||
}
|
||||
|
||||
|
||||
@@ -422,6 +422,12 @@ async def get_booter(
|
||||
) -> ComputerBooter:
|
||||
config = context.get_config(umo=session_id)
|
||||
|
||||
runtime = config.get("provider_settings", {}).get("computer_use_runtime", "local")
|
||||
if runtime == "local":
|
||||
return get_local_booter()
|
||||
elif runtime == "none":
|
||||
raise RuntimeError("Sandbox runtime is disabled by configuration.")
|
||||
|
||||
sandbox_cfg = config.get("provider_settings", {}).get("sandbox", {})
|
||||
booter_type = sandbox_cfg.get("booter", "shipyard_neo")
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ from typing import Any, TypedDict
|
||||
|
||||
from astrbot.core.utils.astrbot_path import get_astrbot_data_path
|
||||
|
||||
VERSION = "4.19.3"
|
||||
VERSION = "4.20.0"
|
||||
DB_PATH = os.path.join(get_astrbot_data_path(), "data_v4.db")
|
||||
|
||||
WEBHOOK_SUPPORTED_PLATFORMS = [
|
||||
@@ -219,6 +219,9 @@ DEFAULT_CONFIG = {
|
||||
"telegram": {
|
||||
"pre_ack_emoji": {"enable": False, "emojis": ["✍️"]},
|
||||
},
|
||||
"discord": {
|
||||
"pre_ack_emoji": {"enable": False, "emojis": ["🤔"]},
|
||||
},
|
||||
},
|
||||
"wake_prefix": ["/"],
|
||||
"log_level": "INFO",
|
||||
@@ -342,19 +345,20 @@ CONFIG_METADATA_2 = {
|
||||
"企业微信智能机器人": {
|
||||
"id": "wecom_ai_bot",
|
||||
"type": "wecom_ai_bot",
|
||||
"hint": "如果发现字段有异常,请重新创建",
|
||||
"enable": True,
|
||||
"wecom_ai_bot_connection_mode": "webhook",
|
||||
"wecom_ai_bot_connection_mode": "long_connection", # long_connection, webhook
|
||||
"wecom_ai_bot_name": "",
|
||||
"wecomaibot_ws_bot_id": "",
|
||||
"wecomaibot_ws_secret": "",
|
||||
"wecomaibot_token": "",
|
||||
"wecomaibot_encoding_aes_key": "",
|
||||
"wecomaibot_init_respond_text": "",
|
||||
"wecomaibot_friend_message_welcome_text": "",
|
||||
"wecom_ai_bot_name": "",
|
||||
"msg_push_webhook_url": "",
|
||||
"only_use_webhook_url_to_send": False,
|
||||
"long_connection_bot_id": "",
|
||||
"long_connection_secret": "",
|
||||
"long_connection_ws_url": "wss://openws.work.weixin.qq.com",
|
||||
"long_connection_heartbeat_interval": 30,
|
||||
"token": "",
|
||||
"encoding_aes_key": "",
|
||||
"wecomaibot_ws_url": "wss://openws.work.weixin.qq.com",
|
||||
"wecomaibot_heartbeat_interval": 30,
|
||||
"unified_webhook_mode": True,
|
||||
"webhook_uuid": "",
|
||||
"callback_server_host": "0.0.0.0",
|
||||
@@ -754,6 +758,22 @@ CONFIG_METADATA_2 = {
|
||||
"type": "string",
|
||||
"hint": "当用户当天进入智能机器人单聊会话,回复欢迎语,留空则不回复。",
|
||||
},
|
||||
"wecomaibot_token": {
|
||||
"description": "企业微信智能机器人 Token",
|
||||
"type": "string",
|
||||
"hint": "用于 Webhook 回调模式的身份验证。",
|
||||
"condition": {
|
||||
"wecom_ai_bot_connection_mode": "webhook",
|
||||
},
|
||||
},
|
||||
"wecomaibot_encoding_aes_key": {
|
||||
"description": "企业微信智能机器人 EncodingAESKey",
|
||||
"type": "string",
|
||||
"hint": "用于 Webhook 回调模式的消息加密解密。",
|
||||
"condition": {
|
||||
"wecom_ai_bot_connection_mode": "webhook",
|
||||
},
|
||||
},
|
||||
"msg_push_webhook_url": {
|
||||
"description": "企业微信消息推送 Webhook URL",
|
||||
"type": "string",
|
||||
@@ -764,7 +784,7 @@ CONFIG_METADATA_2 = {
|
||||
"type": "bool",
|
||||
"hint": "启用后,企业微信智能机器人的所有回复都改为通过消息推送 Webhook 发送。消息推送 Webhook 支持更多的消息类型(如图片、文件等)。",
|
||||
},
|
||||
"long_connection_bot_id": {
|
||||
"wecomaibot_ws_bot_id": {
|
||||
"description": "长连接 BotID",
|
||||
"type": "string",
|
||||
"hint": "企业微信智能机器人长连接模式凭证 BotID。",
|
||||
@@ -772,7 +792,7 @@ CONFIG_METADATA_2 = {
|
||||
"wecom_ai_bot_connection_mode": "long_connection",
|
||||
},
|
||||
},
|
||||
"long_connection_secret": {
|
||||
"wecomaibot_ws_secret": {
|
||||
"description": "长连接 Secret",
|
||||
"type": "string",
|
||||
"hint": "企业微信智能机器人长连接模式凭证 Secret。",
|
||||
@@ -780,17 +800,19 @@ CONFIG_METADATA_2 = {
|
||||
"wecom_ai_bot_connection_mode": "long_connection",
|
||||
},
|
||||
},
|
||||
"long_connection_ws_url": {
|
||||
"wecomaibot_ws_url": {
|
||||
"description": "长连接 WebSocket 地址",
|
||||
"type": "string",
|
||||
"invisible": True,
|
||||
"hint": "默认值为 wss://openws.work.weixin.qq.com,一般无需修改。",
|
||||
"condition": {
|
||||
"wecom_ai_bot_connection_mode": "long_connection",
|
||||
},
|
||||
},
|
||||
"long_connection_heartbeat_interval": {
|
||||
"wecomaibot_heartbeat_interval": {
|
||||
"description": "长连接心跳间隔",
|
||||
"type": "int",
|
||||
"invisible": True,
|
||||
"hint": "长连接模式心跳间隔(秒),建议 30 秒。",
|
||||
"condition": {
|
||||
"wecom_ai_bot_connection_mode": "long_connection",
|
||||
@@ -840,7 +862,7 @@ CONFIG_METADATA_2 = {
|
||||
"unified_webhook_mode": {
|
||||
"description": "统一 Webhook 模式",
|
||||
"type": "bool",
|
||||
"hint": "启用后,将使用 AstrBot 统一 Webhook 入口,无需单独开启端口。回调地址为 /api/platform/webhook/{webhook_uuid}。",
|
||||
"hint": "Webhook 模式下使用 AstrBot 统一 Webhook 入口,无需单独开启端口。回调地址为 /api/platform/webhook/{webhook_uuid}。",
|
||||
},
|
||||
"webhook_uuid": {
|
||||
"invisible": True,
|
||||
|
||||
@@ -34,7 +34,7 @@ from .server import LarkWebhookServer
|
||||
|
||||
|
||||
@register_platform_adapter(
|
||||
"lark", "飞书机器人官方 API 适配器", support_streaming_message=False
|
||||
"lark", "飞书机器人官方 API 适配器", support_streaming_message=True
|
||||
)
|
||||
class LarkPlatformAdapter(Platform):
|
||||
def __init__(
|
||||
@@ -491,7 +491,7 @@ class LarkPlatformAdapter(Platform):
|
||||
name="lark",
|
||||
description="飞书机器人官方 API 适配器",
|
||||
id=cast(str, self.config.get("id")),
|
||||
support_streaming_message=False,
|
||||
support_streaming_message=True,
|
||||
)
|
||||
|
||||
async def convert_msg(self, event: lark.im.v1.P2ImMessageReceiveV1) -> None:
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import asyncio
|
||||
import base64
|
||||
import json
|
||||
import os
|
||||
@@ -5,6 +6,14 @@ import uuid
|
||||
from io import BytesIO
|
||||
|
||||
import lark_oapi as lark
|
||||
from lark_oapi.api.cardkit.v1 import (
|
||||
ContentCardElementRequest,
|
||||
ContentCardElementRequestBody,
|
||||
CreateCardRequest,
|
||||
CreateCardRequestBody,
|
||||
SettingsCardRequest,
|
||||
SettingsCardRequestBody,
|
||||
)
|
||||
from lark_oapi.api.im.v1 import (
|
||||
CreateFileRequest,
|
||||
CreateFileRequestBody,
|
||||
@@ -28,6 +37,7 @@ from astrbot.core.utils.media_utils import (
|
||||
convert_video_format,
|
||||
get_media_duration,
|
||||
)
|
||||
from astrbot.core.utils.metrics import Metric
|
||||
|
||||
|
||||
class LarkMessageEvent(AstrMessageEvent):
|
||||
@@ -555,15 +565,257 @@ class LarkMessageEvent(AstrMessageEvent):
|
||||
logger.error(f"发送飞书表情回应失败({response.code}): {response.msg}")
|
||||
return
|
||||
|
||||
async def send_streaming(self, generator, use_fallback: bool = False):
|
||||
async def _create_streaming_card(self) -> str | None:
|
||||
"""创建一个开启流式更新模式的卡片实体,返回 card_id。"""
|
||||
if self.bot.cardkit is None:
|
||||
logger.error("[Lark] API Client cardkit 模块未初始化")
|
||||
return None
|
||||
|
||||
card_json = {
|
||||
"schema": "2.0",
|
||||
"header": {
|
||||
"title": {"content": "", "tag": "plain_text"},
|
||||
},
|
||||
"config": {
|
||||
"streaming_mode": True,
|
||||
"summary": {"content": ""},
|
||||
"streaming_config": {
|
||||
"print_frequency_ms": {"default": 50},
|
||||
"print_step": {"default": 2},
|
||||
"print_strategy": "fast",
|
||||
},
|
||||
},
|
||||
"body": {
|
||||
"elements": [
|
||||
{
|
||||
"tag": "markdown",
|
||||
"content": "",
|
||||
"element_id": "markdown_1",
|
||||
}
|
||||
]
|
||||
},
|
||||
}
|
||||
|
||||
request = (
|
||||
CreateCardRequest.builder()
|
||||
.request_body(
|
||||
CreateCardRequestBody.builder()
|
||||
.type("card_json")
|
||||
.data(json.dumps(card_json, ensure_ascii=False))
|
||||
.build()
|
||||
)
|
||||
.build()
|
||||
)
|
||||
|
||||
try:
|
||||
response = await self.bot.cardkit.v1.card.acreate(request)
|
||||
except Exception as e:
|
||||
logger.error(f"[Lark] 创建流式卡片实体失败: {e}")
|
||||
return None
|
||||
|
||||
if not response.success():
|
||||
logger.error(
|
||||
f"[Lark] 创建流式卡片实体失败({response.code}): {response.msg}"
|
||||
)
|
||||
return None
|
||||
|
||||
if response.data is None or not response.data.card_id:
|
||||
logger.error("[Lark] 创建流式卡片实体成功但未返回 card_id")
|
||||
return None
|
||||
|
||||
card_id = response.data.card_id
|
||||
logger.debug(f"[Lark] 创建流式卡片实体成功: {card_id}")
|
||||
return card_id
|
||||
|
||||
async def _send_card_message(
|
||||
self,
|
||||
card_id: str,
|
||||
reply_message_id: str | None = None,
|
||||
receive_id: str | None = None,
|
||||
receive_id_type: str | None = None,
|
||||
) -> bool:
|
||||
"""将卡片实体作为 interactive 消息发送。"""
|
||||
content = json.dumps(
|
||||
{"type": "card", "data": {"card_id": card_id}},
|
||||
ensure_ascii=False,
|
||||
)
|
||||
return await self._send_im_message(
|
||||
self.bot,
|
||||
content=content,
|
||||
msg_type="interactive",
|
||||
reply_message_id=reply_message_id,
|
||||
receive_id=receive_id,
|
||||
receive_id_type=receive_id_type,
|
||||
)
|
||||
|
||||
async def _update_streaming_text(
|
||||
self,
|
||||
card_id: str,
|
||||
content: str,
|
||||
sequence: int,
|
||||
) -> bool:
|
||||
"""调用 CardKit 流式更新文本接口,向 markdown_1 组件推送全量文本。"""
|
||||
if self.bot.cardkit is None:
|
||||
logger.error("[Lark] API Client cardkit 模块未初始化")
|
||||
return False
|
||||
|
||||
request = (
|
||||
ContentCardElementRequest.builder()
|
||||
.card_id(card_id)
|
||||
.element_id("markdown_1")
|
||||
.request_body(
|
||||
ContentCardElementRequestBody.builder()
|
||||
.content(content)
|
||||
.sequence(sequence)
|
||||
.uuid(str(uuid.uuid4()))
|
||||
.build()
|
||||
)
|
||||
.build()
|
||||
)
|
||||
|
||||
try:
|
||||
response = await self.bot.cardkit.v1.card_element.acontent(request)
|
||||
except Exception as e:
|
||||
logger.debug(f"[Lark] 流式更新文本失败 (ignored): {e}")
|
||||
return False
|
||||
|
||||
if not response.success():
|
||||
logger.debug(f"[Lark] 流式更新文本失败({response.code}): {response.msg}")
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
async def _close_streaming_mode(
|
||||
self,
|
||||
card_id: str,
|
||||
sequence: int,
|
||||
) -> None:
|
||||
"""关闭卡片的流式更新模式,使其可正常转发、摘要恢复。"""
|
||||
if self.bot.cardkit is None:
|
||||
logger.error("[Lark] API Client cardkit 模块未初始化")
|
||||
return
|
||||
|
||||
settings_json = json.dumps(
|
||||
{"config": {"streaming_mode": False}},
|
||||
ensure_ascii=False,
|
||||
)
|
||||
|
||||
request = (
|
||||
SettingsCardRequest.builder()
|
||||
.card_id(card_id)
|
||||
.request_body(
|
||||
SettingsCardRequestBody.builder()
|
||||
.settings(settings_json)
|
||||
.sequence(sequence)
|
||||
.uuid(str(uuid.uuid4()))
|
||||
.build()
|
||||
)
|
||||
.build()
|
||||
)
|
||||
|
||||
try:
|
||||
response = await self.bot.cardkit.v1.card.asettings(request)
|
||||
except Exception as e:
|
||||
logger.error(f"[Lark] 关闭流式模式失败: {e}")
|
||||
return
|
||||
|
||||
if not response.success():
|
||||
logger.error(f"[Lark] 关闭流式模式失败({response.code}): {response.msg}")
|
||||
else:
|
||||
logger.debug(f"[Lark] 流式模式已关闭: {card_id}")
|
||||
|
||||
async def _fallback_send_streaming(self, generator, use_fallback: bool = False):
|
||||
"""回退到非流式发送:缓冲全部文本后一次性发送,并保留父类副作用。"""
|
||||
buffer = None
|
||||
async for chain in generator:
|
||||
if not buffer:
|
||||
buffer = chain
|
||||
else:
|
||||
buffer.chain.extend(chain.chain)
|
||||
if not buffer:
|
||||
return None
|
||||
buffer.squash_plain()
|
||||
await self.send(buffer)
|
||||
return await super().send_streaming(generator, use_fallback)
|
||||
|
||||
if buffer:
|
||||
buffer.squash_plain()
|
||||
await self.send(buffer)
|
||||
|
||||
await Metric.upload(msg_event_tick=1, adapter_name=self.platform_meta.name)
|
||||
self._has_send_oper = True
|
||||
|
||||
async def send_streaming(self, generator, use_fallback: bool = False):
|
||||
"""使用 CardKit 流式卡片实现打字机效果。
|
||||
|
||||
流程:创建卡片实体 → 发送消息 → 流式更新文本 → 关闭流式模式。
|
||||
使用解耦发送循环,LLM token 到达时只更新 buffer 并唤醒发送协程,
|
||||
发送频率由网络 RTT 自然限流。
|
||||
"""
|
||||
# Step 1: 创建流式卡片实体
|
||||
card_id = await self._create_streaming_card()
|
||||
if not card_id:
|
||||
logger.warning("[Lark] 无法创建流式卡片,回退到非流式发送")
|
||||
await self._fallback_send_streaming(generator, use_fallback)
|
||||
return
|
||||
|
||||
# Step 2: 发送卡片消息
|
||||
sent = await self._send_card_message(
|
||||
card_id,
|
||||
reply_message_id=self.message_obj.message_id,
|
||||
)
|
||||
if not sent:
|
||||
logger.error("[Lark] 发送流式卡片消息失败,回退到非流式发送")
|
||||
await self._fallback_send_streaming(generator, use_fallback)
|
||||
return
|
||||
|
||||
logger.info("[Lark] 流式输出: 使用 CardKit 流式卡片")
|
||||
|
||||
# Step 3: 解耦发送循环 (Event-driven, 参考 Telegram Draft 路径)
|
||||
sequence = 0
|
||||
delta = ""
|
||||
last_sent = ""
|
||||
done = False
|
||||
text_changed = asyncio.Event()
|
||||
|
||||
async def _sender_loop() -> None:
|
||||
"""信号驱动的文本发送循环,有新内容就发,RTT 自然限流。"""
|
||||
nonlocal sequence, last_sent
|
||||
while not done:
|
||||
await text_changed.wait()
|
||||
text_changed.clear()
|
||||
snapshot = delta
|
||||
if snapshot and snapshot != last_sent:
|
||||
sequence += 1
|
||||
ok = await self._update_streaming_text(card_id, snapshot, sequence)
|
||||
if ok:
|
||||
last_sent = snapshot
|
||||
if delta != snapshot:
|
||||
text_changed.set()
|
||||
|
||||
sender_task = asyncio.create_task(_sender_loop())
|
||||
|
||||
try:
|
||||
async for chain in generator:
|
||||
if not isinstance(chain, MessageChain):
|
||||
continue
|
||||
|
||||
if chain.type == "break":
|
||||
# 飞书卡片不支持分段,忽略 break
|
||||
continue
|
||||
|
||||
for comp in chain.chain:
|
||||
if isinstance(comp, Plain):
|
||||
delta += comp.text
|
||||
text_changed.set()
|
||||
finally:
|
||||
done = True
|
||||
text_changed.set()
|
||||
await sender_task
|
||||
|
||||
# Step 4: 必要时补发最终文本 + 关闭流式模式
|
||||
if delta and delta != last_sent:
|
||||
sequence += 1
|
||||
await self._update_streaming_text(card_id, delta, sequence)
|
||||
|
||||
sequence += 1
|
||||
await self._close_streaming_mode(card_id, sequence)
|
||||
|
||||
# Step 5: 内联父类 send_streaming 的副作用
|
||||
await Metric.upload(msg_event_tick=1, adapter_name=self.platform_meta.name)
|
||||
self._has_send_oper = True
|
||||
|
||||
@@ -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 Image, Plain, Record
|
||||
from astrbot.api.message_components import File, Image, Plain, Record, Video
|
||||
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,6 +47,11 @@ _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,
|
||||
@@ -65,35 +70,71 @@ class QQOfficialMessageEvent(AstrMessageEvent):
|
||||
await self._post_send()
|
||||
|
||||
async def send_streaming(self, generator, use_fallback: bool = False):
|
||||
"""流式输出仅支持消息列表私聊"""
|
||||
"""流式输出仅支持消息列表私聊(C2C),其他消息源退化为普通发送"""
|
||||
# 先标记事件层“已执行发送操作”,避免异常路径遗漏
|
||||
await super().send_streaming(generator, use_fallback)
|
||||
# QQ C2C 流式协议:开始/中间分片使用 state=1,结束分片使用 state=10
|
||||
stream_payload = {"state": 1, "id": None, "index": 0, "reset": False}
|
||||
last_edit_time = 0 # 上次编辑消息的时间
|
||||
throttle_interval = 1 # 编辑消息的间隔时间 (秒)
|
||||
last_edit_time = 0 # 上次发送分片的时间
|
||||
throttle_interval = 1 # 分片间最短间隔 (秒)
|
||||
ret = None
|
||||
source = (
|
||||
self.message_obj.raw_message
|
||||
) # 提前获取,避免 generator 为空时 NameError
|
||||
try:
|
||||
async for chain in generator:
|
||||
source = self.message_obj.raw_message
|
||||
|
||||
if not isinstance(source, botpy.message.C2CMessage):
|
||||
# 非 C2C 场景:直接累积,最后统一发
|
||||
if not self.send_buffer:
|
||||
self.send_buffer = chain
|
||||
else:
|
||||
self.send_buffer.chain.extend(chain.chain)
|
||||
continue
|
||||
|
||||
# ---- C2C 流式场景 ----
|
||||
|
||||
# tool_call break 信号:工具开始执行,先把已有 buffer 以 state=10 结束当前流式段
|
||||
if chain.type == "break":
|
||||
if self.send_buffer:
|
||||
stream_payload["state"] = 10
|
||||
ret = await self._post_send(stream=stream_payload)
|
||||
ret_id = self._extract_response_message_id(ret)
|
||||
if ret_id is not None:
|
||||
stream_payload["id"] = ret_id
|
||||
# 重置 stream_payload,为下一段流式做准备
|
||||
stream_payload = {
|
||||
"state": 1,
|
||||
"id": None,
|
||||
"index": 0,
|
||||
"reset": False,
|
||||
}
|
||||
last_edit_time = 0
|
||||
continue
|
||||
|
||||
# 累积内容
|
||||
if not self.send_buffer:
|
||||
self.send_buffer = chain
|
||||
else:
|
||||
self.send_buffer.chain.extend(chain.chain)
|
||||
|
||||
if isinstance(source, botpy.message.C2CMessage):
|
||||
# 真流式传输
|
||||
current_time = asyncio.get_running_loop().time()
|
||||
time_since_last_edit = current_time - last_edit_time
|
||||
|
||||
if time_since_last_edit >= throttle_interval:
|
||||
ret = cast(
|
||||
message.Message,
|
||||
await self._post_send(stream=stream_payload),
|
||||
)
|
||||
stream_payload["index"] += 1
|
||||
stream_payload["id"] = ret["id"]
|
||||
last_edit_time = asyncio.get_running_loop().time()
|
||||
# 节流:按时间间隔发送中间分片
|
||||
current_time = asyncio.get_running_loop().time()
|
||||
if current_time - last_edit_time >= throttle_interval:
|
||||
ret = cast(
|
||||
message.Message,
|
||||
await self._post_send(stream=stream_payload),
|
||||
)
|
||||
stream_payload["index"] += 1
|
||||
ret_id = self._extract_response_message_id(ret)
|
||||
if ret_id is not None:
|
||||
stream_payload["id"] = ret_id
|
||||
last_edit_time = asyncio.get_running_loop().time()
|
||||
self.send_buffer = None # 清空已发送的分片,避免下次重复发送旧内容
|
||||
|
||||
if isinstance(source, botpy.message.C2CMessage):
|
||||
# 结束流式对话,并且传输 buffer 中剩余的消息
|
||||
# 结束流式对话,发送 buffer 中剩余内容
|
||||
stream_payload["state"] = 10
|
||||
ret = await self._post_send(stream=stream_payload)
|
||||
else:
|
||||
@@ -101,9 +142,22 @@ class QQOfficialMessageEvent(AstrMessageEvent):
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"发送流式消息时出错: {e}", exc_info=True)
|
||||
# 避免累计内容在异常后被整包重复发送:仅清理缓存,不做非流式整包兜底
|
||||
# 如需兜底,应该只发送未发送 delta(后续可继续优化)
|
||||
self.send_buffer = None
|
||||
|
||||
return await super().send_streaming(generator, use_fallback)
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _extract_response_message_id(ret) -> str | None:
|
||||
"""兼容 qq-botpy 返回 Message 对象或 dict 两种形态。"""
|
||||
if ret is None:
|
||||
return None
|
||||
if isinstance(ret, dict):
|
||||
ret_id = ret.get("id")
|
||||
return str(ret_id) if ret_id is not None else None
|
||||
ret_id = getattr(ret, "id", None)
|
||||
return str(ret_id) if ret_id is not None else None
|
||||
|
||||
async def _post_send(self, stream: dict | None = None):
|
||||
if not self.send_buffer:
|
||||
@@ -126,16 +180,37 @@ 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,
|
||||
@@ -157,7 +232,7 @@ class QQOfficialMessageEvent(AstrMessageEvent):
|
||||
if image_base64:
|
||||
media = await self.upload_group_and_c2c_image(
|
||||
image_base64,
|
||||
1,
|
||||
self.IMAGE_FILE_TYPE,
|
||||
group_openid=source.group_openid,
|
||||
)
|
||||
payload["media"] = media
|
||||
@@ -165,15 +240,39 @@ 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_record(
|
||||
media = await self.upload_group_and_c2c_media(
|
||||
record_file_path,
|
||||
3,
|
||||
self.VOICE_FILE_TYPE,
|
||||
group_openid=source.group_openid,
|
||||
)
|
||||
payload["media"] = media
|
||||
payload["msg_type"] = 7
|
||||
payload.pop("markdown", None)
|
||||
payload["content"] = plain_text or None
|
||||
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
|
||||
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
|
||||
@@ -181,13 +280,14 @@ 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,
|
||||
1,
|
||||
self.IMAGE_FILE_TYPE,
|
||||
openid=source.author.user_openid,
|
||||
)
|
||||
payload["media"] = media
|
||||
@@ -195,15 +295,39 @@ 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_record(
|
||||
media = await self.upload_group_and_c2c_media(
|
||||
record_file_path,
|
||||
3,
|
||||
self.VOICE_FILE_TYPE,
|
||||
openid=source.author.user_openid,
|
||||
)
|
||||
payload["media"] = media
|
||||
payload["msg_type"] = 7
|
||||
payload.pop("markdown", None)
|
||||
payload["content"] = plain_text or None
|
||||
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
|
||||
if stream:
|
||||
ret = await self._send_with_markdown_fallback(
|
||||
send_func=lambda retry_payload: self.post_c2c_message(
|
||||
@@ -213,6 +337,7 @@ class QQOfficialMessageEvent(AstrMessageEvent):
|
||||
),
|
||||
payload=payload,
|
||||
plain_text=plain_text,
|
||||
stream=stream,
|
||||
)
|
||||
else:
|
||||
ret = await self._send_with_markdown_fallback(
|
||||
@@ -222,6 +347,7 @@ class QQOfficialMessageEvent(AstrMessageEvent):
|
||||
),
|
||||
payload=payload,
|
||||
plain_text=plain_text,
|
||||
stream=stream,
|
||||
)
|
||||
logger.debug(f"Message sent to C2C: {ret}")
|
||||
|
||||
@@ -237,6 +363,7 @@ class QQOfficialMessageEvent(AstrMessageEvent):
|
||||
),
|
||||
payload=payload,
|
||||
plain_text=plain_text,
|
||||
stream=stream,
|
||||
)
|
||||
|
||||
case botpy.message.DirectMessage():
|
||||
@@ -251,6 +378,7 @@ class QQOfficialMessageEvent(AstrMessageEvent):
|
||||
),
|
||||
payload=payload,
|
||||
plain_text=plain_text,
|
||||
stream=stream,
|
||||
)
|
||||
|
||||
case _:
|
||||
@@ -267,10 +395,31 @@ class QQOfficialMessageEvent(AstrMessageEvent):
|
||||
send_func,
|
||||
payload: dict,
|
||||
plain_text: str,
|
||||
stream: dict | None = None,
|
||||
):
|
||||
try:
|
||||
return await send_func(payload)
|
||||
except botpy.errors.ServerError as err:
|
||||
# QQ 流式 markdown 分片校验:内容必须以换行结尾。
|
||||
# 某些边界场景服务端仍可能判定失败,这里做一次修正重试。
|
||||
if stream and self.STREAM_MARKDOWN_NEWLINE_ERROR in str(err):
|
||||
retry_payload = payload.copy()
|
||||
|
||||
markdown_payload = retry_payload.get("markdown")
|
||||
if isinstance(markdown_payload, dict):
|
||||
md_content = cast(str, markdown_payload.get("content", "") or "")
|
||||
if md_content and not md_content.endswith("\n"):
|
||||
retry_payload["markdown"] = {"content": md_content + "\n"}
|
||||
|
||||
content = cast(str | None, retry_payload.get("content"))
|
||||
if content and not content.endswith("\n"):
|
||||
retry_payload["content"] = content + "\n"
|
||||
|
||||
logger.warning(
|
||||
"[QQOfficial] 流式 markdown 分片换行校验失败,已修正后重试一次。"
|
||||
)
|
||||
return await send_func(retry_payload)
|
||||
|
||||
if (
|
||||
self.MARKDOWN_NOT_ALLOWED_ERROR not in str(err)
|
||||
or not payload.get("markdown")
|
||||
@@ -282,10 +431,14 @@ class QQOfficialMessageEvent(AstrMessageEvent):
|
||||
"[QQOfficial] markdown 发送被拒绝,回退到 content 模式重试。"
|
||||
)
|
||||
fallback_payload = payload.copy()
|
||||
fallback_payload["markdown"] = None
|
||||
fallback_payload.pop("markdown", None)
|
||||
fallback_payload["content"] = plain_text
|
||||
if fallback_payload.get("msg_type") == 2:
|
||||
fallback_payload["msg_type"] = 0
|
||||
if stream:
|
||||
fallback_content = cast(str, fallback_payload.get("content") or "")
|
||||
if fallback_content and not fallback_content.endswith("\n"):
|
||||
fallback_payload["content"] = fallback_content + "\n"
|
||||
return await send_func(fallback_payload)
|
||||
|
||||
async def upload_group_and_c2c_image(
|
||||
@@ -327,16 +480,19 @@ class QQOfficialMessageEvent(AstrMessageEvent):
|
||||
ttl=result.get("ttl", 0),
|
||||
)
|
||||
|
||||
async def upload_group_and_c2c_record(
|
||||
async def upload_group_and_c2c_media(
|
||||
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):
|
||||
@@ -400,13 +556,21 @@ class QQOfficialMessageEvent(AstrMessageEvent):
|
||||
) -> message.Message:
|
||||
payload = locals()
|
||||
payload.pop("self", None)
|
||||
# QQ API does not accept stream.id=None; remove it when not yet assigned
|
||||
if "stream" in payload and payload["stream"] is not None:
|
||||
stream_data = dict(payload["stream"])
|
||||
if stream_data.get("id") is None:
|
||||
stream_data.pop("id", None)
|
||||
payload["stream"] = stream_data
|
||||
route = Route("POST", "/v2/users/{openid}/messages", openid=openid)
|
||||
result = await self.bot.api._http.request(route, json=payload)
|
||||
|
||||
if result is None:
|
||||
logger.warning("[QQOfficial] post_c2c_message: API 返回 None,跳过本次发送")
|
||||
return None
|
||||
if not isinstance(result, dict):
|
||||
raise RuntimeError(
|
||||
f"Failed to post c2c message, response is not dict: {result}"
|
||||
)
|
||||
logger.error(f"[QQOfficial] post_c2c_message: 响应不是 dict: {result}")
|
||||
return None
|
||||
|
||||
return message.Message(**result)
|
||||
|
||||
@@ -416,6 +580,9 @@ 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
|
||||
@@ -454,6 +621,30 @@ 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
|
||||
return (
|
||||
plain_text,
|
||||
image_base64,
|
||||
image_file_path,
|
||||
record_file_path,
|
||||
video_file_source,
|
||||
file_source,
|
||||
file_name,
|
||||
)
|
||||
|
||||
@@ -3,8 +3,10 @@ from __future__ import annotations
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
import random
|
||||
import time
|
||||
from typing import cast
|
||||
from types import SimpleNamespace
|
||||
from typing import Any, cast
|
||||
|
||||
import botpy
|
||||
import botpy.message
|
||||
@@ -12,7 +14,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
|
||||
from astrbot.api.message_components import At, File, Image, Plain, Record, Video
|
||||
from astrbot.api.platform import (
|
||||
AstrBotMessage,
|
||||
MessageMember,
|
||||
@@ -46,6 +48,7 @@ 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)
|
||||
|
||||
# 收到频道消息
|
||||
@@ -56,6 +59,7 @@ 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)
|
||||
|
||||
# 收到私聊消息
|
||||
@@ -67,6 +71,7 @@ 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 消息
|
||||
@@ -76,9 +81,11 @@ 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,
|
||||
@@ -124,6 +131,9 @@ 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(
|
||||
@@ -131,14 +141,191 @@ class QQOfficialPlatformAdapter(Platform):
|
||||
session: MessageSesion,
|
||||
message_chain: MessageChain,
|
||||
) -> None:
|
||||
raise NotImplementedError("QQ 机器人官方 API 适配器不支持 send_by_session")
|
||||
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
|
||||
|
||||
def meta(self) -> PlatformMetadata:
|
||||
return PlatformMetadata(
|
||||
name="qq_official",
|
||||
description="QQ 机器人官方 API 适配器",
|
||||
id=cast(str, self.config.get("id")),
|
||||
support_proactive_message=False,
|
||||
support_proactive_message=True,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
@@ -158,7 +345,10 @@ class QQOfficialPlatformAdapter(Platform):
|
||||
return
|
||||
|
||||
for attachment in attachments:
|
||||
content_type = cast(str, getattr(attachment, "content_type", "") or "")
|
||||
content_type = cast(
|
||||
str,
|
||||
getattr(attachment, "content_type", "") or "",
|
||||
).lower()
|
||||
url = QQOfficialPlatformAdapter._normalize_attachment_url(
|
||||
cast(str | None, getattr(attachment, "url", None))
|
||||
)
|
||||
@@ -174,7 +364,32 @@ class QQOfficialPlatformAdapter(Platform):
|
||||
or getattr(attachment, "name", None)
|
||||
or "attachment",
|
||||
)
|
||||
msg.append(File(name=filename, file=url, url=url))
|
||||
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_from_qqofficial(
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import asyncio
|
||||
import logging
|
||||
import random
|
||||
from types import SimpleNamespace
|
||||
from typing import Any, cast
|
||||
|
||||
import botpy
|
||||
@@ -15,7 +13,6 @@ 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
|
||||
@@ -123,95 +120,11 @@ class QQOfficialWebhookPlatformAdapter(Platform):
|
||||
session: MessageSesion,
|
||||
message_chain: MessageChain,
|
||||
) -> None:
|
||||
(
|
||||
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)
|
||||
await QQOfficialPlatformAdapter._send_by_session_common(
|
||||
cast(Any, self),
|
||||
session,
|
||||
message_chain,
|
||||
)
|
||||
|
||||
def remember_session_message_id(self, session_id: str, message_id: str) -> None:
|
||||
if not session_id or not message_id:
|
||||
|
||||
@@ -278,7 +278,6 @@ class TelegramPlatformEvent(AstrMessageEvent):
|
||||
try:
|
||||
md_text = telegramify_markdown.markdownify(
|
||||
chunk,
|
||||
normalize_whitespace=False,
|
||||
)
|
||||
await client.send_message(
|
||||
text=md_text,
|
||||
@@ -456,7 +455,6 @@ class TelegramPlatformEvent(AstrMessageEvent):
|
||||
try:
|
||||
markdown_text = telegramify_markdown.markdownify(
|
||||
delta,
|
||||
normalize_whitespace=False,
|
||||
)
|
||||
await self.client.send_message(
|
||||
text=markdown_text,
|
||||
@@ -537,7 +535,6 @@ class TelegramPlatformEvent(AstrMessageEvent):
|
||||
try:
|
||||
md = telegramify_markdown.markdownify(
|
||||
draft_text,
|
||||
normalize_whitespace=False,
|
||||
)
|
||||
await self._send_message_draft(
|
||||
user_name,
|
||||
@@ -695,7 +692,6 @@ class TelegramPlatformEvent(AstrMessageEvent):
|
||||
try:
|
||||
markdown_text = telegramify_markdown.markdownify(
|
||||
delta,
|
||||
normalize_whitespace=False,
|
||||
)
|
||||
await self.client.edit_message_text(
|
||||
text=markdown_text,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
"""企业微信智能机器人平台适配器
|
||||
基于企业微信智能机器人 API 的消息平台适配器,支持 HTTP 回调
|
||||
基于企业微信智能机器人 API 的消息平台适配器,支持 HTTP 回调与长连接
|
||||
参考webchat_adapter.py的队列机制,实现异步消息处理和流式响应
|
||||
"""
|
||||
|
||||
@@ -31,6 +31,7 @@ from .wecomai_api import (
|
||||
WecomAIBotStreamMessageBuilder,
|
||||
)
|
||||
from .wecomai_event import WecomAIBotMessageEvent
|
||||
from .wecomai_long_connection import WecomAIBotLongConnectionClient
|
||||
from .wecomai_queue_mgr import WecomAIQueueMgr
|
||||
from .wecomai_server import WecomAIBotServer
|
||||
from .wecomai_utils import (
|
||||
@@ -78,8 +79,13 @@ class WecomAIBotAdapter(Platform):
|
||||
self.settings = platform_settings
|
||||
|
||||
# 初始化配置参数
|
||||
self.token = self.config["token"]
|
||||
self.encoding_aes_key = self.config["encoding_aes_key"]
|
||||
self.connection_mode = self.config.get(
|
||||
"wecom_ai_bot_connection_mode", "webhook"
|
||||
)
|
||||
self.token = self.config.get("token", self.config.get("wecomaibot_token", ""))
|
||||
self.encoding_aes_key = self.config.get(
|
||||
"encoding_aes_key", self.config.get("wecomaibot_encoding_aes_key", "")
|
||||
)
|
||||
self.port = int(self.config["port"])
|
||||
self.host = self.config.get("callback_server_host", "0.0.0.0")
|
||||
self.bot_name = self.config.get("wecom_ai_bot_name", "")
|
||||
@@ -96,25 +102,52 @@ class WecomAIBotAdapter(Platform):
|
||||
self.only_use_webhook_url_to_send = bool(
|
||||
self.config.get("only_use_webhook_url_to_send", False),
|
||||
)
|
||||
self.long_connection_bot_id = self.config.get(
|
||||
"wecomaibot_ws_bot_id", self.config.get("long_connection_bot_id", "")
|
||||
)
|
||||
self.long_connection_secret = self.config.get(
|
||||
"wecomaibot_ws_secret", self.config.get("long_connection_secret", "")
|
||||
)
|
||||
self.long_connection_ws_url = self.config.get(
|
||||
"wecomaibot_ws_url",
|
||||
"wss://openws.work.weixin.qq.com",
|
||||
)
|
||||
self.long_connection_heartbeat_interval = int(
|
||||
self.config.get("wecomaibot_heartbeat_interval", 30),
|
||||
)
|
||||
|
||||
# 平台元数据
|
||||
self.metadata = PlatformMetadata(
|
||||
name="wecom_ai_bot",
|
||||
description="企业微信智能机器人适配器,支持 HTTP 回调接收消息",
|
||||
description="企业微信智能机器人适配器,支持 HTTP 回调和长连接模式",
|
||||
id=self.config.get("id", "wecom_ai_bot"),
|
||||
support_proactive_message=bool(self.msg_push_webhook_url),
|
||||
)
|
||||
|
||||
# 初始化 API 客户端
|
||||
self.api_client = WecomAIBotAPIClient(self.token, self.encoding_aes_key)
|
||||
self.api_client: WecomAIBotAPIClient | None = None
|
||||
self.server: WecomAIBotServer | None = None
|
||||
self.long_connection_client: WecomAIBotLongConnectionClient | None = None
|
||||
|
||||
# 初始化 HTTP 服务器
|
||||
self.server = WecomAIBotServer(
|
||||
host=self.host,
|
||||
port=self.port,
|
||||
api_client=self.api_client,
|
||||
message_handler=self._process_message,
|
||||
)
|
||||
if self.connection_mode == "long_connection":
|
||||
if not self.long_connection_bot_id or not self.long_connection_secret:
|
||||
logger.warning(
|
||||
"企业微信智能机器人长连接模式缺少 BotID 或 Secret,连接可能失败"
|
||||
)
|
||||
self.long_connection_client = WecomAIBotLongConnectionClient(
|
||||
bot_id=self.long_connection_bot_id,
|
||||
secret=self.long_connection_secret,
|
||||
ws_url=self.long_connection_ws_url,
|
||||
heartbeat_interval=self.long_connection_heartbeat_interval,
|
||||
message_handler=self._process_long_connection_payload,
|
||||
)
|
||||
else:
|
||||
self.api_client = WecomAIBotAPIClient(self.token, self.encoding_aes_key)
|
||||
self.server = WecomAIBotServer(
|
||||
host=self.host,
|
||||
port=self.port,
|
||||
api_client=self.api_client,
|
||||
message_handler=self._process_message,
|
||||
)
|
||||
|
||||
# 事件循环和关闭信号
|
||||
self.shutdown_event = asyncio.Event()
|
||||
@@ -161,6 +194,9 @@ class WecomAIBotAdapter(Platform):
|
||||
加密后的响应消息,无需响应时返回 None
|
||||
|
||||
"""
|
||||
if not self.api_client:
|
||||
logger.error("Webhook 消息处理失败: API 客户端未初始化")
|
||||
return None
|
||||
msgtype = message_data.get("msgtype")
|
||||
if not msgtype:
|
||||
logger.warning(f"消息类型未知,忽略: {message_data}")
|
||||
@@ -320,6 +356,89 @@ class WecomAIBotAdapter(Platform):
|
||||
logger.error("处理欢迎消息时发生异常: %s", e)
|
||||
return None
|
||||
|
||||
async def _process_long_connection_payload(
|
||||
self,
|
||||
payload: dict[str, Any],
|
||||
) -> None:
|
||||
"""处理长连接回调消息。"""
|
||||
cmd = payload.get("cmd")
|
||||
headers = payload.get("headers") or {}
|
||||
body = payload.get("body") or {}
|
||||
req_id = headers.get("req_id")
|
||||
if not isinstance(body, dict):
|
||||
return
|
||||
|
||||
if cmd == "aibot_msg_callback":
|
||||
session_id = self._extract_session_id(body)
|
||||
stream_id = f"{session_id}_{generate_random_string(10)}"
|
||||
await self._enqueue_message(
|
||||
body, {"req_id": req_id or ""}, stream_id, session_id
|
||||
)
|
||||
self.queue_mgr.set_pending_response(
|
||||
stream_id,
|
||||
{
|
||||
"req_id": req_id or "",
|
||||
"connection_mode": "long_connection",
|
||||
},
|
||||
)
|
||||
|
||||
if self.initial_respond_text and req_id:
|
||||
await self._send_long_connection_respond_msg(
|
||||
req_id=req_id,
|
||||
body={
|
||||
"msgtype": "stream",
|
||||
"stream": {
|
||||
"id": stream_id,
|
||||
"finish": False,
|
||||
"content": self.initial_respond_text,
|
||||
},
|
||||
},
|
||||
)
|
||||
return
|
||||
|
||||
if cmd == "aibot_event_callback":
|
||||
event = body.get("event") or {}
|
||||
event_type = event.get("eventtype")
|
||||
if (
|
||||
event_type == "enter_chat"
|
||||
and self.friend_message_welcome_text
|
||||
and req_id
|
||||
):
|
||||
await self._send_long_connection_respond_welcome(req_id)
|
||||
elif event_type == "disconnected_event":
|
||||
logger.warning(
|
||||
"[WecomAI][LongConn] 收到 disconnected_event,旧连接将被关闭"
|
||||
)
|
||||
|
||||
async def _send_long_connection_respond_welcome(self, req_id: str) -> bool:
|
||||
client = self.long_connection_client
|
||||
if not client:
|
||||
return False
|
||||
return await client.send_command(
|
||||
cmd="aibot_respond_welcome_msg",
|
||||
req_id=req_id,
|
||||
body={
|
||||
"msgtype": "text",
|
||||
"text": {
|
||||
"content": self.friend_message_welcome_text,
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
async def _send_long_connection_respond_msg(
|
||||
self,
|
||||
req_id: str,
|
||||
body: dict[str, Any],
|
||||
) -> bool:
|
||||
client = self.long_connection_client
|
||||
if not client:
|
||||
return False
|
||||
return await client.send_command(
|
||||
cmd="aibot_respond_msg",
|
||||
req_id=req_id,
|
||||
body=body,
|
||||
)
|
||||
|
||||
def _extract_session_id(self, message_data: dict[str, Any]) -> str:
|
||||
"""从消息数据中提取会话ID"""
|
||||
user_id = message_data.get("from", {}).get("userid", "default_user")
|
||||
@@ -355,15 +474,16 @@ class WecomAIBotAdapter(Platform):
|
||||
content = ""
|
||||
image_base64 = []
|
||||
|
||||
_img_url_to_process = []
|
||||
_img_url_to_process: list[tuple[str, str | None]] = []
|
||||
msg_items = []
|
||||
|
||||
if msgtype == WecomAIBotConstants.MSG_TYPE_TEXT:
|
||||
content = WecomAIBotMessageParser.parse_text_message(message_data)
|
||||
elif msgtype == WecomAIBotConstants.MSG_TYPE_IMAGE:
|
||||
_img_url_to_process.append(
|
||||
WecomAIBotMessageParser.parse_image_message(message_data),
|
||||
)
|
||||
image_payload = message_data.get("image", {})
|
||||
image_url = image_payload.get("url", "")
|
||||
if image_url:
|
||||
_img_url_to_process.append((image_url, image_payload.get("aeskey")))
|
||||
elif msgtype == WecomAIBotConstants.MSG_TYPE_MIXED:
|
||||
# 提取混合消息中的文本内容
|
||||
msg_items = WecomAIBotMessageParser.parse_mixed_message(message_data)
|
||||
@@ -374,9 +494,12 @@ class WecomAIBotAdapter(Platform):
|
||||
if text_content:
|
||||
text_parts.append(text_content)
|
||||
elif item.get("msgtype") == WecomAIBotConstants.MSG_TYPE_IMAGE:
|
||||
image_url = item.get("image", {}).get("url", "")
|
||||
image_payload = item.get("image", {})
|
||||
image_url = image_payload.get("url", "")
|
||||
if image_url:
|
||||
_img_url_to_process.append(image_url)
|
||||
_img_url_to_process.append(
|
||||
(image_url, image_payload.get("aeskey"))
|
||||
)
|
||||
content = " ".join(text_parts) if text_parts else ""
|
||||
else:
|
||||
content = f"[{msgtype}消息]"
|
||||
@@ -384,8 +507,8 @@ class WecomAIBotAdapter(Platform):
|
||||
# 并行处理图片下载和解密
|
||||
if _img_url_to_process:
|
||||
tasks = [
|
||||
process_encrypted_image(url, self.encoding_aes_key)
|
||||
for url in _img_url_to_process
|
||||
process_encrypted_image(url, aes_key or self.encoding_aes_key)
|
||||
for url, aes_key in _img_url_to_process
|
||||
]
|
||||
results = await asyncio.gather(*tasks)
|
||||
for success, result in results:
|
||||
@@ -459,26 +582,43 @@ class WecomAIBotAdapter(Platform):
|
||||
"""运行适配器,同时启动HTTP服务器和队列监听器"""
|
||||
|
||||
async def run_both() -> None:
|
||||
# 如果启用统一 webhook 模式,则不启动独立服务器
|
||||
webhook_uuid = self.config.get("webhook_uuid")
|
||||
if self.unified_webhook_mode and webhook_uuid:
|
||||
log_webhook_info(f"{self.meta().id}(企业微信智能机器人)", webhook_uuid)
|
||||
# 只运行队列监听器
|
||||
await self.queue_listener.run()
|
||||
else:
|
||||
if self.connection_mode == "long_connection":
|
||||
if not self.long_connection_client:
|
||||
raise RuntimeError("长连接客户端未初始化")
|
||||
logger.info(
|
||||
"启动企业微信智能机器人适配器,监听 %s:%d", self.host, self.port
|
||||
"启动企业微信智能机器人长连接模式: %s", self.long_connection_ws_url
|
||||
)
|
||||
# 同时运行HTTP服务器和队列监听器
|
||||
await asyncio.gather(
|
||||
self.server.start_server(),
|
||||
self.long_connection_client.start(),
|
||||
self.queue_listener.run(),
|
||||
)
|
||||
else:
|
||||
# 如果启用统一 webhook 模式,则不启动独立服务器
|
||||
webhook_uuid = self.config.get("webhook_uuid")
|
||||
if self.unified_webhook_mode and webhook_uuid:
|
||||
log_webhook_info(
|
||||
f"{self.meta().id}(企业微信智能机器人)", webhook_uuid
|
||||
)
|
||||
# 只运行队列监听器
|
||||
await self.queue_listener.run()
|
||||
else:
|
||||
if not self.server:
|
||||
raise RuntimeError("Webhook 服务器未初始化")
|
||||
logger.info(
|
||||
"启动企业微信智能机器人适配器,监听 %s:%d", self.host, self.port
|
||||
)
|
||||
# 同时运行HTTP服务器和队列监听器
|
||||
await asyncio.gather(
|
||||
self.server.start_server(),
|
||||
self.queue_listener.run(),
|
||||
)
|
||||
|
||||
return run_both()
|
||||
|
||||
async def webhook_callback(self, request: Any) -> Any:
|
||||
"""统一 Webhook 回调入口"""
|
||||
if self.connection_mode == "long_connection" or not self.server:
|
||||
return "long_connection mode does not accept webhook callbacks", 400
|
||||
# 根据请求方法分发到不同的处理函数
|
||||
if request.method == "GET":
|
||||
return await self.server.handle_verify(request)
|
||||
@@ -489,7 +629,10 @@ class WecomAIBotAdapter(Platform):
|
||||
"""终止适配器"""
|
||||
logger.info("企业微信智能机器人适配器正在关闭...")
|
||||
self.shutdown_event.set()
|
||||
await self.server.shutdown()
|
||||
if self.long_connection_client:
|
||||
await self.long_connection_client.shutdown()
|
||||
if self.server:
|
||||
await self.server.shutdown()
|
||||
|
||||
def meta(self) -> PlatformMetadata:
|
||||
"""获取平台元数据"""
|
||||
@@ -507,17 +650,22 @@ class WecomAIBotAdapter(Platform):
|
||||
queue_mgr=self.queue_mgr,
|
||||
webhook_client=self.webhook_client,
|
||||
only_use_webhook_url_to_send=self.only_use_webhook_url_to_send,
|
||||
long_connection_sender=self._send_long_connection_respond_msg,
|
||||
)
|
||||
message_event.is_at_or_wake_command = (
|
||||
True # 企业微信智能机器人默认消息都是 at 或唤醒命令
|
||||
)
|
||||
message_event.is_wake = True # 企业微信智能机器人消息默认当做唤醒命令处理
|
||||
|
||||
self.commit_event(message_event)
|
||||
|
||||
except Exception as e:
|
||||
logger.error("处理消息时发生异常: %s", e)
|
||||
|
||||
def get_client(self) -> WecomAIBotAPIClient:
|
||||
def get_client(self) -> WecomAIBotAPIClient | None:
|
||||
"""获取 API 客户端"""
|
||||
return self.api_client
|
||||
|
||||
def get_server(self) -> WecomAIBotServer:
|
||||
def get_server(self) -> WecomAIBotServer | None:
|
||||
"""获取 HTTP 服务器实例"""
|
||||
return self.server
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
"""企业微信智能机器人事件处理模块,处理消息事件的发送和接收"""
|
||||
|
||||
from collections.abc import Awaitable, Callable
|
||||
|
||||
from astrbot.api import logger
|
||||
from astrbot.api.event import AstrMessageEvent, MessageChain
|
||||
from astrbot.api.message_components import At, Image, Plain
|
||||
@@ -18,10 +20,11 @@ class WecomAIBotMessageEvent(AstrMessageEvent):
|
||||
message_obj,
|
||||
platform_meta,
|
||||
session_id: str,
|
||||
api_client: WecomAIBotAPIClient,
|
||||
api_client: WecomAIBotAPIClient | None,
|
||||
queue_mgr: WecomAIQueueMgr,
|
||||
webhook_client: WecomAIBotWebhookClient | None = None,
|
||||
only_use_webhook_url_to_send: bool = False,
|
||||
long_connection_sender: (Callable[[str, dict], Awaitable[bool]] | None) = None,
|
||||
) -> None:
|
||||
"""初始化消息事件
|
||||
|
||||
@@ -38,6 +41,7 @@ class WecomAIBotMessageEvent(AstrMessageEvent):
|
||||
self.queue_mgr = queue_mgr
|
||||
self.webhook_client = webhook_client
|
||||
self.only_use_webhook_url_to_send = only_use_webhook_url_to_send
|
||||
self.long_connection_sender = long_connection_sender
|
||||
|
||||
async def _mark_stream_complete(self, stream_id: str) -> None:
|
||||
back_queue = self.queue_mgr.get_or_create_back_queue(stream_id)
|
||||
@@ -117,6 +121,18 @@ class WecomAIBotMessageEvent(AstrMessageEvent):
|
||||
|
||||
return data
|
||||
|
||||
@staticmethod
|
||||
def _extract_plain_text_from_chain(message_chain: MessageChain | None) -> str:
|
||||
if not message_chain:
|
||||
return ""
|
||||
plain_parts: list[str] = []
|
||||
for comp in message_chain.chain:
|
||||
if isinstance(comp, At):
|
||||
plain_parts.append(f"@{comp.name} ")
|
||||
elif isinstance(comp, Plain):
|
||||
plain_parts.append(comp.text)
|
||||
return "".join(plain_parts).strip()
|
||||
|
||||
async def send(self, message: MessageChain | None) -> None:
|
||||
"""发送消息"""
|
||||
raw = self.message_obj.raw_message
|
||||
@@ -124,6 +140,44 @@ class WecomAIBotMessageEvent(AstrMessageEvent):
|
||||
"wecom_ai_bot platform event raw_message should be a dict"
|
||||
)
|
||||
stream_id = raw.get("stream_id", self.session_id)
|
||||
pending_response = self.queue_mgr.get_pending_response(stream_id) or {}
|
||||
connection_mode = pending_response.get("callback_params", {}).get(
|
||||
"connection_mode"
|
||||
)
|
||||
req_id = pending_response.get("callback_params", {}).get("req_id")
|
||||
|
||||
if (
|
||||
connection_mode == "long_connection"
|
||||
and self.long_connection_sender
|
||||
and isinstance(req_id, str)
|
||||
and req_id
|
||||
):
|
||||
if self.only_use_webhook_url_to_send and self.webhook_client and message:
|
||||
await self.webhook_client.send_message_chain(message)
|
||||
await super().send(MessageChain([]))
|
||||
return
|
||||
|
||||
if self.webhook_client and message:
|
||||
await self.webhook_client.send_message_chain(
|
||||
message,
|
||||
unsupported_only=True,
|
||||
)
|
||||
|
||||
content = self._extract_plain_text_from_chain(message)
|
||||
await self.long_connection_sender(
|
||||
req_id,
|
||||
{
|
||||
"msgtype": "stream",
|
||||
"stream": {
|
||||
"id": stream_id,
|
||||
"finish": True,
|
||||
"content": content,
|
||||
},
|
||||
},
|
||||
)
|
||||
await super().send(MessageChain([]))
|
||||
return
|
||||
|
||||
if self.only_use_webhook_url_to_send and self.webhook_client and message:
|
||||
await self.webhook_client.send_message_chain(message)
|
||||
await self._mark_stream_complete(stream_id)
|
||||
@@ -152,8 +206,77 @@ class WecomAIBotMessageEvent(AstrMessageEvent):
|
||||
"wecom_ai_bot platform event raw_message should be a dict"
|
||||
)
|
||||
stream_id = raw.get("stream_id", self.session_id)
|
||||
pending_response = self.queue_mgr.get_pending_response(stream_id) or {}
|
||||
connection_mode = pending_response.get("callback_params", {}).get(
|
||||
"connection_mode"
|
||||
)
|
||||
req_id = pending_response.get("callback_params", {}).get("req_id")
|
||||
back_queue = self.queue_mgr.get_or_create_back_queue(stream_id)
|
||||
|
||||
if (
|
||||
connection_mode == "long_connection"
|
||||
and self.long_connection_sender
|
||||
and isinstance(req_id, str)
|
||||
and req_id
|
||||
):
|
||||
if self.only_use_webhook_url_to_send and self.webhook_client:
|
||||
merged_chain = MessageChain([])
|
||||
async for chain in generator:
|
||||
merged_chain.chain.extend(chain.chain)
|
||||
merged_chain.squash_plain()
|
||||
await self.webhook_client.send_message_chain(merged_chain)
|
||||
await self.long_connection_sender(
|
||||
req_id,
|
||||
{
|
||||
"msgtype": "stream",
|
||||
"stream": {
|
||||
"id": stream_id,
|
||||
"finish": True,
|
||||
"content": "",
|
||||
},
|
||||
},
|
||||
)
|
||||
await super().send_streaming(generator, use_fallback)
|
||||
return
|
||||
|
||||
increment_plain = ""
|
||||
async for chain in generator:
|
||||
if self.webhook_client:
|
||||
await self.webhook_client.send_message_chain(
|
||||
chain,
|
||||
unsupported_only=True,
|
||||
)
|
||||
|
||||
chain.squash_plain()
|
||||
chunk_text = self._extract_plain_text_from_chain(chain)
|
||||
if chunk_text:
|
||||
increment_plain += chunk_text
|
||||
await self.long_connection_sender(
|
||||
req_id,
|
||||
{
|
||||
"msgtype": "stream",
|
||||
"stream": {
|
||||
"id": stream_id,
|
||||
"finish": False,
|
||||
"content": increment_plain,
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
await self.long_connection_sender(
|
||||
req_id,
|
||||
{
|
||||
"msgtype": "stream",
|
||||
"stream": {
|
||||
"id": stream_id,
|
||||
"finish": True,
|
||||
"content": increment_plain,
|
||||
},
|
||||
},
|
||||
)
|
||||
await super().send_streaming(generator, use_fallback)
|
||||
return
|
||||
|
||||
if self.only_use_webhook_url_to_send and self.webhook_client:
|
||||
merged_chain = MessageChain([])
|
||||
async for chain in generator:
|
||||
|
||||
@@ -0,0 +1,236 @@
|
||||
"""企业微信智能机器人长连接客户端。"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import uuid
|
||||
from collections.abc import Awaitable, Callable
|
||||
from typing import Any
|
||||
|
||||
import aiohttp
|
||||
|
||||
from astrbot.api import logger
|
||||
|
||||
|
||||
class WecomAIBotLongConnectionClient:
|
||||
"""企业微信智能机器人 WebSocket 长连接客户端。"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
bot_id: str,
|
||||
secret: str,
|
||||
ws_url: str,
|
||||
heartbeat_interval: int,
|
||||
message_handler: Callable[[dict[str, Any]], Awaitable[None]],
|
||||
) -> None:
|
||||
self.bot_id = bot_id
|
||||
self.secret = secret
|
||||
self.ws_url = ws_url
|
||||
self.heartbeat_interval = max(5, int(heartbeat_interval))
|
||||
self.message_handler = message_handler
|
||||
|
||||
self._session: aiohttp.ClientSession | None = None
|
||||
self._ws: aiohttp.ClientWebSocketResponse | None = None
|
||||
self._shutdown_event = asyncio.Event()
|
||||
self._send_lock = asyncio.Lock()
|
||||
self._command_lock = asyncio.Lock()
|
||||
self._response_waiters: dict[str, asyncio.Future[dict[str, Any]]] = {}
|
||||
|
||||
@staticmethod
|
||||
def gen_req_id() -> str:
|
||||
return uuid.uuid4().hex
|
||||
|
||||
async def start(self) -> None:
|
||||
"""启动长连接并自动重连。"""
|
||||
reconnect_delay = 1
|
||||
while not self._shutdown_event.is_set():
|
||||
try:
|
||||
await self._run_once()
|
||||
reconnect_delay = 1
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error("[WecomAI][LongConn] 长连接异常: %s", e)
|
||||
if self._shutdown_event.is_set():
|
||||
break
|
||||
await asyncio.sleep(reconnect_delay)
|
||||
reconnect_delay = min(reconnect_delay * 2, 30)
|
||||
|
||||
async def _run_once(self) -> None:
|
||||
timeout = aiohttp.ClientTimeout(total=None, sock_connect=15, sock_read=None)
|
||||
async with aiohttp.ClientSession(timeout=timeout) as session:
|
||||
self._session = session
|
||||
logger.info("[WecomAI][LongConn] 正在连接: %s", self.ws_url)
|
||||
async with session.ws_connect(
|
||||
self.ws_url, heartbeat=None, autoping=True
|
||||
) as ws:
|
||||
self._ws = ws
|
||||
await self._subscribe()
|
||||
logger.info("[WecomAI][LongConn] 订阅成功,已建立长连接")
|
||||
|
||||
heartbeat_task = asyncio.create_task(self._heartbeat_loop())
|
||||
try:
|
||||
while not self._shutdown_event.is_set():
|
||||
message = await ws.receive()
|
||||
if message.type == aiohttp.WSMsgType.TEXT:
|
||||
await self._handle_text_message(message.data)
|
||||
elif message.type in {
|
||||
aiohttp.WSMsgType.CLOSED,
|
||||
aiohttp.WSMsgType.CLOSE,
|
||||
aiohttp.WSMsgType.ERROR,
|
||||
}:
|
||||
break
|
||||
finally:
|
||||
heartbeat_task.cancel()
|
||||
try:
|
||||
await heartbeat_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
self._ws = None
|
||||
|
||||
async def _subscribe(self) -> None:
|
||||
"""发送 aibot_subscribe,并等待响应。"""
|
||||
req_id = self.gen_req_id()
|
||||
payload = {
|
||||
"cmd": "aibot_subscribe",
|
||||
"headers": {"req_id": req_id},
|
||||
"body": {"bot_id": self.bot_id, "secret": self.secret},
|
||||
}
|
||||
await self._send_json(payload)
|
||||
|
||||
if not self._ws:
|
||||
raise RuntimeError("WebSocket 未建立")
|
||||
|
||||
reply = await self._ws.receive(timeout=10)
|
||||
if reply.type != aiohttp.WSMsgType.TEXT:
|
||||
raise RuntimeError(f"订阅失败: 非文本响应 {reply.type}")
|
||||
|
||||
data = json.loads(reply.data)
|
||||
if data.get("errcode") != 0:
|
||||
raise RuntimeError(
|
||||
f"订阅失败 errcode={data.get('errcode')} errmsg={data.get('errmsg')}"
|
||||
)
|
||||
|
||||
async def _heartbeat_loop(self) -> None:
|
||||
while not self._shutdown_event.is_set():
|
||||
await asyncio.sleep(self.heartbeat_interval)
|
||||
if self._shutdown_event.is_set():
|
||||
break
|
||||
try:
|
||||
await self.send_command("ping", self.gen_req_id(), None)
|
||||
except Exception as e:
|
||||
logger.warning("[WecomAI][LongConn] 发送心跳失败: %s", e)
|
||||
return
|
||||
|
||||
async def _handle_text_message(self, text: str) -> None:
|
||||
try:
|
||||
payload = json.loads(text)
|
||||
except json.JSONDecodeError:
|
||||
logger.warning("[WecomAI][LongConn] 收到非 JSON 消息: %s", text)
|
||||
return
|
||||
|
||||
headers = payload.get("headers") or {}
|
||||
req_id = headers.get("req_id")
|
||||
if isinstance(req_id, str):
|
||||
waiter = self._response_waiters.get(req_id)
|
||||
if waiter and not waiter.done():
|
||||
waiter.set_result(payload)
|
||||
return
|
||||
|
||||
cmd = payload.get("cmd")
|
||||
if cmd in {"aibot_msg_callback", "aibot_event_callback"}:
|
||||
await self.message_handler(payload)
|
||||
return
|
||||
|
||||
if payload.get("errcode") not in (None, 0):
|
||||
logger.warning(
|
||||
"[WecomAI][LongConn] 服务端返回错误: errcode=%s errmsg=%s",
|
||||
payload.get("errcode"),
|
||||
payload.get("errmsg"),
|
||||
)
|
||||
|
||||
async def send_command(
|
||||
self,
|
||||
cmd: str,
|
||||
req_id: str,
|
||||
body: dict[str, Any] | None,
|
||||
) -> bool:
|
||||
"""发送长连接命令。"""
|
||||
headers = {"req_id": req_id}
|
||||
payload: dict[str, Any] = {"cmd": cmd, "headers": headers}
|
||||
if body is not None:
|
||||
payload["body"] = body
|
||||
|
||||
async with self._command_lock:
|
||||
max_retries = 3
|
||||
for attempt in range(max_retries + 1):
|
||||
response = await self._send_and_wait_response(req_id, payload)
|
||||
if not response:
|
||||
if attempt < max_retries:
|
||||
await asyncio.sleep(min(0.2 * (2**attempt), 2.0))
|
||||
continue
|
||||
return False
|
||||
|
||||
errcode = response.get("errcode")
|
||||
if errcode in (0, None):
|
||||
return True
|
||||
|
||||
if errcode == 6000 and attempt < max_retries:
|
||||
backoff = min(0.2 * (2**attempt), 2.0)
|
||||
logger.warning(
|
||||
"[WecomAI][LongConn] 命令冲突(errcode=6000),将重试。cmd=%s req_id=%s attempt=%d",
|
||||
cmd,
|
||||
req_id,
|
||||
attempt + 1,
|
||||
)
|
||||
await asyncio.sleep(backoff)
|
||||
continue
|
||||
|
||||
logger.warning(
|
||||
"[WecomAI][LongConn] 命令失败: cmd=%s req_id=%s errcode=%s errmsg=%s",
|
||||
cmd,
|
||||
req_id,
|
||||
errcode,
|
||||
response.get("errmsg"),
|
||||
)
|
||||
return False
|
||||
|
||||
return False
|
||||
|
||||
async def _send_and_wait_response(
|
||||
self,
|
||||
req_id: str,
|
||||
payload: dict[str, Any],
|
||||
timeout: float = 10.0,
|
||||
) -> dict[str, Any] | None:
|
||||
loop = asyncio.get_running_loop()
|
||||
waiter: asyncio.Future[dict[str, Any]] = loop.create_future()
|
||||
self._response_waiters[req_id] = waiter
|
||||
try:
|
||||
await self._send_json(payload)
|
||||
return await asyncio.wait_for(waiter, timeout=timeout)
|
||||
except TimeoutError:
|
||||
logger.warning(
|
||||
"[WecomAI][LongConn] 等待命令响应超时: cmd=%s req_id=%s",
|
||||
payload.get("cmd"),
|
||||
req_id,
|
||||
)
|
||||
return None
|
||||
finally:
|
||||
self._response_waiters.pop(req_id, None)
|
||||
|
||||
async def _send_json(self, payload: dict[str, Any]) -> None:
|
||||
ws = self._ws
|
||||
if ws is None or ws.closed:
|
||||
raise RuntimeError("长连接尚未建立")
|
||||
async with self._send_lock:
|
||||
await ws.send_json(payload)
|
||||
|
||||
async def shutdown(self) -> None:
|
||||
self._shutdown_event.set()
|
||||
ws = self._ws
|
||||
if ws is not None and not ws.closed:
|
||||
await ws.close()
|
||||
|
||||
session = self._session
|
||||
if session is not None and not session.closed:
|
||||
await session.close()
|
||||
@@ -21,8 +21,8 @@ from astrbot.core.utils.astrbot_path import get_astrbot_data_path
|
||||
|
||||
DEFAULT_MCP_CONFIG = {"mcpServers": {}}
|
||||
|
||||
DEFAULT_MCP_INIT_TIMEOUT_SECONDS = 20.0
|
||||
DEFAULT_ENABLE_MCP_TIMEOUT_SECONDS = 30.0
|
||||
DEFAULT_MCP_INIT_TIMEOUT_SECONDS = 180.0
|
||||
DEFAULT_ENABLE_MCP_TIMEOUT_SECONDS = 180.0
|
||||
MCP_INIT_TIMEOUT_ENV = "ASTRBOT_MCP_INIT_TIMEOUT"
|
||||
ENABLE_MCP_TIMEOUT_ENV = "ASTRBOT_MCP_ENABLE_TIMEOUT"
|
||||
MAX_MCP_TIMEOUT_SECONDS = 300.0
|
||||
@@ -417,9 +417,11 @@ class FunctionToolManager:
|
||||
for (name, cfg, _), result in zip(active_configs, results, strict=False):
|
||||
if isinstance(result, Exception):
|
||||
if isinstance(result, MCPInitTimeoutError):
|
||||
logger.error(f"MCP 服务 {name} 初始化超时({timeout_display}秒)")
|
||||
logger.error(
|
||||
f"Connected to MCP server {name} timeout ({timeout_display} seconds)"
|
||||
)
|
||||
else:
|
||||
logger.error(f"MCP 服务 {name} 初始化失败: {result}")
|
||||
logger.error(f"Failed to initialize MCP server {name}: {result}")
|
||||
self._log_safe_mcp_debug_config(cfg)
|
||||
failed_services.append(name)
|
||||
async with self._runtime_lock:
|
||||
@@ -430,16 +432,18 @@ class FunctionToolManager:
|
||||
|
||||
if failed_services:
|
||||
logger.warning(
|
||||
f"以下 MCP 服务初始化失败: {', '.join(failed_services)}。"
|
||||
f"请检查配置文件 mcp_server.json 和服务器可用性。"
|
||||
f"The following MCP services failed to initialize: {', '.join(failed_services)}. "
|
||||
f"Please check the mcp_server.json file and server availability."
|
||||
)
|
||||
|
||||
summary = MCPInitSummary(
|
||||
total=len(active_configs), success=success_count, failed=failed_services
|
||||
)
|
||||
logger.info(f"MCP 服务初始化完成: {summary.success}/{summary.total} 成功")
|
||||
logger.info(
|
||||
f"MCP services initialization completed: {summary.success}/{summary.total} successful, {len(summary.failed)} failed."
|
||||
)
|
||||
if summary.total > 0 and summary.success == 0:
|
||||
msg = "全部 MCP 服务初始化失败,请检查 mcp_server.json 配置和服务器可用性。"
|
||||
msg = "All MCP services failed to initialize, please check the mcp_server.json and server availability."
|
||||
if raise_on_all_failed:
|
||||
raise MCPAllServicesFailedError(msg)
|
||||
logger.error(msg)
|
||||
@@ -461,7 +465,7 @@ class FunctionToolManager:
|
||||
async with self._runtime_lock:
|
||||
if name in self._mcp_server_runtime or name in self._mcp_starting:
|
||||
logger.warning(
|
||||
f"MCP 服务 {name} 已在运行,忽略本次启用请求(timeout={timeout:g})。"
|
||||
f"Connected to MCP server {name}, ignoring this startup request (timeout={timeout:g})."
|
||||
)
|
||||
self._log_safe_mcp_debug_config(cfg)
|
||||
return
|
||||
@@ -478,10 +482,10 @@ class FunctionToolManager:
|
||||
)
|
||||
except asyncio.TimeoutError as exc:
|
||||
raise MCPInitTimeoutError(
|
||||
f"MCP 服务 {name} 初始化超时({timeout:g} 秒)"
|
||||
f"Connected to MCP server {name} timeout ({timeout:g} seconds)"
|
||||
) from exc
|
||||
except Exception:
|
||||
logger.error(f"初始化 MCP 客户端 {name} 失败", exc_info=True)
|
||||
logger.error(f"Failed to initialize MCP client {name}", exc_info=True)
|
||||
raise
|
||||
finally:
|
||||
if mcp_client is None:
|
||||
@@ -491,9 +495,9 @@ class FunctionToolManager:
|
||||
async def lifecycle() -> None:
|
||||
try:
|
||||
await shutdown_event.wait()
|
||||
logger.info(f"收到 MCP 客户端 {name} 终止信号")
|
||||
logger.info(f"Received shutdown signal for MCP client {name}")
|
||||
except asyncio.CancelledError:
|
||||
logger.debug(f"MCP 客户端 {name} 任务被取消")
|
||||
logger.debug(f"MCP client {name} task was cancelled")
|
||||
raise
|
||||
finally:
|
||||
await self._terminate_mcp_client(name)
|
||||
@@ -545,7 +549,7 @@ class FunctionToolManager:
|
||||
if strict:
|
||||
raise MCPShutdownTimeoutError(pending_names, timeout)
|
||||
logger.warning(
|
||||
"MCP 服务关闭超时(%s 秒),以下服务未完全关闭:%s",
|
||||
"MCP server shutdown timeout (%s seconds), the following servers were not fully closed: %s",
|
||||
f"{timeout:g}",
|
||||
", ".join(pending_names),
|
||||
)
|
||||
@@ -568,7 +572,9 @@ class FunctionToolManager:
|
||||
try:
|
||||
await mcp_client.cleanup()
|
||||
except Exception as cleanup_exc: # noqa: BLE001 - only log here
|
||||
logger.error(f"清理 MCP 客户端资源 {name} 失败: {cleanup_exc}")
|
||||
logger.error(
|
||||
f"Failed to cleanup MCP client resources {name}: {cleanup_exc}"
|
||||
)
|
||||
|
||||
async def _init_mcp_client(self, name: str, config: dict) -> MCPClient:
|
||||
"""初始化单个MCP客户端"""
|
||||
@@ -602,7 +608,7 @@ class FunctionToolManager:
|
||||
)
|
||||
self.func_list.append(func_tool)
|
||||
|
||||
logger.info(f"已连接 MCP 服务 {name}, Tools: {tool_names}")
|
||||
logger.info(f"Connected to MCP server {name}, Tools: {tool_names}")
|
||||
return mcp_client
|
||||
|
||||
async def _terminate_mcp_client(self, name: str) -> None:
|
||||
@@ -622,7 +628,7 @@ class FunctionToolManager:
|
||||
async with self._runtime_lock:
|
||||
self._mcp_server_runtime.pop(name, None)
|
||||
self._mcp_starting.discard(name)
|
||||
logger.info(f"已关闭 MCP 服务 {name}")
|
||||
logger.info(f"Disconnected from MCP server {name}")
|
||||
return
|
||||
|
||||
# Runtime missing but stale tools may still exist after failed flows.
|
||||
|
||||
@@ -79,6 +79,7 @@ class ProviderManager:
|
||||
self._provider_change_hooks: list[
|
||||
Callable[[str, ProviderType, str | None], None]
|
||||
] = []
|
||||
self._mcp_init_task: asyncio.Task | None = None
|
||||
|
||||
def set_provider_change_callback(
|
||||
self,
|
||||
@@ -330,24 +331,16 @@ class ProviderManager:
|
||||
if not self.curr_tts_provider_inst and self.tts_provider_insts:
|
||||
self.curr_tts_provider_inst = self.tts_provider_insts[0]
|
||||
|
||||
# 初始化 MCP Client 连接(等待完成以确保工具可用)
|
||||
strict_mcp_init = os.getenv("ASTRBOT_MCP_INIT_STRICT", "").strip().lower() in {
|
||||
"1",
|
||||
"true",
|
||||
"yes",
|
||||
"on",
|
||||
}
|
||||
mcp_init_summary = await self.llm_tools.init_mcp_clients(
|
||||
raise_on_all_failed=strict_mcp_init
|
||||
)
|
||||
if (
|
||||
mcp_init_summary.total > 0
|
||||
and mcp_init_summary.success == 0
|
||||
and not strict_mcp_init
|
||||
):
|
||||
logger.warning(
|
||||
"MCP 服务全部初始化失败,系统将继续启动(可设置 "
|
||||
"ASTRBOT_MCP_INIT_STRICT=1 以在此场景下中止启动)。"
|
||||
async def _init_mcp_clients_bg() -> None:
|
||||
try:
|
||||
await self.llm_tools.init_mcp_clients()
|
||||
except Exception:
|
||||
logger.error("MCP init background task failed", exc_info=True)
|
||||
|
||||
if self._mcp_init_task is None or self._mcp_init_task.done():
|
||||
self._mcp_init_task = asyncio.create_task(
|
||||
_init_mcp_clients_bg(),
|
||||
name="provider-manager:mcp-init",
|
||||
)
|
||||
|
||||
def dynamic_import_provider(self, type: str) -> None:
|
||||
@@ -817,6 +810,13 @@ class ProviderManager:
|
||||
await self.load_provider(new_config)
|
||||
|
||||
async def terminate(self) -> None:
|
||||
if self._mcp_init_task and not self._mcp_init_task.done():
|
||||
self._mcp_init_task.cancel()
|
||||
try:
|
||||
await self._mcp_init_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
for provider_inst in self.provider_insts:
|
||||
if hasattr(provider_inst, "terminate"):
|
||||
await provider_inst.terminate() # type: ignore
|
||||
|
||||
@@ -281,7 +281,24 @@ class TTSProvider(AbstractProvider):
|
||||
accumulated_text += text_part
|
||||
|
||||
async def test(self) -> None:
|
||||
await self.get_audio("hi")
|
||||
audio_path = await self.get_audio("hi")
|
||||
|
||||
# 检查生成的音频文件是否有效
|
||||
if not os.path.exists(audio_path):
|
||||
raise Exception("TTS test failed: audio file was not created")
|
||||
|
||||
file_size = os.path.getsize(audio_path)
|
||||
if file_size == 0:
|
||||
raise Exception(
|
||||
"TTS test failed: generated audio file is empty (0 bytes). "
|
||||
"Please check your TTS provider configuration, especially required parameters like group_id for MiniMax."
|
||||
)
|
||||
|
||||
# 清理测试文件
|
||||
try:
|
||||
os.remove(audio_path)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
class EmbeddingProvider(AbstractProvider):
|
||||
|
||||
@@ -20,6 +20,7 @@ from ..register import register_provider_adapter
|
||||
|
||||
TEMP_DIR = Path(get_astrbot_temp_path()) / "azure_tts"
|
||||
TEMP_DIR.mkdir(parents=True, exist_ok=True)
|
||||
AZURE_TTS_SUBSCRIPTION_KEY_PATTERN = r"^(?:[a-zA-Z0-9]{32}|[a-zA-Z0-9]{84})$"
|
||||
|
||||
|
||||
class OTTSProvider:
|
||||
@@ -116,7 +117,7 @@ class AzureNativeProvider(TTSProvider):
|
||||
"azure_tts_subscription_key",
|
||||
"",
|
||||
).strip()
|
||||
if not re.fullmatch(r"^[a-zA-Z0-9]{32}$", self.subscription_key):
|
||||
if not re.fullmatch(AZURE_TTS_SUBSCRIPTION_KEY_PATTERN, self.subscription_key):
|
||||
raise ValueError("无效的Azure订阅密钥")
|
||||
self.region = provider_config.get("azure_tts_region", "eastus").strip()
|
||||
self.endpoint = (
|
||||
@@ -235,9 +236,9 @@ class AzureTTSProvider(TTSProvider):
|
||||
raise ValueError(error_msg) from e
|
||||
except KeyError as e:
|
||||
raise ValueError(f"配置错误: 缺少必要参数 {e}") from e
|
||||
if re.fullmatch(r"^[a-zA-Z0-9]{32}$", key_value):
|
||||
if re.fullmatch(AZURE_TTS_SUBSCRIPTION_KEY_PATTERN, key_value):
|
||||
return AzureNativeProvider(config, self.provider_settings)
|
||||
raise ValueError("订阅密钥格式无效,应为32位字母数字或other[...]格式")
|
||||
raise ValueError("订阅密钥格式无效,应为32位或84位字母数字或other[...]格式")
|
||||
|
||||
async def get_audio(self, text: str) -> str:
|
||||
if isinstance(self.provider, OTTSProvider):
|
||||
|
||||
@@ -154,6 +154,14 @@ class ProviderMiniMaxTTSAPI(TTSProvider):
|
||||
audio_stream = self._call_tts_stream(text)
|
||||
audio = await self._audio_play(audio_stream)
|
||||
|
||||
# 检查音频数据是否为空
|
||||
if not audio or len(audio) == 0:
|
||||
raise Exception(
|
||||
"MiniMax TTS API returned empty audio data. "
|
||||
"Please verify your configuration, especially the 'group_id' parameter. "
|
||||
"You can find your group_id in Account Management -> Basic Information on the MiniMax platform."
|
||||
)
|
||||
|
||||
# 结果保存至文件
|
||||
with open(path, "wb") as file:
|
||||
file.write(audio)
|
||||
@@ -161,4 +169,4 @@ class ProviderMiniMaxTTSAPI(TTSProvider):
|
||||
return path
|
||||
|
||||
except aiohttp.ClientError as e:
|
||||
raise e
|
||||
raise Exception(f"MiniMax TTS API request failed: {e!s}")
|
||||
|
||||
@@ -3,6 +3,7 @@ from __future__ import annotations
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import shlex
|
||||
import shutil
|
||||
import tempfile
|
||||
import zipfile
|
||||
@@ -79,7 +80,59 @@ 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"[^A-Za-z0-9_./ -]")
|
||||
_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}"
|
||||
|
||||
|
||||
def build_skills_prompt(skills: list[SkillInfo]) -> str:
|
||||
@@ -92,16 +145,37 @@ 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"- **{skill.name}**: {description}\n File: `{skill.path}`"
|
||||
f"- **{display_name}**: {description}\n File: `{rendered_path}`"
|
||||
)
|
||||
if not example_path:
|
||||
example_path = skill.path
|
||||
example_path = rendered_path
|
||||
skills_block = "\n".join(skills_lines)
|
||||
# Sanitize example_path — it may originate from sandbox cache (untrusted)
|
||||
example_path = _SAFE_PATH_RE.sub("", example_path) if example_path else ""
|
||||
example_path = example_path or "<skills_root>/<skill_name>/SKILL.md"
|
||||
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)
|
||||
|
||||
return (
|
||||
"## Skills\n\n"
|
||||
@@ -119,8 +193,9 @@ 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 with the "
|
||||
f"**absolute path** shown above (e.g. `cat {example_path}`). "
|
||||
"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}`). "
|
||||
"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"
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
"""插件的重载、启停、安装、卸载等操作。"""
|
||||
|
||||
import asyncio
|
||||
import contextlib
|
||||
import functools
|
||||
import inspect
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
import traceback
|
||||
from types import ModuleType
|
||||
|
||||
@@ -14,7 +16,12 @@ import yaml
|
||||
from packaging.specifiers import InvalidSpecifier, SpecifierSet
|
||||
from packaging.version import InvalidVersion, Version
|
||||
|
||||
from astrbot.core import logger, pip_installer, sp
|
||||
from astrbot.core import (
|
||||
DependencyConflictError,
|
||||
logger,
|
||||
pip_installer,
|
||||
sp,
|
||||
)
|
||||
from astrbot.core.agent.handoff import FunctionTool, HandoffTool
|
||||
from astrbot.core.config.astrbot_config import AstrBotConfig
|
||||
from astrbot.core.config.default import VERSION
|
||||
@@ -24,9 +31,13 @@ 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,
|
||||
)
|
||||
|
||||
from . import StarMetadata
|
||||
from .command_management import sync_command_configs
|
||||
@@ -48,6 +59,97 @@ class PluginVersionIncompatibleError(Exception):
|
||||
"""Raised when plugin astrbot_version is incompatible with current AstrBot."""
|
||||
|
||||
|
||||
class PluginDependencyInstallError(Exception):
|
||||
"""Raised when plugin dependency installation fails."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
plugin_label: str,
|
||||
requirements_path: str,
|
||||
error: Exception,
|
||||
) -> None:
|
||||
message = f"插件 {plugin_label} 依赖安装失败: {error!s}"
|
||||
super().__init__(message)
|
||||
self.plugin_label = plugin_label
|
||||
self.requirements_path = requirements_path
|
||||
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:
|
||||
logger.info(
|
||||
f"正在安装插件 {plugin_label} 的依赖库(缺失依赖预检查不可裁剪,回退到完整安装): "
|
||||
f"{requirements_path}"
|
||||
)
|
||||
await pip_installer.install(requirements_path=requirements_path)
|
||||
return
|
||||
|
||||
if not install_plan.missing_names:
|
||||
logger.info(f"插件 {plugin_label} 的依赖已满足,跳过安装。")
|
||||
return
|
||||
|
||||
if not install_plan.install_lines:
|
||||
fallback_reason = install_plan.fallback_reason or "unknown reason"
|
||||
logger.info(
|
||||
"检测到插件 %s 缺失依赖,但无法安全裁剪 requirements,回退到完整安装: %s (%s)",
|
||||
plugin_label,
|
||||
requirements_path,
|
||||
fallback_reason,
|
||||
)
|
||||
await pip_installer.install(requirements_path=requirements_path)
|
||||
return
|
||||
|
||||
logger.info(
|
||||
f"检测到插件 {plugin_label} 缺失依赖,正在按 requirements.txt 安装: "
|
||||
f"{requirements_path} -> {sorted(install_plan.missing_names)}"
|
||||
)
|
||||
|
||||
with _temporary_filtered_requirements_file(
|
||||
install_lines=install_plan.install_lines,
|
||||
) as filtered_requirements_path:
|
||||
await pip_installer.install(requirements_path=filtered_requirements_path)
|
||||
|
||||
|
||||
class PluginManager:
|
||||
def __init__(self, context: Context, config: AstrBotConfig) -> None:
|
||||
from .star_tools import StarTools
|
||||
@@ -198,15 +300,37 @@ class PluginManager:
|
||||
to_update.append(p.root_dir_name)
|
||||
for p in to_update:
|
||||
plugin_path = os.path.join(plugin_dir, p)
|
||||
if os.path.exists(os.path.join(plugin_path, "requirements.txt")):
|
||||
pth = os.path.join(plugin_path, "requirements.txt")
|
||||
logger.info(f"正在安装插件 {p} 所需的依赖库: {pth}")
|
||||
try:
|
||||
await pip_installer.install(requirements_path=pth)
|
||||
except Exception as e:
|
||||
logger.error(f"更新插件 {p} 的依赖失败。Code: {e!s}")
|
||||
await self._ensure_plugin_requirements(plugin_path, p)
|
||||
return True
|
||||
|
||||
async def _ensure_plugin_requirements(
|
||||
self,
|
||||
plugin_dir_path: str,
|
||||
plugin_label: str,
|
||||
) -> None:
|
||||
requirements_path = os.path.join(plugin_dir_path, "requirements.txt")
|
||||
if not os.path.exists(requirements_path):
|
||||
return
|
||||
|
||||
try:
|
||||
await _install_requirements_with_precheck(
|
||||
plugin_label=plugin_label,
|
||||
requirements_path=requirements_path,
|
||||
)
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except DependencyConflictError as e:
|
||||
logger.error(f"插件 {plugin_label} 依赖冲突: {e!s}")
|
||||
raise
|
||||
except Exception as e:
|
||||
dependency_error = PluginDependencyInstallError(
|
||||
plugin_label=plugin_label,
|
||||
requirements_path=requirements_path,
|
||||
error=e,
|
||||
)
|
||||
logger.exception(str(dependency_error))
|
||||
raise dependency_error from e
|
||||
|
||||
async def _import_plugin_with_dependency_recovery(
|
||||
self,
|
||||
path: str,
|
||||
@@ -422,7 +546,7 @@ class PluginManager:
|
||||
root_dir_name: str,
|
||||
plugin_dir_path: str,
|
||||
reserved: bool,
|
||||
error: Exception | str,
|
||||
error: BaseException | str,
|
||||
error_trace: str,
|
||||
) -> dict:
|
||||
record: dict = {
|
||||
@@ -495,6 +619,9 @@ class PluginManager:
|
||||
|
||||
self._cleanup_plugin_state(dir_name)
|
||||
|
||||
plugin_path = os.path.join(self.plugin_store_path, dir_name)
|
||||
await self._ensure_plugin_requirements(plugin_path, dir_name)
|
||||
|
||||
success, error = await self.load(specified_dir_name=dir_name)
|
||||
if success:
|
||||
self.failed_plugin_dict.pop(dir_name, None)
|
||||
@@ -1078,6 +1205,10 @@ class PluginManager:
|
||||
|
||||
# reload the plugin
|
||||
dir_name = os.path.basename(plugin_path)
|
||||
await self._ensure_plugin_requirements(
|
||||
plugin_path,
|
||||
dir_name,
|
||||
)
|
||||
success, error_message = await self.load(
|
||||
specified_dir_name=dir_name,
|
||||
ignore_version_check=ignore_version_check,
|
||||
@@ -1317,6 +1448,12 @@ class PluginManager:
|
||||
raise Exception("该插件是 AstrBot 保留插件,无法更新。")
|
||||
|
||||
await self.updator.update(plugin, proxy=proxy)
|
||||
if plugin.root_dir_name:
|
||||
plugin_dir_path = os.path.join(self.plugin_store_path, plugin.root_dir_name)
|
||||
await self._ensure_plugin_requirements(
|
||||
plugin_dir_path,
|
||||
plugin_name,
|
||||
)
|
||||
await self.reload(plugin_name)
|
||||
|
||||
async def turn_off_plugin(self, plugin_name: str) -> None:
|
||||
@@ -1488,6 +1625,7 @@ class PluginManager:
|
||||
os.remove(zip_file_path)
|
||||
except BaseException as e:
|
||||
logger.warning(f"删除插件压缩包失败: {e!s}")
|
||||
await self._ensure_plugin_requirements(desti_dir, dir_name)
|
||||
# await self.reload()
|
||||
success, error_message = await self.load(
|
||||
specified_dir_name=dir_name,
|
||||
|
||||
@@ -30,7 +30,7 @@ class CreateActiveCronTool(FunctionTool[AstrAgentContext]):
|
||||
"properties": {
|
||||
"cron_expression": {
|
||||
"type": "string",
|
||||
"description": "Cron expression defining recurring schedule (e.g., '0 8 * * *').",
|
||||
"description": "Cron expression defining recurring schedule (e.g., '0 8 * * *' or '0 23 * * mon-fri'). Prefer named weekdays like 'mon-fri' or 'sat,sun' instead of numeric day-of-week ranges such as '1-5' to avoid ambiguity across cron implementations.",
|
||||
},
|
||||
"run_at": {
|
||||
"type": "string",
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
import contextlib
|
||||
import functools
|
||||
import importlib.metadata as importlib_metadata
|
||||
import logging
|
||||
import os
|
||||
from collections.abc import Iterator
|
||||
|
||||
from packaging.requirements import Requirement
|
||||
|
||||
from astrbot.core.utils.requirements_utils import (
|
||||
canonicalize_distribution_name,
|
||||
collect_installed_distribution_versions,
|
||||
get_requirement_check_paths,
|
||||
)
|
||||
|
||||
logger = logging.getLogger("astrbot")
|
||||
|
||||
|
||||
def _resolve_core_dist_name(core_dist_name: str | None) -> str | None:
|
||||
if core_dist_name:
|
||||
try:
|
||||
importlib_metadata.distribution(core_dist_name)
|
||||
return core_dist_name
|
||||
except importlib_metadata.PackageNotFoundError:
|
||||
return None
|
||||
|
||||
try:
|
||||
importlib_metadata.distribution("AstrBot")
|
||||
return "AstrBot"
|
||||
except importlib_metadata.PackageNotFoundError:
|
||||
pass
|
||||
|
||||
if not __package__:
|
||||
return None
|
||||
|
||||
top_pkg = __package__.split(".")[0]
|
||||
for dist in importlib_metadata.distributions():
|
||||
try:
|
||||
top_level = dist.read_text("top_level.txt") or ""
|
||||
except Exception:
|
||||
continue
|
||||
if top_pkg in top_level.splitlines():
|
||||
if "Name" in dist.metadata:
|
||||
return dist.metadata["Name"]
|
||||
|
||||
return None
|
||||
|
||||
|
||||
@functools.cache
|
||||
def _get_core_constraints(core_dist_name: str | None) -> tuple[str, ...]:
|
||||
try:
|
||||
resolved_core_dist_name = _resolve_core_dist_name(core_dist_name)
|
||||
except Exception as exc:
|
||||
logger.warning("解析核心分发名称失败: %s", exc)
|
||||
return ()
|
||||
|
||||
if not resolved_core_dist_name:
|
||||
return ()
|
||||
|
||||
try:
|
||||
dist = importlib_metadata.distribution(resolved_core_dist_name)
|
||||
except importlib_metadata.PackageNotFoundError:
|
||||
return ()
|
||||
except Exception as exc:
|
||||
logger.warning("读取核心分发元数据失败 (%s): %s", resolved_core_dist_name, exc)
|
||||
return ()
|
||||
|
||||
if not dist or not dist.requires:
|
||||
return ()
|
||||
|
||||
installed = collect_installed_distribution_versions(get_requirement_check_paths())
|
||||
if not installed:
|
||||
return ()
|
||||
|
||||
constraints: list[str] = []
|
||||
for req_str in dist.requires:
|
||||
try:
|
||||
req = Requirement(req_str)
|
||||
if req.marker and not req.marker.evaluate():
|
||||
continue
|
||||
name = canonicalize_distribution_name(req.name)
|
||||
if name in installed:
|
||||
constraints.append(f"{name}=={installed[name]}")
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
return tuple(constraints)
|
||||
|
||||
|
||||
class CoreConstraintsProvider:
|
||||
def __init__(self, core_dist_name: str | None) -> None:
|
||||
self._core_dist_name = core_dist_name
|
||||
|
||||
@contextlib.contextmanager
|
||||
def constraints_file(self) -> Iterator[str | None]:
|
||||
constraints = _get_core_constraints(self._core_dist_name)
|
||||
if not constraints:
|
||||
yield None
|
||||
return
|
||||
|
||||
path: str | None = None
|
||||
try:
|
||||
import tempfile
|
||||
|
||||
with tempfile.NamedTemporaryFile(
|
||||
mode="w", suffix="_constraints.txt", delete=False, encoding="utf-8"
|
||||
) as f:
|
||||
f.write("\n".join(constraints))
|
||||
path = f.name
|
||||
logger.info("已启用核心依赖版本保护 (%d 个约束)", len(constraints))
|
||||
except Exception as exc:
|
||||
logger.warning("创建临时约束文件失败: %s", exc)
|
||||
yield None
|
||||
return
|
||||
|
||||
try:
|
||||
yield path
|
||||
finally:
|
||||
if path and os.path.exists(path):
|
||||
with contextlib.suppress(Exception):
|
||||
os.remove(path)
|
||||
@@ -7,21 +7,71 @@ import io
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import shlex
|
||||
import sys
|
||||
import threading
|
||||
from collections import deque
|
||||
from dataclasses import dataclass
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from astrbot.core.utils.astrbot_path import get_astrbot_site_packages_path
|
||||
from astrbot.core.utils.core_constraints import CoreConstraintsProvider
|
||||
from astrbot.core.utils.requirements_utils import (
|
||||
canonicalize_distribution_name as _canonicalize_distribution_name,
|
||||
)
|
||||
from astrbot.core.utils.requirements_utils import (
|
||||
extract_requirement_name,
|
||||
extract_requirement_names,
|
||||
parse_package_install_input,
|
||||
)
|
||||
from astrbot.core.utils.runtime_env import is_packaged_desktop_runtime
|
||||
|
||||
logger = logging.getLogger("astrbot")
|
||||
|
||||
_DISTLIB_FINDER_PATCH_ATTEMPTED = False
|
||||
_SITE_PACKAGES_IMPORT_LOCK = threading.RLock()
|
||||
_PIP_FAILURE_PATTERNS = {
|
||||
"error_prefix": re.compile(r"^\s*error:", re.IGNORECASE),
|
||||
"user_requested": re.compile(r"\bthe user requested\b", re.IGNORECASE),
|
||||
"resolution_impossible": re.compile(r"\bresolutionimpossible\b", re.IGNORECASE),
|
||||
"cannot_install": re.compile(r"\bcannot install\b", re.IGNORECASE),
|
||||
"conflict": re.compile(r"\bconflict(?:ing|s)?\b", re.IGNORECASE),
|
||||
"constraint": re.compile(r"\(constraint\)", re.IGNORECASE),
|
||||
"dependency_detail": re.compile(r"\bdepends on\b", re.IGNORECASE),
|
||||
}
|
||||
_SENSITIVE_PIP_VALUE_KEYS = frozenset(
|
||||
{"password", "passwd", "pass", "api_token", "token", "auth_token"}
|
||||
)
|
||||
_MAX_PIP_OUTPUT_LINES = 200
|
||||
|
||||
|
||||
def _canonicalize_distribution_name(name: str) -> str:
|
||||
return re.sub(r"[-_.]+", "-", name).strip("-").lower()
|
||||
class DependencyConflictError(Exception):
|
||||
"""Raised when pip encounters a dependency conflict."""
|
||||
|
||||
def __init__(
|
||||
self, message: str, errors: list[str], *, is_core_conflict: bool
|
||||
) -> None:
|
||||
super().__init__(message)
|
||||
self.errors = errors
|
||||
self.is_core_conflict = is_core_conflict
|
||||
|
||||
|
||||
class PipInstallError(Exception):
|
||||
"""Raised when pip install fails without a classified dependency conflict."""
|
||||
|
||||
def __init__(self, message: str, *, code: int) -> None:
|
||||
super().__init__(message)
|
||||
self.code = code
|
||||
|
||||
|
||||
@dataclass
|
||||
class PipConflictContext:
|
||||
relevant_lines: list[str]
|
||||
requested_lines: list[str]
|
||||
dependency_detail_lines: list[str]
|
||||
constraint_lines: list[str]
|
||||
has_strong_conflict_signal: bool
|
||||
has_contextual_conflict_signal: bool
|
||||
|
||||
|
||||
def _get_pip_main():
|
||||
@@ -41,11 +91,12 @@ def _get_pip_main():
|
||||
return pip_main
|
||||
|
||||
|
||||
def _run_pip_main_with_output(pip_main, args: list[str]) -> tuple[int, str]:
|
||||
stream = io.StringIO()
|
||||
with contextlib.redirect_stdout(stream), contextlib.redirect_stderr(stream):
|
||||
result_code = pip_main(args)
|
||||
return result_code, stream.getvalue()
|
||||
def _prepend_sys_path(path: str) -> None:
|
||||
normalized_target = os.path.realpath(path)
|
||||
sys.path[:] = [
|
||||
item for item in sys.path if os.path.realpath(item) != normalized_target
|
||||
]
|
||||
sys.path.insert(0, normalized_target)
|
||||
|
||||
|
||||
def _cleanup_added_root_handlers(original_handlers: list[logging.Handler]) -> None:
|
||||
@@ -59,76 +110,258 @@ def _cleanup_added_root_handlers(original_handlers: list[logging.Handler]) -> No
|
||||
handler.close()
|
||||
|
||||
|
||||
def _prepend_sys_path(path: str) -> None:
|
||||
normalized_target = os.path.realpath(path)
|
||||
sys.path[:] = [
|
||||
item for item in sys.path if os.path.realpath(item) != normalized_target
|
||||
]
|
||||
sys.path.insert(0, normalized_target)
|
||||
def _get_trusted_host_for_index_url(index_url: str) -> str | None:
|
||||
parsed = urlparse(index_url if "://" in index_url else f"//{index_url}")
|
||||
host = parsed.hostname
|
||||
if host == "mirrors.aliyun.com":
|
||||
return host
|
||||
return None
|
||||
|
||||
|
||||
def _module_exists_in_site_packages(module_name: str, site_packages_path: str) -> bool:
|
||||
base_path = os.path.join(site_packages_path, *module_name.split("."))
|
||||
package_init = os.path.join(base_path, "__init__.py")
|
||||
module_file = f"{base_path}.py"
|
||||
return os.path.isfile(package_init) or os.path.isfile(module_file)
|
||||
def _normalize_sensitive_pip_key(raw_key: str) -> str:
|
||||
return raw_key.lstrip("-").replace("-", "_").lower()
|
||||
|
||||
|
||||
def _is_module_loaded_from_site_packages(
|
||||
module_name: str,
|
||||
site_packages_path: str,
|
||||
) -> bool:
|
||||
module = sys.modules.get(module_name)
|
||||
if module is None:
|
||||
try:
|
||||
module = importlib.import_module(module_name)
|
||||
except Exception:
|
||||
return False
|
||||
def _is_sensitive_pip_value_key(raw_key: str) -> bool:
|
||||
return _normalize_sensitive_pip_key(raw_key) in _SENSITIVE_PIP_VALUE_KEYS
|
||||
|
||||
module_file = getattr(module, "__file__", None)
|
||||
if not module_file:
|
||||
return False
|
||||
|
||||
module_path = os.path.realpath(module_file)
|
||||
site_packages_real = os.path.realpath(site_packages_path)
|
||||
try:
|
||||
return (
|
||||
os.path.commonpath([module_path, site_packages_real]) == site_packages_real
|
||||
def _redact_url_credentials(raw_value: str) -> str:
|
||||
"""Redact URL credentials and known inline secret values for safe logging."""
|
||||
parsed = urlparse(raw_value)
|
||||
if parsed.netloc and "@" in parsed.netloc:
|
||||
hostname = parsed.hostname or ""
|
||||
port = f":{parsed.port}" if parsed.port else ""
|
||||
return parsed._replace(netloc=f"<redacted>@{hostname}{port}").geturl()
|
||||
|
||||
if raw_value.startswith("--"):
|
||||
option, separator, _ = raw_value.partition("=")
|
||||
if separator and _is_sensitive_pip_value_key(option):
|
||||
return f"{option}=****"
|
||||
return raw_value
|
||||
|
||||
key, separator, _ = raw_value.partition("=")
|
||||
if separator and _is_sensitive_pip_value_key(key):
|
||||
return f"{key}=****"
|
||||
|
||||
return raw_value
|
||||
|
||||
|
||||
def _redact_pip_args_for_logging(args: list[str]) -> list[str]:
|
||||
redacted_args: list[str] = []
|
||||
redact_next_value = False
|
||||
|
||||
for arg in args:
|
||||
if redact_next_value:
|
||||
redacted_args.append("****")
|
||||
redact_next_value = False
|
||||
continue
|
||||
|
||||
if arg.startswith("--") and "=" in arg:
|
||||
option, value = arg.split("=", 1)
|
||||
if _is_sensitive_pip_value_key(option):
|
||||
redacted_args.append(f"{option}=****")
|
||||
else:
|
||||
redacted_args.append(f"{option}={_redact_url_credentials(value)}")
|
||||
continue
|
||||
|
||||
if arg.startswith("-i") and arg != "-i":
|
||||
redacted_args.append(f"-i{_redact_url_credentials(arg[2:])}")
|
||||
continue
|
||||
|
||||
if _is_sensitive_pip_value_key(arg):
|
||||
redacted_args.append(arg)
|
||||
redact_next_value = True
|
||||
continue
|
||||
|
||||
redacted_args.append(_redact_url_credentials(arg))
|
||||
|
||||
return redacted_args
|
||||
|
||||
|
||||
def _package_specs_override_index(package_specs: list[str]) -> bool:
|
||||
for index, spec in enumerate(package_specs):
|
||||
if spec == "--no-index":
|
||||
return True
|
||||
if spec in {"-i", "--index-url"}:
|
||||
if index + 1 < len(package_specs):
|
||||
return True
|
||||
continue
|
||||
if spec.startswith("--index-url="):
|
||||
return True
|
||||
if spec.startswith("-i") and spec != "-i":
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
class _StreamingLogWriter(io.TextIOBase):
|
||||
def __init__(self, log_func, *, max_lines: int | None = None) -> None:
|
||||
self._log_func = log_func
|
||||
self._lines = deque(maxlen=max_lines or _MAX_PIP_OUTPUT_LINES)
|
||||
self._buffer = ""
|
||||
|
||||
def write(self, text: str) -> int:
|
||||
if not text:
|
||||
return 0
|
||||
|
||||
self._buffer += text.replace("\r\n", "\n").replace("\r", "\n")
|
||||
while "\n" in self._buffer:
|
||||
raw_line, self._buffer = self._buffer.split("\n", 1)
|
||||
line = raw_line.rstrip("\r\n")
|
||||
self._log_func(line)
|
||||
self._lines.append(line)
|
||||
return len(text)
|
||||
|
||||
def flush(self) -> None:
|
||||
line = self._buffer.rstrip("\r\n")
|
||||
if line:
|
||||
self._log_func(line)
|
||||
self._lines.append(line)
|
||||
self._buffer = ""
|
||||
|
||||
@property
|
||||
def lines(self) -> list[str]:
|
||||
return list(self._lines)
|
||||
|
||||
|
||||
def _run_pip_main_streaming(pip_main, args: list[str]) -> tuple[int, list[str]]:
|
||||
stream = _StreamingLogWriter(logger.info, max_lines=_MAX_PIP_OUTPUT_LINES)
|
||||
with (
|
||||
contextlib.redirect_stdout(stream),
|
||||
contextlib.redirect_stderr(stream),
|
||||
):
|
||||
result_code = pip_main(args)
|
||||
stream.flush()
|
||||
return result_code, stream.lines
|
||||
|
||||
|
||||
def _matches_pip_failure_pattern(line: str, *pattern_names: str) -> bool:
|
||||
names = pattern_names or tuple(_PIP_FAILURE_PATTERNS)
|
||||
return any(_PIP_FAILURE_PATTERNS[name].search(line) for name in names)
|
||||
|
||||
|
||||
def _normalize_conflict_detail_line(line: str) -> str:
|
||||
stripped = line.strip()
|
||||
if _matches_pip_failure_pattern(stripped, "user_requested"):
|
||||
return re.sub(
|
||||
r"^\s*The user requested\s+",
|
||||
"",
|
||||
stripped,
|
||||
flags=re.IGNORECASE,
|
||||
)
|
||||
except ValueError:
|
||||
return False
|
||||
return stripped
|
||||
|
||||
|
||||
def _extract_requirement_name(raw_requirement: str) -> str | None:
|
||||
line = raw_requirement.split("#", 1)[0].strip()
|
||||
if not line:
|
||||
return None
|
||||
if line.startswith(("-r", "--requirement", "-c", "--constraint")):
|
||||
return None
|
||||
if line.startswith("-"):
|
||||
def _build_pip_conflict_context(output_lines: list[str]) -> PipConflictContext | None:
|
||||
matched_indices = [
|
||||
index
|
||||
for index, line in enumerate(output_lines)
|
||||
if _matches_pip_failure_pattern(line)
|
||||
]
|
||||
if matched_indices:
|
||||
relevant_index_set: set[int] = set()
|
||||
for index in matched_indices:
|
||||
start = max(0, index - 1)
|
||||
end = min(len(output_lines), index + 2)
|
||||
relevant_index_set.update(range(start, end))
|
||||
relevant_output_lines = [
|
||||
line
|
||||
for index, line in enumerate(output_lines)
|
||||
if index in relevant_index_set
|
||||
]
|
||||
else:
|
||||
relevant_output_lines = output_lines[-5:]
|
||||
|
||||
if not relevant_output_lines:
|
||||
return None
|
||||
|
||||
egg_match = re.search(r"#egg=([A-Za-z0-9_.-]+)", raw_requirement)
|
||||
if egg_match:
|
||||
return _canonicalize_distribution_name(egg_match.group(1))
|
||||
dependency_detail_lines = [
|
||||
line.strip()
|
||||
for line in relevant_output_lines
|
||||
if _matches_pip_failure_pattern(line, "dependency_detail")
|
||||
]
|
||||
requested_lines = [
|
||||
line.strip()
|
||||
for line in relevant_output_lines
|
||||
if _matches_pip_failure_pattern(line, "user_requested")
|
||||
and not _matches_pip_failure_pattern(line, "constraint")
|
||||
]
|
||||
if not requested_lines:
|
||||
requested_lines = [
|
||||
line
|
||||
for line in dependency_detail_lines
|
||||
if not _matches_pip_failure_pattern(line, "constraint")
|
||||
]
|
||||
constraint_lines = [
|
||||
line.strip()
|
||||
for line in relevant_output_lines
|
||||
if _matches_pip_failure_pattern(line, "constraint")
|
||||
]
|
||||
|
||||
candidate = re.split(r"[<>=!~;\s\[]", line, maxsplit=1)[0].strip()
|
||||
if not candidate:
|
||||
has_strong_conflict_signal = any(
|
||||
_matches_pip_failure_pattern(
|
||||
line,
|
||||
"resolution_impossible",
|
||||
"cannot_install",
|
||||
)
|
||||
for line in relevant_output_lines
|
||||
)
|
||||
|
||||
has_contextual_conflict_signal = any(
|
||||
_matches_pip_failure_pattern(line, "conflict") for line in relevant_output_lines
|
||||
) and bool(dependency_detail_lines or requested_lines or constraint_lines)
|
||||
|
||||
return PipConflictContext(
|
||||
relevant_lines=relevant_output_lines,
|
||||
requested_lines=requested_lines,
|
||||
dependency_detail_lines=dependency_detail_lines,
|
||||
constraint_lines=constraint_lines,
|
||||
has_strong_conflict_signal=has_strong_conflict_signal,
|
||||
has_contextual_conflict_signal=has_contextual_conflict_signal,
|
||||
)
|
||||
|
||||
|
||||
def _classify_pip_failure(output_lines: list[str]) -> DependencyConflictError | None:
|
||||
context = _build_pip_conflict_context(output_lines)
|
||||
if context is None:
|
||||
return None
|
||||
return _canonicalize_distribution_name(candidate)
|
||||
|
||||
if (
|
||||
not context.has_strong_conflict_signal
|
||||
and not context.has_contextual_conflict_signal
|
||||
and not (context.requested_lines and context.constraint_lines)
|
||||
):
|
||||
return None
|
||||
|
||||
def _extract_requirement_names(requirements_path: str) -> set[str]:
|
||||
names: set[str] = set()
|
||||
try:
|
||||
with open(requirements_path, encoding="utf-8") as requirements_file:
|
||||
for line in requirements_file:
|
||||
requirement_name = _extract_requirement_name(line)
|
||||
if requirement_name:
|
||||
names.add(requirement_name)
|
||||
except Exception as exc:
|
||||
logger.warning("读取依赖文件失败,跳过冲突检测: %s", exc)
|
||||
return names
|
||||
is_core_conflict = bool(context.constraint_lines)
|
||||
|
||||
detail = ""
|
||||
if context.constraint_lines and context.requested_lines:
|
||||
detail = (
|
||||
" 冲突详情: "
|
||||
f"{_normalize_conflict_detail_line(context.requested_lines[0])} vs "
|
||||
f"{_normalize_conflict_detail_line(context.constraint_lines[0])}。"
|
||||
)
|
||||
elif len(context.dependency_detail_lines) >= 2:
|
||||
detail = (
|
||||
" 冲突详情: "
|
||||
f"{_normalize_conflict_detail_line(context.dependency_detail_lines[0])} vs "
|
||||
f"{_normalize_conflict_detail_line(context.dependency_detail_lines[1])}。"
|
||||
)
|
||||
|
||||
if is_core_conflict:
|
||||
message = (
|
||||
f"检测到核心依赖版本保护冲突。{detail}插件要求的依赖版本与 AstrBot 核心不兼容,"
|
||||
"为了系统稳定,已阻止该降级行为。请联系插件作者或调整 requirements.txt。"
|
||||
)
|
||||
else:
|
||||
message = f"检测到依赖冲突。{detail}"
|
||||
|
||||
return DependencyConflictError(
|
||||
message,
|
||||
context.relevant_lines,
|
||||
is_core_conflict=is_core_conflict,
|
||||
)
|
||||
|
||||
|
||||
def _extract_top_level_modules(
|
||||
@@ -155,7 +388,11 @@ def _collect_candidate_modules(
|
||||
by_name: dict[str, list[importlib_metadata.Distribution]] = {}
|
||||
try:
|
||||
for distribution in importlib_metadata.distributions(path=[site_packages_path]):
|
||||
distribution_name = distribution.metadata.get("Name")
|
||||
distribution_name = (
|
||||
distribution.metadata["Name"]
|
||||
if "Name" in distribution.metadata
|
||||
else None
|
||||
)
|
||||
if not distribution_name:
|
||||
continue
|
||||
canonical_name = _canonicalize_distribution_name(distribution_name)
|
||||
@@ -173,7 +410,7 @@ def _collect_candidate_modules(
|
||||
|
||||
for distribution in by_name.get(requirement_name, []):
|
||||
for dependency_line in distribution.requires or []:
|
||||
dependency_name = _extract_requirement_name(dependency_line)
|
||||
dependency_name = extract_requirement_name(dependency_line)
|
||||
if not dependency_name:
|
||||
continue
|
||||
if dependency_name in expanded_requirement_names:
|
||||
@@ -230,6 +467,38 @@ def _ensure_preferred_modules(
|
||||
raise RuntimeError(conflict_message)
|
||||
|
||||
|
||||
def _module_exists_in_site_packages(module_name: str, site_packages_path: str) -> bool:
|
||||
base_path = os.path.join(site_packages_path, *module_name.split("."))
|
||||
package_init = os.path.join(base_path, "__init__.py")
|
||||
module_file = f"{base_path}.py"
|
||||
return os.path.isfile(package_init) or os.path.isfile(module_file)
|
||||
|
||||
|
||||
def _is_module_loaded_from_site_packages(
|
||||
module_name: str,
|
||||
site_packages_path: str,
|
||||
) -> bool:
|
||||
module = sys.modules.get(module_name)
|
||||
if module is None:
|
||||
try:
|
||||
module = importlib.import_module(module_name)
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
module_file = getattr(module, "__file__", None)
|
||||
if not module_file:
|
||||
return False
|
||||
|
||||
module_path = os.path.realpath(module_file)
|
||||
site_packages_real = os.path.realpath(site_packages_path)
|
||||
try:
|
||||
return (
|
||||
os.path.commonpath([module_path, site_packages_real]) == site_packages_real
|
||||
)
|
||||
except ValueError:
|
||||
return False
|
||||
|
||||
|
||||
def _prefer_module_from_site_packages(
|
||||
module_name: str, site_packages_path: str
|
||||
) -> bool:
|
||||
@@ -531,9 +800,63 @@ def _patch_distlib_finder_for_frozen_runtime() -> None:
|
||||
|
||||
|
||||
class PipInstaller:
|
||||
def __init__(self, pip_install_arg: str, pypi_index_url: str | None = None) -> None:
|
||||
def __init__(
|
||||
self,
|
||||
pip_install_arg: str,
|
||||
pypi_index_url: str | None = None,
|
||||
core_dist_name: str | None = "AstrBot",
|
||||
) -> None:
|
||||
self.pip_install_arg = pip_install_arg
|
||||
self.pypi_index_url = pypi_index_url
|
||||
self.core_dist_name = core_dist_name
|
||||
self._core_constraints = CoreConstraintsProvider(core_dist_name)
|
||||
|
||||
def _build_pip_args(
|
||||
self,
|
||||
package_name: str | None,
|
||||
requirements_path: str | None,
|
||||
mirror: str | None,
|
||||
) -> tuple[list[str], set[str]]:
|
||||
args: list[str] = []
|
||||
requested_requirements: set[str] = set()
|
||||
normalized_requirements_path = (
|
||||
requirements_path.strip() if requirements_path else ""
|
||||
)
|
||||
|
||||
if package_name and normalized_requirements_path:
|
||||
raise ValueError(
|
||||
"package_name and requirements_path cannot be used together"
|
||||
)
|
||||
|
||||
if package_name:
|
||||
parsed_package = parse_package_install_input(package_name)
|
||||
if parsed_package.specs:
|
||||
args = ["install", *parsed_package.specs]
|
||||
requested_requirements = set(parsed_package.requirement_names)
|
||||
elif normalized_requirements_path:
|
||||
args = ["install", "-r", normalized_requirements_path]
|
||||
requested_requirements = extract_requirement_names(
|
||||
normalized_requirements_path
|
||||
)
|
||||
|
||||
if not args:
|
||||
return [], requested_requirements
|
||||
|
||||
pip_install_args = (
|
||||
shlex.split(self.pip_install_arg) if self.pip_install_arg else []
|
||||
)
|
||||
|
||||
if not _package_specs_override_index([*args[1:], *pip_install_args]):
|
||||
index_url = mirror or self.pypi_index_url or "https://pypi.org/simple"
|
||||
trusted_host = _get_trusted_host_for_index_url(index_url)
|
||||
if trusted_host:
|
||||
args.extend(["--trusted-host", trusted_host])
|
||||
args.extend(["-i", index_url])
|
||||
|
||||
if pip_install_args:
|
||||
args.extend(pip_install_args)
|
||||
|
||||
return args, requested_requirements
|
||||
|
||||
async def install(
|
||||
self,
|
||||
@@ -541,36 +864,37 @@ class PipInstaller:
|
||||
requirements_path: str | None = None,
|
||||
mirror: str | None = None,
|
||||
) -> None:
|
||||
args = ["install"]
|
||||
requested_requirements: set[str] = set()
|
||||
if package_name:
|
||||
args.append(package_name)
|
||||
requirement_name = _extract_requirement_name(package_name)
|
||||
if requirement_name:
|
||||
requested_requirements.add(requirement_name)
|
||||
elif requirements_path:
|
||||
args.extend(["-r", requirements_path])
|
||||
requested_requirements = _extract_requirement_names(requirements_path)
|
||||
|
||||
index_url = mirror or self.pypi_index_url or "https://pypi.org/simple"
|
||||
args.extend(["--trusted-host", "mirrors.aliyun.com", "-i", index_url])
|
||||
args, requested_requirements = self._build_pip_args(
|
||||
package_name, requirements_path, mirror
|
||||
)
|
||||
if not args:
|
||||
logger.info("Pip 包管理器跳过安装:未提供有效的包名或 requirements 文件。")
|
||||
return
|
||||
|
||||
target_site_packages = None
|
||||
if is_packaged_desktop_runtime():
|
||||
target_site_packages = get_astrbot_site_packages_path()
|
||||
os.makedirs(target_site_packages, exist_ok=True)
|
||||
_prepend_sys_path(target_site_packages)
|
||||
args.extend(["--target", target_site_packages])
|
||||
args.extend(["--upgrade", "--force-reinstall"])
|
||||
args.extend(
|
||||
[
|
||||
"--target",
|
||||
target_site_packages,
|
||||
"--upgrade",
|
||||
"--upgrade-strategy",
|
||||
"only-if-needed",
|
||||
]
|
||||
)
|
||||
|
||||
if self.pip_install_arg:
|
||||
args.extend(self.pip_install_arg.split())
|
||||
with self._core_constraints.constraints_file() as constraints_file_path:
|
||||
if constraints_file_path:
|
||||
args.extend(["-c", constraints_file_path])
|
||||
|
||||
logger.info(f"Pip 包管理器: pip {' '.join(args)}")
|
||||
result_code = await self._run_pip_in_process(args)
|
||||
|
||||
if result_code != 0:
|
||||
raise Exception(f"安装失败,错误码:{result_code}")
|
||||
logger.info(
|
||||
"Pip 包管理器 argv: %s",
|
||||
["pip", *_redact_pip_args_for_logging(args)],
|
||||
)
|
||||
await self._run_pip_with_classification(args)
|
||||
|
||||
if target_site_packages:
|
||||
_prepend_sys_path(target_site_packages)
|
||||
@@ -589,7 +913,7 @@ class PipInstaller:
|
||||
if not os.path.isdir(target_site_packages):
|
||||
return
|
||||
|
||||
requested_requirements = _extract_requirement_names(requirements_path)
|
||||
requested_requirements = extract_requirement_names(requirements_path)
|
||||
if not requested_requirements:
|
||||
return
|
||||
|
||||
@@ -605,13 +929,21 @@ class PipInstaller:
|
||||
_patch_distlib_finder_for_frozen_runtime()
|
||||
|
||||
original_handlers = list(logging.getLogger().handlers)
|
||||
result_code, output = await asyncio.to_thread(
|
||||
_run_pip_main_with_output, pip_main, args
|
||||
)
|
||||
for line in output.splitlines():
|
||||
line = line.strip()
|
||||
if line:
|
||||
logger.info(line)
|
||||
try:
|
||||
result_code, output_lines = await asyncio.to_thread(
|
||||
_run_pip_main_streaming, pip_main, args
|
||||
)
|
||||
finally:
|
||||
_cleanup_added_root_handlers(original_handlers)
|
||||
|
||||
if result_code != 0:
|
||||
conflict = _classify_pip_failure(output_lines)
|
||||
if conflict:
|
||||
raise conflict
|
||||
|
||||
_cleanup_added_root_handlers(original_handlers)
|
||||
return result_code
|
||||
|
||||
async def _run_pip_with_classification(self, args: list[str]) -> None:
|
||||
result_code = await self._run_pip_in_process(args)
|
||||
if result_code != 0:
|
||||
raise PipInstallError(f"安装失败,错误码:{result_code}", code=result_code)
|
||||
|
||||
@@ -0,0 +1,486 @@
|
||||
import importlib.metadata as importlib_metadata
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import shlex
|
||||
import sys
|
||||
from collections.abc import Iterable, Iterator, Sequence
|
||||
from dataclasses import dataclass
|
||||
|
||||
from packaging.requirements import InvalidRequirement, Requirement
|
||||
from packaging.specifiers import SpecifierSet
|
||||
from packaging.version import InvalidVersion, Version
|
||||
|
||||
from astrbot.core.utils.astrbot_path import get_astrbot_site_packages_path
|
||||
from astrbot.core.utils.runtime_env import is_packaged_desktop_runtime
|
||||
|
||||
logger = logging.getLogger("astrbot")
|
||||
|
||||
|
||||
class RequirementsPrecheckFailed(Exception):
|
||||
"""Raised when the pre-check of requirements fails."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ParsedPackageInput:
|
||||
specs: tuple[str, ...]
|
||||
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()
|
||||
|
||||
|
||||
def strip_inline_requirement_comment(raw_input: str) -> str:
|
||||
if raw_input.lstrip().startswith("#"):
|
||||
return ""
|
||||
return re.split(r"[ \t]+#", raw_input, maxsplit=1)[0].strip()
|
||||
|
||||
|
||||
def _specifier_contains_version(specifier: SpecifierSet, version: str) -> bool:
|
||||
try:
|
||||
parsed_version = Version(version)
|
||||
except InvalidVersion:
|
||||
return False
|
||||
return specifier.contains(parsed_version, prereleases=True)
|
||||
|
||||
|
||||
def _looks_like_local_path_reference(token: str) -> bool:
|
||||
candidate = token.strip()
|
||||
if not candidate:
|
||||
return False
|
||||
return candidate in {".", ".."} or candidate.startswith(
|
||||
("./", "../", "/", "~/", ".\\", "..\\", "\\")
|
||||
)
|
||||
|
||||
|
||||
def looks_like_direct_reference(token: str) -> bool:
|
||||
candidate = token.strip()
|
||||
if not candidate:
|
||||
return False
|
||||
return (
|
||||
_looks_like_local_path_reference(candidate)
|
||||
or candidate.startswith("git+")
|
||||
or "://" in candidate
|
||||
)
|
||||
|
||||
|
||||
def extract_requirement_name(raw_requirement: str) -> str | None:
|
||||
line = raw_requirement.split("#", 1)[0].strip()
|
||||
if not line:
|
||||
return None
|
||||
if line.startswith(("-r", "--requirement", "-c", "--constraint")):
|
||||
return None
|
||||
|
||||
egg_match = re.search(r"#egg=([A-Za-z0-9_.-]+)", raw_requirement)
|
||||
if egg_match:
|
||||
return canonicalize_distribution_name(egg_match.group(1))
|
||||
|
||||
if line.startswith("-"):
|
||||
return None
|
||||
|
||||
candidate = re.split(r"[<>=!~;\s\[]", line, maxsplit=1)[0].strip()
|
||||
if not candidate:
|
||||
return None
|
||||
return canonicalize_distribution_name(candidate)
|
||||
|
||||
|
||||
def _parse_editable_or_direct_name(target: str) -> str | None:
|
||||
name = extract_requirement_name(target)
|
||||
if not name:
|
||||
return None
|
||||
if "#egg=" in target or not looks_like_direct_reference(target):
|
||||
return name
|
||||
return None
|
||||
|
||||
|
||||
def _parse_requirement_name_and_spec(
|
||||
line: str,
|
||||
) -> tuple[str | None, SpecifierSet | None]:
|
||||
if line.startswith(("-c", "--constraint")):
|
||||
return None, None
|
||||
|
||||
try:
|
||||
req = Requirement(line)
|
||||
except InvalidRequirement:
|
||||
tokens = shlex.split(line)
|
||||
if not tokens:
|
||||
return None, None
|
||||
|
||||
editable_target: str | None = None
|
||||
if tokens[0] in {"-e", "--editable"} and len(tokens) > 1:
|
||||
editable_target = tokens[1]
|
||||
elif tokens[0].startswith("--editable="):
|
||||
editable_target = tokens[0].split("=", 1)[1]
|
||||
|
||||
if editable_target:
|
||||
name = _parse_editable_or_direct_name(editable_target)
|
||||
return (name, None) if name else (None, None)
|
||||
|
||||
name = _parse_editable_or_direct_name(line)
|
||||
return (name, None) if name else (None, None)
|
||||
|
||||
if req.marker and not req.marker.evaluate():
|
||||
return None, None
|
||||
|
||||
return canonicalize_distribution_name(req.name), (req.specifier or None)
|
||||
|
||||
|
||||
def _parse_requirement_line(
|
||||
line: str,
|
||||
) -> tuple[str, SpecifierSet | None] | None:
|
||||
name, specifier = _parse_requirement_name_and_spec(line)
|
||||
return (name, specifier) if name else None
|
||||
|
||||
|
||||
def _extract_requirement_names_from_package_tokens(tokens: list[str]) -> frozenset[str]:
|
||||
requirement_names: set[str] = set()
|
||||
skip_next_for: str | None = None
|
||||
|
||||
for token in tokens:
|
||||
if skip_next_for:
|
||||
if skip_next_for == "editable":
|
||||
name = _parse_editable_or_direct_name(token)
|
||||
if name:
|
||||
requirement_names.add(name)
|
||||
skip_next_for = None
|
||||
continue
|
||||
|
||||
if token in {"-e", "--editable"}:
|
||||
skip_next_for = "editable"
|
||||
continue
|
||||
|
||||
if token in {
|
||||
"-i",
|
||||
"--index-url",
|
||||
"--extra-index-url",
|
||||
"-f",
|
||||
"--find-links",
|
||||
"--trusted-host",
|
||||
"-r",
|
||||
"--requirement",
|
||||
"-c",
|
||||
"--constraint",
|
||||
}:
|
||||
skip_next_for = "option-value"
|
||||
continue
|
||||
|
||||
if token.startswith(("--editable=",)):
|
||||
editable_target = token.split("=", 1)[1]
|
||||
name = _parse_editable_or_direct_name(editable_target)
|
||||
if name:
|
||||
requirement_names.add(name)
|
||||
continue
|
||||
|
||||
if token.startswith(
|
||||
(
|
||||
"--index-url=",
|
||||
"--extra-index-url=",
|
||||
"--find-links=",
|
||||
"--trusted-host=",
|
||||
"--requirement=",
|
||||
"--constraint=",
|
||||
)
|
||||
):
|
||||
continue
|
||||
|
||||
if (
|
||||
(token.startswith("-i") and token != "-i")
|
||||
or (token.startswith("-f") and token != "-f")
|
||||
or token == "--no-index"
|
||||
):
|
||||
continue
|
||||
|
||||
if token.startswith("-"):
|
||||
continue
|
||||
|
||||
name, _ = _parse_requirement_name_and_spec(token)
|
||||
if name:
|
||||
requirement_names.add(name)
|
||||
|
||||
return frozenset(requirement_names)
|
||||
|
||||
|
||||
def parse_package_install_input(raw_input: str) -> ParsedPackageInput:
|
||||
specs: list[str] = []
|
||||
requirement_names: set[str] = set()
|
||||
normalized = raw_input.strip()
|
||||
if not normalized:
|
||||
return ParsedPackageInput(specs=(), requirement_names=frozenset())
|
||||
|
||||
for raw_line in normalized.splitlines():
|
||||
line = strip_inline_requirement_comment(raw_line)
|
||||
if not line:
|
||||
continue
|
||||
|
||||
try:
|
||||
Requirement(line)
|
||||
except InvalidRequirement:
|
||||
tokens = shlex.split(line)
|
||||
if not tokens:
|
||||
continue
|
||||
specs.extend(tokens)
|
||||
requirement_names.update(
|
||||
_extract_requirement_names_from_package_tokens(tokens)
|
||||
)
|
||||
continue
|
||||
|
||||
specs.append(line)
|
||||
name, _ = _parse_requirement_name_and_spec(line)
|
||||
if name:
|
||||
requirement_names.add(name)
|
||||
|
||||
return ParsedPackageInput(
|
||||
specs=tuple(specs),
|
||||
requirement_names=frozenset(requirement_names),
|
||||
)
|
||||
|
||||
|
||||
def _iter_requirement_lines(
|
||||
requirements_path: str,
|
||||
_visited: set[str] | None = None,
|
||||
) -> Iterator[str]:
|
||||
visited = _visited or set()
|
||||
resolved_path = os.path.realpath(requirements_path)
|
||||
if resolved_path in visited:
|
||||
logger.warning(
|
||||
"检测到循环依赖的 requirements 包含: %s,将跳过该文件", resolved_path
|
||||
)
|
||||
return
|
||||
visited.add(resolved_path)
|
||||
|
||||
with open(resolved_path, encoding="utf-8") as f:
|
||||
for raw_line in f:
|
||||
line = strip_inline_requirement_comment(raw_line)
|
||||
if not line:
|
||||
continue
|
||||
|
||||
tokens = shlex.split(line)
|
||||
if not tokens:
|
||||
continue
|
||||
|
||||
nested: str | None = None
|
||||
if tokens[0] in {"-r", "--requirement"} and len(tokens) > 1:
|
||||
nested = tokens[1]
|
||||
elif tokens[0].startswith("--requirement="):
|
||||
nested = tokens[0].split("=", 1)[1]
|
||||
|
||||
if nested:
|
||||
if not os.path.isabs(nested):
|
||||
nested = os.path.join(os.path.dirname(resolved_path), nested)
|
||||
yield from _iter_requirement_lines(nested, _visited=visited)
|
||||
continue
|
||||
|
||||
yield line
|
||||
|
||||
|
||||
def iter_requirements(
|
||||
requirements_path: str | None = None,
|
||||
lines: Iterable[str] | None = None,
|
||||
) -> Iterator[tuple[str, SpecifierSet | None]]:
|
||||
if lines is None:
|
||||
if requirements_path is None:
|
||||
raise ValueError("Either requirements_path or lines must be provided")
|
||||
lines = _iter_requirement_lines(requirements_path)
|
||||
|
||||
for line in lines:
|
||||
parsed = _parse_requirement_line(line)
|
||||
if parsed is not None:
|
||||
yield parsed
|
||||
|
||||
|
||||
def extract_requirement_names(requirements_path: str) -> set[str]:
|
||||
try:
|
||||
return {
|
||||
name for name, _ in iter_requirements(requirements_path=requirements_path)
|
||||
}
|
||||
except Exception as exc:
|
||||
logger.warning("读取依赖文件失败,跳过冲突检测: %s", exc)
|
||||
return set()
|
||||
|
||||
|
||||
def get_requirement_check_paths() -> list[str]:
|
||||
paths = list(sys.path)
|
||||
if is_packaged_desktop_runtime():
|
||||
target_site_packages = get_astrbot_site_packages_path()
|
||||
if os.path.isdir(target_site_packages):
|
||||
paths.insert(0, target_site_packages)
|
||||
return paths
|
||||
|
||||
|
||||
def _canonical_distribution_identity(distribution) -> tuple[str | None, str | None]:
|
||||
distribution_name = (
|
||||
distribution.metadata["Name"] if "Name" in distribution.metadata else None
|
||||
)
|
||||
if not distribution_name:
|
||||
return None, None
|
||||
return canonicalize_distribution_name(distribution_name), distribution.version
|
||||
|
||||
|
||||
def collect_installed_distribution_versions(paths: list[str]) -> dict[str, str] | None:
|
||||
installed: dict[str, str] = {}
|
||||
try:
|
||||
for distribution in importlib_metadata.distributions(path=paths):
|
||||
distribution_name, version = _canonical_distribution_identity(distribution)
|
||||
if not distribution_name or not version:
|
||||
continue
|
||||
installed.setdefault(distribution_name, version)
|
||||
except Exception as exc:
|
||||
logger.warning("读取已安装依赖失败,跳过缺失依赖预检查: %s", exc)
|
||||
return None
|
||||
return installed
|
||||
|
||||
|
||||
def _load_requirement_lines_for_precheck(
|
||||
requirements_path: str,
|
||||
) -> tuple[bool, list[str] | None]:
|
||||
try:
|
||||
requirement_lines = list(_iter_requirement_lines(requirements_path))
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"预检查缺失依赖失败,将回退到完整安装: %s (%s)",
|
||||
requirements_path,
|
||||
exc,
|
||||
)
|
||||
return False, None
|
||||
|
||||
fallback_line = next(
|
||||
(
|
||||
line
|
||||
for line in requirement_lines
|
||||
if (
|
||||
(
|
||||
line.startswith(("-e ", "--editable ", "--editable="))
|
||||
and "#egg=" not in line
|
||||
)
|
||||
or (
|
||||
_parse_requirement_line(line) is None
|
||||
and looks_like_direct_reference(line)
|
||||
)
|
||||
)
|
||||
),
|
||||
None,
|
||||
)
|
||||
if fallback_line is not None:
|
||||
logger.info(
|
||||
"缺失依赖预检查发现无法安全裁剪的 option/direct-reference 行,将回退到完整安装: %s (%s)",
|
||||
requirements_path,
|
||||
fallback_line,
|
||||
)
|
||||
return False, None
|
||||
|
||||
return True, requirement_lines
|
||||
|
||||
|
||||
def find_missing_requirements(requirements_path: str) -> set[str] | None:
|
||||
can_precheck, requirement_lines = _load_requirement_lines_for_precheck(
|
||||
requirements_path
|
||||
)
|
||||
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()
|
||||
|
||||
installed = collect_installed_distribution_versions(get_requirement_check_paths())
|
||||
if installed is None:
|
||||
return None
|
||||
|
||||
missing: set[str] = set()
|
||||
for name, specifier in required:
|
||||
installed_version = installed.get(name)
|
||||
if not installed_version:
|
||||
missing.add(name)
|
||||
continue
|
||||
if specifier and not _specifier_contains_version(specifier, installed_version):
|
||||
missing.add(name)
|
||||
|
||||
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:
|
||||
raise RequirementsPrecheckFailed(f"预检查失败: {requirements_path}")
|
||||
return missing
|
||||
@@ -977,7 +977,17 @@ class BackupRoute(Route):
|
||||
if not jwt_secret:
|
||||
return Response().error("服务器配置错误").__dict__
|
||||
|
||||
jwt.decode(token, jwt_secret, algorithms=["HS256"])
|
||||
# Verify JWT token with strict security options
|
||||
jwt.decode(
|
||||
token,
|
||||
jwt_secret,
|
||||
algorithms=["HS256"],
|
||||
options={
|
||||
"require": ["exp"], # Require expiration claim
|
||||
"verify_signature": True, # Explicitly verify signature
|
||||
"verify_exp": True, # Verify expiration
|
||||
},
|
||||
)
|
||||
except jwt.ExpiredSignatureError:
|
||||
return Response().error("Token 已过期,请刷新页面后重试").__dict__
|
||||
except jwt.InvalidTokenError:
|
||||
|
||||
@@ -5,7 +5,8 @@ import os
|
||||
import ssl
|
||||
import traceback
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
import aiohttp
|
||||
import certifi
|
||||
@@ -352,6 +353,34 @@ class PluginRoute(Route):
|
||||
logger.warning(f"获取插件 Logo 失败: {e}")
|
||||
return None
|
||||
|
||||
def _resolve_plugin_dir(self, plugin) -> Path | None:
|
||||
if not plugin.root_dir_name:
|
||||
return None
|
||||
|
||||
base_dir = Path(
|
||||
self.plugin_manager.reserved_plugin_path
|
||||
if plugin.reserved
|
||||
else self.plugin_manager.plugin_store_path
|
||||
)
|
||||
plugin_dir = base_dir / plugin.root_dir_name
|
||||
if not plugin_dir.is_dir():
|
||||
return None
|
||||
return plugin_dir
|
||||
|
||||
def _get_plugin_installed_at(self, plugin) -> str | None:
|
||||
plugin_dir = self._resolve_plugin_dir(plugin)
|
||||
if plugin_dir is None:
|
||||
return None
|
||||
|
||||
try:
|
||||
return datetime.fromtimestamp(
|
||||
plugin_dir.stat().st_mtime,
|
||||
timezone.utc,
|
||||
).isoformat()
|
||||
except OSError as exc:
|
||||
logger.warning(f"获取插件安装时间失败 {plugin.name}: {exc!s}")
|
||||
return None
|
||||
|
||||
async def get_plugins(self):
|
||||
_plugin_resp = []
|
||||
plugin_name = request.args.get("name")
|
||||
@@ -377,6 +406,7 @@ class PluginRoute(Route):
|
||||
"logo": f"/api/file/{logo_url}" if logo_url else None,
|
||||
"support_platforms": plugin.support_platforms,
|
||||
"astrbot_version": plugin.astrbot_version,
|
||||
"installed_at": self._get_plugin_installed_at(plugin),
|
||||
}
|
||||
# 检查是否为全空的幽灵插件
|
||||
if not any(
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
## What's Changed
|
||||
|
||||
### 新增
|
||||
|
||||
- 企业微信智能机器人支持长连接模式。[#5930](https://github.com/AstrBotDevs/AstrBot/pull/5930)
|
||||
|
||||
### New
|
||||
|
||||
- Wecom AI Bot supports long-connection mode(Websockets). [#5930](https://github.com/AstrBotDevs/AstrBot/pull/5930)
|
||||
@@ -0,0 +1,43 @@
|
||||
## What's Changed
|
||||
|
||||
### 新增
|
||||
|
||||
- Lark 适配器支持 CardKit 流式输出(飞书)([#5777](https://github.com/AstrBotDevs/AstrBot/pull/5777))。
|
||||
- WebUI 已安装插件列表新增筛选与排序功能 ([#5923](https://github.com/AstrBotDevs/AstrBot/pull/5923))。
|
||||
|
||||
### 优化
|
||||
- 启动时后台加载 MCP Server,不阻塞加载流程 ([#5993](https://github.com/AstrBotDevs/AstrBot/pull/5993))。
|
||||
|
||||
### 修复
|
||||
|
||||
- 部分情况下 MCP 页报错 500 导致查看不了 MCP 服务器 ([#5993](https://github.com/AstrBotDevs/AstrBot/pull/5993))。
|
||||
- 修复 TTS Provider 测试:增加文件大小校验,并补充 MiniMax 空音频检测 ([#5999](https://github.com/AstrBotDevs/AstrBot/pull/5999))。
|
||||
- 修复前端切换到 Chat 后又回到 Welcome 时,页面切换配置未正确持久化的问题 ([#5792](https://github.com/AstrBotDevs/AstrBot/pull/5792))。
|
||||
- 修复 Azure TTS 不支持 84 位订阅密钥的问题 ([#5813](https://github.com/AstrBotDevs/AstrBot/pull/5813))。
|
||||
|
||||
### 文档
|
||||
|
||||
- 文档仓库迁移:将 `AstrBotDevs/AstrBot-docs` 内容迁移至 `AstrBotDevs/AstrBot` ([#5960](https://github.com/AstrBotDevs/AstrBot/pull/5960))。
|
||||
|
||||
---
|
||||
|
||||
## What's Changed (EN)
|
||||
|
||||
### New Features
|
||||
|
||||
- Added CardKit streaming output support for the Lark/Feishu adapter ([#5777](https://github.com/AstrBotDevs/AstrBot/pull/5777)).
|
||||
- Added filtering and sorting for installed plugins in the WebUI ([#5923](https://github.com/AstrBotDevs/AstrBot/pull/5923)).
|
||||
|
||||
### Impprovement
|
||||
- MCP Server now loads in the background during startup without blocking the loading process ([#5993](https://github.com/AstrBotDevs/AstrBot/pull/5993)).
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Added file size validation in TTS provider tests and MiniMax empty-audio detection ([#5999](https://github.com/AstrBotDevs/AstrBot/pull/5999)).
|
||||
- Fixed frontend state persistence when switching from Chat back to Welcome ([#5792](https://github.com/AstrBotDevs/AstrBot/pull/5792)).
|
||||
- Fixed Azure TTS support for 84-character subscription keys ([#5813](https://github.com/AstrBotDevs/AstrBot/pull/5813)).
|
||||
- Reverted the MCP stdio missing-command error wording change after the previous fix ([#5992](https://github.com/AstrBotDevs/AstrBot/pull/5992)).
|
||||
|
||||
### Documentation
|
||||
|
||||
- Migrated documentation content from `AstrBotDevs/AstrBot-docs` into `AstrBotDevs/AstrBot` ([#5960](https://github.com/AstrBotDevs/AstrBot/pull/5960)).
|
||||
@@ -0,0 +1,64 @@
|
||||
## What's Changed
|
||||
|
||||
### 新增
|
||||
|
||||
- 新增俄语翻译([#6081](https://github.com/AstrBotDevs/AstrBot/pull/6081))。
|
||||
- QQ 官方 Bot 新增文件、语音、视频消息支持(含 WebSocket 模式)([#6063](https://github.com/AstrBotDevs/AstrBot/pull/6063))。
|
||||
|
||||
### 优化
|
||||
|
||||
- 优化 QQ 官方 Bot 的流式消息投递可靠性与主动媒体发送能力([#6131](https://github.com/AstrBotDevs/AstrBot/pull/6131))。
|
||||
- 优化边界场景下 booter 选择逻辑与消息发送工具([#6064](https://github.com/AstrBotDevs/AstrBot/pull/6064))。
|
||||
|
||||
### 修复
|
||||
|
||||
- 修复 Dashboard README 对话框锚点导航失效([#6083](https://github.com/AstrBotDevs/AstrBot/pull/6083))。
|
||||
- 优先使用具名 weekday 的 cron 示例,避免歧义([#6091](https://github.com/AstrBotDevs/AstrBot/pull/6091))。
|
||||
- 修复插件市场安装后状态未及时刷新的问题([#6124](https://github.com/AstrBotDevs/AstrBot/pull/6124))。
|
||||
- 修复插件依赖安装逻辑:仅安装缺失依赖([#6088](https://github.com/AstrBotDevs/AstrBot/pull/6088))。
|
||||
- 移除 Telegram 适配器中已废弃的 `normalize_whitespace` 参数([#6044](https://github.com/AstrBotDevs/AstrBot/pull/6044))。
|
||||
- 修复 Windows 本地 skill 文件读取问题([#6028](https://github.com/AstrBotDevs/AstrBot/pull/6028))。
|
||||
- 修复 Discord pre-ack emoji 配置重启后不持久化的问题([#6031](https://github.com/AstrBotDevs/AstrBot/pull/6031))。
|
||||
- 统一 WebUI 搜索框清空行为([#6017](https://github.com/AstrBotDevs/AstrBot/pull/6017))。
|
||||
- 优化插件依赖自动安装流程与 Dashboard 安装体验([#5954](https://github.com/AstrBotDevs/AstrBot/pull/5954))。
|
||||
|
||||
|
||||
### 文档
|
||||
|
||||
- 新增 Astrbook 和玖帕喵社区链接([#6135](https://github.com/AstrBotDevs/AstrBot/pull/6135))。
|
||||
- 修正文档 `docker.md` 与 `napcat.md` 中的拼写错误([#6048](https://github.com/AstrBotDevs/AstrBot/pull/6048))。
|
||||
- 在多语言 README 中补充官方开发群号,并改进配置元数据中的正则说明。
|
||||
- 更新编辑链接模式并移除过时仓库引用。
|
||||
|
||||
---
|
||||
|
||||
## What's Changed (EN)
|
||||
|
||||
### New Features
|
||||
|
||||
- Added Russian translation support ([#6081](https://github.com/AstrBotDevs/AstrBot/pull/6081)).
|
||||
- Added file, voice, and video message support for QQ Official Bot (including WebSocket mode) ([#6063](https://github.com/AstrBotDevs/AstrBot/pull/6063)).
|
||||
|
||||
### Improvements
|
||||
|
||||
- Improved streaming message delivery reliability and proactive media sending for QQ Official API ([#6131](https://github.com/AstrBotDevs/AstrBot/pull/6131)).
|
||||
- Optimized booter selection logic in edge cases and message sending tooling ([#6064](https://github.com/AstrBotDevs/AstrBot/pull/6064)).
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Fixed broken README dialog anchor navigation in the Dashboard ([#6083](https://github.com/AstrBotDevs/AstrBot/pull/6083)).
|
||||
- Preferred named weekday cron examples to reduce ambiguity ([#6091](https://github.com/AstrBotDevs/AstrBot/pull/6091)).
|
||||
- Fixed plugin market install-state refresh after installation ([#6124](https://github.com/AstrBotDevs/AstrBot/pull/6124)).
|
||||
- Fixed plugin dependency installation logic to install only missing packages ([#6088](https://github.com/AstrBotDevs/AstrBot/pull/6088)).
|
||||
- Removed deprecated `normalize_whitespace` parameter in the Telegram adapter ([#6044](https://github.com/AstrBotDevs/AstrBot/pull/6044)).
|
||||
- Fixed local skill file reading issues on Windows ([#6028](https://github.com/AstrBotDevs/AstrBot/pull/6028)).
|
||||
- Fixed Discord pre-ack emoji config not being persisted across restarts ([#6031](https://github.com/AstrBotDevs/AstrBot/pull/6031)).
|
||||
- Unified WebUI search input clear behavior ([#6017](https://github.com/AstrBotDevs/AstrBot/pull/6017)).
|
||||
- Improved plugin dependency auto-install flow and Dashboard installation experience ([#5954](https://github.com/AstrBotDevs/AstrBot/pull/5954)).
|
||||
|
||||
### Documentation
|
||||
|
||||
- Added Astrbook and Jiupa Miao community links ([#6135](https://github.com/AstrBotDevs/AstrBot/pull/6135)).
|
||||
- Fixed typos in `docker.md` and `napcat.md` ([#6048](https://github.com/AstrBotDevs/AstrBot/pull/6048)).
|
||||
- Added official developer group IDs to multilingual READMEs and improved regex description in config metadata.
|
||||
- Updated edit-link patterns and removed obsolete repository references.
|
||||
+13
-7
@@ -17,17 +17,17 @@
|
||||
"@tiptap/starter-kit": "2.1.7",
|
||||
"@tiptap/vue-3": "2.1.7",
|
||||
"apexcharts": "3.42.0",
|
||||
"axios": ">=1.6.2 <1.10.0 || >1.10.0 <2.0.0",
|
||||
"axios": "1.13.5",
|
||||
"axios-mock-adapter": "^1.22.0",
|
||||
"chance": "1.1.11",
|
||||
"date-fns": "2.30.0",
|
||||
"dompurify": "^3.3.1",
|
||||
"dompurify": "^3.3.2",
|
||||
"event-source-polyfill": "^1.0.31",
|
||||
"highlight.js": "^11.11.1",
|
||||
"js-md5": "^0.8.3",
|
||||
"katex": "^0.16.27",
|
||||
"lodash": "4.17.21",
|
||||
"markdown-it": "^14.1.0",
|
||||
"lodash": "4.17.23",
|
||||
"markdown-it": "^14.1.1",
|
||||
"markstream-vue": "^0.0.6",
|
||||
"mermaid": "^11.12.2",
|
||||
"monaco-editor": "^0.52.2",
|
||||
@@ -38,7 +38,7 @@
|
||||
"stream-markdown": "^0.0.13",
|
||||
"stream-monaco": "^0.0.17",
|
||||
"vee-validate": "4.11.3",
|
||||
"vite-plugin-vuetify": "1.0.2",
|
||||
"vite-plugin-vuetify": "2.1.3",
|
||||
"vue": "3.3.4",
|
||||
"vue-i18n": "^11.1.5",
|
||||
"vue-router": "4.2.4",
|
||||
@@ -54,7 +54,7 @@
|
||||
"@types/dompurify": "^3.0.5",
|
||||
"@types/markdown-it": "^14.1.2",
|
||||
"@types/node": "^20.5.7",
|
||||
"@vitejs/plugin-vue": "4.3.3",
|
||||
"@vitejs/plugin-vue": "5.2.4",
|
||||
"@vue/eslint-config-prettier": "8.0.0",
|
||||
"@vue/eslint-config-typescript": "11.0.3",
|
||||
"@vue/tsconfig": "^0.4.0",
|
||||
@@ -64,9 +64,15 @@
|
||||
"sass": "1.66.1",
|
||||
"sass-loader": "13.3.2",
|
||||
"typescript": "5.1.6",
|
||||
"vite": "4.4.9",
|
||||
"vite": "6.4.1",
|
||||
"vue-cli-plugin-vuetify": "2.5.8",
|
||||
"vue-tsc": "1.8.8",
|
||||
"vuetify-loader": "^2.0.0-alpha.9"
|
||||
},
|
||||
"pnpm": {
|
||||
"overrides": {
|
||||
"immutable": "4.3.8",
|
||||
"lodash-es": "4.17.23"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+597
-267
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,97 @@
|
||||
<script setup>
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
items: {
|
||||
type: Array,
|
||||
required: true,
|
||||
},
|
||||
label: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
order: {
|
||||
type: String,
|
||||
default: "desc",
|
||||
},
|
||||
ascendingLabel: {
|
||||
type: String,
|
||||
default: "Ascending",
|
||||
},
|
||||
descendingLabel: {
|
||||
type: String,
|
||||
default: "Descending",
|
||||
},
|
||||
showOrder: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(["update:modelValue", "update:order"]);
|
||||
|
||||
const updateSortBy = (value) => {
|
||||
emit("update:modelValue", value);
|
||||
};
|
||||
|
||||
const toggleOrder = () => {
|
||||
emit("update:order", props.order === "desc" ? "asc" : "desc");
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="plugin-sort-control">
|
||||
<v-select
|
||||
:model-value="modelValue"
|
||||
:items="items"
|
||||
density="compact"
|
||||
variant="outlined"
|
||||
hide-details
|
||||
:label="label"
|
||||
class="plugin-sort-control__select"
|
||||
@update:model-value="updateSortBy"
|
||||
>
|
||||
<template #prepend-inner>
|
||||
<v-icon size="small">mdi-sort</v-icon>
|
||||
</template>
|
||||
</v-select>
|
||||
|
||||
<v-btn
|
||||
v-if="showOrder"
|
||||
icon
|
||||
variant="text"
|
||||
density="compact"
|
||||
@click="toggleOrder"
|
||||
>
|
||||
<v-icon>{{
|
||||
order === "desc" ? "mdi-arrow-down-thin" : "mdi-arrow-up-thin"
|
||||
}}</v-icon>
|
||||
<v-tooltip activator="parent" location="top">
|
||||
{{ order === "desc" ? descendingLabel : ascendingLabel }}
|
||||
</v-tooltip>
|
||||
</v-btn>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.plugin-sort-control {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.plugin-sort-control__select {
|
||||
min-width: 180px;
|
||||
max-width: 220px;
|
||||
}
|
||||
|
||||
.plugin-sort-control__select :deep(.v-field__input),
|
||||
.plugin-sort-control__select :deep(.v-field-label),
|
||||
.plugin-sort-control__select :deep(.v-select__selection-text),
|
||||
.plugin-sort-control__select :deep(.v-field__prepend-inner) {
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
</style>
|
||||
@@ -1,6 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { useModuleI18n } from '@/i18n/composables';
|
||||
import { normalizeTextInput } from '@/utils/inputValue';
|
||||
|
||||
const { tm } = useModuleI18n('features/command');
|
||||
|
||||
@@ -52,6 +53,7 @@ const statusItems = [
|
||||
{ title: tm('filters.disabled'), value: 'disabled' },
|
||||
{ title: tm('filters.conflict'), value: 'conflict' }
|
||||
];
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -108,10 +110,11 @@ const statusItems = [
|
||||
<div style="min-width: 200px; max-width: 350px; flex: 1; border: 1px solid #B9B9B9; border-radius: 16px;">
|
||||
<v-text-field
|
||||
:model-value="searchQuery"
|
||||
@update:model-value="emit('update:searchQuery', $event)"
|
||||
@update:model-value="emit('update:searchQuery', normalizeTextInput($event))"
|
||||
density="compact"
|
||||
:label="tm('search.placeholder')"
|
||||
prepend-inner-icon="mdi-magnify"
|
||||
clearable
|
||||
variant="solo-filled"
|
||||
flat
|
||||
hide-details
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
*/
|
||||
import { ref, computed, type Ref } from 'vue';
|
||||
import type { CommandItem, FilterState } from '../types';
|
||||
import { normalizeTextInput } from '@/utils/inputValue';
|
||||
|
||||
export function useCommandFilters(commands: Ref<CommandItem[]>) {
|
||||
// 过滤状态
|
||||
@@ -95,7 +96,7 @@ export function useCommandFilters(commands: Ref<CommandItem[]>) {
|
||||
* 过滤后的指令列表(支持层级结构)
|
||||
*/
|
||||
const filteredCommands = computed(() => {
|
||||
const query = searchQuery.value.toLowerCase();
|
||||
const query = normalizeTextInput(searchQuery.value).toLowerCase();
|
||||
const conflictCmds: CommandItem[] = [];
|
||||
const normalCmds: CommandItem[] = [];
|
||||
|
||||
@@ -184,4 +185,3 @@ export function useCommandFilters(commands: Ref<CommandItem[]>) {
|
||||
isGroupExpanded
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
import { computed, onActivated, onMounted, ref, watch} from 'vue';
|
||||
import axios from 'axios';
|
||||
import { useModuleI18n } from '@/i18n/composables';
|
||||
import { normalizeTextInput } from '@/utils/inputValue';
|
||||
|
||||
// Composables
|
||||
import { useComponentData } from './composables/useComponentData';
|
||||
@@ -83,7 +84,7 @@ const {
|
||||
} = useCommandActions(toast, () => fetchCommands(tm('messages.loadFailed')));
|
||||
|
||||
const filteredTools = computed(() => {
|
||||
const query = toolSearch.value.trim().toLowerCase();
|
||||
const query = normalizeTextInput(toolSearch.value).trim().toLowerCase();
|
||||
if (!query) return tools.value;
|
||||
return tools.value.filter(tool =>
|
||||
tool.name?.toLowerCase().includes(query) ||
|
||||
@@ -253,7 +254,8 @@ watch(viewMode, async (mode) => {
|
||||
<div class="d-flex flex-wrap align-center ga-3 mb-4">
|
||||
<div style="min-width: 240px; max-width: 380px; flex: 1;">
|
||||
<v-text-field
|
||||
v-model="toolSearch"
|
||||
:model-value="toolSearch"
|
||||
@update:model-value="toolSearch = normalizeTextInput($event)"
|
||||
prepend-inner-icon="mdi-magnify"
|
||||
:label="tmTool('functionTools.search')"
|
||||
variant="outlined"
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
v-model="modelSearchProxy"
|
||||
density="compact"
|
||||
prepend-inner-icon="mdi-magnify"
|
||||
clearable
|
||||
hide-details
|
||||
variant="solo-filled"
|
||||
flat
|
||||
@@ -161,6 +162,7 @@
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import { normalizeTextInput } from '@/utils/inputValue'
|
||||
|
||||
const props = defineProps({
|
||||
entries: {
|
||||
@@ -222,7 +224,7 @@ const emit = defineEmits([
|
||||
|
||||
const modelSearchProxy = computed({
|
||||
get: () => props.modelSearch,
|
||||
set: (val) => emit('update:modelSearch', val)
|
||||
set: (val) => emit('update:modelSearch', normalizeTextInput(val))
|
||||
})
|
||||
|
||||
const isProviderTesting = (providerId) => props.testingProviders.includes(providerId)
|
||||
|
||||
@@ -48,6 +48,24 @@ const loading = ref(false);
|
||||
const isEmpty = ref(false);
|
||||
const copyFeedbackTimer = ref(null);
|
||||
const lastRequestId = ref(0);
|
||||
const scrollContainer = ref(null);
|
||||
|
||||
function slugifyHeading(text, slugCounts) {
|
||||
const base = (text || "")
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.normalize("NFKD")
|
||||
.replace(/[\u0300-\u036f]/g, "")
|
||||
.replace(/[^\p{Letter}\p{Number}\s-]/gu, "")
|
||||
.replace(/\s+/g, "-")
|
||||
.replace(/-+/g, "-");
|
||||
|
||||
if (!base) return "";
|
||||
|
||||
const count = slugCounts.get(base) || 0;
|
||||
slugCounts.set(base, count + 1);
|
||||
return count === 0 ? base : `${base}-${count}`;
|
||||
}
|
||||
|
||||
onUnmounted(() => {
|
||||
if (copyFeedbackTimer.value) clearTimeout(copyFeedbackTimer.value);
|
||||
@@ -153,6 +171,18 @@ const renderedHtml = computed(() => {
|
||||
// 3. 后处理方案:完全隔离,安全性最高
|
||||
const tempDiv = document.createElement("div");
|
||||
tempDiv.innerHTML = cleanHtml;
|
||||
|
||||
const slugCounts = new Map();
|
||||
tempDiv.querySelectorAll("h1, h2, h3, h4, h5, h6").forEach((heading) => {
|
||||
if (heading.id) {
|
||||
slugCounts.set(heading.id, (slugCounts.get(heading.id) || 0) + 1);
|
||||
return;
|
||||
}
|
||||
|
||||
const slug = slugifyHeading(heading.textContent, slugCounts);
|
||||
if (slug) heading.id = slug;
|
||||
});
|
||||
|
||||
tempDiv.querySelectorAll("a").forEach((link) => {
|
||||
const href = link.getAttribute("href");
|
||||
// 强制所有外部链接使用安全的 _blank 策略
|
||||
@@ -251,18 +281,35 @@ watch(
|
||||
|
||||
function handleContainerClick(event) {
|
||||
const btn = event.target.closest(".copy-code-btn");
|
||||
if (!btn) return;
|
||||
const code = btn.closest(".code-block-wrapper")?.querySelector("code");
|
||||
if (code) {
|
||||
if (navigator.clipboard?.writeText) {
|
||||
navigator.clipboard
|
||||
.writeText(code.textContent)
|
||||
.then(() => showCopyFeedback(btn, true))
|
||||
.catch(() => tryFallbackCopy(code.textContent, btn));
|
||||
} else {
|
||||
tryFallbackCopy(code.textContent, btn);
|
||||
if (btn) {
|
||||
const code = btn.closest(".code-block-wrapper")?.querySelector("code");
|
||||
if (code) {
|
||||
if (navigator.clipboard?.writeText) {
|
||||
navigator.clipboard
|
||||
.writeText(code.textContent)
|
||||
.then(() => showCopyFeedback(btn, true))
|
||||
.catch(() => tryFallbackCopy(code.textContent, btn));
|
||||
} else {
|
||||
tryFallbackCopy(code.textContent, btn);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const anchor = event.target.closest('a[href^="#"]');
|
||||
if (!anchor) return;
|
||||
|
||||
const rawHref = anchor.getAttribute("href");
|
||||
const targetId = rawHref ? decodeURIComponent(rawHref.slice(1)) : "";
|
||||
if (!targetId) return;
|
||||
|
||||
const target = scrollContainer.value?.querySelector(
|
||||
`#${CSS.escape(targetId)}`,
|
||||
);
|
||||
if (!target) return;
|
||||
|
||||
event.preventDefault();
|
||||
target.scrollIntoView({ behavior: "smooth", block: "start" });
|
||||
}
|
||||
|
||||
function tryFallbackCopy(text, btn) {
|
||||
@@ -326,7 +373,7 @@ const showActionArea = computed(() => {
|
||||
<v-icon>mdi-close</v-icon>
|
||||
</v-btn>
|
||||
</v-card-title>
|
||||
<v-card-text style="overflow-y: auto">
|
||||
<v-card-text ref="scrollContainer" style="overflow-y: auto">
|
||||
<div v-if="showActionArea" class="d-flex justify-space-between mb-4">
|
||||
<v-btn
|
||||
v-if="modeConfig.showGithubButton && repoUrl"
|
||||
@@ -436,6 +483,7 @@ const showActionArea = computed(() => {
|
||||
margin-bottom: 16px;
|
||||
font-weight: 600;
|
||||
line-height: 1.25;
|
||||
scroll-margin-top: 12px;
|
||||
}
|
||||
|
||||
:deep(.markdown-body h1) {
|
||||
|
||||
@@ -2,6 +2,7 @@ import { ref, computed, onMounted, nextTick, watch } from 'vue'
|
||||
import axios from 'axios'
|
||||
import { getProviderIcon } from '@/utils/providerUtils'
|
||||
import { askForConfirmation as askForConfirmationDialog, useConfirmDialog } from '@/utils/confirmDialog'
|
||||
import { normalizeTextInput } from '@/utils/inputValue'
|
||||
|
||||
export interface UseProviderSourcesOptions {
|
||||
defaultTab?: string
|
||||
@@ -157,7 +158,7 @@ export function useProviderSources(options: UseProviderSourcesOptions) {
|
||||
})
|
||||
|
||||
const filteredMergedModelEntries = computed(() => {
|
||||
const term = modelSearch.value.trim().toLowerCase()
|
||||
const term = normalizeTextInput(modelSearch.value).trim().toLowerCase()
|
||||
if (!term) return mergedModelEntries.value
|
||||
|
||||
return mergedModelEntries.value.filter((entry: any) => {
|
||||
|
||||
@@ -11,7 +11,7 @@ const translations = ref<Record<string, any>>({});
|
||||
*/
|
||||
export async function initI18n(locale: Locale = 'zh-CN') {
|
||||
currentLocale.value = locale;
|
||||
|
||||
|
||||
// 加载静态翻译数据
|
||||
loadTranslations(locale);
|
||||
}
|
||||
@@ -50,7 +50,7 @@ export function useI18n() {
|
||||
const t = (key: string, params?: Record<string, string | number>): string => {
|
||||
const keys = key.split('.');
|
||||
let value: any = translations.value;
|
||||
|
||||
|
||||
// 遍历键路径
|
||||
for (const k of keys) {
|
||||
if (value && typeof value === 'object' && k in value) {
|
||||
@@ -61,35 +61,35 @@ export function useI18n() {
|
||||
return `[MISSING: ${key}]`;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
if (typeof value !== 'string') {
|
||||
console.warn(`Translation value is not string: ${key}`, value);
|
||||
// 返回带括号的键名,便于在开发时识别类型错误的翻译
|
||||
return `[INVALID: ${key}]`;
|
||||
}
|
||||
|
||||
|
||||
// 此时value确定是string类型
|
||||
let result: string = value;
|
||||
|
||||
|
||||
// 处理参数插值
|
||||
if (params) {
|
||||
result = result.replace(/\{(\w+)\}/g, (match: string, paramKey: string) => {
|
||||
return params[paramKey]?.toString() || match;
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
|
||||
// 切换语言
|
||||
const setLocale = async (newLocale: Locale) => {
|
||||
if (newLocale !== currentLocale.value) {
|
||||
currentLocale.value = newLocale;
|
||||
loadTranslations(newLocale);
|
||||
|
||||
|
||||
// 保存到localStorage
|
||||
localStorage.setItem('astrbot-locale', newLocale);
|
||||
|
||||
|
||||
// 触发自定义事件,通知相关页面重新加载配置数据
|
||||
// 这是因为插件适配器的 i18n 数据是通过后端 API 注入的,
|
||||
// 需要根据 Accept-Language 头重新获取
|
||||
@@ -98,16 +98,16 @@ export function useI18n() {
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
// 获取当前语言
|
||||
const locale = computed(() => currentLocale.value);
|
||||
|
||||
|
||||
// 获取可用语言列表
|
||||
const availableLocales: Locale[] = ['zh-CN', 'en-US'];
|
||||
|
||||
const availableLocales: Locale[] = ['zh-CN', 'en-US', 'ru-RU'];
|
||||
|
||||
// 检查是否已加载
|
||||
const isLoaded = computed(() => Object.keys(translations.value).length > 0);
|
||||
|
||||
|
||||
return {
|
||||
t,
|
||||
locale,
|
||||
@@ -122,13 +122,13 @@ export function useI18n() {
|
||||
*/
|
||||
export function useModuleI18n(moduleName: string) {
|
||||
const { t } = useI18n();
|
||||
|
||||
|
||||
const tm = (key: string, params?: Record<string, string | number>): string => {
|
||||
// 将斜杠转换为点号以匹配嵌套对象结构
|
||||
const normalizedModuleName = moduleName.replace(/\//g, '.');
|
||||
return t(`${normalizedModuleName}.${key}`, params);
|
||||
};
|
||||
|
||||
|
||||
// 获取原始翻译值(可能是字符串、数组或对象)
|
||||
const getRaw = (key: string): any => {
|
||||
const normalizedModuleName = moduleName.replace(/\//g, '.');
|
||||
@@ -143,10 +143,10 @@ export function useModuleI18n(moduleName: string) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
return value;
|
||||
};
|
||||
|
||||
|
||||
return { tm, getRaw };
|
||||
}
|
||||
|
||||
@@ -155,20 +155,21 @@ export function useModuleI18n(moduleName: string) {
|
||||
*/
|
||||
export function useLanguageSwitcher() {
|
||||
const { locale, setLocale, availableLocales } = useI18n();
|
||||
|
||||
|
||||
const languageOptions = computed(() => [
|
||||
{ value: 'zh-CN', label: '简体中文', flag: '🇨🇳' },
|
||||
{ value: 'en-US', label: 'English', flag: '🇺🇸' }
|
||||
{ value: 'en-US', label: 'English', flag: '🇺🇸' },
|
||||
{ value: 'ru-RU', label: 'Русский', flag: '🇷🇺' }
|
||||
]);
|
||||
|
||||
|
||||
const currentLanguage = computed(() => {
|
||||
return languageOptions.value.find(lang => lang.value === locale.value);
|
||||
});
|
||||
|
||||
|
||||
const switchLanguage = async (newLocale: Locale) => {
|
||||
await setLocale(newLocale);
|
||||
};
|
||||
|
||||
|
||||
return {
|
||||
locale,
|
||||
languageOptions,
|
||||
@@ -220,9 +221,9 @@ function deepMerge(target: Record<string, any>, source: Record<string, any>) {
|
||||
export async function setupI18n() {
|
||||
// 从localStorage获取保存的语言设置
|
||||
const savedLocale = localStorage.getItem('astrbot-locale') as Locale;
|
||||
const initialLocale = savedLocale && ['zh-CN', 'en-US'].includes(savedLocale)
|
||||
? savedLocale
|
||||
const initialLocale = savedLocale && ['zh-CN', 'en-US', 'ru-RU'].includes(savedLocale)
|
||||
? savedLocale
|
||||
: 'zh-CN';
|
||||
|
||||
|
||||
await initI18n(initialLocale);
|
||||
}
|
||||
@@ -78,6 +78,7 @@
|
||||
},
|
||||
"persona": {
|
||||
"description": "Persona",
|
||||
"hint": "Set the default persona for AI conversations. Personas can be managed in the Persona tab.",
|
||||
"provider_settings": {
|
||||
"default_personality": {
|
||||
"description": "Default Persona"
|
||||
@@ -550,6 +551,10 @@
|
||||
"description": "WeCom AI Bot Name",
|
||||
"hint": "Must be correct; otherwise some commands won't work."
|
||||
},
|
||||
"wecom_ai_bot_connection_mode": {
|
||||
"description": "WeCom AI Bot Connection Mode",
|
||||
"hint": "Webhook mode requires Token/EncodingAESKey; long_connection mode requires BotID/Secret."
|
||||
},
|
||||
"wecomaibot_friend_message_welcome_text": {
|
||||
"description": "WeCom AI Bot DM Welcome Message",
|
||||
"hint": "When a user enters a DM session on that day, reply with a welcome message. Leave empty to disable."
|
||||
@@ -558,6 +563,30 @@
|
||||
"description": "WeCom AI Bot Initial Response Text",
|
||||
"hint": "First reply when the bot receives a message. Leave empty to disable."
|
||||
},
|
||||
"wecomaibot_token": {
|
||||
"description": "WeCom AI Bot Token",
|
||||
"hint": "Used for authentication in webhook callback mode."
|
||||
},
|
||||
"wecomaibot_encoding_aes_key": {
|
||||
"description": "WeCom AI Bot EncodingAESKey",
|
||||
"hint": "Used for message encryption/decryption in webhook callback mode."
|
||||
},
|
||||
"wecomaibot_ws_bot_id": {
|
||||
"description": "Long Connection BotID",
|
||||
"hint": "BotID credential for WeCom AI Bot long connection mode."
|
||||
},
|
||||
"wecomaibot_ws_secret": {
|
||||
"description": "Long Connection Secret",
|
||||
"hint": "Secret credential for WeCom AI Bot long connection mode."
|
||||
},
|
||||
"wecomaibot_ws_url": {
|
||||
"description": "Long Connection WebSocket URL",
|
||||
"hint": "Default is wss://openws.work.weixin.qq.com and usually does not need changes."
|
||||
},
|
||||
"wecomaibot_heartbeat_interval": {
|
||||
"description": "Long Connection Heartbeat Interval",
|
||||
"hint": "Heartbeat interval (seconds) in long connection mode. 30 seconds is recommended."
|
||||
},
|
||||
"wpp_active_message_poll": {
|
||||
"description": "Enable Proactive Message Polling",
|
||||
"hint": "Only enable if WeChat messages are not syncing to AstrBot on time. Disabled by default."
|
||||
@@ -845,7 +874,8 @@
|
||||
]
|
||||
},
|
||||
"regex": {
|
||||
"description": "Segmentation Regular Expression"
|
||||
"description": "Segmentation Regular Expression",
|
||||
"hint": "Used to identify split points with a regular expression. Prefer patterns that match separators."
|
||||
},
|
||||
"split_words": {
|
||||
"description": "Split Word List",
|
||||
@@ -1493,4 +1523,4 @@
|
||||
"helpMiddle": "or",
|
||||
"helpSuffix": "."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -23,6 +23,9 @@
|
||||
"placeholder": "Search extensions...",
|
||||
"marketPlaceholder": "Search market extensions..."
|
||||
},
|
||||
"filters": {
|
||||
"all": "All"
|
||||
},
|
||||
"views": {
|
||||
"card": "Card View",
|
||||
"list": "List View"
|
||||
@@ -122,10 +125,14 @@
|
||||
"sourceSafetyWarning": "Even with the default source, plugin stability and security cannot be fully guaranteed. Please verify carefully before use."
|
||||
},
|
||||
"sort": {
|
||||
"by": "Sort by",
|
||||
"default": "Default",
|
||||
"installTime": "Last Modified",
|
||||
"name": "Name",
|
||||
"stars": "Stars",
|
||||
"author": "Author",
|
||||
"updated": "Last Updated",
|
||||
"updateStatus": "Update Status",
|
||||
"ascending": "Ascending",
|
||||
"descending": "Descending"
|
||||
},
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"create": "Создать",
|
||||
"read": "Чтение",
|
||||
"update": "Обновить",
|
||||
"delete": "Удалить",
|
||||
"search": "Поиск",
|
||||
"filter": "Фильтр",
|
||||
"sort": "Сортировка",
|
||||
"export": "Экспорт",
|
||||
"import": "Импорт",
|
||||
"backup": "Резервное копирование",
|
||||
"restore": "Восстановление",
|
||||
"copy": "Копировать",
|
||||
"paste": "Вставить",
|
||||
"cut": "Вырезать",
|
||||
"undo": "Отменить",
|
||||
"redo": "Повторить",
|
||||
"refresh": "Обновить",
|
||||
"submit": "Отправить",
|
||||
"reset": "Сбросить",
|
||||
"clear": "Очистить",
|
||||
"save": "Сохранить",
|
||||
"close": "Закрыть"
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
{
|
||||
"save": "Сохранить",
|
||||
"cancel": "Отмена",
|
||||
"close": "Закрыть",
|
||||
"copy": "Копировать",
|
||||
"copied": "Скопировано",
|
||||
"copyFailed": "Ошибка копирования",
|
||||
"delete": "Удалить",
|
||||
"edit": "Редактировать",
|
||||
"add": "Добавить",
|
||||
"confirm": "Подтвердить",
|
||||
"loading": "Загрузка...",
|
||||
"success": "Успешно",
|
||||
"error": "Ошибка",
|
||||
"warning": "Внимание",
|
||||
"info": "Информация",
|
||||
"name": "Имя",
|
||||
"description": "Описание",
|
||||
"author": "Автор",
|
||||
"status": "Статус",
|
||||
"actions": "Действия",
|
||||
"enable": "Включить",
|
||||
"disable": "Выключить",
|
||||
"enabled": "Включено",
|
||||
"disabled": "Выключено",
|
||||
"reload": "Перезагрузить",
|
||||
"configure": "Настроить",
|
||||
"install": "Установить",
|
||||
"uninstall": "Удалить",
|
||||
"update": "Обновить",
|
||||
"language": "Язык",
|
||||
"settings": "Настройки",
|
||||
"locale": "JSON",
|
||||
"type": "Тип",
|
||||
"press": "Нажмите",
|
||||
"longPress": "Долгое нажатие",
|
||||
"yes": "Да",
|
||||
"no": "Нет",
|
||||
"imagePreview": "Предпросмотр изображения",
|
||||
"autoDetect": "Автоопределение",
|
||||
"dialog": {
|
||||
"confirmTitle": "Подтверждение",
|
||||
"confirmMessage": "Вы уверены, что хотите выполнить это действие?",
|
||||
"confirmButton": "ОК",
|
||||
"cancelButton": "Отмена"
|
||||
},
|
||||
"restart": {
|
||||
"waiting": "Ожидание перезагрузки AstrBot...",
|
||||
"maxRetriesReached": "Превышено количество попыток проверки статуса. Пожалуйста, проверьте вручную."
|
||||
},
|
||||
"readme": {
|
||||
"title": "Документация плагина",
|
||||
"buttons": {
|
||||
"viewOnGithub": "Открыть репозиторий на GitHub",
|
||||
"refresh": "Обновить"
|
||||
},
|
||||
"loading": "Загрузка README...",
|
||||
"errors": {
|
||||
"fetchFailed": "Не удалось загрузить README",
|
||||
"fetchError": "Произошла ошибка при загрузке README"
|
||||
},
|
||||
"empty": {
|
||||
"title": "У этого плагина нет ссылки на документацию или репозиторий GitHub.",
|
||||
"subtitle": "Пожалуйста, посетите магазин плагинов или свяжитесь с автором для получения дополнительной информации."
|
||||
}
|
||||
},
|
||||
"changelog": {
|
||||
"title": "Журнал изменений",
|
||||
"loading": "Загрузка журнала изменений...",
|
||||
"empty": {
|
||||
"title": "У этого плагина нет журнала изменений",
|
||||
"subtitle": "Разработчики могут добавить файл CHANGELOG.md в директорию плагина"
|
||||
}
|
||||
},
|
||||
"editor": {
|
||||
"fullscreen": "На весь экран",
|
||||
"editingTitle": "Редактирование содержимого"
|
||||
},
|
||||
"templateList": {
|
||||
"addEntry": "Добавить запись",
|
||||
"empty": "Записей нет, выберите шаблон для добавления",
|
||||
"missingTemplate": "Шаблон не найден, пожалуйста, удалите и добавьте заново.",
|
||||
"unknownTemplate": "Неизвестный шаблон"
|
||||
},
|
||||
"list": {
|
||||
"addItemPlaceholder": "Добавьте новый элемент и нажмите Enter",
|
||||
"addButton": "Добавить",
|
||||
"addMore": "Добавить еще",
|
||||
"batchImport": "Массовый импорт",
|
||||
"batchImportTitle": "Массовый импорт",
|
||||
"batchImportLabel": "Один элемент на строку",
|
||||
"batchImportPlaceholder": "Например:\nЭлемент 1\nЭлемент 2\nЭлемент 3",
|
||||
"batchImportHint": "Каждая строка будет считаться отдельным элементом. Пустые строки игнорируются.",
|
||||
"batchImportButton": "Импортировать {count} эл.",
|
||||
"noItems": "Список пуст",
|
||||
"noItemsHint": "Элементов нет. Напишите что-нибудь выше и нажмите Enter.",
|
||||
"inputPlaceholder": "Введите текст и нажмите Enter",
|
||||
"editTitle": "Изменить элемент",
|
||||
"modifyButton": "Изменить"
|
||||
},
|
||||
"itemCard": {
|
||||
"enabled": "Включено",
|
||||
"disabled": "Выключено",
|
||||
"delete": "Удалить",
|
||||
"edit": "Изменить",
|
||||
"copy": "Копировать",
|
||||
"noData": "Нет данных"
|
||||
},
|
||||
"objectEditor": {
|
||||
"dialogTitle": "Изменение пар ключ-значение",
|
||||
"noItems": "Нет элементов",
|
||||
"noParams": "Нет параметров",
|
||||
"presets": "Пресеты",
|
||||
"newKeyLabel": "Имя ключа",
|
||||
"valueTypeLabel": "Тип значения",
|
||||
"keyExists": "Ключ уже существует",
|
||||
"invalidJson": "Некорректный формат JSON",
|
||||
"placeholders": {
|
||||
"keyName": "Ключ",
|
||||
"stringValue": "Строка",
|
||||
"numberValue": "Число",
|
||||
"jsonValue": "JSON"
|
||||
}
|
||||
},
|
||||
"firstNotice": {
|
||||
"title": "Первичная информация",
|
||||
"loading": "Загрузка информации...",
|
||||
"empty": {
|
||||
"title": "Нет информации для отображения",
|
||||
"subtitle": "Файл FIRST_NOTICE.md не найден или пуст."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
{
|
||||
"logoTitle": "Панель управления AstrBot",
|
||||
"version": {
|
||||
"hasNewVersion": "Доступна новая версия AstrBot!",
|
||||
"dashboardHasNewVersion": "Доступна новая версия WebUI!"
|
||||
},
|
||||
"buttons": {
|
||||
"update": "Обновить",
|
||||
"account": "Аккаунт",
|
||||
"theme": {
|
||||
"light": "Светлая тема",
|
||||
"dark": "Темная тема"
|
||||
}
|
||||
},
|
||||
"updateDialog": {
|
||||
"title": "Обновить AstrBot",
|
||||
"currentVersion": "Текущая версия",
|
||||
"status": {
|
||||
"checking": "Проверка обновлений...",
|
||||
"switching": "Переключение версии...",
|
||||
"updating": "Обновление..."
|
||||
},
|
||||
"tabs": {
|
||||
"release": "😊 Релиз"
|
||||
},
|
||||
"updateToLatest": "Обновить до последней версии",
|
||||
"preRelease": "Предварительная версия",
|
||||
"preReleaseWarning": {
|
||||
"title": "Внимание: предварительная версия",
|
||||
"description": "Версии с меткой Pre-release могут содержать неизвестные ошибки. Не рекомендуется использовать в рабочих средах. Если вы обнаружили ошибку, пожалуйста, сообщите о ней в ",
|
||||
"issueLink": "GitHub Issues"
|
||||
},
|
||||
"tip": "💡 ПОДСКАЗКА: ",
|
||||
"tipContinue": "По умолчанию при переключении версии загружаются соответствующие файлы WebUI. Код WebUI находится в директории dashboard, вы можете собрать его самостоятельно с помощью npm.",
|
||||
"dockerTip": "При переключении версии будет предпринята попытка обновить как основной процесс бота, так и панель управления. Если вы используете Docker, вы также можете обновить образ или использовать",
|
||||
"dockerTipLink": "watchtower",
|
||||
"dockerTipContinue": "для автоматического мониторинга и обновления.",
|
||||
"table": {
|
||||
"tag": "Тег",
|
||||
"publishDate": "Дата публикации",
|
||||
"content": "Содержание",
|
||||
"sourceUrl": "Исходный код",
|
||||
"actions": "Действия",
|
||||
"view": "Просмотр",
|
||||
"switch": "Переключить"
|
||||
},
|
||||
"releaseNotes": {
|
||||
"title": "Журнал изменений"
|
||||
},
|
||||
"redirectConfirm": {
|
||||
"title": "Переход по ссылке",
|
||||
"message": "Вы будете перенаправлены на страницу GitHub Releases. Продолжить?",
|
||||
"latestLabel": "Последняя версия",
|
||||
"targetVersion": "Целевая версия:",
|
||||
"currentVersion": "Текущая версия:",
|
||||
"guideTitle": "Рекомендации после перехода:",
|
||||
"guideStep1": "Загрузите пакет, соответствующий архитектуре вашей системы.",
|
||||
"guideStep2": "После завершения установки перезапустите AstrBot.",
|
||||
"guideStep3": "Если вы используете Docker, отдайте приоритет обновлению через образ."
|
||||
},
|
||||
"desktopApp": {
|
||||
"title": "Обновить десктопное приложение",
|
||||
"message": "Проверка и обновление десктопной версии AstrBot.",
|
||||
"currentVersion": "Текущая версия:",
|
||||
"latestVersion": "Последняя версия:",
|
||||
"checking": "Проверка обновлений десктопного приложения...",
|
||||
"hasNewVersion": "Найдена новая версия. Нажмите для подтверждения обновления.",
|
||||
"isLatest": "Установлена последняя версия",
|
||||
"installing": "Загрузка и установка обновления... Приложение будет перезапущено автоматически.",
|
||||
"checkFailed": "Ошибка проверки обновлений. Попробуйте позже.",
|
||||
"installFailed": "Ошибка обновления. Попробуйте позже."
|
||||
},
|
||||
"dashboardUpdate": {
|
||||
"title": "Обновить только панель управления",
|
||||
"currentVersion": "Текущая версия",
|
||||
"hasNewVersion": "Доступна новая версия!",
|
||||
"isLatest": "Установлена последняя версия.",
|
||||
"downloadAndUpdate": "Скачать и обновить"
|
||||
}
|
||||
},
|
||||
"accountDialog": {
|
||||
"title": "Изменить аккаунт",
|
||||
"securityWarning": "Безопасность: Пожалуйста, смените пароль по умолчанию для защиты аккаунта",
|
||||
"form": {
|
||||
"currentPassword": "Текущий пароль",
|
||||
"newPassword": "Новый пароль",
|
||||
"confirmPassword": "Подтвердите новый пароль",
|
||||
"newUsername": "Новое имя пользователя (опционально)",
|
||||
"passwordHint": "Пароль должен быть не менее 8 символов",
|
||||
"confirmPasswordHint": "Введите новый пароль еще раз",
|
||||
"usernameHint": "Оставьте пустым, если не хотите менять имя пользователя",
|
||||
"defaultCredentials": "Логин и пароль по умолчанию: astrbot"
|
||||
},
|
||||
"validation": {
|
||||
"passwordRequired": "Введите пароль",
|
||||
"passwordMinLength": "Пароль должен быть не менее 8 символов",
|
||||
"passwordMatch": "Паролы не совпадают",
|
||||
"usernameMinLength": "Имя пользователя должно быть не менее 3 символов"
|
||||
},
|
||||
"actions": {
|
||||
"save": "Сохранить изменения",
|
||||
"cancel": "Отмена"
|
||||
},
|
||||
"messages": {
|
||||
"updateFailed": "Ошибка обновления, попробуйте еще раз"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
{
|
||||
"welcome": "Добро пожаловать",
|
||||
"dashboard": "Статистика",
|
||||
"platforms": "Боты",
|
||||
"providers": "Провайдеры моделей",
|
||||
"commands": "Команды",
|
||||
"persona": "Персонажи",
|
||||
"subagent": "Субагенты",
|
||||
"toolUse": "Инструменты MCP",
|
||||
"extension": "Плагины",
|
||||
"extensionTabs": {
|
||||
"installed": "Плагины AstrBot",
|
||||
"market": "Магазин плагинов",
|
||||
"mcp": "Серверы MCP",
|
||||
"skills": "Навыки",
|
||||
"components": "Управление поведением"
|
||||
},
|
||||
"config": "Конфигурация",
|
||||
"chat": "Чат",
|
||||
"cron": "Запланированные задачи",
|
||||
"conversation": "Данные диалогов",
|
||||
"sessionManagement": "Пользовательские правила",
|
||||
"console": "Логи платформы",
|
||||
"trace": "Трассировка",
|
||||
"alkaid": "Alkaid Lab",
|
||||
"knowledgeBase": "База знаний",
|
||||
"about": "О программе",
|
||||
"settings": "Настройки",
|
||||
"changelog": "Журнал изменений",
|
||||
"documentation": "Документация",
|
||||
"faq": "FAQ",
|
||||
"github": "GitHub",
|
||||
"drag": "Перетащить",
|
||||
"groups": {
|
||||
"more": "Дополнительно"
|
||||
},
|
||||
"changelogDialog": {
|
||||
"title": "Журнал изменений",
|
||||
"loading": "Загрузка...",
|
||||
"error": "Ошибка загрузки",
|
||||
"notFound": "Журнал изменений для этой версии не найден",
|
||||
"selectVersion": "Выберите версию",
|
||||
"current": "Текущая"
|
||||
},
|
||||
"configTabs": {
|
||||
"normal": "Обычная конфигурация",
|
||||
"system": "Системная конфигурация"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
{
|
||||
"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": "Обновить",
|
||||
"syntaxHint": "Поддерживается синтаксис jinja2. Переменные: text | safe (текст для рендеринга), version (версия AstrBot)",
|
||||
"saveAndApply": "Сохранить и применить текущий шаблон",
|
||||
"confirmReset": "Подтверждение сброса",
|
||||
"confirmResetMessage": "Вы уверены, что хотите сбросить шаблон 'base' до значений по умолчанию? Все несохраненные изменения будут потеряны. Это действие необратимо.",
|
||||
"confirmResetButton": "Сбросить",
|
||||
"confirmDelete": "Подтверждение удаления",
|
||||
"confirmDeleteMessage": "Вы уверены, что хотите удалить шаблон '{name}'? Это действие необратимо.",
|
||||
"confirmDeleteButton": "Удалить",
|
||||
"confirmAction": "Подтверждение действия",
|
||||
"confirmApplyMessage": "Вы уверены, что хотите сохранить изменения в '{name}' и сделать его активным шаблоном?"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"loading": "Загрузка",
|
||||
"success": "Успешно",
|
||||
"error": "Ошибка",
|
||||
"warning": "Внимание",
|
||||
"info": "Информация",
|
||||
"pending": "В ожидании",
|
||||
"processing": "В процессе",
|
||||
"completed": "Завершено",
|
||||
"failed": "Ошибка",
|
||||
"cancelled": "Отменено",
|
||||
"timeout": "Тайм-аут",
|
||||
"connecting": "Подключение",
|
||||
"connected": "Подключено",
|
||||
"disconnected": "Отключено",
|
||||
"online": "В сети",
|
||||
"offline": "Не в сети",
|
||||
"active": "Активен",
|
||||
"inactive": "Неактивен",
|
||||
"ready": "Готов",
|
||||
"busy": "Занят"
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"hero": {
|
||||
"title": "AstrBot",
|
||||
"subtitle": "Проект, рожденный из интереса и любви ❤️",
|
||||
"starButton": "Star этот проект! 🌟",
|
||||
"issueButton": "Сообщить об ошибке"
|
||||
},
|
||||
"contributors": {
|
||||
"title": "Контрибьюторы",
|
||||
"description": "Этот проект поддерживается участниками open-source сообщества. Спасибо каждому за вклад!",
|
||||
"viewLink": "Посмотреть всех участников"
|
||||
},
|
||||
"stats": {
|
||||
"title": "Глобальное развертывание",
|
||||
"license": "AstrBot распространяется по лицензии AGPL v3"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
{
|
||||
"title": "Лаборатория Alkaid",
|
||||
"subtitle": "Исследуйте передовые возможности AI",
|
||||
"comingSoon": "Этот мир еще впереди, заходите позже!",
|
||||
"page": {
|
||||
"title": "Проект Alkaid.",
|
||||
"subtitle": "AstrBot Alpha Project",
|
||||
"navigation": {
|
||||
"knowledgeBase": "База знаний (Плагин)",
|
||||
"longTermMemory": "Долгосрочная память",
|
||||
"other": "..."
|
||||
}
|
||||
},
|
||||
"features": {
|
||||
"knowledgeBase": "База знаний",
|
||||
"longTermMemory": "Долгосрочная память",
|
||||
"advancedChat": "Продвинутый чат",
|
||||
"multiModal": "Мультимодальность"
|
||||
},
|
||||
"status": {
|
||||
"experimental": "Экспериментально",
|
||||
"beta": "Бета",
|
||||
"stable": "Стабильно",
|
||||
"deprecated": "Устарело"
|
||||
},
|
||||
"sigma": {
|
||||
"subtitle": "Экспериментальный проект AstrBot",
|
||||
"visualization": "Визуализация",
|
||||
"filterUserId": "Фильтр по User ID",
|
||||
"filter": "Фильтр",
|
||||
"resetFilter": "Сброс",
|
||||
"refreshGraph": "Обновить граф",
|
||||
"nodeDetails": "Детали узла",
|
||||
"id": "ID",
|
||||
"type": "Тип",
|
||||
"name": "Имя",
|
||||
"userId": "ID пользователя",
|
||||
"timestamp": "Метка времени",
|
||||
"graphStats": "Статистика графа",
|
||||
"nodeCount": "Узлов",
|
||||
"edgeCount": "Связей",
|
||||
"inDevelopment": "В разработке"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
{
|
||||
"title": "База знаний",
|
||||
"subtitle": "Управление контентом базы знаний и поиск",
|
||||
"documents": {
|
||||
"title": "Список документов",
|
||||
"name": "Имя файла",
|
||||
"size": "Размер",
|
||||
"uploadTime": "Дата загрузки",
|
||||
"status": "Статус",
|
||||
"actions": "Действия"
|
||||
},
|
||||
"management": {
|
||||
"delete": "Удалить",
|
||||
"preview": "Предпросмотр",
|
||||
"download": "Скачать",
|
||||
"reindex": "Переиндексировать"
|
||||
},
|
||||
"notInstalled": {
|
||||
"title": "Плагин базы знаний не установлен",
|
||||
"install": "Установить сейчас"
|
||||
},
|
||||
"empty": {
|
||||
"title": "База знаний пуста. Создайте свою первую базу! 🙂",
|
||||
"create": "Создать базу знаний"
|
||||
},
|
||||
"list": {
|
||||
"title": "Список баз знаний",
|
||||
"create": "Создать базу знаний",
|
||||
"config": "Настройка",
|
||||
"checkUpdate": "Проверить обновления плагина",
|
||||
"updatePlugin": "Обновить плагин до версии {version}",
|
||||
"knowledgeCount": "записей",
|
||||
"tips": "Совет: используйте команду /kb в чате, чтобы узнать, как пользоваться базой!"
|
||||
},
|
||||
"createDialog": {
|
||||
"title": "Создание базы знаний",
|
||||
"nameLabel": "Название",
|
||||
"descriptionLabel": "Описание",
|
||||
"descriptionPlaceholder": "Краткое описание...",
|
||||
"embeddingModelLabel": "Embedding модель",
|
||||
"rerankModelLabel": "Rerank модель",
|
||||
"providerInfo": "Провайдер: {id} | Размерность: {dimensions}",
|
||||
"rerankProviderInfo": "Провайдер: {id}",
|
||||
"tips": "Совет: после выбора Embedding модели не рекомендуется менять провайдера или размерность векторов, так как это сделает текущий индекс нечитаемым.",
|
||||
"cancel": "Отмена",
|
||||
"create": "Создать"
|
||||
},
|
||||
"emojiPicker": {
|
||||
"title": "Выберите иконку",
|
||||
"close": "Закрыть",
|
||||
"categories": {
|
||||
"emotions": "Смайлы",
|
||||
"animals": "Животные и природа",
|
||||
"food": "Еда и напитки",
|
||||
"activities": "Занятия и вещи",
|
||||
"travel": "Места и путешествия",
|
||||
"symbols": "Символы и флаги"
|
||||
}
|
||||
},
|
||||
"contentDialog": {
|
||||
"title": "Управление базой знаний",
|
||||
"embeddingModel": "Embedding модель",
|
||||
"vectorDimension": "Размерность",
|
||||
"usage": "Использование: введите «/kb use {name}» в чате",
|
||||
"tabs": {
|
||||
"upload": "Загрузка файлов",
|
||||
"search": "Поиск",
|
||||
"fromURL": "Импорт из URL"
|
||||
}
|
||||
},
|
||||
"upload": {
|
||||
"title": "Загрузка файлов",
|
||||
"subtitle": "Поддерживаются форматы txt, pdf, word, excel и др.",
|
||||
"dropzone": "Перетащите файлы сюда или нажмите для выбора",
|
||||
"chunkSettings": {
|
||||
"title": "Настройка фрагментации (Chunking)",
|
||||
"tooltip": "Размер фрагмента определяет объем текста в одном блоке. Перекрытие позволяет сохранить контекст между соседними блоками.\nМаленькие фрагменты точнее, но увеличивают объем базы.",
|
||||
"chunkSizeLabel": "Размер фрагмента",
|
||||
"chunkSizeHint": "Длина текста в одном блоке (пусто = по умолчанию)",
|
||||
"overlapLabel": "Перекрытие",
|
||||
"overlapHint": "Нахлест между соседними блоками (пусто = по умолчанию)"
|
||||
},
|
||||
"upload": "Начать загрузку",
|
||||
"uploading": "Загрузка..."
|
||||
},
|
||||
"search": {
|
||||
"queryLabel": "Поиск по базе знаний",
|
||||
"queryPlaceholder": "Введите ключевые слова...",
|
||||
"resultCountLabel": "Количество результатов",
|
||||
"searching": "Поиск...",
|
||||
"resultsTitle": "Результаты поиска",
|
||||
"relevance": "Релевантность",
|
||||
"noResults": "Совпадений не найдено"
|
||||
},
|
||||
"deleteDialog": {
|
||||
"title": "Подтверждение удаления",
|
||||
"confirmText": "Вы уверены, что хотите удалить базу знаний «{name}»?",
|
||||
"warning": "Это действие необратимо. Весь контент базы знаний будет навсегда удален.",
|
||||
"cancel": "Отмена",
|
||||
"delete": "Удалить"
|
||||
},
|
||||
"messages": {
|
||||
"pluginNotAvailable": "Плагин не установлен или недоступен",
|
||||
"pluginNotActivated": "Плагин astrbot_plugin_knowledge_base не включен. Пожалуйста, активируйте его в разделе плагинов и перезапустите AstrBot.",
|
||||
"checkPluginFailed": "Не удалось проверить плагин",
|
||||
"installFailed": "Ошибка установки",
|
||||
"installPluginFailed": "Не удалось установить плагин",
|
||||
"getKnowledgeBaseListFailed": "Ошибка получения списка баз знаний",
|
||||
"knowledgeBaseCreated": "База знаний создана",
|
||||
"createFailed": "Ошибка создания",
|
||||
"createKnowledgeBaseFailed": "Не удалось создать базу знаний",
|
||||
"pleaseEnterKnowledgeBaseName": "Укажите название базы знаний",
|
||||
"pleaseSelectFile": "Пожалуйста, сначала выберите файл",
|
||||
"operationSuccess": "Успешно: {message}",
|
||||
"uploadFailed": "Ошибка загрузки",
|
||||
"fileUploadFailed": "Не удалось загрузить файл",
|
||||
"pleaseEnterSearchContent": "Введите текст для поиска",
|
||||
"noMatchingContent": "Ничего не найдено",
|
||||
"searchFailed": "Ошибка поиска",
|
||||
"searchKnowledgeBaseFailed": "Не удалось выполнить поиск",
|
||||
"deleteTargetNotExists": "Объект для удаления не найден",
|
||||
"knowledgeBaseDeleted": "База знаний удалена",
|
||||
"deleteFailed": "Ошибка удаления",
|
||||
"deleteKnowledgeBaseFailed": "Не удалось удалить базу знаний",
|
||||
"getEmbeddingModelListFailed": "Не удалось загрузить список Embedding моделей",
|
||||
"updateAvailable": "Доступна новая версия: {current} -> {latest}",
|
||||
"pluginUpToDate": "У вас последняя версия плагина",
|
||||
"pluginNotFoundInMarket": "Плагин не найден в магазине",
|
||||
"checkUpdateFailed": "Ошибка проверки обновлений",
|
||||
"updateSuccess": "Плагин успешно обновлен",
|
||||
"updateFailed": "Ошибка обновления",
|
||||
"updatePluginFailed": "Не удалось обновить плагин"
|
||||
},
|
||||
"importFromUrl": {
|
||||
"title": "Импорт из URL",
|
||||
"urlLabel": "Адрес страницы",
|
||||
"urlPlaceholder": "Введите URL для извлечения знаний",
|
||||
"optionsTitle": "Настройки импорта",
|
||||
"tooltip": "Эти параметры управляют извлечением текста из URL.\nЕсли оставить пустыми, будут использованы настройки по умолчанию.\nТекстовая очистка через LLM может занять время.",
|
||||
"useLlmRepairLabel": "Исправление текста через LLM",
|
||||
"useClusteringSummaryLabel": "Кластеризация и суммаризация",
|
||||
"repairLlmProviderIdLabel": "Модель для очистки",
|
||||
"summarizeLlmProviderIdLabel": "Модель для суммаризации",
|
||||
"embeddingProviderIdLabel": "Embedding модель",
|
||||
"chunkSizeLabel": "Размер фрагмента",
|
||||
"chunkOverlapLabel": "Перекрытие",
|
||||
"startImport": "Начать импорт",
|
||||
"importing": "Импорт...",
|
||||
"importSuccess": "Импортировано успешно",
|
||||
"importFailed": "Ошибка импорта",
|
||||
"uploadingChunks": "Текст извлечен, загрузка фрагментов...",
|
||||
"preRequisite": "Примечание: сначала установите плагин astrbot_plugin_url_2_knowledge_base и выполните установку playwright согласно документации.",
|
||||
"allChunksUploaded": "Все фрагменты успешно загружены"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
{
|
||||
"title": "Долгосрочная память",
|
||||
"subtitle": "Управление памятью вашего AI-помощника",
|
||||
"memories": {
|
||||
"title": "Список воспоминаний",
|
||||
"content": "Содержание",
|
||||
"importance": "Важность",
|
||||
"createTime": "Дата создания",
|
||||
"lastAccess": "Последнее обращение",
|
||||
"category": "Категория"
|
||||
},
|
||||
"categories": {
|
||||
"personal": "Личное",
|
||||
"preferences": "Предпочтения",
|
||||
"conversations": "История диалогов",
|
||||
"facts": "Факты",
|
||||
"skills": "Навыки"
|
||||
},
|
||||
"importance": {
|
||||
"high": "Высокая",
|
||||
"medium": "Средняя",
|
||||
"low": "Низкая"
|
||||
},
|
||||
"actions": {
|
||||
"view": "Детали",
|
||||
"edit": "Изменить",
|
||||
"delete": "Удалить",
|
||||
"pin": "Закрепить",
|
||||
"unpin": "Открепить"
|
||||
},
|
||||
"filters": {
|
||||
"all": "Все",
|
||||
"category": "По категории",
|
||||
"importance": "По важности",
|
||||
"dateRange": "По периоду",
|
||||
"title": "Фильтр",
|
||||
"userIdLabel": "Фильтр по User ID",
|
||||
"filterButton": "Применить",
|
||||
"resetButton": "Сбросить",
|
||||
"refreshButton": "Обновить граф"
|
||||
},
|
||||
"search": {
|
||||
"title": "Поиск по памяти",
|
||||
"userIdLabel": "ID пользователя",
|
||||
"queryLabel": "Ключевое слово",
|
||||
"searchButton": "Поиск",
|
||||
"resultsTitle": "Результаты поиска",
|
||||
"noResults": "Ничего не найдено",
|
||||
"similarity": "Сходство",
|
||||
"noTextContent": "Нет текста"
|
||||
},
|
||||
"addMemory": {
|
||||
"title": "Добавить данные в память",
|
||||
"textLabel": "Текст воспоминания",
|
||||
"userIdLabel": "ID пользователя",
|
||||
"summarizeLabel": "Нужна суммаризация",
|
||||
"addButton": "Добавить"
|
||||
},
|
||||
"nodeDetails": {
|
||||
"title": "Детали узла",
|
||||
"id": "ID",
|
||||
"type": "Тип",
|
||||
"name": "Имя",
|
||||
"userId": "ID пользователя",
|
||||
"timestamp": "Метка времени"
|
||||
},
|
||||
"graphStats": {
|
||||
"title": "Статистика графа",
|
||||
"nodeCount": "Узлов",
|
||||
"edgeCount": "Связей"
|
||||
},
|
||||
"factDialog": {
|
||||
"title": "Факт из памяти",
|
||||
"id": "ID",
|
||||
"docId": "ID документа",
|
||||
"createdAt": "Создано",
|
||||
"updatedAt": "Обновлено",
|
||||
"metadata": "Метаданные",
|
||||
"metadataKey": "Ключ",
|
||||
"metadataValue": "Значение",
|
||||
"loading": "Загрузка...",
|
||||
"close": "Закрыть",
|
||||
"noValue": "нет",
|
||||
"unknown": "неизвестно"
|
||||
},
|
||||
"messages": {
|
||||
"searchQueryRequired": "Пожалуйста, введите запрос",
|
||||
"searchSuccess": "Найдено записей: {count}",
|
||||
"searchNoResults": "В памяти ничего не найдено",
|
||||
"searchError": "Ошибка поиска",
|
||||
"addSuccess": "Данные успешно добавлены в память!",
|
||||
"addError": "Не удалось добавить данные",
|
||||
"factDetailsError": "Ошибка загрузки деталей",
|
||||
"metadataParseError": "Не удалось разобрать метаданные",
|
||||
"relationNoMemoryData": "У этой связи нет ассоциированных данных"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"login": "Вход",
|
||||
"username": "Имя пользователя",
|
||||
"password": "Пароль",
|
||||
"defaultHint": "Логин и пароль по умолчанию: astrbot",
|
||||
"logo": {
|
||||
"title": "Панель управления AstrBot",
|
||||
"subtitle": "Добро пожаловать"
|
||||
},
|
||||
"theme": {
|
||||
"switchToDark": "Перейти на темную тему",
|
||||
"switchToLight": "Перейти на светлую тему"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"messageCount": "Количество сообщений",
|
||||
"time": "Время"
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
{
|
||||
"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": "Вставить изображение"
|
||||
},
|
||||
"streaming": {
|
||||
"enabled": "Потоковый ответ включен",
|
||||
"disabled": "Потоковый ответ выключен",
|
||||
"on": "Поток",
|
||||
"off": "Обычный"
|
||||
},
|
||||
"transport": {
|
||||
"title": "Протокол передачи",
|
||||
"sse": "SSE",
|
||||
"websocket": "WebSocket"
|
||||
},
|
||||
"config": {
|
||||
"title": "Конфигурация"
|
||||
},
|
||||
"reasoning": {
|
||||
"thinking": "Рассуждение"
|
||||
},
|
||||
"reply": {
|
||||
"replyTo": "В ответ на",
|
||||
"notFound": "Сообщение не найдено"
|
||||
},
|
||||
"project": {
|
||||
"title": "Проект",
|
||||
"create": "Создать проект",
|
||||
"edit": "Изменить проект",
|
||||
"name": "Имя проекта",
|
||||
"emoji": "Иконка (Emoji)",
|
||||
"description": "Описание проекта (опционально)",
|
||||
"noSessions": "В этом проекте пока нет диалогов",
|
||||
"confirmDelete": "Вы уверены, что хотите удалить проект «{title}»? Диалоги внутри проекта не будут удалены."
|
||||
},
|
||||
"time": {
|
||||
"today": "Сегодня",
|
||||
"yesterday": "Вчера"
|
||||
},
|
||||
"stats": {
|
||||
"tokens": "Токены",
|
||||
"inputTokens": "Входящие",
|
||||
"outputTokens": "Исходящие",
|
||||
"cachedTokens": "Кэшированные",
|
||||
"duration": "Время",
|
||||
"ttft": "Время до первого токена"
|
||||
},
|
||||
"refs": {
|
||||
"title": "Ссылки",
|
||||
"sources": "Источники"
|
||||
},
|
||||
"connection": {
|
||||
"title": "Статус подключения",
|
||||
"message": "Системе необходимо переустановить соединение с чатом.",
|
||||
"reasons": "Это может быть вызвано следующими причинами:",
|
||||
"reasonWindowResize": "Изменение размера окна (нормально)",
|
||||
"reasonMultipleTabs": "Страница чата открыта в другой вкладке",
|
||||
"reasonNetworkIssue": "Временная проблема с сетью",
|
||||
"notice": "Примечание: для стабильной работы допускается только одно активное соединение. Если вы используете чат в нескольких вкладках, рекомендуем оставить только одну.",
|
||||
"understand": "Понятно",
|
||||
"status": {
|
||||
"reconnecting": "Переподключение...",
|
||||
"reconnected": "Соединение восстановлено",
|
||||
"failed": "Ошибка подключения, обновите страницу"
|
||||
}
|
||||
},
|
||||
"errors": {
|
||||
"sendMessageFailed": "Ошибка отправки сообщения, попробуйте еще раз",
|
||||
"createSessionFailed": "Ошибка создания сессии, обновите страницу"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
{
|
||||
"title": "Управление командами",
|
||||
"summary": {
|
||||
"total": "Всего команд",
|
||||
"disabled": "Отключено",
|
||||
"conflicts": "Конфликты"
|
||||
},
|
||||
"conflictAlert": {
|
||||
"title": "Обнаружены конфликты команд",
|
||||
"description": "Сейчас конфликтуют {count} пары команд. Это может привести к одновременному срабатыванию нескольких плагинов и непредсказуемому поведению.",
|
||||
"hint": "Нажмите «Переименовать», чтобы изменить название конфликтующей команды."
|
||||
},
|
||||
"table": {
|
||||
"headers": {
|
||||
"command": "Команда",
|
||||
"type": "Тип",
|
||||
"plugin": "Плагин",
|
||||
"description": "Описание",
|
||||
"permission": "Доступ",
|
||||
"status": "Статус",
|
||||
"actions": "Действия"
|
||||
}
|
||||
},
|
||||
"type": {
|
||||
"command": "Команда",
|
||||
"group": "Группа команд",
|
||||
"subCommand": "Под-команда"
|
||||
},
|
||||
"status": {
|
||||
"enabled": "Активна",
|
||||
"disabled": "Отключена",
|
||||
"conflict": "Конфликт"
|
||||
},
|
||||
"permission": {
|
||||
"everyone": "Все",
|
||||
"admin": "Админ"
|
||||
},
|
||||
"tooltips": {
|
||||
"enable": "Включить",
|
||||
"disable": "Выключить",
|
||||
"rename": "Переименовать",
|
||||
"viewDetails": "Подробности"
|
||||
},
|
||||
"dialogs": {
|
||||
"rename": {
|
||||
"title": "Переименование команды",
|
||||
"newName": "Новое название",
|
||||
"aliases": "Управление алиасами",
|
||||
"addAlias": "Добавить алиас",
|
||||
"cancel": "Отмена",
|
||||
"confirm": "Подтвердить"
|
||||
},
|
||||
"details": {
|
||||
"title": "Детали команды",
|
||||
"type": "Тип команды",
|
||||
"handler": "Обработчик (Handler)",
|
||||
"module": "Путь к модулю",
|
||||
"originalCommand": "Исходная команда",
|
||||
"effectiveCommand": "Действующая команда",
|
||||
"parentGroup": "Родительская группа",
|
||||
"subCommands": "Под-команды",
|
||||
"aliases": "Алиасы (Синонимы)",
|
||||
"permission": "Требования прав",
|
||||
"conflictStatus": "Статус конфликта"
|
||||
}
|
||||
},
|
||||
"messages": {
|
||||
"toggleSuccess": "Статус команды обновлен",
|
||||
"toggleFailed": "Не удалось изменить статус команды",
|
||||
"renameSuccess": "Команда переименована",
|
||||
"renameFailed": "Ошибка переименования",
|
||||
"loadFailed": "Ошибка загрузки списка команд",
|
||||
"updateSuccess": "Обновлено успешно",
|
||||
"updateFailed": "Ошибка обновления"
|
||||
},
|
||||
"search": {
|
||||
"placeholder": "Поиск команд..."
|
||||
},
|
||||
"empty": {
|
||||
"noCommands": "Команд не найдено",
|
||||
"noCommandsDesc": "По вашему запросу не найдено ни одной команды"
|
||||
},
|
||||
"filters": {
|
||||
"all": "Все",
|
||||
"enabled": "Активные",
|
||||
"disabled": "Отключенные",
|
||||
"conflict": "Конфликтующие",
|
||||
"byPlugin": "По плагину",
|
||||
"byType": "По типу",
|
||||
"byPermission": "По правам",
|
||||
"byStatus": "По статусу",
|
||||
"showSystemPlugins": "Показывать системные плагины",
|
||||
"systemPluginConflictHint": "Конфликт затрагивает системный плагин, его нельзя скрыть до разрешения конфликта"
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,129 @@
|
||||
{
|
||||
"title": "Конфигурация",
|
||||
"subtitle": "Управление системными настройками",
|
||||
"editor": {
|
||||
"visual": "Визуальный редактор",
|
||||
"code": "Редактор кода",
|
||||
"revertCode": "Отменить изменения",
|
||||
"applyConfig": "Применить",
|
||||
"applyTip": "Кнопка «Применить» временно фиксирует изменения в визуальном редакторе. Чтобы сохранить их на постоянной основе, нажмите кнопку «Сохранить» в правом нижнем углу."
|
||||
},
|
||||
"actions": {
|
||||
"save": "Сохранить",
|
||||
"delete": "Удалить",
|
||||
"add": "Добавить",
|
||||
"reset": "Сбросить настройки",
|
||||
"export": "Экспорт",
|
||||
"import": "Импорт",
|
||||
"validate": "Проверить"
|
||||
},
|
||||
"help": {
|
||||
"documentation": "Документация",
|
||||
"support": "Поддержка",
|
||||
"helpText": "Нужна помощь? См. {documentation} или обратитесь в {support}.",
|
||||
"helpPrefix": "Нужна помощь? См.",
|
||||
"helpMiddle": "или обратитесь в",
|
||||
"helpSuffix": "."
|
||||
},
|
||||
"messages": {
|
||||
"configApplied": "Настройки применены образно. Нажмите «Сохранить» для окончательной записи.",
|
||||
"configApplyError": "Ошибка применения: некорректный формат JSON.",
|
||||
"unsavedChangesNotice": "Есть несохраненные изменения. Пожалуйста, нажмите «Сохранить», чтобы они вступили в силу.",
|
||||
"saveSuccess": "Настройки успешно сохранены",
|
||||
"saveError": "Ошибка при сохранении",
|
||||
"loadError": "Ошибка при загрузке настроек",
|
||||
"deleteSuccess": "Удалено",
|
||||
"deleteError": "Ошибка удаления",
|
||||
"updateSuccess": "Обновлено",
|
||||
"updateError": "Ошибка обновления"
|
||||
},
|
||||
"sections": {
|
||||
"general": "Основные",
|
||||
"advanced": "Расширенные",
|
||||
"security": "Безопасность",
|
||||
"appearance": "Внешний вид",
|
||||
"notification": "Уведомления"
|
||||
},
|
||||
"general": {
|
||||
"botName": "Имя бота",
|
||||
"language": "Язык интерфейса",
|
||||
"timezone": "Часовой пояс",
|
||||
"autoSave": "Автосохранение",
|
||||
"debugMode": "Режим отладки"
|
||||
},
|
||||
"advanced": {
|
||||
"logLevel": "Уровень логирования",
|
||||
"maxConnections": "Макс. соединений",
|
||||
"timeout": "Тайм-аут",
|
||||
"retryAttempts": "Попытки повтора",
|
||||
"cacheSize": "Размер кэша"
|
||||
},
|
||||
"security": {
|
||||
"apiKey": "Ключ API",
|
||||
"allowedHosts": "Разрешенные хосты",
|
||||
"rateLimit": "Лимит запросов",
|
||||
"encryption": "Шифрование"
|
||||
},
|
||||
"configSelection": {
|
||||
"selectConfig": "Выбор конфигурации",
|
||||
"normalConfig": "Обычная",
|
||||
"systemConfig": "Системная"
|
||||
},
|
||||
"search": {
|
||||
"placeholder": "Поиск по настройкам (поле/описание/подсказка)",
|
||||
"noResult": "Совпадений не найдено"
|
||||
},
|
||||
"configManagement": {
|
||||
"title": "Управление конфигурациями",
|
||||
"description": "AstrBot поддерживает несколько конфигураций для разных ботов. По умолчанию используется «default».",
|
||||
"newConfig": "Новая конфигурация",
|
||||
"editConfig": "Изменить конфигурацию",
|
||||
"manageConfigs": "Управление файлами...",
|
||||
"configName": "Имя",
|
||||
"fillConfigName": "Введите имя конфигурации",
|
||||
"confirmDelete": "Вы уверены, что хотите удалить конфигурацию «{name}»? Это действие необратимо.",
|
||||
"pleaseEnterName": "Пожалуйста, введите имя",
|
||||
"createFailed": "Ошибка создания конфигурации",
|
||||
"deleteFailed": "Ошибка удаления",
|
||||
"updateFailed": "Ошибка обновления"
|
||||
},
|
||||
"buttons": {
|
||||
"cancel": "Отмена",
|
||||
"create": "Создать",
|
||||
"update": "Обновить"
|
||||
},
|
||||
"codeEditor": {
|
||||
"title": "Редактирование файла"
|
||||
},
|
||||
"fileUpload": {
|
||||
"button": "Файлы",
|
||||
"dialogTitle": "Загруженные файлы",
|
||||
"dropzone": "Загрузить файлы",
|
||||
"allowedTypes": "Разрешенные типы: {types}",
|
||||
"empty": "Файлов нет",
|
||||
"statusMissing": "Файл отсутствует",
|
||||
"statusUnconfigured": "Не в конфиге",
|
||||
"uploadSuccess": "Загружено файлов: {count}",
|
||||
"uploadFailed": "Ошибка загрузки",
|
||||
"loadFailed": "Ошибка получения списка файлов",
|
||||
"fileTooLarge": "Файл слишком велик (макс. {max} МБ): {name}",
|
||||
"deleteSuccess": "Файл удален",
|
||||
"deleteFailed": "Ошибка удаления",
|
||||
"addToConfig": "Добавлено в конфигурацию",
|
||||
"fileCount": "Файлов: {count}",
|
||||
"done": "Готово"
|
||||
},
|
||||
"unsavedChangesWarning": {
|
||||
"dialogTitle": "Несохраненные изменения",
|
||||
"leavePage": "У вас есть несохраненные изменения. Сохранить перед уходом?",
|
||||
"switchConfig": "Переключение конфигурации приведет к потере несохраненных изменений. Сохранить?",
|
||||
"options": {
|
||||
"save": "Сохранить",
|
||||
"saveAndSwitch": "Сохранить и переключить",
|
||||
"discardAndSwitch": "Сбросить и переключить",
|
||||
"closeCard": "Закрыть",
|
||||
"confirm": "ОК",
|
||||
"cancel": "Отмена"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"title": "Логи платформы",
|
||||
"autoScroll": {
|
||||
"enabled": "Автопрокрутка включена",
|
||||
"disabled": "Автопрокрутка выключена"
|
||||
},
|
||||
"pipInstall": {
|
||||
"button": "Установить pip-пакет",
|
||||
"dialogTitle": "Установка Pip-пакета",
|
||||
"packageLabel": "*Имя пакета, например: llmtuner",
|
||||
"mirrorLabel": "Использовать зеркало PyPI (опционально)",
|
||||
"mirrorHint": "Приоритет зеркала PyPI > настройки «Зеркало репозитория PyPI»",
|
||||
"installButton": "Установить"
|
||||
},
|
||||
"debugHint": {
|
||||
"text": "Для отображения Debug-логов необходимо установить соответствующий уровень в «Конфигурация → Система → Уровень логирования»"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
{
|
||||
"title": "Управление диалогами",
|
||||
"subtitle": "Просмотр и управление историей сообщений",
|
||||
"filters": {
|
||||
"title": "Фильтры",
|
||||
"platform": "ID бота",
|
||||
"type": "Тип",
|
||||
"search": "Поиск по ключевым словам",
|
||||
"reset": "Сбросить"
|
||||
},
|
||||
"history": {
|
||||
"title": "История",
|
||||
"refresh": "Обновить"
|
||||
},
|
||||
"batch": {
|
||||
"deleteSelected": "Удалить выбранные ({count})",
|
||||
"exportSelected": "Экспорт выбранных ({count})"
|
||||
},
|
||||
"pagination": {
|
||||
"itemsPerPage": "на странице",
|
||||
"showingItems": "Показано {start}-{end} из {total}"
|
||||
},
|
||||
"table": {
|
||||
"headers": {
|
||||
"title": "Заголовок диалога",
|
||||
"platform": "ID бота",
|
||||
"type": "Тип сообщения",
|
||||
"cid": "ID диалога",
|
||||
"umo": "Источник сообщения",
|
||||
"sessionId": "ID сессии",
|
||||
"createdAt": "Создан",
|
||||
"updatedAt": "Обновлен",
|
||||
"actions": "Действия"
|
||||
}
|
||||
},
|
||||
"actions": {
|
||||
"view": "Просмотр",
|
||||
"edit": "Редактировать",
|
||||
"delete": "Удалить"
|
||||
},
|
||||
"messageTypes": {
|
||||
"group": "Группа",
|
||||
"friend": "ЛС",
|
||||
"unknown": "Неизвестно"
|
||||
},
|
||||
"status": {
|
||||
"noTitle": "Без заголовка",
|
||||
"unknown": "Неизвестно",
|
||||
"noData": "История диалогов пуста",
|
||||
"emptyContent": "Содержимое диалога пусто",
|
||||
"audioNotSupported": "Ваш браузер не поддерживает воспроизведение аудио."
|
||||
},
|
||||
"dialogs": {
|
||||
"view": {
|
||||
"title": "Детали диалога",
|
||||
"editMode": "Режим редактирования",
|
||||
"previewMode": "Режим просмотра",
|
||||
"saveChanges": "Сохранить изменения",
|
||||
"close": "Закрыть",
|
||||
"confirmClose": "У вас есть несохраненные изменения. Вы уверены, что хотите закрыть?"
|
||||
},
|
||||
"edit": {
|
||||
"title": "Изменить информацию",
|
||||
"titleLabel": "Заголовок диалога",
|
||||
"titlePlaceholder": "Введите заголовок",
|
||||
"cancel": "Отмена",
|
||||
"save": "Сохранить"
|
||||
},
|
||||
"delete": {
|
||||
"title": "Подтверждение удаления",
|
||||
"message": "Вы уверены, что хотите удалить диалог «{title}»? Это действие необратимо.",
|
||||
"cancel": "Отмена",
|
||||
"confirm": "Удалить"
|
||||
},
|
||||
"batchDelete": {
|
||||
"title": "Массовое удаление",
|
||||
"message": "Вы уверены, что хотите удалить {count} выбранных диалогов? Это действие необратимо!",
|
||||
"andMore": "и еще {count}",
|
||||
"cancel": "Отмена",
|
||||
"confirm": "Удалить всё",
|
||||
"warning": "Внимание: удаление нельзя будет отменить!"
|
||||
}
|
||||
},
|
||||
"messages": {
|
||||
"fetchError": "Не удалось загрузить список диалогов",
|
||||
"saveSuccess": "Сохранено",
|
||||
"saveError": "Ошибка сохранения",
|
||||
"deleteSuccess": "Удалено",
|
||||
"deleteError": "Ошибка удаления",
|
||||
"historyError": "Не удалось загрузить историю диалога",
|
||||
"historySaveSuccess": "История сохранена",
|
||||
"historySaveError": "Ошибка сохранения истории",
|
||||
"invalidJson": "Некорректный формат JSON",
|
||||
"noItemSelected": "Сначала выберите диалоги для удаления",
|
||||
"batchDeleteSuccess": "Успешно удалено {count} диалогов",
|
||||
"batchDeleteError": "Ошибка массового удаления",
|
||||
"batchDeletePartial": "Удаление завершено: успешно {deleted}, ошибок {failed}",
|
||||
"exportSuccess": "Экспорт завершен",
|
||||
"exportError": "Ошибка экспорта",
|
||||
"noItemSelectedForExport": "Сначала выберите диалоги для экспорта"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
{
|
||||
"page": {
|
||||
"title": "Запланированные задачи",
|
||||
"beta": "Экспериментальные функции",
|
||||
"subtitle": "Управление будущими задачами AstrBot. Бот автоматически проснется, выполнит задачу и отправит результат. Требуется включить «Проактивные способности» в конфигурации.",
|
||||
"proactive": {
|
||||
"supported": "Отправка результатов поддерживается на платформах: {platforms}",
|
||||
"unsupported": "Нет платформ, поддерживающих проактивные сообщения. Включите их в настройках платформ."
|
||||
}
|
||||
},
|
||||
"actions": {
|
||||
"create": "Новая задача",
|
||||
"refresh": "Обновить",
|
||||
"delete": "Удалить",
|
||||
"cancel": "Отмена",
|
||||
"submit": "Создать"
|
||||
},
|
||||
"table": {
|
||||
"title": "Список задач",
|
||||
"empty": "Задач пока нет.",
|
||||
"headers": {
|
||||
"name": "Имя",
|
||||
"type": "Тип",
|
||||
"cron": "Cron",
|
||||
"session": "ID сессии",
|
||||
"nextRun": "Следующий запуск",
|
||||
"lastRun": "Последний запуск",
|
||||
"note": "Описание",
|
||||
"actions": "Действия"
|
||||
},
|
||||
"type": {
|
||||
"once": "Разовая",
|
||||
"recurring": "Повторяющаяся",
|
||||
"activeAgent": "Активный агент",
|
||||
"workflow": "Рабочий процесс",
|
||||
"unknown": "{type}"
|
||||
},
|
||||
"timezoneLocal": "Местное время",
|
||||
"notAvailable": "—"
|
||||
},
|
||||
"form": {
|
||||
"title": "Создать задачу",
|
||||
"chatHint": "Вы можете ставить задачи прямо в чате, AstrBot создаст их автоматически без заполнения этой формы.",
|
||||
"runOnce": "Разовая задача",
|
||||
"name": "Имя задачи",
|
||||
"note": "Описание",
|
||||
"cron": "Cron-выражения",
|
||||
"cronPlaceholder": "0 9 * * *",
|
||||
"runAt": "Время запуска",
|
||||
"session": "Целевая сессия (platform_id:message_type:session_id)",
|
||||
"timezone": "Часовой пояс (опционально, напр. Europe/Moscow)",
|
||||
"enabled": "Включено"
|
||||
},
|
||||
"messages": {
|
||||
"loadFailed": "Ошибка загрузки задач",
|
||||
"updateFailed": "Ошибка обновления",
|
||||
"deleteSuccess": "Удалено",
|
||||
"deleteFailed": "Ошибка удаления",
|
||||
"sessionRequired": "Укажите сессию",
|
||||
"noteRequired": "Заполните описание",
|
||||
"cronRequired": "Укажите Cron-выражение",
|
||||
"runAtRequired": "Выберите время запуска",
|
||||
"createSuccess": "Задача создана",
|
||||
"createFailed": "Ошибка создания"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
{
|
||||
"title": "Логи платформы",
|
||||
"subtitle": "Мониторинг и статистика в реальном времени",
|
||||
"lastUpdate": "Последнее обновление",
|
||||
"status": {
|
||||
"loading": "Загрузка...",
|
||||
"dataError": "Ошибка получения данных",
|
||||
"noticeError": "Ошибка получения объявлений",
|
||||
"online": "В сети",
|
||||
"uptime": "Время работы",
|
||||
"memoryUsage": "Память"
|
||||
},
|
||||
"stats": {
|
||||
"totalMessage": {
|
||||
"title": "Всего сообщений",
|
||||
"subtitle": "Все сообщения со всех платформ"
|
||||
},
|
||||
"onlinePlatform": {
|
||||
"title": "Платформы",
|
||||
"subtitle": "Количество подключенных платформ"
|
||||
},
|
||||
"runningTime": {
|
||||
"title": "Время работы",
|
||||
"subtitle": "Общее время работы системы",
|
||||
"format": "{hours} ч. {minutes} мин. {seconds} сек."
|
||||
},
|
||||
"memoryUsage": {
|
||||
"title": "Память",
|
||||
"subtitle": "Использование оперативной памяти",
|
||||
"cpuLoad": "Загрузка CPU",
|
||||
"status": {
|
||||
"good": "Отлично",
|
||||
"normal": "Нормально",
|
||||
"high": "Высокая"
|
||||
}
|
||||
}
|
||||
},
|
||||
"charts": {
|
||||
"messageTrend": {
|
||||
"title": "Тренды сообщений",
|
||||
"subtitle": "Изменение количества сообщений во времени",
|
||||
"totalMessages": "Всего сообщений",
|
||||
"dailyAverage": "В среднем за день",
|
||||
"growthRate": "Скорость роста",
|
||||
"timeLabel": "Время",
|
||||
"messageCount": "Кол-во сообщений",
|
||||
"timeRanges": {
|
||||
"1day": "За 1 день",
|
||||
"3days": "За 3 дня",
|
||||
"1week": "За 7 дней",
|
||||
"1month": "За 30 дней"
|
||||
}
|
||||
},
|
||||
"platformStat": {
|
||||
"title": "Статистика по платформам",
|
||||
"subtitle": "Распределение сообщений по платформам",
|
||||
"total": "Всего",
|
||||
"noData": "Нет данных по платформам",
|
||||
"messageUnit": "шт.",
|
||||
"platformCount": "Кол-во платформ",
|
||||
"mostActive": "Самый активный",
|
||||
"totalPercentage": "Доля от общего числа"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,358 @@
|
||||
{
|
||||
"title": "Плагины",
|
||||
"subtitle": "Управление и настройка расширений системы",
|
||||
"tabs": {
|
||||
"installedPlugins": "Плагины AstrBot",
|
||||
"market": "Магазин плагинов",
|
||||
"installedMcpServers": "MCP",
|
||||
"skills": "Навыки",
|
||||
"handlersOperation": "Управление поведением"
|
||||
},
|
||||
"titles": {
|
||||
"installedAstrBotPlugins": "Установленные плагины AstrBot"
|
||||
},
|
||||
"failedPlugins": {
|
||||
"title": "Ошибка загрузки ({count})",
|
||||
"hint": "Эти плагины не удалось загрузить. Вы можете попробовать перезагрузить их или удалить.",
|
||||
"columns": {
|
||||
"plugin": "Плагин",
|
||||
"error": "Ошибка"
|
||||
}
|
||||
},
|
||||
"search": {
|
||||
"placeholder": "Поиск плагинов...",
|
||||
"marketPlaceholder": "Поиск в магазине..."
|
||||
},
|
||||
"filters": {
|
||||
"all": "Все"
|
||||
},
|
||||
"views": {
|
||||
"card": "Плитка",
|
||||
"list": "Список"
|
||||
},
|
||||
"buttons": {
|
||||
"showSystemPlugins": "Показать системные",
|
||||
"hideSystemPlugins": "Скрыть системные",
|
||||
"install": "Установить",
|
||||
"uninstall": "Удалить",
|
||||
"update": "Обновить",
|
||||
"reload": "Перезагрузить",
|
||||
"enable": "Включить",
|
||||
"disable": "Выключить",
|
||||
"configure": "Настроить",
|
||||
"viewInfo": "Детали",
|
||||
"viewDocs": "Документация",
|
||||
"viewRepo": "Репозиторий",
|
||||
"close": "Закрыть",
|
||||
"save": "Сохранить",
|
||||
"saveAndClose": "Сохранить и закрыть",
|
||||
"cancel": "Отмена",
|
||||
"actions": "Действия",
|
||||
"back": "Назад",
|
||||
"selectFile": "Выбрать файл",
|
||||
"refresh": "Обновить",
|
||||
"updateAll": "Обновить все",
|
||||
"deleteSource": "Удалить источник",
|
||||
"reshuffle": "Мне повезет!"
|
||||
},
|
||||
"status": {
|
||||
"enabled": "Включен",
|
||||
"disabled": "Выключен",
|
||||
"system": "Системный",
|
||||
"loading": "Загрузка...",
|
||||
"installed": "Установлен",
|
||||
"unknown": "Неизвестно"
|
||||
},
|
||||
"tooltips": {
|
||||
"enable": "Включить",
|
||||
"disable": "Выключить",
|
||||
"reload": "Перезагрузить",
|
||||
"configure": "Настроить",
|
||||
"viewInfo": "Просмотр поведения",
|
||||
"viewDocs": "Документация",
|
||||
"update": "Обновить",
|
||||
"uninstall": "Удалить"
|
||||
},
|
||||
"table": {
|
||||
"headers": {
|
||||
"name": "Имя",
|
||||
"description": "Описание",
|
||||
"version": "Версия",
|
||||
"author": "Автор",
|
||||
"status": "Статус",
|
||||
"actions": "Действия",
|
||||
"stars": "Звезды",
|
||||
"lastUpdate": "Обновлен",
|
||||
"tags": "Теги",
|
||||
"eventType": "Тип события",
|
||||
"specificType": "Тип",
|
||||
"trigger": "Триггер"
|
||||
}
|
||||
},
|
||||
"empty": {
|
||||
"noPlugins": "Плагины не найдены",
|
||||
"noPluginsDesc": "Попробуйте установить новые плагины или включите отображение системных."
|
||||
},
|
||||
"market": {
|
||||
"recommended": "🥳 Рекомендуем",
|
||||
"allPlugins": "📦 Все плагины",
|
||||
"showFullName": "Полное имя",
|
||||
"devDocs": "Документация для разработчиков",
|
||||
"submitRepo": "Добавить репозиторий",
|
||||
"customSource": "Свои источники",
|
||||
"source": "Источник",
|
||||
"availableSources": "Доступные источники",
|
||||
"sourceManagement": "Управление источниками",
|
||||
"addSource": "Добавить источник",
|
||||
"sourceName": "Имя",
|
||||
"sourceUrl": "Исходный URL",
|
||||
"defaultSource": "Источник по умолчанию",
|
||||
"removeSource": "Удалить источник",
|
||||
"confirmRemoveSource": "Вы уверены, что хотите удалить этот источник плагинов?",
|
||||
"sourceAdded": "Источник успешно добавлен",
|
||||
"sourceRemoved": "Источник удален",
|
||||
"sourceError": "Ошибка операции",
|
||||
"selectSource": "Выбрать источник",
|
||||
"currentSource": "Текущий источник",
|
||||
"editSource": "Изменить источник",
|
||||
"sourceUpdated": "Источник обновлен",
|
||||
"defaultOfficialSource": "Официальный источник",
|
||||
"sourceExists": "Этот источник уже есть в списке",
|
||||
"installPlugin": "Установить плагин",
|
||||
"randomPlugins": "🎲 Случайные плагины",
|
||||
"showRandomPlugins": "Показать случайные",
|
||||
"hideRandomPlugins": "Скрыть случайные",
|
||||
"sourceSafetyWarning": "Даже при использовании источников по умолчанию мы не можем гарантировать 100% безопасность и стабильность сторонних плагинов. Пожалуйста, будьте внимательны."
|
||||
},
|
||||
"sort": {
|
||||
"by": "Сортировать по",
|
||||
"default": "По умолчанию",
|
||||
"installTime": "Дате установки",
|
||||
"name": "Имени",
|
||||
"stars": "Звездам",
|
||||
"author": "Автору",
|
||||
"updated": "Дате обновления",
|
||||
"updateStatus": "Статусу обновления",
|
||||
"ascending": "По возрастанию",
|
||||
"descending": "По убыванию"
|
||||
},
|
||||
"tags": {
|
||||
"danger": "Опасно"
|
||||
},
|
||||
"dialogs": {
|
||||
"error": {
|
||||
"title": "Ошибка",
|
||||
"checkConsole": "Подробности смотрите в логах платформы"
|
||||
},
|
||||
"config": {
|
||||
"title": "Настройка плагина",
|
||||
"noConfig": "У этого плагина нет настраиваемых параметров"
|
||||
},
|
||||
"loading": {
|
||||
"title": "Загрузка...",
|
||||
"logs": "Логи"
|
||||
},
|
||||
"uninstall": {
|
||||
"title": "Подтверждение удаления",
|
||||
"message": "Вы уверены, что хотите удалить этот плагин?",
|
||||
"deleteConfig": "Удалить файл конфигурации плагина",
|
||||
"deleteData": "Удалить сохраненные данные плагина",
|
||||
"configHint": "Конфиг находится в data/config",
|
||||
"dataHint": "Данные находятся в data/plugin_data и data/plugins_data"
|
||||
},
|
||||
"install": {
|
||||
"title": "Установка плагина",
|
||||
"fromFile": "Из файла",
|
||||
"fromUrl": "По ссылке",
|
||||
"supportPlatformsCount": "Поддерживает платформ: {count}"
|
||||
},
|
||||
"danger_warning": {
|
||||
"title": "Внимание!",
|
||||
"message": "Этот плагин может содержать небезопасный код или функции, которые могут привести к нестабильности системы или потере данных. Вы уверены, что хотите продолжить установку?",
|
||||
"confirm": "Продолжить",
|
||||
"cancel": "Отмена"
|
||||
},
|
||||
"versionCompatibility": {
|
||||
"title": "Предупреждение о версии",
|
||||
"message": "Требуемая плагином версия AstrBot не совпадает с вашей текущей версией. Вы можете продолжить установку на свой страх и риск.",
|
||||
"confirm": "Игнорировать и установить",
|
||||
"cancel": "Отмена"
|
||||
},
|
||||
"forceUpdate": {
|
||||
"title": "Новых версий не найдено",
|
||||
"message": "Новых версий не обнаружено. Выполнить принудительную переустановку из удаленного репозитория?",
|
||||
"confirm": "Принудительно"
|
||||
},
|
||||
"updateAllConfirm": {
|
||||
"title": "Обновить всё",
|
||||
"message": "Обновить все плагины ({count} шт.)? Это может занять некоторое время.",
|
||||
"confirm": "Подтвердить"
|
||||
}
|
||||
},
|
||||
"messages": {
|
||||
"uninstalling": "Удаление",
|
||||
"refreshing": "Обновление списка плагинов...",
|
||||
"refreshSuccess": "Список плагинов обновлен",
|
||||
"refreshFailed": "Ошибка при обновлении списка",
|
||||
"operationFailed": "Ошибка операции",
|
||||
"reloadSuccess": "Перезагрузка завершена",
|
||||
"reloadFailed": "Ошибка перезагрузки",
|
||||
"updateSuccess": "Обновление завершено",
|
||||
"addSuccess": "Успешно добавлено",
|
||||
"saveSuccess": "Сохранено",
|
||||
"deleteSuccess": "Удалено",
|
||||
"installing": "Установка из файла...",
|
||||
"installingFromUrl": "Установка по ссылке...",
|
||||
"installFailed": "Ошибка установки:",
|
||||
"getMarketDataFailed": "Ошибка получения данных магазина:",
|
||||
"hasUpdate": "Доступно обновление:",
|
||||
"confirmDelete": "Вы уверены, что хотите удалить плагин?",
|
||||
"fillUrlOrFile": "Укажите ссылку или выберите файл",
|
||||
"dontFillBoth": "Пожалуйста, используйте либо ссылку, либо файл, но не оба сразу",
|
||||
"supportedFormats": "Поддерживаются файлы плагинов в формате .zip",
|
||||
"updateAllSuccess": "Все плагины успешно обновлены",
|
||||
"updateAllFailed": "Ошибок при обновлении: {failed} из {total}:",
|
||||
"fillSourceNameAndUrl": "Пожалуйста, введите имя и адрес источника",
|
||||
"invalidUrl": "Введите корректный URL",
|
||||
"enterJsonUrl": "Введите URL, возвращающий список плагинов в формате JSON"
|
||||
},
|
||||
"upload": {
|
||||
"fromFile": "Загрузить файл",
|
||||
"fromUrl": "Указать ссылку",
|
||||
"selectFile": "Выбрать файл",
|
||||
"enterUrl": "Ссылка на репозиторий"
|
||||
},
|
||||
"skills": {
|
||||
"modeLocal": "Локальные навыки",
|
||||
"modeNeo": "Навыки Neo",
|
||||
"actions": "Действия",
|
||||
"upload": "Загрузить навыки",
|
||||
"refresh": "Обновить",
|
||||
"empty": "Навыки не найдены",
|
||||
"emptyHint": "Пожалуйста, загрузите архив с навыками",
|
||||
"uploadDialogTitle": "Загрузка навыков",
|
||||
"uploadHint": "Поддерживается массовая загрузка zip-архивов. Вы также можете перетащить файлы в это окно. Система автоматически проверит структуру каждого архива.",
|
||||
"structureRequirement": "Архив должен содержать одну корневую папку (например, `skillname/`), внутри которой обязательно должен находиться файл `SKILL.md`.",
|
||||
"abilityMultiple": "Поддержка массовой загрузки",
|
||||
"abilityValidate": "Автопроверка `SKILL.md`",
|
||||
"abilitySkip": "Пропуск дубликатов",
|
||||
"selectFile": "Выбрать файл",
|
||||
"selectFiles": "Выбрать файлы",
|
||||
"dropzoneTitle": "Перетащите zip-файлы сюда",
|
||||
"dropzoneAction": "или нажмите, чтобы выбрать файлы на компьютере",
|
||||
"dropzoneHint": "Система проверит структуру архивов перед загрузкой",
|
||||
"fileListTitle": "Очередь загрузки",
|
||||
"fileListEmpty": "Здесь будет отображаться статус проверки и загрузки файлов",
|
||||
"uploading": "Загрузка...",
|
||||
"batchResultTitle": "Результаты загрузки",
|
||||
"batchResultSummary": "Всего: {total}, успешно: {success}",
|
||||
"batchSuccessList": "Успешно загружено",
|
||||
"batchFailedList": "Ошибка загрузки",
|
||||
"confirm": "ОК",
|
||||
"confirmUpload": "Начать загрузку",
|
||||
"cancel": "Отмена",
|
||||
"statusWaiting": "В очереди",
|
||||
"statusUploading": "Загрузка...",
|
||||
"statusSuccess": "Готово",
|
||||
"statusError": "Ошибка структуры",
|
||||
"statusSkipped": "Пропущено",
|
||||
"summaryTotal": "Всего: {count}",
|
||||
"summaryReady": "Готовы: {count}",
|
||||
"summarySuccess": "Успешно: {count}",
|
||||
"summaryFailed": "Ошибок: {count}",
|
||||
"summarySkipped": "Дубликатов: {count}",
|
||||
"validationReady": "Ожидает загрузки (проверка структуры будет выполнена автоматически)",
|
||||
"validationZipOnly": "Допускаются только zip-архивы",
|
||||
"validationDuplicate": "Файл уже есть в списке, пропуск",
|
||||
"validationUploading": "Проверка и загрузка...",
|
||||
"validationUploadFailed": "Ошибка загрузки, попробуйте еще раз",
|
||||
"validationUploadedAs": "Установлено как {name}",
|
||||
"validationNoResult": "Результат не получен, проверьте логи платформы",
|
||||
"noDescription": "Нет описания",
|
||||
"path": "Путь",
|
||||
"uploadSuccess": "Успешно загружено",
|
||||
"uploadFailed": "Ошибка загрузки",
|
||||
"download": "Скачать",
|
||||
"downloadSuccess": "Скачивание начато",
|
||||
"downloadFailed": "Ошибка скачивания",
|
||||
"loadFailed": "Не удалось загрузить навыки",
|
||||
"updateSuccess": "Обновлено",
|
||||
"updateFailed": "Ошибка обновления",
|
||||
"deleteTitle": "Подтверждение удаления",
|
||||
"deleteMessage": "Вы уверены, что хотите удалить этот навык?",
|
||||
"deleteSuccess": "Удалено",
|
||||
"deleteFailed": "Ошибка удаления",
|
||||
"neoSkillKey": "Фильтр по ключу",
|
||||
"neoStatus": "Статус кандидата",
|
||||
"neoStage": "Этап публикации",
|
||||
"neoFilterHint": "Фильтрация записей о публикации",
|
||||
"neoAll": "Все",
|
||||
"neoCandidates": "Кандидаты Neo",
|
||||
"neoReleases": "Релизы Neo",
|
||||
"neoLoadFailed": "Ошибка загрузки данных Neo Skills",
|
||||
"neoPass": "Одобрить",
|
||||
"neoReject": "Отклонить",
|
||||
"neoEvaluateSuccess": "Оценка обновлена",
|
||||
"neoEvaluateFailed": "Ошибка обновления оценки",
|
||||
"neoPromoteSuccess": "Опубликовано",
|
||||
"neoPromoteFailed": "Ошибка публикации",
|
||||
"neoRollback": "Откат",
|
||||
"neoRollbackSuccess": "Откат выполнен",
|
||||
"neoRollbackFailed": "Ошибка отката",
|
||||
"neoDeactivate": "Деактивация",
|
||||
"neoDeactivateSuccess": "Деактивировано",
|
||||
"neoDeactivateFailed": "Ошибка деактивации",
|
||||
"neoSync": "Синхронизация",
|
||||
"neoSyncSuccess": "Синхронизировано",
|
||||
"neoSyncFailed": "Ошибка синхронизации",
|
||||
"neoDelete": "Удалить",
|
||||
"neoDeleteSuccess": "Удалено",
|
||||
"neoDeleteFailed": "Ошибка удаления",
|
||||
"neoPayloadTitle": "Детали Neo Payload",
|
||||
"neoPayloadFailed": "Ошибка чтения Payload",
|
||||
"runtimeNoneWarning": "Среда выполнения Computer Use не задана. Навыки могут не работать, так как нет активного окружения.",
|
||||
"runtimeHint": "Установите среду выполнения в «local» или «sandbox» в настройках способностей использования компьютера.",
|
||||
"neoRuntimeRequired": "Neo Skills доступны только в среде sandbox с драйвером shipyard_neo.",
|
||||
"sourceLocalOnly": "Локальный навык",
|
||||
"sourceSandboxOnly": "Предустановленный Sandbox навык",
|
||||
"sourceBoth": "Локальный + Sandbox",
|
||||
"sandboxDiscoveryPending": "Предустановленные Sandbox навыки не найдены. Запустите сессию Sandbox хотя бы один раз.",
|
||||
"sandboxPresetReadonly": "Предустановленные навыки Sandbox доступны только для чтения и не могут быть удалены здесь."
|
||||
},
|
||||
"card": {
|
||||
"actions": {
|
||||
"pluginConfig": "Настройки",
|
||||
"uninstallPlugin": "Удалить",
|
||||
"reloadPlugin": "Перезагрузить",
|
||||
"togglePlugin": "Плагин",
|
||||
"viewHandlers": "Действия",
|
||||
"updateTo": "Обновить до",
|
||||
"reinstall": "Переустановить"
|
||||
},
|
||||
"status": {
|
||||
"hasUpdate": "Доступно обновление",
|
||||
"disabled": "Плагин выключен",
|
||||
"handlersCount": "действий",
|
||||
"supportPlatform": "Платформы",
|
||||
"supportPlatformsCount": "Платформ: {count}",
|
||||
"astrbotVersion": "Требуемая версия AstrBot"
|
||||
},
|
||||
"alt": {
|
||||
"logo": "логотип",
|
||||
"extensionIcon": "иконка расширения"
|
||||
},
|
||||
"errors": {
|
||||
"confirmNotRegistered": "$confirm не зарегистрирован"
|
||||
}
|
||||
},
|
||||
"conflicts": {
|
||||
"title": "Конфликт команд",
|
||||
"message": "Обнаружены конфликтующие команды. Это может привести к некорректной работе. Рекомендуется разрешить конфликты в панели «Управление командами».",
|
||||
"pairs": "конфликтующих пар",
|
||||
"goToManage": "Управление",
|
||||
"later": "Позже"
|
||||
},
|
||||
"pluginChangelog": {
|
||||
"menuTitle": "Журнал изменений"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
{
|
||||
"title": "Детали базы знаний",
|
||||
"backToList": "К списку",
|
||||
"tabs": {
|
||||
"overview": "Обзор",
|
||||
"documents": "Документы",
|
||||
"retrieval": "Поиск",
|
||||
"sessions": "Сессии",
|
||||
"settings": "Настройки"
|
||||
},
|
||||
"overview": {
|
||||
"title": "Информация",
|
||||
"name": "Название",
|
||||
"description": "Описание",
|
||||
"emoji": "Иконка",
|
||||
"createdAt": "Создана",
|
||||
"updatedAt": "Обновлена",
|
||||
"stats": "Статистика",
|
||||
"docCount": "Количество документов",
|
||||
"chunkCount": "Количество фрагментов",
|
||||
"embeddingModel": "Embedding модель",
|
||||
"rerankModel": "Rerank модель",
|
||||
"notSet": "не выбрано"
|
||||
},
|
||||
"documents": {
|
||||
"title": "Список документов",
|
||||
"upload": "Загрузить",
|
||||
"empty": "Документов нет",
|
||||
"name": "Имя файла",
|
||||
"type": "Тип",
|
||||
"size": "Размер",
|
||||
"chunks": "Фрагменты",
|
||||
"createdAt": "Дата загрузки",
|
||||
"actions": "Действия",
|
||||
"view": "Смотреть",
|
||||
"delete": "Удалить",
|
||||
"deleteConfirm": "Вы уверены, что хотите удалить «{name}»?",
|
||||
"deleteWarning": "Это удалит файл и все его фрагменты из индекса.",
|
||||
"uploading": "Загрузка...",
|
||||
"uploadSuccess": "Файл успешно загружен",
|
||||
"uploadFailed": "Ошибка загрузки",
|
||||
"deleteSuccess": "Файл удален",
|
||||
"deleteFailed": "Ошибка удаления"
|
||||
},
|
||||
"upload": {
|
||||
"title": "Добавление контента",
|
||||
"selectFile": "Файл",
|
||||
"dropzone": "Нажмите или перетащите файл сюда",
|
||||
"supportedFormats": "Форматы: ",
|
||||
"maxSize": "Максимум: 128MB",
|
||||
"chunkSettings": "Фрагментация",
|
||||
"batchSettings": "Пакетная обработка",
|
||||
"cleaningSettings": "Очистка данных",
|
||||
"enableCleaning": "Включить очистку контента",
|
||||
"cleaningProvider": "Сервис для очистки",
|
||||
"cleaningProviderHint": "LLM провайдер для суммаризации и извлечения смыслов из веб-страниц",
|
||||
"chunkSize": "Размер чанка",
|
||||
"chunkSizeHint": "Символов в блоке (по умолчанию: 512)",
|
||||
"chunkOverlap": "Перекрытие",
|
||||
"chunkOverlapHint": "Перекрытие между блоками (по умолчанию: 50)",
|
||||
"batchSize": "Размер пакета",
|
||||
"batchSizeHint": "Блоков за один запрос (по умолчанию: 32)",
|
||||
"tasksLimit": "Лимит задач",
|
||||
"tasksLimitHint": "Макс. параллельных потоков (по умолчанию: 3)",
|
||||
"maxRetries": "Попытки",
|
||||
"maxRetriesHint": "Повторов при сбое (по умолчанию: 3)",
|
||||
"cancel": "Отмена",
|
||||
"submit": "Загрузить",
|
||||
"fileRequired": "Пожалуйста, выберите файл",
|
||||
"fileUpload": "Загрузка файла",
|
||||
"fromUrl": "Из URL",
|
||||
"urlPlaceholder": "Ссылка на веб-страницу",
|
||||
"urlRequired": "Введите URL",
|
||||
"urlHint": "Контент будет автоматически извлечен со страницы. Убедитесь, что сайт разрешает доступ роботам.",
|
||||
"beta": "Бета-версия"
|
||||
},
|
||||
"retrieval": {
|
||||
"title": "Поиск и проверка",
|
||||
"subtitle": "Проверьте качество поиска (Dense & Sparse) по вашей базе знаний",
|
||||
"query": "Тестовый запрос",
|
||||
"queryPlaceholder": "Что вы хотите найти?",
|
||||
"search": "Найти",
|
||||
"searching": "Ищем...",
|
||||
"results": "Результаты поиска",
|
||||
"noResults": "Релевантный контент не найден",
|
||||
"tryDifferentQuery": "Попробуйте изменить формулировку запроса",
|
||||
"settings": "Параметры поиска",
|
||||
"topK": "Количество результатов",
|
||||
"topKHint": "Сколько фрагментов возвращать",
|
||||
"enableRerank": "Включить Rerank",
|
||||
"enableRerankHint": "Применить переранжирование для повышения точности",
|
||||
"score": "Вес (Score)",
|
||||
"document": "Документ",
|
||||
"chunk": "Фрагмент #{index}",
|
||||
"content": "Текст",
|
||||
"charCount": "{count} симв.",
|
||||
"searchSuccess": "Поиск завершен, найдено: {count}",
|
||||
"searchFailed": "Ошибка выполнения поиска",
|
||||
"queryRequired": "Введите поисковый запрос"
|
||||
},
|
||||
"settings": {
|
||||
"title": "Общие настройки базы",
|
||||
"basic": "Основные",
|
||||
"retrieval": "Поиск",
|
||||
"chunkSize": "Размер чанка",
|
||||
"chunkOverlap": "Перекрытие",
|
||||
"topKDense": "Вернуть (Dense)",
|
||||
"topKSparse": "Вернуть (Sparse)",
|
||||
"topMFinal": "Итоговый результат",
|
||||
"enableRerank": "Включить Rerank",
|
||||
"embeddingProvider": "Провайдер Embedding",
|
||||
"rerankProvider": "Провайдер Rerank",
|
||||
"save": "Сохранить",
|
||||
"saveSuccess": "Настройки сохранены",
|
||||
"saveFailed": "Ошибка сохранения",
|
||||
"tips": "Внимание! Изменение этих параметров повлияет на будущую выдачу базы знаний."
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
{
|
||||
"title": "Просмотр документа",
|
||||
"backToKB": "К базе знаний",
|
||||
"info": {
|
||||
"title": "Информация о документе",
|
||||
"name": "Имя файла",
|
||||
"type": "Формат",
|
||||
"size": "Размер",
|
||||
"chunkCount": "Количество фрагментов",
|
||||
"createdAt": "Загружен"
|
||||
},
|
||||
"chunks": {
|
||||
"title": "Фрагменты текста",
|
||||
"empty": "Фрагменты не найдены",
|
||||
"index": "Индекс",
|
||||
"content": "Текст",
|
||||
"charCount": "Символов",
|
||||
"actions": "Действия",
|
||||
"view": "Детали",
|
||||
"edit": "Изменить",
|
||||
"delete": "Удалить",
|
||||
"preview": "Обзор",
|
||||
"search": "Поиск по документу",
|
||||
"searchPlaceholder": "Найти во фрагментах...",
|
||||
"showing": "Показано",
|
||||
"deleteConfirm": "Удалить этот фрагмент?",
|
||||
"deleteSuccess": "Фрагмент удален",
|
||||
"deleteFailed": "Ошибка удаления"
|
||||
},
|
||||
"edit": {
|
||||
"title": "Редактирование фрагмента",
|
||||
"content": "Текст",
|
||||
"cancel": "Отмена",
|
||||
"save": "Сохранить",
|
||||
"saveSuccess": "Фрагмент обновлен",
|
||||
"saveFailed": "Ошибка сохранения"
|
||||
},
|
||||
"delete": {
|
||||
"title": "Удаление",
|
||||
"confirmText": "Вы уверены?",
|
||||
"warning": "Удаление фрагмента может ухудшить качество ответов AI по этой теме.",
|
||||
"cancel": "Отмена",
|
||||
"confirm": "Удалить",
|
||||
"deleteSuccess": "Удаление выполнено",
|
||||
"deleteFailed": "Ошибка удаления"
|
||||
},
|
||||
"view": {
|
||||
"title": "Детальный просмотр",
|
||||
"index": "Индекс",
|
||||
"content": "Текст",
|
||||
"charCount": "Символов",
|
||||
"vecDocId": "ID вектора",
|
||||
"close": "Закрыть"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
{
|
||||
"title": "Управление базами знаний",
|
||||
"subtitle": "Централизованное управление всеми знаниями AstrBot",
|
||||
"list": {
|
||||
"title": "Мои базы знаний",
|
||||
"subtitle": "Все доступные коллекции знаний",
|
||||
"create": "Создать базу",
|
||||
"refresh": "Обновить",
|
||||
"empty": "Баз знаний пока нет",
|
||||
"loading": "Загрузка...",
|
||||
"documents": "док.",
|
||||
"chunks": "фрагм.",
|
||||
"sessionConfig": "Профиль"
|
||||
},
|
||||
"card": {
|
||||
"edit": "Изменить",
|
||||
"delete": "Удалить",
|
||||
"open": "Открыть",
|
||||
"docCount": "Документов: {count}",
|
||||
"chunkCount": "Фрагментов: {count}"
|
||||
},
|
||||
"create": {
|
||||
"title": "Создание базы знаний",
|
||||
"nameLabel": "Название",
|
||||
"namePlaceholder": "Придумайте имя для базы",
|
||||
"descriptionLabel": "Описание",
|
||||
"descriptionPlaceholder": "Для чего нужна эта база?",
|
||||
"emojiLabel": "Иконка",
|
||||
"embeddingModelLabel": "Embedding модель",
|
||||
"rerankModelLabel": "Rerank модель (опционально)",
|
||||
"providerInfo": "Провайдер: {id} | Размерность: {dimensions}",
|
||||
"rerankProviderInfo": "Провайдер: {id}",
|
||||
"cancel": "Отмена",
|
||||
"submit": "Создать",
|
||||
"nameRequired": "Введите название базы знаний"
|
||||
},
|
||||
"edit": {
|
||||
"title": "Редактирование",
|
||||
"submit": "Сохранить"
|
||||
},
|
||||
"delete": {
|
||||
"title": "Удаление",
|
||||
"confirmText": "Вы уверены, что хотите удалить базу знаний «{name}»?",
|
||||
"warning": "Это действие необратимо. Все документы, фрагменты и настройки будут навсегда удалены.",
|
||||
"cancel": "Отмена",
|
||||
"confirm": "Удалить"
|
||||
},
|
||||
"emoji": {
|
||||
"title": "Выберите иконку",
|
||||
"close": "Закрыть",
|
||||
"categories": {
|
||||
"books": "Книги и документы",
|
||||
"emotions": "Эмоции",
|
||||
"objects": "Вещи",
|
||||
"symbols": "Символы"
|
||||
}
|
||||
},
|
||||
"messages": {
|
||||
"createSuccess": "База знаний создана",
|
||||
"createFailed": "Ошибка создания",
|
||||
"updateSuccess": "Обновлено успешно",
|
||||
"updateFailed": "Ошибка обновления",
|
||||
"deleteSuccess": "Удалено успешно",
|
||||
"deleteFailed": "Ошибка удаления",
|
||||
"loadError": "Не удалось загрузить список"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"dialog": {
|
||||
"title": "Помощник по миграции",
|
||||
"warning": "👋 Добро пожаловать в v4.0.0! В этой версии мы оптимизировали формат хранения данных. Обнаружена необходимость миграции базы данных.",
|
||||
"loading": "Загрузка списка платформ...",
|
||||
"loadError": "Ошибка загрузки, попробуйте еще раз",
|
||||
"noPlatforms": "Конфигурации платформ не найдены",
|
||||
"retry": "Повторить",
|
||||
"startMigration": "Начать миграцию",
|
||||
"migrating": "Выполняется миграция...",
|
||||
"migratingSubtitle": "Пожалуйста, подождите. Не закрывайте это окно до завершения процесса.",
|
||||
"migrationError": "Ошибка миграции",
|
||||
"success": "Миграция успешно завершена!",
|
||||
"completed": "Миграция выполнена",
|
||||
"restartRecommended": "Рекомендуется перезапустить приложение, чтобы все изменения вступили в силу.",
|
||||
"restartNow": "Перезапустить сейчас"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
{
|
||||
"page": {
|
||||
"description": "Управление настройками и поведением персонажей"
|
||||
},
|
||||
"buttons": {
|
||||
"create": "Создать персонажа",
|
||||
"createFirst": "Создать первого персонажа",
|
||||
"edit": "Изменить",
|
||||
"delete": "Удалить",
|
||||
"cancel": "Отмена",
|
||||
"save": "Сохранить",
|
||||
"move": "Переместить",
|
||||
"addDialogPair": "Добавить пример диалога"
|
||||
},
|
||||
"labels": {
|
||||
"presetDialogs": "Примеры диалогов ({count})",
|
||||
"createdAt": "Создан",
|
||||
"updatedAt": "Обновлен"
|
||||
},
|
||||
"form": {
|
||||
"personaId": "ID персонажа",
|
||||
"systemPrompt": "Системный промпт",
|
||||
"customErrorMessage": "Свое сообщение об ошибке (опционально)",
|
||||
"customErrorMessageHelp": "Это сообщение будет отправлено пользователю при сбое запроса к LLM. Если оставить пустым, будет использовано системное сообщение по умолчанию.",
|
||||
"presetDialogs": "Примеры диалогов",
|
||||
"presetDialogsHelp": "Добавьте примеры взаимодействия, чтобы помочь AI лучше понять свою роль и стиль общения.",
|
||||
"userMessage": "Сообщение пользователя",
|
||||
"assistantMessage": "Ответ AI",
|
||||
"tools": "Инструменты / MCP серверы",
|
||||
"toolsHelp": "Выберите инструменты, доступные этому персонажу. Инструменты позволяют AI взаимодействовать с внешним миром: искать в интернете, выполнять расчеты и т.д.",
|
||||
"toolsSelection": "Выбор инструментов",
|
||||
"selectAllTools": "Выбрать все",
|
||||
"clearAllTools": "Очистить всё",
|
||||
"allSelected": "Выбрано всё",
|
||||
"mcpServersQuickSelect": "Быстрый выбор MCP серверов",
|
||||
"searchTools": "Поиск инструментов",
|
||||
"selectedTools": "Выбранные инструменты",
|
||||
"noToolsAvailable": "Нет доступных инструментов",
|
||||
"noToolsFound": "Инструменты не найдены",
|
||||
"loadingTools": "Загрузка инструментов...",
|
||||
"allToolsAvailable": "Использовать все доступные инструменты",
|
||||
"noToolsSelected": "Инструменты не выбраны",
|
||||
"skills": "Навыки (Skills)",
|
||||
"skillsHelp": "Выберите навыки, доступные этому персонажу. Навыки предоставляют AI готовые сценарии и правила работы.",
|
||||
"skillsAllAvailable": "По умолчанию использовать все навыки",
|
||||
"skillsSelectSpecific": "Выбрать определенные навыки",
|
||||
"searchSkills": "Поиск навыков",
|
||||
"selectedSkills": "Выбранные навыки",
|
||||
"noSkillsAvailable": "Нет доступных навыков",
|
||||
"noSkillsFound": "Навыки не найдены",
|
||||
"loadingSkills": "Загрузка навыков...",
|
||||
"allSkillsAvailable": "Использовать все доступные навыки",
|
||||
"noSkillsSelected": "Навыки не выбраны",
|
||||
"skillsRuntimeNoneWarning": "Среда выполнения Computer Use не задана. Навыки могут не работать, так как нет активного окружения.",
|
||||
"createInFolder": "Будет создан в папке «{folder}»",
|
||||
"rootFolder": "Все персонажи"
|
||||
},
|
||||
"dialog": {
|
||||
"create": {
|
||||
"title": "Создание персонажа"
|
||||
},
|
||||
"edit": {
|
||||
"title": "Редактирование персонажа"
|
||||
}
|
||||
},
|
||||
"empty": {
|
||||
"title": "Персонажи не настроены",
|
||||
"description": "Самое время создать одного!",
|
||||
"folderEmpty": "Папка пуста",
|
||||
"folderEmptyDescription": "Создайте нового персонажа или папку, чтобы начать"
|
||||
},
|
||||
"validation": {
|
||||
"required": "Это поле обязательно для заполнения",
|
||||
"minLength": "Минимум {min} символов",
|
||||
"alphanumeric": "Разрешены только латинские буквы, цифры, подчёркивания и дефисы",
|
||||
"dialogRequired": "{type} не может быть пустым",
|
||||
"personaIdExists": "Персонаж с таким ID уже существует"
|
||||
},
|
||||
"messages": {
|
||||
"loadError": "Не удалось загрузить список персонажей",
|
||||
"saveSuccess": "Сохранено",
|
||||
"saveError": "Ошибка сохранения",
|
||||
"deleteConfirm": "Вы уверены, что хотите удалить персонажа «{id}»? Это действие необратимо.",
|
||||
"deleteSuccess": "Удалено",
|
||||
"deleteError": "Ошибка удаления"
|
||||
},
|
||||
"persona": {
|
||||
"personasTitle": "Персонаж",
|
||||
"toolsCount": "инстр.",
|
||||
"skillsCount": "навыков",
|
||||
"contextMenu": {
|
||||
"moveTo": "Переместить в..."
|
||||
},
|
||||
"messages": {
|
||||
"moveSuccess": "Персонаж перемещен",
|
||||
"moveError": "Не удалось переместить персонажа"
|
||||
}
|
||||
},
|
||||
"folder": {
|
||||
"sidebarTitle": "Папки",
|
||||
"rootFolder": "Корень",
|
||||
"foldersTitle": "Папки",
|
||||
"noFolders": "Папок нет",
|
||||
"createButton": "Новая папка",
|
||||
"searchPlaceholder": "Поиск папок...",
|
||||
"form": {
|
||||
"name": "Имя папки",
|
||||
"description": "Описание (опционально)"
|
||||
},
|
||||
"validation": {
|
||||
"nameRequired": "Имя папки не может быть пустым"
|
||||
},
|
||||
"contextMenu": {
|
||||
"open": "Открыть",
|
||||
"rename": "Переименовать",
|
||||
"moveTo": "Переместить в...",
|
||||
"delete": "Удалить"
|
||||
},
|
||||
"createDialog": {
|
||||
"title": "Создать папку",
|
||||
"createButton": "Создать"
|
||||
},
|
||||
"renameDialog": {
|
||||
"title": "Переименовать папку"
|
||||
},
|
||||
"deleteDialog": {
|
||||
"title": "Удаление папки",
|
||||
"message": "Вы уверены, что хотите удалить папку «{name}»?",
|
||||
"warning": "Все персонажи из этой папки будут перемещены в корневой каталог."
|
||||
},
|
||||
"messages": {
|
||||
"createSuccess": "Папка создана",
|
||||
"createError": "Ошибка создания папки",
|
||||
"renameSuccess": "Папка переименована",
|
||||
"renameError": "Ошибка переименования папки",
|
||||
"deleteSuccess": "Папка удалена",
|
||||
"deleteError": "Ошибка удаления папки"
|
||||
}
|
||||
},
|
||||
"moveDialog": {
|
||||
"title": "Перемещение",
|
||||
"description": "Выберите папку для «{name}»",
|
||||
"success": "Объект перемещен",
|
||||
"error": "Ошибка перемещения"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
{
|
||||
"title": "Боты",
|
||||
"subtitle": "Управление адаптерами платформ для подключения к мессенджерам",
|
||||
"adapters": "Адаптеры платформ",
|
||||
"addAdapter": "Создать бота",
|
||||
"emptyText": "Боты не настроены. Нажмите «Создать бота», чтобы начать.",
|
||||
"viewWebhook": "Показать Webhook",
|
||||
"webhookCopied": "URL скопирован в буфер обмена",
|
||||
"webhookCopyFailed": "Не удалось скопировать, сделайте это вручную",
|
||||
"webhookDialog": {
|
||||
"title": "Адрес Webhook",
|
||||
"description": "Используйте этот адрес для обратных вызовов. Убедитесь, что ваш AstrBot доступен из интернета. Рекомендуется указать «Внешний URL для Webhook» в Конфигурация -> Система.",
|
||||
"close": "Закрыть"
|
||||
},
|
||||
"details": {
|
||||
"adapterType": "Тип адаптера",
|
||||
"token": "Токен",
|
||||
"description": "Описание"
|
||||
},
|
||||
"logs": {
|
||||
"title": "Логи платформы",
|
||||
"expand": "Развернуть",
|
||||
"collapse": "Свернуть"
|
||||
},
|
||||
"dialog": {
|
||||
"add": "Добавить",
|
||||
"edit": "Изменить",
|
||||
"adapter": "Бот",
|
||||
"refresh": "Обновить",
|
||||
"cancel": "Отмена",
|
||||
"save": "Сохранить",
|
||||
"addPlatform": "Создать бота",
|
||||
"connectTitle": "Подключение к {name}",
|
||||
"viewTutorial": "Открыть руководство",
|
||||
"noTemplates": "Шаблоны не найдены",
|
||||
"idConflict": {
|
||||
"title": "Конфликт ID",
|
||||
"message": "Бот с ID «{id}» уже существует. Пожалуйста, используйте уникальный ID.",
|
||||
"confirm": "Понятно"
|
||||
},
|
||||
"securityWarning": {
|
||||
"title": "Безопасность",
|
||||
"aiocqhttpTokenMissing": "Для защиты соединения крайне рекомендуется установить ws_reverse_token. Работа без токена небезопасна.",
|
||||
"learnMore": "Подробнее"
|
||||
},
|
||||
"invalidPlatformId": "ID платформы не может содержать символы ':' или '!'."
|
||||
},
|
||||
"createDialog": {
|
||||
"step1Title": "Выберите мессенджер",
|
||||
"step1Hint": "Куда вы хотите подключить бота? (QQ, Telegram, Discord, WeChat и др.)",
|
||||
"platformTypeLabel": "Платформа",
|
||||
"configFileTitle": "Файл конфигурации",
|
||||
"optional": "опционально",
|
||||
"configHint": "Как настроить бота? Конфиг содержит модель, персонажа, базу знаний и набор плагинов.",
|
||||
"configDefaultHint": "По умолчанию используется профиль «default». Вы сможете изменить его позже.",
|
||||
"useExistingConfig": "Использовать существующий конфиг",
|
||||
"selectConfigLabel": "Выберите профиль",
|
||||
"createNewConfig": "Создать новый профиль",
|
||||
"newConfigNameLabel": "Имя нового профиля",
|
||||
"newConfigTitle": "Создание нового профиля",
|
||||
"newConfigLoadFailed": "Не удалось загрузить шаблон конфигурации",
|
||||
"addRouteRule": "Добавить правило маршрутизации",
|
||||
"viewMode": "Просмотр",
|
||||
"editMode": "Редактирование",
|
||||
"noRouteRules": "Правила маршрутизации не заданы, будет использоваться профиль по умолчанию",
|
||||
"sessionIdPlaceholder": "ID сессии или *",
|
||||
"allSessions": "Все сессии",
|
||||
"configMissing": "Файл конфигурации не найден",
|
||||
"routeHint": "* При получении сообщения AstrBot ищет первое совпадение в списке сверху вниз. Используйте слэш-команду /sid, чтобы узнать ID текущей сессии. Если совпадений нет, используется профиль по умолчанию.",
|
||||
"warningContinue": "Игнорировать и создать",
|
||||
"warningEditAgain": "Вернуться к редактированию",
|
||||
"configDrawerTitle": "Управление профилями",
|
||||
"configDrawerIdLabel": "ID",
|
||||
"configTableHeaders": {
|
||||
"configId": "ID связанного профиля",
|
||||
"scope": "Область применения"
|
||||
},
|
||||
"routeTableHeaders": {
|
||||
"source": "Источник (тип:ID)",
|
||||
"config": "Файл конфига",
|
||||
"actions": "Действия"
|
||||
},
|
||||
"messageTypeOptions": {
|
||||
"all": "Все сообщения",
|
||||
"group": "Групповые (GroupMessage)",
|
||||
"friend": "Личные (FriendMessage)"
|
||||
},
|
||||
"messageTypeLabels": {
|
||||
"all": "Все",
|
||||
"group": "Группа",
|
||||
"friend": "ЛС"
|
||||
}
|
||||
},
|
||||
"messages": {
|
||||
"updateSuccess": "Обновлено!",
|
||||
"addSuccess": "Добавлено!",
|
||||
"deleteSuccess": "Удалено!",
|
||||
"statusUpdateSuccess": "Статус обновлен!",
|
||||
"deleteConfirm": "Вы уверены, что хотите удалить этого бота?",
|
||||
"configNotFoundOpenConfig": "Целевой конфиг не найден. Открыта страница настроек для проверки.",
|
||||
"updateMissingPlatformId": "Ошибка обновления: отсутствует ID платформы.",
|
||||
"platformUpdateFailed": "Не удалось обновить платформу.",
|
||||
"addSuccessWithConfig": "Бот успешно добавлен, профиль обновлен",
|
||||
"configIdMissing": "Не удалось получить ID конфигурации.",
|
||||
"routingUpdateFailed": "Ошибка обновления маршрутов: {message}",
|
||||
"createConfigFailed": "Ошибка создания профиля: {message}",
|
||||
"platformIdMissing": "Не удалось получить ID платформы.",
|
||||
"routingSaveFailed": "Ошибка сохранения маршрутов: {message}"
|
||||
},
|
||||
"status": {
|
||||
"enabled": "Включен",
|
||||
"disabled": "Выключен",
|
||||
"connecting": "Подключение",
|
||||
"connected": "Подключен",
|
||||
"disconnected": "Отключен",
|
||||
"error": "Ошибка"
|
||||
},
|
||||
"runtimeStatus": {
|
||||
"running": "Работает",
|
||||
"error": "Ошибка",
|
||||
"pending": "Ожидание",
|
||||
"stopped": "Остановлен",
|
||||
"unknown": "Неизвестно",
|
||||
"errors": "ошибок"
|
||||
},
|
||||
"errorDialog": {
|
||||
"title": "Детали ошибки",
|
||||
"platformId": "ID платформы",
|
||||
"errorCount": "Кол-во ошибок",
|
||||
"lastError": "Последняя ошибка",
|
||||
"occurredAt": "Время",
|
||||
"traceback": "Стек вызовов",
|
||||
"close": "Закрыть"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
{
|
||||
"title": "Провайдеры моделей",
|
||||
"subtitle": "Настройка AI моделей для диалогов. Также поддерживает Dify, Coze, а также внешние Agent-сервисы.",
|
||||
"providers": {
|
||||
"title": "Сервис-провайдеры",
|
||||
"settings": "Настройки",
|
||||
"addProvider": "Добавить провайдера",
|
||||
"providerType": "Тип провайдера",
|
||||
"tabs": {
|
||||
"all": "Все",
|
||||
"chatCompletion": "Диалоги",
|
||||
"agentRunner": "Агенты",
|
||||
"speechToText": "STT (Речь -> Текст)",
|
||||
"textToSpeech": "TTS (Текст -> Речь)",
|
||||
"embedding": "Эмбеддинги",
|
||||
"rerank": "Rerank (Ранжирование)"
|
||||
},
|
||||
"empty": {
|
||||
"all": "Провайдеры не добавлены. Нажмите «Добавить провайдера», чтобы начать.",
|
||||
"typed": "Провайдеры типа «{type}» не найдены."
|
||||
},
|
||||
"description": {
|
||||
"openai": "Поддерживаются все провайдеры, совместимые с OpenAI API.",
|
||||
"vllm_rerank": "Также поддерживает Jina AI, Cohere, PPIO и другие.",
|
||||
"default": "Преобразование речи в текст"
|
||||
}
|
||||
},
|
||||
"availability": {
|
||||
"title": "Доступность провайдеров",
|
||||
"subtitle": "Статус определяется путем выполнения тестового запроса. Может взиматься плата согласно тарифу API.",
|
||||
"refresh": "Проверить статус",
|
||||
"noData": "Нажмите «Проверить статус», чтобы узнать доступность моделей",
|
||||
"available": "Доступен",
|
||||
"unavailable": "Недоступен",
|
||||
"pending": "Проверка...",
|
||||
"errorMessage": "Ошибка",
|
||||
"test": "Тест"
|
||||
},
|
||||
"logs": {
|
||||
"title": "Логи сервиса",
|
||||
"expand": "Развернуть",
|
||||
"collapse": "Свернуть"
|
||||
},
|
||||
"dialogs": {
|
||||
"addProvider": {
|
||||
"title": "Новый провайдер",
|
||||
"tabs": {
|
||||
"basic": "Диалоги",
|
||||
"agentRunner": "Агенты",
|
||||
"speechToText": "Преобразование текста в речь",
|
||||
"textToSpeech": "Переранжирование",
|
||||
"embedding": "Эмбеддинги",
|
||||
"rerank": "API Key"
|
||||
},
|
||||
"noTemplates": "Шаблоны для этого типа не найдены"
|
||||
},
|
||||
"config": {
|
||||
"addTitle": "Добавить",
|
||||
"editTitle": "Изменить",
|
||||
"provider": "Провайдер",
|
||||
"cancel": "Отмена",
|
||||
"save": "Сохранить"
|
||||
},
|
||||
"settings": {
|
||||
"title": "Общие настройки провайдеров",
|
||||
"sessionSeparation": {
|
||||
"title": "Изоляция провайдеров по сессиям",
|
||||
"description": "Позволяет выбирать независимых провайдеров для генерации текста, TTS и STT в каждой конкретной сессии."
|
||||
},
|
||||
"close": "Закрыть"
|
||||
}
|
||||
},
|
||||
"messages": {
|
||||
"success": {
|
||||
"update": "Обновлено!",
|
||||
"add": "Добавлено!",
|
||||
"delete": "Удалено!",
|
||||
"statusUpdate": "Статус обновлен!",
|
||||
"sessionSeparation": "Настройки изоляции сохранены"
|
||||
},
|
||||
"error": {
|
||||
"sessionSeparation": "Не удалось загрузить настройки изоляции",
|
||||
"fetchStatus": "Не удалось получить статус провайдеров",
|
||||
"testError": "Тест {id} провален: {error}"
|
||||
},
|
||||
"confirm": {
|
||||
"delete": "Вы уверены, что хотите удалить провайдера «{id}»?"
|
||||
}
|
||||
},
|
||||
"providerTypes": {
|
||||
"title": "Тип провайдера"
|
||||
},
|
||||
"providerSources": {
|
||||
"title": "Источник провайдера",
|
||||
"add": "Добавить",
|
||||
"empty": "Источники не найдены",
|
||||
"selectHint": "Пожалуйста, выберите источник провайдера",
|
||||
"selectCreated": "Выбрать существующий источник",
|
||||
"save": "Сохранить конфиг",
|
||||
"saveAndFetchModels": "Сохранить и загрузить модели",
|
||||
"fetchModels": "Загрузить список моделей",
|
||||
"saveSuccess": "Источник успешно сохранен",
|
||||
"saveError": "Ошибка сохранения источника",
|
||||
"deleteConfirm": "Вы уверены, что хотите удалить источник «{id}»? Все связанные конфигурации моделей будут удалены.",
|
||||
"deleteSuccess": "Источник удален",
|
||||
"deleteError": "Ошибка удаления",
|
||||
"enabled": "Включен",
|
||||
"disabled": "Выключен",
|
||||
"advancedConfig": "Расширенные настройки...",
|
||||
"fields": {
|
||||
"name": "Имя",
|
||||
"apiKey": "Base URL",
|
||||
"baseUrl": "Base URL"
|
||||
},
|
||||
"hints": {
|
||||
"id": "Уникальный ID источника",
|
||||
"key": "Ваш серетный API-ключ",
|
||||
"apiBase": "Адрес API точки входа (Endpoint URL)",
|
||||
"proxy": "Прокси сервер (HTTP/HTTPS), напр. http://127.0.0.1:7890. Используется только для запросов к этому провайдеру."
|
||||
},
|
||||
"labels": {
|
||||
"proxy": "Прокси"
|
||||
}
|
||||
},
|
||||
"models": {
|
||||
"available": "Доступные модели",
|
||||
"configured": "Настроенные модели",
|
||||
"empty": "Модели не настроены. Нажмите «Загрузить список моделей» выше.",
|
||||
"noModelsFound": "Модели не найдены",
|
||||
"fetchError": "Не удалось получить список моделей",
|
||||
"addSuccess": "Модель {model} успешно добавлена",
|
||||
"deleteConfirm": "Вы уверены, что хотите удалить модель «{id}»?",
|
||||
"deleteSuccess": "Модель удалена",
|
||||
"deleteError": "Ошибка удаления модели",
|
||||
"testSuccess": "Тест модели «{id}» пройден успешно",
|
||||
"testError": "Тест модели провален",
|
||||
"searchPlaceholder": "Поиск по имени или ID",
|
||||
"manualAddButton": "Добавить вручную",
|
||||
"manualDialogTitle": "Произвольная модель",
|
||||
"manualDialogModelLabel": "Код модели (напр. gpt-4o-mini)",
|
||||
"manualDialogPreviewLabel": "Отображаемый ID (авто)",
|
||||
"manualDialogPreviewHint": "Будет выглядеть как: SourceID/ModelID",
|
||||
"manualModelRequired": "Укажите ID модели",
|
||||
"manualModelExists": "Эта модель уже добавлена",
|
||||
"configure": "Настроить",
|
||||
"tooltips": {
|
||||
"providerId": "ID провайдера",
|
||||
"modelId": "ID модели"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
{
|
||||
"title": "Управление сессиями",
|
||||
"subtitle": "Настройка индивидуальных правил для конкретных диалогов. Эти правила имеют приоритет над глобальной конфигурацией.",
|
||||
"buttons": {
|
||||
"refresh": "Обновить",
|
||||
"edit": "Изменить",
|
||||
"editRule": "Редактировать правило",
|
||||
"deleteAllRules": "Удалить все правила",
|
||||
"addRule": "Добавить правило",
|
||||
"save": "Сохранить",
|
||||
"cancel": "Отмена",
|
||||
"delete": "Удалить",
|
||||
"clear": "Очистить",
|
||||
"next": "Далее",
|
||||
"editCustomName": "Изменить заметку",
|
||||
"batchDelete": "Массовое удаление"
|
||||
},
|
||||
"customRules": {
|
||||
"title": "Пользовательские правила",
|
||||
"rulesCount": "правил",
|
||||
"hasRules": "Настроено",
|
||||
"noRules": "Индивидуальных правил нет",
|
||||
"noRulesDesc": "Нажмите «Добавить правило», чтобы задать настройки для конкретного диалога",
|
||||
"serviceConfig": "Сервис",
|
||||
"pluginConfig": "Плагины",
|
||||
"kbConfig": "База знаний",
|
||||
"providerConfig": "Модель",
|
||||
"configured": "Настроено",
|
||||
"noCustomName": "Без заметки"
|
||||
},
|
||||
"quickEditName": {
|
||||
"title": "Редактирование заметки"
|
||||
},
|
||||
"search": {
|
||||
"placeholder": "Поиск сессии..."
|
||||
},
|
||||
"table": {
|
||||
"headers": {
|
||||
"umoInfo": "Источник (UMO)",
|
||||
"rulesOverview": "Обзор правил",
|
||||
"actions": "Действия"
|
||||
}
|
||||
},
|
||||
"persona": {
|
||||
"none": "Из конфигурации"
|
||||
},
|
||||
"provider": {
|
||||
"followConfig": "Из конфигурации"
|
||||
},
|
||||
"addRule": {
|
||||
"title": "Добавление правила",
|
||||
"description": "Выберите источник сообщения (UMO) для настройки. Индивидуальные правила приоритетнее глобальных. Используйте команду /sid в чате, чтобы узнать информацию об источнике.",
|
||||
"selectUmo": "Выберите сессию",
|
||||
"noUmos": "Нет доступных сессий"
|
||||
},
|
||||
"ruleEditor": {
|
||||
"title": "Редактор правил",
|
||||
"description": "Настройте поведение для этой сессии. Настройки ниже перекроют глобальный конфиг.",
|
||||
"serviceConfig": {
|
||||
"title": "Сервисные настройки",
|
||||
"sessionEnabled": "Обрабатывать сообщения",
|
||||
"llmEnabled": "Использовать LLM",
|
||||
"ttsEnabled": "Использовать TTS",
|
||||
"customName": "Заметка для сессии"
|
||||
},
|
||||
"providerConfig": {
|
||||
"title": "Выбор моделей",
|
||||
"chatProvider": "Чат-модель",
|
||||
"sttProvider": "STT (Распознавание)",
|
||||
"ttsProvider": "TTS (Озвучка)"
|
||||
},
|
||||
"personaConfig": {
|
||||
"title": "Персона",
|
||||
"selectPersona": "Выберите Persona",
|
||||
"hint": "При выборе Persona все диалоги из этого источника будут использовать именно её."
|
||||
},
|
||||
"pluginConfig": {
|
||||
"title": "Плагины",
|
||||
"disabledPlugins": "Отключенные плагины",
|
||||
"hint": "Выберите плагины, которые нужно ОТКЛЮЧИТЬ в этой сессии. Остальные останутся активными."
|
||||
},
|
||||
"kbConfig": {
|
||||
"title": "База знаний",
|
||||
"selectKbs": "Выбор баз знаний",
|
||||
"topK": "Количество результатов (Top K)",
|
||||
"enableRerank": "Использовать Rerank"
|
||||
}
|
||||
},
|
||||
"deleteConfirm": {
|
||||
"title": "Подтверждение",
|
||||
"message": "Удалить все настройки для этой сессии? Будут применены глобальные настройки."
|
||||
},
|
||||
"batchDeleteConfirm": {
|
||||
"title": "Массовое удаление",
|
||||
"message": "Удалить {count} выбранных правил? Будут применены глобальные настройки."
|
||||
},
|
||||
"batchOperations": {
|
||||
"title": "Массовые операции",
|
||||
"hint": "Быстрое изменение настроек для группы сессий",
|
||||
"scope": "Область применения",
|
||||
"scopeSelected": "Выбранные",
|
||||
"scopeAll": "Все сессии",
|
||||
"scopeGroup": "Все группы",
|
||||
"scopePrivate": "Личные диалоги",
|
||||
"llmStatus": "Статус LLM",
|
||||
"ttsStatus": "Статус TTS",
|
||||
"chatProvider": "Чат-модель",
|
||||
"ttsProvider": "TTS-модель",
|
||||
"apply": "Применить"
|
||||
},
|
||||
"status": {
|
||||
"enabled": "Включено",
|
||||
"disabled": "Выключено"
|
||||
},
|
||||
"messages": {
|
||||
"refreshSuccess": "Данные обновлены",
|
||||
"loadError": "Ошибка загрузки",
|
||||
"saveSuccess": "Настройки сохранены",
|
||||
"saveError": "Ошибка сохранения",
|
||||
"clearSuccess": "Очищено",
|
||||
"clearError": "Ошибка очистки",
|
||||
"deleteSuccess": "Удалено",
|
||||
"deleteError": "Ошибка удаления",
|
||||
"noChanges": "Изменений не обнаружено",
|
||||
"batchDeleteSuccess": "Массовое удаление выполнено",
|
||||
"batchDeleteError": "Ошибка массового удаления",
|
||||
"batchUpdateError": "Ошибка пакетного обновления",
|
||||
"batchUpdateSuccess": "Пакетное обновление успешно выполнено"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
{
|
||||
"network": {
|
||||
"title": "Сеть",
|
||||
"githubProxy": {
|
||||
"title": "Зеркало GitHub",
|
||||
"subtitle": "Адрес для ускорения загрузки плагинов и обновлений AstrBot. Особенно актуально для пользователей из Китая. Все адреса предоставляются как есть, если обновление не удается — проверьте доступность выбранного зеркала.",
|
||||
"label": "Выбрать ускоритель GitHub"
|
||||
},
|
||||
"proxySelector": {
|
||||
"title": "Ускорение GitHub",
|
||||
"noProxy": "Не использовать",
|
||||
"useProxy": "Включить",
|
||||
"testConnection": "Проверить соединение",
|
||||
"available": "Доступен",
|
||||
"unavailable": "Недоступен",
|
||||
"custom": "Свой вариант"
|
||||
}
|
||||
},
|
||||
"theme": {
|
||||
"title": "Тема оформления",
|
||||
"subtitle": "Настройка основных и дополнительных цветов. Изменения вступают в силу немедленно и сохраняются в браузере.",
|
||||
"customize": {
|
||||
"title": "Цвета темы",
|
||||
"primary": "Основной",
|
||||
"secondary": "Дополнительный",
|
||||
"reset": "Сбросить"
|
||||
}
|
||||
},
|
||||
"system": {
|
||||
"title": "Система",
|
||||
"restart": {
|
||||
"title": "Перезапуск",
|
||||
"subtitle": "Выполнить мягкий перезапуск AstrBot",
|
||||
"button": "Перезагрузить"
|
||||
},
|
||||
"migration": {
|
||||
"title": "Миграция данных в v4.0.0",
|
||||
"subtitle": "Если у вас возникли проблемы с совместимостью данных после обновления, запустите помощник вручную.",
|
||||
"button": "Запустить миграцию"
|
||||
},
|
||||
"backup": {
|
||||
"title": "Резервное копирование",
|
||||
"subtitle": "Важнейший инструмент для безопасного переноса данных между серверами.",
|
||||
"button": "Управление бэкапами"
|
||||
}
|
||||
},
|
||||
"sidebar": {
|
||||
"title": "Боковая панель",
|
||||
"customize": {
|
||||
"title": "Настройка меню",
|
||||
"subtitle": "Перетаскивайте элементы, чтобы изменить их порядок или скрыть в группе «Дополнительно». Настройки сохраняются локально в браузере.",
|
||||
"reset": "Сбросить порядок",
|
||||
"mainItems": "Основные разделы",
|
||||
"moreItems": "Дополнительно"
|
||||
}
|
||||
},
|
||||
"backup": {
|
||||
"dialog": {
|
||||
"title": "Резервное копирование"
|
||||
},
|
||||
"tabs": {
|
||||
"export": "Экспорт",
|
||||
"import": "Импорт",
|
||||
"list": "Список копий"
|
||||
},
|
||||
"export": {
|
||||
"title": "Создать резервную копию",
|
||||
"description": "Экспорт всех данных в ZIP-архив, включая базы данных, базу знаний, конфигурации и вложения.",
|
||||
"includes": "Включает: основную БД, векторные индексы знаний, файлы конфигурации, медиа-вложения.",
|
||||
"button": "Начать экспорт",
|
||||
"processing": "Экспорт...",
|
||||
"wait": "Пожалуйста, подождите, мы упаковываем данные...",
|
||||
"completed": "Готово!",
|
||||
"download": "Скачать архив",
|
||||
"another": "Создать новый",
|
||||
"failed": "Ошибка экспорта",
|
||||
"retry": "Повторить"
|
||||
},
|
||||
"import": {
|
||||
"title": "Восстановление из копии",
|
||||
"warning": "⚠️ Внимание! Импорт полностью удалит и перезапишет текущие данные! Убедитесь, что у вас есть копия текущего состояния.",
|
||||
"selectFile": "Выберите ZIP-архив",
|
||||
"uploadAndCheck": "Загрузить и проверить",
|
||||
"uploading": "Загрузка...",
|
||||
"uploadWait": "Файл передается на сервер...",
|
||||
"uploadInit": "Инициализация...",
|
||||
"uploadingChunks": "Передача фрагментов...",
|
||||
"uploadComplete": "Загружено, идет сборка...",
|
||||
"checking": "Проверка структуры...",
|
||||
"invalidBackup": "Некорректный файл резервной копии",
|
||||
"backupContents": "Состав архива",
|
||||
"tables": "таблиц БД",
|
||||
"knowledgeBases": "баз знаний",
|
||||
"configFiles": "конфигов",
|
||||
"confirmImport": "Подтвердите импорт",
|
||||
"button": "Начать восстановление",
|
||||
"processing": "Восстановление...",
|
||||
"wait": "Идет процесс развертывания данных...",
|
||||
"completed": "Восстановление успешно завершено!",
|
||||
"restartRequired": "Данные восстановлены. Необходимо немедленно перезапустить AstrBot для вступления изменений в силу.",
|
||||
"restartNow": "Перезапустить сейчас",
|
||||
"failed": "Ошибка импорта",
|
||||
"retry": "Повторить",
|
||||
"version": {
|
||||
"backupVersion": "Версия бэкапа",
|
||||
"currentVersion": "Текущая версия",
|
||||
"backupTime": "Дата создания",
|
||||
"matchTitle": "✅ Версии совпадают",
|
||||
"matchMessage": "Импорт перезапишет все текущие данные, включая:\n• Основную БД (чаты, настройки)\n• Базы знаний\n• Плагины и их данные\n• Файлы конфигурации\n\nЭто действие необратимо! Продолжить?",
|
||||
"minorDiffTitle": "⚠️ Разница в минорной версии",
|
||||
"minorDiffMessage": "Разница в минорных версиях обычно допустима, но структура данных могла немного измениться. Все текущие данные будут удалены!\n\nПродолжить импорт?",
|
||||
"majorDiffTitle": "⛔ Импорт невозможен",
|
||||
"majorDiffMessage": "Версии основного выпуска различаются. Импорт между мажорными версиями может привести к фатальному повреждению данных.\nИспользуйте AstrBot той же основной версии."
|
||||
}
|
||||
},
|
||||
"list": {
|
||||
"empty": "Резервные копии не найдены",
|
||||
"refresh": "Обновить список",
|
||||
"confirmDelete": "Вы уверены, что хотите безвозвратно удалить эту копию?",
|
||||
"uploaded": "Загружено",
|
||||
"restore": "Восстановить из этого файла",
|
||||
"rename": "Переименовать",
|
||||
"renameTitle": "Переименование файла",
|
||||
"newName": "Новое имя",
|
||||
"renameHint": "Разрешены буквы, цифры, точки, дефисы и подчеркивания",
|
||||
"renameRequired": "Введите имя файла",
|
||||
"renameInvalidChars": "Имя содержит недопустимые символы",
|
||||
"renameFailed": "Ошибка переименования",
|
||||
"ftpHint": "Для больших архивов вы можете загружать их напрямую в папку data/backups через FTP/SFTP."
|
||||
}
|
||||
},
|
||||
"apiKey": {
|
||||
"title": "API Keys",
|
||||
"manageTitle": "Ключи доступа разработчика",
|
||||
"subtitle": "Управление токенами для доступа к открытому HTTP API AstrBot.",
|
||||
"name": "Имя ключа",
|
||||
"expiresInDays": "Срок действия",
|
||||
"expiryOptions": {
|
||||
"day1": "1 день",
|
||||
"day7": "7 дней",
|
||||
"day30": "30 дней",
|
||||
"day90": "90 дней",
|
||||
"permanent": "Бессрочно"
|
||||
},
|
||||
"permanentWarning": "Бессрочные ключи менее безопасны. Пожалуйста, храните их в надежном месте.",
|
||||
"scopes": "Область доступа (Scopes)",
|
||||
"create": "Создать API Key",
|
||||
"revoke": "Отозвать",
|
||||
"delete": "Удалить",
|
||||
"copy": "Копировать",
|
||||
"docsLink": "Документация API",
|
||||
"plaintextHint": "Обязательно сохраните ключ сейчас. После закрытия окна вы больше не сможете увидеть его значение.",
|
||||
"empty": "Ключи не созданы",
|
||||
"status": {
|
||||
"active": "Активен",
|
||||
"inactive": "Неактивен"
|
||||
},
|
||||
"table": {
|
||||
"name": "Имя",
|
||||
"prefix": "Префикс",
|
||||
"scopes": "Права",
|
||||
"status": "Статус",
|
||||
"lastUsed": "Использован",
|
||||
"createdAt": "Создан",
|
||||
"actions": "Действия"
|
||||
},
|
||||
"messages": {
|
||||
"loadFailed": "Не удалось загрузить ключи",
|
||||
"scopeRequired": "Выберите хотя бы одну область доступа",
|
||||
"createSuccess": "API Key создан",
|
||||
"createFailed": "Ошибка создания ключа",
|
||||
"revokeSuccess": "Ключ отозван",
|
||||
"revokeFailed": "Ошибка отзыва ключа",
|
||||
"deleteSuccess": "Ключ удален",
|
||||
"deleteFailed": "Ошибка удаления ключа",
|
||||
"copySuccess": "Ключ скопирован",
|
||||
"copyFailed": "Ошибка копирования"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
{
|
||||
"page": {
|
||||
"title": "Оркестрация SubAgent",
|
||||
"beta": "Экспериментально",
|
||||
"subtitle": "Основной LLM может напрямую использовать свои инструменты или делегировать задачи SubAgent через handoff."
|
||||
},
|
||||
"actions": {
|
||||
"refresh": "Обновить",
|
||||
"save": "Сохранить",
|
||||
"add": "Добавить SubAgent",
|
||||
"delete": "Удалить",
|
||||
"close": "Закрыть"
|
||||
},
|
||||
"switches": {
|
||||
"enable": "Включить оркестрацию SubAgent",
|
||||
"enableHint": "Включить функциональность под-агентов",
|
||||
"dedupe": "Дедупликация инструментов основного LLM (скрывать инструменты, дублируемые SubAgent)",
|
||||
"dedupeHint": "Удалить дублирующиеся инструменты из основного агента"
|
||||
},
|
||||
"description": {
|
||||
"disabled": "Выключено: SubAgent отключен; основной LLM подключает инструменты согласно правилам персонажа (все по умолчанию) и вызывает их напрямую.",
|
||||
"enabled": "Включено: основной LLM сохраняет свои инструменты и подключает инструменты делегирования transfer_to_*. При дедупликации инструменты, пересекающиеся с SubAgent, удаляются из основного набора."
|
||||
},
|
||||
"section": {
|
||||
"title": "Субагенты",
|
||||
"globalSettings": "Глобальные настройки"
|
||||
},
|
||||
"cards": {
|
||||
"statusEnabled": "Включено",
|
||||
"statusDisabled": "Отключено",
|
||||
"unnamed": "Безымянный SubAgent",
|
||||
"transferPrefix": "передать_{name}",
|
||||
"switchLabel": "Включить",
|
||||
"previewTitle": "Предпросмотр: инструмент handoff, видимый основному LLM",
|
||||
"personaChip": "Персонаж: {id}",
|
||||
"personaPreview": "ПРЕДПРОСМОТР ПЕРСОНАЖА"
|
||||
},
|
||||
"form": {
|
||||
"nameLabel": "Имя агента (используется для transfer_to_{name})",
|
||||
"nameHint": "Используйте строчные латинские буквы и подчеркивания; имя должно быть глобально уникальным.",
|
||||
"providerLabel": "Chat Provider (опционально)",
|
||||
"providerHint": "Оставьте пустым, чтобы использовать глобальный провайдер по умолчанию.",
|
||||
"personaLabel": "Выберите персонажа",
|
||||
"personaHint": "SubAgent наследует системные настройки и инструменты выбранного персонажа.",
|
||||
"descriptionLabel": "Описание для основного LLM (используется для принятия решения о handoff)",
|
||||
"descriptionHint": "Отображается как описание инструмента transfer_to_* — будьте кратки и ясны."
|
||||
},
|
||||
"messages": {
|
||||
"loadConfigFailed": "Не удалось загрузить конфигурацию",
|
||||
"loadPersonaFailed": "Не удалось загрузить список персонажей",
|
||||
"nameMissing": "У SubAgent отсутствует имя",
|
||||
"nameInvalid": "Недопустимое имя SubAgent: только строчные латинские буквы/цифры/подчеркивания, должно начинаться с буквы",
|
||||
"nameDuplicate": "Дублирующееся имя SubAgent: {name}",
|
||||
"personaMissing": "У SubAgent {name} не выбран персонаж",
|
||||
"saveSuccess": "Успешно сохранено",
|
||||
"saveFailed": "Ошибка сохранения",
|
||||
"nameRequired": "Имя обязательно",
|
||||
"namePattern": "Только строчные буквы, цифры и подчеркивание"
|
||||
},
|
||||
"empty": {
|
||||
"title": "Агенты не настроены",
|
||||
"subtitle": "Добавьте первого под-агента, чтобы начать",
|
||||
"action": "Создать первого агента"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
{
|
||||
"title": "Инструменты и функции",
|
||||
"subtitle": "Управление MCP-серверами и доступными функциями",
|
||||
"tooltip": {
|
||||
"info": "Что такое Function Calling и MCP?",
|
||||
"marketplace": "Обзор и установка MCP-серверов от сообщества",
|
||||
"serverConfig": "Конфигурация MCP-серверов (stdio) поддерживает следующие поля:\ncommand: имя команды (например, python или uv)\nargs: массив аргументов (например, [\"run\", \"server.py\"])\nenv: объект переменных окружения (например, {\"api_key\": \"abc\"})\ncwd: рабочий каталог (например, /path/to/server)\nencoding: кодировка вывода (по умолчанию utf-8)\nПодробности см. в документации MCP.\n⚠️ Если вы используете Docker, устанавливайте сервера в смонтированную директорию data."
|
||||
},
|
||||
"tabs": {
|
||||
"local": "Локальные сервера",
|
||||
"marketplace": "Магазин MCP"
|
||||
},
|
||||
"mcpServers": {
|
||||
"title": "MCP Сервера",
|
||||
"buttons": {
|
||||
"refresh": "Обновить",
|
||||
"add": "Добавить сервер",
|
||||
"useTemplateStdio": "Шаблон Stdio",
|
||||
"useTemplateStreamableHttp": "Шаблон Streamable HTTP",
|
||||
"useTemplateSse": "Шаблон SSE",
|
||||
"sync": "Синхронизировать"
|
||||
},
|
||||
"empty": "MCP-сервера не найдены. Нажмите «Добавить сервер».",
|
||||
"status": {
|
||||
"noTools": "Нет доступных инструментов",
|
||||
"availableTools": "Доступные инструменты",
|
||||
"configSummary": "Конфигурация: {keys}",
|
||||
"noConfig": "Конфигурация не задана"
|
||||
}
|
||||
},
|
||||
"functionTools": {
|
||||
"title": "Функции (Tools)",
|
||||
"buttons": {
|
||||
"view": "Показать инструменты"
|
||||
},
|
||||
"search": "Поиск по функциям",
|
||||
"empty": "Доступные инструменты не найдены",
|
||||
"description": "Описание функции",
|
||||
"parameters": "Параметры",
|
||||
"noParameters": "У этого инструмента нет параметров",
|
||||
"table": {
|
||||
"paramName": "Параметр",
|
||||
"type": "Тип",
|
||||
"description": "Описание",
|
||||
"required": "Обяз.",
|
||||
"origin": "Источник",
|
||||
"originName": "Имя источника",
|
||||
"actions": "Действия"
|
||||
}
|
||||
},
|
||||
"marketplace": {
|
||||
"title": "Магазин MCP-серверов",
|
||||
"search": "Поиск по магазину",
|
||||
"buttons": {
|
||||
"refresh": "Обновить",
|
||||
"detail": "Инфо",
|
||||
"import": "Импорт"
|
||||
},
|
||||
"loading": "Загрузка списка серверов...",
|
||||
"empty": "Доступных MCP-серверов не найдено",
|
||||
"status": {
|
||||
"availableTools": "Инструментов: {count}",
|
||||
"noToolsInfo": "Нет данных об инструментах"
|
||||
}
|
||||
},
|
||||
"dialogs": {
|
||||
"addServer": {
|
||||
"title": "Добавление MCP-сервера",
|
||||
"editTitle": "Редактирование MCP-сервера",
|
||||
"fields": {
|
||||
"name": "Название сервера",
|
||||
"nameRequired": "Название обязательно",
|
||||
"enable": "Включить сервер",
|
||||
"config": "Конфигурация сервера"
|
||||
},
|
||||
"errors": {
|
||||
"configEmpty": "Конфигурация не может быть пустой",
|
||||
"jsonFormat": "Ошибка формата JSON: {error}",
|
||||
"jsonParse": "Ошибка разбора JSON: {error}"
|
||||
},
|
||||
"buttons": {
|
||||
"cancel": "Отмена",
|
||||
"save": "Сохранить",
|
||||
"testConnection": "Тест связи",
|
||||
"sync": "Синхронизировать"
|
||||
},
|
||||
"tips": {
|
||||
"timeoutConfig": "Тайм-аут вызова инструментов настраивается отдельно на странице конфигурации"
|
||||
}
|
||||
},
|
||||
"serverDetail": {
|
||||
"title": "Детали сервера",
|
||||
"installConfig": "Конфигурация установки",
|
||||
"availableTools": "Список инструментов",
|
||||
"buttons": {
|
||||
"close": "Закрыть",
|
||||
"importConfig": "Импортировать конфиг"
|
||||
}
|
||||
},
|
||||
"confirmDelete": "Вы уверены, что хотите удалить сервер «{name}»?",
|
||||
"syncProvider": {
|
||||
"title": "Синхронизация MCP",
|
||||
"subtitle": "Загрузка конфигурации MCP-серверов от провайдера",
|
||||
"steps": {
|
||||
"selectProvider": "Шаг 1: Провайдер",
|
||||
"configureAuth": "Шаг 2: Авторизация",
|
||||
"syncServers": "Шаг 3: Синхронизация"
|
||||
},
|
||||
"providers": {
|
||||
"modelscope": "ModelScope",
|
||||
"description": "ModelScope — это сообщество моделей с открытым исходным кодом, предоставляющее различные MCP-сервера для AI-сервисов"
|
||||
},
|
||||
"fields": {
|
||||
"provider": "Выберите провайдера",
|
||||
"accessToken": "Токен доступа",
|
||||
"tokenRequired": "Токен обязателен",
|
||||
"tokenHint": "Введите ваш токен доступа ModelScope"
|
||||
},
|
||||
"buttons": {
|
||||
"cancel": "Отмена",
|
||||
"previous": "Назад",
|
||||
"next": "Далее",
|
||||
"sync": "Начать",
|
||||
"getToken": "Получить токен"
|
||||
},
|
||||
"status": {
|
||||
"selectProvider": "Пожалуйста, выберите провайдера MCP-серверов",
|
||||
"enterToken": "Введите токен для продолжения",
|
||||
"readyToSync": "Готов к синхронизации"
|
||||
},
|
||||
"messages": {
|
||||
"syncSuccess": "MCP-сервера успешно синхронизированы!",
|
||||
"syncError": "Ошибка синхронизации: {error}",
|
||||
"tokenHelp": "Как получить токен ModelScope? Нажмите кнопку справа для инструкции"
|
||||
}
|
||||
}
|
||||
},
|
||||
"messages": {
|
||||
"getServersError": "Ошибка получения списка серверов: {error}",
|
||||
"getToolsError": "Ошибка получения списка инструментов: {error}",
|
||||
"saveSuccess": "Настройки сохранены!",
|
||||
"saveError": "Ошибка сохранения: {error}",
|
||||
"deleteSuccess": "Сервер удален успешно!",
|
||||
"deleteError": "Ошибка удаления: {error}",
|
||||
"updateSuccess": "Обновлено успешно!",
|
||||
"updateError": "Ошибка обновления: {error}",
|
||||
"getMarketError": "Не удалось загрузить магазин MCP: {error}",
|
||||
"importError": {
|
||||
"noConfig": "У этого сервера нет доступной конфигурации",
|
||||
"invalidFormat": "Неверный формат конфигурации",
|
||||
"failed": "Импорт не удался: {error}"
|
||||
},
|
||||
"configParseError": "Ошибка разбора конфигурации: {error}",
|
||||
"noAvailableConfig": "Конфигурация отсутствует",
|
||||
"toggleToolSuccess": "Статус инструмента изменен!",
|
||||
"toggleToolError": "Не удалось изменить статус: {error}",
|
||||
"testError": "Ошибка теста связи: {error}"
|
||||
},
|
||||
"syncProvider": {
|
||||
"title": "Синхронизация серверов MCP",
|
||||
"subtitle": "Синхронизировать конфигурации серверов MCP от провайдеров с локальными",
|
||||
"steps": {
|
||||
"selectProvider": "Шаг 1: Выберите провайдер",
|
||||
"configureAuth": "Шаг 2: Настройте аутентификацию",
|
||||
"syncServers": "Шаг 3: Синхронизируйте серверы"
|
||||
},
|
||||
"providers": {
|
||||
"modelscope": "ModelScope",
|
||||
"description": "ModelScope — это сообщество открытых моделей, предоставляющее серверы MCP для различных сервисов машинного обучения и ИИ"
|
||||
},
|
||||
"fields": {
|
||||
"provider": "Выберите провайдер",
|
||||
"accessToken": "Токен доступа",
|
||||
"tokenRequired": "Требуется токен доступа",
|
||||
"tokenHint": "Введите ваш токен доступа ModelScope"
|
||||
},
|
||||
"buttons": {
|
||||
"cancel": "Отмена",
|
||||
"previous": "Назад",
|
||||
"next": "Далее",
|
||||
"sync": "Начать синхронизацию",
|
||||
"getToken": "Получить токен"
|
||||
},
|
||||
"status": {
|
||||
"selectProvider": "Пожалуйста, выберите провайдер сервера MCP",
|
||||
"enterToken": "Введите токен доступа для продолжения",
|
||||
"readyToSync": "Готово к синхронизации конфигураций серверов"
|
||||
},
|
||||
"messages": {
|
||||
"syncSuccess": "Серверы MCP успешно синхронизированы!",
|
||||
"syncError": "Ошибка синхронизации: {error}",
|
||||
"tokenHelp": "Как получить токен доступа ModelScope? Нажмите кнопку справа для получения инструкций"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"title": "Трассировка (Trace)",
|
||||
"autoScroll": {
|
||||
"enabled": "Автопрокрутка: ВКЛ",
|
||||
"disabled": "Автопрокрутка: ВЫКЛ"
|
||||
},
|
||||
"hint": "В данный момент записываются только вызовы моделей основного агента AstrBot. Система будет совершенствоваться.",
|
||||
"recording": "Запись...",
|
||||
"paused": "Пауза"
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"greeting": {
|
||||
"morning": "Доброе утро, добро пожаловать в AstrBot",
|
||||
"afternoon": "Добрый день, добро пожаловать в AstrBot",
|
||||
"evening": "Добрый вечер, добро пожаловать в AstrBot",
|
||||
"newYear": "С Новым Годом!"
|
||||
},
|
||||
"subtitle": "Сначала пройдите базовое руководство. Настройку платформ и провайдеров моделей можно завершить позже.",
|
||||
"announcement": {
|
||||
"title": "Объявление"
|
||||
},
|
||||
"onboard": {
|
||||
"title": "Быстрый старт",
|
||||
"subtitle": "Вы можете выполнить первичную настройку прямо здесь.",
|
||||
"step1Title": "Настройка платформ",
|
||||
"step1Desc": "Подключите AstrBot к QQ, Lark, WeChat, Telegram и другим мессенджерам.",
|
||||
"step2Title": "Настройка AI моделей",
|
||||
"step2Desc": "Выберите и настройте AI провайдеров для AstrBot.",
|
||||
"configure": "Настроить",
|
||||
"skip": "Пропустить",
|
||||
"pending": "Ожидает",
|
||||
"completed": "Готово",
|
||||
"skipped": "Пропущено",
|
||||
"platformLoadFailed": "Ошибка загрузки конфигурации платформ",
|
||||
"providerLoadFailed": "Ошибка загрузки конфигурации провайдеров",
|
||||
"providerUpdateFailed": "Ошибка обновления провайдера по умолчанию в файле default",
|
||||
"providerDefaultUpdated": "Провайдер {id} установлен по умолчанию в файле default"
|
||||
},
|
||||
"resources": {
|
||||
"title": "Ресурсы",
|
||||
"githubDesc": "Поставьте нам звезду на GitHub!",
|
||||
"docsTitle": "Документация",
|
||||
"docsDesc": "Официальная документация AstrBot.",
|
||||
"afdianTitle": "Afdian",
|
||||
"afdianDesc": "Поддержите команду AstrBot через Afdian."
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
{
|
||||
"network": {
|
||||
"timeout": "Время ожидания запроса истекло, попробуйте позже",
|
||||
"connection": "Ошибка сетевого соединения. Проверьте интернет",
|
||||
"server": "Внутренняя ошибка сервера. Обратитесь в поддержку",
|
||||
"unavailable": "Сервис временно недоступен",
|
||||
"forbidden": "Доступ запрещен"
|
||||
},
|
||||
"validation": {
|
||||
"required": "Это поле обязательно для заполнения",
|
||||
"invalid": "Неверный формат ввода",
|
||||
"tooLong": "Введено слишком много символов",
|
||||
"tooShort": "Введено слишком мало символов",
|
||||
"email": "Укажите корректный email",
|
||||
"url": "Укажите корректный URL",
|
||||
"number": "Введите числовое значение"
|
||||
},
|
||||
"auth": {
|
||||
"unauthorized": "Авторизация не выполнена, войдите снова",
|
||||
"forbidden": "Недостаточно прав для выполнения операции",
|
||||
"tokenExpired": "Сессия истекла, пожалуйста, войдите заново",
|
||||
"invalidCredentials": "Неверное имя пользователя или пароль"
|
||||
},
|
||||
"file": {
|
||||
"uploadFailed": "Загрузка файла не удалась",
|
||||
"invalidFormat": "Неподдерживаемый формат файла",
|
||||
"tooLarge": "Файл слишком большой",
|
||||
"notFound": "Файл не найден"
|
||||
},
|
||||
"operation": {
|
||||
"failed": "Операция не удалась",
|
||||
"cancelled": "Операция отменена",
|
||||
"notSupported": "Действие не поддерживается",
|
||||
"conflict": "Конфликт операций, попробуйте позже"
|
||||
},
|
||||
"browser": {
|
||||
"audioNotSupported": "Ваш браузер не поддерживает воспроизведение аудио."
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"operation": {
|
||||
"saved": "Сохранено",
|
||||
"created": "Создано",
|
||||
"updated": "Обновлено успешно",
|
||||
"deleted": "Удалено",
|
||||
"uploaded": "Загружено",
|
||||
"downloaded": "Скачано",
|
||||
"imported": "Импорт завершен",
|
||||
"exported": "Экспорт завершен",
|
||||
"copied": "Скопировано в буфер",
|
||||
"sent": "Отправлено"
|
||||
},
|
||||
"connection": {
|
||||
"connected": "Подключено",
|
||||
"authenticated": "Вход выполнен",
|
||||
"synchronized": "Синхронизация завершена"
|
||||
},
|
||||
"validation": {
|
||||
"valid": "Проверка пройдена",
|
||||
"completed": "Готово"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"required": "Это поле обязательно",
|
||||
"email": "Введите корректный email",
|
||||
"url": "Введите корректный URL",
|
||||
"number": "Введите число",
|
||||
"min": "Минимальное значение: {min}",
|
||||
"max": "Максимальное значение: {max}",
|
||||
"minLength": "Минимум {length} симв.",
|
||||
"maxLength": "Максимум {length} симв.",
|
||||
"pattern": "Неверный формат",
|
||||
"unique": "Такое значение уже существует",
|
||||
"confirm": "Значения не совпадают",
|
||||
"fileSize": "Размер файла не должен превышать {size}MB",
|
||||
"fileType": "Неподдерживаемый тип файла",
|
||||
"required_field": "Заполните обязательные поля",
|
||||
"invalid_format": "Некорректный формат",
|
||||
"password_too_short": "Пароль должен быть не менее 8 символов",
|
||||
"password_too_weak": "Пароль слишком слабый",
|
||||
"invalid_phone": "Некорректный номер телефона",
|
||||
"invalid_date": "Некорректная дата",
|
||||
"date_range": "Неверный диапазон дат",
|
||||
"upload_failed": "Загрузка не удалась",
|
||||
"network_error": "Ошибка сети, попробуйте снова",
|
||||
"operation_cannot_be_undone": "⚠️ Это действие нельзя отменить, будьте осторожны!"
|
||||
}
|
||||
@@ -543,7 +543,7 @@
|
||||
},
|
||||
"unified_webhook_mode": {
|
||||
"description": "统一 Webhook 模式",
|
||||
"hint": "启用后,将使用 AstrBot 统一 Webhook 入口,无需单独开启端口。回调地址为 /api/platform/webhook/{webhook_uuid}。"
|
||||
"hint": "Webhook 模式下使用 AstrBot 统一 Webhook 入口,无需单独开启端口。回调地址为 /api/platform/webhook/{webhook_uuid}。"
|
||||
},
|
||||
"webhook_uuid": {
|
||||
"description": "Webhook UUID",
|
||||
@@ -553,13 +553,41 @@
|
||||
"description": "企业微信智能机器人的名字",
|
||||
"hint": "请务必填写正确,否则无法使用一些指令。"
|
||||
},
|
||||
"wecom_ai_bot_connection_mode": {
|
||||
"description": "企业微信智能机器人连接模式",
|
||||
"hint": "Webhook 回调模式需要配置 Token/EncodingAESKey;长连接模式需要配置 BotID/Secret。"
|
||||
},
|
||||
"wecomaibot_friend_message_welcome_text": {
|
||||
"description": "企业微信智能机器人私聊欢迎语",
|
||||
"hint": "当用户当天进入智能机器人单聊会话,回复欢迎语,如 “💭 思考中...”。留空则不回复。"
|
||||
"hint": "可选。当用户当天进入智能机器人单聊会话,回复欢迎语,如 “💭 思考中...”。留空则不回复。"
|
||||
},
|
||||
"wecomaibot_init_respond_text": {
|
||||
"description": "企业微信智能机器人初始响应文本",
|
||||
"hint": "当机器人收到消息时,首先回复的文本内容。留空则不设置。"
|
||||
"hint": "可选。当机器人收到消息时,首先回复的文本内容。留空则不设置。"
|
||||
},
|
||||
"wecomaibot_token": {
|
||||
"description": "企业微信智能机器人 Token",
|
||||
"hint": "用于 Webhook 回调模式的身份验证。"
|
||||
},
|
||||
"wecomaibot_encoding_aes_key": {
|
||||
"description": "企业微信智能机器人 EncodingAESKey",
|
||||
"hint": "用于 Webhook 回调模式的消息加密解密。"
|
||||
},
|
||||
"wecomaibot_ws_bot_id": {
|
||||
"description": "长连接 BotID",
|
||||
"hint": "企业微信智能机器人长连接模式凭证 BotID。"
|
||||
},
|
||||
"wecomaibot_ws_secret": {
|
||||
"description": "长连接 Secret",
|
||||
"hint": "企业微信智能机器人长连接模式凭证 Secret。"
|
||||
},
|
||||
"wecomaibot_ws_url": {
|
||||
"description": "长连接 WebSocket 地址",
|
||||
"hint": "默认值为 wss://openws.work.weixin.qq.com,一般无需修改。"
|
||||
},
|
||||
"wecomaibot_heartbeat_interval": {
|
||||
"description": "长连接心跳间隔",
|
||||
"hint": "长连接模式心跳间隔(秒),建议 30 秒。"
|
||||
},
|
||||
"wpp_active_message_poll": {
|
||||
"description": "是否启用主动消息轮询",
|
||||
@@ -582,11 +610,11 @@
|
||||
},
|
||||
"msg_push_webhook_url": {
|
||||
"description": "企业微信消息推送 Webhook URL",
|
||||
"hint": "用于主动消息推送,请在企微群->消息推送得到 URL。强烈建议设置此项以带来更好的消息发送体验。"
|
||||
"hint": "可选。用于主动消息推送,请在企微群->消息推送得到 URL。建议设置此项以带来更好的消息发送体验。"
|
||||
},
|
||||
"only_use_webhook_url_to_send": {
|
||||
"description": "仅使用 Webhook 发送消息",
|
||||
"hint": "启用后,企业微信智能机器人的所有回复都改为通过消息推送 Webhook 发送。消息推送 Webhook 支持更多的消息类型(如图片、文件等)。如果不需要打字机效果,强烈建议使用此选项。"
|
||||
"hint": "可选。启用后,企业微信智能机器人的所有回复都改为通过消息推送 Webhook 发送。消息推送 Webhook 支持更多的消息类型(如图片、文件等)。如果不需要打字机效果,强烈建议使用此选项。"
|
||||
},
|
||||
"kook_bot_token": {
|
||||
"description": "机器人 Token",
|
||||
@@ -848,7 +876,8 @@
|
||||
]
|
||||
},
|
||||
"regex": {
|
||||
"description": "分段正则表达式"
|
||||
"description": "分段正则表达式",
|
||||
"hint": "用于按正则规则识别分段点。建议使用能匹配分隔符的表达式。"
|
||||
},
|
||||
"split_words": {
|
||||
"description": "分段词列表",
|
||||
@@ -1496,4 +1525,4 @@
|
||||
"helpMiddle": "或",
|
||||
"helpSuffix": "。"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -23,6 +23,9 @@
|
||||
"placeholder": "搜索插件...",
|
||||
"marketPlaceholder": "搜索市场插件..."
|
||||
},
|
||||
"filters": {
|
||||
"all": "全部"
|
||||
},
|
||||
"views": {
|
||||
"card": "卡片视图",
|
||||
"list": "列表视图"
|
||||
@@ -122,10 +125,14 @@
|
||||
"sourceSafetyWarning": "即使是默认插件源,我们也不能完全保证插件的稳定性和安全性,使用前请谨慎核查。"
|
||||
},
|
||||
"sort": {
|
||||
"by": "排序方式",
|
||||
"default": "默认排序",
|
||||
"installTime": "最后修改时间",
|
||||
"name": "名称",
|
||||
"stars": "Star数",
|
||||
"author": "作者名",
|
||||
"updated": "更新时间",
|
||||
"updateStatus": "更新状态",
|
||||
"ascending": "升序",
|
||||
"descending": "降序"
|
||||
},
|
||||
|
||||
@@ -42,7 +42,7 @@ import zhCNErrors from './locales/zh-CN/messages/errors.json';
|
||||
import zhCNSuccess from './locales/zh-CN/messages/success.json';
|
||||
import zhCNValidation from './locales/zh-CN/messages/validation.json';
|
||||
|
||||
// 英文翻译
|
||||
// English translation
|
||||
import enUSCommon from './locales/en-US/core/common.json';
|
||||
import enUSActions from './locales/en-US/core/actions.json';
|
||||
import enUSStatus from './locales/en-US/core/status.json';
|
||||
@@ -83,6 +83,47 @@ import enUSErrors from './locales/en-US/messages/errors.json';
|
||||
import enUSSuccess from './locales/en-US/messages/success.json';
|
||||
import enUSValidation from './locales/en-US/messages/validation.json';
|
||||
|
||||
// Russian translation
|
||||
import ruRUCommon from './locales/ru-RU/core/common.json';
|
||||
import ruRUActions from './locales/ru-RU/core/actions.json';
|
||||
import ruRUStatus from './locales/ru-RU/core/status.json';
|
||||
import ruRUNavigation from './locales/ru-RU/core/navigation.json';
|
||||
import ruRUHeader from './locales/ru-RU/core/header.json';
|
||||
import ruRUShared from './locales/ru-RU/core/shared.json';
|
||||
|
||||
import ruRUChat from './locales/ru-RU/features/chat.json';
|
||||
import ruRUExtension from './locales/ru-RU/features/extension.json';
|
||||
import ruRUConversation from './locales/ru-RU/features/conversation.json';
|
||||
import ruRUSessionManagement from './locales/ru-RU/features/session-management.json';
|
||||
import ruRUToolUse from './locales/ru-RU/features/tool-use.json';
|
||||
import ruRUProvider from './locales/ru-RU/features/provider.json';
|
||||
import ruRUPlatform from './locales/ru-RU/features/platform.json';
|
||||
import ruRUConfig from './locales/ru-RU/features/config.json';
|
||||
import ruRUConfigMetadata from './locales/ru-RU/features/config-metadata.json';
|
||||
import ruRUConsole from './locales/ru-RU/features/console.json';
|
||||
import ruRUTrace from './locales/ru-RU/features/trace.json';
|
||||
import ruRUAbout from './locales/ru-RU/features/about.json';
|
||||
import ruRUSettings from './locales/ru-RU/features/settings.json';
|
||||
import ruRUAuth from './locales/ru-RU/features/auth.json';
|
||||
import ruRUChart from './locales/ru-RU/features/chart.json';
|
||||
import ruRUDashboard from './locales/ru-RU/features/dashboard.json';
|
||||
import ruRUCron from './locales/ru-RU/features/cron.json';
|
||||
import ruRUAlkaidIndex from './locales/ru-RU/features/alkaid/index.json';
|
||||
import ruRUAlkaidKnowledgeBase from './locales/ru-RU/features/alkaid/knowledge-base.json';
|
||||
import ruRUAlkaidMemory from './locales/ru-RU/features/alkaid/memory.json';
|
||||
import ruRUKnowledgeBaseIndex from './locales/ru-RU/features/knowledge-base/index.json';
|
||||
import ruRUKnowledgeBaseDetail from './locales/ru-RU/features/knowledge-base/detail.json';
|
||||
import ruRUKnowledgeBaseDocument from './locales/ru-RU/features/knowledge-base/document.json';
|
||||
import ruRUPersona from './locales/ru-RU/features/persona.json';
|
||||
import ruRUMigration from './locales/ru-RU/features/migration.json';
|
||||
import ruRUCommand from './locales/ru-RU/features/command.json';
|
||||
import ruRUSubagent from './locales/ru-RU/features/subagent.json';
|
||||
import ruRUWelcome from './locales/ru-RU/features/welcome.json';
|
||||
|
||||
import ruRUErrors from './locales/ru-RU/messages/errors.json';
|
||||
import ruRUSuccess from './locales/ru-RU/messages/success.json';
|
||||
import ruRUValidation from './locales/ru-RU/messages/validation.json';
|
||||
|
||||
// 组装翻译对象
|
||||
export const translations = {
|
||||
'zh-CN': {
|
||||
@@ -182,6 +223,55 @@ export const translations = {
|
||||
success: enUSSuccess,
|
||||
validation: enUSValidation
|
||||
}
|
||||
},
|
||||
'ru-RU': {
|
||||
core: {
|
||||
common: ruRUCommon,
|
||||
actions: ruRUActions,
|
||||
status: ruRUStatus,
|
||||
navigation: ruRUNavigation,
|
||||
header: ruRUHeader,
|
||||
shared: ruRUShared
|
||||
},
|
||||
features: {
|
||||
chat: ruRUChat,
|
||||
extension: ruRUExtension,
|
||||
conversation: ruRUConversation,
|
||||
'session-management': ruRUSessionManagement,
|
||||
tooluse: ruRUToolUse,
|
||||
provider: ruRUProvider,
|
||||
platform: ruRUPlatform,
|
||||
config: ruRUConfig,
|
||||
'config-metadata': ruRUConfigMetadata,
|
||||
console: ruRUConsole,
|
||||
trace: ruRUTrace,
|
||||
about: ruRUAbout,
|
||||
settings: ruRUSettings,
|
||||
auth: ruRUAuth,
|
||||
chart: ruRUChart,
|
||||
dashboard: ruRUDashboard,
|
||||
cron: ruRUCron,
|
||||
alkaid: {
|
||||
index: ruRUAlkaidIndex,
|
||||
'knowledge-base': ruRUAlkaidKnowledgeBase,
|
||||
memory: ruRUAlkaidMemory
|
||||
},
|
||||
'knowledge-base': {
|
||||
index: ruRUKnowledgeBaseIndex,
|
||||
detail: ruRUKnowledgeBaseDetail,
|
||||
document: ruRUKnowledgeBaseDocument
|
||||
},
|
||||
persona: ruRUPersona,
|
||||
migration: ruRUMigration,
|
||||
command: ruRUCommand,
|
||||
subagent: ruRUSubagent,
|
||||
welcome: ruRUWelcome
|
||||
},
|
||||
messages: {
|
||||
errors: ruRUErrors,
|
||||
success: ruRUSuccess,
|
||||
validation: ruRUValidation
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -27,6 +27,7 @@ const customizer = useCustomizerStore();
|
||||
const theme = useTheme();
|
||||
const { t } = useI18n();
|
||||
const route = useRoute();
|
||||
const LAST_BOT_ROUTE_KEY = 'astrbot:last_bot_route';
|
||||
let dialog = ref(false);
|
||||
let accountWarning = ref(false)
|
||||
let updateStatusDialog = ref(false);
|
||||
@@ -402,15 +403,32 @@ const viewMode = computed({
|
||||
});
|
||||
|
||||
// 监听 viewMode 变化,切换到 bot 模式时跳转到首页
|
||||
watch(() => customizer.viewMode, (newMode, oldMode) => {
|
||||
if (newMode === 'bot' && oldMode === 'chat') {
|
||||
// 从 chat 模式切换到 bot 模式时,跳转到首页
|
||||
if (route.path !== '/') {
|
||||
router.push('/');
|
||||
// 保存 bot 模式的最後路由
|
||||
// 監聽 route 變化,保存最後一次 bot 路由
|
||||
watch(() => route.fullPath, (newPath) => {
|
||||
if (customizer.viewMode === 'bot' && typeof window !== 'undefined') {
|
||||
try {
|
||||
localStorage.setItem(LAST_BOT_ROUTE_KEY, newPath);
|
||||
} catch (e) {
|
||||
console.error('Failed to save last bot route to localStorage:', e);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// 監聽 viewMode 切換
|
||||
watch(() => customizer.viewMode, (newMode, oldMode) => {
|
||||
if (newMode === 'bot' && oldMode === 'chat' && typeof window !== 'undefined') {
|
||||
// 從 chat 切換回 bot,跳轉到最後一次的 bot 路由
|
||||
let lastBotRoute = '/';
|
||||
try {
|
||||
lastBotRoute = localStorage.getItem(LAST_BOT_ROUTE_KEY) || '/';
|
||||
} catch (e) {
|
||||
console.error('Failed to read last bot route from localStorage:', e);
|
||||
}
|
||||
router.push(lastBotRoute);
|
||||
}
|
||||
});
|
||||
|
||||
// Merry Christmas! 🎄
|
||||
const isChristmas = computed(() => {
|
||||
const today = new Date();
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
export const normalizeTextInput = (value: unknown): string =>
|
||||
typeof value === 'string' ? value : '';
|
||||
@@ -13,9 +13,11 @@
|
||||
</v-select>
|
||||
<v-text-field
|
||||
class="config-search-input"
|
||||
v-model="configSearchKeyword"
|
||||
:model-value="configSearchKeyword"
|
||||
@update:model-value="onConfigSearchInput"
|
||||
prepend-inner-icon="mdi-magnify"
|
||||
:label="tm('search.placeholder')"
|
||||
clearable
|
||||
hide-details
|
||||
density="compact"
|
||||
rounded="md"
|
||||
@@ -211,6 +213,7 @@ import {
|
||||
useConfirmDialog
|
||||
} from '@/utils/confirmDialog';
|
||||
import UnsavedChangesConfirmDialog from '@/components/config/UnsavedChangesConfirmDialog.vue';
|
||||
import { normalizeTextInput } from '@/utils/inputValue';
|
||||
|
||||
export default {
|
||||
name: 'ConfigPage',
|
||||
@@ -419,6 +422,9 @@ export default {
|
||||
|
||||
},
|
||||
methods: {
|
||||
onConfigSearchInput(value) {
|
||||
this.configSearchKeyword = normalizeTextInput(value);
|
||||
},
|
||||
extractConfigTypeFromHash(hash) {
|
||||
const rawHash = String(hash || '');
|
||||
const lastHashIndex = rawHash.lastIndexOf('#');
|
||||
|
||||
@@ -353,10 +353,11 @@
|
||||
<v-window-item value="search">
|
||||
<div class="search-container pa-4">
|
||||
<v-form @submit.prevent="searchKnowledgeBase" class="d-flex align-center">
|
||||
<v-text-field v-model="searchQuery" :label="tm('search.queryLabel')"
|
||||
<v-text-field :model-value="searchQuery"
|
||||
@update:model-value="onSearchQueryInput" :label="tm('search.queryLabel')"
|
||||
append-icon="mdi-magnify" variant="outlined" class="flex-grow-1 me-2"
|
||||
@click:append="searchKnowledgeBase" @keyup.enter="searchKnowledgeBase"
|
||||
:placeholder="tm('search.queryPlaceholder')" hide-details></v-text-field>
|
||||
:placeholder="tm('search.queryPlaceholder')" hide-details clearable></v-text-field>
|
||||
|
||||
<v-select v-model="topK" :items="[3, 5, 10, 20]"
|
||||
:label="tm('search.resultCountLabel')" variant="outlined"
|
||||
@@ -434,6 +435,7 @@
|
||||
import axios from 'axios';
|
||||
import ConsoleDisplayer from '@/components/shared/ConsoleDisplayer.vue';
|
||||
import { useModuleI18n } from '@/i18n/composables';
|
||||
import { normalizeTextInput } from '@/utils/inputValue';
|
||||
|
||||
export default {
|
||||
name: 'KnowledgeBase',
|
||||
@@ -580,6 +582,9 @@ export default {
|
||||
this.getProviderList();
|
||||
},
|
||||
methods: {
|
||||
onSearchQueryInput(value) {
|
||||
this.searchQuery = normalizeTextInput(value);
|
||||
},
|
||||
getSelectedGitHubProxy() {
|
||||
if (typeof window === "undefined" || !window.localStorage) return "";
|
||||
return localStorage.getItem("githubProxyRadioValue") === "1"
|
||||
@@ -903,7 +908,8 @@ export default {
|
||||
},
|
||||
|
||||
searchKnowledgeBase() {
|
||||
if (!this.searchQuery.trim()) {
|
||||
const query = normalizeTextInput(this.searchQuery).trim();
|
||||
if (!query) {
|
||||
this.showSnackbar(this.tm('messages.pleaseEnterSearchContent'), 'warning');
|
||||
return;
|
||||
}
|
||||
@@ -914,7 +920,7 @@ export default {
|
||||
axios.get(`/api/plug/alkaid/kb/collection/search`, {
|
||||
params: {
|
||||
collection_name: this.currentKB.collection_name,
|
||||
query: this.searchQuery,
|
||||
query,
|
||||
top_k: this.topK
|
||||
}
|
||||
})
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user