Compare commits
75 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4c957ffe35 | |||
| 41a7a660c8 | |||
| 44c8c63899 | |||
| 3fbd16b211 | |||
| e77500ff69 | |||
| 2c49ac0dcf | |||
| 65decfbe87 | |||
| 92c31192de | |||
| b795f804a7 | |||
| bc3b5e58a4 | |||
| 7e3c32b828 | |||
| ceb32dce9f | |||
| 84e880af5f | |||
| 9909d774ed | |||
| 6b3868b4be | |||
| 11c840953a | |||
| 2bbca887ce | |||
| dd89a4b334 | |||
| a3fa8a5a7c | |||
| aa60467782 | |||
| d936bb0a10 | |||
| 64e0183b55 | |||
| 420d82df11 | |||
| d87cf897da | |||
| 2f51916a73 | |||
| b0e10cf479 | |||
| 20efaa5320 | |||
| 3ccd70cd4e | |||
| da520e573a | |||
| 6d055e81e9 | |||
| d41ccb70c5 | |||
| 18a99a25c2 | |||
| 96cafe001d | |||
| 29d100dd83 | |||
| 14f3701c4a | |||
| 1044fc48ca | |||
| 693c2ca818 | |||
| b1c486ba98 | |||
| 9363fb824a | |||
| 044b361ac5 | |||
| 06fd2d2428 | |||
| dd6bc1dcdb | |||
| 52d5258b10 | |||
| 91933bbd19 | |||
| f8d075b5d3 | |||
| 86ef758a9a | |||
| 1a03180643 | |||
| 326183a3fd | |||
| 08fc657755 | |||
| 0ff9539599 | |||
| 38f5e077ee | |||
| 89fbd75e7a | |||
| 493662524a | |||
| 1afbb357db | |||
| 8d2140f607 | |||
| 97732987d9 | |||
| a60a40bca3 | |||
| a8ff2b3d9c | |||
| a21bb5b234 | |||
| 994d39241e | |||
| e6c1164755 | |||
| 89cc8a1a65 | |||
| c0e4f1e114 | |||
| 7b43448ce4 | |||
| bdac0b65f4 | |||
| cf9ee6f20c | |||
| 01eae72a64 | |||
| bca1476eab | |||
| fbcbde0a4b | |||
| 3914d766db | |||
| 3e2cb6a2ab | |||
| 25830524f3 | |||
| 304094630c | |||
| 5c3643c54c | |||
| 589cce18af |
@@ -21,7 +21,23 @@
|
||||
<!--If merged, your code will serve tens of thousands of users! Please double-check the following items before submitting.-->
|
||||
<!--如果分支被合并,您的代码将服务于数万名用户!在提交前,请核查一下几点内容。-->
|
||||
|
||||
- [ ] 😊 如果 PR 中有新加入的功能,已经通过 Issue / 邮件等方式和作者讨论过。/ If there are new features added in the PR, I have discussed it with the authors through issues/emails, etc.
|
||||
- [ ] 👀 我的更改经过了良好的测试,**并已在上方提供了“验证步骤”和“运行截图”**。/ My changes have been well-tested, **and "Verification Steps" and "Screenshots" have been provided above**.
|
||||
- [ ] 🤓 我确保没有引入新依赖库,或者引入了新依赖库的同时将其添加到了 `requirements.txt` 和 `pyproject.toml` 文件相应位置。/ I have ensured that no new dependencies are introduced, OR if new dependencies are introduced, they have been added to the appropriate locations in `requirements.txt` and `pyproject.toml`.
|
||||
- [ ] 😮 我的更改没有引入恶意代码。/ My changes do not introduce malicious code.
|
||||
- [ ] 😊 如果 PR 中有新加入的功能,已经通过 Issue / 邮件等方式和作者讨论过。
|
||||
/ If there are new features added in the PR, I have discussed it with the authors through issues/emails, etc.
|
||||
|
||||
- [ ] 👀 我的更改经过了良好的测试,**并已在上方提供了“验证步骤”和“运行截图”**。
|
||||
/ My changes have been well-tested, **and "Verification Steps" and "Screenshots" have been provided above**.
|
||||
|
||||
- [ ] 🤓 我确保没有引入新依赖库,或者引入了新依赖库的同时将其添加到 `requirements.txt` 和 `pyproject.toml` 文件相应位置。
|
||||
/ I have ensured that no new dependencies are introduced, OR if new dependencies are introduced, they have been added to the appropriate locations in `requirements.txt` and `pyproject.toml`.
|
||||
|
||||
- [ ] 😮 我的更改没有引入恶意代码。
|
||||
/ My changes do not introduce malicious code.
|
||||
|
||||
- [ ] ⚠️ 我已认真阅读并理解以上所有内容,确保本次提交符合规范。
|
||||
/ I have read and understood all the above and confirm this PR follows the rules.
|
||||
|
||||
- [ ] 🚀 我确保本次开发**基于 dev 分支**,并将代码合并至**开发分支**(除非极其紧急,才允许合并到主分支)。
|
||||
/ I confirm that this development is **based on the dev branch** and will be merged into the **development branch**, unless it is extremely urgent to merge into the main branch.
|
||||
|
||||
- [ ] ⚠️ 我**没有**认真阅读以上内容,直接提交。
|
||||
/ I **did not** read the above carefully before submitting.
|
||||
|
||||
@@ -11,7 +11,7 @@ jobs:
|
||||
runs-on: ubuntu-latest # 运行环境
|
||||
steps:
|
||||
- name: checkout
|
||||
uses: actions/checkout@master
|
||||
uses: actions/checkout@v6
|
||||
- name: nodejs installation
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
@@ -23,7 +23,7 @@ jobs:
|
||||
run: npm run docs:build
|
||||
working-directory: './docs'
|
||||
- name: scp
|
||||
uses: appleboy/scp-action@master
|
||||
uses: appleboy/scp-action@v1.0.0
|
||||
with:
|
||||
host: ${{ secrets.HOST_NEKO }}
|
||||
username: ${{ secrets.USERNAME }}
|
||||
@@ -31,7 +31,7 @@ jobs:
|
||||
source: 'docs/.vitepress/dist/*'
|
||||
target: '/tmp/'
|
||||
- name: script
|
||||
uses: appleboy/ssh-action@master
|
||||
uses: appleboy/ssh-action@v1.2.5
|
||||
with:
|
||||
host: ${{ secrets.HOST_NEKO }}
|
||||
username: ${{ secrets.USERNAME }}
|
||||
|
||||
@@ -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@v4
|
||||
uses: docker/setup-qemu-action@v4.0.0
|
||||
|
||||
- name: Set Docker Buildx
|
||||
uses: docker/setup-buildx-action@v4
|
||||
uses: docker/setup-buildx-action@v4.0.0
|
||||
|
||||
- name: Log in to DockerHub
|
||||
uses: docker/login-action@v4
|
||||
uses: docker/login-action@v4.0.0
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_HUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_HUB_PASSWORD }}
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
if: env.HAS_GHCR_TOKEN == 'true'
|
||||
uses: docker/login-action@v4
|
||||
uses: docker/login-action@v4.0.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ env.GHCR_OWNER }}
|
||||
@@ -98,7 +98,7 @@ jobs:
|
||||
echo "EOF" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Build and Push Nightly Image
|
||||
uses: docker/build-push-action@v7
|
||||
uses: docker/build-push-action@v7.0.0
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/amd64,linux/arm64
|
||||
@@ -163,27 +163,27 @@ jobs:
|
||||
cp -r dashboard/dist data/
|
||||
|
||||
- name: Set QEMU
|
||||
uses: docker/setup-qemu-action@v4
|
||||
uses: docker/setup-qemu-action@v4.0.0
|
||||
|
||||
- name: Set Docker Buildx
|
||||
uses: docker/setup-buildx-action@v4
|
||||
uses: docker/setup-buildx-action@v4.0.0
|
||||
|
||||
- name: Log in to DockerHub
|
||||
uses: docker/login-action@v4
|
||||
uses: docker/login-action@v4.0.0
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_HUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_HUB_PASSWORD }}
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
if: env.HAS_GHCR_TOKEN == 'true'
|
||||
uses: docker/login-action@v4
|
||||
uses: docker/login-action@v4.0.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ env.GHCR_OWNER }}
|
||||
password: ${{ secrets.GHCR_GITHUB_TOKEN }}
|
||||
|
||||
- name: Build and Push Release Image
|
||||
uses: docker/build-push-action@v7
|
||||
uses: docker/build-push-action@v7.0.0
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/amd64,linux/arm64
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
name: PR Checklist Check
|
||||
|
||||
on:
|
||||
pull_request_target:
|
||||
types: [opened, edited, reopened, synchronize]
|
||||
|
||||
jobs:
|
||||
check:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
permissions:
|
||||
pull-requests: write
|
||||
issues: write
|
||||
|
||||
steps:
|
||||
- name: Check checklist
|
||||
id: check
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
const body = context.payload.pull_request.body || "";
|
||||
const regex = /-\s*\[\s*x\s*\].*没有.*认真阅读/i;
|
||||
const bad = regex.test(body);
|
||||
core.setOutput("bad", bad);
|
||||
|
||||
- name: Close PR
|
||||
if: steps.check.outputs.bad == 'true'
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
const pr = context.payload.pull_request;
|
||||
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: pr.number,
|
||||
body: `检测到你勾选了“我没有认真阅读”,PR 已关闭。`
|
||||
});
|
||||
|
||||
await github.rest.pulls.update({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
pull_number: pr.number,
|
||||
state: "closed"
|
||||
});
|
||||
@@ -50,7 +50,7 @@ jobs:
|
||||
echo "tag=$tag" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
uses: pnpm/action-setup@v4.4.0
|
||||
with:
|
||||
version: 10.28.2
|
||||
|
||||
|
||||
+1
-1
@@ -62,4 +62,4 @@ GenieData/
|
||||
.opencode/
|
||||
.kilocode/
|
||||
.worktrees/
|
||||
docs/plans/
|
||||
|
||||
|
||||
@@ -78,7 +78,7 @@ For users who want to quickly experience AstrBot, are familiar with command-line
|
||||
```bash
|
||||
uv tool install astrbot
|
||||
astrbot init # Only execute this command for the first time to initialize the environment
|
||||
astrbot
|
||||
astrbot run
|
||||
```
|
||||
|
||||
> Requires [uv](https://docs.astral.sh/uv/) to be installed.
|
||||
|
||||
+1
-1
@@ -78,7 +78,7 @@ Pour les utilisateurs qui veulent découvrir AstrBot rapidement, qui sont famili
|
||||
```bash
|
||||
uv tool install astrbot
|
||||
astrbot init # Exécutez cette commande uniquement la première fois pour initialiser l'environnement
|
||||
astrbot
|
||||
astrbot run
|
||||
```
|
||||
|
||||
> [uv](https://docs.astral.sh/uv/) doit être installé.
|
||||
|
||||
+1
-1
@@ -78,7 +78,7 @@ AstrBot を素早く試したいユーザーで、コマンドラインに慣れ
|
||||
```bash
|
||||
uv tool install astrbot
|
||||
astrbot init # 初回のみ実行して環境を初期化します
|
||||
astrbot
|
||||
astrbot run
|
||||
```
|
||||
|
||||
> [uv](https://docs.astral.sh/uv/) のインストールが必要です。
|
||||
|
||||
+1
-1
@@ -78,7 +78,7 @@ AstrBot — это универсальная платформа Agent-чатб
|
||||
```bash
|
||||
uv tool install astrbot
|
||||
astrbot init # Выполните эту команду только при первом запуске для инициализации окружения
|
||||
astrbot
|
||||
astrbot run
|
||||
```
|
||||
|
||||
> Требуется установленный [uv](https://docs.astral.sh/uv/).
|
||||
|
||||
+1
-1
@@ -78,7 +78,7 @@ AstrBot 是一個開源的一站式 Agent 聊天機器人平台,可接入主
|
||||
```bash
|
||||
uv tool install astrbot
|
||||
astrbot init # 僅首次執行此命令以初始化環境
|
||||
astrbot
|
||||
astrbot run
|
||||
```
|
||||
|
||||
> 需要安裝 [uv](https://docs.astral.sh/uv/)。
|
||||
|
||||
+1
-1
@@ -78,7 +78,7 @@ AstrBot 是一个开源的一站式 Agentic 个人和群聊助手,可在 QQ、
|
||||
```bash
|
||||
uv tool install astrbot
|
||||
astrbot init # 仅首次执行此命令以初始化环境
|
||||
astrbot
|
||||
astrbot run
|
||||
```
|
||||
|
||||
> 需要安装 [uv](https://docs.astral.sh/uv/)。
|
||||
|
||||
@@ -1 +1 @@
|
||||
__version__ = "4.19.5"
|
||||
__version__ = "4.20.0"
|
||||
|
||||
@@ -778,9 +778,14 @@ def _plugin_tool_fix(event: AstrMessageEvent, req: ProviderRequest) -> None:
|
||||
continue
|
||||
mp = tool.handler_module_path
|
||||
if not mp:
|
||||
# 没有 plugin 归属信息的工具(如 subagent transfer_to_*)
|
||||
# 不应受到会话插件过滤影响。
|
||||
new_tool_set.add_tool(tool)
|
||||
continue
|
||||
plugin = star_map.get(mp)
|
||||
if not plugin:
|
||||
# 无法解析插件归属时,保守保留工具,避免误过滤。
|
||||
new_tool_set.add_tool(tool)
|
||||
continue
|
||||
if plugin.name in event.plugins_name or plugin.reserved:
|
||||
new_tool_set.add_tool(tool)
|
||||
|
||||
@@ -188,7 +188,12 @@ class KnowledgeBaseQueryTool(FunctionTool[AstrAgentContext]):
|
||||
@dataclass
|
||||
class SendMessageToUserTool(FunctionTool[AstrAgentContext]):
|
||||
name: str = "send_message_to_user"
|
||||
description: str = "Directly send message to the user. Only use this tool when you need to proactively message the user. Otherwise you can directly output the reply in the conversation."
|
||||
description: str = (
|
||||
"Send message to the user. "
|
||||
"Supports various message types including `plain`, `image`, `record`, `video`, `file`, and `mention_user`. "
|
||||
"Use this tool to send media files (`image`, `record`, `video`, `file`), "
|
||||
"or when you need to proactively message the user(such as cron job). For normal text replies, you can output directly."
|
||||
)
|
||||
|
||||
parameters: dict = Field(
|
||||
default_factory=lambda: {
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
@@ -164,7 +164,10 @@ class CreateSkillPayloadTool(NeoSkillToolBase):
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"payload": {
|
||||
"anyOf": [{"type": "object"}, {"type": "array"}],
|
||||
"anyOf": [
|
||||
{"type": "object"},
|
||||
{"type": "array", "items": {"type": "object"}},
|
||||
],
|
||||
"description": (
|
||||
"Skill payload JSON. Typical schema: {skill_markdown, inputs, outputs, meta}. "
|
||||
"This only stores content and returns payload_ref; it does not create a candidate or release."
|
||||
|
||||
@@ -5,7 +5,7 @@ from typing import Any, TypedDict
|
||||
|
||||
from astrbot.core.utils.astrbot_path import get_astrbot_data_path
|
||||
|
||||
VERSION = "4.19.5"
|
||||
VERSION = "4.20.0"
|
||||
DB_PATH = os.path.join(get_astrbot_data_path(), "data_v4.db")
|
||||
|
||||
WEBHOOK_SUPPORTED_PLATFORMS = [
|
||||
@@ -1132,6 +1132,18 @@ CONFIG_METADATA_2 = {
|
||||
"proxy": "",
|
||||
"custom_headers": {},
|
||||
},
|
||||
"MiniMax": {
|
||||
"id": "minimax",
|
||||
"provider": "minimax",
|
||||
"type": "openai_chat_completion",
|
||||
"provider_type": "chat_completion",
|
||||
"enable": True,
|
||||
"key": [],
|
||||
"api_base": "https://api.minimaxi.com/v1",
|
||||
"timeout": 120,
|
||||
"proxy": "",
|
||||
"custom_headers": {},
|
||||
},
|
||||
"xAI": {
|
||||
"id": "xai",
|
||||
"provider": "xai",
|
||||
|
||||
@@ -332,9 +332,9 @@ class CronJobManager:
|
||||
cron_job=cron_job_str
|
||||
)
|
||||
req.prompt = (
|
||||
"You are now responding to a scheduled task"
|
||||
"You are now responding to a scheduled task. "
|
||||
"Proceed according to your system instructions. "
|
||||
"Output using same language as previous conversation."
|
||||
"Output using same language as previous conversation. "
|
||||
"After completing your task, summarize and output your actions and results."
|
||||
)
|
||||
if not req.func_tool:
|
||||
|
||||
@@ -647,6 +647,13 @@ class BaseDatabase(abc.ABC):
|
||||
"""Get a Platform session by its ID."""
|
||||
...
|
||||
|
||||
@abc.abstractmethod
|
||||
async def get_platform_sessions_by_ids(
|
||||
self, session_ids: list[str]
|
||||
) -> list[PlatformSession]:
|
||||
"""Get platform sessions by IDs."""
|
||||
...
|
||||
|
||||
@abc.abstractmethod
|
||||
async def get_platform_sessions_by_creator(
|
||||
self,
|
||||
|
||||
@@ -1417,6 +1417,21 @@ class SQLiteDatabase(BaseDatabase):
|
||||
result = await session.execute(query)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
async def get_platform_sessions_by_ids(
|
||||
self, session_ids: list[str]
|
||||
) -> list[PlatformSession]:
|
||||
"""Get platform sessions by IDs."""
|
||||
if not session_ids:
|
||||
return []
|
||||
|
||||
async with self.get_db() as session:
|
||||
session: AsyncSession
|
||||
query = select(PlatformSession).where(
|
||||
col(PlatformSession.session_id).in_(session_ids)
|
||||
)
|
||||
result = await session.execute(query)
|
||||
return list(result.scalars().all())
|
||||
|
||||
async def get_platform_sessions_by_creator(
|
||||
self,
|
||||
creator: str,
|
||||
|
||||
@@ -96,10 +96,10 @@ class Plain(BaseMessageComponent):
|
||||
def __init__(self, text: str, convert: bool = True, **_) -> None:
|
||||
super().__init__(text=text, convert=convert, **_)
|
||||
|
||||
def toDict(self):
|
||||
return {"type": "text", "data": {"text": self.text.strip()}}
|
||||
def toDict(self) -> dict:
|
||||
return {"type": "text", "data": {"text": self.text}}
|
||||
|
||||
async def to_dict(self):
|
||||
async def to_dict(self) -> dict:
|
||||
return {"type": "text", "data": {"text": self.text}}
|
||||
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ from aiocqhttp import CQHttp, Event
|
||||
|
||||
from astrbot.api.event import AstrMessageEvent, MessageChain
|
||||
from astrbot.api.message_components import (
|
||||
At,
|
||||
BaseMessageComponent,
|
||||
File,
|
||||
Image,
|
||||
@@ -70,11 +71,19 @@ class AiocqhttpMessageEvent(AstrMessageEvent):
|
||||
"""解析成 OneBot json 格式"""
|
||||
ret = []
|
||||
for segment in message_chain.chain:
|
||||
if isinstance(segment, Plain):
|
||||
if isinstance(segment, At):
|
||||
# At 组件后插入一个空格,避免与后续文本粘连
|
||||
d = await AiocqhttpMessageEvent._from_segment_to_dict(segment)
|
||||
ret.append(d)
|
||||
ret.append({"type": "text", "data": {"text": " "}})
|
||||
elif isinstance(segment, Plain):
|
||||
if not segment.text.strip():
|
||||
continue
|
||||
d = await AiocqhttpMessageEvent._from_segment_to_dict(segment)
|
||||
ret.append(d)
|
||||
d = await AiocqhttpMessageEvent._from_segment_to_dict(segment)
|
||||
ret.append(d)
|
||||
else:
|
||||
d = await AiocqhttpMessageEvent._from_segment_to_dict(segment)
|
||||
ret.append(d)
|
||||
return ret
|
||||
|
||||
@classmethod
|
||||
|
||||
@@ -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,73 @@ 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_face_message(content: str) -> str:
|
||||
"""Parse QQ official face message format and convert to readable text.
|
||||
|
||||
QQ official face message format:
|
||||
<faceType=4,faceId="",ext="eyJ0ZXh0IjoiW+a7oeWktOmXruWPt10ifQ==">
|
||||
|
||||
The ext field contains base64-encoded JSON with a 'text' field
|
||||
describing the emoji (e.g., '[满头问号]').
|
||||
|
||||
Args:
|
||||
content: The message content that may contain face tags.
|
||||
|
||||
Returns:
|
||||
Content with face tags replaced by readable emoji descriptions.
|
||||
"""
|
||||
import base64
|
||||
import json
|
||||
import re
|
||||
|
||||
def replace_face(match):
|
||||
face_tag = match.group(0)
|
||||
# Extract ext field from the face tag
|
||||
ext_match = re.search(r'ext="([^"]*)"', face_tag)
|
||||
if ext_match:
|
||||
try:
|
||||
ext_encoded = ext_match.group(1)
|
||||
# Decode base64 and parse JSON
|
||||
ext_decoded = base64.b64decode(ext_encoded).decode("utf-8")
|
||||
ext_data = json.loads(ext_decoded)
|
||||
emoji_text = ext_data.get("text", "")
|
||||
if emoji_text:
|
||||
return f"[表情:{emoji_text}]"
|
||||
except Exception:
|
||||
pass
|
||||
# Fallback if parsing fails
|
||||
return "[表情]"
|
||||
|
||||
# Match face tags: <faceType=...>
|
||||
return re.sub(r"<faceType=\d+[^>]*>", replace_face, content)
|
||||
|
||||
@staticmethod
|
||||
def _parse_from_qqofficial(
|
||||
@@ -201,7 +457,10 @@ class QQOfficialPlatformAdapter(Platform):
|
||||
abm.group_id = message.group_openid
|
||||
else:
|
||||
abm.sender = MessageMember(message.author.user_openid, "")
|
||||
abm.message_str = message.content.strip()
|
||||
# Parse face messages to readable text
|
||||
abm.message_str = QQOfficialPlatformAdapter._parse_face_message(
|
||||
message.content.strip()
|
||||
)
|
||||
abm.self_id = "unknown_selfid"
|
||||
msg.append(At(qq="qq_official"))
|
||||
msg.append(Plain(abm.message_str))
|
||||
@@ -217,10 +476,12 @@ class QQOfficialPlatformAdapter(Platform):
|
||||
else:
|
||||
abm.self_id = ""
|
||||
|
||||
plain_content = message.content.replace(
|
||||
"<@!" + str(abm.self_id) + ">",
|
||||
"",
|
||||
).strip()
|
||||
plain_content = QQOfficialPlatformAdapter._parse_face_message(
|
||||
message.content.replace(
|
||||
"<@!" + str(abm.self_id) + ">",
|
||||
"",
|
||||
).strip()
|
||||
)
|
||||
|
||||
QQOfficialPlatformAdapter._append_attachments(msg, message.attachments)
|
||||
abm.message = msg
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import asyncio
|
||||
import logging
|
||||
import time
|
||||
from typing import cast
|
||||
|
||||
import quart
|
||||
@@ -39,6 +40,9 @@ class QQOfficialWebhook:
|
||||
self.client = botpy_client
|
||||
self.event_queue = event_queue
|
||||
self.shutdown_event = asyncio.Event()
|
||||
# Deduplication cache for webhook retry callbacks.
|
||||
self._seen_event_ids: dict[str, float] = {}
|
||||
self._dedup_ttl: int = 60 # seconds
|
||||
|
||||
async def initialize(self) -> None:
|
||||
logger.info("正在登录到 QQ 官方机器人...")
|
||||
@@ -106,6 +110,22 @@ class QQOfficialWebhook:
|
||||
print(signed)
|
||||
return signed
|
||||
|
||||
event_id = msg.get("id")
|
||||
if event_id:
|
||||
now = time.monotonic()
|
||||
# Lazily evict expired entries to prevent unbounded growth.
|
||||
expired = [
|
||||
k
|
||||
for k, ts in self._seen_event_ids.items()
|
||||
if now - ts > self._dedup_ttl
|
||||
]
|
||||
for k in expired:
|
||||
del self._seen_event_ids[k]
|
||||
if event_id in self._seen_event_ids:
|
||||
logger.debug(f"Duplicate webhook event {event_id!r}, skipping.")
|
||||
return {"opcode": 12}
|
||||
self._seen_event_ids[event_id] = now
|
||||
|
||||
if event and opcode == BotWebSocket.WS_DISPATCH_EVENT:
|
||||
event = msg["t"].lower()
|
||||
try:
|
||||
|
||||
@@ -289,8 +289,8 @@ class TelegramPlatformAdapter(Platform):
|
||||
else:
|
||||
message.type = MessageType.GROUP_MESSAGE
|
||||
message.group_id = str(update.message.chat.id)
|
||||
if update.message.message_thread_id:
|
||||
# Topic Group
|
||||
if update.message.is_topic_message and update.message.message_thread_id:
|
||||
# Telegram Topic Group: include thread id to isolate per-topic sessions.
|
||||
message.group_id += "#" + str(update.message.message_thread_id)
|
||||
message.session_id = message.group_id
|
||||
message.message_id = str(update.message.message_id)
|
||||
|
||||
@@ -25,6 +25,16 @@ from astrbot.api.platform import AstrBotMessage, MessageType, PlatformMetadata
|
||||
from astrbot.core.utils.metrics import Metric
|
||||
|
||||
|
||||
def _is_gif(path: str) -> bool:
|
||||
if path.lower().endswith(".gif"):
|
||||
return True
|
||||
try:
|
||||
with open(path, "rb") as f:
|
||||
return f.read(6) in (b"GIF87a", b"GIF89a")
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
|
||||
class TelegramPlatformEvent(AstrMessageEvent):
|
||||
# Telegram 的最大消息长度限制
|
||||
MAX_MESSAGE_LENGTH = 4096
|
||||
@@ -278,7 +288,6 @@ class TelegramPlatformEvent(AstrMessageEvent):
|
||||
try:
|
||||
md_text = telegramify_markdown.markdownify(
|
||||
chunk,
|
||||
normalize_whitespace=False,
|
||||
)
|
||||
await client.send_message(
|
||||
text=md_text,
|
||||
@@ -292,7 +301,13 @@ class TelegramPlatformEvent(AstrMessageEvent):
|
||||
await client.send_message(text=chunk, **cast(Any, payload))
|
||||
elif isinstance(i, Image):
|
||||
image_path = await i.convert_to_file_path()
|
||||
await client.send_photo(photo=image_path, **cast(Any, payload))
|
||||
if _is_gif(image_path):
|
||||
send_coro = client.send_animation
|
||||
media_kwarg = {"animation": image_path}
|
||||
else:
|
||||
send_coro = client.send_photo
|
||||
media_kwarg = {"photo": image_path}
|
||||
await send_coro(**media_kwarg, **cast(Any, payload))
|
||||
elif isinstance(i, File):
|
||||
path = await i.get_file()
|
||||
name = i.name or os.path.basename(path)
|
||||
@@ -407,12 +422,20 @@ class TelegramPlatformEvent(AstrMessageEvent):
|
||||
on_text(i.text)
|
||||
elif isinstance(i, Image):
|
||||
image_path = await i.convert_to_file_path()
|
||||
if _is_gif(image_path):
|
||||
action = ChatAction.UPLOAD_VIDEO
|
||||
send_coro = self.client.send_animation
|
||||
media_kwarg = {"animation": image_path}
|
||||
else:
|
||||
action = ChatAction.UPLOAD_PHOTO
|
||||
send_coro = self.client.send_photo
|
||||
media_kwarg = {"photo": image_path}
|
||||
await self._send_media_with_action(
|
||||
self.client,
|
||||
ChatAction.UPLOAD_PHOTO,
|
||||
self.client.send_photo,
|
||||
action,
|
||||
send_coro,
|
||||
user_name=user_name,
|
||||
photo=image_path,
|
||||
**media_kwarg,
|
||||
**cast(Any, payload),
|
||||
)
|
||||
elif isinstance(i, File):
|
||||
@@ -456,7 +479,6 @@ class TelegramPlatformEvent(AstrMessageEvent):
|
||||
try:
|
||||
markdown_text = telegramify_markdown.markdownify(
|
||||
delta,
|
||||
normalize_whitespace=False,
|
||||
)
|
||||
await self.client.send_message(
|
||||
text=markdown_text,
|
||||
@@ -537,7 +559,6 @@ class TelegramPlatformEvent(AstrMessageEvent):
|
||||
try:
|
||||
md = telegramify_markdown.markdownify(
|
||||
draft_text,
|
||||
normalize_whitespace=False,
|
||||
)
|
||||
await self._send_message_draft(
|
||||
user_name,
|
||||
@@ -695,7 +716,6 @@ class TelegramPlatformEvent(AstrMessageEvent):
|
||||
try:
|
||||
markdown_text = telegramify_markdown.markdownify(
|
||||
delta,
|
||||
normalize_whitespace=False,
|
||||
)
|
||||
await self.client.edit_message_text(
|
||||
text=markdown_text,
|
||||
|
||||
@@ -440,9 +440,16 @@ class WecomAIBotAdapter(Platform):
|
||||
)
|
||||
|
||||
def _extract_session_id(self, message_data: dict[str, Any]) -> str:
|
||||
"""从消息数据中提取会话ID"""
|
||||
user_id = message_data.get("from", {}).get("userid", "default_user")
|
||||
return format_session_id("wecomai", user_id)
|
||||
"""从消息数据中提取会话ID
|
||||
群聊使用 chatid,单聊使用 userid
|
||||
"""
|
||||
chattype = message_data.get("chattype", "single")
|
||||
if chattype == "group":
|
||||
chat_id = message_data.get("chatid", "default_group")
|
||||
return format_session_id("wecomai", chat_id)
|
||||
else:
|
||||
user_id = message_data.get("from", {}).get("userid", "default_user")
|
||||
return format_session_id("wecomai", user_id)
|
||||
|
||||
async def _enqueue_message(
|
||||
self,
|
||||
|
||||
@@ -808,6 +808,8 @@ class ProviderManager:
|
||||
config.save_config()
|
||||
# load instance
|
||||
await self.load_provider(new_config)
|
||||
# sync in-memory config for API queries (e.g., embedding provider list)
|
||||
self.providers_config = astrbot_config["provider"]
|
||||
|
||||
async def terminate(self) -> None:
|
||||
if self._mcp_init_task and not self._mcp_init_task.done():
|
||||
|
||||
@@ -13,3 +13,11 @@ class ProviderGroq(ProviderOpenAIOfficial):
|
||||
) -> None:
|
||||
super().__init__(provider_config, provider_settings)
|
||||
self.reasoning_key = "reasoning"
|
||||
|
||||
def _finally_convert_payload(self, payloads: dict) -> None:
|
||||
"""Groq rejects assistant history items that include reasoning_content."""
|
||||
super()._finally_convert_payload(payloads)
|
||||
for message in payloads.get("messages", []):
|
||||
if message.get("role") == "assistant":
|
||||
message.pop("reasoning_content", None)
|
||||
message.pop("reasoning", None)
|
||||
|
||||
@@ -40,25 +40,46 @@ class OpenAIEmbeddingProvider(EmbeddingProvider):
|
||||
|
||||
async def get_embedding(self, text: str) -> list[float]:
|
||||
"""获取文本的嵌入"""
|
||||
kwargs = self._embedding_kwargs()
|
||||
embedding = await self.client.embeddings.create(
|
||||
input=text,
|
||||
model=self.model,
|
||||
dimensions=self.get_dim(),
|
||||
**kwargs,
|
||||
)
|
||||
return embedding.data[0].embedding
|
||||
|
||||
async def get_embeddings(self, text: list[str]) -> list[list[float]]:
|
||||
"""批量获取文本的嵌入"""
|
||||
kwargs = self._embedding_kwargs()
|
||||
embeddings = await self.client.embeddings.create(
|
||||
input=text,
|
||||
model=self.model,
|
||||
dimensions=self.get_dim(),
|
||||
**kwargs,
|
||||
)
|
||||
return [item.embedding for item in embeddings.data]
|
||||
|
||||
def _embedding_kwargs(self) -> dict:
|
||||
"""构建嵌入请求的可选参数"""
|
||||
kwargs = {}
|
||||
if "embedding_dimensions" in self.provider_config:
|
||||
try:
|
||||
kwargs["dimensions"] = int(self.provider_config["embedding_dimensions"])
|
||||
except (ValueError, TypeError):
|
||||
logger.warning(
|
||||
f"embedding_dimensions in embedding configs is not a valid integer: '{self.provider_config['embedding_dimensions']}', ignored."
|
||||
)
|
||||
return kwargs
|
||||
|
||||
def get_dim(self) -> int:
|
||||
"""获取向量的维度"""
|
||||
return int(self.provider_config.get("embedding_dimensions", 1024))
|
||||
if "embedding_dimensions" in self.provider_config:
|
||||
try:
|
||||
return int(self.provider_config["embedding_dimensions"])
|
||||
except (ValueError, TypeError):
|
||||
logger.warning(
|
||||
f"embedding_dimensions in embedding configs is not a valid integer: '{self.provider_config['embedding_dimensions']}', ignored."
|
||||
)
|
||||
return 0
|
||||
|
||||
async def terminate(self):
|
||||
if self.client:
|
||||
|
||||
@@ -311,7 +311,7 @@ class ProviderOpenAIOfficial(Provider):
|
||||
state.handle_chunk(chunk)
|
||||
except Exception as e:
|
||||
logger.warning("Saving chunk state error: " + str(e))
|
||||
if len(chunk.choices) == 0:
|
||||
if not chunk.choices:
|
||||
continue
|
||||
delta = chunk.choices[0].delta
|
||||
# logger.debug(f"chunk delta: {delta}")
|
||||
@@ -322,7 +322,7 @@ class ProviderOpenAIOfficial(Provider):
|
||||
if reasoning:
|
||||
llm_response.reasoning_content = reasoning
|
||||
_y = True
|
||||
if delta.content:
|
||||
if delta and delta.content:
|
||||
# Don't strip streaming chunks to preserve spaces between words
|
||||
completion_text = self._normalize_content(delta.content, strip=False)
|
||||
llm_response.result_chain = MessageChain(
|
||||
@@ -345,7 +345,7 @@ class ProviderOpenAIOfficial(Provider):
|
||||
) -> str:
|
||||
"""Extract reasoning content from OpenAI ChatCompletion if available."""
|
||||
reasoning_text = ""
|
||||
if len(completion.choices) == 0:
|
||||
if not completion.choices:
|
||||
return reasoning_text
|
||||
if isinstance(completion, ChatCompletion):
|
||||
choice = completion.choices[0]
|
||||
@@ -468,7 +468,7 @@ class ProviderOpenAIOfficial(Provider):
|
||||
"""Parse OpenAI ChatCompletion into LLMResponse"""
|
||||
llm_response = LLMResponse("assistant")
|
||||
|
||||
if len(completion.choices) == 0:
|
||||
if not completion.choices:
|
||||
raise Exception("API 返回的 completion 为空。")
|
||||
choice = completion.choices[0]
|
||||
|
||||
@@ -629,7 +629,8 @@ class ProviderOpenAIOfficial(Provider):
|
||||
# 最后一次不等待
|
||||
if retry_cnt < max_retries - 1:
|
||||
await asyncio.sleep(1)
|
||||
available_api_keys.remove(chosen_key)
|
||||
if chosen_key in available_api_keys:
|
||||
available_api_keys.remove(chosen_key)
|
||||
if len(available_api_keys) > 0:
|
||||
chosen_key = random.choice(available_api_keys)
|
||||
return (
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -29,12 +31,12 @@ from astrbot.core.utils.astrbot_path import (
|
||||
get_astrbot_config_path,
|
||||
get_astrbot_path,
|
||||
get_astrbot_plugin_path,
|
||||
get_astrbot_temp_path,
|
||||
)
|
||||
from astrbot.core.utils.io import remove_dir
|
||||
from astrbot.core.utils.metrics import Metric
|
||||
from astrbot.core.utils.requirements_utils import (
|
||||
RequirementsPrecheckFailed,
|
||||
find_missing_requirements_or_raise,
|
||||
plan_missing_requirements_install,
|
||||
)
|
||||
|
||||
from . import StarMetadata
|
||||
@@ -74,30 +76,78 @@ class PluginDependencyInstallError(Exception):
|
||||
self.error = error
|
||||
|
||||
|
||||
@contextlib.contextmanager
|
||||
def _temporary_filtered_requirements_file(
|
||||
*,
|
||||
install_lines: tuple[str, ...],
|
||||
):
|
||||
filtered_requirements_path: str | None = None
|
||||
temp_dir = get_astrbot_temp_path()
|
||||
|
||||
try:
|
||||
os.makedirs(temp_dir, exist_ok=True)
|
||||
with tempfile.NamedTemporaryFile(
|
||||
mode="w",
|
||||
suffix="_plugin_requirements.txt",
|
||||
delete=False,
|
||||
dir=temp_dir,
|
||||
encoding="utf-8",
|
||||
) as filtered_requirements_file:
|
||||
filtered_requirements_file.write("\n".join(install_lines) + "\n")
|
||||
filtered_requirements_path = filtered_requirements_file.name
|
||||
|
||||
yield filtered_requirements_path
|
||||
finally:
|
||||
if filtered_requirements_path and os.path.exists(filtered_requirements_path):
|
||||
try:
|
||||
os.remove(filtered_requirements_path)
|
||||
except OSError as exc:
|
||||
logger.warning(
|
||||
"删除临时插件依赖文件失败:%s(路径:%s)",
|
||||
exc,
|
||||
filtered_requirements_path,
|
||||
)
|
||||
|
||||
|
||||
async def _install_requirements_with_precheck(
|
||||
*,
|
||||
plugin_label: str,
|
||||
requirements_path: str,
|
||||
) -> None:
|
||||
try:
|
||||
missing = find_missing_requirements_or_raise(requirements_path)
|
||||
except RequirementsPrecheckFailed:
|
||||
install_plan = plan_missing_requirements_install(requirements_path)
|
||||
|
||||
if install_plan is None:
|
||||
logger.info(
|
||||
f"正在安装插件 {plugin_label} 的依赖库(预检查失败,回退到完整安装): "
|
||||
f"正在安装插件 {plugin_label} 的依赖库(缺失依赖预检查不可裁剪,回退到完整安装): "
|
||||
f"{requirements_path}"
|
||||
)
|
||||
await pip_installer.install(requirements_path=requirements_path)
|
||||
return
|
||||
|
||||
if not missing:
|
||||
if not install_plan.missing_names:
|
||||
logger.info(f"插件 {plugin_label} 的依赖已满足,跳过安装。")
|
||||
return
|
||||
|
||||
if not install_plan.install_lines:
|
||||
fallback_reason = install_plan.fallback_reason or "unknown reason"
|
||||
logger.info(
|
||||
"检测到插件 %s 缺失依赖,但无法安全裁剪 requirements,回退到完整安装: %s (%s)",
|
||||
plugin_label,
|
||||
requirements_path,
|
||||
fallback_reason,
|
||||
)
|
||||
await pip_installer.install(requirements_path=requirements_path)
|
||||
return
|
||||
|
||||
logger.info(
|
||||
f"检测到插件 {plugin_label} 缺失依赖,正在按 requirements.txt 安装: "
|
||||
f"{requirements_path} -> {sorted(missing)}"
|
||||
f"{requirements_path} -> {sorted(install_plan.missing_names)}"
|
||||
)
|
||||
await pip_installer.install(requirements_path=requirements_path)
|
||||
|
||||
with _temporary_filtered_requirements_file(
|
||||
install_lines=install_plan.install_lines,
|
||||
) as filtered_requirements_path:
|
||||
await pip_installer.install(requirements_path=filtered_requirements_path)
|
||||
|
||||
|
||||
class PluginManager:
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -25,12 +25,22 @@ class UmopConfigRouter:
|
||||
)
|
||||
self.umop_to_conf_id = sp_data
|
||||
|
||||
@staticmethod
|
||||
def _split_umo(umo: str) -> tuple[str, str, str] | None:
|
||||
"""将 UMO 拆分为 3 个部分,同时保留 session_id 中的 ':'"""
|
||||
if not isinstance(umo, str):
|
||||
return None
|
||||
parts = umo.split(":", 2)
|
||||
if len(parts) != 3:
|
||||
return None
|
||||
return parts[0], parts[1], parts[2]
|
||||
|
||||
def _is_umo_match(self, p1: str, p2: str) -> bool:
|
||||
"""判断 p2 umo 是否逻辑包含于 p1 umo"""
|
||||
p1_ls = p1.split(":")
|
||||
p2_ls = p2.split(":")
|
||||
p1_ls = self._split_umo(p1)
|
||||
p2_ls = self._split_umo(p2)
|
||||
|
||||
if len(p1_ls) != 3 or len(p2_ls) != 3:
|
||||
if p1_ls is None or p2_ls is None:
|
||||
return False # 非法格式
|
||||
|
||||
return all(p == "" or fnmatch.fnmatchcase(t, p) for p, t in zip(p1_ls, p2_ls))
|
||||
@@ -62,7 +72,7 @@ class UmopConfigRouter:
|
||||
|
||||
"""
|
||||
for part in new_routing:
|
||||
if not isinstance(part, str) or len(part.split(":")) != 3:
|
||||
if self._split_umo(part) is None:
|
||||
raise ValueError(
|
||||
"umop keys must be strings in the format [platform_id]:[message_type]:[session_id], with optional wildcards * or empty for all",
|
||||
)
|
||||
@@ -81,7 +91,7 @@ class UmopConfigRouter:
|
||||
ValueError: 如果 umo 格式不正确
|
||||
|
||||
"""
|
||||
if not isinstance(umo, str) or len(umo.split(":")) != 3:
|
||||
if self._split_umo(umo) is None:
|
||||
raise ValueError(
|
||||
"umop must be a string in the format [platform_id]:[message_type]:[session_id], with optional wildcards * or empty for all",
|
||||
)
|
||||
@@ -99,7 +109,7 @@ class UmopConfigRouter:
|
||||
ValueError: 当 umo 格式不正确时抛出
|
||||
"""
|
||||
|
||||
if not isinstance(umo, str) or len(umo.split(":")) != 3:
|
||||
if self._split_umo(umo) is None:
|
||||
raise ValueError(
|
||||
"umop must be a string in the format [platform_id]:[message_type]:[session_id], with optional wildcards * or empty for all",
|
||||
)
|
||||
|
||||
@@ -4,7 +4,7 @@ import os
|
||||
import re
|
||||
import shlex
|
||||
import sys
|
||||
from collections.abc import Iterable, Iterator
|
||||
from collections.abc import Iterable, Iterator, Sequence
|
||||
from dataclasses import dataclass
|
||||
|
||||
from packaging.requirements import InvalidRequirement, Requirement
|
||||
@@ -29,6 +29,13 @@ class ParsedPackageInput:
|
||||
requirement_names: frozenset[str]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class MissingRequirementsPlan:
|
||||
missing_names: frozenset[str]
|
||||
install_lines: tuple[str, ...]
|
||||
fallback_reason: str | None = None
|
||||
|
||||
|
||||
def canonicalize_distribution_name(name: str) -> str:
|
||||
return re.sub(r"[-_.]+", "-", name).strip("-").lower()
|
||||
|
||||
@@ -364,8 +371,8 @@ def _load_requirement_lines_for_precheck(
|
||||
None,
|
||||
)
|
||||
if fallback_line is not None:
|
||||
logger.warning(
|
||||
"预检查缺失依赖失败,将回退到完整安装: unresolved direct reference in %s: %s",
|
||||
logger.info(
|
||||
"缺失依赖预检查发现无法安全裁剪的 option/direct-reference 行,将回退到完整安装: %s (%s)",
|
||||
requirements_path,
|
||||
fallback_line,
|
||||
)
|
||||
@@ -381,6 +388,13 @@ def find_missing_requirements(requirements_path: str) -> set[str] | None:
|
||||
if not can_precheck or requirement_lines is None:
|
||||
return None
|
||||
|
||||
return find_missing_requirements_from_lines(requirement_lines)
|
||||
|
||||
|
||||
def find_missing_requirements_from_lines(
|
||||
requirement_lines: Sequence[str],
|
||||
) -> set[str] | None:
|
||||
|
||||
required = list(iter_requirements(lines=requirement_lines))
|
||||
if not required:
|
||||
return set()
|
||||
@@ -401,6 +415,70 @@ def find_missing_requirements(requirements_path: str) -> set[str] | None:
|
||||
return missing
|
||||
|
||||
|
||||
def build_missing_requirements_install_lines(
|
||||
requirements_path: str,
|
||||
requirement_lines: Sequence[str],
|
||||
missing_names: set[str] | frozenset[str],
|
||||
) -> tuple[str, ...] | None:
|
||||
wanted_names = set(missing_names)
|
||||
install_lines: list[str] = []
|
||||
for line in requirement_lines:
|
||||
parsed = _parse_requirement_line(line)
|
||||
if parsed is None:
|
||||
if looks_like_direct_reference(line) or line.startswith(("-", "--")):
|
||||
logger.debug(
|
||||
"缺失依赖行筛选回退到完整安装:requirements 中包含无法安全裁剪的 option/direct-reference 行: %s (%s)",
|
||||
requirements_path,
|
||||
line,
|
||||
)
|
||||
return None
|
||||
continue
|
||||
|
||||
name, _specifier = parsed
|
||||
if name in wanted_names:
|
||||
install_lines.append(line)
|
||||
|
||||
return tuple(install_lines)
|
||||
|
||||
|
||||
def plan_missing_requirements_install(
|
||||
requirements_path: str,
|
||||
) -> MissingRequirementsPlan | None:
|
||||
can_precheck, requirement_lines = _load_requirement_lines_for_precheck(
|
||||
requirements_path
|
||||
)
|
||||
if not can_precheck or requirement_lines is None:
|
||||
return None
|
||||
|
||||
missing = find_missing_requirements_from_lines(requirement_lines)
|
||||
if missing is None:
|
||||
return None
|
||||
|
||||
install_lines = build_missing_requirements_install_lines(
|
||||
requirements_path,
|
||||
requirement_lines,
|
||||
missing,
|
||||
)
|
||||
if install_lines is None:
|
||||
return None
|
||||
if missing and not install_lines:
|
||||
logger.warning(
|
||||
"预检查缺失依赖成功,但无法映射到可安装 requirement 行,将回退到完整安装: %s -> %s",
|
||||
requirements_path,
|
||||
sorted(missing),
|
||||
)
|
||||
return MissingRequirementsPlan(
|
||||
missing_names=frozenset(missing),
|
||||
install_lines=(),
|
||||
fallback_reason="unmapped missing requirement names",
|
||||
)
|
||||
|
||||
return MissingRequirementsPlan(
|
||||
missing_names=frozenset(missing),
|
||||
install_lines=install_lines,
|
||||
)
|
||||
|
||||
|
||||
def find_missing_requirements_or_raise(requirements_path: str) -> set[str]:
|
||||
missing = find_missing_requirements(requirements_path)
|
||||
if missing is None:
|
||||
|
||||
@@ -82,7 +82,8 @@ class AuthRoute(Route):
|
||||
def generate_jwt(self, username):
|
||||
payload = {
|
||||
"username": username,
|
||||
"exp": datetime.datetime.utcnow() + datetime.timedelta(days=7),
|
||||
"exp": datetime.datetime.now(datetime.timezone.utc)
|
||||
+ datetime.timedelta(days=7),
|
||||
}
|
||||
jwt_token = self.config["dashboard"].get("jwt_secret", None)
|
||||
if not jwt_token:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -36,6 +36,20 @@ async def track_conversation(convs: dict, conv_id: str):
|
||||
convs.pop(conv_id, None)
|
||||
|
||||
|
||||
async def _poll_webchat_stream_result(back_queue, username: str):
|
||||
try:
|
||||
result = await asyncio.wait_for(back_queue.get(), timeout=1)
|
||||
except asyncio.TimeoutError:
|
||||
return None, False
|
||||
except asyncio.CancelledError:
|
||||
logger.debug(f"[WebChat] 用户 {username} 断开聊天长连接。")
|
||||
return None, True
|
||||
except Exception as e:
|
||||
logger.error(f"WebChat stream error: {e}")
|
||||
return None, False
|
||||
return result, False
|
||||
|
||||
|
||||
class ChatRoute(Route):
|
||||
def __init__(
|
||||
self,
|
||||
@@ -51,6 +65,7 @@ class ChatRoute(Route):
|
||||
"/chat/get_session": ("GET", self.get_session),
|
||||
"/chat/stop": ("POST", self.stop_session),
|
||||
"/chat/delete_session": ("GET", self.delete_webchat_session),
|
||||
"/chat/batch_delete_sessions": ("POST", self.batch_delete_sessions),
|
||||
"/chat/update_session_display_name": (
|
||||
"POST",
|
||||
self.update_session_display_name,
|
||||
@@ -342,16 +357,12 @@ class ChatRoute(Route):
|
||||
|
||||
async with track_conversation(self.running_convs, webchat_conv_id):
|
||||
while True:
|
||||
try:
|
||||
result = await asyncio.wait_for(back_queue.get(), timeout=1)
|
||||
except asyncio.TimeoutError:
|
||||
continue
|
||||
except asyncio.CancelledError:
|
||||
logger.debug(f"[WebChat] 用户 {username} 断开聊天长连接。")
|
||||
result, should_break = await _poll_webchat_stream_result(
|
||||
back_queue, username
|
||||
)
|
||||
if should_break:
|
||||
client_disconnected = True
|
||||
except Exception as e:
|
||||
logger.error(f"WebChat stream error: {e}")
|
||||
|
||||
break
|
||||
if not result:
|
||||
continue
|
||||
|
||||
@@ -578,19 +589,9 @@ class ChatRoute(Route):
|
||||
|
||||
return Response().ok(data={"stopped_count": stopped_count}).__dict__
|
||||
|
||||
async def delete_webchat_session(self):
|
||||
"""Delete a Platform session and all its related data."""
|
||||
session_id = request.args.get("session_id")
|
||||
if not session_id:
|
||||
return Response().error("Missing key: session_id").__dict__
|
||||
username = g.get("username", "guest")
|
||||
|
||||
# 验证会话是否存在且属于当前用户
|
||||
session = await self.db.get_platform_session_by_id(session_id)
|
||||
if not session:
|
||||
return Response().error(f"Session {session_id} not found").__dict__
|
||||
if session.creator != username:
|
||||
return Response().error("Permission denied").__dict__
|
||||
async def _delete_session_internal(self, session, username: str) -> None:
|
||||
"""Delete a single session and all its related data."""
|
||||
session_id = session.session_id
|
||||
|
||||
# 删除该会话下的所有对话
|
||||
message_type = "GroupMessage" if session.is_group else "FriendMessage"
|
||||
@@ -632,8 +633,70 @@ class ChatRoute(Route):
|
||||
# 删除会话
|
||||
await self.db.delete_platform_session(session_id)
|
||||
|
||||
async def delete_webchat_session(self):
|
||||
"""Delete a Platform session and all its related data."""
|
||||
session_id = request.args.get("session_id")
|
||||
if not session_id:
|
||||
return Response().error("Missing key: session_id").__dict__
|
||||
username = g.get("username", "guest")
|
||||
|
||||
session = await self.db.get_platform_session_by_id(session_id)
|
||||
if not session:
|
||||
return Response().error(f"Session {session_id} not found").__dict__
|
||||
if session.creator != username:
|
||||
return Response().error("Permission denied").__dict__
|
||||
|
||||
await self._delete_session_internal(session, username)
|
||||
|
||||
return Response().ok().__dict__
|
||||
|
||||
async def batch_delete_sessions(self):
|
||||
"""Batch delete multiple Platform sessions."""
|
||||
post_data = await request.json
|
||||
if post_data is None:
|
||||
return Response().error("Missing JSON body").__dict__
|
||||
if not isinstance(post_data, dict):
|
||||
return Response().error("Invalid JSON body: expected object").__dict__
|
||||
|
||||
session_ids = post_data.get("session_ids")
|
||||
if not session_ids or not isinstance(session_ids, list):
|
||||
return Response().error("Missing or invalid key: session_ids").__dict__
|
||||
|
||||
username = g.get("username", "guest")
|
||||
sessions = await self.db.get_platform_sessions_by_ids(session_ids)
|
||||
sessions_by_id = {session.session_id: session for session in sessions}
|
||||
deleted_count = 0
|
||||
failed_items = []
|
||||
|
||||
for sid in session_ids:
|
||||
session = sessions_by_id.get(sid)
|
||||
if not session:
|
||||
failed_items.append({"session_id": sid, "reason": "not found"})
|
||||
continue
|
||||
if session.creator != username:
|
||||
failed_items.append({"session_id": sid, "reason": "permission denied"})
|
||||
continue
|
||||
|
||||
try:
|
||||
await self._delete_session_internal(session, username)
|
||||
deleted_count += 1
|
||||
sessions_by_id.pop(sid, None)
|
||||
except Exception:
|
||||
logger.warning("Failed to delete session %s", sid)
|
||||
failed_items.append({"session_id": sid, "reason": "internal_error"})
|
||||
|
||||
return (
|
||||
Response()
|
||||
.ok(
|
||||
data={
|
||||
"deleted_count": deleted_count,
|
||||
"failed_count": len(failed_items),
|
||||
"failed_items": failed_items,
|
||||
}
|
||||
)
|
||||
.__dict__
|
||||
)
|
||||
|
||||
def _extract_attachment_ids(self, history_list) -> list[str]:
|
||||
"""从消息历史中提取所有 attachment_id"""
|
||||
attachment_ids = []
|
||||
|
||||
@@ -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.
|
||||
@@ -37,6 +37,7 @@ services:
|
||||
- DEFAULT_SHIP_MEMORY=512m
|
||||
volumes:
|
||||
- ${PWD}/data/shipyard/bay_data:/app/data
|
||||
- ${PWD}/data/temp:/AstrBot/data/temp # Bind the local temp directory to the sandbox so that the uploaded file can be accessed in the sandbox
|
||||
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||
networks:
|
||||
- astrbot_network
|
||||
|
||||
+13
-8
@@ -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",
|
||||
@@ -36,9 +36,8 @@
|
||||
"remixicon": "3.5.0",
|
||||
"shiki": "^3.20.0",
|
||||
"stream-markdown": "^0.0.13",
|
||||
"stream-monaco": "^0.0.17",
|
||||
"vee-validate": "4.11.3",
|
||||
"vite-plugin-vuetify": "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 +53,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 +63,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
+601
-271
File diff suppressed because it is too large
Load Diff
@@ -11,6 +11,7 @@
|
||||
:currSessionId="currSessionId"
|
||||
:selectedProjectId="selectedProjectId"
|
||||
:transportMode="transportMode"
|
||||
:sendShortcut="sendShortcut"
|
||||
:isDark="isDark"
|
||||
:chatboxMode="chatboxMode"
|
||||
:isMobile="isMobile"
|
||||
@@ -20,6 +21,7 @@
|
||||
@selectConversation="handleSelectConversation"
|
||||
@editTitle="showEditTitleDialog"
|
||||
@deleteConversation="handleDeleteConversation"
|
||||
@batchDeleteConversations="handleBatchDeleteConversations"
|
||||
@closeMobileSidebar="closeMobileSidebar"
|
||||
@toggleTheme="toggleTheme"
|
||||
@toggleFullscreen="toggleFullscreen"
|
||||
@@ -28,6 +30,7 @@
|
||||
@editProject="showEditProjectDialog"
|
||||
@deleteProject="handleDeleteProject"
|
||||
@updateTransportMode="setTransportMode"
|
||||
@updateSendShortcut="setSendShortcut"
|
||||
/>
|
||||
|
||||
<!-- 右侧聊天内容区域 -->
|
||||
@@ -71,13 +74,14 @@
|
||||
:stagedImagesUrl="stagedImagesUrl"
|
||||
:stagedAudioUrl="stagedAudioUrl"
|
||||
:stagedFiles="stagedNonImageFiles"
|
||||
:disabled="isStreaming"
|
||||
:disabled="false"
|
||||
:is-running="isStreaming || isConvRunning"
|
||||
:enableStreaming="enableStreaming"
|
||||
:isRecording="isRecording"
|
||||
:session-id="currSessionId || null"
|
||||
:current-session="getCurrentSession"
|
||||
:replyTo="replyTo"
|
||||
:send-shortcut="sendShortcut"
|
||||
@send="handleSendMessage"
|
||||
@stop="handleStopMessage"
|
||||
@toggleStreaming="toggleStreaming"
|
||||
@@ -102,13 +106,14 @@
|
||||
:stagedImagesUrl="stagedImagesUrl"
|
||||
:stagedAudioUrl="stagedAudioUrl"
|
||||
:stagedFiles="stagedNonImageFiles"
|
||||
:disabled="isStreaming"
|
||||
:disabled="false"
|
||||
:is-running="isStreaming || isConvRunning"
|
||||
:enableStreaming="enableStreaming"
|
||||
:isRecording="isRecording"
|
||||
:session-id="currSessionId || null"
|
||||
:current-session="getCurrentSession"
|
||||
:replyTo="replyTo"
|
||||
:send-shortcut="sendShortcut"
|
||||
@send="handleSendMessage"
|
||||
@stop="handleStopMessage"
|
||||
@toggleStreaming="toggleStreaming"
|
||||
@@ -132,13 +137,14 @@
|
||||
:stagedImagesUrl="stagedImagesUrl"
|
||||
:stagedAudioUrl="stagedAudioUrl"
|
||||
:stagedFiles="stagedNonImageFiles"
|
||||
:disabled="isStreaming"
|
||||
:disabled="false"
|
||||
:is-running="isStreaming || isConvRunning"
|
||||
:enableStreaming="enableStreaming"
|
||||
:isRecording="isRecording"
|
||||
:session-id="currSessionId || null"
|
||||
:current-session="getCurrentSession"
|
||||
:replyTo="replyTo"
|
||||
:send-shortcut="sendShortcut"
|
||||
@send="handleSendMessage"
|
||||
@stop="handleStopMessage"
|
||||
@toggleStreaming="toggleStreaming"
|
||||
@@ -220,10 +226,13 @@ import { useMediaHandling } from '@/composables/useMediaHandling';
|
||||
import { useProjects } from '@/composables/useProjects';
|
||||
import type { Project } from '@/components/chat/ProjectList.vue';
|
||||
import { useRecording } from '@/composables/useRecording';
|
||||
import { useToast } from '@/utils/toast';
|
||||
|
||||
interface Props {
|
||||
chatboxMode?: boolean;
|
||||
}
|
||||
type SendShortcut = 'enter' | 'shift_enter';
|
||||
const SEND_SHORTCUT_STORAGE_KEY = 'chat_send_shortcut';
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
chatboxMode: false
|
||||
@@ -233,6 +242,7 @@ const router = useRouter();
|
||||
const route = useRoute();
|
||||
const { t } = useI18n();
|
||||
const { tm } = useModuleI18n('features/chat');
|
||||
const { warning: toastWarning } = useToast();
|
||||
const theme = useTheme();
|
||||
const customizer = useCustomizerStore();
|
||||
|
||||
@@ -257,6 +267,7 @@ const {
|
||||
getSessions,
|
||||
newSession,
|
||||
deleteSession: deleteSessionFn,
|
||||
batchDeleteSessions,
|
||||
showEditTitleDialog,
|
||||
saveTitle,
|
||||
updateSessionTitle,
|
||||
@@ -330,6 +341,18 @@ interface ReplyInfo {
|
||||
const replyTo = ref<ReplyInfo | null>(null);
|
||||
|
||||
const isDark = computed(() => useCustomizerStore().uiTheme === 'PurpleThemeDark');
|
||||
const sendShortcut = ref<SendShortcut>('shift_enter');
|
||||
|
||||
function setSendShortcut(mode: SendShortcut) {
|
||||
sendShortcut.value = mode;
|
||||
localStorage.setItem(SEND_SHORTCUT_STORAGE_KEY, mode);
|
||||
}
|
||||
|
||||
function focusChatInput() {
|
||||
nextTick(() => {
|
||||
chatInputRef.value?.focusInput?.();
|
||||
});
|
||||
}
|
||||
|
||||
// 检测是否为手机端
|
||||
function checkMobile() {
|
||||
@@ -488,6 +511,7 @@ async function handleSelectConversation(sessionIds: string[]) {
|
||||
nextTick(() => {
|
||||
messageList.value?.scrollToBottom();
|
||||
});
|
||||
focusChatInput();
|
||||
}
|
||||
|
||||
function handleNewChat() {
|
||||
@@ -497,6 +521,7 @@ function handleNewChat() {
|
||||
// 退出项目视图
|
||||
selectedProjectId.value = null;
|
||||
projectSessions.value = [];
|
||||
focusChatInput();
|
||||
}
|
||||
|
||||
async function handleDeleteConversation(sessionId: string) {
|
||||
@@ -510,6 +535,33 @@ async function handleDeleteConversation(sessionId: string) {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleBatchDeleteConversations(sessionIds: string[]) {
|
||||
try {
|
||||
const result = await batchDeleteSessions(sessionIds);
|
||||
|
||||
// 仅在当前会话成功删除时清除信息
|
||||
if (result.currentSessionDeleted) {
|
||||
messages.value = [];
|
||||
}
|
||||
|
||||
// 失败处理
|
||||
if (result.failed_count > 0) {
|
||||
toastWarning(
|
||||
tm('batch.partialFailure', { failed: result.failed_count, total: sessionIds.length })
|
||||
);
|
||||
}
|
||||
|
||||
// 如果在项目视图中,刷新项目会话列表
|
||||
if (selectedProjectId.value) {
|
||||
const sessions = await getProjectSessions(selectedProjectId.value);
|
||||
projectSessions.value = sessions;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Batch delete sessions failed:', err);
|
||||
toastWarning(tm('batch.requestFailed'));
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSelectProject(projectId: string) {
|
||||
selectedProjectId.value = projectId;
|
||||
const sessions = await getProjectSessions(projectId);
|
||||
@@ -627,6 +679,11 @@ async function handleSendMessage() {
|
||||
const selectedProviderId = selection?.providerId || '';
|
||||
const selectedModelName = selection?.modelName || '';
|
||||
|
||||
// 点击发送后立即将消息区滚到底部,确保用户看到最新消息
|
||||
nextTick(() => {
|
||||
messageList.value?.scrollToBottom();
|
||||
});
|
||||
|
||||
await sendMsg(
|
||||
promptToSend,
|
||||
filesToSend,
|
||||
@@ -636,6 +693,11 @@ async function handleSendMessage() {
|
||||
replyToSend
|
||||
);
|
||||
|
||||
// 发送流程结束后再兜底一次,处理异步渲染场景
|
||||
nextTick(() => {
|
||||
messageList.value?.scrollToBottom();
|
||||
});
|
||||
|
||||
// 如果在项目中创建了新会话,将其添加到项目
|
||||
if (isCreatingNewSession && currentProjectId && currSessionId.value) {
|
||||
await addSessionToProject(currSessionId.value, currentProjectId);
|
||||
@@ -694,6 +756,10 @@ watch(sessions, (newSessions) => {
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
const storedShortcut = localStorage.getItem(SEND_SHORTCUT_STORAGE_KEY);
|
||||
if (storedShortcut === 'enter' || storedShortcut === 'shift_enter') {
|
||||
sendShortcut.value = storedShortcut;
|
||||
}
|
||||
checkMobile();
|
||||
window.addEventListener('resize', checkMobile);
|
||||
getSessions();
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
<transition name="fade">
|
||||
<div v-if="isDragging" class="drop-overlay">
|
||||
<div class="drop-overlay-content">
|
||||
<v-icon size="48" color="deep-purple">mdi-cloud-upload</v-icon>
|
||||
<v-icon size="48" color="primary">mdi-cloud-upload</v-icon>
|
||||
<span class="drop-text">{{ tm('input.dropToUpload') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -41,7 +41,7 @@
|
||||
<!-- Settings Menu -->
|
||||
<StyledMenu offset="8" location="top start" :close-on-content-click="false">
|
||||
<template v-slot:activator="{ props: activatorProps }">
|
||||
<v-btn v-bind="activatorProps" icon="mdi-plus" variant="text" color="deep-purple" />
|
||||
<v-btn v-bind="activatorProps" icon="mdi-plus" variant="text" color="primary" />
|
||||
</template>
|
||||
|
||||
<!-- Upload Files -->
|
||||
@@ -87,7 +87,7 @@
|
||||
{{ tm('voice.liveMode') }}
|
||||
</v-tooltip>
|
||||
</v-btn> -->
|
||||
<v-btn @click="handleRecordClick" icon variant="text" :color="isRecording ? 'error' : 'deep-purple'"
|
||||
<v-btn @click="handleRecordClick" icon variant="text" :color="isRecording ? 'error' : 'primary'"
|
||||
class="record-btn">
|
||||
<v-icon :icon="isRecording ? 'mdi-stop-circle' : 'mdi-microphone'" variant="text"
|
||||
plain></v-icon>
|
||||
@@ -95,13 +95,13 @@
|
||||
{{ isRecording ? tm('voice.speaking') : tm('voice.startRecording') }}
|
||||
</v-tooltip>
|
||||
</v-btn>
|
||||
<v-btn icon v-if="isRunning" @click="$emit('stop')" variant="tonal" color="deep-purple" class="send-btn">
|
||||
<v-btn icon v-if="isRunning && !canSend" @click="$emit('stop')" variant="tonal" color="primary" class="send-btn">
|
||||
<v-icon icon="mdi-stop" variant="text" plain></v-icon>
|
||||
<v-tooltip activator="parent" location="top">
|
||||
{{ tm('input.stopGenerating') }}
|
||||
</v-tooltip>
|
||||
</v-btn>
|
||||
<v-btn v-else @click="$emit('send')" icon="mdi-send" variant="tonal" color="deep-purple"
|
||||
<v-btn v-else @click="$emit('send')" icon="mdi-send" variant="tonal" color="primary"
|
||||
:disabled="!canSend" class="send-btn" />
|
||||
</div>
|
||||
</div>
|
||||
@@ -117,7 +117,7 @@
|
||||
</div>
|
||||
|
||||
<div v-if="stagedAudioUrl" class="audio-preview">
|
||||
<v-chip color="deep-purple-lighten-4" class="audio-chip">
|
||||
<v-chip color="primary" variant="tonal" class="audio-chip">
|
||||
<v-icon start icon="mdi-microphone" size="small"></v-icon>
|
||||
{{ tm('voice.recording') }}
|
||||
</v-chip>
|
||||
@@ -126,7 +126,7 @@
|
||||
</div>
|
||||
|
||||
<div v-for="(file, index) in stagedFiles" :key="'file-' + index" class="file-preview">
|
||||
<v-chip color="blue-grey-lighten-4" class="file-chip">
|
||||
<v-chip color="primary" variant="tonal" class="file-chip">
|
||||
<v-icon start icon="mdi-file-document-outline" size="small"></v-icon>
|
||||
<span class="file-name-preview">{{ file.original_name }}</span>
|
||||
</v-chip>
|
||||
@@ -173,6 +173,7 @@ interface Props {
|
||||
currentSession?: Session | null;
|
||||
configId?: string | null;
|
||||
replyTo?: ReplyInfo | null;
|
||||
sendShortcut?: 'enter' | 'shift_enter';
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
@@ -180,7 +181,8 @@ const props = withDefaults(defineProps<Props>(), {
|
||||
currentSession: null,
|
||||
configId: null,
|
||||
stagedFiles: () => [],
|
||||
replyTo: null
|
||||
replyTo: null,
|
||||
sendShortcut: 'shift_enter'
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -253,9 +255,29 @@ watch(localPrompt, () => {
|
||||
});
|
||||
|
||||
function handleKeyDown(e: KeyboardEvent) {
|
||||
// Enter 插入换行(桌面和手机端均如此,发送通过右下角发送按鈕)
|
||||
// Shift+Enter 发送(Ctrl+Enter / Cmd+Enter 也保留)
|
||||
if (e.keyCode === 13 && (e.shiftKey || e.ctrlKey || e.metaKey)) {
|
||||
const isEnter = e.key === 'Enter';
|
||||
if (!isEnter) {
|
||||
// Ctrl+B 录音
|
||||
if (e.ctrlKey && e.keyCode === 66) {
|
||||
e.preventDefault();
|
||||
if (ctrlKeyDown.value) return;
|
||||
|
||||
ctrlKeyDown.value = true;
|
||||
ctrlKeyTimer.value = window.setTimeout(() => {
|
||||
if (ctrlKeyDown.value && !props.isRecording) {
|
||||
emit('startRecording');
|
||||
}
|
||||
}, ctrlKeyLongPressThreshold);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const isSendHotkey =
|
||||
e.ctrlKey ||
|
||||
e.metaKey ||
|
||||
(props.sendShortcut === 'enter' ? !e.shiftKey : e.shiftKey);
|
||||
|
||||
if (isSendHotkey) {
|
||||
e.preventDefault();
|
||||
if (localPrompt.value.trim() === '/astr_live_dev') {
|
||||
emit('openLiveMode');
|
||||
@@ -267,19 +289,6 @@ function handleKeyDown(e: KeyboardEvent) {
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Ctrl+B 录音
|
||||
if (e.ctrlKey && e.keyCode === 66) {
|
||||
e.preventDefault();
|
||||
if (ctrlKeyDown.value) return;
|
||||
|
||||
ctrlKeyDown.value = true;
|
||||
ctrlKeyTimer.value = window.setTimeout(() => {
|
||||
if (ctrlKeyDown.value && !props.isRecording) {
|
||||
emit('startRecording');
|
||||
}
|
||||
}, ctrlKeyLongPressThreshold);
|
||||
}
|
||||
}
|
||||
|
||||
function handleKeyUp(e: KeyboardEvent) {
|
||||
@@ -364,6 +373,11 @@ function getCurrentSelection() {
|
||||
return providerModelMenuRef.value?.getCurrentSelection();
|
||||
}
|
||||
|
||||
function focusInput() {
|
||||
if (!inputField.value) return;
|
||||
inputField.value.focus();
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (inputField.value) {
|
||||
inputField.value.addEventListener('paste', handlePaste);
|
||||
@@ -379,7 +393,8 @@ onBeforeUnmount(() => {
|
||||
});
|
||||
|
||||
defineExpose({
|
||||
getCurrentSelection
|
||||
getCurrentSelection,
|
||||
focusInput
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -399,8 +414,8 @@ defineExpose({
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-color: rgba(103, 58, 183, 0.15);
|
||||
border: 2px dashed rgba(103, 58, 183, 0.5);
|
||||
background-color: rgba(var(--v-theme-primary), 0.12);
|
||||
border: 2px dashed rgba(var(--v-theme-primary), 0.45);
|
||||
border-radius: 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -419,7 +434,7 @@ defineExpose({
|
||||
.drop-text {
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
color: #673ab7;
|
||||
color: rgb(var(--v-theme-primary));
|
||||
}
|
||||
|
||||
/* Fade transition for drop overlay */
|
||||
@@ -439,7 +454,7 @@ defineExpose({
|
||||
justify-content: space-between;
|
||||
padding: 8px 16px;
|
||||
margin: 8px 8px 0 8px;
|
||||
background-color: rgba(103, 58, 183, 0.06);
|
||||
background-color: rgba(var(--v-theme-primary), 0.06);
|
||||
border-radius: 12px;
|
||||
gap: 8px;
|
||||
max-height: 500px;
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
'mobile-sidebar-open': isMobile && mobileMenuOpen,
|
||||
'mobile-sidebar': isMobile
|
||||
}"
|
||||
:style="{ 'background-color': isDark ? sidebarCollapsed ? '#1e1e1e' : '#2d2d2d' : sidebarCollapsed ? '#ffffff' : '#f1f4f9' }">
|
||||
:style="{ backgroundColor: sidebarCollapsed && !isMobile ? 'rgb(var(--v-theme-surface))' : 'rgb(var(--v-theme-mcpCardBg))' }">
|
||||
|
||||
<div class="sidebar-collapse-btn-container" v-if="!isMobile">
|
||||
<v-btn icon class="sidebar-collapse-btn" @click="toggleSidebar" variant="text" color="deep-purple">
|
||||
@@ -21,12 +21,31 @@
|
||||
</div>
|
||||
|
||||
<div style="padding: 8px; opacity: 0.6;">
|
||||
<v-btn block variant="text" class="new-chat-btn" @click="$emit('newChat')" :disabled="!currSessionId && !selectedProjectId"
|
||||
v-if="!sidebarCollapsed || isMobile" prepend-icon="mdi-square-edit-outline">{{ tm('actions.newChat') }}</v-btn>
|
||||
<v-btn icon="mdi-square-edit-outline" rounded="xl" @click="$emit('newChat')" :disabled="!currSessionId && !selectedProjectId"
|
||||
<div class="new-chat-row" v-if="!sidebarCollapsed || isMobile">
|
||||
<v-btn block variant="text" class="new-chat-btn" @click="$emit('newChat')" :disabled="!currSessionId && !selectedProjectId"
|
||||
prepend-icon="mdi-square-edit-outline">{{ tm('actions.newChat') }}</v-btn>
|
||||
<v-btn v-if="sessions.length > 0" icon size="small" variant="text" @click="toggleBatchMode"
|
||||
:color="batchMode ? 'primary' : undefined">
|
||||
<v-icon>mdi-checkbox-multiple-marked-outline</v-icon>
|
||||
</v-btn>
|
||||
</div>
|
||||
<v-btn icon="mdi-square-edit-outline" rounded="xl" @click="$emit('newChat')" :disabled="!currSessionId && !selectedProjectId"
|
||||
v-if="sidebarCollapsed && !isMobile" elevation="0"></v-btn>
|
||||
</div>
|
||||
|
||||
<!-- Batch action bar -->
|
||||
<div v-if="batchMode && (!sidebarCollapsed || isMobile)" class="batch-action-bar">
|
||||
<v-btn size="x-small" variant="text" @click="toggleSelectAll">
|
||||
{{ isAllSelected ? tm('batch.deselectAll') : tm('batch.selectAll') }}
|
||||
</v-btn>
|
||||
<span class="batch-selected-count">{{ tm('batch.selected', { count: batchSelected.length }) }}</span>
|
||||
<v-spacer />
|
||||
<v-btn size="x-small" variant="text" color="error" :disabled="batchSelected.length === 0"
|
||||
@click="handleBatchDelete">
|
||||
{{ tm('batch.delete') }}
|
||||
</v-btn>
|
||||
</div>
|
||||
|
||||
<!-- 项目列表组件 -->
|
||||
<ProjectList
|
||||
v-if="!sidebarCollapsed || isMobile"
|
||||
@@ -41,19 +60,34 @@
|
||||
v-if="!sidebarCollapsed || isMobile">
|
||||
<v-card v-if="sessions.length > 0" flat style="background-color: transparent;">
|
||||
<v-list density="compact" nav class="conversation-list"
|
||||
style="background-color: transparent;" :selected="selectedSessions"
|
||||
@update:selected="$emit('selectConversation', $event)">
|
||||
style="background-color: transparent;" :selected="batchMode ? [] : selectedSessions"
|
||||
@update:selected="handleListSelect">
|
||||
<v-list-item v-for="item in sessions" :key="item.session_id" :value="item.session_id"
|
||||
rounded="lg" class="conversation-item" active-color="secondary">
|
||||
rounded="lg" class="conversation-item" active-color="secondary"
|
||||
@click="batchMode ? toggleBatchItem(item.session_id) : undefined">
|
||||
|
||||
<template v-slot:prepend>
|
||||
<div class="batch-checkbox-slot" :class="{ 'batch-checkbox-slot--active': batchMode }">
|
||||
<v-checkbox-btn
|
||||
:model-value="batchSelected.includes(item.session_id)"
|
||||
@update:model-value="toggleBatchItem(item.session_id)"
|
||||
@click.stop
|
||||
density="compact"
|
||||
hide-details
|
||||
class="batch-checkbox"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<v-list-item-title v-if="!sidebarCollapsed || isMobile" class="conversation-title"
|
||||
:style="{ color: isDark ? '#ffffff' : '#000000' }">
|
||||
:style="{ color: 'rgb(var(--v-theme-primaryText))' }">
|
||||
{{ item.display_name || tm('conversation.newConversation') }}
|
||||
</v-list-item-title>
|
||||
<!-- <v-list-item-subtitle v-if="!sidebarCollapsed || isMobile" class="timestamp">
|
||||
{{ new Date(item.updated_at).toLocaleString() }}
|
||||
</v-list-item-subtitle> -->
|
||||
|
||||
<template v-if="!sidebarCollapsed || isMobile" v-slot:append>
|
||||
<template v-if="!batchMode && (!sidebarCollapsed || isMobile)" v-slot:append>
|
||||
<div class="conversation-actions">
|
||||
<v-btn icon="mdi-pencil" size="x-small" variant="text"
|
||||
class="edit-title-btn"
|
||||
@@ -98,16 +132,52 @@
|
||||
</v-btn>
|
||||
</template>
|
||||
|
||||
<!-- 语言切换 -->
|
||||
<v-list-item class="styled-menu-item">
|
||||
<template v-slot:prepend>
|
||||
<v-icon>mdi-translate</v-icon>
|
||||
<!-- 语言切换(分组) -->
|
||||
<v-menu
|
||||
:open-on-hover="!isMobile"
|
||||
:open-on-click="isMobile"
|
||||
:open-delay="!isMobile ? 60 : 0"
|
||||
:close-delay="!isMobile ? 120 : 0"
|
||||
:location="isMobile ? 'bottom' : 'end center'"
|
||||
offset="8"
|
||||
close-on-content-click
|
||||
>
|
||||
<template v-slot:activator="{ props: languageMenuProps }">
|
||||
<v-list-item
|
||||
v-bind="languageMenuProps"
|
||||
class="styled-menu-item chat-settings-group-trigger"
|
||||
rounded="md"
|
||||
>
|
||||
<template v-slot:prepend>
|
||||
<v-icon>mdi-translate</v-icon>
|
||||
</template>
|
||||
<v-list-item-title>{{ t('core.common.language') }}</v-list-item-title>
|
||||
<template v-slot:append>
|
||||
<span class="chat-settings-group-current">{{ currentLanguage?.flag }}</span>
|
||||
<v-icon size="18" class="chat-settings-group-arrow">mdi-chevron-right</v-icon>
|
||||
</template>
|
||||
</v-list-item>
|
||||
</template>
|
||||
<v-list-item-title>{{ t('core.common.language') }}</v-list-item-title>
|
||||
<template v-slot:append>
|
||||
<LanguageSwitcher variant="chatbox" />
|
||||
</template>
|
||||
</v-list-item>
|
||||
|
||||
<v-card class="styled-menu-card" style="min-width: 180px;" elevation="8" rounded="lg">
|
||||
<v-list density="compact" class="styled-menu-list pa-1">
|
||||
<v-list-item
|
||||
v-for="lang in languages"
|
||||
:key="lang.code"
|
||||
:value="lang.code"
|
||||
@click="changeLanguage(lang.code)"
|
||||
:class="{ 'styled-menu-item-active': currentLocale === lang.code }"
|
||||
class="styled-menu-item"
|
||||
rounded="md"
|
||||
>
|
||||
<template v-slot:prepend>
|
||||
<span class="language-flag">{{ lang.flag }}</span>
|
||||
</template>
|
||||
<v-list-item-title>{{ lang.name }}</v-list-item-title>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
</v-card>
|
||||
</v-menu>
|
||||
|
||||
<!-- 主题切换 -->
|
||||
<v-list-item class="styled-menu-item" @click="$emit('toggleTheme')">
|
||||
@@ -117,26 +187,93 @@
|
||||
<v-list-item-title>{{ isDark ? tm('modes.lightMode') : tm('modes.darkMode') }}</v-list-item-title>
|
||||
</v-list-item>
|
||||
|
||||
<!-- 通信传输模式 -->
|
||||
<v-list-item class="styled-menu-item">
|
||||
<template v-slot:prepend>
|
||||
<v-icon>mdi-lan-connect</v-icon>
|
||||
<!-- 通信传输模式(分组) -->
|
||||
<v-menu
|
||||
:open-on-hover="!isMobile"
|
||||
:open-on-click="isMobile"
|
||||
:open-delay="!isMobile ? 60 : 0"
|
||||
:close-delay="!isMobile ? 120 : 0"
|
||||
:location="isMobile ? 'bottom' : 'end center'"
|
||||
offset="8"
|
||||
close-on-content-click
|
||||
>
|
||||
<template v-slot:activator="{ props: transportMenuProps }">
|
||||
<v-list-item
|
||||
v-bind="transportMenuProps"
|
||||
class="styled-menu-item chat-settings-group-trigger"
|
||||
rounded="md"
|
||||
>
|
||||
<template v-slot:prepend>
|
||||
<v-icon>mdi-lan-connect</v-icon>
|
||||
</template>
|
||||
<v-list-item-title>{{ tm('transport.title') }}</v-list-item-title>
|
||||
<template v-slot:append>
|
||||
<span class="chat-settings-group-current chat-settings-transport-current">{{ currentTransportLabel }}</span>
|
||||
<v-icon size="18" class="chat-settings-group-arrow">mdi-chevron-right</v-icon>
|
||||
</template>
|
||||
</v-list-item>
|
||||
</template>
|
||||
<v-list-item-title>{{ tm('transport.title') }}</v-list-item-title>
|
||||
<template v-slot:append>
|
||||
<v-select
|
||||
:model-value="transportMode"
|
||||
:items="transportOptions"
|
||||
item-title="label"
|
||||
item-value="value"
|
||||
density="compact"
|
||||
variant="underlined"
|
||||
hide-details
|
||||
class="transport-mode-select"
|
||||
@update:model-value="handleTransportModeChange"
|
||||
/>
|
||||
|
||||
<v-card class="styled-menu-card" style="min-width: 220px;" elevation="8" rounded="lg">
|
||||
<v-list density="compact" class="styled-menu-list pa-1">
|
||||
<v-list-item
|
||||
v-for="opt in transportOptions"
|
||||
:key="opt.value"
|
||||
:value="opt.value"
|
||||
@click="handleTransportModeChange(opt.value)"
|
||||
:class="{ 'styled-menu-item-active': transportMode === opt.value }"
|
||||
class="styled-menu-item"
|
||||
rounded="md"
|
||||
>
|
||||
<v-list-item-title>{{ opt.label }}</v-list-item-title>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
</v-card>
|
||||
</v-menu>
|
||||
|
||||
<!-- 发送快捷键(分组) -->
|
||||
<v-menu
|
||||
:open-on-hover="!isMobile"
|
||||
:open-on-click="isMobile"
|
||||
:open-delay="!isMobile ? 60 : 0"
|
||||
:close-delay="!isMobile ? 120 : 0"
|
||||
:location="isMobile ? 'bottom' : 'end center'"
|
||||
offset="8"
|
||||
close-on-content-click
|
||||
>
|
||||
<template v-slot:activator="{ props: sendShortcutMenuProps }">
|
||||
<v-list-item
|
||||
v-bind="sendShortcutMenuProps"
|
||||
class="styled-menu-item chat-settings-group-trigger"
|
||||
rounded="md"
|
||||
>
|
||||
<template v-slot:prepend>
|
||||
<v-icon>mdi-keyboard-outline</v-icon>
|
||||
</template>
|
||||
<v-list-item-title>{{ tm('shortcuts.sendKey.title') }}</v-list-item-title>
|
||||
<template v-slot:append>
|
||||
<span class="chat-settings-group-current chat-settings-transport-current">{{ currentSendShortcutLabel }}</span>
|
||||
<v-icon size="18" class="chat-settings-group-arrow">mdi-chevron-right</v-icon>
|
||||
</template>
|
||||
</v-list-item>
|
||||
</template>
|
||||
</v-list-item>
|
||||
|
||||
<v-card class="styled-menu-card" style="min-width: 220px;" elevation="8" rounded="lg">
|
||||
<v-list density="compact" class="styled-menu-list pa-1">
|
||||
<v-list-item
|
||||
v-for="opt in sendShortcutOptions"
|
||||
:key="opt.value"
|
||||
:value="opt.value"
|
||||
@click="handleSendShortcutChange(opt.value)"
|
||||
:class="{ 'styled-menu-item-active': props.sendShortcut === opt.value }"
|
||||
class="styled-menu-item"
|
||||
rounded="md"
|
||||
>
|
||||
<v-list-item-title>{{ opt.label }}</v-list-item-title>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
</v-card>
|
||||
</v-menu>
|
||||
|
||||
<!-- 全屏/退出全屏 -->
|
||||
<v-list-item class="styled-menu-item" @click="$emit('toggleFullscreen')">
|
||||
@@ -162,15 +299,16 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
import { ref, computed } from 'vue';
|
||||
import { useI18n, useModuleI18n } from '@/i18n/composables';
|
||||
import type { Session } from '@/composables/useSessions';
|
||||
import { askForConfirmation, useConfirmDialog } from '@/utils/confirmDialog';
|
||||
import LanguageSwitcher from '@/components/shared/LanguageSwitcher.vue';
|
||||
import StyledMenu from '@/components/shared/StyledMenu.vue';
|
||||
import ProviderConfigDialog from '@/components/chat/ProviderConfigDialog.vue';
|
||||
import ProjectList from '@/components/chat/ProjectList.vue';
|
||||
import type { Project } from '@/components/chat/ProjectList.vue';
|
||||
import { useLanguageSwitcher } from '@/i18n/composables';
|
||||
import type { Locale } from '@/i18n/types';
|
||||
|
||||
interface Props {
|
||||
sessions: Session[];
|
||||
@@ -183,6 +321,7 @@ interface Props {
|
||||
isMobile: boolean;
|
||||
mobileMenuOpen: boolean;
|
||||
projects?: Project[];
|
||||
sendShortcut: 'enter' | 'shift_enter';
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
@@ -194,6 +333,7 @@ const emit = defineEmits<{
|
||||
selectConversation: [sessionIds: string[]];
|
||||
editTitle: [sessionId: string, title: string];
|
||||
deleteConversation: [sessionId: string];
|
||||
batchDeleteConversations: [sessionIds: string[]];
|
||||
closeMobileSidebar: [];
|
||||
toggleTheme: [];
|
||||
toggleFullscreen: [];
|
||||
@@ -202,6 +342,7 @@ const emit = defineEmits<{
|
||||
editProject: [project: Project];
|
||||
deleteProject: [projectId: string];
|
||||
updateTransportMode: [mode: 'sse' | 'websocket'];
|
||||
updateSendShortcut: [mode: 'enter' | 'shift_enter'];
|
||||
}>();
|
||||
|
||||
const { t } = useI18n();
|
||||
@@ -211,10 +352,84 @@ const confirmDialog = useConfirmDialog();
|
||||
|
||||
const sidebarCollapsed = ref(true);
|
||||
const showProviderConfigDialog = ref(false);
|
||||
|
||||
// Batch mode state
|
||||
const batchMode = ref(false);
|
||||
const batchSelected = ref<string[]>([]);
|
||||
|
||||
const isAllSelected = computed(() =>
|
||||
props.sessions.length > 0 && batchSelected.value.length === props.sessions.length
|
||||
);
|
||||
|
||||
function toggleBatchMode() {
|
||||
batchMode.value = !batchMode.value;
|
||||
batchSelected.value = [];
|
||||
}
|
||||
|
||||
function toggleBatchItem(sessionId: string) {
|
||||
const idx = batchSelected.value.indexOf(sessionId);
|
||||
if (idx >= 0) {
|
||||
batchSelected.value.splice(idx, 1);
|
||||
} else {
|
||||
batchSelected.value.push(sessionId);
|
||||
}
|
||||
}
|
||||
|
||||
function toggleSelectAll() {
|
||||
if (isAllSelected.value) {
|
||||
batchSelected.value = [];
|
||||
} else {
|
||||
batchSelected.value = props.sessions.map(s => s.session_id);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleBatchDelete() {
|
||||
const count = batchSelected.value.length;
|
||||
if (count === 0) return;
|
||||
const message = tm('batch.confirmDelete', { count });
|
||||
if (await askForConfirmation(message, confirmDialog)) {
|
||||
emit('batchDeleteConversations', [...batchSelected.value]);
|
||||
batchSelected.value = [];
|
||||
batchMode.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleListSelect(sessionIds: string[]) {
|
||||
if (!batchMode.value) {
|
||||
emit('selectConversation', sessionIds);
|
||||
}
|
||||
}
|
||||
const transportOptions = [
|
||||
{ label: tm('transport.sse'), value: 'sse' as const },
|
||||
{ label: tm('transport.websocket'), value: 'websocket' as const }
|
||||
];
|
||||
const sendShortcutOptions = [
|
||||
{ label: tm('shortcuts.sendKey.enterToSend'), value: 'enter' as const },
|
||||
{ label: tm('shortcuts.sendKey.shiftEnterToSend'), value: 'shift_enter' as const }
|
||||
];
|
||||
|
||||
// Language switcher
|
||||
const { languageOptions, currentLanguage, switchLanguage, locale } = useLanguageSwitcher();
|
||||
const languages = computed(() =>
|
||||
languageOptions.value.map(lang => ({
|
||||
code: lang.value,
|
||||
name: lang.label,
|
||||
flag: lang.flag
|
||||
}))
|
||||
);
|
||||
const currentLocale = computed(() => locale.value);
|
||||
const changeLanguage = async (langCode: string) => {
|
||||
await switchLanguage(langCode as Locale);
|
||||
};
|
||||
|
||||
const currentTransportLabel = computed(() => {
|
||||
const found = transportOptions.find(opt => opt.value === props.transportMode);
|
||||
return found?.label ?? '';
|
||||
});
|
||||
const currentSendShortcutLabel = computed(() => {
|
||||
const found = sendShortcutOptions.find(opt => opt.value === props.sendShortcut);
|
||||
return found?.label ?? '';
|
||||
});
|
||||
|
||||
// 从 localStorage 读取侧边栏折叠状态
|
||||
const savedCollapsedState = localStorage.getItem('sidebarCollapsed');
|
||||
@@ -242,6 +457,12 @@ function handleTransportModeChange(mode: string | null) {
|
||||
emit('updateTransportMode', mode);
|
||||
}
|
||||
}
|
||||
|
||||
function handleSendShortcutChange(mode: string | null) {
|
||||
if (mode === 'enter' || mode === 'shift_enter') {
|
||||
emit('updateSendShortcut', mode);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@@ -310,7 +531,7 @@ function handleTransportModeChange(mode: string | null) {
|
||||
}
|
||||
|
||||
.conversation-item:hover {
|
||||
background-color: rgba(103, 58, 183, 0.05);
|
||||
background-color: rgba(var(--v-theme-primary), 0.05);
|
||||
}
|
||||
|
||||
.conversation-item:hover .conversation-actions {
|
||||
@@ -402,7 +623,74 @@ function handleTransportModeChange(mode: string | null) {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.transport-mode-select {
|
||||
min-width: 120px;
|
||||
.chat-settings-group-trigger :deep(.v-list-item__append) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.chat-settings-group-current {
|
||||
font-size: 14px;
|
||||
line-height: 1;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.chat-settings-transport-current {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.chat-settings-group-arrow {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.language-flag {
|
||||
font-size: 16px;
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.new-chat-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.new-chat-row .new-chat-btn {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.batch-action-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 4px 12px;
|
||||
gap: 4px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.batch-selected-count {
|
||||
font-size: 12px;
|
||||
opacity: 0.7;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.batch-checkbox {
|
||||
flex: none;
|
||||
transition: opacity 0.2s ease, transform 0.2s ease;
|
||||
}
|
||||
|
||||
.batch-checkbox-slot {
|
||||
width: 0;
|
||||
opacity: 0;
|
||||
overflow: hidden;
|
||||
pointer-events: none;
|
||||
transform: translateX(-8px);
|
||||
transition: width 0.2s ease, opacity 0.2s ease, transform 0.2s ease;
|
||||
}
|
||||
|
||||
.batch-checkbox-slot--active {
|
||||
width: 28px;
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
transform: translateX(0);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -180,7 +180,7 @@
|
||||
|
||||
<script>
|
||||
import { useI18n, useModuleI18n } from '@/i18n/composables';
|
||||
import { enableKatex, enableMermaid, setCustomComponents } from 'markstream-vue'
|
||||
import { enableKatex, enableMermaid, MarkdownCodeBlockNode, setCustomComponents } from 'markstream-vue'
|
||||
import 'markstream-vue/index.css'
|
||||
import 'katex/dist/katex.min.css'
|
||||
import 'highlight.js/styles/github.css';
|
||||
@@ -194,8 +194,11 @@ import ActionRef from './message_list_comps/ActionRef.vue';
|
||||
enableKatex();
|
||||
enableMermaid();
|
||||
|
||||
// 注册自定义 ref 组件
|
||||
setCustomComponents('message-list', { ref: RefNode });
|
||||
// 注册 message-list 专用组件:引用节点 + Shiki 代码块渲染
|
||||
setCustomComponents('message-list', {
|
||||
ref: RefNode,
|
||||
code_block: MarkdownCodeBlockNode
|
||||
});
|
||||
|
||||
export default {
|
||||
name: 'MessageList',
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
v-model:prompt="prompt"
|
||||
:stagedImagesUrl="stagedImagesUrl"
|
||||
:stagedAudioUrl="stagedAudioUrl"
|
||||
:disabled="isStreaming"
|
||||
:disabled="false"
|
||||
:is-running="isStreaming || isConvRunning"
|
||||
:enableStreaming="enableStreaming"
|
||||
:isRecording="isRecording"
|
||||
|
||||
@@ -63,8 +63,9 @@
|
||||
<!-- Text (Markdown) -->
|
||||
<MarkdownRender
|
||||
v-else-if="renderPart.part.type === 'plain' && renderPart.part.text && renderPart.part.text.trim()"
|
||||
:key="`${renderPart.key}-${isDark ? 'dark' : 'light'}`"
|
||||
custom-id="message-list" :custom-html-tags="['ref']" :content="renderPart.part.text" :typewriter="false"
|
||||
class="markdown-content" :is-dark="isDark" :monacoOptions="{ theme: isDark ? 'vs-dark' : 'vs-light' }" />
|
||||
class="markdown-content" :is-dark="isDark" />
|
||||
|
||||
<!-- Image -->
|
||||
<div v-else-if="renderPart.part.type === 'image' && renderPart.part.embedded_url" class="embedded-images">
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="isExpanded" class="reasoning-content animate-fade-in">
|
||||
<MarkdownRender :content="reasoning" class="reasoning-text markdown-content"
|
||||
<MarkdownRender :key="`reasoning-${isDark ? 'dark' : 'light'}`" :content="reasoning" class="reasoning-text markdown-content"
|
||||
:typewriter="false" :is-dark="isDark" :style="isDark ? { opacity: '0.85' } : {}" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
<template>
|
||||
<v-chip v-if="domain" class="ref-chip" size="x-small" variant="flat"
|
||||
:style="{ backgroundColor: isDark ? '#303030' : '#f4f4f4', color: isDark ? '#999' : '#666' }" :href="url"
|
||||
:style="chipStyle" :href="url"
|
||||
target="_blank" clickable>
|
||||
<v-icon start size="x-small" color>mdi-link-variant</v-icon>
|
||||
<span>{{ domain }}</span>
|
||||
|
||||
</v-chip>
|
||||
<span v-else class="ref-fallback" :style="{ color: isDark ? '#999' : '#666' }">{{ 'site' }}</span>
|
||||
<span v-else class="ref-fallback" :style="fallbackStyle">{{ 'site' }}</span>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
@@ -46,6 +46,15 @@ const domain = computed(() => {
|
||||
return ''
|
||||
}
|
||||
})
|
||||
|
||||
const chipStyle = computed(() => ({
|
||||
backgroundColor: isDark ? 'rgba(var(--v-theme-on-surface), 0.08)' : 'rgba(var(--v-theme-on-surface), 0.04)',
|
||||
color: isDark ? 'rgba(var(--v-theme-on-surface), 0.62)' : 'rgba(var(--v-theme-on-surface), 0.72)'
|
||||
}))
|
||||
|
||||
const fallbackStyle = computed(() => ({
|
||||
color: isDark ? 'rgba(var(--v-theme-on-surface), 0.62)' : 'rgba(var(--v-theme-on-surface), 0.72)'
|
||||
}))
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
>
|
||||
<v-icon
|
||||
size="18"
|
||||
:color="props.variant === 'default' ? (useCustomizerStore().uiTheme === 'PurpleTheme' ? '#5e35b1' : '#d7c5fa') : undefined"
|
||||
:color="props.variant === 'default' ? 'rgb(var(--v-theme-primary))' : undefined"
|
||||
>
|
||||
mdi-translate
|
||||
</v-icon>
|
||||
@@ -42,7 +42,6 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useI18n, useLanguageSwitcher } from '@/i18n/composables'
|
||||
import { useCustomizerStore } from '@/stores/customizer'
|
||||
import type { Locale } from '@/i18n/types'
|
||||
import StyledMenu from '@/components/shared/StyledMenu.vue'
|
||||
|
||||
@@ -90,7 +89,7 @@ const changeLanguage = async (langCode: string) => {
|
||||
|
||||
.language-switcher--default:hover {
|
||||
transform: scale(1.05);
|
||||
background: rgba(94, 53, 177, 0.08) !important;
|
||||
background: rgba(var(--v-theme-primary), 0.08) !important;
|
||||
}
|
||||
|
||||
/* Header变体样式 - 完全继承Vuetify和action-btn的默认样式 */
|
||||
@@ -103,8 +102,4 @@ const changeLanguage = async (langCode: string) => {
|
||||
/* 继承action-btn样式,与工具栏主题按钮保持一致 */
|
||||
}
|
||||
|
||||
/* 深色模式下的悬停效果(仅对default变体) */
|
||||
:deep(.v-theme--PurpleThemeDark) .language-switcher--default:hover {
|
||||
background: rgba(114, 46, 209, 0.12) !important;
|
||||
}
|
||||
</style>
|
||||
@@ -6,11 +6,11 @@
|
||||
</div>
|
||||
<div class="logo-text">
|
||||
<h2
|
||||
:style="{color: useCustomizerStore().uiTheme === 'PurpleTheme' ? '#5e35b1' : '#d7c5fa'}"
|
||||
:style="{ color: 'rgb(var(--v-theme-primary))' }"
|
||||
v-html="formatTitle(title || t('core.header.logoTitle'))"
|
||||
></h2>
|
||||
<!-- 父子组件传递css变量可能会出错,暂时使用十六进制颜色值 -->
|
||||
<h4 :style="{color: useCustomizerStore().uiTheme === 'PurpleTheme' ? '#000000aa' : '#ffffffcc'}"
|
||||
<h4 :style="{ color: 'rgba(var(--v-theme-on-surface), 0.72)' }"
|
||||
class="hint-text">{{ subtitle || t('core.header.accountDialog.title') }}</h4>
|
||||
</div>
|
||||
</div>
|
||||
@@ -18,7 +18,6 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useCustomizerStore } from "@/stores/customizer";
|
||||
import { useI18n } from '@/i18n/composables';
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
@@ -48,6 +48,24 @@ const loading = ref(false);
|
||||
const isEmpty = ref(false);
|
||||
const copyFeedbackTimer = ref(null);
|
||||
const lastRequestId = ref(0);
|
||||
const scrollContainer = ref(null);
|
||||
|
||||
function slugifyHeading(text, slugCounts) {
|
||||
const base = (text || "")
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.normalize("NFKD")
|
||||
.replace(/[\u0300-\u036f]/g, "")
|
||||
.replace(/[^\p{Letter}\p{Number}\s-]/gu, "")
|
||||
.replace(/\s+/g, "-")
|
||||
.replace(/-+/g, "-");
|
||||
|
||||
if (!base) return "";
|
||||
|
||||
const count = slugCounts.get(base) || 0;
|
||||
slugCounts.set(base, count + 1);
|
||||
return count === 0 ? base : `${base}-${count}`;
|
||||
}
|
||||
|
||||
onUnmounted(() => {
|
||||
if (copyFeedbackTimer.value) clearTimeout(copyFeedbackTimer.value);
|
||||
@@ -153,6 +171,18 @@ const renderedHtml = computed(() => {
|
||||
// 3. 后处理方案:完全隔离,安全性最高
|
||||
const tempDiv = document.createElement("div");
|
||||
tempDiv.innerHTML = cleanHtml;
|
||||
|
||||
const slugCounts = new Map();
|
||||
tempDiv.querySelectorAll("h1, h2, h3, h4, h5, h6").forEach((heading) => {
|
||||
if (heading.id) {
|
||||
slugCounts.set(heading.id, (slugCounts.get(heading.id) || 0) + 1);
|
||||
return;
|
||||
}
|
||||
|
||||
const slug = slugifyHeading(heading.textContent, slugCounts);
|
||||
if (slug) heading.id = slug;
|
||||
});
|
||||
|
||||
tempDiv.querySelectorAll("a").forEach((link) => {
|
||||
const href = link.getAttribute("href");
|
||||
// 强制所有外部链接使用安全的 _blank 策略
|
||||
@@ -251,18 +281,35 @@ watch(
|
||||
|
||||
function handleContainerClick(event) {
|
||||
const btn = event.target.closest(".copy-code-btn");
|
||||
if (!btn) return;
|
||||
const code = btn.closest(".code-block-wrapper")?.querySelector("code");
|
||||
if (code) {
|
||||
if (navigator.clipboard?.writeText) {
|
||||
navigator.clipboard
|
||||
.writeText(code.textContent)
|
||||
.then(() => showCopyFeedback(btn, true))
|
||||
.catch(() => tryFallbackCopy(code.textContent, btn));
|
||||
} else {
|
||||
tryFallbackCopy(code.textContent, btn);
|
||||
if (btn) {
|
||||
const code = btn.closest(".code-block-wrapper")?.querySelector("code");
|
||||
if (code) {
|
||||
if (navigator.clipboard?.writeText) {
|
||||
navigator.clipboard
|
||||
.writeText(code.textContent)
|
||||
.then(() => showCopyFeedback(btn, true))
|
||||
.catch(() => tryFallbackCopy(code.textContent, btn));
|
||||
} else {
|
||||
tryFallbackCopy(code.textContent, btn);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const anchor = event.target.closest('a[href^="#"]');
|
||||
if (!anchor) return;
|
||||
|
||||
const rawHref = anchor.getAttribute("href");
|
||||
const targetId = rawHref ? decodeURIComponent(rawHref.slice(1)) : "";
|
||||
if (!targetId) return;
|
||||
|
||||
const target = scrollContainer.value?.querySelector(
|
||||
`#${CSS.escape(targetId)}`,
|
||||
);
|
||||
if (!target) return;
|
||||
|
||||
event.preventDefault();
|
||||
target.scrollIntoView({ behavior: "smooth", block: "start" });
|
||||
}
|
||||
|
||||
function tryFallbackCopy(text, btn) {
|
||||
@@ -326,7 +373,7 @@ const showActionArea = computed(() => {
|
||||
<v-icon>mdi-close</v-icon>
|
||||
</v-btn>
|
||||
</v-card-title>
|
||||
<v-card-text style="overflow-y: auto">
|
||||
<v-card-text ref="scrollContainer" style="overflow-y: auto">
|
||||
<div v-if="showActionArea" class="d-flex justify-space-between mb-4">
|
||||
<v-btn
|
||||
v-if="modeConfig.showGithubButton && repoUrl"
|
||||
@@ -436,6 +483,7 @@ const showActionArea = computed(() => {
|
||||
margin-bottom: 16px;
|
||||
font-weight: 600;
|
||||
line-height: 1.25;
|
||||
scroll-margin-top: 12px;
|
||||
}
|
||||
|
||||
:deep(.markdown-body h1) {
|
||||
|
||||
@@ -24,12 +24,12 @@ withDefaults(defineProps<{
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
<style>
|
||||
.styled-menu-card {
|
||||
min-width: 100px;
|
||||
width: fit-content;
|
||||
border: 1px solid rgba(94, 53, 177, 0.15) !important;
|
||||
background: #f8f6fc !important;
|
||||
border: 1px solid rgba(var(--v-theme-primary), 0.15) !important;
|
||||
background: rgba(var(--v-theme-surface), 0.98) !important;
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
@@ -37,43 +37,41 @@ withDefaults(defineProps<{
|
||||
background: transparent !important;
|
||||
}
|
||||
|
||||
:deep(.styled-menu-item) {
|
||||
.styled-menu-item {
|
||||
margin: 2px 0;
|
||||
transition: all 0.2s ease;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
:deep(.styled-menu-item:hover) {
|
||||
background: rgba(94, 53, 177, 0.08) !important;
|
||||
.styled-menu-item:hover {
|
||||
background: rgba(var(--v-theme-primary), 0.08) !important;
|
||||
}
|
||||
|
||||
:deep(.styled-menu-item-active) {
|
||||
background: rgba(94, 53, 177, 0.15) !important;
|
||||
.styled-menu-item-active {
|
||||
background: rgba(var(--v-theme-primary), 0.15) !important;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
:deep(.styled-menu-item-active:hover) {
|
||||
background: rgba(94, 53, 177, 0.2) !important;
|
||||
.styled-menu-item-active:hover {
|
||||
background: rgba(var(--v-theme-primary), 0.2) !important;
|
||||
}
|
||||
</style>
|
||||
|
||||
<style>
|
||||
/* 深色模式下的下拉框样式 - 需要全局样式才能检测主题 */
|
||||
.v-theme--PurpleThemeDark .styled-menu-card {
|
||||
background: #2a2733 !important;
|
||||
border: 1px solid rgba(110, 60, 180, 0.692) !important;
|
||||
background: rgba(var(--v-theme-surface), 0.98) !important;
|
||||
border: 1px solid rgba(var(--v-theme-primary), 0.2) !important;
|
||||
}
|
||||
|
||||
/* 深色模式下的列表项悬停效果 */
|
||||
.v-theme--PurpleThemeDark .styled-menu-item:hover {
|
||||
background: rgba(114, 46, 209, 0.12) !important;
|
||||
background: rgba(var(--v-theme-primary), 0.12) !important;
|
||||
}
|
||||
|
||||
.v-theme--PurpleThemeDark .styled-menu-item-active {
|
||||
background: rgba(114, 46, 209, 0.2) !important;
|
||||
background: rgba(var(--v-theme-primary), 0.2) !important;
|
||||
}
|
||||
|
||||
.v-theme--PurpleThemeDark .styled-menu-item-active:hover {
|
||||
background: rgba(114, 46, 209, 0.25) !important;
|
||||
background: rgba(var(--v-theme-primary), 0.25) !important;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -590,9 +590,11 @@ export function useProviderSources(options: UseProviderSourcesOptions) {
|
||||
async function testProvider(provider: any) {
|
||||
testingProviders.value.push(provider.id)
|
||||
try {
|
||||
const startTime = performance.now()
|
||||
const response = await axios.get('/api/config/provider/check_one', { params: { id: provider.id } })
|
||||
if (response.data.status === 'ok' && response.data.data.error === null) {
|
||||
showMessage(tm('models.testSuccess', { id: provider.id }))
|
||||
const latency = Math.max(0, Math.round(performance.now() - startTime))
|
||||
showMessage(tm('models.testSuccessWithLatency', { id: provider.id, latency }))
|
||||
} else {
|
||||
throw new Error(response.data.data.error || tm('models.testError'))
|
||||
}
|
||||
|
||||
@@ -109,6 +109,73 @@ export function useSessions(chatboxMode: boolean = false) {
|
||||
}
|
||||
}
|
||||
|
||||
interface BatchDeleteFailedItem {
|
||||
session_id: string;
|
||||
reason: string;
|
||||
}
|
||||
|
||||
interface BatchDeleteResult {
|
||||
deleted_count: number;
|
||||
failed_count: number;
|
||||
failed_items: BatchDeleteFailedItem[];
|
||||
currentSessionDeleted: boolean;
|
||||
}
|
||||
|
||||
function isBatchDeleteResponseData(data: unknown): data is {
|
||||
deleted_count: number;
|
||||
failed_count: number;
|
||||
failed_items: BatchDeleteFailedItem[];
|
||||
} {
|
||||
if (!data || typeof data !== 'object') {
|
||||
return false;
|
||||
}
|
||||
const payload = data as Record<string, unknown>;
|
||||
return (
|
||||
typeof payload.deleted_count === 'number' &&
|
||||
typeof payload.failed_count === 'number' &&
|
||||
Array.isArray(payload.failed_items)
|
||||
);
|
||||
}
|
||||
|
||||
async function batchDeleteSessions(sessionIds: string[]): Promise<BatchDeleteResult> {
|
||||
try {
|
||||
const currentSessionId = currSessionId.value;
|
||||
const response = await axios.post('/api/chat/batch_delete_sessions', { session_ids: sessionIds });
|
||||
if (response.data?.status !== 'ok') {
|
||||
throw new Error(response.data?.message || 'Failed to batch delete sessions');
|
||||
}
|
||||
|
||||
const data = response.data?.data;
|
||||
if (!isBatchDeleteResponseData(data)) {
|
||||
throw new Error('Invalid batch delete response payload');
|
||||
}
|
||||
|
||||
const failedItems = data.failed_items;
|
||||
const failedSessionIds = new Set(failedItems.map(item => item.session_id));
|
||||
const currentSessionDeleted = Boolean(
|
||||
currentSessionId &&
|
||||
sessionIds.includes(currentSessionId) &&
|
||||
!failedSessionIds.has(currentSessionId)
|
||||
);
|
||||
|
||||
if (currentSessionDeleted) {
|
||||
currSessionId.value = '';
|
||||
selectedSessions.value = [];
|
||||
}
|
||||
await getSessions();
|
||||
|
||||
return {
|
||||
deleted_count: data.deleted_count,
|
||||
failed_count: data.failed_count,
|
||||
failed_items: failedItems,
|
||||
currentSessionDeleted,
|
||||
};
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
function showEditTitleDialog(sessionId: string, title: string) {
|
||||
editingSessionId.value = sessionId;
|
||||
editingTitle.value = title || '';
|
||||
@@ -167,6 +234,7 @@ export function useSessions(chatboxMode: boolean = false) {
|
||||
getSessions,
|
||||
newSession,
|
||||
deleteSession,
|
||||
batchDeleteSessions,
|
||||
showEditTitleDialog,
|
||||
saveTitle,
|
||||
updateSessionTitle,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -96,6 +96,7 @@
|
||||
"save": "Save",
|
||||
"livePreview": "Live Preview (may differ)",
|
||||
"refreshPreview": "Refresh Preview",
|
||||
"previewText": "This is a sample text used to preview the template output.\n\nIt can contain multiple lines and various formatting.",
|
||||
"syntaxHint": "Supports jinja2 syntax. Available variables: text | safe (text to render), version (AstrBot version)",
|
||||
"saveAndApply": "Save and Apply Current Template",
|
||||
"confirmReset": "Confirm Reset",
|
||||
|
||||
@@ -71,10 +71,16 @@
|
||||
"modes": {
|
||||
"darkMode": "Switch to Dark Mode",
|
||||
"lightMode": "Switch to Light Mode"
|
||||
}, "shortcuts": {
|
||||
},
|
||||
"shortcuts": {
|
||||
"help": "Get Help",
|
||||
"voiceRecord": "Record Voice",
|
||||
"pasteImage": "Paste Image"
|
||||
"pasteImage": "Paste Image",
|
||||
"sendKey": {
|
||||
"title": "Send Shortcut",
|
||||
"enterToSend": "Enter to send",
|
||||
"shiftEnterToSend": "Shift+Enter to send"
|
||||
}
|
||||
},
|
||||
"streaming": {
|
||||
"enabled": "Streaming enabled",
|
||||
@@ -141,5 +147,15 @@
|
||||
"errors": {
|
||||
"sendMessageFailed": "Failed to send message, please try again",
|
||||
"createSessionFailed": "Failed to create session, please refresh the page"
|
||||
},
|
||||
"batch": {
|
||||
"selected": "{count} selected",
|
||||
"confirmDelete": "Are you sure you want to delete {count} conversation(s)? This action cannot be undone.",
|
||||
"selectAll": "Select All",
|
||||
"deselectAll": "Deselect All",
|
||||
"delete": "Delete",
|
||||
"exit": "Exit",
|
||||
"partialFailure": "{failed} of {total} conversations failed to delete",
|
||||
"requestFailed": "Failed to delete conversations. Please try again."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -78,6 +78,7 @@
|
||||
},
|
||||
"persona": {
|
||||
"description": "Persona",
|
||||
"hint": "Set the default persona for AI conversations. Personas can be managed in the Persona tab.",
|
||||
"provider_settings": {
|
||||
"default_personality": {
|
||||
"description": "Default Persona"
|
||||
|
||||
@@ -132,6 +132,7 @@
|
||||
"deleteSuccess": "Model deleted successfully",
|
||||
"deleteError": "Failed to delete model",
|
||||
"testSuccess": "Model {id} test passed",
|
||||
"testSuccessWithLatency": "Model {id} test passed, latency {latency} ms",
|
||||
"testError": "Model test failed",
|
||||
"searchPlaceholder": "Search models or ID",
|
||||
"manualAddButton": "Custom Model",
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"create": "Создать",
|
||||
"read": "Чтение",
|
||||
"update": "Обновить",
|
||||
"delete": "Удалить",
|
||||
"search": "Поиск",
|
||||
"filter": "Фильтр",
|
||||
"sort": "Сортировка",
|
||||
"export": "Экспорт",
|
||||
"import": "Импорт",
|
||||
"backup": "Резервное копирование",
|
||||
"restore": "Восстановление",
|
||||
"copy": "Копировать",
|
||||
"paste": "Вставить",
|
||||
"cut": "Вырезать",
|
||||
"undo": "Отменить",
|
||||
"redo": "Повторить",
|
||||
"refresh": "Обновить",
|
||||
"submit": "Отправить",
|
||||
"reset": "Сбросить",
|
||||
"clear": "Очистить",
|
||||
"save": "Сохранить",
|
||||
"close": "Закрыть"
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
{
|
||||
"save": "Сохранить",
|
||||
"cancel": "Отмена",
|
||||
"close": "Закрыть",
|
||||
"copy": "Копировать",
|
||||
"copied": "Скопировано",
|
||||
"copyFailed": "Ошибка копирования",
|
||||
"delete": "Удалить",
|
||||
"edit": "Редактировать",
|
||||
"add": "Добавить",
|
||||
"confirm": "Подтвердить",
|
||||
"loading": "Загрузка...",
|
||||
"success": "Успешно",
|
||||
"error": "Ошибка",
|
||||
"warning": "Внимание",
|
||||
"info": "Информация",
|
||||
"name": "Имя",
|
||||
"description": "Описание",
|
||||
"author": "Автор",
|
||||
"status": "Статус",
|
||||
"actions": "Действия",
|
||||
"enable": "Включить",
|
||||
"disable": "Выключить",
|
||||
"enabled": "Включено",
|
||||
"disabled": "Выключено",
|
||||
"reload": "Перезагрузить",
|
||||
"configure": "Настроить",
|
||||
"install": "Установить",
|
||||
"uninstall": "Удалить",
|
||||
"update": "Обновить",
|
||||
"language": "Язык",
|
||||
"settings": "Настройки",
|
||||
"locale": "JSON",
|
||||
"type": "Тип",
|
||||
"press": "Нажмите",
|
||||
"longPress": "Долгое нажатие",
|
||||
"yes": "Да",
|
||||
"no": "Нет",
|
||||
"imagePreview": "Предпросмотр изображения",
|
||||
"autoDetect": "Автоопределение",
|
||||
"dialog": {
|
||||
"confirmTitle": "Подтверждение",
|
||||
"confirmMessage": "Вы уверены, что хотите выполнить это действие?",
|
||||
"confirmButton": "ОК",
|
||||
"cancelButton": "Отмена"
|
||||
},
|
||||
"restart": {
|
||||
"waiting": "Ожидание перезагрузки AstrBot...",
|
||||
"maxRetriesReached": "Превышено количество попыток проверки статуса. Пожалуйста, проверьте вручную."
|
||||
},
|
||||
"readme": {
|
||||
"title": "Документация плагина",
|
||||
"buttons": {
|
||||
"viewOnGithub": "Открыть репозиторий на GitHub",
|
||||
"refresh": "Обновить"
|
||||
},
|
||||
"loading": "Загрузка README...",
|
||||
"errors": {
|
||||
"fetchFailed": "Не удалось загрузить README",
|
||||
"fetchError": "Произошла ошибка при загрузке README"
|
||||
},
|
||||
"empty": {
|
||||
"title": "У этого плагина нет ссылки на документацию или репозиторий GitHub.",
|
||||
"subtitle": "Пожалуйста, посетите магазин плагинов или свяжитесь с автором для получения дополнительной информации."
|
||||
}
|
||||
},
|
||||
"changelog": {
|
||||
"title": "Журнал изменений",
|
||||
"loading": "Загрузка журнала изменений...",
|
||||
"empty": {
|
||||
"title": "У этого плагина нет журнала изменений",
|
||||
"subtitle": "Разработчики могут добавить файл CHANGELOG.md в директорию плагина"
|
||||
}
|
||||
},
|
||||
"editor": {
|
||||
"fullscreen": "На весь экран",
|
||||
"editingTitle": "Редактирование содержимого"
|
||||
},
|
||||
"templateList": {
|
||||
"addEntry": "Добавить запись",
|
||||
"empty": "Записей нет, выберите шаблон для добавления",
|
||||
"missingTemplate": "Шаблон не найден, пожалуйста, удалите и добавьте заново.",
|
||||
"unknownTemplate": "Неизвестный шаблон"
|
||||
},
|
||||
"list": {
|
||||
"addItemPlaceholder": "Добавьте новый элемент и нажмите Enter",
|
||||
"addButton": "Добавить",
|
||||
"addMore": "Добавить еще",
|
||||
"batchImport": "Массовый импорт",
|
||||
"batchImportTitle": "Массовый импорт",
|
||||
"batchImportLabel": "Один элемент на строку",
|
||||
"batchImportPlaceholder": "Например:\nЭлемент 1\nЭлемент 2\nЭлемент 3",
|
||||
"batchImportHint": "Каждая строка будет считаться отдельным элементом. Пустые строки игнорируются.",
|
||||
"batchImportButton": "Импортировать {count} эл.",
|
||||
"noItems": "Список пуст",
|
||||
"noItemsHint": "Элементов нет. Напишите что-нибудь выше и нажмите Enter.",
|
||||
"inputPlaceholder": "Введите текст и нажмите Enter",
|
||||
"editTitle": "Изменить элемент",
|
||||
"modifyButton": "Изменить"
|
||||
},
|
||||
"itemCard": {
|
||||
"enabled": "Включено",
|
||||
"disabled": "Выключено",
|
||||
"delete": "Удалить",
|
||||
"edit": "Изменить",
|
||||
"copy": "Копировать",
|
||||
"noData": "Нет данных"
|
||||
},
|
||||
"objectEditor": {
|
||||
"dialogTitle": "Изменение пар ключ-значение",
|
||||
"noItems": "Нет элементов",
|
||||
"noParams": "Нет параметров",
|
||||
"presets": "Пресеты",
|
||||
"newKeyLabel": "Имя ключа",
|
||||
"valueTypeLabel": "Тип значения",
|
||||
"keyExists": "Ключ уже существует",
|
||||
"invalidJson": "Некорректный формат JSON",
|
||||
"placeholders": {
|
||||
"keyName": "Ключ",
|
||||
"stringValue": "Строка",
|
||||
"numberValue": "Число",
|
||||
"jsonValue": "JSON"
|
||||
}
|
||||
},
|
||||
"firstNotice": {
|
||||
"title": "Первичная информация",
|
||||
"loading": "Загрузка информации...",
|
||||
"empty": {
|
||||
"title": "Нет информации для отображения",
|
||||
"subtitle": "Файл FIRST_NOTICE.md не найден или пуст."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
{
|
||||
"logoTitle": "Панель управления AstrBot",
|
||||
"version": {
|
||||
"hasNewVersion": "Доступна новая версия AstrBot!",
|
||||
"dashboardHasNewVersion": "Доступна новая версия WebUI!"
|
||||
},
|
||||
"buttons": {
|
||||
"update": "Обновить",
|
||||
"account": "Аккаунт",
|
||||
"theme": {
|
||||
"light": "Светлая тема",
|
||||
"dark": "Темная тема"
|
||||
}
|
||||
},
|
||||
"updateDialog": {
|
||||
"title": "Обновить AstrBot",
|
||||
"currentVersion": "Текущая версия",
|
||||
"status": {
|
||||
"checking": "Проверка обновлений...",
|
||||
"switching": "Переключение версии...",
|
||||
"updating": "Обновление..."
|
||||
},
|
||||
"tabs": {
|
||||
"release": "😊 Релиз"
|
||||
},
|
||||
"updateToLatest": "Обновить до последней версии",
|
||||
"preRelease": "Предварительная версия",
|
||||
"preReleaseWarning": {
|
||||
"title": "Внимание: предварительная версия",
|
||||
"description": "Версии с меткой Pre-release могут содержать неизвестные ошибки. Не рекомендуется использовать в рабочих средах. Если вы обнаружили ошибку, пожалуйста, сообщите о ней в ",
|
||||
"issueLink": "GitHub Issues"
|
||||
},
|
||||
"tip": "💡 ПОДСКАЗКА: ",
|
||||
"tipContinue": "По умолчанию при переключении версии загружаются соответствующие файлы WebUI. Код WebUI находится в директории dashboard, вы можете собрать его самостоятельно с помощью npm.",
|
||||
"dockerTip": "При переключении версии будет предпринята попытка обновить как основной процесс бота, так и панель управления. Если вы используете Docker, вы также можете обновить образ или использовать",
|
||||
"dockerTipLink": "watchtower",
|
||||
"dockerTipContinue": "для автоматического мониторинга и обновления.",
|
||||
"table": {
|
||||
"tag": "Тег",
|
||||
"publishDate": "Дата публикации",
|
||||
"content": "Содержание",
|
||||
"sourceUrl": "Исходный код",
|
||||
"actions": "Действия",
|
||||
"view": "Просмотр",
|
||||
"switch": "Переключить"
|
||||
},
|
||||
"releaseNotes": {
|
||||
"title": "Журнал изменений"
|
||||
},
|
||||
"redirectConfirm": {
|
||||
"title": "Переход по ссылке",
|
||||
"message": "Вы будете перенаправлены на страницу GitHub Releases. Продолжить?",
|
||||
"latestLabel": "Последняя версия",
|
||||
"targetVersion": "Целевая версия:",
|
||||
"currentVersion": "Текущая версия:",
|
||||
"guideTitle": "Рекомендации после перехода:",
|
||||
"guideStep1": "Загрузите пакет, соответствующий архитектуре вашей системы.",
|
||||
"guideStep2": "После завершения установки перезапустите AstrBot.",
|
||||
"guideStep3": "Если вы используете Docker, отдайте приоритет обновлению через образ."
|
||||
},
|
||||
"desktopApp": {
|
||||
"title": "Обновить десктопное приложение",
|
||||
"message": "Проверка и обновление десктопной версии AstrBot.",
|
||||
"currentVersion": "Текущая версия:",
|
||||
"latestVersion": "Последняя версия:",
|
||||
"checking": "Проверка обновлений десктопного приложения...",
|
||||
"hasNewVersion": "Найдена новая версия. Нажмите для подтверждения обновления.",
|
||||
"isLatest": "Установлена последняя версия",
|
||||
"installing": "Загрузка и установка обновления... Приложение будет перезапущено автоматически.",
|
||||
"checkFailed": "Ошибка проверки обновлений. Попробуйте позже.",
|
||||
"installFailed": "Ошибка обновления. Попробуйте позже."
|
||||
},
|
||||
"dashboardUpdate": {
|
||||
"title": "Обновить только панель управления",
|
||||
"currentVersion": "Текущая версия",
|
||||
"hasNewVersion": "Доступна новая версия!",
|
||||
"isLatest": "Установлена последняя версия.",
|
||||
"downloadAndUpdate": "Скачать и обновить"
|
||||
}
|
||||
},
|
||||
"accountDialog": {
|
||||
"title": "Изменить аккаунт",
|
||||
"securityWarning": "Безопасность: Пожалуйста, смените пароль по умолчанию для защиты аккаунта",
|
||||
"form": {
|
||||
"currentPassword": "Текущий пароль",
|
||||
"newPassword": "Новый пароль",
|
||||
"confirmPassword": "Подтвердите новый пароль",
|
||||
"newUsername": "Новое имя пользователя (опционально)",
|
||||
"passwordHint": "Пароль должен быть не менее 8 символов",
|
||||
"confirmPasswordHint": "Введите новый пароль еще раз",
|
||||
"usernameHint": "Оставьте пустым, если не хотите менять имя пользователя",
|
||||
"defaultCredentials": "Логин и пароль по умолчанию: astrbot"
|
||||
},
|
||||
"validation": {
|
||||
"passwordRequired": "Введите пароль",
|
||||
"passwordMinLength": "Пароль должен быть не менее 8 символов",
|
||||
"passwordMatch": "Паролы не совпадают",
|
||||
"usernameMinLength": "Имя пользователя должно быть не менее 3 символов"
|
||||
},
|
||||
"actions": {
|
||||
"save": "Сохранить изменения",
|
||||
"cancel": "Отмена"
|
||||
},
|
||||
"messages": {
|
||||
"updateFailed": "Ошибка обновления, попробуйте еще раз"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
{
|
||||
"welcome": "Добро пожаловать",
|
||||
"dashboard": "Статистика",
|
||||
"platforms": "Боты",
|
||||
"providers": "Провайдеры моделей",
|
||||
"commands": "Команды",
|
||||
"persona": "Персонажи",
|
||||
"subagent": "Субагенты",
|
||||
"toolUse": "Инструменты MCP",
|
||||
"extension": "Плагины",
|
||||
"extensionTabs": {
|
||||
"installed": "Плагины AstrBot",
|
||||
"market": "Магазин плагинов",
|
||||
"mcp": "Серверы MCP",
|
||||
"skills": "Навыки",
|
||||
"components": "Управление поведением"
|
||||
},
|
||||
"config": "Конфигурация",
|
||||
"chat": "Чат",
|
||||
"cron": "Запланированные задачи",
|
||||
"conversation": "Данные диалогов",
|
||||
"sessionManagement": "Пользовательские правила",
|
||||
"console": "Логи платформы",
|
||||
"trace": "Трассировка",
|
||||
"alkaid": "Alkaid Lab",
|
||||
"knowledgeBase": "База знаний",
|
||||
"about": "О программе",
|
||||
"settings": "Настройки",
|
||||
"changelog": "Журнал изменений",
|
||||
"documentation": "Документация",
|
||||
"faq": "FAQ",
|
||||
"github": "GitHub",
|
||||
"drag": "Перетащить",
|
||||
"groups": {
|
||||
"more": "Дополнительно"
|
||||
},
|
||||
"changelogDialog": {
|
||||
"title": "Журнал изменений",
|
||||
"loading": "Загрузка...",
|
||||
"error": "Ошибка загрузки",
|
||||
"notFound": "Журнал изменений для этой версии не найден",
|
||||
"selectVersion": "Выберите версию",
|
||||
"current": "Текущая"
|
||||
},
|
||||
"configTabs": {
|
||||
"normal": "Обычная конфигурация",
|
||||
"system": "Системная конфигурация"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
{
|
||||
"knowledgeBaseSelector": {
|
||||
"notSelected": "Не выбрано",
|
||||
"buttonText": "Выбрать базу знаний...",
|
||||
"dialogTitle": "Выбор базы знаний",
|
||||
"loading": "Загрузка...",
|
||||
"noKnowledgeBases": "Базы знаний не найдены",
|
||||
"createKnowledgeBase": "Создать базу знаний",
|
||||
"selectedCount": "Выбрано баз знаний: {count}",
|
||||
"confirmSelection": "ОК",
|
||||
"cancelSelection": "Отмена",
|
||||
"noDescription": "Нет описания",
|
||||
"documentCount": "Документов: {count}",
|
||||
"chunkCount": "Фрагментов: {count}"
|
||||
},
|
||||
"pluginSetSelector": {
|
||||
"notSelected": "Плагины не включены",
|
||||
"allPlugins": "Включить все плагины (*)",
|
||||
"selectedCount": "Выбрано плагинов: {count}",
|
||||
"buttonText": "Выбрать набор плагинов...",
|
||||
"dialogTitle": "Выбор набора плагинов",
|
||||
"loading": "Загрузка...",
|
||||
"enableAll": "Включить все",
|
||||
"enableNone": "Ничего не включать",
|
||||
"customSelect": "Настроить выбор",
|
||||
"noPlugins": "Доступных плагинов нет",
|
||||
"confirmSelection": "ОК",
|
||||
"cancelSelection": "Отмена",
|
||||
"noDescription": "Нет описания",
|
||||
"notActivated": "Не активирован",
|
||||
"note": "*Системные и уже выключенные в настройках плагины не отображаются.",
|
||||
"selectedPluginsLabel": "Выбранные плагины:",
|
||||
"allPluginsLabel": "Все плагины"
|
||||
},
|
||||
"providerSelector": {
|
||||
"notSelected": "Не выбрано",
|
||||
"buttonText": "Выбрать провайдера...",
|
||||
"dialogTitle": "Выбор провайдера",
|
||||
"loading": "Загрузка...",
|
||||
"noProviders": "Доступных провайдеров нет",
|
||||
"confirmSelection": "ОК",
|
||||
"cancelSelection": "Отмена",
|
||||
"clearSelection": "Сбросить выбор",
|
||||
"clearSelectionSubtitle": "Очистить текущий выбор",
|
||||
"unknownType": "Неизвестный тип",
|
||||
"createProvider": "Создать провайдера",
|
||||
"manageProviders": "Управление провайдерами",
|
||||
"selectProviderPool": "Выбрать пул провайдеров...",
|
||||
"selectedCount": "Выбрано провайдеров: {count}"
|
||||
},
|
||||
"personaSelector": {
|
||||
"notSelected": "Не выбрано",
|
||||
"defaultPersona": "Персонаж по умолчанию",
|
||||
"buttonText": "Выбрать персонажа...",
|
||||
"editPersona": "Изменить текущего персонажа",
|
||||
"dialogTitle": "Выбор персонажа",
|
||||
"noDescription": "Нет описания",
|
||||
"noPersonas": "Доступных персонажей нет",
|
||||
"createPersona": "Создать персонажа",
|
||||
"cancelSelection": "Отмена",
|
||||
"confirmSelection": "ОК",
|
||||
"selectPersonaPool": "Выбрать пул персонажей...",
|
||||
"rootFolder": "Все персонажи",
|
||||
"emptyFolder": "Папка пуста"
|
||||
},
|
||||
"personaQuickPreview": {
|
||||
"title": "Быстрый просмотр",
|
||||
"loading": "Загрузка...",
|
||||
"noPersonaSelected": "Персонаж не выбран",
|
||||
"personaNotFound": "Информация о персонаже не найдена",
|
||||
"systemPromptLabel": "Системный промпт",
|
||||
"toolsLabel": "Инструменты",
|
||||
"skillsLabel": "Навыки (Skills)",
|
||||
"originLabel": "Источник",
|
||||
"originNameLabel": "Имя источника",
|
||||
"toolInactive": "Выключено",
|
||||
"toolInactiveTooltip": "Этот инструмент выключен. Включите его в Плагины -> Управление поведением -> Функции.",
|
||||
"allTools": "Доступны все инструменты",
|
||||
"allToolsWithCount": "Доступны все инструменты ({count})",
|
||||
"noTools": "Инструменты не настроены",
|
||||
"allSkills": "Доступны все навыки (Skills)",
|
||||
"allSkillsWithCount": "Доступны все навыки ({count})",
|
||||
"noSkills": "Навыки (Skills) не настроены"
|
||||
},
|
||||
"t2iTemplateEditor": {
|
||||
"buttonText": "Настроить T2I шаблон",
|
||||
"dialogTitle": "Настройка HTML шаблона Text-to-Image",
|
||||
"newTemplateNameLabel": "Введите имя нового шаблона",
|
||||
"nameRequired": "Имя обязательно для заполнения",
|
||||
"selectTemplateLabel": "Выбрать шаблон",
|
||||
"applied": "Применено",
|
||||
"apply": "Применить",
|
||||
"templateEditor": "Редактор шаблона",
|
||||
"new": "Создать",
|
||||
"resetBase": "Сбросить 'base'",
|
||||
"delete": "Удалить",
|
||||
"save": "Сохранить",
|
||||
"livePreview": "Предпросмотр (может отличаться)",
|
||||
"refreshPreview": "Обновить",
|
||||
"previewText": "Это пример текста для предпросмотра результата шаблона.\n\nОн может содержать несколько строк и различные форматы.",
|
||||
"syntaxHint": "Поддерживается синтаксис jinja2. Переменные: text | safe (текст для рендеринга), version (версия AstrBot)",
|
||||
"saveAndApply": "Сохранить и применить текущий шаблон",
|
||||
"confirmReset": "Подтверждение сброса",
|
||||
"confirmResetMessage": "Вы уверены, что хотите сбросить шаблон 'base' до значений по умолчанию? Все несохраненные изменения будут потеряны. Это действие необратимо.",
|
||||
"confirmResetButton": "Сбросить",
|
||||
"confirmDelete": "Подтверждение удаления",
|
||||
"confirmDeleteMessage": "Вы уверены, что хотите удалить шаблон '{name}'? Это действие необратимо.",
|
||||
"confirmDeleteButton": "Удалить",
|
||||
"confirmAction": "Подтверждение действия",
|
||||
"confirmApplyMessage": "Вы уверены, что хотите сохранить изменения в '{name}' и сделать его активным шаблоном?"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"loading": "Загрузка",
|
||||
"success": "Успешно",
|
||||
"error": "Ошибка",
|
||||
"warning": "Внимание",
|
||||
"info": "Информация",
|
||||
"pending": "В ожидании",
|
||||
"processing": "В процессе",
|
||||
"completed": "Завершено",
|
||||
"failed": "Ошибка",
|
||||
"cancelled": "Отменено",
|
||||
"timeout": "Тайм-аут",
|
||||
"connecting": "Подключение",
|
||||
"connected": "Подключено",
|
||||
"disconnected": "Отключено",
|
||||
"online": "В сети",
|
||||
"offline": "Не в сети",
|
||||
"active": "Активен",
|
||||
"inactive": "Неактивен",
|
||||
"ready": "Готов",
|
||||
"busy": "Занят"
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"hero": {
|
||||
"title": "AstrBot",
|
||||
"subtitle": "Проект, рожденный из интереса и любви ❤️",
|
||||
"starButton": "Star этот проект! 🌟",
|
||||
"issueButton": "Сообщить об ошибке"
|
||||
},
|
||||
"contributors": {
|
||||
"title": "Контрибьюторы",
|
||||
"description": "Этот проект поддерживается участниками open-source сообщества. Спасибо каждому за вклад!",
|
||||
"viewLink": "Посмотреть всех участников"
|
||||
},
|
||||
"stats": {
|
||||
"title": "Глобальное развертывание",
|
||||
"license": "AstrBot распространяется по лицензии AGPL v3"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
{
|
||||
"title": "Лаборатория Alkaid",
|
||||
"subtitle": "Исследуйте передовые возможности AI",
|
||||
"comingSoon": "Этот мир еще впереди, заходите позже!",
|
||||
"page": {
|
||||
"title": "Проект Alkaid.",
|
||||
"subtitle": "AstrBot Alpha Project",
|
||||
"navigation": {
|
||||
"knowledgeBase": "База знаний (Плагин)",
|
||||
"longTermMemory": "Долгосрочная память",
|
||||
"other": "..."
|
||||
}
|
||||
},
|
||||
"features": {
|
||||
"knowledgeBase": "База знаний",
|
||||
"longTermMemory": "Долгосрочная память",
|
||||
"advancedChat": "Продвинутый чат",
|
||||
"multiModal": "Мультимодальность"
|
||||
},
|
||||
"status": {
|
||||
"experimental": "Экспериментально",
|
||||
"beta": "Бета",
|
||||
"stable": "Стабильно",
|
||||
"deprecated": "Устарело"
|
||||
},
|
||||
"sigma": {
|
||||
"subtitle": "Экспериментальный проект AstrBot",
|
||||
"visualization": "Визуализация",
|
||||
"filterUserId": "Фильтр по User ID",
|
||||
"filter": "Фильтр",
|
||||
"resetFilter": "Сброс",
|
||||
"refreshGraph": "Обновить граф",
|
||||
"nodeDetails": "Детали узла",
|
||||
"id": "ID",
|
||||
"type": "Тип",
|
||||
"name": "Имя",
|
||||
"userId": "ID пользователя",
|
||||
"timestamp": "Метка времени",
|
||||
"graphStats": "Статистика графа",
|
||||
"nodeCount": "Узлов",
|
||||
"edgeCount": "Связей",
|
||||
"inDevelopment": "В разработке"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
{
|
||||
"title": "База знаний",
|
||||
"subtitle": "Управление контентом базы знаний и поиск",
|
||||
"documents": {
|
||||
"title": "Список документов",
|
||||
"name": "Имя файла",
|
||||
"size": "Размер",
|
||||
"uploadTime": "Дата загрузки",
|
||||
"status": "Статус",
|
||||
"actions": "Действия"
|
||||
},
|
||||
"management": {
|
||||
"delete": "Удалить",
|
||||
"preview": "Предпросмотр",
|
||||
"download": "Скачать",
|
||||
"reindex": "Переиндексировать"
|
||||
},
|
||||
"notInstalled": {
|
||||
"title": "Плагин базы знаний не установлен",
|
||||
"install": "Установить сейчас"
|
||||
},
|
||||
"empty": {
|
||||
"title": "База знаний пуста. Создайте свою первую базу! 🙂",
|
||||
"create": "Создать базу знаний"
|
||||
},
|
||||
"list": {
|
||||
"title": "Список баз знаний",
|
||||
"create": "Создать базу знаний",
|
||||
"config": "Настройка",
|
||||
"checkUpdate": "Проверить обновления плагина",
|
||||
"updatePlugin": "Обновить плагин до версии {version}",
|
||||
"knowledgeCount": "записей",
|
||||
"tips": "Совет: используйте команду /kb в чате, чтобы узнать, как пользоваться базой!"
|
||||
},
|
||||
"createDialog": {
|
||||
"title": "Создание базы знаний",
|
||||
"nameLabel": "Название",
|
||||
"descriptionLabel": "Описание",
|
||||
"descriptionPlaceholder": "Краткое описание...",
|
||||
"embeddingModelLabel": "Embedding модель",
|
||||
"rerankModelLabel": "Rerank модель",
|
||||
"providerInfo": "Провайдер: {id} | Размерность: {dimensions}",
|
||||
"rerankProviderInfo": "Провайдер: {id}",
|
||||
"tips": "Совет: после выбора Embedding модели не рекомендуется менять провайдера или размерность векторов, так как это сделает текущий индекс нечитаемым.",
|
||||
"cancel": "Отмена",
|
||||
"create": "Создать"
|
||||
},
|
||||
"emojiPicker": {
|
||||
"title": "Выберите иконку",
|
||||
"close": "Закрыть",
|
||||
"categories": {
|
||||
"emotions": "Смайлы",
|
||||
"animals": "Животные и природа",
|
||||
"food": "Еда и напитки",
|
||||
"activities": "Занятия и вещи",
|
||||
"travel": "Места и путешествия",
|
||||
"symbols": "Символы и флаги"
|
||||
}
|
||||
},
|
||||
"contentDialog": {
|
||||
"title": "Управление базой знаний",
|
||||
"embeddingModel": "Embedding модель",
|
||||
"vectorDimension": "Размерность",
|
||||
"usage": "Использование: введите «/kb use {name}» в чате",
|
||||
"tabs": {
|
||||
"upload": "Загрузка файлов",
|
||||
"search": "Поиск",
|
||||
"fromURL": "Импорт из URL"
|
||||
}
|
||||
},
|
||||
"upload": {
|
||||
"title": "Загрузка файлов",
|
||||
"subtitle": "Поддерживаются форматы txt, pdf, word, excel и др.",
|
||||
"dropzone": "Перетащите файлы сюда или нажмите для выбора",
|
||||
"chunkSettings": {
|
||||
"title": "Настройка фрагментации (Chunking)",
|
||||
"tooltip": "Размер фрагмента определяет объем текста в одном блоке. Перекрытие позволяет сохранить контекст между соседними блоками.\nМаленькие фрагменты точнее, но увеличивают объем базы.",
|
||||
"chunkSizeLabel": "Размер фрагмента",
|
||||
"chunkSizeHint": "Длина текста в одном блоке (пусто = по умолчанию)",
|
||||
"overlapLabel": "Перекрытие",
|
||||
"overlapHint": "Нахлест между соседними блоками (пусто = по умолчанию)"
|
||||
},
|
||||
"upload": "Начать загрузку",
|
||||
"uploading": "Загрузка..."
|
||||
},
|
||||
"search": {
|
||||
"queryLabel": "Поиск по базе знаний",
|
||||
"queryPlaceholder": "Введите ключевые слова...",
|
||||
"resultCountLabel": "Количество результатов",
|
||||
"searching": "Поиск...",
|
||||
"resultsTitle": "Результаты поиска",
|
||||
"relevance": "Релевантность",
|
||||
"noResults": "Совпадений не найдено"
|
||||
},
|
||||
"deleteDialog": {
|
||||
"title": "Подтверждение удаления",
|
||||
"confirmText": "Вы уверены, что хотите удалить базу знаний «{name}»?",
|
||||
"warning": "Это действие необратимо. Весь контент базы знаний будет навсегда удален.",
|
||||
"cancel": "Отмена",
|
||||
"delete": "Удалить"
|
||||
},
|
||||
"messages": {
|
||||
"pluginNotAvailable": "Плагин не установлен или недоступен",
|
||||
"pluginNotActivated": "Плагин astrbot_plugin_knowledge_base не включен. Пожалуйста, активируйте его в разделе плагинов и перезапустите AstrBot.",
|
||||
"checkPluginFailed": "Не удалось проверить плагин",
|
||||
"installFailed": "Ошибка установки",
|
||||
"installPluginFailed": "Не удалось установить плагин",
|
||||
"getKnowledgeBaseListFailed": "Ошибка получения списка баз знаний",
|
||||
"knowledgeBaseCreated": "База знаний создана",
|
||||
"createFailed": "Ошибка создания",
|
||||
"createKnowledgeBaseFailed": "Не удалось создать базу знаний",
|
||||
"pleaseEnterKnowledgeBaseName": "Укажите название базы знаний",
|
||||
"pleaseSelectFile": "Пожалуйста, сначала выберите файл",
|
||||
"operationSuccess": "Успешно: {message}",
|
||||
"uploadFailed": "Ошибка загрузки",
|
||||
"fileUploadFailed": "Не удалось загрузить файл",
|
||||
"pleaseEnterSearchContent": "Введите текст для поиска",
|
||||
"noMatchingContent": "Ничего не найдено",
|
||||
"searchFailed": "Ошибка поиска",
|
||||
"searchKnowledgeBaseFailed": "Не удалось выполнить поиск",
|
||||
"deleteTargetNotExists": "Объект для удаления не найден",
|
||||
"knowledgeBaseDeleted": "База знаний удалена",
|
||||
"deleteFailed": "Ошибка удаления",
|
||||
"deleteKnowledgeBaseFailed": "Не удалось удалить базу знаний",
|
||||
"getEmbeddingModelListFailed": "Не удалось загрузить список Embedding моделей",
|
||||
"updateAvailable": "Доступна новая версия: {current} -> {latest}",
|
||||
"pluginUpToDate": "У вас последняя версия плагина",
|
||||
"pluginNotFoundInMarket": "Плагин не найден в магазине",
|
||||
"checkUpdateFailed": "Ошибка проверки обновлений",
|
||||
"updateSuccess": "Плагин успешно обновлен",
|
||||
"updateFailed": "Ошибка обновления",
|
||||
"updatePluginFailed": "Не удалось обновить плагин"
|
||||
},
|
||||
"importFromUrl": {
|
||||
"title": "Импорт из URL",
|
||||
"urlLabel": "Адрес страницы",
|
||||
"urlPlaceholder": "Введите URL для извлечения знаний",
|
||||
"optionsTitle": "Настройки импорта",
|
||||
"tooltip": "Эти параметры управляют извлечением текста из URL.\nЕсли оставить пустыми, будут использованы настройки по умолчанию.\nТекстовая очистка через LLM может занять время.",
|
||||
"useLlmRepairLabel": "Исправление текста через LLM",
|
||||
"useClusteringSummaryLabel": "Кластеризация и суммаризация",
|
||||
"repairLlmProviderIdLabel": "Модель для очистки",
|
||||
"summarizeLlmProviderIdLabel": "Модель для суммаризации",
|
||||
"embeddingProviderIdLabel": "Embedding модель",
|
||||
"chunkSizeLabel": "Размер фрагмента",
|
||||
"chunkOverlapLabel": "Перекрытие",
|
||||
"startImport": "Начать импорт",
|
||||
"importing": "Импорт...",
|
||||
"importSuccess": "Импортировано успешно",
|
||||
"importFailed": "Ошибка импорта",
|
||||
"uploadingChunks": "Текст извлечен, загрузка фрагментов...",
|
||||
"preRequisite": "Примечание: сначала установите плагин astrbot_plugin_url_2_knowledge_base и выполните установку playwright согласно документации.",
|
||||
"allChunksUploaded": "Все фрагменты успешно загружены"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
{
|
||||
"title": "Долгосрочная память",
|
||||
"subtitle": "Управление памятью вашего AI-помощника",
|
||||
"memories": {
|
||||
"title": "Список воспоминаний",
|
||||
"content": "Содержание",
|
||||
"importance": "Важность",
|
||||
"createTime": "Дата создания",
|
||||
"lastAccess": "Последнее обращение",
|
||||
"category": "Категория"
|
||||
},
|
||||
"categories": {
|
||||
"personal": "Личное",
|
||||
"preferences": "Предпочтения",
|
||||
"conversations": "История диалогов",
|
||||
"facts": "Факты",
|
||||
"skills": "Навыки"
|
||||
},
|
||||
"importance": {
|
||||
"high": "Высокая",
|
||||
"medium": "Средняя",
|
||||
"low": "Низкая"
|
||||
},
|
||||
"actions": {
|
||||
"view": "Детали",
|
||||
"edit": "Изменить",
|
||||
"delete": "Удалить",
|
||||
"pin": "Закрепить",
|
||||
"unpin": "Открепить"
|
||||
},
|
||||
"filters": {
|
||||
"all": "Все",
|
||||
"category": "По категории",
|
||||
"importance": "По важности",
|
||||
"dateRange": "По периоду",
|
||||
"title": "Фильтр",
|
||||
"userIdLabel": "Фильтр по User ID",
|
||||
"filterButton": "Применить",
|
||||
"resetButton": "Сбросить",
|
||||
"refreshButton": "Обновить граф"
|
||||
},
|
||||
"search": {
|
||||
"title": "Поиск по памяти",
|
||||
"userIdLabel": "ID пользователя",
|
||||
"queryLabel": "Ключевое слово",
|
||||
"searchButton": "Поиск",
|
||||
"resultsTitle": "Результаты поиска",
|
||||
"noResults": "Ничего не найдено",
|
||||
"similarity": "Сходство",
|
||||
"noTextContent": "Нет текста"
|
||||
},
|
||||
"addMemory": {
|
||||
"title": "Добавить данные в память",
|
||||
"textLabel": "Текст воспоминания",
|
||||
"userIdLabel": "ID пользователя",
|
||||
"summarizeLabel": "Нужна суммаризация",
|
||||
"addButton": "Добавить"
|
||||
},
|
||||
"nodeDetails": {
|
||||
"title": "Детали узла",
|
||||
"id": "ID",
|
||||
"type": "Тип",
|
||||
"name": "Имя",
|
||||
"userId": "ID пользователя",
|
||||
"timestamp": "Метка времени"
|
||||
},
|
||||
"graphStats": {
|
||||
"title": "Статистика графа",
|
||||
"nodeCount": "Узлов",
|
||||
"edgeCount": "Связей"
|
||||
},
|
||||
"factDialog": {
|
||||
"title": "Факт из памяти",
|
||||
"id": "ID",
|
||||
"docId": "ID документа",
|
||||
"createdAt": "Создано",
|
||||
"updatedAt": "Обновлено",
|
||||
"metadata": "Метаданные",
|
||||
"metadataKey": "Ключ",
|
||||
"metadataValue": "Значение",
|
||||
"loading": "Загрузка...",
|
||||
"close": "Закрыть",
|
||||
"noValue": "нет",
|
||||
"unknown": "неизвестно"
|
||||
},
|
||||
"messages": {
|
||||
"searchQueryRequired": "Пожалуйста, введите запрос",
|
||||
"searchSuccess": "Найдено записей: {count}",
|
||||
"searchNoResults": "В памяти ничего не найдено",
|
||||
"searchError": "Ошибка поиска",
|
||||
"addSuccess": "Данные успешно добавлены в память!",
|
||||
"addError": "Не удалось добавить данные",
|
||||
"factDetailsError": "Ошибка загрузки деталей",
|
||||
"metadataParseError": "Не удалось разобрать метаданные",
|
||||
"relationNoMemoryData": "У этой связи нет ассоциированных данных"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"login": "Вход",
|
||||
"username": "Имя пользователя",
|
||||
"password": "Пароль",
|
||||
"defaultHint": "Логин и пароль по умолчанию: astrbot",
|
||||
"logo": {
|
||||
"title": "Панель управления AstrBot",
|
||||
"subtitle": "Добро пожаловать"
|
||||
},
|
||||
"theme": {
|
||||
"switchToDark": "Перейти на темную тему",
|
||||
"switchToLight": "Перейти на светлую тему"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"messageCount": "Количество сообщений",
|
||||
"time": "Время"
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
{
|
||||
"title": "Давай пообщаемся!",
|
||||
"subtitle": "Общение с AI-помощником",
|
||||
"input": {
|
||||
"placeholder": "Введите сообщение...",
|
||||
"send": "Отправить",
|
||||
"clear": "Очистить",
|
||||
"upload": "Загрузить файл",
|
||||
"voice": "Голосовой ввод",
|
||||
"recordingPrompt": "Запись... говорите",
|
||||
"chatPrompt": "Давай пообщаемся!",
|
||||
"dropToUpload": "Отпустите, чтобы загрузить файл",
|
||||
"stopGenerating": "Остановить генерацию"
|
||||
},
|
||||
"message": {
|
||||
"user": "Вы",
|
||||
"assistant": "Ассистент",
|
||||
"system": "Система",
|
||||
"error": "Ошибка в сообщении",
|
||||
"loading": "Думаю..."
|
||||
},
|
||||
"voice": {
|
||||
"start": "Начать запись",
|
||||
"stop": "Стоп",
|
||||
"recording": "Запись",
|
||||
"processing": "Обработка...",
|
||||
"error": "Ошибка записи",
|
||||
"listening": "Слушаю...",
|
||||
"speaking": "Говорю",
|
||||
"startRecording": "Начать голосовой ввод",
|
||||
"liveMode": "Общение в реальном времени"
|
||||
},
|
||||
"welcome": {
|
||||
"title": "Добро пожаловать в AstrBot",
|
||||
"subtitle": "Ваш умный помощник",
|
||||
"quickActions": "Быстрые действия",
|
||||
"examples": "Примеры вопросов"
|
||||
},
|
||||
"actions": {
|
||||
"copy": "Копировать",
|
||||
"regenerate": "Перегенерировать",
|
||||
"like": "Нравится",
|
||||
"dislike": "Не нравится",
|
||||
"share": "Поделиться",
|
||||
"newChat": "Новый чат",
|
||||
"deleteChat": "Удалить чат",
|
||||
"editTitle": "Изменить заголовок",
|
||||
"fullscreen": "На весь экран",
|
||||
"exitFullscreen": "Выход из полноэкранного режима",
|
||||
"reply": "Ответить",
|
||||
"providerConfig": "Настройки AI",
|
||||
"toolsUsed": "Использованные инструменты",
|
||||
"toolCallUsed": "Использован инструмент {name}",
|
||||
"pythonCodeAnalysis": "Использован анализ кода Python"
|
||||
},
|
||||
"ipython": {
|
||||
"output": "Вывод"
|
||||
},
|
||||
"conversation": {
|
||||
"newConversation": "Новый чат",
|
||||
"noHistory": "История диалогов пуста",
|
||||
"systemStatus": "Статус системы",
|
||||
"llmService": "Сервис LLM",
|
||||
"speechToText": "Преобразование речи",
|
||||
"editDisplayName": "Изменить имя чата",
|
||||
"displayName": "Имя чата",
|
||||
"displayNameUpdated": "Имя чата обновлено",
|
||||
"displayNameUpdateFailed": "Не удалось обновить имя чата",
|
||||
"confirmDelete": "Вы уверены, что хотите удалить «{name}»? Это действие необратимо."
|
||||
},
|
||||
"modes": {
|
||||
"darkMode": "Темная тема",
|
||||
"lightMode": "Светлая тема"
|
||||
},
|
||||
"shortcuts": {
|
||||
"help": "Справка",
|
||||
"voiceRecord": "Запись голоса",
|
||||
"pasteImage": "Вставить изображение",
|
||||
"sendKey": {
|
||||
"title": "Клавиша отправки",
|
||||
"enterToSend": "Enter для отправки",
|
||||
"shiftEnterToSend": "Shift+Enter для отправки"
|
||||
}
|
||||
},
|
||||
"streaming": {
|
||||
"enabled": "Потоковый ответ включен",
|
||||
"disabled": "Потоковый ответ выключен",
|
||||
"on": "Поток",
|
||||
"off": "Обычный"
|
||||
},
|
||||
"transport": {
|
||||
"title": "Протокол передачи",
|
||||
"sse": "SSE",
|
||||
"websocket": "WebSocket"
|
||||
},
|
||||
"config": {
|
||||
"title": "Конфигурация"
|
||||
},
|
||||
"reasoning": {
|
||||
"thinking": "Рассуждение"
|
||||
},
|
||||
"reply": {
|
||||
"replyTo": "В ответ на",
|
||||
"notFound": "Сообщение не найдено"
|
||||
},
|
||||
"project": {
|
||||
"title": "Проект",
|
||||
"create": "Создать проект",
|
||||
"edit": "Изменить проект",
|
||||
"name": "Имя проекта",
|
||||
"emoji": "Иконка (Emoji)",
|
||||
"description": "Описание проекта (опционально)",
|
||||
"noSessions": "В этом проекте пока нет диалогов",
|
||||
"confirmDelete": "Вы уверены, что хотите удалить проект «{title}»? Диалоги внутри проекта не будут удалены."
|
||||
},
|
||||
"time": {
|
||||
"today": "Сегодня",
|
||||
"yesterday": "Вчера"
|
||||
},
|
||||
"stats": {
|
||||
"tokens": "Токены",
|
||||
"inputTokens": "Входящие",
|
||||
"outputTokens": "Исходящие",
|
||||
"cachedTokens": "Кэшированные",
|
||||
"duration": "Время",
|
||||
"ttft": "Время до первого токена"
|
||||
},
|
||||
"refs": {
|
||||
"title": "Ссылки",
|
||||
"sources": "Источники"
|
||||
},
|
||||
"connection": {
|
||||
"title": "Статус подключения",
|
||||
"message": "Системе необходимо переустановить соединение с чатом.",
|
||||
"reasons": "Это может быть вызвано следующими причинами:",
|
||||
"reasonWindowResize": "Изменение размера окна (нормально)",
|
||||
"reasonMultipleTabs": "Страница чата открыта в другой вкладке",
|
||||
"reasonNetworkIssue": "Временная проблема с сетью",
|
||||
"notice": "Примечание: для стабильной работы допускается только одно активное соединение. Если вы используете чат в нескольких вкладках, рекомендуем оставить только одну.",
|
||||
"understand": "Понятно",
|
||||
"status": {
|
||||
"reconnecting": "Переподключение...",
|
||||
"reconnected": "Соединение восстановлено",
|
||||
"failed": "Ошибка подключения, обновите страницу"
|
||||
}
|
||||
},
|
||||
"errors": {
|
||||
"sendMessageFailed": "Ошибка отправки сообщения, попробуйте еще раз",
|
||||
"createSessionFailed": "Ошибка создания сессии, обновите страницу"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
{
|
||||
"title": "Управление командами",
|
||||
"summary": {
|
||||
"total": "Всего команд",
|
||||
"disabled": "Отключено",
|
||||
"conflicts": "Конфликты"
|
||||
},
|
||||
"conflictAlert": {
|
||||
"title": "Обнаружены конфликты команд",
|
||||
"description": "Сейчас конфликтуют {count} пары команд. Это может привести к одновременному срабатыванию нескольких плагинов и непредсказуемому поведению.",
|
||||
"hint": "Нажмите «Переименовать», чтобы изменить название конфликтующей команды."
|
||||
},
|
||||
"table": {
|
||||
"headers": {
|
||||
"command": "Команда",
|
||||
"type": "Тип",
|
||||
"plugin": "Плагин",
|
||||
"description": "Описание",
|
||||
"permission": "Доступ",
|
||||
"status": "Статус",
|
||||
"actions": "Действия"
|
||||
}
|
||||
},
|
||||
"type": {
|
||||
"command": "Команда",
|
||||
"group": "Группа команд",
|
||||
"subCommand": "Под-команда"
|
||||
},
|
||||
"status": {
|
||||
"enabled": "Активна",
|
||||
"disabled": "Отключена",
|
||||
"conflict": "Конфликт"
|
||||
},
|
||||
"permission": {
|
||||
"everyone": "Все",
|
||||
"admin": "Админ"
|
||||
},
|
||||
"tooltips": {
|
||||
"enable": "Включить",
|
||||
"disable": "Выключить",
|
||||
"rename": "Переименовать",
|
||||
"viewDetails": "Подробности"
|
||||
},
|
||||
"dialogs": {
|
||||
"rename": {
|
||||
"title": "Переименование команды",
|
||||
"newName": "Новое название",
|
||||
"aliases": "Управление алиасами",
|
||||
"addAlias": "Добавить алиас",
|
||||
"cancel": "Отмена",
|
||||
"confirm": "Подтвердить"
|
||||
},
|
||||
"details": {
|
||||
"title": "Детали команды",
|
||||
"type": "Тип команды",
|
||||
"handler": "Обработчик (Handler)",
|
||||
"module": "Путь к модулю",
|
||||
"originalCommand": "Исходная команда",
|
||||
"effectiveCommand": "Действующая команда",
|
||||
"parentGroup": "Родительская группа",
|
||||
"subCommands": "Под-команды",
|
||||
"aliases": "Алиасы (Синонимы)",
|
||||
"permission": "Требования прав",
|
||||
"conflictStatus": "Статус конфликта"
|
||||
}
|
||||
},
|
||||
"messages": {
|
||||
"toggleSuccess": "Статус команды обновлен",
|
||||
"toggleFailed": "Не удалось изменить статус команды",
|
||||
"renameSuccess": "Команда переименована",
|
||||
"renameFailed": "Ошибка переименования",
|
||||
"loadFailed": "Ошибка загрузки списка команд",
|
||||
"updateSuccess": "Обновлено успешно",
|
||||
"updateFailed": "Ошибка обновления"
|
||||
},
|
||||
"search": {
|
||||
"placeholder": "Поиск команд..."
|
||||
},
|
||||
"empty": {
|
||||
"noCommands": "Команд не найдено",
|
||||
"noCommandsDesc": "По вашему запросу не найдено ни одной команды"
|
||||
},
|
||||
"filters": {
|
||||
"all": "Все",
|
||||
"enabled": "Активные",
|
||||
"disabled": "Отключенные",
|
||||
"conflict": "Конфликтующие",
|
||||
"byPlugin": "По плагину",
|
||||
"byType": "По типу",
|
||||
"byPermission": "По правам",
|
||||
"byStatus": "По статусу",
|
||||
"showSystemPlugins": "Показывать системные плагины",
|
||||
"systemPluginConflictHint": "Конфликт затрагивает системный плагин, его нельзя скрыть до разрешения конфликта"
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,129 @@
|
||||
{
|
||||
"title": "Конфигурация",
|
||||
"subtitle": "Управление системными настройками",
|
||||
"editor": {
|
||||
"visual": "Визуальный редактор",
|
||||
"code": "Редактор кода",
|
||||
"revertCode": "Отменить изменения",
|
||||
"applyConfig": "Применить",
|
||||
"applyTip": "Кнопка «Применить» временно фиксирует изменения в визуальном редакторе. Чтобы сохранить их на постоянной основе, нажмите кнопку «Сохранить» в правом нижнем углу."
|
||||
},
|
||||
"actions": {
|
||||
"save": "Сохранить",
|
||||
"delete": "Удалить",
|
||||
"add": "Добавить",
|
||||
"reset": "Сбросить настройки",
|
||||
"export": "Экспорт",
|
||||
"import": "Импорт",
|
||||
"validate": "Проверить"
|
||||
},
|
||||
"help": {
|
||||
"documentation": "Документация",
|
||||
"support": "Поддержка",
|
||||
"helpText": "Нужна помощь? См. {documentation} или обратитесь в {support}.",
|
||||
"helpPrefix": "Нужна помощь? См.",
|
||||
"helpMiddle": "или обратитесь в",
|
||||
"helpSuffix": "."
|
||||
},
|
||||
"messages": {
|
||||
"configApplied": "Настройки применены образно. Нажмите «Сохранить» для окончательной записи.",
|
||||
"configApplyError": "Ошибка применения: некорректный формат JSON.",
|
||||
"unsavedChangesNotice": "Есть несохраненные изменения. Пожалуйста, нажмите «Сохранить», чтобы они вступили в силу.",
|
||||
"saveSuccess": "Настройки успешно сохранены",
|
||||
"saveError": "Ошибка при сохранении",
|
||||
"loadError": "Ошибка при загрузке настроек",
|
||||
"deleteSuccess": "Удалено",
|
||||
"deleteError": "Ошибка удаления",
|
||||
"updateSuccess": "Обновлено",
|
||||
"updateError": "Ошибка обновления"
|
||||
},
|
||||
"sections": {
|
||||
"general": "Основные",
|
||||
"advanced": "Расширенные",
|
||||
"security": "Безопасность",
|
||||
"appearance": "Внешний вид",
|
||||
"notification": "Уведомления"
|
||||
},
|
||||
"general": {
|
||||
"botName": "Имя бота",
|
||||
"language": "Язык интерфейса",
|
||||
"timezone": "Часовой пояс",
|
||||
"autoSave": "Автосохранение",
|
||||
"debugMode": "Режим отладки"
|
||||
},
|
||||
"advanced": {
|
||||
"logLevel": "Уровень логирования",
|
||||
"maxConnections": "Макс. соединений",
|
||||
"timeout": "Тайм-аут",
|
||||
"retryAttempts": "Попытки повтора",
|
||||
"cacheSize": "Размер кэша"
|
||||
},
|
||||
"security": {
|
||||
"apiKey": "Ключ API",
|
||||
"allowedHosts": "Разрешенные хосты",
|
||||
"rateLimit": "Лимит запросов",
|
||||
"encryption": "Шифрование"
|
||||
},
|
||||
"configSelection": {
|
||||
"selectConfig": "Выбор конфигурации",
|
||||
"normalConfig": "Обычная",
|
||||
"systemConfig": "Системная"
|
||||
},
|
||||
"search": {
|
||||
"placeholder": "Поиск по настройкам (поле/описание/подсказка)",
|
||||
"noResult": "Совпадений не найдено"
|
||||
},
|
||||
"configManagement": {
|
||||
"title": "Управление конфигурациями",
|
||||
"description": "AstrBot поддерживает несколько конфигураций для разных ботов. По умолчанию используется «default».",
|
||||
"newConfig": "Новая конфигурация",
|
||||
"editConfig": "Изменить конфигурацию",
|
||||
"manageConfigs": "Управление файлами...",
|
||||
"configName": "Имя",
|
||||
"fillConfigName": "Введите имя конфигурации",
|
||||
"confirmDelete": "Вы уверены, что хотите удалить конфигурацию «{name}»? Это действие необратимо.",
|
||||
"pleaseEnterName": "Пожалуйста, введите имя",
|
||||
"createFailed": "Ошибка создания конфигурации",
|
||||
"deleteFailed": "Ошибка удаления",
|
||||
"updateFailed": "Ошибка обновления"
|
||||
},
|
||||
"buttons": {
|
||||
"cancel": "Отмена",
|
||||
"create": "Создать",
|
||||
"update": "Обновить"
|
||||
},
|
||||
"codeEditor": {
|
||||
"title": "Редактирование файла"
|
||||
},
|
||||
"fileUpload": {
|
||||
"button": "Файлы",
|
||||
"dialogTitle": "Загруженные файлы",
|
||||
"dropzone": "Загрузить файлы",
|
||||
"allowedTypes": "Разрешенные типы: {types}",
|
||||
"empty": "Файлов нет",
|
||||
"statusMissing": "Файл отсутствует",
|
||||
"statusUnconfigured": "Не в конфиге",
|
||||
"uploadSuccess": "Загружено файлов: {count}",
|
||||
"uploadFailed": "Ошибка загрузки",
|
||||
"loadFailed": "Ошибка получения списка файлов",
|
||||
"fileTooLarge": "Файл слишком велик (макс. {max} МБ): {name}",
|
||||
"deleteSuccess": "Файл удален",
|
||||
"deleteFailed": "Ошибка удаления",
|
||||
"addToConfig": "Добавлено в конфигурацию",
|
||||
"fileCount": "Файлов: {count}",
|
||||
"done": "Готово"
|
||||
},
|
||||
"unsavedChangesWarning": {
|
||||
"dialogTitle": "Несохраненные изменения",
|
||||
"leavePage": "У вас есть несохраненные изменения. Сохранить перед уходом?",
|
||||
"switchConfig": "Переключение конфигурации приведет к потере несохраненных изменений. Сохранить?",
|
||||
"options": {
|
||||
"save": "Сохранить",
|
||||
"saveAndSwitch": "Сохранить и переключить",
|
||||
"discardAndSwitch": "Сбросить и переключить",
|
||||
"closeCard": "Закрыть",
|
||||
"confirm": "ОК",
|
||||
"cancel": "Отмена"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"title": "Логи платформы",
|
||||
"autoScroll": {
|
||||
"enabled": "Автопрокрутка включена",
|
||||
"disabled": "Автопрокрутка выключена"
|
||||
},
|
||||
"pipInstall": {
|
||||
"button": "Установить pip-пакет",
|
||||
"dialogTitle": "Установка Pip-пакета",
|
||||
"packageLabel": "*Имя пакета, например: llmtuner",
|
||||
"mirrorLabel": "Использовать зеркало PyPI (опционально)",
|
||||
"mirrorHint": "Приоритет зеркала PyPI > настройки «Зеркало репозитория PyPI»",
|
||||
"installButton": "Установить"
|
||||
},
|
||||
"debugHint": {
|
||||
"text": "Для отображения Debug-логов необходимо установить соответствующий уровень в «Конфигурация → Система → Уровень логирования»"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
{
|
||||
"title": "Управление диалогами",
|
||||
"subtitle": "Просмотр и управление историей сообщений",
|
||||
"filters": {
|
||||
"title": "Фильтры",
|
||||
"platform": "ID бота",
|
||||
"type": "Тип",
|
||||
"search": "Поиск по ключевым словам",
|
||||
"reset": "Сбросить"
|
||||
},
|
||||
"history": {
|
||||
"title": "История",
|
||||
"refresh": "Обновить"
|
||||
},
|
||||
"batch": {
|
||||
"deleteSelected": "Удалить выбранные ({count})",
|
||||
"exportSelected": "Экспорт выбранных ({count})"
|
||||
},
|
||||
"pagination": {
|
||||
"itemsPerPage": "на странице",
|
||||
"showingItems": "Показано {start}-{end} из {total}"
|
||||
},
|
||||
"table": {
|
||||
"headers": {
|
||||
"title": "Заголовок диалога",
|
||||
"platform": "ID бота",
|
||||
"type": "Тип сообщения",
|
||||
"cid": "ID диалога",
|
||||
"umo": "Источник сообщения",
|
||||
"sessionId": "ID сессии",
|
||||
"createdAt": "Создан",
|
||||
"updatedAt": "Обновлен",
|
||||
"actions": "Действия"
|
||||
}
|
||||
},
|
||||
"actions": {
|
||||
"view": "Просмотр",
|
||||
"edit": "Редактировать",
|
||||
"delete": "Удалить"
|
||||
},
|
||||
"messageTypes": {
|
||||
"group": "Группа",
|
||||
"friend": "ЛС",
|
||||
"unknown": "Неизвестно"
|
||||
},
|
||||
"status": {
|
||||
"noTitle": "Без заголовка",
|
||||
"unknown": "Неизвестно",
|
||||
"noData": "История диалогов пуста",
|
||||
"emptyContent": "Содержимое диалога пусто",
|
||||
"audioNotSupported": "Ваш браузер не поддерживает воспроизведение аудио."
|
||||
},
|
||||
"dialogs": {
|
||||
"view": {
|
||||
"title": "Детали диалога",
|
||||
"editMode": "Режим редактирования",
|
||||
"previewMode": "Режим просмотра",
|
||||
"saveChanges": "Сохранить изменения",
|
||||
"close": "Закрыть",
|
||||
"confirmClose": "У вас есть несохраненные изменения. Вы уверены, что хотите закрыть?"
|
||||
},
|
||||
"edit": {
|
||||
"title": "Изменить информацию",
|
||||
"titleLabel": "Заголовок диалога",
|
||||
"titlePlaceholder": "Введите заголовок",
|
||||
"cancel": "Отмена",
|
||||
"save": "Сохранить"
|
||||
},
|
||||
"delete": {
|
||||
"title": "Подтверждение удаления",
|
||||
"message": "Вы уверены, что хотите удалить диалог «{title}»? Это действие необратимо.",
|
||||
"cancel": "Отмена",
|
||||
"confirm": "Удалить"
|
||||
},
|
||||
"batchDelete": {
|
||||
"title": "Массовое удаление",
|
||||
"message": "Вы уверены, что хотите удалить {count} выбранных диалогов? Это действие необратимо!",
|
||||
"andMore": "и еще {count}",
|
||||
"cancel": "Отмена",
|
||||
"confirm": "Удалить всё",
|
||||
"warning": "Внимание: удаление нельзя будет отменить!"
|
||||
}
|
||||
},
|
||||
"messages": {
|
||||
"fetchError": "Не удалось загрузить список диалогов",
|
||||
"saveSuccess": "Сохранено",
|
||||
"saveError": "Ошибка сохранения",
|
||||
"deleteSuccess": "Удалено",
|
||||
"deleteError": "Ошибка удаления",
|
||||
"historyError": "Не удалось загрузить историю диалога",
|
||||
"historySaveSuccess": "История сохранена",
|
||||
"historySaveError": "Ошибка сохранения истории",
|
||||
"invalidJson": "Некорректный формат JSON",
|
||||
"noItemSelected": "Сначала выберите диалоги для удаления",
|
||||
"batchDeleteSuccess": "Успешно удалено {count} диалогов",
|
||||
"batchDeleteError": "Ошибка массового удаления",
|
||||
"batchDeletePartial": "Удаление завершено: успешно {deleted}, ошибок {failed}",
|
||||
"exportSuccess": "Экспорт завершен",
|
||||
"exportError": "Ошибка экспорта",
|
||||
"noItemSelectedForExport": "Сначала выберите диалоги для экспорта"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
{
|
||||
"page": {
|
||||
"title": "Запланированные задачи",
|
||||
"beta": "Экспериментальные функции",
|
||||
"subtitle": "Управление будущими задачами AstrBot. Бот автоматически проснется, выполнит задачу и отправит результат. Требуется включить «Проактивные способности» в конфигурации.",
|
||||
"proactive": {
|
||||
"supported": "Отправка результатов поддерживается на платформах: {platforms}",
|
||||
"unsupported": "Нет платформ, поддерживающих проактивные сообщения. Включите их в настройках платформ."
|
||||
}
|
||||
},
|
||||
"actions": {
|
||||
"create": "Новая задача",
|
||||
"refresh": "Обновить",
|
||||
"delete": "Удалить",
|
||||
"cancel": "Отмена",
|
||||
"submit": "Создать"
|
||||
},
|
||||
"table": {
|
||||
"title": "Список задач",
|
||||
"empty": "Задач пока нет.",
|
||||
"headers": {
|
||||
"name": "Имя",
|
||||
"type": "Тип",
|
||||
"cron": "Cron",
|
||||
"session": "ID сессии",
|
||||
"nextRun": "Следующий запуск",
|
||||
"lastRun": "Последний запуск",
|
||||
"note": "Описание",
|
||||
"actions": "Действия"
|
||||
},
|
||||
"type": {
|
||||
"once": "Разовая",
|
||||
"recurring": "Повторяющаяся",
|
||||
"activeAgent": "Активный агент",
|
||||
"workflow": "Рабочий процесс",
|
||||
"unknown": "{type}"
|
||||
},
|
||||
"timezoneLocal": "Местное время",
|
||||
"notAvailable": "—"
|
||||
},
|
||||
"form": {
|
||||
"title": "Создать задачу",
|
||||
"chatHint": "Вы можете ставить задачи прямо в чате, AstrBot создаст их автоматически без заполнения этой формы.",
|
||||
"runOnce": "Разовая задача",
|
||||
"name": "Имя задачи",
|
||||
"note": "Описание",
|
||||
"cron": "Cron-выражения",
|
||||
"cronPlaceholder": "0 9 * * *",
|
||||
"runAt": "Время запуска",
|
||||
"session": "Целевая сессия (platform_id:message_type:session_id)",
|
||||
"timezone": "Часовой пояс (опционально, напр. Europe/Moscow)",
|
||||
"enabled": "Включено"
|
||||
},
|
||||
"messages": {
|
||||
"loadFailed": "Ошибка загрузки задач",
|
||||
"updateFailed": "Ошибка обновления",
|
||||
"deleteSuccess": "Удалено",
|
||||
"deleteFailed": "Ошибка удаления",
|
||||
"sessionRequired": "Укажите сессию",
|
||||
"noteRequired": "Заполните описание",
|
||||
"cronRequired": "Укажите Cron-выражение",
|
||||
"runAtRequired": "Выберите время запуска",
|
||||
"createSuccess": "Задача создана",
|
||||
"createFailed": "Ошибка создания"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
{
|
||||
"title": "Логи платформы",
|
||||
"subtitle": "Мониторинг и статистика в реальном времени",
|
||||
"lastUpdate": "Последнее обновление",
|
||||
"status": {
|
||||
"loading": "Загрузка...",
|
||||
"dataError": "Ошибка получения данных",
|
||||
"noticeError": "Ошибка получения объявлений",
|
||||
"online": "В сети",
|
||||
"uptime": "Время работы",
|
||||
"memoryUsage": "Память"
|
||||
},
|
||||
"stats": {
|
||||
"totalMessage": {
|
||||
"title": "Всего сообщений",
|
||||
"subtitle": "Все сообщения со всех платформ"
|
||||
},
|
||||
"onlinePlatform": {
|
||||
"title": "Платформы",
|
||||
"subtitle": "Количество подключенных платформ"
|
||||
},
|
||||
"runningTime": {
|
||||
"title": "Время работы",
|
||||
"subtitle": "Общее время работы системы",
|
||||
"format": "{hours} ч. {minutes} мин. {seconds} сек."
|
||||
},
|
||||
"memoryUsage": {
|
||||
"title": "Память",
|
||||
"subtitle": "Использование оперативной памяти",
|
||||
"cpuLoad": "Загрузка CPU",
|
||||
"status": {
|
||||
"good": "Отлично",
|
||||
"normal": "Нормально",
|
||||
"high": "Высокая"
|
||||
}
|
||||
}
|
||||
},
|
||||
"charts": {
|
||||
"messageTrend": {
|
||||
"title": "Тренды сообщений",
|
||||
"subtitle": "Изменение количества сообщений во времени",
|
||||
"totalMessages": "Всего сообщений",
|
||||
"dailyAverage": "В среднем за день",
|
||||
"growthRate": "Скорость роста",
|
||||
"timeLabel": "Время",
|
||||
"messageCount": "Кол-во сообщений",
|
||||
"timeRanges": {
|
||||
"1day": "За 1 день",
|
||||
"3days": "За 3 дня",
|
||||
"1week": "За 7 дней",
|
||||
"1month": "За 30 дней"
|
||||
}
|
||||
},
|
||||
"platformStat": {
|
||||
"title": "Статистика по платформам",
|
||||
"subtitle": "Распределение сообщений по платформам",
|
||||
"total": "Всего",
|
||||
"noData": "Нет данных по платформам",
|
||||
"messageUnit": "шт.",
|
||||
"platformCount": "Кол-во платформ",
|
||||
"mostActive": "Самый активный",
|
||||
"totalPercentage": "Доля от общего числа"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,358 @@
|
||||
{
|
||||
"title": "Плагины",
|
||||
"subtitle": "Управление и настройка расширений системы",
|
||||
"tabs": {
|
||||
"installedPlugins": "Плагины AstrBot",
|
||||
"market": "Магазин плагинов",
|
||||
"installedMcpServers": "MCP",
|
||||
"skills": "Навыки",
|
||||
"handlersOperation": "Управление поведением"
|
||||
},
|
||||
"titles": {
|
||||
"installedAstrBotPlugins": "Установленные плагины AstrBot"
|
||||
},
|
||||
"failedPlugins": {
|
||||
"title": "Ошибка загрузки ({count})",
|
||||
"hint": "Эти плагины не удалось загрузить. Вы можете попробовать перезагрузить их или удалить.",
|
||||
"columns": {
|
||||
"plugin": "Плагин",
|
||||
"error": "Ошибка"
|
||||
}
|
||||
},
|
||||
"search": {
|
||||
"placeholder": "Поиск плагинов...",
|
||||
"marketPlaceholder": "Поиск в магазине..."
|
||||
},
|
||||
"filters": {
|
||||
"all": "Все"
|
||||
},
|
||||
"views": {
|
||||
"card": "Плитка",
|
||||
"list": "Список"
|
||||
},
|
||||
"buttons": {
|
||||
"showSystemPlugins": "Показать системные",
|
||||
"hideSystemPlugins": "Скрыть системные",
|
||||
"install": "Установить",
|
||||
"uninstall": "Удалить",
|
||||
"update": "Обновить",
|
||||
"reload": "Перезагрузить",
|
||||
"enable": "Включить",
|
||||
"disable": "Выключить",
|
||||
"configure": "Настроить",
|
||||
"viewInfo": "Детали",
|
||||
"viewDocs": "Документация",
|
||||
"viewRepo": "Репозиторий",
|
||||
"close": "Закрыть",
|
||||
"save": "Сохранить",
|
||||
"saveAndClose": "Сохранить и закрыть",
|
||||
"cancel": "Отмена",
|
||||
"actions": "Действия",
|
||||
"back": "Назад",
|
||||
"selectFile": "Выбрать файл",
|
||||
"refresh": "Обновить",
|
||||
"updateAll": "Обновить все",
|
||||
"deleteSource": "Удалить источник",
|
||||
"reshuffle": "Мне повезет!"
|
||||
},
|
||||
"status": {
|
||||
"enabled": "Включен",
|
||||
"disabled": "Выключен",
|
||||
"system": "Системный",
|
||||
"loading": "Загрузка...",
|
||||
"installed": "Установлен",
|
||||
"unknown": "Неизвестно"
|
||||
},
|
||||
"tooltips": {
|
||||
"enable": "Включить",
|
||||
"disable": "Выключить",
|
||||
"reload": "Перезагрузить",
|
||||
"configure": "Настроить",
|
||||
"viewInfo": "Просмотр поведения",
|
||||
"viewDocs": "Документация",
|
||||
"update": "Обновить",
|
||||
"uninstall": "Удалить"
|
||||
},
|
||||
"table": {
|
||||
"headers": {
|
||||
"name": "Имя",
|
||||
"description": "Описание",
|
||||
"version": "Версия",
|
||||
"author": "Автор",
|
||||
"status": "Статус",
|
||||
"actions": "Действия",
|
||||
"stars": "Звезды",
|
||||
"lastUpdate": "Обновлен",
|
||||
"tags": "Теги",
|
||||
"eventType": "Тип события",
|
||||
"specificType": "Тип",
|
||||
"trigger": "Триггер"
|
||||
}
|
||||
},
|
||||
"empty": {
|
||||
"noPlugins": "Плагины не найдены",
|
||||
"noPluginsDesc": "Попробуйте установить новые плагины или включите отображение системных."
|
||||
},
|
||||
"market": {
|
||||
"recommended": "🥳 Рекомендуем",
|
||||
"allPlugins": "📦 Все плагины",
|
||||
"showFullName": "Полное имя",
|
||||
"devDocs": "Документация для разработчиков",
|
||||
"submitRepo": "Добавить репозиторий",
|
||||
"customSource": "Свои источники",
|
||||
"source": "Источник",
|
||||
"availableSources": "Доступные источники",
|
||||
"sourceManagement": "Управление источниками",
|
||||
"addSource": "Добавить источник",
|
||||
"sourceName": "Имя",
|
||||
"sourceUrl": "Исходный URL",
|
||||
"defaultSource": "Источник по умолчанию",
|
||||
"removeSource": "Удалить источник",
|
||||
"confirmRemoveSource": "Вы уверены, что хотите удалить этот источник плагинов?",
|
||||
"sourceAdded": "Источник успешно добавлен",
|
||||
"sourceRemoved": "Источник удален",
|
||||
"sourceError": "Ошибка операции",
|
||||
"selectSource": "Выбрать источник",
|
||||
"currentSource": "Текущий источник",
|
||||
"editSource": "Изменить источник",
|
||||
"sourceUpdated": "Источник обновлен",
|
||||
"defaultOfficialSource": "Официальный источник",
|
||||
"sourceExists": "Этот источник уже есть в списке",
|
||||
"installPlugin": "Установить плагин",
|
||||
"randomPlugins": "🎲 Случайные плагины",
|
||||
"showRandomPlugins": "Показать случайные",
|
||||
"hideRandomPlugins": "Скрыть случайные",
|
||||
"sourceSafetyWarning": "Даже при использовании источников по умолчанию мы не можем гарантировать 100% безопасность и стабильность сторонних плагинов. Пожалуйста, будьте внимательны."
|
||||
},
|
||||
"sort": {
|
||||
"by": "Сортировать по",
|
||||
"default": "По умолчанию",
|
||||
"installTime": "Дате установки",
|
||||
"name": "Имени",
|
||||
"stars": "Звездам",
|
||||
"author": "Автору",
|
||||
"updated": "Дате обновления",
|
||||
"updateStatus": "Статусу обновления",
|
||||
"ascending": "По возрастанию",
|
||||
"descending": "По убыванию"
|
||||
},
|
||||
"tags": {
|
||||
"danger": "Опасно"
|
||||
},
|
||||
"dialogs": {
|
||||
"error": {
|
||||
"title": "Ошибка",
|
||||
"checkConsole": "Подробности смотрите в логах платформы"
|
||||
},
|
||||
"config": {
|
||||
"title": "Настройка плагина",
|
||||
"noConfig": "У этого плагина нет настраиваемых параметров"
|
||||
},
|
||||
"loading": {
|
||||
"title": "Загрузка...",
|
||||
"logs": "Логи"
|
||||
},
|
||||
"uninstall": {
|
||||
"title": "Подтверждение удаления",
|
||||
"message": "Вы уверены, что хотите удалить этот плагин?",
|
||||
"deleteConfig": "Удалить файл конфигурации плагина",
|
||||
"deleteData": "Удалить сохраненные данные плагина",
|
||||
"configHint": "Конфиг находится в data/config",
|
||||
"dataHint": "Данные находятся в data/plugin_data и data/plugins_data"
|
||||
},
|
||||
"install": {
|
||||
"title": "Установка плагина",
|
||||
"fromFile": "Из файла",
|
||||
"fromUrl": "По ссылке",
|
||||
"supportPlatformsCount": "Поддерживает платформ: {count}"
|
||||
},
|
||||
"danger_warning": {
|
||||
"title": "Внимание!",
|
||||
"message": "Этот плагин может содержать небезопасный код или функции, которые могут привести к нестабильности системы или потере данных. Вы уверены, что хотите продолжить установку?",
|
||||
"confirm": "Продолжить",
|
||||
"cancel": "Отмена"
|
||||
},
|
||||
"versionCompatibility": {
|
||||
"title": "Предупреждение о версии",
|
||||
"message": "Требуемая плагином версия AstrBot не совпадает с вашей текущей версией. Вы можете продолжить установку на свой страх и риск.",
|
||||
"confirm": "Игнорировать и установить",
|
||||
"cancel": "Отмена"
|
||||
},
|
||||
"forceUpdate": {
|
||||
"title": "Новых версий не найдено",
|
||||
"message": "Новых версий не обнаружено. Выполнить принудительную переустановку из удаленного репозитория?",
|
||||
"confirm": "Принудительно"
|
||||
},
|
||||
"updateAllConfirm": {
|
||||
"title": "Обновить всё",
|
||||
"message": "Обновить все плагины ({count} шт.)? Это может занять некоторое время.",
|
||||
"confirm": "Подтвердить"
|
||||
}
|
||||
},
|
||||
"messages": {
|
||||
"uninstalling": "Удаление",
|
||||
"refreshing": "Обновление списка плагинов...",
|
||||
"refreshSuccess": "Список плагинов обновлен",
|
||||
"refreshFailed": "Ошибка при обновлении списка",
|
||||
"operationFailed": "Ошибка операции",
|
||||
"reloadSuccess": "Перезагрузка завершена",
|
||||
"reloadFailed": "Ошибка перезагрузки",
|
||||
"updateSuccess": "Обновление завершено",
|
||||
"addSuccess": "Успешно добавлено",
|
||||
"saveSuccess": "Сохранено",
|
||||
"deleteSuccess": "Удалено",
|
||||
"installing": "Установка из файла...",
|
||||
"installingFromUrl": "Установка по ссылке...",
|
||||
"installFailed": "Ошибка установки:",
|
||||
"getMarketDataFailed": "Ошибка получения данных магазина:",
|
||||
"hasUpdate": "Доступно обновление:",
|
||||
"confirmDelete": "Вы уверены, что хотите удалить плагин?",
|
||||
"fillUrlOrFile": "Укажите ссылку или выберите файл",
|
||||
"dontFillBoth": "Пожалуйста, используйте либо ссылку, либо файл, но не оба сразу",
|
||||
"supportedFormats": "Поддерживаются файлы плагинов в формате .zip",
|
||||
"updateAllSuccess": "Все плагины успешно обновлены",
|
||||
"updateAllFailed": "Ошибок при обновлении: {failed} из {total}:",
|
||||
"fillSourceNameAndUrl": "Пожалуйста, введите имя и адрес источника",
|
||||
"invalidUrl": "Введите корректный URL",
|
||||
"enterJsonUrl": "Введите URL, возвращающий список плагинов в формате JSON"
|
||||
},
|
||||
"upload": {
|
||||
"fromFile": "Загрузить файл",
|
||||
"fromUrl": "Указать ссылку",
|
||||
"selectFile": "Выбрать файл",
|
||||
"enterUrl": "Ссылка на репозиторий"
|
||||
},
|
||||
"skills": {
|
||||
"modeLocal": "Локальные навыки",
|
||||
"modeNeo": "Навыки Neo",
|
||||
"actions": "Действия",
|
||||
"upload": "Загрузить навыки",
|
||||
"refresh": "Обновить",
|
||||
"empty": "Навыки не найдены",
|
||||
"emptyHint": "Пожалуйста, загрузите архив с навыками",
|
||||
"uploadDialogTitle": "Загрузка навыков",
|
||||
"uploadHint": "Поддерживается массовая загрузка zip-архивов. Вы также можете перетащить файлы в это окно. Система автоматически проверит структуру каждого архива.",
|
||||
"structureRequirement": "Архив должен содержать одну корневую папку (например, `skillname/`), внутри которой обязательно должен находиться файл `SKILL.md`.",
|
||||
"abilityMultiple": "Поддержка массовой загрузки",
|
||||
"abilityValidate": "Автопроверка `SKILL.md`",
|
||||
"abilitySkip": "Пропуск дубликатов",
|
||||
"selectFile": "Выбрать файл",
|
||||
"selectFiles": "Выбрать файлы",
|
||||
"dropzoneTitle": "Перетащите zip-файлы сюда",
|
||||
"dropzoneAction": "или нажмите, чтобы выбрать файлы на компьютере",
|
||||
"dropzoneHint": "Система проверит структуру архивов перед загрузкой",
|
||||
"fileListTitle": "Очередь загрузки",
|
||||
"fileListEmpty": "Здесь будет отображаться статус проверки и загрузки файлов",
|
||||
"uploading": "Загрузка...",
|
||||
"batchResultTitle": "Результаты загрузки",
|
||||
"batchResultSummary": "Всего: {total}, успешно: {success}",
|
||||
"batchSuccessList": "Успешно загружено",
|
||||
"batchFailedList": "Ошибка загрузки",
|
||||
"confirm": "ОК",
|
||||
"confirmUpload": "Начать загрузку",
|
||||
"cancel": "Отмена",
|
||||
"statusWaiting": "В очереди",
|
||||
"statusUploading": "Загрузка...",
|
||||
"statusSuccess": "Готово",
|
||||
"statusError": "Ошибка структуры",
|
||||
"statusSkipped": "Пропущено",
|
||||
"summaryTotal": "Всего: {count}",
|
||||
"summaryReady": "Готовы: {count}",
|
||||
"summarySuccess": "Успешно: {count}",
|
||||
"summaryFailed": "Ошибок: {count}",
|
||||
"summarySkipped": "Дубликатов: {count}",
|
||||
"validationReady": "Ожидает загрузки (проверка структуры будет выполнена автоматически)",
|
||||
"validationZipOnly": "Допускаются только zip-архивы",
|
||||
"validationDuplicate": "Файл уже есть в списке, пропуск",
|
||||
"validationUploading": "Проверка и загрузка...",
|
||||
"validationUploadFailed": "Ошибка загрузки, попробуйте еще раз",
|
||||
"validationUploadedAs": "Установлено как {name}",
|
||||
"validationNoResult": "Результат не получен, проверьте логи платформы",
|
||||
"noDescription": "Нет описания",
|
||||
"path": "Путь",
|
||||
"uploadSuccess": "Успешно загружено",
|
||||
"uploadFailed": "Ошибка загрузки",
|
||||
"download": "Скачать",
|
||||
"downloadSuccess": "Скачивание начато",
|
||||
"downloadFailed": "Ошибка скачивания",
|
||||
"loadFailed": "Не удалось загрузить навыки",
|
||||
"updateSuccess": "Обновлено",
|
||||
"updateFailed": "Ошибка обновления",
|
||||
"deleteTitle": "Подтверждение удаления",
|
||||
"deleteMessage": "Вы уверены, что хотите удалить этот навык?",
|
||||
"deleteSuccess": "Удалено",
|
||||
"deleteFailed": "Ошибка удаления",
|
||||
"neoSkillKey": "Фильтр по ключу",
|
||||
"neoStatus": "Статус кандидата",
|
||||
"neoStage": "Этап публикации",
|
||||
"neoFilterHint": "Фильтрация записей о публикации",
|
||||
"neoAll": "Все",
|
||||
"neoCandidates": "Кандидаты Neo",
|
||||
"neoReleases": "Релизы Neo",
|
||||
"neoLoadFailed": "Ошибка загрузки данных Neo Skills",
|
||||
"neoPass": "Одобрить",
|
||||
"neoReject": "Отклонить",
|
||||
"neoEvaluateSuccess": "Оценка обновлена",
|
||||
"neoEvaluateFailed": "Ошибка обновления оценки",
|
||||
"neoPromoteSuccess": "Опубликовано",
|
||||
"neoPromoteFailed": "Ошибка публикации",
|
||||
"neoRollback": "Откат",
|
||||
"neoRollbackSuccess": "Откат выполнен",
|
||||
"neoRollbackFailed": "Ошибка отката",
|
||||
"neoDeactivate": "Деактивация",
|
||||
"neoDeactivateSuccess": "Деактивировано",
|
||||
"neoDeactivateFailed": "Ошибка деактивации",
|
||||
"neoSync": "Синхронизация",
|
||||
"neoSyncSuccess": "Синхронизировано",
|
||||
"neoSyncFailed": "Ошибка синхронизации",
|
||||
"neoDelete": "Удалить",
|
||||
"neoDeleteSuccess": "Удалено",
|
||||
"neoDeleteFailed": "Ошибка удаления",
|
||||
"neoPayloadTitle": "Детали Neo Payload",
|
||||
"neoPayloadFailed": "Ошибка чтения Payload",
|
||||
"runtimeNoneWarning": "Среда выполнения Computer Use не задана. Навыки могут не работать, так как нет активного окружения.",
|
||||
"runtimeHint": "Установите среду выполнения в «local» или «sandbox» в настройках способностей использования компьютера.",
|
||||
"neoRuntimeRequired": "Neo Skills доступны только в среде sandbox с драйвером shipyard_neo.",
|
||||
"sourceLocalOnly": "Локальный навык",
|
||||
"sourceSandboxOnly": "Предустановленный Sandbox навык",
|
||||
"sourceBoth": "Локальный + Sandbox",
|
||||
"sandboxDiscoveryPending": "Предустановленные Sandbox навыки не найдены. Запустите сессию Sandbox хотя бы один раз.",
|
||||
"sandboxPresetReadonly": "Предустановленные навыки Sandbox доступны только для чтения и не могут быть удалены здесь."
|
||||
},
|
||||
"card": {
|
||||
"actions": {
|
||||
"pluginConfig": "Настройки",
|
||||
"uninstallPlugin": "Удалить",
|
||||
"reloadPlugin": "Перезагрузить",
|
||||
"togglePlugin": "Плагин",
|
||||
"viewHandlers": "Действия",
|
||||
"updateTo": "Обновить до",
|
||||
"reinstall": "Переустановить"
|
||||
},
|
||||
"status": {
|
||||
"hasUpdate": "Доступно обновление",
|
||||
"disabled": "Плагин выключен",
|
||||
"handlersCount": "действий",
|
||||
"supportPlatform": "Платформы",
|
||||
"supportPlatformsCount": "Платформ: {count}",
|
||||
"astrbotVersion": "Требуемая версия AstrBot"
|
||||
},
|
||||
"alt": {
|
||||
"logo": "логотип",
|
||||
"extensionIcon": "иконка расширения"
|
||||
},
|
||||
"errors": {
|
||||
"confirmNotRegistered": "$confirm не зарегистрирован"
|
||||
}
|
||||
},
|
||||
"conflicts": {
|
||||
"title": "Конфликт команд",
|
||||
"message": "Обнаружены конфликтующие команды. Это может привести к некорректной работе. Рекомендуется разрешить конфликты в панели «Управление командами».",
|
||||
"pairs": "конфликтующих пар",
|
||||
"goToManage": "Управление",
|
||||
"later": "Позже"
|
||||
},
|
||||
"pluginChangelog": {
|
||||
"menuTitle": "Журнал изменений"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
{
|
||||
"title": "Детали базы знаний",
|
||||
"backToList": "К списку",
|
||||
"tabs": {
|
||||
"overview": "Обзор",
|
||||
"documents": "Документы",
|
||||
"retrieval": "Поиск",
|
||||
"sessions": "Сессии",
|
||||
"settings": "Настройки"
|
||||
},
|
||||
"overview": {
|
||||
"title": "Информация",
|
||||
"name": "Название",
|
||||
"description": "Описание",
|
||||
"emoji": "Иконка",
|
||||
"createdAt": "Создана",
|
||||
"updatedAt": "Обновлена",
|
||||
"stats": "Статистика",
|
||||
"docCount": "Количество документов",
|
||||
"chunkCount": "Количество фрагментов",
|
||||
"embeddingModel": "Embedding модель",
|
||||
"rerankModel": "Rerank модель",
|
||||
"notSet": "не выбрано"
|
||||
},
|
||||
"documents": {
|
||||
"title": "Список документов",
|
||||
"upload": "Загрузить",
|
||||
"empty": "Документов нет",
|
||||
"name": "Имя файла",
|
||||
"type": "Тип",
|
||||
"size": "Размер",
|
||||
"chunks": "Фрагменты",
|
||||
"createdAt": "Дата загрузки",
|
||||
"actions": "Действия",
|
||||
"view": "Смотреть",
|
||||
"delete": "Удалить",
|
||||
"deleteConfirm": "Вы уверены, что хотите удалить «{name}»?",
|
||||
"deleteWarning": "Это удалит файл и все его фрагменты из индекса.",
|
||||
"uploading": "Загрузка...",
|
||||
"uploadSuccess": "Файл успешно загружен",
|
||||
"uploadFailed": "Ошибка загрузки",
|
||||
"deleteSuccess": "Файл удален",
|
||||
"deleteFailed": "Ошибка удаления"
|
||||
},
|
||||
"upload": {
|
||||
"title": "Добавление контента",
|
||||
"selectFile": "Файл",
|
||||
"dropzone": "Нажмите или перетащите файл сюда",
|
||||
"supportedFormats": "Форматы: ",
|
||||
"maxSize": "Максимум: 128MB",
|
||||
"chunkSettings": "Фрагментация",
|
||||
"batchSettings": "Пакетная обработка",
|
||||
"cleaningSettings": "Очистка данных",
|
||||
"enableCleaning": "Включить очистку контента",
|
||||
"cleaningProvider": "Сервис для очистки",
|
||||
"cleaningProviderHint": "LLM провайдер для суммаризации и извлечения смыслов из веб-страниц",
|
||||
"chunkSize": "Размер чанка",
|
||||
"chunkSizeHint": "Символов в блоке (по умолчанию: 512)",
|
||||
"chunkOverlap": "Перекрытие",
|
||||
"chunkOverlapHint": "Перекрытие между блоками (по умолчанию: 50)",
|
||||
"batchSize": "Размер пакета",
|
||||
"batchSizeHint": "Блоков за один запрос (по умолчанию: 32)",
|
||||
"tasksLimit": "Лимит задач",
|
||||
"tasksLimitHint": "Макс. параллельных потоков (по умолчанию: 3)",
|
||||
"maxRetries": "Попытки",
|
||||
"maxRetriesHint": "Повторов при сбое (по умолчанию: 3)",
|
||||
"cancel": "Отмена",
|
||||
"submit": "Загрузить",
|
||||
"fileRequired": "Пожалуйста, выберите файл",
|
||||
"fileUpload": "Загрузка файла",
|
||||
"fromUrl": "Из URL",
|
||||
"urlPlaceholder": "Ссылка на веб-страницу",
|
||||
"urlRequired": "Введите URL",
|
||||
"urlHint": "Контент будет автоматически извлечен со страницы. Убедитесь, что сайт разрешает доступ роботам.",
|
||||
"beta": "Бета-версия"
|
||||
},
|
||||
"retrieval": {
|
||||
"title": "Поиск и проверка",
|
||||
"subtitle": "Проверьте качество поиска (Dense & Sparse) по вашей базе знаний",
|
||||
"query": "Тестовый запрос",
|
||||
"queryPlaceholder": "Что вы хотите найти?",
|
||||
"search": "Найти",
|
||||
"searching": "Ищем...",
|
||||
"results": "Результаты поиска",
|
||||
"noResults": "Релевантный контент не найден",
|
||||
"tryDifferentQuery": "Попробуйте изменить формулировку запроса",
|
||||
"settings": "Параметры поиска",
|
||||
"topK": "Количество результатов",
|
||||
"topKHint": "Сколько фрагментов возвращать",
|
||||
"enableRerank": "Включить Rerank",
|
||||
"enableRerankHint": "Применить переранжирование для повышения точности",
|
||||
"score": "Вес (Score)",
|
||||
"document": "Документ",
|
||||
"chunk": "Фрагмент #{index}",
|
||||
"content": "Текст",
|
||||
"charCount": "{count} симв.",
|
||||
"searchSuccess": "Поиск завершен, найдено: {count}",
|
||||
"searchFailed": "Ошибка выполнения поиска",
|
||||
"queryRequired": "Введите поисковый запрос"
|
||||
},
|
||||
"settings": {
|
||||
"title": "Общие настройки базы",
|
||||
"basic": "Основные",
|
||||
"retrieval": "Поиск",
|
||||
"chunkSize": "Размер чанка",
|
||||
"chunkOverlap": "Перекрытие",
|
||||
"topKDense": "Вернуть (Dense)",
|
||||
"topKSparse": "Вернуть (Sparse)",
|
||||
"topMFinal": "Итоговый результат",
|
||||
"enableRerank": "Включить Rerank",
|
||||
"embeddingProvider": "Провайдер Embedding",
|
||||
"rerankProvider": "Провайдер Rerank",
|
||||
"save": "Сохранить",
|
||||
"saveSuccess": "Настройки сохранены",
|
||||
"saveFailed": "Ошибка сохранения",
|
||||
"tips": "Внимание! Изменение этих параметров повлияет на будущую выдачу базы знаний."
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
{
|
||||
"title": "Просмотр документа",
|
||||
"backToKB": "К базе знаний",
|
||||
"info": {
|
||||
"title": "Информация о документе",
|
||||
"name": "Имя файла",
|
||||
"type": "Формат",
|
||||
"size": "Размер",
|
||||
"chunkCount": "Количество фрагментов",
|
||||
"createdAt": "Загружен"
|
||||
},
|
||||
"chunks": {
|
||||
"title": "Фрагменты текста",
|
||||
"empty": "Фрагменты не найдены",
|
||||
"index": "Индекс",
|
||||
"content": "Текст",
|
||||
"charCount": "Символов",
|
||||
"actions": "Действия",
|
||||
"view": "Детали",
|
||||
"edit": "Изменить",
|
||||
"delete": "Удалить",
|
||||
"preview": "Обзор",
|
||||
"search": "Поиск по документу",
|
||||
"searchPlaceholder": "Найти во фрагментах...",
|
||||
"showing": "Показано",
|
||||
"deleteConfirm": "Удалить этот фрагмент?",
|
||||
"deleteSuccess": "Фрагмент удален",
|
||||
"deleteFailed": "Ошибка удаления"
|
||||
},
|
||||
"edit": {
|
||||
"title": "Редактирование фрагмента",
|
||||
"content": "Текст",
|
||||
"cancel": "Отмена",
|
||||
"save": "Сохранить",
|
||||
"saveSuccess": "Фрагмент обновлен",
|
||||
"saveFailed": "Ошибка сохранения"
|
||||
},
|
||||
"delete": {
|
||||
"title": "Удаление",
|
||||
"confirmText": "Вы уверены?",
|
||||
"warning": "Удаление фрагмента может ухудшить качество ответов AI по этой теме.",
|
||||
"cancel": "Отмена",
|
||||
"confirm": "Удалить",
|
||||
"deleteSuccess": "Удаление выполнено",
|
||||
"deleteFailed": "Ошибка удаления"
|
||||
},
|
||||
"view": {
|
||||
"title": "Детальный просмотр",
|
||||
"index": "Индекс",
|
||||
"content": "Текст",
|
||||
"charCount": "Символов",
|
||||
"vecDocId": "ID вектора",
|
||||
"close": "Закрыть"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
{
|
||||
"title": "Управление базами знаний",
|
||||
"subtitle": "Централизованное управление всеми знаниями AstrBot",
|
||||
"list": {
|
||||
"title": "Мои базы знаний",
|
||||
"subtitle": "Все доступные коллекции знаний",
|
||||
"create": "Создать базу",
|
||||
"refresh": "Обновить",
|
||||
"empty": "Баз знаний пока нет",
|
||||
"loading": "Загрузка...",
|
||||
"documents": "док.",
|
||||
"chunks": "фрагм.",
|
||||
"sessionConfig": "Профиль"
|
||||
},
|
||||
"card": {
|
||||
"edit": "Изменить",
|
||||
"delete": "Удалить",
|
||||
"open": "Открыть",
|
||||
"docCount": "Документов: {count}",
|
||||
"chunkCount": "Фрагментов: {count}"
|
||||
},
|
||||
"create": {
|
||||
"title": "Создание базы знаний",
|
||||
"nameLabel": "Название",
|
||||
"namePlaceholder": "Придумайте имя для базы",
|
||||
"descriptionLabel": "Описание",
|
||||
"descriptionPlaceholder": "Для чего нужна эта база?",
|
||||
"emojiLabel": "Иконка",
|
||||
"embeddingModelLabel": "Embedding модель",
|
||||
"rerankModelLabel": "Rerank модель (опционально)",
|
||||
"providerInfo": "Провайдер: {id} | Размерность: {dimensions}",
|
||||
"rerankProviderInfo": "Провайдер: {id}",
|
||||
"cancel": "Отмена",
|
||||
"submit": "Создать",
|
||||
"nameRequired": "Введите название базы знаний"
|
||||
},
|
||||
"edit": {
|
||||
"title": "Редактирование",
|
||||
"submit": "Сохранить"
|
||||
},
|
||||
"delete": {
|
||||
"title": "Удаление",
|
||||
"confirmText": "Вы уверены, что хотите удалить базу знаний «{name}»?",
|
||||
"warning": "Это действие необратимо. Все документы, фрагменты и настройки будут навсегда удалены.",
|
||||
"cancel": "Отмена",
|
||||
"confirm": "Удалить"
|
||||
},
|
||||
"emoji": {
|
||||
"title": "Выберите иконку",
|
||||
"close": "Закрыть",
|
||||
"categories": {
|
||||
"books": "Книги и документы",
|
||||
"emotions": "Эмоции",
|
||||
"objects": "Вещи",
|
||||
"symbols": "Символы"
|
||||
}
|
||||
},
|
||||
"messages": {
|
||||
"createSuccess": "База знаний создана",
|
||||
"createFailed": "Ошибка создания",
|
||||
"updateSuccess": "Обновлено успешно",
|
||||
"updateFailed": "Ошибка обновления",
|
||||
"deleteSuccess": "Удалено успешно",
|
||||
"deleteFailed": "Ошибка удаления",
|
||||
"loadError": "Не удалось загрузить список"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"dialog": {
|
||||
"title": "Помощник по миграции",
|
||||
"warning": "👋 Добро пожаловать в v4.0.0! В этой версии мы оптимизировали формат хранения данных. Обнаружена необходимость миграции базы данных.",
|
||||
"loading": "Загрузка списка платформ...",
|
||||
"loadError": "Ошибка загрузки, попробуйте еще раз",
|
||||
"noPlatforms": "Конфигурации платформ не найдены",
|
||||
"retry": "Повторить",
|
||||
"startMigration": "Начать миграцию",
|
||||
"migrating": "Выполняется миграция...",
|
||||
"migratingSubtitle": "Пожалуйста, подождите. Не закрывайте это окно до завершения процесса.",
|
||||
"migrationError": "Ошибка миграции",
|
||||
"success": "Миграция успешно завершена!",
|
||||
"completed": "Миграция выполнена",
|
||||
"restartRecommended": "Рекомендуется перезапустить приложение, чтобы все изменения вступили в силу.",
|
||||
"restartNow": "Перезапустить сейчас"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
{
|
||||
"page": {
|
||||
"description": "Управление настройками и поведением персонажей"
|
||||
},
|
||||
"buttons": {
|
||||
"create": "Создать персонажа",
|
||||
"createFirst": "Создать первого персонажа",
|
||||
"edit": "Изменить",
|
||||
"delete": "Удалить",
|
||||
"cancel": "Отмена",
|
||||
"save": "Сохранить",
|
||||
"move": "Переместить",
|
||||
"addDialogPair": "Добавить пример диалога"
|
||||
},
|
||||
"labels": {
|
||||
"presetDialogs": "Примеры диалогов ({count})",
|
||||
"createdAt": "Создан",
|
||||
"updatedAt": "Обновлен"
|
||||
},
|
||||
"form": {
|
||||
"personaId": "ID персонажа",
|
||||
"systemPrompt": "Системный промпт",
|
||||
"customErrorMessage": "Свое сообщение об ошибке (опционально)",
|
||||
"customErrorMessageHelp": "Это сообщение будет отправлено пользователю при сбое запроса к LLM. Если оставить пустым, будет использовано системное сообщение по умолчанию.",
|
||||
"presetDialogs": "Примеры диалогов",
|
||||
"presetDialogsHelp": "Добавьте примеры взаимодействия, чтобы помочь AI лучше понять свою роль и стиль общения.",
|
||||
"userMessage": "Сообщение пользователя",
|
||||
"assistantMessage": "Ответ AI",
|
||||
"tools": "Инструменты / MCP серверы",
|
||||
"toolsHelp": "Выберите инструменты, доступные этому персонажу. Инструменты позволяют AI взаимодействовать с внешним миром: искать в интернете, выполнять расчеты и т.д.",
|
||||
"toolsSelection": "Выбор инструментов",
|
||||
"selectAllTools": "Выбрать все",
|
||||
"clearAllTools": "Очистить всё",
|
||||
"allSelected": "Выбрано всё",
|
||||
"mcpServersQuickSelect": "Быстрый выбор MCP серверов",
|
||||
"searchTools": "Поиск инструментов",
|
||||
"selectedTools": "Выбранные инструменты",
|
||||
"noToolsAvailable": "Нет доступных инструментов",
|
||||
"noToolsFound": "Инструменты не найдены",
|
||||
"loadingTools": "Загрузка инструментов...",
|
||||
"allToolsAvailable": "Использовать все доступные инструменты",
|
||||
"noToolsSelected": "Инструменты не выбраны",
|
||||
"skills": "Навыки (Skills)",
|
||||
"skillsHelp": "Выберите навыки, доступные этому персонажу. Навыки предоставляют AI готовые сценарии и правила работы.",
|
||||
"skillsAllAvailable": "По умолчанию использовать все навыки",
|
||||
"skillsSelectSpecific": "Выбрать определенные навыки",
|
||||
"searchSkills": "Поиск навыков",
|
||||
"selectedSkills": "Выбранные навыки",
|
||||
"noSkillsAvailable": "Нет доступных навыков",
|
||||
"noSkillsFound": "Навыки не найдены",
|
||||
"loadingSkills": "Загрузка навыков...",
|
||||
"allSkillsAvailable": "Использовать все доступные навыки",
|
||||
"noSkillsSelected": "Навыки не выбраны",
|
||||
"skillsRuntimeNoneWarning": "Среда выполнения Computer Use не задана. Навыки могут не работать, так как нет активного окружения.",
|
||||
"createInFolder": "Будет создан в папке «{folder}»",
|
||||
"rootFolder": "Все персонажи"
|
||||
},
|
||||
"dialog": {
|
||||
"create": {
|
||||
"title": "Создание персонажа"
|
||||
},
|
||||
"edit": {
|
||||
"title": "Редактирование персонажа"
|
||||
}
|
||||
},
|
||||
"empty": {
|
||||
"title": "Персонажи не настроены",
|
||||
"description": "Самое время создать одного!",
|
||||
"folderEmpty": "Папка пуста",
|
||||
"folderEmptyDescription": "Создайте нового персонажа или папку, чтобы начать"
|
||||
},
|
||||
"validation": {
|
||||
"required": "Это поле обязательно для заполнения",
|
||||
"minLength": "Минимум {min} символов",
|
||||
"alphanumeric": "Разрешены только латинские буквы, цифры, подчёркивания и дефисы",
|
||||
"dialogRequired": "{type} не может быть пустым",
|
||||
"personaIdExists": "Персонаж с таким ID уже существует"
|
||||
},
|
||||
"messages": {
|
||||
"loadError": "Не удалось загрузить список персонажей",
|
||||
"saveSuccess": "Сохранено",
|
||||
"saveError": "Ошибка сохранения",
|
||||
"deleteConfirm": "Вы уверены, что хотите удалить персонажа «{id}»? Это действие необратимо.",
|
||||
"deleteSuccess": "Удалено",
|
||||
"deleteError": "Ошибка удаления"
|
||||
},
|
||||
"persona": {
|
||||
"personasTitle": "Персонаж",
|
||||
"toolsCount": "инстр.",
|
||||
"skillsCount": "навыков",
|
||||
"contextMenu": {
|
||||
"moveTo": "Переместить в..."
|
||||
},
|
||||
"messages": {
|
||||
"moveSuccess": "Персонаж перемещен",
|
||||
"moveError": "Не удалось переместить персонажа"
|
||||
}
|
||||
},
|
||||
"folder": {
|
||||
"sidebarTitle": "Папки",
|
||||
"rootFolder": "Корень",
|
||||
"foldersTitle": "Папки",
|
||||
"noFolders": "Папок нет",
|
||||
"createButton": "Новая папка",
|
||||
"searchPlaceholder": "Поиск папок...",
|
||||
"form": {
|
||||
"name": "Имя папки",
|
||||
"description": "Описание (опционально)"
|
||||
},
|
||||
"validation": {
|
||||
"nameRequired": "Имя папки не может быть пустым"
|
||||
},
|
||||
"contextMenu": {
|
||||
"open": "Открыть",
|
||||
"rename": "Переименовать",
|
||||
"moveTo": "Переместить в...",
|
||||
"delete": "Удалить"
|
||||
},
|
||||
"createDialog": {
|
||||
"title": "Создать папку",
|
||||
"createButton": "Создать"
|
||||
},
|
||||
"renameDialog": {
|
||||
"title": "Переименовать папку"
|
||||
},
|
||||
"deleteDialog": {
|
||||
"title": "Удаление папки",
|
||||
"message": "Вы уверены, что хотите удалить папку «{name}»?",
|
||||
"warning": "Все персонажи из этой папки будут перемещены в корневой каталог."
|
||||
},
|
||||
"messages": {
|
||||
"createSuccess": "Папка создана",
|
||||
"createError": "Ошибка создания папки",
|
||||
"renameSuccess": "Папка переименована",
|
||||
"renameError": "Ошибка переименования папки",
|
||||
"deleteSuccess": "Папка удалена",
|
||||
"deleteError": "Ошибка удаления папки"
|
||||
}
|
||||
},
|
||||
"moveDialog": {
|
||||
"title": "Перемещение",
|
||||
"description": "Выберите папку для «{name}»",
|
||||
"success": "Объект перемещен",
|
||||
"error": "Ошибка перемещения"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
{
|
||||
"title": "Боты",
|
||||
"subtitle": "Управление адаптерами платформ для подключения к мессенджерам",
|
||||
"adapters": "Адаптеры платформ",
|
||||
"addAdapter": "Создать бота",
|
||||
"emptyText": "Боты не настроены. Нажмите «Создать бота», чтобы начать.",
|
||||
"viewWebhook": "Показать Webhook",
|
||||
"webhookCopied": "URL скопирован в буфер обмена",
|
||||
"webhookCopyFailed": "Не удалось скопировать, сделайте это вручную",
|
||||
"webhookDialog": {
|
||||
"title": "Адрес Webhook",
|
||||
"description": "Используйте этот адрес для обратных вызовов. Убедитесь, что ваш AstrBot доступен из интернета. Рекомендуется указать «Внешний URL для Webhook» в Конфигурация -> Система.",
|
||||
"close": "Закрыть"
|
||||
},
|
||||
"details": {
|
||||
"adapterType": "Тип адаптера",
|
||||
"token": "Токен",
|
||||
"description": "Описание"
|
||||
},
|
||||
"logs": {
|
||||
"title": "Логи платформы",
|
||||
"expand": "Развернуть",
|
||||
"collapse": "Свернуть"
|
||||
},
|
||||
"dialog": {
|
||||
"add": "Добавить",
|
||||
"edit": "Изменить",
|
||||
"adapter": "Бот",
|
||||
"refresh": "Обновить",
|
||||
"cancel": "Отмена",
|
||||
"save": "Сохранить",
|
||||
"addPlatform": "Создать бота",
|
||||
"connectTitle": "Подключение к {name}",
|
||||
"viewTutorial": "Открыть руководство",
|
||||
"noTemplates": "Шаблоны не найдены",
|
||||
"idConflict": {
|
||||
"title": "Конфликт ID",
|
||||
"message": "Бот с ID «{id}» уже существует. Пожалуйста, используйте уникальный ID.",
|
||||
"confirm": "Понятно"
|
||||
},
|
||||
"securityWarning": {
|
||||
"title": "Безопасность",
|
||||
"aiocqhttpTokenMissing": "Для защиты соединения крайне рекомендуется установить ws_reverse_token. Работа без токена небезопасна.",
|
||||
"learnMore": "Подробнее"
|
||||
},
|
||||
"invalidPlatformId": "ID платформы не может содержать символы ':' или '!'."
|
||||
},
|
||||
"createDialog": {
|
||||
"step1Title": "Выберите мессенджер",
|
||||
"step1Hint": "Куда вы хотите подключить бота? (QQ, Telegram, Discord, WeChat и др.)",
|
||||
"platformTypeLabel": "Платформа",
|
||||
"configFileTitle": "Файл конфигурации",
|
||||
"optional": "опционально",
|
||||
"configHint": "Как настроить бота? Конфиг содержит модель, персонажа, базу знаний и набор плагинов.",
|
||||
"configDefaultHint": "По умолчанию используется профиль «default». Вы сможете изменить его позже.",
|
||||
"useExistingConfig": "Использовать существующий конфиг",
|
||||
"selectConfigLabel": "Выберите профиль",
|
||||
"createNewConfig": "Создать новый профиль",
|
||||
"newConfigNameLabel": "Имя нового профиля",
|
||||
"newConfigTitle": "Создание нового профиля",
|
||||
"newConfigLoadFailed": "Не удалось загрузить шаблон конфигурации",
|
||||
"addRouteRule": "Добавить правило маршрутизации",
|
||||
"viewMode": "Просмотр",
|
||||
"editMode": "Редактирование",
|
||||
"noRouteRules": "Правила маршрутизации не заданы, будет использоваться профиль по умолчанию",
|
||||
"sessionIdPlaceholder": "ID сессии или *",
|
||||
"allSessions": "Все сессии",
|
||||
"configMissing": "Файл конфигурации не найден",
|
||||
"routeHint": "* При получении сообщения AstrBot ищет первое совпадение в списке сверху вниз. Используйте слэш-команду /sid, чтобы узнать ID текущей сессии. Если совпадений нет, используется профиль по умолчанию.",
|
||||
"warningContinue": "Игнорировать и создать",
|
||||
"warningEditAgain": "Вернуться к редактированию",
|
||||
"configDrawerTitle": "Управление профилями",
|
||||
"configDrawerIdLabel": "ID",
|
||||
"configTableHeaders": {
|
||||
"configId": "ID связанного профиля",
|
||||
"scope": "Область применения"
|
||||
},
|
||||
"routeTableHeaders": {
|
||||
"source": "Источник (тип:ID)",
|
||||
"config": "Файл конфига",
|
||||
"actions": "Действия"
|
||||
},
|
||||
"messageTypeOptions": {
|
||||
"all": "Все сообщения",
|
||||
"group": "Групповые (GroupMessage)",
|
||||
"friend": "Личные (FriendMessage)"
|
||||
},
|
||||
"messageTypeLabels": {
|
||||
"all": "Все",
|
||||
"group": "Группа",
|
||||
"friend": "ЛС"
|
||||
}
|
||||
},
|
||||
"messages": {
|
||||
"updateSuccess": "Обновлено!",
|
||||
"addSuccess": "Добавлено!",
|
||||
"deleteSuccess": "Удалено!",
|
||||
"statusUpdateSuccess": "Статус обновлен!",
|
||||
"deleteConfirm": "Вы уверены, что хотите удалить этого бота?",
|
||||
"configNotFoundOpenConfig": "Целевой конфиг не найден. Открыта страница настроек для проверки.",
|
||||
"updateMissingPlatformId": "Ошибка обновления: отсутствует ID платформы.",
|
||||
"platformUpdateFailed": "Не удалось обновить платформу.",
|
||||
"addSuccessWithConfig": "Бот успешно добавлен, профиль обновлен",
|
||||
"configIdMissing": "Не удалось получить ID конфигурации.",
|
||||
"routingUpdateFailed": "Ошибка обновления маршрутов: {message}",
|
||||
"createConfigFailed": "Ошибка создания профиля: {message}",
|
||||
"platformIdMissing": "Не удалось получить ID платформы.",
|
||||
"routingSaveFailed": "Ошибка сохранения маршрутов: {message}"
|
||||
},
|
||||
"status": {
|
||||
"enabled": "Включен",
|
||||
"disabled": "Выключен",
|
||||
"connecting": "Подключение",
|
||||
"connected": "Подключен",
|
||||
"disconnected": "Отключен",
|
||||
"error": "Ошибка"
|
||||
},
|
||||
"runtimeStatus": {
|
||||
"running": "Работает",
|
||||
"error": "Ошибка",
|
||||
"pending": "Ожидание",
|
||||
"stopped": "Остановлен",
|
||||
"unknown": "Неизвестно",
|
||||
"errors": "ошибок"
|
||||
},
|
||||
"errorDialog": {
|
||||
"title": "Детали ошибки",
|
||||
"platformId": "ID платформы",
|
||||
"errorCount": "Кол-во ошибок",
|
||||
"lastError": "Последняя ошибка",
|
||||
"occurredAt": "Время",
|
||||
"traceback": "Стек вызовов",
|
||||
"close": "Закрыть"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
{
|
||||
"title": "Провайдеры моделей",
|
||||
"subtitle": "Настройка AI моделей для диалогов. Также поддерживает Dify, Coze, а также внешние Agent-сервисы.",
|
||||
"providers": {
|
||||
"title": "Сервис-провайдеры",
|
||||
"settings": "Настройки",
|
||||
"addProvider": "Добавить провайдера",
|
||||
"providerType": "Тип провайдера",
|
||||
"tabs": {
|
||||
"all": "Все",
|
||||
"chatCompletion": "Диалоги",
|
||||
"agentRunner": "Агенты",
|
||||
"speechToText": "STT (Речь -> Текст)",
|
||||
"textToSpeech": "TTS (Текст -> Речь)",
|
||||
"embedding": "Эмбеддинги",
|
||||
"rerank": "Rerank (Ранжирование)"
|
||||
},
|
||||
"empty": {
|
||||
"all": "Провайдеры не добавлены. Нажмите «Добавить провайдера», чтобы начать.",
|
||||
"typed": "Провайдеры типа «{type}» не найдены."
|
||||
},
|
||||
"description": {
|
||||
"openai": "Поддерживаются все провайдеры, совместимые с OpenAI API.",
|
||||
"vllm_rerank": "Также поддерживает Jina AI, Cohere, PPIO и другие.",
|
||||
"default": "Преобразование речи в текст"
|
||||
}
|
||||
},
|
||||
"availability": {
|
||||
"title": "Доступность провайдеров",
|
||||
"subtitle": "Статус определяется путем выполнения тестового запроса. Может взиматься плата согласно тарифу API.",
|
||||
"refresh": "Проверить статус",
|
||||
"noData": "Нажмите «Проверить статус», чтобы узнать доступность моделей",
|
||||
"available": "Доступен",
|
||||
"unavailable": "Недоступен",
|
||||
"pending": "Проверка...",
|
||||
"errorMessage": "Ошибка",
|
||||
"test": "Тест"
|
||||
},
|
||||
"logs": {
|
||||
"title": "Логи сервиса",
|
||||
"expand": "Развернуть",
|
||||
"collapse": "Свернуть"
|
||||
},
|
||||
"dialogs": {
|
||||
"addProvider": {
|
||||
"title": "Новый провайдер",
|
||||
"tabs": {
|
||||
"basic": "Диалоги",
|
||||
"agentRunner": "Агенты",
|
||||
"speechToText": "Преобразование текста в речь",
|
||||
"textToSpeech": "Переранжирование",
|
||||
"embedding": "Эмбеддинги",
|
||||
"rerank": "API Key"
|
||||
},
|
||||
"noTemplates": "Шаблоны для этого типа не найдены"
|
||||
},
|
||||
"config": {
|
||||
"addTitle": "Добавить",
|
||||
"editTitle": "Изменить",
|
||||
"provider": "Провайдер",
|
||||
"cancel": "Отмена",
|
||||
"save": "Сохранить"
|
||||
},
|
||||
"settings": {
|
||||
"title": "Общие настройки провайдеров",
|
||||
"sessionSeparation": {
|
||||
"title": "Изоляция провайдеров по сессиям",
|
||||
"description": "Позволяет выбирать независимых провайдеров для генерации текста, TTS и STT в каждой конкретной сессии."
|
||||
},
|
||||
"close": "Закрыть"
|
||||
}
|
||||
},
|
||||
"messages": {
|
||||
"success": {
|
||||
"update": "Обновлено!",
|
||||
"add": "Добавлено!",
|
||||
"delete": "Удалено!",
|
||||
"statusUpdate": "Статус обновлен!",
|
||||
"sessionSeparation": "Настройки изоляции сохранены"
|
||||
},
|
||||
"error": {
|
||||
"sessionSeparation": "Не удалось загрузить настройки изоляции",
|
||||
"fetchStatus": "Не удалось получить статус провайдеров",
|
||||
"testError": "Тест {id} провален: {error}"
|
||||
},
|
||||
"confirm": {
|
||||
"delete": "Вы уверены, что хотите удалить провайдера «{id}»?"
|
||||
}
|
||||
},
|
||||
"providerTypes": {
|
||||
"title": "Тип провайдера"
|
||||
},
|
||||
"providerSources": {
|
||||
"title": "Источник провайдера",
|
||||
"add": "Добавить",
|
||||
"empty": "Источники не найдены",
|
||||
"selectHint": "Пожалуйста, выберите источник провайдера",
|
||||
"selectCreated": "Выбрать существующий источник",
|
||||
"save": "Сохранить конфиг",
|
||||
"saveAndFetchModels": "Сохранить и загрузить модели",
|
||||
"fetchModels": "Загрузить список моделей",
|
||||
"saveSuccess": "Источник успешно сохранен",
|
||||
"saveError": "Ошибка сохранения источника",
|
||||
"deleteConfirm": "Вы уверены, что хотите удалить источник «{id}»? Все связанные конфигурации моделей будут удалены.",
|
||||
"deleteSuccess": "Источник удален",
|
||||
"deleteError": "Ошибка удаления",
|
||||
"enabled": "Включен",
|
||||
"disabled": "Выключен",
|
||||
"advancedConfig": "Расширенные настройки...",
|
||||
"fields": {
|
||||
"name": "Имя",
|
||||
"apiKey": "Base URL",
|
||||
"baseUrl": "Base URL"
|
||||
},
|
||||
"hints": {
|
||||
"id": "Уникальный ID источника",
|
||||
"key": "Ваш серетный API-ключ",
|
||||
"apiBase": "Адрес API точки входа (Endpoint URL)",
|
||||
"proxy": "Прокси сервер (HTTP/HTTPS), напр. http://127.0.0.1:7890. Используется только для запросов к этому провайдеру."
|
||||
},
|
||||
"labels": {
|
||||
"proxy": "Прокси"
|
||||
}
|
||||
},
|
||||
"models": {
|
||||
"available": "Доступные модели",
|
||||
"configured": "Настроенные модели",
|
||||
"empty": "Модели не настроены. Нажмите «Загрузить список моделей» выше.",
|
||||
"noModelsFound": "Модели не найдены",
|
||||
"fetchError": "Не удалось получить список моделей",
|
||||
"addSuccess": "Модель {model} успешно добавлена",
|
||||
"deleteConfirm": "Вы уверены, что хотите удалить модель «{id}»?",
|
||||
"deleteSuccess": "Модель удалена",
|
||||
"deleteError": "Ошибка удаления модели",
|
||||
"testSuccess": "Тест модели «{id}» пройден успешно",
|
||||
"testError": "Тест модели провален",
|
||||
"searchPlaceholder": "Поиск по имени или ID",
|
||||
"manualAddButton": "Добавить вручную",
|
||||
"manualDialogTitle": "Произвольная модель",
|
||||
"manualDialogModelLabel": "Код модели (напр. gpt-4o-mini)",
|
||||
"manualDialogPreviewLabel": "Отображаемый ID (авто)",
|
||||
"manualDialogPreviewHint": "Будет выглядеть как: SourceID/ModelID",
|
||||
"manualModelRequired": "Укажите ID модели",
|
||||
"manualModelExists": "Эта модель уже добавлена",
|
||||
"configure": "Настроить",
|
||||
"tooltips": {
|
||||
"providerId": "ID провайдера",
|
||||
"modelId": "ID модели"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
{
|
||||
"title": "Управление сессиями",
|
||||
"subtitle": "Настройка индивидуальных правил для конкретных диалогов. Эти правила имеют приоритет над глобальной конфигурацией.",
|
||||
"buttons": {
|
||||
"refresh": "Обновить",
|
||||
"edit": "Изменить",
|
||||
"editRule": "Редактировать правило",
|
||||
"deleteAllRules": "Удалить все правила",
|
||||
"addRule": "Добавить правило",
|
||||
"save": "Сохранить",
|
||||
"cancel": "Отмена",
|
||||
"delete": "Удалить",
|
||||
"clear": "Очистить",
|
||||
"next": "Далее",
|
||||
"editCustomName": "Изменить заметку",
|
||||
"batchDelete": "Массовое удаление"
|
||||
},
|
||||
"customRules": {
|
||||
"title": "Пользовательские правила",
|
||||
"rulesCount": "правил",
|
||||
"hasRules": "Настроено",
|
||||
"noRules": "Индивидуальных правил нет",
|
||||
"noRulesDesc": "Нажмите «Добавить правило», чтобы задать настройки для конкретного диалога",
|
||||
"serviceConfig": "Сервис",
|
||||
"pluginConfig": "Плагины",
|
||||
"kbConfig": "База знаний",
|
||||
"providerConfig": "Модель",
|
||||
"configured": "Настроено",
|
||||
"noCustomName": "Без заметки"
|
||||
},
|
||||
"quickEditName": {
|
||||
"title": "Редактирование заметки"
|
||||
},
|
||||
"search": {
|
||||
"placeholder": "Поиск сессии..."
|
||||
},
|
||||
"table": {
|
||||
"headers": {
|
||||
"umoInfo": "Источник (UMO)",
|
||||
"rulesOverview": "Обзор правил",
|
||||
"actions": "Действия"
|
||||
}
|
||||
},
|
||||
"persona": {
|
||||
"none": "Из конфигурации"
|
||||
},
|
||||
"provider": {
|
||||
"followConfig": "Из конфигурации"
|
||||
},
|
||||
"addRule": {
|
||||
"title": "Добавление правила",
|
||||
"description": "Выберите источник сообщения (UMO) для настройки. Индивидуальные правила приоритетнее глобальных. Используйте команду /sid в чате, чтобы узнать информацию об источнике.",
|
||||
"selectUmo": "Выберите сессию",
|
||||
"noUmos": "Нет доступных сессий"
|
||||
},
|
||||
"ruleEditor": {
|
||||
"title": "Редактор правил",
|
||||
"description": "Настройте поведение для этой сессии. Настройки ниже перекроют глобальный конфиг.",
|
||||
"serviceConfig": {
|
||||
"title": "Сервисные настройки",
|
||||
"sessionEnabled": "Обрабатывать сообщения",
|
||||
"llmEnabled": "Использовать LLM",
|
||||
"ttsEnabled": "Использовать TTS",
|
||||
"customName": "Заметка для сессии"
|
||||
},
|
||||
"providerConfig": {
|
||||
"title": "Выбор моделей",
|
||||
"chatProvider": "Чат-модель",
|
||||
"sttProvider": "STT (Распознавание)",
|
||||
"ttsProvider": "TTS (Озвучка)"
|
||||
},
|
||||
"personaConfig": {
|
||||
"title": "Персона",
|
||||
"selectPersona": "Выберите Persona",
|
||||
"hint": "При выборе Persona все диалоги из этого источника будут использовать именно её."
|
||||
},
|
||||
"pluginConfig": {
|
||||
"title": "Плагины",
|
||||
"disabledPlugins": "Отключенные плагины",
|
||||
"hint": "Выберите плагины, которые нужно ОТКЛЮЧИТЬ в этой сессии. Остальные останутся активными."
|
||||
},
|
||||
"kbConfig": {
|
||||
"title": "База знаний",
|
||||
"selectKbs": "Выбор баз знаний",
|
||||
"topK": "Количество результатов (Top K)",
|
||||
"enableRerank": "Использовать Rerank"
|
||||
}
|
||||
},
|
||||
"deleteConfirm": {
|
||||
"title": "Подтверждение",
|
||||
"message": "Удалить все настройки для этой сессии? Будут применены глобальные настройки."
|
||||
},
|
||||
"batchDeleteConfirm": {
|
||||
"title": "Массовое удаление",
|
||||
"message": "Удалить {count} выбранных правил? Будут применены глобальные настройки."
|
||||
},
|
||||
"batchOperations": {
|
||||
"title": "Массовые операции",
|
||||
"hint": "Быстрое изменение настроек для группы сессий",
|
||||
"scope": "Область применения",
|
||||
"scopeSelected": "Выбранные",
|
||||
"scopeAll": "Все сессии",
|
||||
"scopeGroup": "Все группы",
|
||||
"scopePrivate": "Личные диалоги",
|
||||
"llmStatus": "Статус LLM",
|
||||
"ttsStatus": "Статус TTS",
|
||||
"chatProvider": "Чат-модель",
|
||||
"ttsProvider": "TTS-модель",
|
||||
"apply": "Применить"
|
||||
},
|
||||
"status": {
|
||||
"enabled": "Включено",
|
||||
"disabled": "Выключено"
|
||||
},
|
||||
"messages": {
|
||||
"refreshSuccess": "Данные обновлены",
|
||||
"loadError": "Ошибка загрузки",
|
||||
"saveSuccess": "Настройки сохранены",
|
||||
"saveError": "Ошибка сохранения",
|
||||
"clearSuccess": "Очищено",
|
||||
"clearError": "Ошибка очистки",
|
||||
"deleteSuccess": "Удалено",
|
||||
"deleteError": "Ошибка удаления",
|
||||
"noChanges": "Изменений не обнаружено",
|
||||
"batchDeleteSuccess": "Массовое удаление выполнено",
|
||||
"batchDeleteError": "Ошибка массового удаления",
|
||||
"batchUpdateError": "Ошибка пакетного обновления",
|
||||
"batchUpdateSuccess": "Пакетное обновление успешно выполнено"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
{
|
||||
"network": {
|
||||
"title": "Сеть",
|
||||
"githubProxy": {
|
||||
"title": "Зеркало GitHub",
|
||||
"subtitle": "Адрес для ускорения загрузки плагинов и обновлений AstrBot. Особенно актуально для пользователей из Китая. Все адреса предоставляются как есть, если обновление не удается — проверьте доступность выбранного зеркала.",
|
||||
"label": "Выбрать ускоритель GitHub"
|
||||
},
|
||||
"proxySelector": {
|
||||
"title": "Ускорение GitHub",
|
||||
"noProxy": "Не использовать",
|
||||
"useProxy": "Включить",
|
||||
"testConnection": "Проверить соединение",
|
||||
"available": "Доступен",
|
||||
"unavailable": "Недоступен",
|
||||
"custom": "Свой вариант"
|
||||
}
|
||||
},
|
||||
"theme": {
|
||||
"title": "Тема оформления",
|
||||
"subtitle": "Настройка основных и дополнительных цветов. Изменения вступают в силу немедленно и сохраняются в браузере.",
|
||||
"customize": {
|
||||
"title": "Цвета темы",
|
||||
"primary": "Основной",
|
||||
"secondary": "Дополнительный",
|
||||
"reset": "Сбросить"
|
||||
}
|
||||
},
|
||||
"system": {
|
||||
"title": "Система",
|
||||
"restart": {
|
||||
"title": "Перезапуск",
|
||||
"subtitle": "Выполнить мягкий перезапуск AstrBot",
|
||||
"button": "Перезагрузить"
|
||||
},
|
||||
"migration": {
|
||||
"title": "Миграция данных в v4.0.0",
|
||||
"subtitle": "Если у вас возникли проблемы с совместимостью данных после обновления, запустите помощник вручную.",
|
||||
"button": "Запустить миграцию"
|
||||
},
|
||||
"backup": {
|
||||
"title": "Резервное копирование",
|
||||
"subtitle": "Важнейший инструмент для безопасного переноса данных между серверами.",
|
||||
"button": "Управление бэкапами"
|
||||
}
|
||||
},
|
||||
"sidebar": {
|
||||
"title": "Боковая панель",
|
||||
"customize": {
|
||||
"title": "Настройка меню",
|
||||
"subtitle": "Перетаскивайте элементы, чтобы изменить их порядок или скрыть в группе «Дополнительно». Настройки сохраняются локально в браузере.",
|
||||
"reset": "Сбросить порядок",
|
||||
"mainItems": "Основные разделы",
|
||||
"moreItems": "Дополнительно"
|
||||
}
|
||||
},
|
||||
"backup": {
|
||||
"dialog": {
|
||||
"title": "Резервное копирование"
|
||||
},
|
||||
"tabs": {
|
||||
"export": "Экспорт",
|
||||
"import": "Импорт",
|
||||
"list": "Список копий"
|
||||
},
|
||||
"export": {
|
||||
"title": "Создать резервную копию",
|
||||
"description": "Экспорт всех данных в ZIP-архив, включая базы данных, базу знаний, конфигурации и вложения.",
|
||||
"includes": "Включает: основную БД, векторные индексы знаний, файлы конфигурации, медиа-вложения.",
|
||||
"button": "Начать экспорт",
|
||||
"processing": "Экспорт...",
|
||||
"wait": "Пожалуйста, подождите, мы упаковываем данные...",
|
||||
"completed": "Готово!",
|
||||
"download": "Скачать архив",
|
||||
"another": "Создать новый",
|
||||
"failed": "Ошибка экспорта",
|
||||
"retry": "Повторить"
|
||||
},
|
||||
"import": {
|
||||
"title": "Восстановление из копии",
|
||||
"warning": "⚠️ Внимание! Импорт полностью удалит и перезапишет текущие данные! Убедитесь, что у вас есть копия текущего состояния.",
|
||||
"selectFile": "Выберите ZIP-архив",
|
||||
"uploadAndCheck": "Загрузить и проверить",
|
||||
"uploading": "Загрузка...",
|
||||
"uploadWait": "Файл передается на сервер...",
|
||||
"uploadInit": "Инициализация...",
|
||||
"uploadingChunks": "Передача фрагментов...",
|
||||
"uploadComplete": "Загружено, идет сборка...",
|
||||
"checking": "Проверка структуры...",
|
||||
"invalidBackup": "Некорректный файл резервной копии",
|
||||
"backupContents": "Состав архива",
|
||||
"tables": "таблиц БД",
|
||||
"knowledgeBases": "баз знаний",
|
||||
"configFiles": "конфигов",
|
||||
"confirmImport": "Подтвердите импорт",
|
||||
"button": "Начать восстановление",
|
||||
"processing": "Восстановление...",
|
||||
"wait": "Идет процесс развертывания данных...",
|
||||
"completed": "Восстановление успешно завершено!",
|
||||
"restartRequired": "Данные восстановлены. Необходимо немедленно перезапустить AstrBot для вступления изменений в силу.",
|
||||
"restartNow": "Перезапустить сейчас",
|
||||
"failed": "Ошибка импорта",
|
||||
"retry": "Повторить",
|
||||
"version": {
|
||||
"backupVersion": "Версия бэкапа",
|
||||
"currentVersion": "Текущая версия",
|
||||
"backupTime": "Дата создания",
|
||||
"matchTitle": "✅ Версии совпадают",
|
||||
"matchMessage": "Импорт перезапишет все текущие данные, включая:\n• Основную БД (чаты, настройки)\n• Базы знаний\n• Плагины и их данные\n• Файлы конфигурации\n\nЭто действие необратимо! Продолжить?",
|
||||
"minorDiffTitle": "⚠️ Разница в минорной версии",
|
||||
"minorDiffMessage": "Разница в минорных версиях обычно допустима, но структура данных могла немного измениться. Все текущие данные будут удалены!\n\nПродолжить импорт?",
|
||||
"majorDiffTitle": "⛔ Импорт невозможен",
|
||||
"majorDiffMessage": "Версии основного выпуска различаются. Импорт между мажорными версиями может привести к фатальному повреждению данных.\nИспользуйте AstrBot той же основной версии."
|
||||
}
|
||||
},
|
||||
"list": {
|
||||
"empty": "Резервные копии не найдены",
|
||||
"refresh": "Обновить список",
|
||||
"confirmDelete": "Вы уверены, что хотите безвозвратно удалить эту копию?",
|
||||
"uploaded": "Загружено",
|
||||
"restore": "Восстановить из этого файла",
|
||||
"rename": "Переименовать",
|
||||
"renameTitle": "Переименование файла",
|
||||
"newName": "Новое имя",
|
||||
"renameHint": "Разрешены буквы, цифры, точки, дефисы и подчеркивания",
|
||||
"renameRequired": "Введите имя файла",
|
||||
"renameInvalidChars": "Имя содержит недопустимые символы",
|
||||
"renameFailed": "Ошибка переименования",
|
||||
"ftpHint": "Для больших архивов вы можете загружать их напрямую в папку data/backups через FTP/SFTP."
|
||||
}
|
||||
},
|
||||
"apiKey": {
|
||||
"title": "API Keys",
|
||||
"manageTitle": "Ключи доступа разработчика",
|
||||
"subtitle": "Управление токенами для доступа к открытому HTTP API AstrBot.",
|
||||
"name": "Имя ключа",
|
||||
"expiresInDays": "Срок действия",
|
||||
"expiryOptions": {
|
||||
"day1": "1 день",
|
||||
"day7": "7 дней",
|
||||
"day30": "30 дней",
|
||||
"day90": "90 дней",
|
||||
"permanent": "Бессрочно"
|
||||
},
|
||||
"permanentWarning": "Бессрочные ключи менее безопасны. Пожалуйста, храните их в надежном месте.",
|
||||
"scopes": "Область доступа (Scopes)",
|
||||
"create": "Создать API Key",
|
||||
"revoke": "Отозвать",
|
||||
"delete": "Удалить",
|
||||
"copy": "Копировать",
|
||||
"docsLink": "Документация API",
|
||||
"plaintextHint": "Обязательно сохраните ключ сейчас. После закрытия окна вы больше не сможете увидеть его значение.",
|
||||
"empty": "Ключи не созданы",
|
||||
"status": {
|
||||
"active": "Активен",
|
||||
"inactive": "Неактивен"
|
||||
},
|
||||
"table": {
|
||||
"name": "Имя",
|
||||
"prefix": "Префикс",
|
||||
"scopes": "Права",
|
||||
"status": "Статус",
|
||||
"lastUsed": "Использован",
|
||||
"createdAt": "Создан",
|
||||
"actions": "Действия"
|
||||
},
|
||||
"messages": {
|
||||
"loadFailed": "Не удалось загрузить ключи",
|
||||
"scopeRequired": "Выберите хотя бы одну область доступа",
|
||||
"createSuccess": "API Key создан",
|
||||
"createFailed": "Ошибка создания ключа",
|
||||
"revokeSuccess": "Ключ отозван",
|
||||
"revokeFailed": "Ошибка отзыва ключа",
|
||||
"deleteSuccess": "Ключ удален",
|
||||
"deleteFailed": "Ошибка удаления ключа",
|
||||
"copySuccess": "Ключ скопирован",
|
||||
"copyFailed": "Ошибка копирования"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
{
|
||||
"page": {
|
||||
"title": "Оркестрация SubAgent",
|
||||
"beta": "Экспериментально",
|
||||
"subtitle": "Основной LLM может напрямую использовать свои инструменты или делегировать задачи SubAgent через handoff."
|
||||
},
|
||||
"actions": {
|
||||
"refresh": "Обновить",
|
||||
"save": "Сохранить",
|
||||
"add": "Добавить SubAgent",
|
||||
"delete": "Удалить",
|
||||
"close": "Закрыть"
|
||||
},
|
||||
"switches": {
|
||||
"enable": "Включить оркестрацию SubAgent",
|
||||
"enableHint": "Включить функциональность под-агентов",
|
||||
"dedupe": "Дедупликация инструментов основного LLM (скрывать инструменты, дублируемые SubAgent)",
|
||||
"dedupeHint": "Удалить дублирующиеся инструменты из основного агента"
|
||||
},
|
||||
"description": {
|
||||
"disabled": "Выключено: SubAgent отключен; основной LLM подключает инструменты согласно правилам персонажа (все по умолчанию) и вызывает их напрямую.",
|
||||
"enabled": "Включено: основной LLM сохраняет свои инструменты и подключает инструменты делегирования transfer_to_*. При дедупликации инструменты, пересекающиеся с SubAgent, удаляются из основного набора."
|
||||
},
|
||||
"section": {
|
||||
"title": "Субагенты",
|
||||
"globalSettings": "Глобальные настройки"
|
||||
},
|
||||
"cards": {
|
||||
"statusEnabled": "Включено",
|
||||
"statusDisabled": "Отключено",
|
||||
"unnamed": "Безымянный SubAgent",
|
||||
"transferPrefix": "передать_{name}",
|
||||
"switchLabel": "Включить",
|
||||
"previewTitle": "Предпросмотр: инструмент handoff, видимый основному LLM",
|
||||
"personaChip": "Персонаж: {id}",
|
||||
"personaPreview": "ПРЕДПРОСМОТР ПЕРСОНАЖА"
|
||||
},
|
||||
"form": {
|
||||
"nameLabel": "Имя агента (используется для transfer_to_{name})",
|
||||
"nameHint": "Используйте строчные латинские буквы и подчеркивания; имя должно быть глобально уникальным.",
|
||||
"providerLabel": "Chat Provider (опционально)",
|
||||
"providerHint": "Оставьте пустым, чтобы использовать глобальный провайдер по умолчанию.",
|
||||
"personaLabel": "Выберите персонажа",
|
||||
"personaHint": "SubAgent наследует системные настройки и инструменты выбранного персонажа.",
|
||||
"descriptionLabel": "Описание для основного LLM (используется для принятия решения о handoff)",
|
||||
"descriptionHint": "Отображается как описание инструмента transfer_to_* — будьте кратки и ясны."
|
||||
},
|
||||
"messages": {
|
||||
"loadConfigFailed": "Не удалось загрузить конфигурацию",
|
||||
"loadPersonaFailed": "Не удалось загрузить список персонажей",
|
||||
"nameMissing": "У SubAgent отсутствует имя",
|
||||
"nameInvalid": "Недопустимое имя SubAgent: только строчные латинские буквы/цифры/подчеркивания, должно начинаться с буквы",
|
||||
"nameDuplicate": "Дублирующееся имя SubAgent: {name}",
|
||||
"personaMissing": "У SubAgent {name} не выбран персонаж",
|
||||
"saveSuccess": "Успешно сохранено",
|
||||
"saveFailed": "Ошибка сохранения",
|
||||
"nameRequired": "Имя обязательно",
|
||||
"namePattern": "Только строчные буквы, цифры и подчеркивание"
|
||||
},
|
||||
"empty": {
|
||||
"title": "Агенты не настроены",
|
||||
"subtitle": "Добавьте первого под-агента, чтобы начать",
|
||||
"action": "Создать первого агента"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
{
|
||||
"title": "Инструменты и функции",
|
||||
"subtitle": "Управление MCP-серверами и доступными функциями",
|
||||
"tooltip": {
|
||||
"info": "Что такое Function Calling и MCP?",
|
||||
"marketplace": "Обзор и установка MCP-серверов от сообщества",
|
||||
"serverConfig": "Конфигурация MCP-серверов (stdio) поддерживает следующие поля:\ncommand: имя команды (например, python или uv)\nargs: массив аргументов (например, [\"run\", \"server.py\"])\nenv: объект переменных окружения (например, {\"api_key\": \"abc\"})\ncwd: рабочий каталог (например, /path/to/server)\nencoding: кодировка вывода (по умолчанию utf-8)\nПодробности см. в документации MCP.\n⚠️ Если вы используете Docker, устанавливайте сервера в смонтированную директорию data."
|
||||
},
|
||||
"tabs": {
|
||||
"local": "Локальные сервера",
|
||||
"marketplace": "Магазин MCP"
|
||||
},
|
||||
"mcpServers": {
|
||||
"title": "MCP Сервера",
|
||||
"buttons": {
|
||||
"refresh": "Обновить",
|
||||
"add": "Добавить сервер",
|
||||
"useTemplateStdio": "Шаблон Stdio",
|
||||
"useTemplateStreamableHttp": "Шаблон Streamable HTTP",
|
||||
"useTemplateSse": "Шаблон SSE",
|
||||
"sync": "Синхронизировать"
|
||||
},
|
||||
"empty": "MCP-сервера не найдены. Нажмите «Добавить сервер».",
|
||||
"status": {
|
||||
"noTools": "Нет доступных инструментов",
|
||||
"availableTools": "Доступные инструменты",
|
||||
"configSummary": "Конфигурация: {keys}",
|
||||
"noConfig": "Конфигурация не задана"
|
||||
}
|
||||
},
|
||||
"functionTools": {
|
||||
"title": "Функции (Tools)",
|
||||
"buttons": {
|
||||
"view": "Показать инструменты"
|
||||
},
|
||||
"search": "Поиск по функциям",
|
||||
"empty": "Доступные инструменты не найдены",
|
||||
"description": "Описание функции",
|
||||
"parameters": "Параметры",
|
||||
"noParameters": "У этого инструмента нет параметров",
|
||||
"table": {
|
||||
"paramName": "Параметр",
|
||||
"type": "Тип",
|
||||
"description": "Описание",
|
||||
"required": "Обяз.",
|
||||
"origin": "Источник",
|
||||
"originName": "Имя источника",
|
||||
"actions": "Действия"
|
||||
}
|
||||
},
|
||||
"marketplace": {
|
||||
"title": "Магазин MCP-серверов",
|
||||
"search": "Поиск по магазину",
|
||||
"buttons": {
|
||||
"refresh": "Обновить",
|
||||
"detail": "Инфо",
|
||||
"import": "Импорт"
|
||||
},
|
||||
"loading": "Загрузка списка серверов...",
|
||||
"empty": "Доступных MCP-серверов не найдено",
|
||||
"status": {
|
||||
"availableTools": "Инструментов: {count}",
|
||||
"noToolsInfo": "Нет данных об инструментах"
|
||||
}
|
||||
},
|
||||
"dialogs": {
|
||||
"addServer": {
|
||||
"title": "Добавление MCP-сервера",
|
||||
"editTitle": "Редактирование MCP-сервера",
|
||||
"fields": {
|
||||
"name": "Название сервера",
|
||||
"nameRequired": "Название обязательно",
|
||||
"enable": "Включить сервер",
|
||||
"config": "Конфигурация сервера"
|
||||
},
|
||||
"errors": {
|
||||
"configEmpty": "Конфигурация не может быть пустой",
|
||||
"jsonFormat": "Ошибка формата JSON: {error}",
|
||||
"jsonParse": "Ошибка разбора JSON: {error}"
|
||||
},
|
||||
"buttons": {
|
||||
"cancel": "Отмена",
|
||||
"save": "Сохранить",
|
||||
"testConnection": "Тест связи",
|
||||
"sync": "Синхронизировать"
|
||||
},
|
||||
"tips": {
|
||||
"timeoutConfig": "Тайм-аут вызова инструментов настраивается отдельно на странице конфигурации"
|
||||
}
|
||||
},
|
||||
"serverDetail": {
|
||||
"title": "Детали сервера",
|
||||
"installConfig": "Конфигурация установки",
|
||||
"availableTools": "Список инструментов",
|
||||
"buttons": {
|
||||
"close": "Закрыть",
|
||||
"importConfig": "Импортировать конфиг"
|
||||
}
|
||||
},
|
||||
"confirmDelete": "Вы уверены, что хотите удалить сервер «{name}»?",
|
||||
"syncProvider": {
|
||||
"title": "Синхронизация MCP",
|
||||
"subtitle": "Загрузка конфигурации MCP-серверов от провайдера",
|
||||
"steps": {
|
||||
"selectProvider": "Шаг 1: Провайдер",
|
||||
"configureAuth": "Шаг 2: Авторизация",
|
||||
"syncServers": "Шаг 3: Синхронизация"
|
||||
},
|
||||
"providers": {
|
||||
"modelscope": "ModelScope",
|
||||
"description": "ModelScope — это сообщество моделей с открытым исходным кодом, предоставляющее различные MCP-сервера для AI-сервисов"
|
||||
},
|
||||
"fields": {
|
||||
"provider": "Выберите провайдера",
|
||||
"accessToken": "Токен доступа",
|
||||
"tokenRequired": "Токен обязателен",
|
||||
"tokenHint": "Введите ваш токен доступа ModelScope"
|
||||
},
|
||||
"buttons": {
|
||||
"cancel": "Отмена",
|
||||
"previous": "Назад",
|
||||
"next": "Далее",
|
||||
"sync": "Начать",
|
||||
"getToken": "Получить токен"
|
||||
},
|
||||
"status": {
|
||||
"selectProvider": "Пожалуйста, выберите провайдера MCP-серверов",
|
||||
"enterToken": "Введите токен для продолжения",
|
||||
"readyToSync": "Готов к синхронизации"
|
||||
},
|
||||
"messages": {
|
||||
"syncSuccess": "MCP-сервера успешно синхронизированы!",
|
||||
"syncError": "Ошибка синхронизации: {error}",
|
||||
"tokenHelp": "Как получить токен ModelScope? Нажмите кнопку справа для инструкции"
|
||||
}
|
||||
}
|
||||
},
|
||||
"messages": {
|
||||
"getServersError": "Ошибка получения списка серверов: {error}",
|
||||
"getToolsError": "Ошибка получения списка инструментов: {error}",
|
||||
"saveSuccess": "Настройки сохранены!",
|
||||
"saveError": "Ошибка сохранения: {error}",
|
||||
"deleteSuccess": "Сервер удален успешно!",
|
||||
"deleteError": "Ошибка удаления: {error}",
|
||||
"updateSuccess": "Обновлено успешно!",
|
||||
"updateError": "Ошибка обновления: {error}",
|
||||
"getMarketError": "Не удалось загрузить магазин MCP: {error}",
|
||||
"importError": {
|
||||
"noConfig": "У этого сервера нет доступной конфигурации",
|
||||
"invalidFormat": "Неверный формат конфигурации",
|
||||
"failed": "Импорт не удался: {error}"
|
||||
},
|
||||
"configParseError": "Ошибка разбора конфигурации: {error}",
|
||||
"noAvailableConfig": "Конфигурация отсутствует",
|
||||
"toggleToolSuccess": "Статус инструмента изменен!",
|
||||
"toggleToolError": "Не удалось изменить статус: {error}",
|
||||
"testError": "Ошибка теста связи: {error}"
|
||||
},
|
||||
"syncProvider": {
|
||||
"title": "Синхронизация серверов MCP",
|
||||
"subtitle": "Синхронизировать конфигурации серверов MCP от провайдеров с локальными",
|
||||
"steps": {
|
||||
"selectProvider": "Шаг 1: Выберите провайдер",
|
||||
"configureAuth": "Шаг 2: Настройте аутентификацию",
|
||||
"syncServers": "Шаг 3: Синхронизируйте серверы"
|
||||
},
|
||||
"providers": {
|
||||
"modelscope": "ModelScope",
|
||||
"description": "ModelScope — это сообщество открытых моделей, предоставляющее серверы MCP для различных сервисов машинного обучения и ИИ"
|
||||
},
|
||||
"fields": {
|
||||
"provider": "Выберите провайдер",
|
||||
"accessToken": "Токен доступа",
|
||||
"tokenRequired": "Требуется токен доступа",
|
||||
"tokenHint": "Введите ваш токен доступа ModelScope"
|
||||
},
|
||||
"buttons": {
|
||||
"cancel": "Отмена",
|
||||
"previous": "Назад",
|
||||
"next": "Далее",
|
||||
"sync": "Начать синхронизацию",
|
||||
"getToken": "Получить токен"
|
||||
},
|
||||
"status": {
|
||||
"selectProvider": "Пожалуйста, выберите провайдер сервера MCP",
|
||||
"enterToken": "Введите токен доступа для продолжения",
|
||||
"readyToSync": "Готово к синхронизации конфигураций серверов"
|
||||
},
|
||||
"messages": {
|
||||
"syncSuccess": "Серверы MCP успешно синхронизированы!",
|
||||
"syncError": "Ошибка синхронизации: {error}",
|
||||
"tokenHelp": "Как получить токен доступа ModelScope? Нажмите кнопку справа для получения инструкций"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"title": "Трассировка (Trace)",
|
||||
"autoScroll": {
|
||||
"enabled": "Автопрокрутка: ВКЛ",
|
||||
"disabled": "Автопрокрутка: ВЫКЛ"
|
||||
},
|
||||
"hint": "В данный момент записываются только вызовы моделей основного агента AstrBot. Система будет совершенствоваться.",
|
||||
"recording": "Запись...",
|
||||
"paused": "Пауза"
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"greeting": {
|
||||
"morning": "Доброе утро, добро пожаловать в AstrBot",
|
||||
"afternoon": "Добрый день, добро пожаловать в AstrBot",
|
||||
"evening": "Добрый вечер, добро пожаловать в AstrBot",
|
||||
"newYear": "С Новым Годом!"
|
||||
},
|
||||
"subtitle": "Сначала пройдите базовое руководство. Настройку платформ и провайдеров моделей можно завершить позже.",
|
||||
"announcement": {
|
||||
"title": "Объявление"
|
||||
},
|
||||
"onboard": {
|
||||
"title": "Быстрый старт",
|
||||
"subtitle": "Вы можете выполнить первичную настройку прямо здесь.",
|
||||
"step1Title": "Настройка платформ",
|
||||
"step1Desc": "Подключите AstrBot к QQ, Lark, WeChat, Telegram и другим мессенджерам.",
|
||||
"step2Title": "Настройка AI моделей",
|
||||
"step2Desc": "Выберите и настройте AI провайдеров для AstrBot.",
|
||||
"configure": "Настроить",
|
||||
"skip": "Пропустить",
|
||||
"pending": "Ожидает",
|
||||
"completed": "Готово",
|
||||
"skipped": "Пропущено",
|
||||
"platformLoadFailed": "Ошибка загрузки конфигурации платформ",
|
||||
"providerLoadFailed": "Ошибка загрузки конфигурации провайдеров",
|
||||
"providerUpdateFailed": "Ошибка обновления провайдера по умолчанию в файле default",
|
||||
"providerDefaultUpdated": "Провайдер {id} установлен по умолчанию в файле default"
|
||||
},
|
||||
"resources": {
|
||||
"title": "Ресурсы",
|
||||
"githubDesc": "Поставьте нам звезду на GitHub!",
|
||||
"docsTitle": "Документация",
|
||||
"docsDesc": "Официальная документация AstrBot.",
|
||||
"afdianTitle": "Afdian",
|
||||
"afdianDesc": "Поддержите команду AstrBot через Afdian."
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user