Compare commits

..

2 Commits

178 changed files with 2205 additions and 10273 deletions
+4 -20
View File
@@ -21,23 +21,7 @@
<!--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.
- [ ] ⚠️ 我已认真阅读并理解以上所有内容,确保本次提交符合规范。
/ 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.
- [ ] 😊 如果 PR 中有新加入的功能,已经通过 Issue / 邮件等方式和作者讨论过。/ If there are new features added in the PR, I have discussed it with the authors through issues/emails, etc.
- [ ] 👀 我的更改经过了良好的测试,**并已在上方提供了“验证步骤”和“运行截图”**。/ My changes have been well-tested, **and "Verification Steps" and "Screenshots" have been provided above**.
- [ ] 🤓 我确保没有引入新依赖库,或者引入了新依赖库的同时将其添加到了 `requirements.txt``pyproject.toml` 文件相应位置。/ I have ensured that no new dependencies are introduced, OR if new dependencies are introduced, they have been added to the appropriate locations in `requirements.txt` and `pyproject.toml`.
- [ ] 😮 我的更改没有引入恶意代码。/ My changes do not introduce malicious code.
+3 -3
View File
@@ -11,7 +11,7 @@ jobs:
runs-on: ubuntu-latest # 运行环境
steps:
- name: checkout
uses: actions/checkout@v6
uses: actions/checkout@master
- name: nodejs installation
uses: actions/setup-node@v6
with:
@@ -23,7 +23,7 @@ jobs:
run: npm run docs:build
working-directory: './docs'
- name: scp
uses: appleboy/scp-action@v1.0.0
uses: appleboy/scp-action@master
with:
host: ${{ secrets.HOST_NEKO }}
username: ${{ secrets.USERNAME }}
@@ -31,7 +31,7 @@ jobs:
source: 'docs/.vitepress/dist/*'
target: '/tmp/'
- name: script
uses: appleboy/ssh-action@v1.2.5
uses: appleboy/ssh-action@master
with:
host: ${{ secrets.HOST_NEKO }}
username: ${{ secrets.USERNAME }}
+1 -1
View File
@@ -45,7 +45,7 @@ jobs:
- name: Create GitHub Release
if: github.event_name == 'push'
uses: ncipollo/release-action@v1.20.0
uses: ncipollo/release-action@v1
with:
tag: release-${{ github.sha }}
owner: AstrBotDevs
+10 -10
View File
@@ -64,20 +64,20 @@ jobs:
echo "build_date=$build_date" >> $GITHUB_OUTPUT
- name: Set QEMU
uses: docker/setup-qemu-action@v4.0.0
uses: docker/setup-qemu-action@v4
- name: Set Docker Buildx
uses: docker/setup-buildx-action@v4.0.0
uses: docker/setup-buildx-action@v4
- name: Log in to DockerHub
uses: docker/login-action@v4.0.0
uses: docker/login-action@v4
with:
username: ${{ secrets.DOCKER_HUB_USERNAME }}
password: ${{ secrets.DOCKER_HUB_PASSWORD }}
- name: Login to GitHub Container Registry
if: env.HAS_GHCR_TOKEN == 'true'
uses: docker/login-action@v4.0.0
uses: docker/login-action@v4
with:
registry: ghcr.io
username: ${{ env.GHCR_OWNER }}
@@ -98,7 +98,7 @@ jobs:
echo "EOF" >> $GITHUB_OUTPUT
- name: Build and Push Nightly Image
uses: docker/build-push-action@v7.0.0
uses: docker/build-push-action@v7
with:
context: .
platforms: linux/amd64,linux/arm64
@@ -163,27 +163,27 @@ jobs:
cp -r dashboard/dist data/
- name: Set QEMU
uses: docker/setup-qemu-action@v4.0.0
uses: docker/setup-qemu-action@v4
- name: Set Docker Buildx
uses: docker/setup-buildx-action@v4.0.0
uses: docker/setup-buildx-action@v4
- name: Log in to DockerHub
uses: docker/login-action@v4.0.0
uses: docker/login-action@v4
with:
username: ${{ secrets.DOCKER_HUB_USERNAME }}
password: ${{ secrets.DOCKER_HUB_PASSWORD }}
- name: Login to GitHub Container Registry
if: env.HAS_GHCR_TOKEN == 'true'
uses: docker/login-action@v4.0.0
uses: docker/login-action@v4
with:
registry: ghcr.io
username: ${{ env.GHCR_OWNER }}
password: ${{ secrets.GHCR_GITHUB_TOKEN }}
- name: Build and Push Release Image
uses: docker/build-push-action@v7.0.0
uses: docker/build-push-action@v7
with:
context: .
platforms: linux/amd64,linux/arm64
-45
View File
@@ -1,45 +0,0 @@
name: PR Checklist Check
on:
pull_request_target:
types: [opened, edited, reopened, synchronize]
jobs:
check:
runs-on: ubuntu-latest
permissions:
pull-requests: write
issues: write
steps:
- name: Check checklist
id: check
uses: actions/github-script@v7
with:
script: |
const body = context.payload.pull_request.body || "";
const regex = /-\s*\[\s*x\s*\].*没有.*认真阅读/i;
const bad = regex.test(body);
core.setOutput("bad", bad);
- name: Close PR
if: steps.check.outputs.bad == 'true'
uses: actions/github-script@v7
with:
script: |
const pr = context.payload.pull_request;
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: pr.number,
body: `检测到你勾选了“我没有认真阅读”,PR 已关闭。`
});
await github.rest.pulls.update({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: pr.number,
state: "closed"
});
+1 -1
View File
@@ -50,7 +50,7 @@ jobs:
echo "tag=$tag" >> "$GITHUB_OUTPUT"
- name: Setup pnpm
uses: pnpm/action-setup@v4.4.0
uses: pnpm/action-setup@v4
with:
version: 10.28.2
+1 -1
View File
@@ -62,4 +62,4 @@ GenieData/
.opencode/
.kilocode/
.worktrees/
docs/plans/
+2 -3
View File
@@ -78,7 +78,7 @@ For users who want to quickly experience AstrBot, are familiar with command-line
```bash
uv tool install astrbot
astrbot init # Only execute this command for the first time to initialize the environment
astrbot run
astrbot
```
> Requires [uv](https://docs.astral.sh/uv/) to be installed.
@@ -234,8 +234,7 @@ pre-commit install
- Group 7: 743746109
- Group 8: 1030353265
- Developer Group(Chit-chat): 975206796
- Developer Group(Formal): 1039761811
- Developer Group: 975206796
### Discord Server
+1 -2
View File
@@ -78,7 +78,7 @@ Pour les utilisateurs qui veulent découvrir AstrBot rapidement, qui sont famili
```bash
uv tool install astrbot
astrbot init # Exécutez cette commande uniquement la première fois pour initialiser l'environnement
astrbot run
astrbot
```
> [uv](https://docs.astral.sh/uv/) doit être installé.
@@ -222,7 +222,6 @@ pre-commit install
- Groupe 5 : 822130018
- Groupe 6 : 753075035
- Groupe développeurs : 975206796
- Groupe développeurs (officiel) : 1039761811
### Serveur Discord
+1 -2
View File
@@ -78,7 +78,7 @@ AstrBot を素早く試したいユーザーで、コマンドラインに慣れ
```bash
uv tool install astrbot
astrbot init # 初回のみ実行して環境を初期化します
astrbot run
astrbot
```
> [uv](https://docs.astral.sh/uv/) のインストールが必要です。
@@ -223,7 +223,6 @@ pre-commit install
- 5群: 822130018
- 6群: 753075035
- 開発者群: 975206796
- 開発者群(正式): 1039761811
### Discord サーバー
+1 -2
View File
@@ -78,7 +78,7 @@ AstrBot — это универсальная платформа Agent-чатб
```bash
uv tool install astrbot
astrbot init # Выполните эту команду только при первом запуске для инициализации окружения
astrbot run
astrbot
```
> Требуется установленный [uv](https://docs.astral.sh/uv/).
@@ -222,7 +222,6 @@ pre-commit install
- Группа 5: 822130018
- Группа 6: 753075035
- Группа разработчиков: 975206796
- Группа разработчиков (официальная): 1039761811
### Сервер Discord
+2 -3
View File
@@ -78,7 +78,7 @@ AstrBot 是一個開源的一站式 Agent 聊天機器人平台,可接入主
```bash
uv tool install astrbot
astrbot init # 僅首次執行此命令以初始化環境
astrbot run
astrbot
```
> 需要安裝 [uv](https://docs.astral.sh/uv/)。
@@ -225,8 +225,7 @@ pre-commit install
- 6 群:753075035
- 7 群:743746109
- 8 群:1030353265
- 開發者群(闲聊吹水)975206796
- 開發者群(正式):1039761811
- 開發者群:975206796
### Discord 群組
+2 -3
View File
@@ -78,7 +78,7 @@ AstrBot 是一个开源的一站式 Agentic 个人和群聊助手,可在 QQ、
```bash
uv tool install astrbot
astrbot init # 仅首次执行此命令以初始化环境
astrbot run
astrbot
```
> 需要安装 [uv](https://docs.astral.sh/uv/)。
@@ -226,8 +226,7 @@ pre-commit install
- 6 群:753075035
- 7 群:743746109
- 8 群:1030353265
- 开发者群(偏闲聊吹水)975206796
- 开发者群(正式):1039761811
- 开发者群:975206796
### Discord 频道
+1 -1
View File
@@ -1 +1 @@
__version__ = "4.20.0"
__version__ = "4.19.5"
+21 -60
View File
@@ -326,7 +326,6 @@ async def run_live_agent(
# 创建队列
text_queue: asyncio.Queue[str | None] = asyncio.Queue()
delta_queue: asyncio.Queue[str | None] = asyncio.Queue()
# audio_queue stored bytes or (text, bytes)
audio_queue: asyncio.Queue[bytes | tuple[str, bytes] | None] = asyncio.Queue()
@@ -335,7 +334,6 @@ async def run_live_agent(
_run_agent_feeder(
agent_runner,
text_queue,
delta_queue,
max_step,
show_tool_use,
show_tool_call_result,
@@ -355,63 +353,32 @@ async def run_live_agent(
# 3. 主循环:从 audio_queue 读取音频并 yield
try:
delta_done = False
audio_done = False
while not (delta_done and audio_done):
task_sources: dict[asyncio.Task, str] = {}
if not delta_done:
task = asyncio.create_task(delta_queue.get())
task_sources[task] = "delta"
if not audio_done:
task = asyncio.create_task(audio_queue.get())
task_sources[task] = "audio"
while True:
queue_item = await audio_queue.get()
done, pending = await asyncio.wait(
list(task_sources),
return_when=asyncio.FIRST_COMPLETED,
)
if queue_item is None:
break
for task in pending:
task.cancel()
if pending:
await asyncio.gather(*pending, return_exceptions=True)
text = None
if isinstance(queue_item, tuple):
text, audio_data = queue_item
else:
audio_data = queue_item
for task in done:
source = task_sources[task]
queue_item = task.result()
if source == "delta":
if queue_item is None:
delta_done = True
continue
yield MessageChain(
chain=[Plain(queue_item)], type="live_text_delta"
)
continue
if not first_chunk_received:
# 记录首帧延迟(从开始处理到收到第一个音频块)
tts_first_frame_time = time.time() - tts_start_time
first_chunk_received = True
if queue_item is None:
audio_done = True
continue
# 将音频数据封装为 MessageChain
import base64
text = None
if isinstance(queue_item, tuple):
text, audio_data = queue_item
else:
audio_data = queue_item
if not first_chunk_received:
# 记录首帧延迟(从开始处理到收到第一个音频块)
tts_first_frame_time = time.time() - tts_start_time
first_chunk_received = True
# 将音频数据封装为 MessageChain
import base64
audio_b64 = base64.b64encode(audio_data).decode("utf-8")
comps: list[BaseMessageComponent] = [Plain(audio_b64)]
if text:
comps.append(Json(data={"text": text}))
chain = MessageChain(chain=comps, type="audio_chunk")
yield chain
audio_b64 = base64.b64encode(audio_data).decode("utf-8")
comps: list[BaseMessageComponent] = [Plain(audio_b64)]
if text:
comps.append(Json(data={"text": text}))
chain = MessageChain(chain=comps, type="audio_chunk")
yield chain
except Exception as e:
logger.error(f"[Live Agent] 运行时发生错误: {e}", exc_info=True)
@@ -454,7 +421,6 @@ async def run_live_agent(
async def _run_agent_feeder(
agent_runner: AgentRunner,
text_queue: asyncio.Queue,
delta_queue: asyncio.Queue,
max_step: int,
show_tool_use: bool,
show_tool_call_result: bool,
@@ -474,13 +440,9 @@ async def _run_agent_feeder(
if chain is None:
continue
if chain.type == "reasoning":
continue
# 提取文本
text = chain.get_plain_text()
if text:
await delta_queue.put(text)
buffer += text
# 分句逻辑:匹配标点符号
@@ -515,7 +477,6 @@ async def _run_agent_feeder(
finally:
# 发送结束信号
await text_queue.put(None)
await delta_queue.put(None)
async def _safe_tts_stream_wrapper(
-5
View File
@@ -778,14 +778,9 @@ def _plugin_tool_fix(event: AstrMessageEvent, req: ProviderRequest) -> None:
continue
mp = tool.handler_module_path
if not mp:
# 没有 plugin 归属信息的工具(如 subagent transfer_to_*
# 不应受到会话插件过滤影响。
new_tool_set.add_tool(tool)
continue
plugin = star_map.get(mp)
if not plugin:
# 无法解析插件归属时,保守保留工具,避免误过滤。
new_tool_set.add_tool(tool)
continue
if plugin.name in event.plugins_name or plugin.reserved:
new_tool_set.add_tool(tool)
+2 -20
View File
@@ -188,12 +188,7 @@ class KnowledgeBaseQueryTool(FunctionTool[AstrAgentContext]):
@dataclass
class SendMessageToUserTool(FunctionTool[AstrAgentContext]):
name: str = "send_message_to_user"
description: str = (
"Send message to the user. "
"Supports various message types including `plain`, `image`, `record`, `video`, `file`, and `mention_user`. "
"Use this tool to send media files (`image`, `record`, `video`, `file`), "
"or when you need to proactively message the user(such as cron job). For normal text replies, you can output directly."
)
description: str = "Directly send message to the user. Only use this tool when you need to proactively message the user. Otherwise you can directly output the reply in the conversation."
parameters: dict = Field(
default_factory=lambda: {
@@ -209,7 +204,7 @@ class SendMessageToUserTool(FunctionTool[AstrAgentContext]):
"type": "string",
"description": (
"Component type. One of: "
"plain, image, record, video, file, mention_user. Record is voice message."
"plain, image, record, file, mention_user"
),
},
"text": {
@@ -325,19 +320,6 @@ class SendMessageToUserTool(FunctionTool[AstrAgentContext]):
components.append(Comp.Record.fromURL(url=url))
else:
return f"error: messages[{idx}] must include path or url for record component."
elif msg_type == "video":
path = msg.get("path")
url = msg.get("url")
if path:
(
local_path,
file_from_sandbox,
) = await self._resolve_path_from_sandbox(context, path)
components.append(Comp.Video.fromFileSystem(path=local_path))
elif url:
components.append(Comp.Video.fromURL(url=url))
else:
return f"error: messages[{idx}] must include path or url for video component."
elif msg_type == "file":
path = msg.get("path")
url = msg.get("url")
+8 -38
View File
@@ -1,7 +1,6 @@
from __future__ import annotations
import asyncio
import locale
import os
import shutil
import subprocess
@@ -53,31 +52,6 @@ def _ensure_safe_path(path: str) -> str:
return abs_path
def _decode_shell_output(output: bytes | None) -> str:
if output is None:
return ""
preferred = locale.getpreferredencoding(False) or "utf-8"
try:
return output.decode("utf-8")
except (LookupError, UnicodeDecodeError):
pass
if os.name == "nt":
for encoding in ("mbcs", "cp936", "gbk", "gb18030"):
try:
return output.decode(encoding)
except (LookupError, UnicodeDecodeError):
continue
try:
return output.decode(preferred)
except (LookupError, UnicodeDecodeError):
pass
return output.decode("utf-8", errors="replace")
@dataclass
class LocalShellComponent(ShellComponent):
async def exec(
@@ -98,32 +72,28 @@ class LocalShellComponent(ShellComponent):
run_env.update({str(k): str(v) for k, v in env.items()})
working_dir = _ensure_safe_path(cwd) if cwd else get_astrbot_root()
if background:
# `command` is intentionally executed through the current shell so
# local computer-use behavior matches existing tool semantics.
# Safety relies on `_is_safe_command()` and the allowed-root checks.
proc = subprocess.Popen( # noqa: S602 # nosemgrep: python.lang.security.audit.dangerous-subprocess-use-audit
proc = subprocess.Popen(
command,
shell=shell,
cwd=working_dir,
env=run_env,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
)
return {"pid": proc.pid, "stdout": "", "stderr": "", "exit_code": None}
# `command` is intentionally executed through the current shell so
# local computer-use behavior matches existing tool semantics.
# Safety relies on `_is_safe_command()` and the allowed-root checks.
result = subprocess.run( # noqa: S602 # nosemgrep: python.lang.security.audit.dangerous-subprocess-use-audit
result = subprocess.run(
command,
shell=shell,
cwd=working_dir,
env=run_env,
timeout=timeout,
capture_output=True,
text=True,
)
return {
"stdout": _decode_shell_output(result.stdout),
"stderr": _decode_shell_output(result.stderr),
"stdout": result.stdout,
"stderr": result.stderr,
"exit_code": result.returncode,
}
-6
View File
@@ -422,12 +422,6 @@ async def get_booter(
) -> ComputerBooter:
config = context.get_config(umo=session_id)
runtime = config.get("provider_settings", {}).get("computer_use_runtime", "local")
if runtime == "local":
return get_local_booter()
elif runtime == "none":
raise RuntimeError("Sandbox runtime is disabled by configuration.")
sandbox_cfg = config.get("provider_settings", {}).get("sandbox", {})
booter_type = sandbox_cfg.get("booter", "shipyard_neo")
+1 -4
View File
@@ -164,10 +164,7 @@ class CreateSkillPayloadTool(NeoSkillToolBase):
"type": "object",
"properties": {
"payload": {
"anyOf": [
{"type": "object"},
{"type": "array", "items": {"type": "object"}},
],
"anyOf": [{"type": "object"}, {"type": "array"}],
"description": (
"Skill payload JSON. Typical schema: {skill_markdown, inputs, outputs, meta}. "
"This only stores content and returns payload_ref; it does not create a candidate or release."
+1 -16
View File
@@ -5,7 +5,7 @@ from typing import Any, TypedDict
from astrbot.core.utils.astrbot_path import get_astrbot_data_path
VERSION = "4.20.0"
VERSION = "4.19.5"
DB_PATH = os.path.join(get_astrbot_data_path(), "data_v4.db")
WEBHOOK_SUPPORTED_PLATFORMS = [
@@ -219,9 +219,6 @@ DEFAULT_CONFIG = {
"telegram": {
"pre_ack_emoji": {"enable": False, "emojis": ["✍️"]},
},
"discord": {
"pre_ack_emoji": {"enable": False, "emojis": ["🤔"]},
},
},
"wake_prefix": ["/"],
"log_level": "INFO",
@@ -1132,18 +1129,6 @@ CONFIG_METADATA_2 = {
"proxy": "",
"custom_headers": {},
},
"MiniMax": {
"id": "minimax",
"provider": "minimax",
"type": "openai_chat_completion",
"provider_type": "chat_completion",
"enable": True,
"key": [],
"api_base": "https://api.minimaxi.com/v1",
"timeout": 120,
"proxy": "",
"custom_headers": {},
},
"xAI": {
"id": "xai",
"provider": "xai",
+2 -2
View File
@@ -332,9 +332,9 @@ class CronJobManager:
cron_job=cron_job_str
)
req.prompt = (
"You are now responding to a scheduled task. "
"You are now responding to a scheduled task"
"Proceed according to your system instructions. "
"Output using same language as previous conversation. "
"Output using same language as previous conversation."
"After completing your task, summarize and output your actions and results."
)
if not req.func_tool:
-7
View File
@@ -647,13 +647,6 @@ class BaseDatabase(abc.ABC):
"""Get a Platform session by its ID."""
...
@abc.abstractmethod
async def get_platform_sessions_by_ids(
self, session_ids: list[str]
) -> list[PlatformSession]:
"""Get platform sessions by IDs."""
...
@abc.abstractmethod
async def get_platform_sessions_by_creator(
self,
-15
View File
@@ -1417,21 +1417,6 @@ class SQLiteDatabase(BaseDatabase):
result = await session.execute(query)
return result.scalar_one_or_none()
async def get_platform_sessions_by_ids(
self, session_ids: list[str]
) -> list[PlatformSession]:
"""Get platform sessions by IDs."""
if not session_ids:
return []
async with self.get_db() as session:
session: AsyncSession
query = select(PlatformSession).where(
col(PlatformSession.session_id).in_(session_ids)
)
result = await session.execute(query)
return list(result.scalars().all())
async def get_platform_sessions_by_creator(
self,
creator: str,
+3 -3
View File
@@ -96,10 +96,10 @@ class Plain(BaseMessageComponent):
def __init__(self, text: str, convert: bool = True, **_) -> None:
super().__init__(text=text, convert=convert, **_)
def toDict(self) -> dict:
return {"type": "text", "data": {"text": self.text}}
def toDict(self):
return {"type": "text", "data": {"text": self.text.strip()}}
async def to_dict(self) -> dict:
async def to_dict(self):
return {"type": "text", "data": {"text": self.text}}
@@ -6,7 +6,6 @@ from aiocqhttp import CQHttp, Event
from astrbot.api.event import AstrMessageEvent, MessageChain
from astrbot.api.message_components import (
At,
BaseMessageComponent,
File,
Image,
@@ -71,19 +70,11 @@ class AiocqhttpMessageEvent(AstrMessageEvent):
"""解析成 OneBot json 格式"""
ret = []
for segment in message_chain.chain:
if isinstance(segment, At):
# At 组件后插入一个空格,避免与后续文本粘连
d = await AiocqhttpMessageEvent._from_segment_to_dict(segment)
ret.append(d)
ret.append({"type": "text", "data": {"text": " "}})
elif isinstance(segment, Plain):
if isinstance(segment, Plain):
if not segment.text.strip():
continue
d = await AiocqhttpMessageEvent._from_segment_to_dict(segment)
ret.append(d)
else:
d = await AiocqhttpMessageEvent._from_segment_to_dict(segment)
ret.append(d)
d = await AiocqhttpMessageEvent._from_segment_to_dict(segment)
ret.append(d)
return ret
@classmethod
@@ -18,7 +18,7 @@ from botpy.types.message import MarkdownPayload, Media
from astrbot.api import logger
from astrbot.api.event import AstrMessageEvent, MessageChain
from astrbot.api.message_components import File, Image, Plain, Record, Video
from astrbot.api.message_components import Image, Plain, Record
from astrbot.api.platform import AstrBotMessage, PlatformMetadata
from astrbot.core.utils.astrbot_path import get_astrbot_temp_path
from astrbot.core.utils.io import download_image_by_url, file_to_base64
@@ -47,11 +47,6 @@ _patch_qq_botpy_formdata()
class QQOfficialMessageEvent(AstrMessageEvent):
MARKDOWN_NOT_ALLOWED_ERROR = "不允许发送原生 markdown"
IMAGE_FILE_TYPE = 1
VIDEO_FILE_TYPE = 2
VOICE_FILE_TYPE = 3
FILE_FILE_TYPE = 4
STREAM_MARKDOWN_NEWLINE_ERROR = "流式消息md分片需要\\n结束"
def __init__(
self,
@@ -70,71 +65,35 @@ class QQOfficialMessageEvent(AstrMessageEvent):
await self._post_send()
async def send_streaming(self, generator, use_fallback: bool = False):
"""流式输出仅支持消息列表私聊C2C),其他消息源退化为普通发送"""
# 先标记事件层“已执行发送操作”,避免异常路径遗漏
await super().send_streaming(generator, use_fallback)
# QQ C2C 流式协议:开始/中间分片使用 state=1,结束分片使用 state=10
"""流式输出仅支持消息列表私聊"""
stream_payload = {"state": 1, "id": None, "index": 0, "reset": False}
last_edit_time = 0 # 上次发送分片的时间
throttle_interval = 1 # 分片间最短间隔 (秒)
last_edit_time = 0 # 上次编辑消息的时间
throttle_interval = 1 # 编辑消息的间隔时间 (秒)
ret = None
source = (
self.message_obj.raw_message
) # 提前获取,避免 generator 为空时 NameError
try:
async for chain in generator:
source = self.message_obj.raw_message
if not isinstance(source, botpy.message.C2CMessage):
# 非 C2C 场景:直接累积,最后统一发
if not self.send_buffer:
self.send_buffer = chain
else:
self.send_buffer.chain.extend(chain.chain)
continue
# ---- C2C 流式场景 ----
# tool_call break 信号:工具开始执行,先把已有 buffer 以 state=10 结束当前流式段
if chain.type == "break":
if self.send_buffer:
stream_payload["state"] = 10
ret = await self._post_send(stream=stream_payload)
ret_id = self._extract_response_message_id(ret)
if ret_id is not None:
stream_payload["id"] = ret_id
# 重置 stream_payload,为下一段流式做准备
stream_payload = {
"state": 1,
"id": None,
"index": 0,
"reset": False,
}
last_edit_time = 0
continue
# 累积内容
if not self.send_buffer:
self.send_buffer = chain
else:
self.send_buffer.chain.extend(chain.chain)
# 节流:按时间间隔发送中间分片
current_time = asyncio.get_running_loop().time()
if current_time - last_edit_time >= throttle_interval:
ret = cast(
message.Message,
await self._post_send(stream=stream_payload),
)
stream_payload["index"] += 1
ret_id = self._extract_response_message_id(ret)
if ret_id is not None:
stream_payload["id"] = ret_id
last_edit_time = asyncio.get_running_loop().time()
self.send_buffer = None # 清空已发送的分片,避免下次重复发送旧内容
if isinstance(source, botpy.message.C2CMessage):
# 真流式传输
current_time = asyncio.get_running_loop().time()
time_since_last_edit = current_time - last_edit_time
if time_since_last_edit >= throttle_interval:
ret = cast(
message.Message,
await self._post_send(stream=stream_payload),
)
stream_payload["index"] += 1
stream_payload["id"] = ret["id"]
last_edit_time = asyncio.get_running_loop().time()
if isinstance(source, botpy.message.C2CMessage):
# 结束流式对话,发送 buffer 中剩余内容
# 结束流式对话,并且传输 buffer 中剩余的消息
stream_payload["state"] = 10
ret = await self._post_send(stream=stream_payload)
else:
@@ -142,22 +101,9 @@ class QQOfficialMessageEvent(AstrMessageEvent):
except Exception as e:
logger.error(f"发送流式消息时出错: {e}", exc_info=True)
# 避免累计内容在异常后被整包重复发送:仅清理缓存,不做非流式整包兜底
# 如需兜底,应该只发送未发送 delta(后续可继续优化)
self.send_buffer = None
return None
@staticmethod
def _extract_response_message_id(ret) -> str | None:
"""兼容 qq-botpy 返回 Message 对象或 dict 两种形态。"""
if ret is None:
return None
if isinstance(ret, dict):
ret_id = ret.get("id")
return str(ret_id) if ret_id is not None else None
ret_id = getattr(ret, "id", None)
return str(ret_id) if ret_id is not None else None
return await super().send_streaming(generator, use_fallback)
async def _post_send(self, stream: dict | None = None):
if not self.send_buffer:
@@ -180,37 +126,16 @@ class QQOfficialMessageEvent(AstrMessageEvent):
image_base64,
image_path,
record_file_path,
video_file_source,
file_source,
file_name,
) = await QQOfficialMessageEvent._parse_to_qqofficial(self.send_buffer)
# C2C 流式仅用于文本分片,富媒体时降级为普通发送,避免平台侧流式校验报错。
if stream and (image_base64 or record_file_path):
logger.debug("[QQOfficial] 检测到富媒体,降级为非流式发送。")
stream = None
if (
not plain_text
and not image_base64
and not image_path
and not record_file_path
and not video_file_source
and not file_source
):
return None
# QQ C2C 流式 API 说明:
# - 开始/中间分片(state=1):增量追加内容,不需要 \n(加了会导致强制换行)
# - 最终分片(state=10):结束流,content 必须以 \n 结尾(QQ API 要求)
if (
stream
and stream.get("state") == 10
and plain_text
and not plain_text.endswith("\n")
):
plain_text = plain_text + "\n"
payload: dict = {
# "content": plain_text,
"markdown": MarkdownPayload(content=plain_text) if plain_text else None,
@@ -232,7 +157,7 @@ class QQOfficialMessageEvent(AstrMessageEvent):
if image_base64:
media = await self.upload_group_and_c2c_image(
image_base64,
self.IMAGE_FILE_TYPE,
1,
group_openid=source.group_openid,
)
payload["media"] = media
@@ -240,39 +165,15 @@ class QQOfficialMessageEvent(AstrMessageEvent):
payload.pop("markdown", None)
payload["content"] = plain_text or None
if record_file_path: # group record msg
media = await self.upload_group_and_c2c_media(
media = await self.upload_group_and_c2c_record(
record_file_path,
self.VOICE_FILE_TYPE,
3,
group_openid=source.group_openid,
)
if media:
payload["media"] = media
payload["msg_type"] = 7
payload.pop("markdown", None)
payload["content"] = plain_text or None
if video_file_source:
media = await self.upload_group_and_c2c_media(
video_file_source,
self.VIDEO_FILE_TYPE,
group_openid=source.group_openid,
)
if media:
payload["media"] = media
payload["msg_type"] = 7
payload.pop("markdown", None)
payload["content"] = plain_text or None
if file_source:
media = await self.upload_group_and_c2c_media(
file_source,
self.FILE_FILE_TYPE,
file_name=file_name,
group_openid=source.group_openid,
)
if media:
payload["media"] = media
payload["msg_type"] = 7
payload.pop("markdown", None)
payload["content"] = plain_text or None
payload["media"] = media
payload["msg_type"] = 7
payload.pop("markdown", None)
payload["content"] = plain_text or None
ret = await self._send_with_markdown_fallback(
send_func=lambda retry_payload: self.bot.api.post_group_message(
group_openid=source.group_openid, # type: ignore
@@ -280,14 +181,13 @@ class QQOfficialMessageEvent(AstrMessageEvent):
),
payload=payload,
plain_text=plain_text,
stream=stream,
)
case botpy.message.C2CMessage():
if image_base64:
media = await self.upload_group_and_c2c_image(
image_base64,
self.IMAGE_FILE_TYPE,
1,
openid=source.author.user_openid,
)
payload["media"] = media
@@ -295,39 +195,15 @@ class QQOfficialMessageEvent(AstrMessageEvent):
payload.pop("markdown", None)
payload["content"] = plain_text or None
if record_file_path: # c2c record
media = await self.upload_group_and_c2c_media(
media = await self.upload_group_and_c2c_record(
record_file_path,
self.VOICE_FILE_TYPE,
3,
openid=source.author.user_openid,
)
if media:
payload["media"] = media
payload["msg_type"] = 7
payload.pop("markdown", None)
payload["content"] = plain_text or None
if video_file_source:
media = await self.upload_group_and_c2c_media(
video_file_source,
self.VIDEO_FILE_TYPE,
openid=source.author.user_openid,
)
if media:
payload["media"] = media
payload["msg_type"] = 7
payload.pop("markdown", None)
payload["content"] = plain_text or None
if file_source:
media = await self.upload_group_and_c2c_media(
file_source,
self.FILE_FILE_TYPE,
file_name=file_name,
openid=source.author.user_openid,
)
if media:
payload["media"] = media
payload["msg_type"] = 7
payload.pop("markdown", None)
payload["content"] = plain_text or None
payload["media"] = media
payload["msg_type"] = 7
payload.pop("markdown", None)
payload["content"] = plain_text or None
if stream:
ret = await self._send_with_markdown_fallback(
send_func=lambda retry_payload: self.post_c2c_message(
@@ -337,7 +213,6 @@ class QQOfficialMessageEvent(AstrMessageEvent):
),
payload=payload,
plain_text=plain_text,
stream=stream,
)
else:
ret = await self._send_with_markdown_fallback(
@@ -347,7 +222,6 @@ class QQOfficialMessageEvent(AstrMessageEvent):
),
payload=payload,
plain_text=plain_text,
stream=stream,
)
logger.debug(f"Message sent to C2C: {ret}")
@@ -363,7 +237,6 @@ class QQOfficialMessageEvent(AstrMessageEvent):
),
payload=payload,
plain_text=plain_text,
stream=stream,
)
case botpy.message.DirectMessage():
@@ -378,7 +251,6 @@ class QQOfficialMessageEvent(AstrMessageEvent):
),
payload=payload,
plain_text=plain_text,
stream=stream,
)
case _:
@@ -395,31 +267,10 @@ class QQOfficialMessageEvent(AstrMessageEvent):
send_func,
payload: dict,
plain_text: str,
stream: dict | None = None,
):
try:
return await send_func(payload)
except botpy.errors.ServerError as err:
# QQ 流式 markdown 分片校验:内容必须以换行结尾。
# 某些边界场景服务端仍可能判定失败,这里做一次修正重试。
if stream and self.STREAM_MARKDOWN_NEWLINE_ERROR in str(err):
retry_payload = payload.copy()
markdown_payload = retry_payload.get("markdown")
if isinstance(markdown_payload, dict):
md_content = cast(str, markdown_payload.get("content", "") or "")
if md_content and not md_content.endswith("\n"):
retry_payload["markdown"] = {"content": md_content + "\n"}
content = cast(str | None, retry_payload.get("content"))
if content and not content.endswith("\n"):
retry_payload["content"] = content + "\n"
logger.warning(
"[QQOfficial] 流式 markdown 分片换行校验失败,已修正后重试一次。"
)
return await send_func(retry_payload)
if (
self.MARKDOWN_NOT_ALLOWED_ERROR not in str(err)
or not payload.get("markdown")
@@ -431,14 +282,10 @@ class QQOfficialMessageEvent(AstrMessageEvent):
"[QQOfficial] markdown 发送被拒绝,回退到 content 模式重试。"
)
fallback_payload = payload.copy()
fallback_payload.pop("markdown", None)
fallback_payload["markdown"] = None
fallback_payload["content"] = plain_text
if fallback_payload.get("msg_type") == 2:
fallback_payload["msg_type"] = 0
if stream:
fallback_content = cast(str, fallback_payload.get("content") or "")
if fallback_content and not fallback_content.endswith("\n"):
fallback_payload["content"] = fallback_content + "\n"
return await send_func(fallback_payload)
async def upload_group_and_c2c_image(
@@ -480,19 +327,16 @@ class QQOfficialMessageEvent(AstrMessageEvent):
ttl=result.get("ttl", 0),
)
async def upload_group_and_c2c_media(
async def upload_group_and_c2c_record(
self,
file_source: str,
file_type: int,
srv_send_msg: bool = False,
file_name: str | None = None,
**kwargs,
) -> Media | None:
"""上传媒体文件"""
# 构建基础payload
payload = {"file_type": file_type, "srv_send_msg": srv_send_msg}
if file_name:
payload["file_name"] = file_name
# 处理文件数据
if os.path.exists(file_source):
@@ -556,21 +400,13 @@ class QQOfficialMessageEvent(AstrMessageEvent):
) -> message.Message:
payload = locals()
payload.pop("self", None)
# QQ API does not accept stream.id=None; remove it when not yet assigned
if "stream" in payload and payload["stream"] is not None:
stream_data = dict(payload["stream"])
if stream_data.get("id") is None:
stream_data.pop("id", None)
payload["stream"] = stream_data
route = Route("POST", "/v2/users/{openid}/messages", openid=openid)
result = await self.bot.api._http.request(route, json=payload)
if result is None:
logger.warning("[QQOfficial] post_c2c_message: API 返回 None,跳过本次发送")
return None
if not isinstance(result, dict):
logger.error(f"[QQOfficial] post_c2c_message: 响应不是 dict: {result}")
return None
raise RuntimeError(
f"Failed to post c2c message, response is not dict: {result}"
)
return message.Message(**result)
@@ -580,9 +416,6 @@ class QQOfficialMessageEvent(AstrMessageEvent):
image_base64 = None # only one img supported
image_file_path = None
record_file_path = None
video_file_source = None
file_source = None
file_name = None
for i in message.chain:
if isinstance(i, Plain):
plain_text += i.text
@@ -621,30 +454,6 @@ class QQOfficialMessageEvent(AstrMessageEvent):
except Exception as e:
logger.error(f"处理语音时出错: {e}")
record_file_path = None
elif isinstance(i, Video) and not video_file_source:
if i.file.startswith("file:///"):
video_file_source = i.file[8:]
else:
video_file_source = i.file
elif isinstance(i, File) and not file_source:
file_name = i.name
if i.file_:
file_path = i.file_
if file_path.startswith("file:///"):
file_path = file_path[8:]
elif file_path.startswith("file://"):
file_path = file_path[7:]
file_source = file_path
elif i.url:
file_source = i.url
else:
logger.debug(f"qq_official 忽略 {i.type}")
return (
plain_text,
image_base64,
image_file_path,
record_file_path,
video_file_source,
file_source,
file_name,
)
return plain_text, image_base64, image_file_path, record_file_path
@@ -3,10 +3,8 @@ from __future__ import annotations
import asyncio
import logging
import os
import random
import time
from types import SimpleNamespace
from typing import Any, cast
from typing import cast
import botpy
import botpy.message
@@ -14,7 +12,7 @@ from botpy import Client
from astrbot import logger
from astrbot.api.event import MessageChain
from astrbot.api.message_components import At, File, Image, Plain, Record, Video
from astrbot.api.message_components import At, File, Image, Plain
from astrbot.api.platform import (
AstrBotMessage,
MessageMember,
@@ -48,7 +46,6 @@ class botClient(Client):
)
abm.group_id = cast(str, message.group_openid)
abm.session_id = abm.group_id
self.platform.remember_session_scene(abm.session_id, "group")
self._commit(abm)
# 收到频道消息
@@ -59,7 +56,6 @@ class botClient(Client):
)
abm.group_id = message.channel_id
abm.session_id = abm.group_id
self.platform.remember_session_scene(abm.session_id, "channel")
self._commit(abm)
# 收到私聊消息
@@ -71,7 +67,6 @@ class botClient(Client):
MessageType.FRIEND_MESSAGE,
)
abm.session_id = abm.sender.user_id
self.platform.remember_session_scene(abm.session_id, "friend")
self._commit(abm)
# 收到 C2C 消息
@@ -81,11 +76,9 @@ class botClient(Client):
MessageType.FRIEND_MESSAGE,
)
abm.session_id = abm.sender.user_id
self.platform.remember_session_scene(abm.session_id, "friend")
self._commit(abm)
def _commit(self, abm: AstrBotMessage) -> None:
self.platform.remember_session_message_id(abm.session_id, abm.message_id)
self.platform.commit_event(
QQOfficialMessageEvent(
abm.message_str,
@@ -131,9 +124,6 @@ class QQOfficialPlatformAdapter(Platform):
self.client.set_platform(self)
self._session_last_message_id: dict[str, str] = {}
self._session_scene: dict[str, str] = {}
self.test_mode = os.environ.get("TEST_MODE", "off") == "on"
async def send_by_session(
@@ -141,191 +131,14 @@ class QQOfficialPlatformAdapter(Platform):
session: MessageSesion,
message_chain: MessageChain,
) -> None:
await self._send_by_session_common(session, message_chain)
async def _send_by_session_common(
self,
session: MessageSesion,
message_chain: MessageChain,
) -> None:
(
plain_text,
image_base64,
image_path,
record_file_path,
video_file_source,
file_source,
file_name,
) = await QQOfficialMessageEvent._parse_to_qqofficial(message_chain)
if (
not plain_text
and not image_path
and not image_base64
and not record_file_path
and not video_file_source
and not file_source
):
return
msg_id = self._session_last_message_id.get(session.session_id)
if not msg_id:
logger.warning(
"[QQOfficial] No cached msg_id for session: %s, skip send_by_session",
session.session_id,
)
return
payload: dict[str, Any] = {"content": plain_text, "msg_id": msg_id}
ret: Any = None
send_helper = SimpleNamespace(bot=self.client)
if session.message_type == MessageType.GROUP_MESSAGE:
scene = self._session_scene.get(session.session_id)
if scene == "group":
payload["msg_seq"] = random.randint(1, 10000)
if image_base64:
media = await QQOfficialMessageEvent.upload_group_and_c2c_image(
send_helper, # type: ignore
image_base64,
QQOfficialMessageEvent.IMAGE_FILE_TYPE,
group_openid=session.session_id,
)
payload["media"] = media
payload["msg_type"] = 7
if record_file_path:
media = await QQOfficialMessageEvent.upload_group_and_c2c_media(
send_helper, # type: ignore
record_file_path,
QQOfficialMessageEvent.VOICE_FILE_TYPE,
group_openid=session.session_id,
)
if media:
payload["media"] = media
payload["msg_type"] = 7
if video_file_source:
media = await QQOfficialMessageEvent.upload_group_and_c2c_media(
send_helper, # type: ignore
video_file_source,
QQOfficialMessageEvent.VIDEO_FILE_TYPE,
group_openid=session.session_id,
)
if media:
payload["media"] = media
payload["msg_type"] = 7
payload.pop("msg_id", None)
if file_source:
media = await QQOfficialMessageEvent.upload_group_and_c2c_media(
send_helper, # type: ignore
file_source,
QQOfficialMessageEvent.FILE_FILE_TYPE,
file_name=file_name,
group_openid=session.session_id,
)
if media:
payload["media"] = media
payload["msg_type"] = 7
payload.pop("msg_id", None)
ret = await self.client.api.post_group_message(
group_openid=session.session_id,
**payload,
)
else:
if image_path:
payload["file_image"] = image_path
ret = await self.client.api.post_message(
channel_id=session.session_id,
**payload,
)
elif session.message_type == MessageType.FRIEND_MESSAGE:
payload["msg_seq"] = random.randint(1, 10000)
if image_base64:
media = await QQOfficialMessageEvent.upload_group_and_c2c_image(
send_helper, # type: ignore
image_base64,
QQOfficialMessageEvent.IMAGE_FILE_TYPE,
openid=session.session_id,
)
payload["media"] = media
payload["msg_type"] = 7
if record_file_path:
media = await QQOfficialMessageEvent.upload_group_and_c2c_media(
send_helper, # type: ignore
record_file_path,
QQOfficialMessageEvent.VOICE_FILE_TYPE,
openid=session.session_id,
)
if media:
payload["media"] = media
payload["msg_type"] = 7
if video_file_source:
media = await QQOfficialMessageEvent.upload_group_and_c2c_media(
send_helper, # type: ignore
video_file_source,
QQOfficialMessageEvent.VIDEO_FILE_TYPE,
openid=session.session_id,
)
if media:
payload["media"] = media
payload["msg_type"] = 7
# QQ API rejects msg_id for media (video/file) messages sent
# via the proactive tool-call path; remove it to avoid 越权 error.
payload.pop("msg_id", None)
if file_source:
media = await QQOfficialMessageEvent.upload_group_and_c2c_media(
send_helper, # type: ignore
file_source,
QQOfficialMessageEvent.FILE_FILE_TYPE,
file_name=file_name,
openid=session.session_id,
)
if media:
payload["media"] = media
payload["msg_type"] = 7
payload.pop("msg_id", None)
ret = await QQOfficialMessageEvent.post_c2c_message(
send_helper, # type: ignore
openid=session.session_id,
**payload,
)
else:
logger.warning(
"[QQOfficial] Unsupported message type for send_by_session: %s",
session.message_type,
)
return
sent_message_id = self._extract_message_id(ret)
if sent_message_id:
self.remember_session_message_id(session.session_id, sent_message_id)
await super().send_by_session(session, message_chain)
def remember_session_message_id(self, session_id: str, message_id: str) -> None:
if not session_id or not message_id:
return
self._session_last_message_id[session_id] = message_id
def remember_session_scene(self, session_id: str, scene: str) -> None:
if not session_id or not scene:
return
self._session_scene[session_id] = scene
def _extract_message_id(self, ret: Any) -> str | None:
if isinstance(ret, dict):
message_id = ret.get("id")
return str(message_id) if message_id else None
message_id = getattr(ret, "id", None)
if message_id:
return str(message_id)
return None
raise NotImplementedError("QQ 机器人官方 API 适配器不支持 send_by_session")
def meta(self) -> PlatformMetadata:
return PlatformMetadata(
name="qq_official",
description="QQ 机器人官方 API 适配器",
id=cast(str, self.config.get("id")),
support_proactive_message=True,
support_proactive_message=False,
)
@staticmethod
@@ -345,10 +158,7 @@ class QQOfficialPlatformAdapter(Platform):
return
for attachment in attachments:
content_type = cast(
str,
getattr(attachment, "content_type", "") or "",
).lower()
content_type = cast(str, getattr(attachment, "content_type", "") or "")
url = QQOfficialPlatformAdapter._normalize_attachment_url(
cast(str | None, getattr(attachment, "url", None))
)
@@ -364,73 +174,7 @@ class QQOfficialPlatformAdapter(Platform):
or getattr(attachment, "name", None)
or "attachment",
)
ext = os.path.splitext(filename)[1].lower()
image_exts = {".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp"}
audio_exts = {
".mp3",
".wav",
".ogg",
".m4a",
".amr",
".silk",
}
video_exts = {
".mp4",
".mov",
".avi",
".mkv",
".webm",
}
if content_type.startswith("audio") or ext in audio_exts:
msg.append(Record.fromURL(url))
elif content_type.startswith("video") or ext in video_exts:
msg.append(Video.fromURL(url))
elif content_type.startswith("image") or ext in image_exts:
msg.append(Image.fromURL(url))
else:
msg.append(File(name=filename, file=url, url=url))
@staticmethod
def _parse_face_message(content: str) -> str:
"""Parse QQ official face message format and convert to readable text.
QQ official face message format:
<faceType=4,faceId="",ext="eyJ0ZXh0IjoiW+a7oeWktOmXruWPt10ifQ==">
The ext field contains base64-encoded JSON with a 'text' field
describing the emoji (e.g., '[满头问号]').
Args:
content: The message content that may contain face tags.
Returns:
Content with face tags replaced by readable emoji descriptions.
"""
import base64
import json
import re
def replace_face(match):
face_tag = match.group(0)
# Extract ext field from the face tag
ext_match = re.search(r'ext="([^"]*)"', face_tag)
if ext_match:
try:
ext_encoded = ext_match.group(1)
# Decode base64 and parse JSON
ext_decoded = base64.b64decode(ext_encoded).decode("utf-8")
ext_data = json.loads(ext_decoded)
emoji_text = ext_data.get("text", "")
if emoji_text:
return f"[表情:{emoji_text}]"
except Exception:
pass
# Fallback if parsing fails
return "[表情]"
# Match face tags: <faceType=...>
return re.sub(r"<faceType=\d+[^>]*>", replace_face, content)
msg.append(File(name=filename, file=url, url=url))
@staticmethod
def _parse_from_qqofficial(
@@ -457,10 +201,7 @@ class QQOfficialPlatformAdapter(Platform):
abm.group_id = message.group_openid
else:
abm.sender = MessageMember(message.author.user_openid, "")
# Parse face messages to readable text
abm.message_str = QQOfficialPlatformAdapter._parse_face_message(
message.content.strip()
)
abm.message_str = message.content.strip()
abm.self_id = "unknown_selfid"
msg.append(At(qq="qq_official"))
msg.append(Plain(abm.message_str))
@@ -476,12 +217,10 @@ class QQOfficialPlatformAdapter(Platform):
else:
abm.self_id = ""
plain_content = QQOfficialPlatformAdapter._parse_face_message(
message.content.replace(
"<@!" + str(abm.self_id) + ">",
"",
).strip()
)
plain_content = message.content.replace(
"<@!" + str(abm.self_id) + ">",
"",
).strip()
QQOfficialPlatformAdapter._append_attachments(msg, message.attachments)
abm.message = msg
@@ -1,5 +1,7 @@
import asyncio
import logging
import random
from types import SimpleNamespace
from typing import Any, cast
import botpy
@@ -13,6 +15,7 @@ from astrbot.core.platform.astr_message_event import MessageSesion
from astrbot.core.utils.webhook_utils import log_webhook_info
from ...register import register_platform_adapter
from ..qqofficial.qqofficial_message_event import QQOfficialMessageEvent
from ..qqofficial.qqofficial_platform_adapter import QQOfficialPlatformAdapter
from .qo_webhook_event import QQOfficialWebhookMessageEvent
from .qo_webhook_server import QQOfficialWebhook
@@ -120,11 +123,95 @@ class QQOfficialWebhookPlatformAdapter(Platform):
session: MessageSesion,
message_chain: MessageChain,
) -> None:
await QQOfficialPlatformAdapter._send_by_session_common(
cast(Any, self),
session,
message_chain,
)
(
plain_text,
image_base64,
image_path,
record_file_path,
) = await QQOfficialMessageEvent._parse_to_qqofficial(message_chain)
if not plain_text and not image_path:
return
msg_id = self._session_last_message_id.get(session.session_id)
if not msg_id:
logger.warning(
"[QQOfficialWebhook] No cached msg_id for session: %s, skip send_by_session",
session.session_id,
)
return
payload: dict[str, Any] = {"content": plain_text, "msg_id": msg_id}
ret: Any = None
send_helper = SimpleNamespace(bot=self.client)
if session.message_type == MessageType.GROUP_MESSAGE:
scene = self._session_scene.get(session.session_id)
if scene == "group":
payload["msg_seq"] = random.randint(1, 10000)
if image_base64:
media = await QQOfficialMessageEvent.upload_group_and_c2c_image(
send_helper, # type: ignore
image_base64,
1,
group_openid=session.session_id,
)
payload["media"] = media
payload["msg_type"] = 7
if record_file_path:
media = await QQOfficialMessageEvent.upload_group_and_c2c_record(
send_helper, # type: ignore
record_file_path,
3,
group_openid=session.session_id,
)
payload["media"] = media
payload["msg_type"] = 7
ret = await self.client.api.post_group_message(
group_openid=session.session_id,
**payload,
)
else:
if image_path:
payload["file_image"] = image_path
ret = await self.client.api.post_message(
channel_id=session.session_id,
**payload,
)
elif session.message_type == MessageType.FRIEND_MESSAGE:
payload["msg_seq"] = random.randint(1, 10000)
if image_base64:
media = await QQOfficialMessageEvent.upload_group_and_c2c_image(
send_helper, # type: ignore
image_base64,
1,
openid=session.session_id,
)
payload["media"] = media
payload["msg_type"] = 7
if record_file_path:
media = await QQOfficialMessageEvent.upload_group_and_c2c_record(
send_helper, # type: ignore
record_file_path,
3,
openid=session.session_id,
)
payload["media"] = media
payload["msg_type"] = 7
ret = await QQOfficialMessageEvent.post_c2c_message(
send_helper, # type: ignore
openid=session.session_id,
**payload,
)
else:
logger.warning(
"[QQOfficialWebhook] Unsupported message type for send_by_session: %s",
session.message_type,
)
return
sent_message_id = self._extract_message_id(ret)
if sent_message_id:
self.remember_session_message_id(session.session_id, sent_message_id)
await super().send_by_session(session, message_chain)
def remember_session_message_id(self, session_id: str, message_id: str) -> None:
if not session_id or not message_id:
@@ -1,6 +1,5 @@
import asyncio
import logging
import time
from typing import cast
import quart
@@ -40,9 +39,6 @@ class QQOfficialWebhook:
self.client = botpy_client
self.event_queue = event_queue
self.shutdown_event = asyncio.Event()
# Deduplication cache for webhook retry callbacks.
self._seen_event_ids: dict[str, float] = {}
self._dedup_ttl: int = 60 # seconds
async def initialize(self) -> None:
logger.info("正在登录到 QQ 官方机器人...")
@@ -110,22 +106,6 @@ class QQOfficialWebhook:
print(signed)
return signed
event_id = msg.get("id")
if event_id:
now = time.monotonic()
# Lazily evict expired entries to prevent unbounded growth.
expired = [
k
for k, ts in self._seen_event_ids.items()
if now - ts > self._dedup_ttl
]
for k in expired:
del self._seen_event_ids[k]
if event_id in self._seen_event_ids:
logger.debug(f"Duplicate webhook event {event_id!r}, skipping.")
return {"opcode": 12}
self._seen_event_ids[event_id] = now
if event and opcode == BotWebSocket.WS_DISPATCH_EVENT:
event = msg["t"].lower()
try:
@@ -289,8 +289,8 @@ class TelegramPlatformAdapter(Platform):
else:
message.type = MessageType.GROUP_MESSAGE
message.group_id = str(update.message.chat.id)
if update.message.is_topic_message and update.message.message_thread_id:
# Telegram Topic Group: include thread id to isolate per-topic sessions.
if update.message.message_thread_id:
# Topic Group
message.group_id += "#" + str(update.message.message_thread_id)
message.session_id = message.group_id
message.message_id = str(update.message.message_id)
@@ -25,16 +25,6 @@ from astrbot.api.platform import AstrBotMessage, MessageType, PlatformMetadata
from astrbot.core.utils.metrics import Metric
def _is_gif(path: str) -> bool:
if path.lower().endswith(".gif"):
return True
try:
with open(path, "rb") as f:
return f.read(6) in (b"GIF87a", b"GIF89a")
except OSError:
return False
class TelegramPlatformEvent(AstrMessageEvent):
# Telegram 的最大消息长度限制
MAX_MESSAGE_LENGTH = 4096
@@ -288,6 +278,7 @@ class TelegramPlatformEvent(AstrMessageEvent):
try:
md_text = telegramify_markdown.markdownify(
chunk,
normalize_whitespace=False,
)
await client.send_message(
text=md_text,
@@ -301,13 +292,7 @@ class TelegramPlatformEvent(AstrMessageEvent):
await client.send_message(text=chunk, **cast(Any, payload))
elif isinstance(i, Image):
image_path = await i.convert_to_file_path()
if _is_gif(image_path):
send_coro = client.send_animation
media_kwarg = {"animation": image_path}
else:
send_coro = client.send_photo
media_kwarg = {"photo": image_path}
await send_coro(**media_kwarg, **cast(Any, payload))
await client.send_photo(photo=image_path, **cast(Any, payload))
elif isinstance(i, File):
path = await i.get_file()
name = i.name or os.path.basename(path)
@@ -422,20 +407,12 @@ class TelegramPlatformEvent(AstrMessageEvent):
on_text(i.text)
elif isinstance(i, Image):
image_path = await i.convert_to_file_path()
if _is_gif(image_path):
action = ChatAction.UPLOAD_VIDEO
send_coro = self.client.send_animation
media_kwarg = {"animation": image_path}
else:
action = ChatAction.UPLOAD_PHOTO
send_coro = self.client.send_photo
media_kwarg = {"photo": image_path}
await self._send_media_with_action(
self.client,
action,
send_coro,
ChatAction.UPLOAD_PHOTO,
self.client.send_photo,
user_name=user_name,
**media_kwarg,
photo=image_path,
**cast(Any, payload),
)
elif isinstance(i, File):
@@ -479,6 +456,7 @@ class TelegramPlatformEvent(AstrMessageEvent):
try:
markdown_text = telegramify_markdown.markdownify(
delta,
normalize_whitespace=False,
)
await self.client.send_message(
text=markdown_text,
@@ -559,6 +537,7 @@ class TelegramPlatformEvent(AstrMessageEvent):
try:
md = telegramify_markdown.markdownify(
draft_text,
normalize_whitespace=False,
)
await self._send_message_draft(
user_name,
@@ -716,6 +695,7 @@ class TelegramPlatformEvent(AstrMessageEvent):
try:
markdown_text = telegramify_markdown.markdownify(
delta,
normalize_whitespace=False,
)
await self.client.edit_message_text(
text=markdown_text,
@@ -440,16 +440,9 @@ class WecomAIBotAdapter(Platform):
)
def _extract_session_id(self, message_data: dict[str, Any]) -> str:
"""从消息数据中提取会话ID
群聊使用 chatid单聊使用 userid
"""
chattype = message_data.get("chattype", "single")
if chattype == "group":
chat_id = message_data.get("chatid", "default_group")
return format_session_id("wecomai", chat_id)
else:
user_id = message_data.get("from", {}).get("userid", "default_user")
return format_session_id("wecomai", user_id)
"""从消息数据中提取会话ID"""
user_id = message_data.get("from", {}).get("userid", "default_user")
return format_session_id("wecomai", user_id)
async def _enqueue_message(
self,
-2
View File
@@ -808,8 +808,6 @@ class ProviderManager:
config.save_config()
# load instance
await self.load_provider(new_config)
# sync in-memory config for API queries (e.g., embedding provider list)
self.providers_config = astrbot_config["provider"]
async def terminate(self) -> None:
if self._mcp_init_task and not self._mcp_init_task.done():
@@ -13,11 +13,3 @@ class ProviderGroq(ProviderOpenAIOfficial):
) -> None:
super().__init__(provider_config, provider_settings)
self.reasoning_key = "reasoning"
def _finally_convert_payload(self, payloads: dict) -> None:
"""Groq rejects assistant history items that include reasoning_content."""
super()._finally_convert_payload(payloads)
for message in payloads.get("messages", []):
if message.get("role") == "assistant":
message.pop("reasoning_content", None)
message.pop("reasoning", None)
@@ -311,7 +311,7 @@ class ProviderOpenAIOfficial(Provider):
state.handle_chunk(chunk)
except Exception as e:
logger.warning("Saving chunk state error: " + str(e))
if not chunk.choices:
if len(chunk.choices) == 0:
continue
delta = chunk.choices[0].delta
# logger.debug(f"chunk delta: {delta}")
@@ -322,7 +322,7 @@ class ProviderOpenAIOfficial(Provider):
if reasoning:
llm_response.reasoning_content = reasoning
_y = True
if delta and delta.content:
if delta.content:
# Don't strip streaming chunks to preserve spaces between words
completion_text = self._normalize_content(delta.content, strip=False)
llm_response.result_chain = MessageChain(
@@ -345,7 +345,7 @@ class ProviderOpenAIOfficial(Provider):
) -> str:
"""Extract reasoning content from OpenAI ChatCompletion if available."""
reasoning_text = ""
if not completion.choices:
if len(completion.choices) == 0:
return reasoning_text
if isinstance(completion, ChatCompletion):
choice = completion.choices[0]
@@ -468,7 +468,7 @@ class ProviderOpenAIOfficial(Provider):
"""Parse OpenAI ChatCompletion into LLMResponse"""
llm_response = LLMResponse("assistant")
if not completion.choices:
if len(completion.choices) == 0:
raise Exception("API 返回的 completion 为空。")
choice = completion.choices[0]
@@ -629,8 +629,7 @@ class ProviderOpenAIOfficial(Provider):
# 最后一次不等待
if retry_cnt < max_retries - 1:
await asyncio.sleep(1)
if chosen_key in available_api_keys:
available_api_keys.remove(chosen_key)
available_api_keys.remove(chosen_key)
if len(available_api_keys) > 0:
chosen_key = random.choice(available_api_keys)
return (
@@ -16,7 +16,4 @@ class ProviderOpenRouter(ProviderOpenAIOfficial):
self.client._custom_headers["HTTP-Referer"] = ( # type: ignore
"https://github.com/AstrBotDevs/AstrBot"
)
self.client._custom_headers["X-OpenRouter-Title"] = "AstrBot" # type: ignore
self.client._custom_headers["X-OpenRouter-Categories"] = (
"general-chat,personal-agent" # type: ignore
)
self.client._custom_headers["X-TITLE"] = "AstrBot" # type: ignore
+7 -82
View File
@@ -3,7 +3,6 @@ from __future__ import annotations
import json
import os
import re
import shlex
import shutil
import tempfile
import zipfile
@@ -80,59 +79,7 @@ def _parse_frontmatter_description(text: str) -> str:
# Regex for sanitizing paths used in prompt examples — only allow
# safe path characters to prevent prompt injection via crafted skill paths.
_SAFE_PATH_RE = re.compile(r"[^\w./ ,()'\-]", re.UNICODE)
_WINDOWS_DRIVE_PATH_RE = re.compile(r"^[A-Za-z]:(?:/|\\)")
_WINDOWS_UNC_PATH_RE = re.compile(r"^(//|\\\\)[^/\\]+[/\\][^/\\]+")
_CONTROL_CHARS_RE = re.compile(r"[\x00-\x1F\x7F]")
def _is_windows_prompt_path(path: str) -> bool:
if os.name != "nt":
return False
return bool(_WINDOWS_DRIVE_PATH_RE.match(path) or _WINDOWS_UNC_PATH_RE.match(path))
def _sanitize_prompt_path_for_prompt(path: str) -> str:
if not path:
return ""
if _WINDOWS_DRIVE_PATH_RE.match(path) or _WINDOWS_UNC_PATH_RE.match(path):
path = path.replace("\\", "/")
drive_prefix = ""
if _WINDOWS_DRIVE_PATH_RE.match(path):
drive_prefix = path[:2]
path = path[2:]
path = path.replace("`", "")
path = _CONTROL_CHARS_RE.sub("", path)
sanitized = _SAFE_PATH_RE.sub("", path)
return f"{drive_prefix}{sanitized}"
def _sanitize_prompt_description(description: str) -> str:
description = description.replace("`", "")
description = _CONTROL_CHARS_RE.sub(" ", description)
description = " ".join(description.split())
return description
def _sanitize_skill_display_name(name: str) -> str:
if _SKILL_NAME_RE.fullmatch(name):
return name
return "<invalid_skill_name>"
def _build_skill_read_command_example(path: str) -> str:
if path == "<skills_root>/<skill_name>/SKILL.md":
return f"cat {path}"
if _is_windows_prompt_path(path):
command = "type"
path_arg = f'"{path}"'
else:
command = "cat"
path_arg = shlex.quote(path)
return f"{command} {path_arg}"
_SAFE_PATH_RE = re.compile(r"[^A-Za-z0-9_./ -]")
def build_skills_prompt(skills: list[SkillInfo]) -> str:
@@ -145,37 +92,16 @@ def build_skills_prompt(skills: list[SkillInfo]) -> str:
skills_lines: list[str] = []
example_path = ""
for skill in skills:
display_name = _sanitize_skill_display_name(skill.name)
description = skill.description or "No description"
if skill.source_type == "sandbox_only":
description = _sanitize_prompt_description(description)
if not description:
description = "Read SKILL.md for details."
if skill.source_type == "sandbox_only":
rendered_path = (
f"{str(SANDBOX_WORKSPACE_ROOT)}/{str(SANDBOX_SKILLS_ROOT)}/"
f"{display_name}/SKILL.md"
)
else:
rendered_path = _sanitize_prompt_path_for_prompt(skill.path)
if not rendered_path:
rendered_path = "<skills_root>/<skill_name>/SKILL.md"
skills_lines.append(
f"- **{display_name}**: {description}\n File: `{rendered_path}`"
f"- **{skill.name}**: {description}\n File: `{skill.path}`"
)
if not example_path:
example_path = rendered_path
example_path = skill.path
skills_block = "\n".join(skills_lines)
# Sanitize example_path — it may originate from sandbox cache (untrusted)
if example_path == "<skills_root>/<skill_name>/SKILL.md":
example_path = "<skills_root>/<skill_name>/SKILL.md"
else:
example_path = _sanitize_prompt_path_for_prompt(example_path)
example_path = example_path or "<skills_root>/<skill_name>/SKILL.md"
example_command = _build_skill_read_command_example(example_path)
example_path = _SAFE_PATH_RE.sub("", example_path) if example_path else ""
example_path = example_path or "<skills_root>/<skill_name>/SKILL.md"
return (
"## Skills\n\n"
@@ -193,9 +119,8 @@ def build_skills_prompt(skills: list[SkillInfo]) -> str:
"*Never silently skip a matching skill* — either use it or briefly "
"explain why you chose not to.\n"
"3. **Mandatory grounding** — Before executing any skill you MUST "
"first read its `SKILL.md` by running a shell command compatible "
"with the current runtime shell and using the **absolute path** "
f"shown above (e.g. `{example_command}`). "
"first read its `SKILL.md` by running a shell command with the "
f"**absolute path** shown above (e.g. `cat {example_path}`). "
"Never rely on memory or assumptions about a skill's content.\n"
"4. **Progressive disclosure** — Load only what is directly "
"referenced from `SKILL.md`:\n"
+9 -59
View File
@@ -1,14 +1,12 @@
"""插件的重载、启停、安装、卸载等操作。"""
import asyncio
import contextlib
import functools
import inspect
import json
import logging
import os
import sys
import tempfile
import traceback
from types import ModuleType
@@ -31,12 +29,12 @@ from astrbot.core.utils.astrbot_path import (
get_astrbot_config_path,
get_astrbot_path,
get_astrbot_plugin_path,
get_astrbot_temp_path,
)
from astrbot.core.utils.io import remove_dir
from astrbot.core.utils.metrics import Metric
from astrbot.core.utils.requirements_utils import (
plan_missing_requirements_install,
RequirementsPrecheckFailed,
find_missing_requirements_or_raise,
)
from . import StarMetadata
@@ -76,78 +74,30 @@ class PluginDependencyInstallError(Exception):
self.error = error
@contextlib.contextmanager
def _temporary_filtered_requirements_file(
*,
install_lines: tuple[str, ...],
):
filtered_requirements_path: str | None = None
temp_dir = get_astrbot_temp_path()
try:
os.makedirs(temp_dir, exist_ok=True)
with tempfile.NamedTemporaryFile(
mode="w",
suffix="_plugin_requirements.txt",
delete=False,
dir=temp_dir,
encoding="utf-8",
) as filtered_requirements_file:
filtered_requirements_file.write("\n".join(install_lines) + "\n")
filtered_requirements_path = filtered_requirements_file.name
yield filtered_requirements_path
finally:
if filtered_requirements_path and os.path.exists(filtered_requirements_path):
try:
os.remove(filtered_requirements_path)
except OSError as exc:
logger.warning(
"删除临时插件依赖文件失败:%s(路径:%s",
exc,
filtered_requirements_path,
)
async def _install_requirements_with_precheck(
*,
plugin_label: str,
requirements_path: str,
) -> None:
install_plan = plan_missing_requirements_install(requirements_path)
if install_plan is None:
try:
missing = find_missing_requirements_or_raise(requirements_path)
except RequirementsPrecheckFailed:
logger.info(
f"正在安装插件 {plugin_label} 的依赖库(缺失依赖预检查不可裁剪,回退到完整安装): "
f"正在安装插件 {plugin_label} 的依赖库(预检查失败,回退到完整安装): "
f"{requirements_path}"
)
await pip_installer.install(requirements_path=requirements_path)
return
if not install_plan.missing_names:
if not missing:
logger.info(f"插件 {plugin_label} 的依赖已满足,跳过安装。")
return
if not install_plan.install_lines:
fallback_reason = install_plan.fallback_reason or "unknown reason"
logger.info(
"检测到插件 %s 缺失依赖,但无法安全裁剪 requirements,回退到完整安装: %s (%s)",
plugin_label,
requirements_path,
fallback_reason,
)
await pip_installer.install(requirements_path=requirements_path)
return
logger.info(
f"检测到插件 {plugin_label} 缺失依赖,正在按 requirements.txt 安装: "
f"{requirements_path} -> {sorted(install_plan.missing_names)}"
f"{requirements_path} -> {sorted(missing)}"
)
with _temporary_filtered_requirements_file(
install_lines=install_plan.install_lines,
) as filtered_requirements_path:
await pip_installer.install(requirements_path=filtered_requirements_path)
await pip_installer.install(requirements_path=requirements_path)
class PluginManager:
+1 -1
View File
@@ -30,7 +30,7 @@ class CreateActiveCronTool(FunctionTool[AstrAgentContext]):
"properties": {
"cron_expression": {
"type": "string",
"description": "Cron expression defining recurring schedule (e.g., '0 8 * * *' or '0 23 * * mon-fri'). Prefer named weekdays like 'mon-fri' or 'sat,sun' instead of numeric day-of-week ranges such as '1-5' to avoid ambiguity across cron implementations.",
"description": "Cron expression defining recurring schedule (e.g., '0 8 * * *').",
},
"run_at": {
"type": "string",
+6 -16
View File
@@ -25,22 +25,12 @@ class UmopConfigRouter:
)
self.umop_to_conf_id = sp_data
@staticmethod
def _split_umo(umo: str) -> tuple[str, str, str] | None:
"""将 UMO 拆分为 3 个部分,同时保留 session_id 中的 ':'"""
if not isinstance(umo, str):
return None
parts = umo.split(":", 2)
if len(parts) != 3:
return None
return parts[0], parts[1], parts[2]
def _is_umo_match(self, p1: str, p2: str) -> bool:
"""判断 p2 umo 是否逻辑包含于 p1 umo"""
p1_ls = self._split_umo(p1)
p2_ls = self._split_umo(p2)
p1_ls = p1.split(":")
p2_ls = p2.split(":")
if p1_ls is None or p2_ls is None:
if len(p1_ls) != 3 or len(p2_ls) != 3:
return False # 非法格式
return all(p == "" or fnmatch.fnmatchcase(t, p) for p, t in zip(p1_ls, p2_ls))
@@ -72,7 +62,7 @@ class UmopConfigRouter:
"""
for part in new_routing:
if self._split_umo(part) is None:
if not isinstance(part, str) or len(part.split(":")) != 3:
raise ValueError(
"umop keys must be strings in the format [platform_id]:[message_type]:[session_id], with optional wildcards * or empty for all",
)
@@ -91,7 +81,7 @@ class UmopConfigRouter:
ValueError: 如果 umo 格式不正确
"""
if self._split_umo(umo) is None:
if not isinstance(umo, str) or len(umo.split(":")) != 3:
raise ValueError(
"umop must be a string in the format [platform_id]:[message_type]:[session_id], with optional wildcards * or empty for all",
)
@@ -109,7 +99,7 @@ class UmopConfigRouter:
ValueError: umo 格式不正确时抛出
"""
if self._split_umo(umo) is None:
if not isinstance(umo, str) or len(umo.split(":")) != 3:
raise ValueError(
"umop must be a string in the format [platform_id]:[message_type]:[session_id], with optional wildcards * or empty for all",
)
+3 -81
View File
@@ -4,7 +4,7 @@ import os
import re
import shlex
import sys
from collections.abc import Iterable, Iterator, Sequence
from collections.abc import Iterable, Iterator
from dataclasses import dataclass
from packaging.requirements import InvalidRequirement, Requirement
@@ -29,13 +29,6 @@ class ParsedPackageInput:
requirement_names: frozenset[str]
@dataclass(frozen=True)
class MissingRequirementsPlan:
missing_names: frozenset[str]
install_lines: tuple[str, ...]
fallback_reason: str | None = None
def canonicalize_distribution_name(name: str) -> str:
return re.sub(r"[-_.]+", "-", name).strip("-").lower()
@@ -371,8 +364,8 @@ def _load_requirement_lines_for_precheck(
None,
)
if fallback_line is not None:
logger.info(
"缺失依赖预检查发现无法安全裁剪的 option/direct-reference 行,将回退到完整安装: %s (%s)",
logger.warning(
"预检查缺失依赖失败,将回退到完整安装: unresolved direct reference in %s: %s",
requirements_path,
fallback_line,
)
@@ -388,13 +381,6 @@ def find_missing_requirements(requirements_path: str) -> set[str] | None:
if not can_precheck or requirement_lines is None:
return None
return find_missing_requirements_from_lines(requirement_lines)
def find_missing_requirements_from_lines(
requirement_lines: Sequence[str],
) -> set[str] | None:
required = list(iter_requirements(lines=requirement_lines))
if not required:
return set()
@@ -415,70 +401,6 @@ def find_missing_requirements_from_lines(
return missing
def build_missing_requirements_install_lines(
requirements_path: str,
requirement_lines: Sequence[str],
missing_names: set[str] | frozenset[str],
) -> tuple[str, ...] | None:
wanted_names = set(missing_names)
install_lines: list[str] = []
for line in requirement_lines:
parsed = _parse_requirement_line(line)
if parsed is None:
if looks_like_direct_reference(line) or line.startswith(("-", "--")):
logger.debug(
"缺失依赖行筛选回退到完整安装:requirements 中包含无法安全裁剪的 option/direct-reference 行: %s (%s)",
requirements_path,
line,
)
return None
continue
name, _specifier = parsed
if name in wanted_names:
install_lines.append(line)
return tuple(install_lines)
def plan_missing_requirements_install(
requirements_path: str,
) -> MissingRequirementsPlan | None:
can_precheck, requirement_lines = _load_requirement_lines_for_precheck(
requirements_path
)
if not can_precheck or requirement_lines is None:
return None
missing = find_missing_requirements_from_lines(requirement_lines)
if missing is None:
return None
install_lines = build_missing_requirements_install_lines(
requirements_path,
requirement_lines,
missing,
)
if install_lines is None:
return None
if missing and not install_lines:
logger.warning(
"预检查缺失依赖成功,但无法映射到可安装 requirement 行,将回退到完整安装: %s -> %s",
requirements_path,
sorted(missing),
)
return MissingRequirementsPlan(
missing_names=frozenset(missing),
install_lines=(),
fallback_reason="unmapped missing requirement names",
)
return MissingRequirementsPlan(
missing_names=frozenset(missing),
install_lines=install_lines,
)
def find_missing_requirements_or_raise(requirements_path: str) -> set[str]:
missing = find_missing_requirements(requirements_path)
if missing is None:
+1 -2
View File
@@ -82,8 +82,7 @@ class AuthRoute(Route):
def generate_jwt(self, username):
payload = {
"username": username,
"exp": datetime.datetime.now(datetime.timezone.utc)
+ datetime.timedelta(days=7),
"exp": datetime.datetime.utcnow() + datetime.timedelta(days=7),
}
jwt_token = self.config["dashboard"].get("jwt_secret", None)
if not jwt_token:
+1 -11
View File
@@ -977,17 +977,7 @@ class BackupRoute(Route):
if not jwt_secret:
return Response().error("服务器配置错误").__dict__
# Verify JWT token with strict security options
jwt.decode(
token,
jwt_secret,
algorithms=["HS256"],
options={
"require": ["exp"], # Require expiration claim
"verify_signature": True, # Explicitly verify signature
"verify_exp": True, # Verify expiration
},
)
jwt.decode(token, jwt_secret, algorithms=["HS256"])
except jwt.ExpiredSignatureError:
return Response().error("Token 已过期,请刷新页面后重试").__dict__
except jwt.InvalidTokenError:
+22 -85
View File
@@ -36,20 +36,6 @@ async def track_conversation(convs: dict, conv_id: str):
convs.pop(conv_id, None)
async def _poll_webchat_stream_result(back_queue, username: str):
try:
result = await asyncio.wait_for(back_queue.get(), timeout=1)
except asyncio.TimeoutError:
return None, False
except asyncio.CancelledError:
logger.debug(f"[WebChat] 用户 {username} 断开聊天长连接。")
return None, True
except Exception as e:
logger.error(f"WebChat stream error: {e}")
return None, False
return result, False
class ChatRoute(Route):
def __init__(
self,
@@ -65,7 +51,6 @@ class ChatRoute(Route):
"/chat/get_session": ("GET", self.get_session),
"/chat/stop": ("POST", self.stop_session),
"/chat/delete_session": ("GET", self.delete_webchat_session),
"/chat/batch_delete_sessions": ("POST", self.batch_delete_sessions),
"/chat/update_session_display_name": (
"POST",
self.update_session_display_name,
@@ -357,12 +342,16 @@ class ChatRoute(Route):
async with track_conversation(self.running_convs, webchat_conv_id):
while True:
result, should_break = await _poll_webchat_stream_result(
back_queue, username
)
if should_break:
try:
result = await asyncio.wait_for(back_queue.get(), timeout=1)
except asyncio.TimeoutError:
continue
except asyncio.CancelledError:
logger.debug(f"[WebChat] 用户 {username} 断开聊天长连接。")
client_disconnected = True
break
except Exception as e:
logger.error(f"WebChat stream error: {e}")
if not result:
continue
@@ -589,9 +578,19 @@ class ChatRoute(Route):
return Response().ok(data={"stopped_count": stopped_count}).__dict__
async def _delete_session_internal(self, session, username: str) -> None:
"""Delete a single session and all its related data."""
session_id = session.session_id
async def delete_webchat_session(self):
"""Delete a Platform session and all its related data."""
session_id = request.args.get("session_id")
if not session_id:
return Response().error("Missing key: session_id").__dict__
username = g.get("username", "guest")
# 验证会话是否存在且属于当前用户
session = await self.db.get_platform_session_by_id(session_id)
if not session:
return Response().error(f"Session {session_id} not found").__dict__
if session.creator != username:
return Response().error("Permission denied").__dict__
# 删除该会话下的所有对话
message_type = "GroupMessage" if session.is_group else "FriendMessage"
@@ -633,70 +632,8 @@ class ChatRoute(Route):
# 删除会话
await self.db.delete_platform_session(session_id)
async def delete_webchat_session(self):
"""Delete a Platform session and all its related data."""
session_id = request.args.get("session_id")
if not session_id:
return Response().error("Missing key: session_id").__dict__
username = g.get("username", "guest")
session = await self.db.get_platform_session_by_id(session_id)
if not session:
return Response().error(f"Session {session_id} not found").__dict__
if session.creator != username:
return Response().error("Permission denied").__dict__
await self._delete_session_internal(session, username)
return Response().ok().__dict__
async def batch_delete_sessions(self):
"""Batch delete multiple Platform sessions."""
post_data = await request.json
if post_data is None:
return Response().error("Missing JSON body").__dict__
if not isinstance(post_data, dict):
return Response().error("Invalid JSON body: expected object").__dict__
session_ids = post_data.get("session_ids")
if not session_ids or not isinstance(session_ids, list):
return Response().error("Missing or invalid key: session_ids").__dict__
username = g.get("username", "guest")
sessions = await self.db.get_platform_sessions_by_ids(session_ids)
sessions_by_id = {session.session_id: session for session in sessions}
deleted_count = 0
failed_items = []
for sid in session_ids:
session = sessions_by_id.get(sid)
if not session:
failed_items.append({"session_id": sid, "reason": "not found"})
continue
if session.creator != username:
failed_items.append({"session_id": sid, "reason": "permission denied"})
continue
try:
await self._delete_session_internal(session, username)
deleted_count += 1
sessions_by_id.pop(sid, None)
except Exception:
logger.warning("Failed to delete session %s", sid)
failed_items.append({"session_id": sid, "reason": "internal_error"})
return (
Response()
.ok(
data={
"deleted_count": deleted_count,
"failed_count": len(failed_items),
"failed_items": failed_items,
}
)
.__dict__
)
def _extract_attachment_ids(self, history_list) -> list[str]:
"""从消息历史中提取所有 attachment_id"""
attachment_ids = []
+57 -169
View File
@@ -130,51 +130,21 @@ class LiveChatRoute(Route):
async def live_chat_ws(self) -> None:
"""Legacy Live Chat WebSocket 处理器(默认 ct=live"""
token = websocket.args.get("token")
if not token:
await websocket.close(1008, "Missing authentication token")
return
try:
jwt_secret = self.config["dashboard"].get("jwt_secret")
payload = jwt.decode(token, jwt_secret, algorithms=["HS256"])
username = payload["username"]
except jwt.ExpiredSignatureError:
await websocket.close(1008, "Token expired")
return
except jwt.InvalidTokenError:
await websocket.close(1008, "Invalid token")
return
await self.run_ws_session(username=username, force_ct="live")
await self._unified_ws_loop(force_ct="live")
async def unified_chat_ws(self) -> None:
"""Unified Chat WebSocket 处理器(支持 ct=live/chat"""
token = websocket.args.get("token")
if not token:
await websocket.close(1008, "Missing authentication token")
return
try:
jwt_secret = self.config["dashboard"].get("jwt_secret")
payload = jwt.decode(token, jwt_secret, algorithms=["HS256"])
username = payload["username"]
except jwt.ExpiredSignatureError:
await websocket.close(1008, "Token expired")
return
except jwt.InvalidTokenError:
await websocket.close(1008, "Invalid token")
return
await self.run_ws_session(username=username, force_ct=None)
await self._unified_ws_loop(force_ct=None)
async def _unified_ws_loop(self, force_ct: str | None = None) -> None:
"""统一 WebSocket 循环"""
# Keep the legacy entry point for internal call sites.
# WebSocket 不能通过 header 传递 token,需要从 query 参数获取
# 注意:WebSocket 上下文使用 websocket.args 而不是 request.args
token = websocket.args.get("token")
if not token:
await websocket.close(1008, "Missing authentication token")
return
try:
jwt_secret = self.config["dashboard"].get("jwt_secret")
payload = jwt.decode(token, jwt_secret, algorithms=["HS256"])
@@ -185,10 +155,7 @@ class LiveChatRoute(Route):
except jwt.InvalidTokenError:
await websocket.close(1008, "Invalid token")
return
await self.run_ws_session(username=username, force_ct=force_ct)
async def run_ws_session(self, username: str, force_ct: str | None = None) -> None:
"""Run a live/unified websocket session for an authenticated username."""
session_id = f"webchat_live!{username}!{uuid.uuid4()}"
live_session = LiveChatSession(session_id, username)
self.sessions[session_id] = live_session
@@ -723,16 +690,6 @@ class LiveChatRoute(Route):
elif msg_type == "end_speaking":
# 结束说话
if session.is_processing:
await websocket.send_json(
{
"t": "error",
"data": "Session is busy",
"code": "PROCESSING_ERROR",
}
)
return
stamp = message.get("stamp")
if not stamp:
logger.warning("[Live Chat] end_speaking 缺少 stamp")
@@ -746,59 +703,45 @@ class LiveChatRoute(Route):
# 处理音频:STT -> LLM -> TTS
await self._process_audio(session, audio_path, assemble_duration)
elif msg_type == "text_input":
if session.is_processing:
await websocket.send_json(
{
"t": "error",
"data": "Session is busy",
"code": "PROCESSING_ERROR",
}
)
return
user_text = message.get("text")
if not isinstance(user_text, str):
user_text = message.get("message")
if not isinstance(user_text, str) or not user_text.strip():
await websocket.send_json(
{
"t": "error",
"data": "message must be non-empty text",
"code": "INVALID_MESSAGE_FORMAT",
}
)
return
await self._process_live_user_text(
session,
user_text=user_text.strip(),
initial_metrics={"input_type": "text"},
processing_start_time=time.time(),
)
elif msg_type == "interrupt":
# 用户打断
session.should_interrupt = True
logger.info(f"[Live Chat] 用户打断: {session.username}")
async def _process_live_user_text(
self,
session: LiveChatSession,
user_text: str,
initial_metrics: dict[str, Any] | None = None,
processing_start_time: float | None = None,
async def _process_audio(
self, session: LiveChatSession, audio_path: str, assemble_duration: float
) -> None:
"""处理 Live 用户文本:走 run_live_agent pipeline 并回传流式 TTS."""
"""处理音频:STT -> LLM -> 流式 TTS"""
try:
if initial_metrics:
await websocket.send_json({"t": "metrics", "data": initial_metrics})
# 发送 WAV 组装耗时
await websocket.send_json(
{"t": "metrics", "data": {"wav_assemble_time": assemble_duration}}
)
wav_assembly_finish_time = time.time()
processing_start = processing_start_time or time.time()
session.is_processing = True
session.should_interrupt = False
# 1. STT - 语音转文字
ctx = self.plugin_manager.context
stt_provider = ctx.provider_manager.stt_provider_insts[0]
if not stt_provider:
logger.error("[Live Chat] STT Provider 未配置")
await websocket.send_json({"t": "error", "data": "语音识别服务未配置"})
return
await websocket.send_json(
{"t": "metrics", "data": {"stt": stt_provider.meta().type}}
)
user_text = await stt_provider.get_text(audio_path)
if not user_text:
logger.warning("[Live Chat] STT 识别结果为空")
return
logger.info(f"[Live Chat] STT 结果: {user_text}")
await websocket.send_json(
{
"t": "user_msg",
@@ -818,6 +761,7 @@ class LiveChatRoute(Route):
"action_type": "live", # 标记为 live mode
}
# 将消息放入队列
await queue.put((session.username, cid, payload))
# 3. 等待响应并流式发送 TTS 音频
@@ -832,9 +776,11 @@ class LiveChatRoute(Route):
# 用户打断,停止处理
logger.info("[Live Chat] 检测到用户打断")
await websocket.send_json({"t": "stop_play"})
# 保存消息并标记为被打断
await self._save_interrupted_message(
session, user_text, bot_text
)
# 清空队列中未处理的消息
while not back_queue.empty():
try:
back_queue.get_nowait()
@@ -859,7 +805,6 @@ class LiveChatRoute(Route):
result_type = result.get("type")
result_chain_type = result.get("chain_type")
result_streaming = bool(result.get("streaming", False))
data = result.get("data", "")
if result_chain_type == "agent_stats":
@@ -882,41 +827,29 @@ class LiveChatRoute(Route):
if result_chain_type == "tts_stats":
try:
stats = json.loads(data)
await websocket.send_json({"t": "metrics", "data": stats})
await websocket.send_json(
{
"t": "metrics",
"data": stats,
}
)
except Exception as e:
logger.error(f"[Live Chat] 解析 TTSStats 失败: {e}")
continue
if result_chain_type == "live_text_delta":
if data:
await websocket.send_json(
{
"t": "bot_delta_chunk",
"data": {"text": data},
}
)
continue
if result_type == "plain":
if (
result_streaming
and data
and result_chain_type != "reasoning"
):
await websocket.send_json(
{
"t": "bot_delta_chunk",
"data": {"text": data},
}
)
# 普通文本消息
bot_text += data
elif result_type == "audio_chunk":
# 流式音频数据
if not audio_playing:
audio_playing = True
logger.debug("[Live Chat] 开始播放音频流")
# Calculate latency from wav assembly finish to first audio chunk
speak_to_first_frame_latency = (
time.time() - processing_start
time.time() - wav_assembly_finish_time
)
await websocket.send_json(
{
@@ -936,15 +869,19 @@ class LiveChatRoute(Route):
}
)
# 发送音频数据给前端
await websocket.send_json(
{
"t": "response",
"data": data,
"data": data, # base64 编码的音频数据
}
)
elif result_type in ["complete", "end"]:
# 处理完成
logger.info(f"[Live Chat] Bot 回复完成: {bot_text}")
# 如果没有音频流,发送 bot 消息文本
if not audio_playing:
await websocket.send_json(
{
@@ -956,8 +893,11 @@ class LiveChatRoute(Route):
}
)
# 发送结束标记
await websocket.send_json({"t": "end"})
wav_to_tts_duration = time.time() - processing_start
# 发送总耗时
wav_to_tts_duration = time.time() - wav_assembly_finish_time
await websocket.send_json(
{
"t": "metrics",
@@ -969,65 +909,13 @@ class LiveChatRoute(Route):
webchat_queue_mgr.remove_back_queue(message_id)
except Exception as e:
logger.error(f"[Live Chat] 处理文本失败: {e}", exc_info=True)
logger.error(f"[Live Chat] 处理音频失败: {e}", exc_info=True)
await websocket.send_json({"t": "error", "data": f"处理失败: {str(e)}"})
finally:
session.is_processing = False
session.should_interrupt = False
async def _process_audio(
self, session: LiveChatSession, audio_path: str, assemble_duration: float
) -> None:
"""处理音频:STT -> LLM -> 流式 TTS"""
try:
await websocket.send_json(
{
"t": "metrics",
"data": {
"wav_assemble_time": assemble_duration,
"input_type": "audio",
},
}
)
wav_assembly_finish_time = time.time()
# 1. STT - 语音转文字
ctx = self.plugin_manager.context
stt_provider = ctx.provider_manager.stt_provider_insts[0]
if not stt_provider:
logger.error("[Live Chat] STT Provider 未配置")
await websocket.send_json({"t": "error", "data": "语音识别服务未配置"})
return
await websocket.send_json(
{
"t": "metrics",
"data": {
"stt": stt_provider.meta().type,
},
}
)
user_text = await stt_provider.get_text(audio_path)
if not user_text:
logger.warning("[Live Chat] STT 识别结果为空")
return
logger.info(f"[Live Chat] STT 结果: {user_text}")
await self._process_live_user_text(
session,
user_text=user_text,
initial_metrics=None,
processing_start_time=wav_assembly_finish_time,
)
except Exception as e:
logger.error(f"[Live Chat] 处理音频失败: {e}", exc_info=True)
await websocket.send_json({"t": "error", "data": f"处理失败: {str(e)}"})
async def _save_interrupted_message(
self, session: LiveChatSession, user_text: str, bot_text: str
) -> None:
-37
View File
@@ -19,7 +19,6 @@ from astrbot.core.utils.datetime_utils import to_utc_isoformat
from .api_key import ALL_OPEN_API_SCOPES
from .chat import ChatRoute
from .live_chat import LiveChatRoute
from .route import Response, Route, RouteContext
@@ -30,14 +29,12 @@ class OpenApiRoute(Route):
db: BaseDatabase,
core_lifecycle: AstrBotCoreLifecycle,
chat_route: ChatRoute,
live_chat_route: LiveChatRoute,
) -> None:
super().__init__(context)
self.db = db
self.core_lifecycle = core_lifecycle
self.platform_manager = core_lifecycle.platform_manager
self.chat_route = chat_route
self.live_chat_route = live_chat_route
self.routes = {
"/v1/chat": ("POST", self.chat_send),
@@ -49,7 +46,6 @@ class OpenApiRoute(Route):
}
self.register_routes()
self.app.websocket("/api/v1/chat/ws")(self.chat_ws)
self.app.websocket("/api/v1/live/ws")(self.live_ws)
@staticmethod
def _resolve_open_username(
@@ -538,39 +534,6 @@ class OpenApiRoute(Route):
except Exception as e:
logger.debug("Open API WS connection closed: %s", e)
async def live_ws(self) -> None:
authed, auth_err = await self._authenticate_chat_ws_api_key()
if not authed:
await self._send_chat_ws_error(auth_err or "Unauthorized", "UNAUTHORIZED")
await websocket.close(1008, auth_err or "Unauthorized")
return
username, username_err = self._resolve_open_username(
websocket.args.get("username")
)
if username_err or not username:
await self._send_chat_ws_error(
username_err or "Invalid username",
"BAD_USER",
)
await websocket.close(1008, username_err or "Invalid username")
return
ct = websocket.args.get("ct")
force_ct = ct.strip() if isinstance(ct, str) and ct.strip() else "live"
if force_ct not in {"live", "chat"}:
await self._send_chat_ws_error(
"ct must be 'live' or 'chat'",
"INVALID_MESSAGE",
)
await websocket.close(1008, "Invalid ct")
return
await self.live_chat_route.run_ws_session(
username=username,
force_ct=force_ct,
)
async def upload_file(self):
return await self.chat_route.post_file()
+153 -1
View File
@@ -43,6 +43,7 @@ class SessionManagementRoute(Route):
"/session/group/create": ("POST", self.create_group),
"/session/group/update": ("POST", self.update_group),
"/session/group/delete": ("POST", self.delete_group),
"/session/group/update-config": ("POST", self.update_group_config),
}
self.conv_mgr = core_lifecycle.conversation_manager
self.core_lifecycle = core_lifecycle
@@ -145,9 +146,20 @@ class SessionManagementRoute(Route):
page=page, page_size=page_size, search=search
)
# 构建规则列表
# 收集属于有配置分组的 UMO,避免重复显示
grouped_umos = set()
groups = self._get_groups()
for group_data in groups.values():
if group_data.get("config"):
grouped_umos.update(group_data.get("umos", []))
# 构建规则列表(排除已被分组管理的 UMO)
rules_list = []
filtered_count = 0
for umo, rules in umo_rules.items():
if umo in grouped_umos:
filtered_count += 1
continue
rule_info = {
"umo": umo,
"rules": rules,
@@ -159,6 +171,7 @@ class SessionManagementRoute(Route):
rule_info["message_type"] = parts[1]
rule_info["session_id"] = parts[2]
rules_list.append(rule_info)
total -= filtered_count
# 获取可用的 providers 和 personas
provider_manager = self.core_lifecycle.provider_manager
@@ -240,6 +253,7 @@ class SessionManagementRoute(Route):
"available_plugins": available_plugins,
"available_kbs": available_kbs,
"available_rule_keys": AVAILABLE_SESSION_RULE_KEYS,
"group_rules": self._get_group_rules(),
}
)
.__dict__
@@ -793,6 +807,51 @@ class SessionManagementRoute(Route):
"""保存分组"""
sp.put("session_groups", groups)
def _get_group_rules(self) -> list:
"""获取有配置的分组列表,用于在规则列表中显示"""
groups = self._get_groups()
group_rules = []
for group_id, group_data in groups.items():
config = group_data.get("config", {})
if config: # 只返回有配置的分组
group_rules.append(
{
"group_id": group_id,
"name": group_data.get("name", ""),
"umo_count": len(group_data.get("umos", [])),
"config": config,
}
)
return group_rules
async def _sync_group_config_to_umos(
self, config: dict, umos: list[str]
) -> tuple[int, list[str]]:
"""将分组配置同步到指定的 UMO 列表
Returns:
(success_count, failed_umos)
"""
success_count = 0
failed_umos = []
for umo in umos:
try:
for rule_key, rule_value in config.items():
if rule_key not in AVAILABLE_SESSION_RULE_KEYS:
continue
if rule_value is None:
continue
if rule_key == "session_plugin_config":
# session_plugin_config 需要包裹 umo key
await sp.session_put(umo, rule_key, {umo: rule_value})
else:
await sp.session_put(umo, rule_key, rule_value)
success_count += 1
except Exception as e:
logger.error(f"同步配置到 {umo} 失败: {e!s}")
failed_umos.append(umo)
return success_count, failed_umos
async def list_groups(self):
"""获取所有分组列表"""
try:
@@ -806,6 +865,7 @@ class SessionManagementRoute(Route):
"name": group_data.get("name", ""),
"umos": group_data.get("umos", []),
"umo_count": len(group_data.get("umos", [])),
"config": group_data.get("config", {}),
}
)
return Response().ok({"groups": groups_list}).__dict__
@@ -875,6 +935,7 @@ class SessionManagementRoute(Route):
return Response().error(f"分组 '{group_id}' 不存在").__dict__
group = groups[group_id]
old_umos = set(group.get("umos", []))
# 更新名称
if name is not None:
@@ -883,6 +944,7 @@ class SessionManagementRoute(Route):
# 直接设置 umos 列表
if umos is not None:
group["umos"] = umos
new_umos = set(umos)
else:
# 增量更新
current_umos = set(group.get("umos", []))
@@ -891,9 +953,21 @@ class SessionManagementRoute(Route):
if remove_umos:
current_umos.difference_update(remove_umos)
group["umos"] = list(current_umos)
new_umos = current_umos
self._save_groups(groups)
# 自动同步分组配置给新加入的成员
group_config = group.get("config", {})
newly_added = new_umos - old_umos
if group_config and newly_added:
sync_count, _ = await self._sync_group_config_to_umos(
group_config, list(newly_added)
)
logger.info(
f"自动同步分组 '{group['name']}' 配置到 {sync_count} 个新成员"
)
return (
Response()
.ok(
@@ -936,3 +1010,81 @@ class SessionManagementRoute(Route):
except Exception as e:
logger.error(f"删除分组失败: {e!s}")
return Response().error(f"删除分组失败: {e!s}").__dict__
async def update_group_config(self):
"""更新分组的配置,并同步到所有成员 UMO
请求体:
{
"group_id": "分组ID",
"config": {
"session_service_config": {...},
"session_plugin_config": {...},
"kb_config": {...},
"provider_perf_chat_completion": ...,
"provider_perf_speech_to_text": ...,
"provider_perf_text_to_speech": ...
}
}
"""
try:
data = await request.get_json()
group_id = data.get("group_id")
config = data.get("config", {})
if not group_id:
return Response().error("缺少必要参数: group_id").__dict__
groups = self._get_groups()
if group_id not in groups:
return Response().error(f"分组 '{group_id}' 不存在").__dict__
group = groups[group_id]
# 保存配置到分组
group["config"] = config
self._save_groups(groups)
# 同步到所有成员 UMO
umos = group.get("umos", [])
if not config:
# 空配置 → 清除成员上的所有分组下发规则
success_count = 0
failed_umos = []
for umo in umos:
try:
for rule_key in AVAILABLE_SESSION_RULE_KEYS:
try:
await sp.session_remove(umo, rule_key)
except Exception:
pass
success_count += 1
except Exception as e:
logger.error(f"清除 {umo} 规则失败: {e!s}")
failed_umos.append(umo)
else:
success_count, failed_umos = await self._sync_group_config_to_umos(
config, umos
)
msg = f"分组 '{group['name']}' 配置已保存并同步到 {success_count}/{len(umos)} 个会话"
if failed_umos:
msg += f"{len(failed_umos)} 个失败"
return (
Response()
.ok(
{
"message": msg,
"success_count": success_count,
"failed_count": len(failed_umos),
"failed_umos": failed_umos,
}
)
.__dict__
)
except Exception as e:
logger.error(f"更新分组配置失败: {e!s}")
return Response().error(f"更新分组配置失败: {e!s}").__dict__
+1 -3
View File
@@ -115,13 +115,11 @@ class AstrBotDashboard:
self.ar = AuthRoute(self.context)
self.api_key_route = ApiKeyRoute(self.context, db)
self.chat_route = ChatRoute(self.context, db, core_lifecycle)
self.live_chat_route = LiveChatRoute(self.context, db, core_lifecycle)
self.open_api_route = OpenApiRoute(
self.context,
db,
core_lifecycle,
self.chat_route,
self.live_chat_route,
)
self.chatui_project_route = ChatUIProjectRoute(self.context, db)
self.tools_root = ToolsRoute(self.context, core_lifecycle)
@@ -140,6 +138,7 @@ class AstrBotDashboard:
self.kb_route = KnowledgeBaseRoute(self.context, core_lifecycle)
self.platform_route = PlatformRoute(self.context, core_lifecycle)
self.backup_route = BackupRoute(self.context, db, core_lifecycle)
self.live_chat_route = LiveChatRoute(self.context, db, core_lifecycle)
self.app.add_url_rule(
"/api/plug/<path:subpath>",
@@ -245,7 +244,6 @@ class AstrBotDashboard:
scope_map = {
"/api/v1/chat": "chat",
"/api/v1/chat/ws": "chat",
"/api/v1/live/ws": "chat",
"/api/v1/chat/sessions": "chat",
"/api/v1/configs": "config",
"/api/v1/file": "file",
-64
View File
@@ -1,64 +0,0 @@
## What's Changed
### 新增
- 新增俄语翻译([#6081](https://github.com/AstrBotDevs/AstrBot/pull/6081))。
- QQ 官方 Bot 新增文件、语音、视频消息支持(含 WebSocket 模式)([#6063](https://github.com/AstrBotDevs/AstrBot/pull/6063))。
### 优化
- 优化 QQ 官方 Bot 的流式消息投递可靠性与主动媒体发送能力([#6131](https://github.com/AstrBotDevs/AstrBot/pull/6131))。
- 优化边界场景下 booter 选择逻辑与消息发送工具([#6064](https://github.com/AstrBotDevs/AstrBot/pull/6064))。
### 修复
- 修复 Dashboard README 对话框锚点导航失效([#6083](https://github.com/AstrBotDevs/AstrBot/pull/6083))。
- 优先使用具名 weekday 的 cron 示例,避免歧义([#6091](https://github.com/AstrBotDevs/AstrBot/pull/6091))。
- 修复插件市场安装后状态未及时刷新的问题([#6124](https://github.com/AstrBotDevs/AstrBot/pull/6124))。
- 修复插件依赖安装逻辑:仅安装缺失依赖([#6088](https://github.com/AstrBotDevs/AstrBot/pull/6088))。
- 移除 Telegram 适配器中已废弃的 `normalize_whitespace` 参数([#6044](https://github.com/AstrBotDevs/AstrBot/pull/6044))。
- 修复 Windows 本地 skill 文件读取问题([#6028](https://github.com/AstrBotDevs/AstrBot/pull/6028))。
- 修复 Discord pre-ack emoji 配置重启后不持久化的问题([#6031](https://github.com/AstrBotDevs/AstrBot/pull/6031))。
- 统一 WebUI 搜索框清空行为([#6017](https://github.com/AstrBotDevs/AstrBot/pull/6017))。
- 优化插件依赖自动安装流程与 Dashboard 安装体验([#5954](https://github.com/AstrBotDevs/AstrBot/pull/5954))。
### 文档
- 新增 Astrbook 和玖帕喵社区链接([#6135](https://github.com/AstrBotDevs/AstrBot/pull/6135))。
- 修正文档 `docker.md``napcat.md` 中的拼写错误([#6048](https://github.com/AstrBotDevs/AstrBot/pull/6048))。
- 在多语言 README 中补充官方开发群号,并改进配置元数据中的正则说明。
- 更新编辑链接模式并移除过时仓库引用。
---
## What's Changed (EN)
### New Features
- Added Russian translation support ([#6081](https://github.com/AstrBotDevs/AstrBot/pull/6081)).
- Added file, voice, and video message support for QQ Official Bot (including WebSocket mode) ([#6063](https://github.com/AstrBotDevs/AstrBot/pull/6063)).
### Improvements
- Improved streaming message delivery reliability and proactive media sending for QQ Official API ([#6131](https://github.com/AstrBotDevs/AstrBot/pull/6131)).
- Optimized booter selection logic in edge cases and message sending tooling ([#6064](https://github.com/AstrBotDevs/AstrBot/pull/6064)).
### Bug Fixes
- Fixed broken README dialog anchor navigation in the Dashboard ([#6083](https://github.com/AstrBotDevs/AstrBot/pull/6083)).
- Preferred named weekday cron examples to reduce ambiguity ([#6091](https://github.com/AstrBotDevs/AstrBot/pull/6091)).
- Fixed plugin market install-state refresh after installation ([#6124](https://github.com/AstrBotDevs/AstrBot/pull/6124)).
- Fixed plugin dependency installation logic to install only missing packages ([#6088](https://github.com/AstrBotDevs/AstrBot/pull/6088)).
- Removed deprecated `normalize_whitespace` parameter in the Telegram adapter ([#6044](https://github.com/AstrBotDevs/AstrBot/pull/6044)).
- Fixed local skill file reading issues on Windows ([#6028](https://github.com/AstrBotDevs/AstrBot/pull/6028)).
- Fixed Discord pre-ack emoji config not being persisted across restarts ([#6031](https://github.com/AstrBotDevs/AstrBot/pull/6031)).
- Unified WebUI search input clear behavior ([#6017](https://github.com/AstrBotDevs/AstrBot/pull/6017)).
- Improved plugin dependency auto-install flow and Dashboard installation experience ([#5954](https://github.com/AstrBotDevs/AstrBot/pull/5954)).
### Documentation
- Added Astrbook and Jiupa Miao community links ([#6135](https://github.com/AstrBotDevs/AstrBot/pull/6135)).
- Fixed typos in `docker.md` and `napcat.md` ([#6048](https://github.com/AstrBotDevs/AstrBot/pull/6048)).
- Added official developer group IDs to multilingual READMEs and improved regex description in config metadata.
- Updated edit-link patterns and removed obsolete repository references.
-1
View File
@@ -37,7 +37,6 @@ services:
- DEFAULT_SHIP_MEMORY=512m
volumes:
- ${PWD}/data/shipyard/bay_data:/app/data
- ${PWD}/data/temp:/AstrBot/data/temp # Bind the local temp directory to the sandbox so that the uploaded file can be accessed in the sandbox
- /var/run/docker.sock:/var/run/docker.sock:ro
networks:
- astrbot_network
+8 -13
View File
@@ -17,17 +17,17 @@
"@tiptap/starter-kit": "2.1.7",
"@tiptap/vue-3": "2.1.7",
"apexcharts": "3.42.0",
"axios": "1.13.5",
"axios": ">=1.6.2 <1.10.0 || >1.10.0 <2.0.0",
"axios-mock-adapter": "^1.22.0",
"chance": "1.1.11",
"date-fns": "2.30.0",
"dompurify": "^3.3.2",
"dompurify": "^3.3.1",
"event-source-polyfill": "^1.0.31",
"highlight.js": "^11.11.1",
"js-md5": "^0.8.3",
"katex": "^0.16.27",
"lodash": "4.17.23",
"markdown-it": "^14.1.1",
"lodash": "4.17.21",
"markdown-it": "^14.1.0",
"markstream-vue": "^0.0.6",
"mermaid": "^11.12.2",
"monaco-editor": "^0.52.2",
@@ -36,8 +36,9 @@
"remixicon": "3.5.0",
"shiki": "^3.20.0",
"stream-markdown": "^0.0.13",
"stream-monaco": "^0.0.17",
"vee-validate": "4.11.3",
"vite-plugin-vuetify": "2.1.3",
"vite-plugin-vuetify": "1.0.2",
"vue": "3.3.4",
"vue-i18n": "^11.1.5",
"vue-router": "4.2.4",
@@ -53,7 +54,7 @@
"@types/dompurify": "^3.0.5",
"@types/markdown-it": "^14.1.2",
"@types/node": "^20.5.7",
"@vitejs/plugin-vue": "5.2.4",
"@vitejs/plugin-vue": "4.3.3",
"@vue/eslint-config-prettier": "8.0.0",
"@vue/eslint-config-typescript": "11.0.3",
"@vue/tsconfig": "^0.4.0",
@@ -63,15 +64,9 @@
"sass": "1.66.1",
"sass-loader": "13.3.2",
"typescript": "5.1.6",
"vite": "6.4.1",
"vite": "4.4.9",
"vue-cli-plugin-vuetify": "2.5.8",
"vue-tsc": "1.8.8",
"vuetify-loader": "^2.0.0-alpha.9"
},
"pnpm": {
"overrides": {
"immutable": "4.3.8",
"lodash-es": "4.17.23"
}
}
}
+271 -601
View File
File diff suppressed because it is too large Load Diff
+3 -69
View File
@@ -11,7 +11,6 @@
:currSessionId="currSessionId"
:selectedProjectId="selectedProjectId"
:transportMode="transportMode"
:sendShortcut="sendShortcut"
:isDark="isDark"
:chatboxMode="chatboxMode"
:isMobile="isMobile"
@@ -21,7 +20,6 @@
@selectConversation="handleSelectConversation"
@editTitle="showEditTitleDialog"
@deleteConversation="handleDeleteConversation"
@batchDeleteConversations="handleBatchDeleteConversations"
@closeMobileSidebar="closeMobileSidebar"
@toggleTheme="toggleTheme"
@toggleFullscreen="toggleFullscreen"
@@ -30,7 +28,6 @@
@editProject="showEditProjectDialog"
@deleteProject="handleDeleteProject"
@updateTransportMode="setTransportMode"
@updateSendShortcut="setSendShortcut"
/>
<!-- 右侧聊天内容区域 -->
@@ -74,14 +71,13 @@
:stagedImagesUrl="stagedImagesUrl"
:stagedAudioUrl="stagedAudioUrl"
:stagedFiles="stagedNonImageFiles"
:disabled="false"
:disabled="isStreaming"
:is-running="isStreaming || isConvRunning"
:enableStreaming="enableStreaming"
:isRecording="isRecording"
:session-id="currSessionId || null"
:current-session="getCurrentSession"
:replyTo="replyTo"
:send-shortcut="sendShortcut"
@send="handleSendMessage"
@stop="handleStopMessage"
@toggleStreaming="toggleStreaming"
@@ -106,14 +102,13 @@
:stagedImagesUrl="stagedImagesUrl"
:stagedAudioUrl="stagedAudioUrl"
:stagedFiles="stagedNonImageFiles"
:disabled="false"
:disabled="isStreaming"
:is-running="isStreaming || isConvRunning"
:enableStreaming="enableStreaming"
:isRecording="isRecording"
:session-id="currSessionId || null"
:current-session="getCurrentSession"
:replyTo="replyTo"
:send-shortcut="sendShortcut"
@send="handleSendMessage"
@stop="handleStopMessage"
@toggleStreaming="toggleStreaming"
@@ -137,14 +132,13 @@
:stagedImagesUrl="stagedImagesUrl"
:stagedAudioUrl="stagedAudioUrl"
:stagedFiles="stagedNonImageFiles"
:disabled="false"
:disabled="isStreaming"
:is-running="isStreaming || isConvRunning"
:enableStreaming="enableStreaming"
:isRecording="isRecording"
:session-id="currSessionId || null"
:current-session="getCurrentSession"
:replyTo="replyTo"
:send-shortcut="sendShortcut"
@send="handleSendMessage"
@stop="handleStopMessage"
@toggleStreaming="toggleStreaming"
@@ -226,13 +220,10 @@ import { useMediaHandling } from '@/composables/useMediaHandling';
import { useProjects } from '@/composables/useProjects';
import type { Project } from '@/components/chat/ProjectList.vue';
import { useRecording } from '@/composables/useRecording';
import { useToast } from '@/utils/toast';
interface Props {
chatboxMode?: boolean;
}
type SendShortcut = 'enter' | 'shift_enter';
const SEND_SHORTCUT_STORAGE_KEY = 'chat_send_shortcut';
const props = withDefaults(defineProps<Props>(), {
chatboxMode: false
@@ -242,7 +233,6 @@ const router = useRouter();
const route = useRoute();
const { t } = useI18n();
const { tm } = useModuleI18n('features/chat');
const { warning: toastWarning } = useToast();
const theme = useTheme();
const customizer = useCustomizerStore();
@@ -267,7 +257,6 @@ const {
getSessions,
newSession,
deleteSession: deleteSessionFn,
batchDeleteSessions,
showEditTitleDialog,
saveTitle,
updateSessionTitle,
@@ -341,18 +330,6 @@ interface ReplyInfo {
const replyTo = ref<ReplyInfo | null>(null);
const isDark = computed(() => useCustomizerStore().uiTheme === 'PurpleThemeDark');
const sendShortcut = ref<SendShortcut>('shift_enter');
function setSendShortcut(mode: SendShortcut) {
sendShortcut.value = mode;
localStorage.setItem(SEND_SHORTCUT_STORAGE_KEY, mode);
}
function focusChatInput() {
nextTick(() => {
chatInputRef.value?.focusInput?.();
});
}
//
function checkMobile() {
@@ -511,7 +488,6 @@ async function handleSelectConversation(sessionIds: string[]) {
nextTick(() => {
messageList.value?.scrollToBottom();
});
focusChatInput();
}
function handleNewChat() {
@@ -521,7 +497,6 @@ function handleNewChat() {
// 退
selectedProjectId.value = null;
projectSessions.value = [];
focusChatInput();
}
async function handleDeleteConversation(sessionId: string) {
@@ -535,33 +510,6 @@ async function handleDeleteConversation(sessionId: string) {
}
}
async function handleBatchDeleteConversations(sessionIds: string[]) {
try {
const result = await batchDeleteSessions(sessionIds);
//
if (result.currentSessionDeleted) {
messages.value = [];
}
//
if (result.failed_count > 0) {
toastWarning(
tm('batch.partialFailure', { failed: result.failed_count, total: sessionIds.length })
);
}
//
if (selectedProjectId.value) {
const sessions = await getProjectSessions(selectedProjectId.value);
projectSessions.value = sessions;
}
} catch (err) {
console.error('Batch delete sessions failed:', err);
toastWarning(tm('batch.requestFailed'));
}
}
async function handleSelectProject(projectId: string) {
selectedProjectId.value = projectId;
const sessions = await getProjectSessions(projectId);
@@ -679,11 +627,6 @@ async function handleSendMessage() {
const selectedProviderId = selection?.providerId || '';
const selectedModelName = selection?.modelName || '';
//
nextTick(() => {
messageList.value?.scrollToBottom();
});
await sendMsg(
promptToSend,
filesToSend,
@@ -693,11 +636,6 @@ async function handleSendMessage() {
replyToSend
);
//
nextTick(() => {
messageList.value?.scrollToBottom();
});
//
if (isCreatingNewSession && currentProjectId && currSessionId.value) {
await addSessionToProject(currSessionId.value, currentProjectId);
@@ -756,10 +694,6 @@ watch(sessions, (newSessions) => {
});
onMounted(() => {
const storedShortcut = localStorage.getItem(SEND_SHORTCUT_STORAGE_KEY);
if (storedShortcut === 'enter' || storedShortcut === 'shift_enter') {
sendShortcut.value = storedShortcut;
}
checkMobile();
window.addEventListener('resize', checkMobile);
getSessions();
+29 -44
View File
@@ -15,7 +15,7 @@
<transition name="fade">
<div v-if="isDragging" class="drop-overlay">
<div class="drop-overlay-content">
<v-icon size="48" color="primary">mdi-cloud-upload</v-icon>
<v-icon size="48" color="deep-purple">mdi-cloud-upload</v-icon>
<span class="drop-text">{{ tm('input.dropToUpload') }}</span>
</div>
</div>
@@ -41,7 +41,7 @@
<!-- Settings Menu -->
<StyledMenu offset="8" location="top start" :close-on-content-click="false">
<template v-slot:activator="{ props: activatorProps }">
<v-btn v-bind="activatorProps" icon="mdi-plus" variant="text" color="primary" />
<v-btn v-bind="activatorProps" icon="mdi-plus" variant="text" color="deep-purple" />
</template>
<!-- Upload Files -->
@@ -87,7 +87,7 @@
{{ tm('voice.liveMode') }}
</v-tooltip>
</v-btn> -->
<v-btn @click="handleRecordClick" icon variant="text" :color="isRecording ? 'error' : 'primary'"
<v-btn @click="handleRecordClick" icon variant="text" :color="isRecording ? 'error' : 'deep-purple'"
class="record-btn">
<v-icon :icon="isRecording ? 'mdi-stop-circle' : 'mdi-microphone'" variant="text"
plain></v-icon>
@@ -95,13 +95,13 @@
{{ isRecording ? tm('voice.speaking') : tm('voice.startRecording') }}
</v-tooltip>
</v-btn>
<v-btn icon v-if="isRunning && !canSend" @click="$emit('stop')" variant="tonal" color="primary" class="send-btn">
<v-btn icon v-if="isRunning" @click="$emit('stop')" variant="tonal" color="deep-purple" class="send-btn">
<v-icon icon="mdi-stop" variant="text" plain></v-icon>
<v-tooltip activator="parent" location="top">
{{ tm('input.stopGenerating') }}
</v-tooltip>
</v-btn>
<v-btn v-else @click="$emit('send')" icon="mdi-send" variant="tonal" color="primary"
<v-btn v-else @click="$emit('send')" icon="mdi-send" variant="tonal" color="deep-purple"
:disabled="!canSend" class="send-btn" />
</div>
</div>
@@ -117,7 +117,7 @@
</div>
<div v-if="stagedAudioUrl" class="audio-preview">
<v-chip color="primary" variant="tonal" class="audio-chip">
<v-chip color="deep-purple-lighten-4" class="audio-chip">
<v-icon start icon="mdi-microphone" size="small"></v-icon>
{{ tm('voice.recording') }}
</v-chip>
@@ -126,7 +126,7 @@
</div>
<div v-for="(file, index) in stagedFiles" :key="'file-' + index" class="file-preview">
<v-chip color="primary" variant="tonal" class="file-chip">
<v-chip color="blue-grey-lighten-4" class="file-chip">
<v-icon start icon="mdi-file-document-outline" size="small"></v-icon>
<span class="file-name-preview">{{ file.original_name }}</span>
</v-chip>
@@ -173,7 +173,6 @@ interface Props {
currentSession?: Session | null;
configId?: string | null;
replyTo?: ReplyInfo | null;
sendShortcut?: 'enter' | 'shift_enter';
}
const props = withDefaults(defineProps<Props>(), {
@@ -181,8 +180,7 @@ const props = withDefaults(defineProps<Props>(), {
currentSession: null,
configId: null,
stagedFiles: () => [],
replyTo: null,
sendShortcut: 'shift_enter'
replyTo: null
});
const emit = defineEmits<{
@@ -255,29 +253,9 @@ watch(localPrompt, () => {
});
function handleKeyDown(e: KeyboardEvent) {
const isEnter = e.key === 'Enter';
if (!isEnter) {
// Ctrl+B
if (e.ctrlKey && e.keyCode === 66) {
e.preventDefault();
if (ctrlKeyDown.value) return;
ctrlKeyDown.value = true;
ctrlKeyTimer.value = window.setTimeout(() => {
if (ctrlKeyDown.value && !props.isRecording) {
emit('startRecording');
}
}, ctrlKeyLongPressThreshold);
}
return;
}
const isSendHotkey =
e.ctrlKey ||
e.metaKey ||
(props.sendShortcut === 'enter' ? !e.shiftKey : e.shiftKey);
if (isSendHotkey) {
// Enter
// Shift+Enter Ctrl+Enter / Cmd+Enter
if (e.keyCode === 13 && (e.shiftKey || e.ctrlKey || e.metaKey)) {
e.preventDefault();
if (localPrompt.value.trim() === '/astr_live_dev') {
emit('openLiveMode');
@@ -289,6 +267,19 @@ function handleKeyDown(e: KeyboardEvent) {
}
return;
}
// Ctrl+B
if (e.ctrlKey && e.keyCode === 66) {
e.preventDefault();
if (ctrlKeyDown.value) return;
ctrlKeyDown.value = true;
ctrlKeyTimer.value = window.setTimeout(() => {
if (ctrlKeyDown.value && !props.isRecording) {
emit('startRecording');
}
}, ctrlKeyLongPressThreshold);
}
}
function handleKeyUp(e: KeyboardEvent) {
@@ -373,11 +364,6 @@ function getCurrentSelection() {
return providerModelMenuRef.value?.getCurrentSelection();
}
function focusInput() {
if (!inputField.value) return;
inputField.value.focus();
}
onMounted(() => {
if (inputField.value) {
inputField.value.addEventListener('paste', handlePaste);
@@ -393,8 +379,7 @@ onBeforeUnmount(() => {
});
defineExpose({
getCurrentSelection,
focusInput
getCurrentSelection
});
</script>
@@ -414,8 +399,8 @@ defineExpose({
left: 0;
right: 0;
bottom: 0;
background-color: rgba(var(--v-theme-primary), 0.12);
border: 2px dashed rgba(var(--v-theme-primary), 0.45);
background-color: rgba(103, 58, 183, 0.15);
border: 2px dashed rgba(103, 58, 183, 0.5);
border-radius: 24px;
display: flex;
align-items: center;
@@ -434,7 +419,7 @@ defineExpose({
.drop-text {
font-size: 16px;
font-weight: 500;
color: rgb(var(--v-theme-primary));
color: #673ab7;
}
/* Fade transition for drop overlay */
@@ -454,7 +439,7 @@ defineExpose({
justify-content: space-between;
padding: 8px 16px;
margin: 8px 8px 0 8px;
background-color: rgba(var(--v-theme-primary), 0.06);
background-color: rgba(103, 58, 183, 0.06);
border-radius: 12px;
gap: 8px;
max-height: 500px;
@@ -5,7 +5,7 @@
'mobile-sidebar-open': isMobile && mobileMenuOpen,
'mobile-sidebar': isMobile
}"
:style="{ backgroundColor: sidebarCollapsed && !isMobile ? 'rgb(var(--v-theme-surface))' : 'rgb(var(--v-theme-mcpCardBg))' }">
:style="{ 'background-color': isDark ? sidebarCollapsed ? '#1e1e1e' : '#2d2d2d' : sidebarCollapsed ? '#ffffff' : '#f1f4f9' }">
<div class="sidebar-collapse-btn-container" v-if="!isMobile">
<v-btn icon class="sidebar-collapse-btn" @click="toggleSidebar" variant="text" color="deep-purple">
@@ -21,31 +21,12 @@
</div>
<div style="padding: 8px; opacity: 0.6;">
<div class="new-chat-row" v-if="!sidebarCollapsed || isMobile">
<v-btn block variant="text" class="new-chat-btn" @click="$emit('newChat')" :disabled="!currSessionId && !selectedProjectId"
prepend-icon="mdi-square-edit-outline">{{ tm('actions.newChat') }}</v-btn>
<v-btn v-if="sessions.length > 0" icon size="small" variant="text" @click="toggleBatchMode"
:color="batchMode ? 'primary' : undefined">
<v-icon>mdi-checkbox-multiple-marked-outline</v-icon>
</v-btn>
</div>
<v-btn icon="mdi-square-edit-outline" rounded="xl" @click="$emit('newChat')" :disabled="!currSessionId && !selectedProjectId"
<v-btn block variant="text" class="new-chat-btn" @click="$emit('newChat')" :disabled="!currSessionId && !selectedProjectId"
v-if="!sidebarCollapsed || isMobile" prepend-icon="mdi-square-edit-outline">{{ tm('actions.newChat') }}</v-btn>
<v-btn icon="mdi-square-edit-outline" rounded="xl" @click="$emit('newChat')" :disabled="!currSessionId && !selectedProjectId"
v-if="sidebarCollapsed && !isMobile" elevation="0"></v-btn>
</div>
<!-- Batch action bar -->
<div v-if="batchMode && (!sidebarCollapsed || isMobile)" class="batch-action-bar">
<v-btn size="x-small" variant="text" @click="toggleSelectAll">
{{ isAllSelected ? tm('batch.deselectAll') : tm('batch.selectAll') }}
</v-btn>
<span class="batch-selected-count">{{ tm('batch.selected', { count: batchSelected.length }) }}</span>
<v-spacer />
<v-btn size="x-small" variant="text" color="error" :disabled="batchSelected.length === 0"
@click="handleBatchDelete">
{{ tm('batch.delete') }}
</v-btn>
</div>
<!-- 项目列表组件 -->
<ProjectList
v-if="!sidebarCollapsed || isMobile"
@@ -60,34 +41,19 @@
v-if="!sidebarCollapsed || isMobile">
<v-card v-if="sessions.length > 0" flat style="background-color: transparent;">
<v-list density="compact" nav class="conversation-list"
style="background-color: transparent;" :selected="batchMode ? [] : selectedSessions"
@update:selected="handleListSelect">
style="background-color: transparent;" :selected="selectedSessions"
@update:selected="$emit('selectConversation', $event)">
<v-list-item v-for="item in sessions" :key="item.session_id" :value="item.session_id"
rounded="lg" class="conversation-item" active-color="secondary"
@click="batchMode ? toggleBatchItem(item.session_id) : undefined">
<template v-slot:prepend>
<div class="batch-checkbox-slot" :class="{ 'batch-checkbox-slot--active': batchMode }">
<v-checkbox-btn
:model-value="batchSelected.includes(item.session_id)"
@update:model-value="toggleBatchItem(item.session_id)"
@click.stop
density="compact"
hide-details
class="batch-checkbox"
/>
</div>
</template>
rounded="lg" class="conversation-item" active-color="secondary">
<v-list-item-title v-if="!sidebarCollapsed || isMobile" class="conversation-title"
:style="{ color: 'rgb(var(--v-theme-primaryText))' }">
:style="{ color: isDark ? '#ffffff' : '#000000' }">
{{ item.display_name || tm('conversation.newConversation') }}
</v-list-item-title>
<!-- <v-list-item-subtitle v-if="!sidebarCollapsed || isMobile" class="timestamp">
{{ new Date(item.updated_at).toLocaleString() }}
</v-list-item-subtitle> -->
<template v-if="!batchMode && (!sidebarCollapsed || isMobile)" v-slot:append>
<template v-if="!sidebarCollapsed || isMobile" v-slot:append>
<div class="conversation-actions">
<v-btn icon="mdi-pencil" size="x-small" variant="text"
class="edit-title-btn"
@@ -132,52 +98,16 @@
</v-btn>
</template>
<!-- 语言切换分组 -->
<v-menu
:open-on-hover="!isMobile"
:open-on-click="isMobile"
:open-delay="!isMobile ? 60 : 0"
:close-delay="!isMobile ? 120 : 0"
:location="isMobile ? 'bottom' : 'end center'"
offset="8"
close-on-content-click
>
<template v-slot:activator="{ props: languageMenuProps }">
<v-list-item
v-bind="languageMenuProps"
class="styled-menu-item chat-settings-group-trigger"
rounded="md"
>
<template v-slot:prepend>
<v-icon>mdi-translate</v-icon>
</template>
<v-list-item-title>{{ t('core.common.language') }}</v-list-item-title>
<template v-slot:append>
<span class="chat-settings-group-current">{{ currentLanguage?.flag }}</span>
<v-icon size="18" class="chat-settings-group-arrow">mdi-chevron-right</v-icon>
</template>
</v-list-item>
<!-- 语言切换 -->
<v-list-item class="styled-menu-item">
<template v-slot:prepend>
<v-icon>mdi-translate</v-icon>
</template>
<v-card class="styled-menu-card" style="min-width: 180px;" elevation="8" rounded="lg">
<v-list density="compact" class="styled-menu-list pa-1">
<v-list-item
v-for="lang in languages"
:key="lang.code"
:value="lang.code"
@click="changeLanguage(lang.code)"
:class="{ 'styled-menu-item-active': currentLocale === lang.code }"
class="styled-menu-item"
rounded="md"
>
<template v-slot:prepend>
<span class="language-flag">{{ lang.flag }}</span>
</template>
<v-list-item-title>{{ lang.name }}</v-list-item-title>
</v-list-item>
</v-list>
</v-card>
</v-menu>
<v-list-item-title>{{ t('core.common.language') }}</v-list-item-title>
<template v-slot:append>
<LanguageSwitcher variant="chatbox" />
</template>
</v-list-item>
<!-- 主题切换 -->
<v-list-item class="styled-menu-item" @click="$emit('toggleTheme')">
@@ -187,93 +117,26 @@
<v-list-item-title>{{ isDark ? tm('modes.lightMode') : tm('modes.darkMode') }}</v-list-item-title>
</v-list-item>
<!-- 通信传输模式分组 -->
<v-menu
:open-on-hover="!isMobile"
:open-on-click="isMobile"
:open-delay="!isMobile ? 60 : 0"
:close-delay="!isMobile ? 120 : 0"
:location="isMobile ? 'bottom' : 'end center'"
offset="8"
close-on-content-click
>
<template v-slot:activator="{ props: transportMenuProps }">
<v-list-item
v-bind="transportMenuProps"
class="styled-menu-item chat-settings-group-trigger"
rounded="md"
>
<template v-slot:prepend>
<v-icon>mdi-lan-connect</v-icon>
</template>
<v-list-item-title>{{ tm('transport.title') }}</v-list-item-title>
<template v-slot:append>
<span class="chat-settings-group-current chat-settings-transport-current">{{ currentTransportLabel }}</span>
<v-icon size="18" class="chat-settings-group-arrow">mdi-chevron-right</v-icon>
</template>
</v-list-item>
<!-- 通信传输模式 -->
<v-list-item class="styled-menu-item">
<template v-slot:prepend>
<v-icon>mdi-lan-connect</v-icon>
</template>
<v-card class="styled-menu-card" style="min-width: 220px;" elevation="8" rounded="lg">
<v-list density="compact" class="styled-menu-list pa-1">
<v-list-item
v-for="opt in transportOptions"
:key="opt.value"
:value="opt.value"
@click="handleTransportModeChange(opt.value)"
:class="{ 'styled-menu-item-active': transportMode === opt.value }"
class="styled-menu-item"
rounded="md"
>
<v-list-item-title>{{ opt.label }}</v-list-item-title>
</v-list-item>
</v-list>
</v-card>
</v-menu>
<!-- 发送快捷键分组 -->
<v-menu
:open-on-hover="!isMobile"
:open-on-click="isMobile"
:open-delay="!isMobile ? 60 : 0"
:close-delay="!isMobile ? 120 : 0"
:location="isMobile ? 'bottom' : 'end center'"
offset="8"
close-on-content-click
>
<template v-slot:activator="{ props: sendShortcutMenuProps }">
<v-list-item
v-bind="sendShortcutMenuProps"
class="styled-menu-item chat-settings-group-trigger"
rounded="md"
>
<template v-slot:prepend>
<v-icon>mdi-keyboard-outline</v-icon>
</template>
<v-list-item-title>{{ tm('shortcuts.sendKey.title') }}</v-list-item-title>
<template v-slot:append>
<span class="chat-settings-group-current chat-settings-transport-current">{{ currentSendShortcutLabel }}</span>
<v-icon size="18" class="chat-settings-group-arrow">mdi-chevron-right</v-icon>
</template>
</v-list-item>
<v-list-item-title>{{ tm('transport.title') }}</v-list-item-title>
<template v-slot:append>
<v-select
:model-value="transportMode"
:items="transportOptions"
item-title="label"
item-value="value"
density="compact"
variant="underlined"
hide-details
class="transport-mode-select"
@update:model-value="handleTransportModeChange"
/>
</template>
<v-card class="styled-menu-card" style="min-width: 220px;" elevation="8" rounded="lg">
<v-list density="compact" class="styled-menu-list pa-1">
<v-list-item
v-for="opt in sendShortcutOptions"
:key="opt.value"
:value="opt.value"
@click="handleSendShortcutChange(opt.value)"
:class="{ 'styled-menu-item-active': props.sendShortcut === opt.value }"
class="styled-menu-item"
rounded="md"
>
<v-list-item-title>{{ opt.label }}</v-list-item-title>
</v-list-item>
</v-list>
</v-card>
</v-menu>
</v-list-item>
<!-- 全屏/退出全屏 -->
<v-list-item class="styled-menu-item" @click="$emit('toggleFullscreen')">
@@ -299,16 +162,15 @@
</template>
<script setup lang="ts">
import { ref, computed } from 'vue';
import { ref } from 'vue';
import { useI18n, useModuleI18n } from '@/i18n/composables';
import type { Session } from '@/composables/useSessions';
import { askForConfirmation, useConfirmDialog } from '@/utils/confirmDialog';
import LanguageSwitcher from '@/components/shared/LanguageSwitcher.vue';
import StyledMenu from '@/components/shared/StyledMenu.vue';
import ProviderConfigDialog from '@/components/chat/ProviderConfigDialog.vue';
import ProjectList from '@/components/chat/ProjectList.vue';
import type { Project } from '@/components/chat/ProjectList.vue';
import { useLanguageSwitcher } from '@/i18n/composables';
import type { Locale } from '@/i18n/types';
interface Props {
sessions: Session[];
@@ -321,7 +183,6 @@ interface Props {
isMobile: boolean;
mobileMenuOpen: boolean;
projects?: Project[];
sendShortcut: 'enter' | 'shift_enter';
}
const props = withDefaults(defineProps<Props>(), {
@@ -333,7 +194,6 @@ const emit = defineEmits<{
selectConversation: [sessionIds: string[]];
editTitle: [sessionId: string, title: string];
deleteConversation: [sessionId: string];
batchDeleteConversations: [sessionIds: string[]];
closeMobileSidebar: [];
toggleTheme: [];
toggleFullscreen: [];
@@ -342,7 +202,6 @@ const emit = defineEmits<{
editProject: [project: Project];
deleteProject: [projectId: string];
updateTransportMode: [mode: 'sse' | 'websocket'];
updateSendShortcut: [mode: 'enter' | 'shift_enter'];
}>();
const { t } = useI18n();
@@ -352,84 +211,10 @@ const confirmDialog = useConfirmDialog();
const sidebarCollapsed = ref(true);
const showProviderConfigDialog = ref(false);
// Batch mode state
const batchMode = ref(false);
const batchSelected = ref<string[]>([]);
const isAllSelected = computed(() =>
props.sessions.length > 0 && batchSelected.value.length === props.sessions.length
);
function toggleBatchMode() {
batchMode.value = !batchMode.value;
batchSelected.value = [];
}
function toggleBatchItem(sessionId: string) {
const idx = batchSelected.value.indexOf(sessionId);
if (idx >= 0) {
batchSelected.value.splice(idx, 1);
} else {
batchSelected.value.push(sessionId);
}
}
function toggleSelectAll() {
if (isAllSelected.value) {
batchSelected.value = [];
} else {
batchSelected.value = props.sessions.map(s => s.session_id);
}
}
async function handleBatchDelete() {
const count = batchSelected.value.length;
if (count === 0) return;
const message = tm('batch.confirmDelete', { count });
if (await askForConfirmation(message, confirmDialog)) {
emit('batchDeleteConversations', [...batchSelected.value]);
batchSelected.value = [];
batchMode.value = false;
}
}
function handleListSelect(sessionIds: string[]) {
if (!batchMode.value) {
emit('selectConversation', sessionIds);
}
}
const transportOptions = [
{ label: tm('transport.sse'), value: 'sse' as const },
{ label: tm('transport.websocket'), value: 'websocket' as const }
];
const sendShortcutOptions = [
{ label: tm('shortcuts.sendKey.enterToSend'), value: 'enter' as const },
{ label: tm('shortcuts.sendKey.shiftEnterToSend'), value: 'shift_enter' as const }
];
// Language switcher
const { languageOptions, currentLanguage, switchLanguage, locale } = useLanguageSwitcher();
const languages = computed(() =>
languageOptions.value.map(lang => ({
code: lang.value,
name: lang.label,
flag: lang.flag
}))
);
const currentLocale = computed(() => locale.value);
const changeLanguage = async (langCode: string) => {
await switchLanguage(langCode as Locale);
};
const currentTransportLabel = computed(() => {
const found = transportOptions.find(opt => opt.value === props.transportMode);
return found?.label ?? '';
});
const currentSendShortcutLabel = computed(() => {
const found = sendShortcutOptions.find(opt => opt.value === props.sendShortcut);
return found?.label ?? '';
});
// localStorage
const savedCollapsedState = localStorage.getItem('sidebarCollapsed');
@@ -457,12 +242,6 @@ function handleTransportModeChange(mode: string | null) {
emit('updateTransportMode', mode);
}
}
function handleSendShortcutChange(mode: string | null) {
if (mode === 'enter' || mode === 'shift_enter') {
emit('updateSendShortcut', mode);
}
}
</script>
<style scoped>
@@ -531,7 +310,7 @@ function handleSendShortcutChange(mode: string | null) {
}
.conversation-item:hover {
background-color: rgba(var(--v-theme-primary), 0.05);
background-color: rgba(103, 58, 183, 0.05);
}
.conversation-item:hover .conversation-actions {
@@ -623,74 +402,7 @@ function handleSendShortcutChange(mode: string | null) {
justify-content: center;
}
.chat-settings-group-trigger :deep(.v-list-item__append) {
display: flex;
align-items: center;
gap: 6px;
}
.chat-settings-group-current {
font-size: 14px;
line-height: 1;
opacity: 0.8;
}
.chat-settings-transport-current {
font-size: 12px;
}
.chat-settings-group-arrow {
opacity: 0.7;
}
.language-flag {
font-size: 16px;
margin-right: 8px;
}
.new-chat-row {
display: flex;
align-items: center;
gap: 4px;
}
.new-chat-row .new-chat-btn {
flex: 1;
min-width: 0;
}
.batch-action-bar {
display: flex;
align-items: center;
padding: 4px 12px;
gap: 4px;
flex-shrink: 0;
}
.batch-selected-count {
font-size: 12px;
opacity: 0.7;
white-space: nowrap;
}
.batch-checkbox {
flex: none;
transition: opacity 0.2s ease, transform 0.2s ease;
}
.batch-checkbox-slot {
width: 0;
opacity: 0;
overflow: hidden;
pointer-events: none;
transform: translateX(-8px);
transition: width 0.2s ease, opacity 0.2s ease, transform 0.2s ease;
}
.batch-checkbox-slot--active {
width: 28px;
opacity: 1;
pointer-events: auto;
transform: translateX(0);
.transport-mode-select {
min-width: 120px;
}
</style>
File diff suppressed because it is too large Load Diff
@@ -180,7 +180,7 @@
<script>
import { useI18n, useModuleI18n } from '@/i18n/composables';
import { enableKatex, enableMermaid, MarkdownCodeBlockNode, setCustomComponents } from 'markstream-vue'
import { enableKatex, enableMermaid, setCustomComponents } from 'markstream-vue'
import 'markstream-vue/index.css'
import 'katex/dist/katex.min.css'
import 'highlight.js/styles/github.css';
@@ -194,11 +194,8 @@ import ActionRef from './message_list_comps/ActionRef.vue';
enableKatex();
enableMermaid();
// message-list + Shiki
setCustomComponents('message-list', {
ref: RefNode,
code_block: MarkdownCodeBlockNode
});
// ref
setCustomComponents('message-list', { ref: RefNode });
export default {
name: 'MessageList',
@@ -22,7 +22,7 @@
v-model:prompt="prompt"
:stagedImagesUrl="stagedImagesUrl"
:stagedAudioUrl="stagedAudioUrl"
:disabled="false"
:disabled="isStreaming"
:is-running="isStreaming || isConvRunning"
:enableStreaming="enableStreaming"
:isRecording="isRecording"
@@ -63,9 +63,8 @@
<!-- Text (Markdown) -->
<MarkdownRender
v-else-if="renderPart.part.type === 'plain' && renderPart.part.text && renderPart.part.text.trim()"
:key="`${renderPart.key}-${isDark ? 'dark' : 'light'}`"
custom-id="message-list" :custom-html-tags="['ref']" :content="renderPart.part.text" :typewriter="false"
class="markdown-content" :is-dark="isDark" />
class="markdown-content" :is-dark="isDark" :monacoOptions="{ theme: isDark ? 'vs-dark' : 'vs-light' }" />
<!-- Image -->
<div v-else-if="renderPart.part.type === 'image' && renderPart.part.embedded_url" class="embedded-images">
@@ -9,7 +9,7 @@
</span>
</div>
<div v-if="isExpanded" class="reasoning-content animate-fade-in">
<MarkdownRender :key="`reasoning-${isDark ? 'dark' : 'light'}`" :content="reasoning" class="reasoning-text markdown-content"
<MarkdownRender :content="reasoning" class="reasoning-text markdown-content"
:typewriter="false" :is-dark="isDark" :style="isDark ? { opacity: '0.85' } : {}" />
</div>
</div>
@@ -1,12 +1,12 @@
<template>
<v-chip v-if="domain" class="ref-chip" size="x-small" variant="flat"
:style="chipStyle" :href="url"
:style="{ backgroundColor: isDark ? '#303030' : '#f4f4f4', color: isDark ? '#999' : '#666' }" :href="url"
target="_blank" clickable>
<v-icon start size="x-small" color>mdi-link-variant</v-icon>
<span>{{ domain }}</span>
</v-chip>
<span v-else class="ref-fallback" :style="fallbackStyle">{{ 'site' }}</span>
<span v-else class="ref-fallback" :style="{ color: isDark ? '#999' : '#666' }">{{ 'site' }}</span>
</template>
<script setup>
@@ -46,15 +46,6 @@ const domain = computed(() => {
return ''
}
})
const chipStyle = computed(() => ({
backgroundColor: isDark ? 'rgba(var(--v-theme-on-surface), 0.08)' : 'rgba(var(--v-theme-on-surface), 0.04)',
color: isDark ? 'rgba(var(--v-theme-on-surface), 0.62)' : 'rgba(var(--v-theme-on-surface), 0.72)'
}))
const fallbackStyle = computed(() => ({
color: isDark ? 'rgba(var(--v-theme-on-surface), 0.62)' : 'rgba(var(--v-theme-on-surface), 0.72)'
}))
</script>
<style scoped>
@@ -1,7 +1,6 @@
<script setup lang="ts">
import { computed } from 'vue';
import { useModuleI18n } from '@/i18n/composables';
import { normalizeTextInput } from '@/utils/inputValue';
const { tm } = useModuleI18n('features/command');
@@ -53,7 +52,6 @@ const statusItems = [
{ title: tm('filters.disabled'), value: 'disabled' },
{ title: tm('filters.conflict'), value: 'conflict' }
];
</script>
<template>
@@ -110,11 +108,10 @@ const statusItems = [
<div style="min-width: 200px; max-width: 350px; flex: 1; border: 1px solid #B9B9B9; border-radius: 16px;">
<v-text-field
:model-value="searchQuery"
@update:model-value="emit('update:searchQuery', normalizeTextInput($event))"
@update:model-value="emit('update:searchQuery', $event)"
density="compact"
:label="tm('search.placeholder')"
prepend-inner-icon="mdi-magnify"
clearable
variant="solo-filled"
flat
hide-details
@@ -3,7 +3,6 @@
*/
import { ref, computed, type Ref } from 'vue';
import type { CommandItem, FilterState } from '../types';
import { normalizeTextInput } from '@/utils/inputValue';
export function useCommandFilters(commands: Ref<CommandItem[]>) {
// 过滤状态
@@ -96,7 +95,7 @@ export function useCommandFilters(commands: Ref<CommandItem[]>) {
*
*/
const filteredCommands = computed(() => {
const query = normalizeTextInput(searchQuery.value).toLowerCase();
const query = searchQuery.value.toLowerCase();
const conflictCmds: CommandItem[] = [];
const normalCmds: CommandItem[] = [];
@@ -185,3 +184,4 @@ export function useCommandFilters(commands: Ref<CommandItem[]>) {
isGroupExpanded
};
}
@@ -15,7 +15,6 @@
import { computed, onActivated, onMounted, ref, watch} from 'vue';
import axios from 'axios';
import { useModuleI18n } from '@/i18n/composables';
import { normalizeTextInput } from '@/utils/inputValue';
// Composables
import { useComponentData } from './composables/useComponentData';
@@ -84,7 +83,7 @@ const {
} = useCommandActions(toast, () => fetchCommands(tm('messages.loadFailed')));
const filteredTools = computed(() => {
const query = normalizeTextInput(toolSearch.value).trim().toLowerCase();
const query = toolSearch.value.trim().toLowerCase();
if (!query) return tools.value;
return tools.value.filter(tool =>
tool.name?.toLowerCase().includes(query) ||
@@ -254,8 +253,7 @@ watch(viewMode, async (mode) => {
<div class="d-flex flex-wrap align-center ga-3 mb-4">
<div style="min-width: 240px; max-width: 380px; flex: 1;">
<v-text-field
:model-value="toolSearch"
@update:model-value="toolSearch = normalizeTextInput($event)"
v-model="toolSearch"
prepend-inner-icon="mdi-magnify"
:label="tmTool('functionTools.search')"
variant="outlined"
@@ -7,7 +7,6 @@
v-model="modelSearchProxy"
density="compact"
prepend-inner-icon="mdi-magnify"
clearable
hide-details
variant="solo-filled"
flat
@@ -162,7 +161,6 @@
<script setup>
import { computed } from 'vue'
import { normalizeTextInput } from '@/utils/inputValue'
const props = defineProps({
entries: {
@@ -224,7 +222,7 @@ const emit = defineEmits([
const modelSearchProxy = computed({
get: () => props.modelSearch,
set: (val) => emit('update:modelSearch', normalizeTextInput(val))
set: (val) => emit('update:modelSearch', val)
})
const isProviderTesting = (providerId) => props.testingProviders.includes(providerId)
@@ -12,7 +12,7 @@
>
<v-icon
size="18"
:color="props.variant === 'default' ? 'rgb(var(--v-theme-primary))' : undefined"
:color="props.variant === 'default' ? (useCustomizerStore().uiTheme === 'PurpleTheme' ? '#5e35b1' : '#d7c5fa') : undefined"
>
mdi-translate
</v-icon>
@@ -42,6 +42,7 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n, useLanguageSwitcher } from '@/i18n/composables'
import { useCustomizerStore } from '@/stores/customizer'
import type { Locale } from '@/i18n/types'
import StyledMenu from '@/components/shared/StyledMenu.vue'
@@ -89,7 +90,7 @@ const changeLanguage = async (langCode: string) => {
.language-switcher--default:hover {
transform: scale(1.05);
background: rgba(var(--v-theme-primary), 0.08) !important;
background: rgba(94, 53, 177, 0.08) !important;
}
/* Header变体样式 - 完全继承Vuetify和action-btn的默认样式 */
@@ -102,4 +103,8 @@ const changeLanguage = async (langCode: string) => {
/* 继承action-btn样式,与工具栏主题按钮保持一致 */
}
/* 深色模式下的悬停效果(仅对default变体) */
:deep(.v-theme--PurpleThemeDark) .language-switcher--default:hover {
background: rgba(114, 46, 209, 0.12) !important;
}
</style>
+3 -2
View File
@@ -6,11 +6,11 @@
</div>
<div class="logo-text">
<h2
:style="{ color: 'rgb(var(--v-theme-primary))' }"
:style="{color: useCustomizerStore().uiTheme === 'PurpleTheme' ? '#5e35b1' : '#d7c5fa'}"
v-html="formatTitle(title || t('core.header.logoTitle'))"
></h2>
<!-- 父子组件传递css变量可能会出错暂时使用十六进制颜色值 -->
<h4 :style="{ color: 'rgba(var(--v-theme-on-surface), 0.72)' }"
<h4 :style="{color: useCustomizerStore().uiTheme === 'PurpleTheme' ? '#000000aa' : '#ffffffcc'}"
class="hint-text">{{ subtitle || t('core.header.accountDialog.title') }}</h4>
</div>
</div>
@@ -18,6 +18,7 @@
</template>
<script setup lang="ts">
import { useCustomizerStore } from "@/stores/customizer";
import { useI18n } from '@/i18n/composables';
const { t } = useI18n();
@@ -48,24 +48,6 @@ const loading = ref(false);
const isEmpty = ref(false);
const copyFeedbackTimer = ref(null);
const lastRequestId = ref(0);
const scrollContainer = ref(null);
function slugifyHeading(text, slugCounts) {
const base = (text || "")
.trim()
.toLowerCase()
.normalize("NFKD")
.replace(/[\u0300-\u036f]/g, "")
.replace(/[^\p{Letter}\p{Number}\s-]/gu, "")
.replace(/\s+/g, "-")
.replace(/-+/g, "-");
if (!base) return "";
const count = slugCounts.get(base) || 0;
slugCounts.set(base, count + 1);
return count === 0 ? base : `${base}-${count}`;
}
onUnmounted(() => {
if (copyFeedbackTimer.value) clearTimeout(copyFeedbackTimer.value);
@@ -171,18 +153,6 @@ const renderedHtml = computed(() => {
// 3.
const tempDiv = document.createElement("div");
tempDiv.innerHTML = cleanHtml;
const slugCounts = new Map();
tempDiv.querySelectorAll("h1, h2, h3, h4, h5, h6").forEach((heading) => {
if (heading.id) {
slugCounts.set(heading.id, (slugCounts.get(heading.id) || 0) + 1);
return;
}
const slug = slugifyHeading(heading.textContent, slugCounts);
if (slug) heading.id = slug;
});
tempDiv.querySelectorAll("a").forEach((link) => {
const href = link.getAttribute("href");
// 使 _blank
@@ -281,35 +251,18 @@ watch(
function handleContainerClick(event) {
const btn = event.target.closest(".copy-code-btn");
if (btn) {
const code = btn.closest(".code-block-wrapper")?.querySelector("code");
if (code) {
if (navigator.clipboard?.writeText) {
navigator.clipboard
.writeText(code.textContent)
.then(() => showCopyFeedback(btn, true))
.catch(() => tryFallbackCopy(code.textContent, btn));
} else {
tryFallbackCopy(code.textContent, btn);
}
if (!btn) return;
const code = btn.closest(".code-block-wrapper")?.querySelector("code");
if (code) {
if (navigator.clipboard?.writeText) {
navigator.clipboard
.writeText(code.textContent)
.then(() => showCopyFeedback(btn, true))
.catch(() => tryFallbackCopy(code.textContent, btn));
} else {
tryFallbackCopy(code.textContent, btn);
}
return;
}
const anchor = event.target.closest('a[href^="#"]');
if (!anchor) return;
const rawHref = anchor.getAttribute("href");
const targetId = rawHref ? decodeURIComponent(rawHref.slice(1)) : "";
if (!targetId) return;
const target = scrollContainer.value?.querySelector(
`#${CSS.escape(targetId)}`,
);
if (!target) return;
event.preventDefault();
target.scrollIntoView({ behavior: "smooth", block: "start" });
}
function tryFallbackCopy(text, btn) {
@@ -373,7 +326,7 @@ const showActionArea = computed(() => {
<v-icon>mdi-close</v-icon>
</v-btn>
</v-card-title>
<v-card-text ref="scrollContainer" style="overflow-y: auto">
<v-card-text style="overflow-y: auto">
<div v-if="showActionArea" class="d-flex justify-space-between mb-4">
<v-btn
v-if="modeConfig.showGithubButton && repoUrl"
@@ -483,7 +436,6 @@ const showActionArea = computed(() => {
margin-bottom: 16px;
font-weight: 600;
line-height: 1.25;
scroll-margin-top: 12px;
}
:deep(.markdown-body h1) {
+17 -15
View File
@@ -24,12 +24,12 @@ withDefaults(defineProps<{
})
</script>
<style>
<style scoped>
.styled-menu-card {
min-width: 100px;
width: fit-content;
border: 1px solid rgba(var(--v-theme-primary), 0.15) !important;
background: rgba(var(--v-theme-surface), 0.98) !important;
border: 1px solid rgba(94, 53, 177, 0.15) !important;
background: #f8f6fc !important;
backdrop-filter: blur(10px);
}
@@ -37,41 +37,43 @@ withDefaults(defineProps<{
background: transparent !important;
}
.styled-menu-item {
:deep(.styled-menu-item) {
margin: 2px 0;
transition: all 0.2s ease;
border-radius: 6px;
}
.styled-menu-item:hover {
background: rgba(var(--v-theme-primary), 0.08) !important;
:deep(.styled-menu-item:hover) {
background: rgba(94, 53, 177, 0.08) !important;
}
.styled-menu-item-active {
background: rgba(var(--v-theme-primary), 0.15) !important;
:deep(.styled-menu-item-active) {
background: rgba(94, 53, 177, 0.15) !important;
font-weight: 500;
}
.styled-menu-item-active:hover {
background: rgba(var(--v-theme-primary), 0.2) !important;
:deep(.styled-menu-item-active:hover) {
background: rgba(94, 53, 177, 0.2) !important;
}
</style>
<style>
/* 深色模式下的下拉框样式 - 需要全局样式才能检测主题 */
.v-theme--PurpleThemeDark .styled-menu-card {
background: rgba(var(--v-theme-surface), 0.98) !important;
border: 1px solid rgba(var(--v-theme-primary), 0.2) !important;
background: #2a2733 !important;
border: 1px solid rgba(110, 60, 180, 0.692) !important;
}
/* 深色模式下的列表项悬停效果 */
.v-theme--PurpleThemeDark .styled-menu-item:hover {
background: rgba(var(--v-theme-primary), 0.12) !important;
background: rgba(114, 46, 209, 0.12) !important;
}
.v-theme--PurpleThemeDark .styled-menu-item-active {
background: rgba(var(--v-theme-primary), 0.2) !important;
background: rgba(114, 46, 209, 0.2) !important;
}
.v-theme--PurpleThemeDark .styled-menu-item-active:hover {
background: rgba(var(--v-theme-primary), 0.25) !important;
background: rgba(114, 46, 209, 0.25) !important;
}
</style>
@@ -2,7 +2,6 @@ import { ref, computed, onMounted, nextTick, watch } from 'vue'
import axios from 'axios'
import { getProviderIcon } from '@/utils/providerUtils'
import { askForConfirmation as askForConfirmationDialog, useConfirmDialog } from '@/utils/confirmDialog'
import { normalizeTextInput } from '@/utils/inputValue'
export interface UseProviderSourcesOptions {
defaultTab?: string
@@ -158,7 +157,7 @@ export function useProviderSources(options: UseProviderSourcesOptions) {
})
const filteredMergedModelEntries = computed(() => {
const term = normalizeTextInput(modelSearch.value).trim().toLowerCase()
const term = modelSearch.value.trim().toLowerCase()
if (!term) return mergedModelEntries.value
return mergedModelEntries.value.filter((entry: any) => {
@@ -590,11 +589,9 @@ export function useProviderSources(options: UseProviderSourcesOptions) {
async function testProvider(provider: any) {
testingProviders.value.push(provider.id)
try {
const startTime = performance.now()
const response = await axios.get('/api/config/provider/check_one', { params: { id: provider.id } })
if (response.data.status === 'ok' && response.data.data.error === null) {
const latency = Math.max(0, Math.round(performance.now() - startTime))
showMessage(tm('models.testSuccessWithLatency', { id: provider.id, latency }))
showMessage(tm('models.testSuccess', { id: provider.id }))
} else {
throw new Error(response.data.data.error || tm('models.testError'))
}
-68
View File
@@ -109,73 +109,6 @@ export function useSessions(chatboxMode: boolean = false) {
}
}
interface BatchDeleteFailedItem {
session_id: string;
reason: string;
}
interface BatchDeleteResult {
deleted_count: number;
failed_count: number;
failed_items: BatchDeleteFailedItem[];
currentSessionDeleted: boolean;
}
function isBatchDeleteResponseData(data: unknown): data is {
deleted_count: number;
failed_count: number;
failed_items: BatchDeleteFailedItem[];
} {
if (!data || typeof data !== 'object') {
return false;
}
const payload = data as Record<string, unknown>;
return (
typeof payload.deleted_count === 'number' &&
typeof payload.failed_count === 'number' &&
Array.isArray(payload.failed_items)
);
}
async function batchDeleteSessions(sessionIds: string[]): Promise<BatchDeleteResult> {
try {
const currentSessionId = currSessionId.value;
const response = await axios.post('/api/chat/batch_delete_sessions', { session_ids: sessionIds });
if (response.data?.status !== 'ok') {
throw new Error(response.data?.message || 'Failed to batch delete sessions');
}
const data = response.data?.data;
if (!isBatchDeleteResponseData(data)) {
throw new Error('Invalid batch delete response payload');
}
const failedItems = data.failed_items;
const failedSessionIds = new Set(failedItems.map(item => item.session_id));
const currentSessionDeleted = Boolean(
currentSessionId &&
sessionIds.includes(currentSessionId) &&
!failedSessionIds.has(currentSessionId)
);
if (currentSessionDeleted) {
currSessionId.value = '';
selectedSessions.value = [];
}
await getSessions();
return {
deleted_count: data.deleted_count,
failed_count: data.failed_count,
failed_items: failedItems,
currentSessionDeleted,
};
} catch (err) {
console.error(err);
throw err;
}
}
function showEditTitleDialog(sessionId: string, title: string) {
editingSessionId.value = sessionId;
editingTitle.value = title || '';
@@ -234,7 +167,6 @@ export function useSessions(chatboxMode: boolean = false) {
getSessions,
newSession,
deleteSession,
batchDeleteSessions,
showEditTitleDialog,
saveTitle,
updateSessionTitle,
+26 -27
View File
@@ -11,7 +11,7 @@ const translations = ref<Record<string, any>>({});
*/
export async function initI18n(locale: Locale = 'zh-CN') {
currentLocale.value = locale;
// 加载静态翻译数据
loadTranslations(locale);
}
@@ -50,7 +50,7 @@ export function useI18n() {
const t = (key: string, params?: Record<string, string | number>): string => {
const keys = key.split('.');
let value: any = translations.value;
// 遍历键路径
for (const k of keys) {
if (value && typeof value === 'object' && k in value) {
@@ -61,35 +61,35 @@ export function useI18n() {
return `[MISSING: ${key}]`;
}
}
if (typeof value !== 'string') {
console.warn(`Translation value is not string: ${key}`, value);
// 返回带括号的键名,便于在开发时识别类型错误的翻译
return `[INVALID: ${key}]`;
}
// 此时value确定是string类型
let result: string = value;
// 处理参数插值
if (params) {
result = result.replace(/\{(\w+)\}/g, (match: string, paramKey: string) => {
return params[paramKey]?.toString() || match;
});
}
return result;
};
// 切换语言
const setLocale = async (newLocale: Locale) => {
if (newLocale !== currentLocale.value) {
currentLocale.value = newLocale;
loadTranslations(newLocale);
// 保存到localStorage
localStorage.setItem('astrbot-locale', newLocale);
// 触发自定义事件,通知相关页面重新加载配置数据
// 这是因为插件适配器的 i18n 数据是通过后端 API 注入的,
// 需要根据 Accept-Language 头重新获取
@@ -98,16 +98,16 @@ export function useI18n() {
}));
}
};
// 获取当前语言
const locale = computed(() => currentLocale.value);
// 获取可用语言列表
const availableLocales: Locale[] = ['zh-CN', 'en-US', 'ru-RU'];
const availableLocales: Locale[] = ['zh-CN', 'en-US'];
// 检查是否已加载
const isLoaded = computed(() => Object.keys(translations.value).length > 0);
return {
t,
locale,
@@ -122,13 +122,13 @@ export function useI18n() {
*/
export function useModuleI18n(moduleName: string) {
const { t } = useI18n();
const tm = (key: string, params?: Record<string, string | number>): string => {
// 将斜杠转换为点号以匹配嵌套对象结构
const normalizedModuleName = moduleName.replace(/\//g, '.');
return t(`${normalizedModuleName}.${key}`, params);
};
// 获取原始翻译值(可能是字符串、数组或对象)
const getRaw = (key: string): any => {
const normalizedModuleName = moduleName.replace(/\//g, '.');
@@ -143,10 +143,10 @@ export function useModuleI18n(moduleName: string) {
return null;
}
}
return value;
};
return { tm, getRaw };
}
@@ -155,21 +155,20 @@ export function useModuleI18n(moduleName: string) {
*/
export function useLanguageSwitcher() {
const { locale, setLocale, availableLocales } = useI18n();
const languageOptions = computed(() => [
{ value: 'zh-CN', label: '简体中文', flag: '🇨🇳' },
{ value: 'en-US', label: 'English', flag: '🇺🇸' },
{ value: 'ru-RU', label: 'Русский', flag: '🇷🇺' }
{ value: 'en-US', label: 'English', flag: '🇺🇸' }
]);
const currentLanguage = computed(() => {
return languageOptions.value.find(lang => lang.value === locale.value);
});
const switchLanguage = async (newLocale: Locale) => {
await setLocale(newLocale);
};
return {
locale,
languageOptions,
@@ -221,9 +220,9 @@ function deepMerge(target: Record<string, any>, source: Record<string, any>) {
export async function setupI18n() {
// 从localStorage获取保存的语言设置
const savedLocale = localStorage.getItem('astrbot-locale') as Locale;
const initialLocale = savedLocale && ['zh-CN', 'en-US', 'ru-RU'].includes(savedLocale)
? savedLocale
const initialLocale = savedLocale && ['zh-CN', 'en-US'].includes(savedLocale)
? savedLocale
: 'zh-CN';
await initI18n(initialLocale);
}
@@ -96,7 +96,6 @@
"save": "Save",
"livePreview": "Live Preview (may differ)",
"refreshPreview": "Refresh Preview",
"previewText": "This is a sample text used to preview the template output.\n\nIt can contain multiple lines and various formatting.",
"syntaxHint": "Supports jinja2 syntax. Available variables: text | safe (text to render), version (AstrBot version)",
"saveAndApply": "Save and Apply Current Template",
"confirmReset": "Confirm Reset",
@@ -71,16 +71,10 @@
"modes": {
"darkMode": "Switch to Dark Mode",
"lightMode": "Switch to Light Mode"
},
"shortcuts": {
}, "shortcuts": {
"help": "Get Help",
"voiceRecord": "Record Voice",
"pasteImage": "Paste Image",
"sendKey": {
"title": "Send Shortcut",
"enterToSend": "Enter to send",
"shiftEnterToSend": "Shift+Enter to send"
}
"pasteImage": "Paste Image"
},
"streaming": {
"enabled": "Streaming enabled",
@@ -147,15 +141,5 @@
"errors": {
"sendMessageFailed": "Failed to send message, please try again",
"createSessionFailed": "Failed to create session, please refresh the page"
},
"batch": {
"selected": "{count} selected",
"confirmDelete": "Are you sure you want to delete {count} conversation(s)? This action cannot be undone.",
"selectAll": "Select All",
"deselectAll": "Deselect All",
"delete": "Delete",
"exit": "Exit",
"partialFailure": "{failed} of {total} conversations failed to delete",
"requestFailed": "Failed to delete conversations. Please try again."
}
}
@@ -78,7 +78,6 @@
},
"persona": {
"description": "Persona",
"hint": "Set the default persona for AI conversations. Personas can be managed in the Persona tab.",
"provider_settings": {
"default_personality": {
"description": "Default Persona"
@@ -874,8 +873,7 @@
]
},
"regex": {
"description": "Segmentation Regular Expression",
"hint": "Used to identify split points with a regular expression. Prefer patterns that match separators."
"description": "Segmentation Regular Expression"
},
"split_words": {
"description": "Split Word List",
@@ -132,7 +132,6 @@
"deleteSuccess": "Model deleted successfully",
"deleteError": "Failed to delete model",
"testSuccess": "Model {id} test passed",
"testSuccessWithLatency": "Model {id} test passed, latency {latency} ms",
"testError": "Model test failed",
"searchPlaceholder": "Search models or ID",
"manualAddButton": "Custom Model",
@@ -1,24 +0,0 @@
{
"create": "Создать",
"read": "Чтение",
"update": "Обновить",
"delete": "Удалить",
"search": "Поиск",
"filter": "Фильтр",
"sort": "Сортировка",
"export": "Экспорт",
"import": "Импорт",
"backup": "Резервное копирование",
"restore": "Восстановление",
"copy": "Копировать",
"paste": "Вставить",
"cut": "Вырезать",
"undo": "Отменить",
"redo": "Повторить",
"refresh": "Обновить",
"submit": "Отправить",
"reset": "Сбросить",
"clear": "Очистить",
"save": "Сохранить",
"close": "Закрыть"
}
@@ -1,133 +0,0 @@
{
"save": "Сохранить",
"cancel": "Отмена",
"close": "Закрыть",
"copy": "Копировать",
"copied": "Скопировано",
"copyFailed": "Ошибка копирования",
"delete": "Удалить",
"edit": "Редактировать",
"add": "Добавить",
"confirm": "Подтвердить",
"loading": "Загрузка...",
"success": "Успешно",
"error": "Ошибка",
"warning": "Внимание",
"info": "Информация",
"name": "Имя",
"description": "Описание",
"author": "Автор",
"status": "Статус",
"actions": "Действия",
"enable": "Включить",
"disable": "Выключить",
"enabled": "Включено",
"disabled": "Выключено",
"reload": "Перезагрузить",
"configure": "Настроить",
"install": "Установить",
"uninstall": "Удалить",
"update": "Обновить",
"language": "Язык",
"settings": "Настройки",
"locale": "JSON",
"type": "Тип",
"press": "Нажмите",
"longPress": "Долгое нажатие",
"yes": "Да",
"no": "Нет",
"imagePreview": "Предпросмотр изображения",
"autoDetect": "Автоопределение",
"dialog": {
"confirmTitle": "Подтверждение",
"confirmMessage": "Вы уверены, что хотите выполнить это действие?",
"confirmButton": "ОК",
"cancelButton": "Отмена"
},
"restart": {
"waiting": "Ожидание перезагрузки AstrBot...",
"maxRetriesReached": "Превышено количество попыток проверки статуса. Пожалуйста, проверьте вручную."
},
"readme": {
"title": "Документация плагина",
"buttons": {
"viewOnGithub": "Открыть репозиторий на GitHub",
"refresh": "Обновить"
},
"loading": "Загрузка README...",
"errors": {
"fetchFailed": "Не удалось загрузить README",
"fetchError": "Произошла ошибка при загрузке README"
},
"empty": {
"title": "У этого плагина нет ссылки на документацию или репозиторий GitHub.",
"subtitle": "Пожалуйста, посетите магазин плагинов или свяжитесь с автором для получения дополнительной информации."
}
},
"changelog": {
"title": "Журнал изменений",
"loading": "Загрузка журнала изменений...",
"empty": {
"title": "У этого плагина нет журнала изменений",
"subtitle": "Разработчики могут добавить файл CHANGELOG.md в директорию плагина"
}
},
"editor": {
"fullscreen": "На весь экран",
"editingTitle": "Редактирование содержимого"
},
"templateList": {
"addEntry": "Добавить запись",
"empty": "Записей нет, выберите шаблон для добавления",
"missingTemplate": "Шаблон не найден, пожалуйста, удалите и добавьте заново.",
"unknownTemplate": "Неизвестный шаблон"
},
"list": {
"addItemPlaceholder": "Добавьте новый элемент и нажмите Enter",
"addButton": "Добавить",
"addMore": "Добавить еще",
"batchImport": "Массовый импорт",
"batchImportTitle": "Массовый импорт",
"batchImportLabel": "Один элемент на строку",
"batchImportPlaceholder": "Например:\nЭлемент 1\nЭлемент 2\nЭлемент 3",
"batchImportHint": "Каждая строка будет считаться отдельным элементом. Пустые строки игнорируются.",
"batchImportButton": "Импортировать {count} эл.",
"noItems": "Список пуст",
"noItemsHint": "Элементов нет. Напишите что-нибудь выше и нажмите Enter.",
"inputPlaceholder": "Введите текст и нажмите Enter",
"editTitle": "Изменить элемент",
"modifyButton": "Изменить"
},
"itemCard": {
"enabled": "Включено",
"disabled": "Выключено",
"delete": "Удалить",
"edit": "Изменить",
"copy": "Копировать",
"noData": "Нет данных"
},
"objectEditor": {
"dialogTitle": "Изменение пар ключ-значение",
"noItems": "Нет элементов",
"noParams": "Нет параметров",
"presets": "Пресеты",
"newKeyLabel": "Имя ключа",
"valueTypeLabel": "Тип значения",
"keyExists": "Ключ уже существует",
"invalidJson": "Некорректный формат JSON",
"placeholders": {
"keyName": "Ключ",
"stringValue": "Строка",
"numberValue": "Число",
"jsonValue": "JSON"
}
},
"firstNotice": {
"title": "Первичная информация",
"loading": "Загрузка информации...",
"empty": {
"title": "Нет информации для отображения",
"subtitle": "Файл FIRST_NOTICE.md не найден или пуст."
}
}
}
@@ -1,108 +0,0 @@
{
"logoTitle": "Панель управления AstrBot",
"version": {
"hasNewVersion": "Доступна новая версия AstrBot!",
"dashboardHasNewVersion": "Доступна новая версия WebUI!"
},
"buttons": {
"update": "Обновить",
"account": "Аккаунт",
"theme": {
"light": "Светлая тема",
"dark": "Темная тема"
}
},
"updateDialog": {
"title": "Обновить AstrBot",
"currentVersion": "Текущая версия",
"status": {
"checking": "Проверка обновлений...",
"switching": "Переключение версии...",
"updating": "Обновление..."
},
"tabs": {
"release": "😊 Релиз"
},
"updateToLatest": "Обновить до последней версии",
"preRelease": "Предварительная версия",
"preReleaseWarning": {
"title": "Внимание: предварительная версия",
"description": "Версии с меткой Pre-release могут содержать неизвестные ошибки. Не рекомендуется использовать в рабочих средах. Если вы обнаружили ошибку, пожалуйста, сообщите о ней в ",
"issueLink": "GitHub Issues"
},
"tip": "💡 ПОДСКАЗКА: ",
"tipContinue": "По умолчанию при переключении версии загружаются соответствующие файлы WebUI. Код WebUI находится в директории dashboard, вы можете собрать его самостоятельно с помощью npm.",
"dockerTip": "При переключении версии будет предпринята попытка обновить как основной процесс бота, так и панель управления. Если вы используете Docker, вы также можете обновить образ или использовать",
"dockerTipLink": "watchtower",
"dockerTipContinue": "для автоматического мониторинга и обновления.",
"table": {
"tag": "Тег",
"publishDate": "Дата публикации",
"content": "Содержание",
"sourceUrl": "Исходный код",
"actions": "Действия",
"view": "Просмотр",
"switch": "Переключить"
},
"releaseNotes": {
"title": "Журнал изменений"
},
"redirectConfirm": {
"title": "Переход по ссылке",
"message": "Вы будете перенаправлены на страницу GitHub Releases. Продолжить?",
"latestLabel": "Последняя версия",
"targetVersion": "Целевая версия:",
"currentVersion": "Текущая версия:",
"guideTitle": "Рекомендации после перехода:",
"guideStep1": "Загрузите пакет, соответствующий архитектуре вашей системы.",
"guideStep2": "После завершения установки перезапустите AstrBot.",
"guideStep3": "Если вы используете Docker, отдайте приоритет обновлению через образ."
},
"desktopApp": {
"title": "Обновить десктопное приложение",
"message": "Проверка и обновление десктопной версии AstrBot.",
"currentVersion": "Текущая версия:",
"latestVersion": "Последняя версия:",
"checking": "Проверка обновлений десктопного приложения...",
"hasNewVersion": "Найдена новая версия. Нажмите для подтверждения обновления.",
"isLatest": "Установлена последняя версия",
"installing": "Загрузка и установка обновления... Приложение будет перезапущено автоматически.",
"checkFailed": "Ошибка проверки обновлений. Попробуйте позже.",
"installFailed": "Ошибка обновления. Попробуйте позже."
},
"dashboardUpdate": {
"title": "Обновить только панель управления",
"currentVersion": "Текущая версия",
"hasNewVersion": "Доступна новая версия!",
"isLatest": "Установлена последняя версия.",
"downloadAndUpdate": "Скачать и обновить"
}
},
"accountDialog": {
"title": "Изменить аккаунт",
"securityWarning": "Безопасность: Пожалуйста, смените пароль по умолчанию для защиты аккаунта",
"form": {
"currentPassword": "Текущий пароль",
"newPassword": "Новый пароль",
"confirmPassword": "Подтвердите новый пароль",
"newUsername": "Новое имя пользователя (опционально)",
"passwordHint": "Пароль должен быть не менее 8 символов",
"confirmPasswordHint": "Введите новый пароль еще раз",
"usernameHint": "Оставьте пустым, если не хотите менять имя пользователя",
"defaultCredentials": "Логин и пароль по умолчанию: astrbot"
},
"validation": {
"passwordRequired": "Введите пароль",
"passwordMinLength": "Пароль должен быть не менее 8 символов",
"passwordMatch": "Паролы не совпадают",
"usernameMinLength": "Имя пользователя должно быть не менее 3 символов"
},
"actions": {
"save": "Сохранить изменения",
"cancel": "Отмена"
},
"messages": {
"updateFailed": "Ошибка обновления, попробуйте еще раз"
}
}
}
@@ -1,49 +0,0 @@
{
"welcome": "Добро пожаловать",
"dashboard": "Статистика",
"platforms": "Боты",
"providers": "Провайдеры моделей",
"commands": "Команды",
"persona": "Персонажи",
"subagent": "Субагенты",
"toolUse": "Инструменты MCP",
"extension": "Плагины",
"extensionTabs": {
"installed": "Плагины AstrBot",
"market": "Магазин плагинов",
"mcp": "Серверы MCP",
"skills": "Навыки",
"components": "Управление поведением"
},
"config": "Конфигурация",
"chat": "Чат",
"cron": "Запланированные задачи",
"conversation": "Данные диалогов",
"sessionManagement": "Пользовательские правила",
"console": "Логи платформы",
"trace": "Трассировка",
"alkaid": "Alkaid Lab",
"knowledgeBase": "База знаний",
"about": "О программе",
"settings": "Настройки",
"changelog": "Журнал изменений",
"documentation": "Документация",
"faq": "FAQ",
"github": "GitHub",
"drag": "Перетащить",
"groups": {
"more": "Дополнительно"
},
"changelogDialog": {
"title": "Журнал изменений",
"loading": "Загрузка...",
"error": "Ошибка загрузки",
"notFound": "Журнал изменений для этой версии не найден",
"selectVersion": "Выберите версию",
"current": "Текущая"
},
"configTabs": {
"normal": "Обычная конфигурация",
"system": "Системная конфигурация"
}
}
@@ -1,112 +0,0 @@
{
"knowledgeBaseSelector": {
"notSelected": "Не выбрано",
"buttonText": "Выбрать базу знаний...",
"dialogTitle": "Выбор базы знаний",
"loading": "Загрузка...",
"noKnowledgeBases": "Базы знаний не найдены",
"createKnowledgeBase": "Создать базу знаний",
"selectedCount": "Выбрано баз знаний: {count}",
"confirmSelection": "ОК",
"cancelSelection": "Отмена",
"noDescription": "Нет описания",
"documentCount": "Документов: {count}",
"chunkCount": "Фрагментов: {count}"
},
"pluginSetSelector": {
"notSelected": "Плагины не включены",
"allPlugins": "Включить все плагины (*)",
"selectedCount": "Выбрано плагинов: {count}",
"buttonText": "Выбрать набор плагинов...",
"dialogTitle": "Выбор набора плагинов",
"loading": "Загрузка...",
"enableAll": "Включить все",
"enableNone": "Ничего не включать",
"customSelect": "Настроить выбор",
"noPlugins": "Доступных плагинов нет",
"confirmSelection": "ОК",
"cancelSelection": "Отмена",
"noDescription": "Нет описания",
"notActivated": "Не активирован",
"note": "*Системные и уже выключенные в настройках плагины не отображаются.",
"selectedPluginsLabel": "Выбранные плагины:",
"allPluginsLabel": "Все плагины"
},
"providerSelector": {
"notSelected": "Не выбрано",
"buttonText": "Выбрать провайдера...",
"dialogTitle": "Выбор провайдера",
"loading": "Загрузка...",
"noProviders": "Доступных провайдеров нет",
"confirmSelection": "ОК",
"cancelSelection": "Отмена",
"clearSelection": "Сбросить выбор",
"clearSelectionSubtitle": "Очистить текущий выбор",
"unknownType": "Неизвестный тип",
"createProvider": "Создать провайдера",
"manageProviders": "Управление провайдерами",
"selectProviderPool": "Выбрать пул провайдеров...",
"selectedCount": "Выбрано провайдеров: {count}"
},
"personaSelector": {
"notSelected": "Не выбрано",
"defaultPersona": "Персонаж по умолчанию",
"buttonText": "Выбрать персонажа...",
"editPersona": "Изменить текущего персонажа",
"dialogTitle": "Выбор персонажа",
"noDescription": "Нет описания",
"noPersonas": "Доступных персонажей нет",
"createPersona": "Создать персонажа",
"cancelSelection": "Отмена",
"confirmSelection": "ОК",
"selectPersonaPool": "Выбрать пул персонажей...",
"rootFolder": "Все персонажи",
"emptyFolder": "Папка пуста"
},
"personaQuickPreview": {
"title": "Быстрый просмотр",
"loading": "Загрузка...",
"noPersonaSelected": "Персонаж не выбран",
"personaNotFound": "Информация о персонаже не найдена",
"systemPromptLabel": "Системный промпт",
"toolsLabel": "Инструменты",
"skillsLabel": "Навыки (Skills)",
"originLabel": "Источник",
"originNameLabel": "Имя источника",
"toolInactive": "Выключено",
"toolInactiveTooltip": "Этот инструмент выключен. Включите его в Плагины -> Управление поведением -> Функции.",
"allTools": "Доступны все инструменты",
"allToolsWithCount": "Доступны все инструменты ({count})",
"noTools": "Инструменты не настроены",
"allSkills": "Доступны все навыки (Skills)",
"allSkillsWithCount": "Доступны все навыки ({count})",
"noSkills": "Навыки (Skills) не настроены"
},
"t2iTemplateEditor": {
"buttonText": "Настроить T2I шаблон",
"dialogTitle": "Настройка HTML шаблона Text-to-Image",
"newTemplateNameLabel": "Введите имя нового шаблона",
"nameRequired": "Имя обязательно для заполнения",
"selectTemplateLabel": "Выбрать шаблон",
"applied": "Применено",
"apply": "Применить",
"templateEditor": "Редактор шаблона",
"new": "Создать",
"resetBase": "Сбросить 'base'",
"delete": "Удалить",
"save": "Сохранить",
"livePreview": "Предпросмотр (может отличаться)",
"refreshPreview": "Обновить",
"previewText": "Это пример текста для предпросмотра результата шаблона.\n\nОн может содержать несколько строк и различные форматы.",
"syntaxHint": "Поддерживается синтаксис jinja2. Переменные: text | safe (текст для рендеринга), version (версия AstrBot)",
"saveAndApply": "Сохранить и применить текущий шаблон",
"confirmReset": "Подтверждение сброса",
"confirmResetMessage": "Вы уверены, что хотите сбросить шаблон 'base' до значений по умолчанию? Все несохраненные изменения будут потеряны. Это действие необратимо.",
"confirmResetButton": "Сбросить",
"confirmDelete": "Подтверждение удаления",
"confirmDeleteMessage": "Вы уверены, что хотите удалить шаблон '{name}'? Это действие необратимо.",
"confirmDeleteButton": "Удалить",
"confirmAction": "Подтверждение действия",
"confirmApplyMessage": "Вы уверены, что хотите сохранить изменения в '{name}' и сделать его активным шаблоном?"
}
}
@@ -1,22 +0,0 @@
{
"loading": "Загрузка",
"success": "Успешно",
"error": "Ошибка",
"warning": "Внимание",
"info": "Информация",
"pending": "В ожидании",
"processing": "В процессе",
"completed": "Завершено",
"failed": "Ошибка",
"cancelled": "Отменено",
"timeout": "Тайм-аут",
"connecting": "Подключение",
"connected": "Подключено",
"disconnected": "Отключено",
"online": "В сети",
"offline": "Не в сети",
"active": "Активен",
"inactive": "Неактивен",
"ready": "Готов",
"busy": "Занят"
}
@@ -1,17 +0,0 @@
{
"hero": {
"title": "AstrBot",
"subtitle": "Проект, рожденный из интереса и любви ❤️",
"starButton": "Star этот проект! 🌟",
"issueButton": "Сообщить об ошибке"
},
"contributors": {
"title": "Контрибьюторы",
"description": "Этот проект поддерживается участниками open-source сообщества. Спасибо каждому за вклад!",
"viewLink": "Посмотреть всех участников"
},
"stats": {
"title": "Глобальное развертывание",
"license": "AstrBot распространяется по лицензии AGPL v3"
}
}
@@ -1,44 +0,0 @@
{
"title": "Лаборатория Alkaid",
"subtitle": "Исследуйте передовые возможности AI",
"comingSoon": "Этот мир еще впереди, заходите позже!",
"page": {
"title": "Проект Alkaid.",
"subtitle": "AstrBot Alpha Project",
"navigation": {
"knowledgeBase": "База знаний (Плагин)",
"longTermMemory": "Долгосрочная память",
"other": "..."
}
},
"features": {
"knowledgeBase": "База знаний",
"longTermMemory": "Долгосрочная память",
"advancedChat": "Продвинутый чат",
"multiModal": "Мультимодальность"
},
"status": {
"experimental": "Экспериментально",
"beta": "Бета",
"stable": "Стабильно",
"deprecated": "Устарело"
},
"sigma": {
"subtitle": "Экспериментальный проект AstrBot",
"visualization": "Визуализация",
"filterUserId": "Фильтр по User ID",
"filter": "Фильтр",
"resetFilter": "Сброс",
"refreshGraph": "Обновить граф",
"nodeDetails": "Детали узла",
"id": "ID",
"type": "Тип",
"name": "Имя",
"userId": "ID пользователя",
"timestamp": "Метка времени",
"graphStats": "Статистика графа",
"nodeCount": "Узлов",
"edgeCount": "Связей",
"inDevelopment": "В разработке"
}
}
@@ -1,155 +0,0 @@
{
"title": "База знаний",
"subtitle": "Управление контентом базы знаний и поиск",
"documents": {
"title": "Список документов",
"name": "Имя файла",
"size": "Размер",
"uploadTime": "Дата загрузки",
"status": "Статус",
"actions": "Действия"
},
"management": {
"delete": "Удалить",
"preview": "Предпросмотр",
"download": "Скачать",
"reindex": "Переиндексировать"
},
"notInstalled": {
"title": "Плагин базы знаний не установлен",
"install": "Установить сейчас"
},
"empty": {
"title": "База знаний пуста. Создайте свою первую базу! 🙂",
"create": "Создать базу знаний"
},
"list": {
"title": "Список баз знаний",
"create": "Создать базу знаний",
"config": "Настройка",
"checkUpdate": "Проверить обновления плагина",
"updatePlugin": "Обновить плагин до версии {version}",
"knowledgeCount": "записей",
"tips": "Совет: используйте команду /kb в чате, чтобы узнать, как пользоваться базой!"
},
"createDialog": {
"title": "Создание базы знаний",
"nameLabel": "Название",
"descriptionLabel": "Описание",
"descriptionPlaceholder": "Краткое описание...",
"embeddingModelLabel": "Embedding модель",
"rerankModelLabel": "Rerank модель",
"providerInfo": "Провайдер: {id} | Размерность: {dimensions}",
"rerankProviderInfo": "Провайдер: {id}",
"tips": "Совет: после выбора Embedding модели не рекомендуется менять провайдера или размерность векторов, так как это сделает текущий индекс нечитаемым.",
"cancel": "Отмена",
"create": "Создать"
},
"emojiPicker": {
"title": "Выберите иконку",
"close": "Закрыть",
"categories": {
"emotions": "Смайлы",
"animals": "Животные и природа",
"food": "Еда и напитки",
"activities": "Занятия и вещи",
"travel": "Места и путешествия",
"symbols": "Символы и флаги"
}
},
"contentDialog": {
"title": "Управление базой знаний",
"embeddingModel": "Embedding модель",
"vectorDimension": "Размерность",
"usage": "Использование: введите «/kb use {name}» в чате",
"tabs": {
"upload": "Загрузка файлов",
"search": "Поиск",
"fromURL": "Импорт из URL"
}
},
"upload": {
"title": "Загрузка файлов",
"subtitle": "Поддерживаются форматы txt, pdf, word, excel и др.",
"dropzone": "Перетащите файлы сюда или нажмите для выбора",
"chunkSettings": {
"title": "Настройка фрагментации (Chunking)",
"tooltip": "Размер фрагмента определяет объем текста в одном блоке. Перекрытие позволяет сохранить контекст между соседними блоками.\nМаленькие фрагменты точнее, но увеличивают объем базы.",
"chunkSizeLabel": "Размер фрагмента",
"chunkSizeHint": "Длина текста в одном блоке (пусто = по умолчанию)",
"overlapLabel": "Перекрытие",
"overlapHint": "Нахлест между соседними блоками (пусто = по умолчанию)"
},
"upload": "Начать загрузку",
"uploading": "Загрузка..."
},
"search": {
"queryLabel": "Поиск по базе знаний",
"queryPlaceholder": "Введите ключевые слова...",
"resultCountLabel": "Количество результатов",
"searching": "Поиск...",
"resultsTitle": "Результаты поиска",
"relevance": "Релевантность",
"noResults": "Совпадений не найдено"
},
"deleteDialog": {
"title": "Подтверждение удаления",
"confirmText": "Вы уверены, что хотите удалить базу знаний «{name}»?",
"warning": "Это действие необратимо. Весь контент базы знаний будет навсегда удален.",
"cancel": "Отмена",
"delete": "Удалить"
},
"messages": {
"pluginNotAvailable": "Плагин не установлен или недоступен",
"pluginNotActivated": "Плагин astrbot_plugin_knowledge_base не включен. Пожалуйста, активируйте его в разделе плагинов и перезапустите AstrBot.",
"checkPluginFailed": "Не удалось проверить плагин",
"installFailed": "Ошибка установки",
"installPluginFailed": "Не удалось установить плагин",
"getKnowledgeBaseListFailed": "Ошибка получения списка баз знаний",
"knowledgeBaseCreated": "База знаний создана",
"createFailed": "Ошибка создания",
"createKnowledgeBaseFailed": "Не удалось создать базу знаний",
"pleaseEnterKnowledgeBaseName": "Укажите название базы знаний",
"pleaseSelectFile": "Пожалуйста, сначала выберите файл",
"operationSuccess": "Успешно: {message}",
"uploadFailed": "Ошибка загрузки",
"fileUploadFailed": "Не удалось загрузить файл",
"pleaseEnterSearchContent": "Введите текст для поиска",
"noMatchingContent": "Ничего не найдено",
"searchFailed": "Ошибка поиска",
"searchKnowledgeBaseFailed": "Не удалось выполнить поиск",
"deleteTargetNotExists": "Объект для удаления не найден",
"knowledgeBaseDeleted": "База знаний удалена",
"deleteFailed": "Ошибка удаления",
"deleteKnowledgeBaseFailed": "Не удалось удалить базу знаний",
"getEmbeddingModelListFailed": "Не удалось загрузить список Embedding моделей",
"updateAvailable": "Доступна новая версия: {current} -> {latest}",
"pluginUpToDate": "У вас последняя версия плагина",
"pluginNotFoundInMarket": "Плагин не найден в магазине",
"checkUpdateFailed": "Ошибка проверки обновлений",
"updateSuccess": "Плагин успешно обновлен",
"updateFailed": "Ошибка обновления",
"updatePluginFailed": "Не удалось обновить плагин"
},
"importFromUrl": {
"title": "Импорт из URL",
"urlLabel": "Адрес страницы",
"urlPlaceholder": "Введите URL для извлечения знаний",
"optionsTitle": "Настройки импорта",
"tooltip": "Эти параметры управляют извлечением текста из URL.\nЕсли оставить пустыми, будут использованы настройки по умолчанию.\nТекстовая очистка через LLM может занять время.",
"useLlmRepairLabel": "Исправление текста через LLM",
"useClusteringSummaryLabel": "Кластеризация и суммаризация",
"repairLlmProviderIdLabel": "Модель для очистки",
"summarizeLlmProviderIdLabel": "Модель для суммаризации",
"embeddingProviderIdLabel": "Embedding модель",
"chunkSizeLabel": "Размер фрагмента",
"chunkOverlapLabel": "Перекрытие",
"startImport": "Начать импорт",
"importing": "Импорт...",
"importSuccess": "Импортировано успешно",
"importFailed": "Ошибка импорта",
"uploadingChunks": "Текст извлечен, загрузка фрагментов...",
"preRequisite": "Примечание: сначала установите плагин astrbot_plugin_url_2_knowledge_base и выполните установку playwright согласно документации.",
"allChunksUploaded": "Все фрагменты успешно загружены"
}
}
@@ -1,97 +0,0 @@
{
"title": "Долгосрочная память",
"subtitle": "Управление памятью вашего AI-помощника",
"memories": {
"title": "Список воспоминаний",
"content": "Содержание",
"importance": "Важность",
"createTime": "Дата создания",
"lastAccess": "Последнее обращение",
"category": "Категория"
},
"categories": {
"personal": "Личное",
"preferences": "Предпочтения",
"conversations": "История диалогов",
"facts": "Факты",
"skills": "Навыки"
},
"importance": {
"high": "Высокая",
"medium": "Средняя",
"low": "Низкая"
},
"actions": {
"view": "Детали",
"edit": "Изменить",
"delete": "Удалить",
"pin": "Закрепить",
"unpin": "Открепить"
},
"filters": {
"all": "Все",
"category": "По категории",
"importance": "По важности",
"dateRange": "По периоду",
"title": "Фильтр",
"userIdLabel": "Фильтр по User ID",
"filterButton": "Применить",
"resetButton": "Сбросить",
"refreshButton": "Обновить граф"
},
"search": {
"title": "Поиск по памяти",
"userIdLabel": "ID пользователя",
"queryLabel": "Ключевое слово",
"searchButton": "Поиск",
"resultsTitle": "Результаты поиска",
"noResults": "Ничего не найдено",
"similarity": "Сходство",
"noTextContent": "Нет текста"
},
"addMemory": {
"title": "Добавить данные в память",
"textLabel": "Текст воспоминания",
"userIdLabel": "ID пользователя",
"summarizeLabel": "Нужна суммаризация",
"addButton": "Добавить"
},
"nodeDetails": {
"title": "Детали узла",
"id": "ID",
"type": "Тип",
"name": "Имя",
"userId": "ID пользователя",
"timestamp": "Метка времени"
},
"graphStats": {
"title": "Статистика графа",
"nodeCount": "Узлов",
"edgeCount": "Связей"
},
"factDialog": {
"title": "Факт из памяти",
"id": "ID",
"docId": "ID документа",
"createdAt": "Создано",
"updatedAt": "Обновлено",
"metadata": "Метаданные",
"metadataKey": "Ключ",
"metadataValue": "Значение",
"loading": "Загрузка...",
"close": "Закрыть",
"noValue": "нет",
"unknown": "неизвестно"
},
"messages": {
"searchQueryRequired": "Пожалуйста, введите запрос",
"searchSuccess": "Найдено записей: {count}",
"searchNoResults": "В памяти ничего не найдено",
"searchError": "Ошибка поиска",
"addSuccess": "Данные успешно добавлены в память!",
"addError": "Не удалось добавить данные",
"factDetailsError": "Ошибка загрузки деталей",
"metadataParseError": "Не удалось разобрать метаданные",
"relationNoMemoryData": "У этой связи нет ассоциированных данных"
}
}
@@ -1,14 +0,0 @@
{
"login": "Вход",
"username": "Имя пользователя",
"password": "Пароль",
"defaultHint": "Логин и пароль по умолчанию: astrbot",
"logo": {
"title": "Панель управления AstrBot",
"subtitle": "Добро пожаловать"
},
"theme": {
"switchToDark": "Перейти на темную тему",
"switchToLight": "Перейти на светлую тему"
}
}
@@ -1,4 +0,0 @@
{
"messageCount": "Количество сообщений",
"time": "Время"
}
@@ -1,151 +0,0 @@
{
"title": "Давай пообщаемся!",
"subtitle": "Общение с AI-помощником",
"input": {
"placeholder": "Введите сообщение...",
"send": "Отправить",
"clear": "Очистить",
"upload": "Загрузить файл",
"voice": "Голосовой ввод",
"recordingPrompt": "Запись... говорите",
"chatPrompt": "Давай пообщаемся!",
"dropToUpload": "Отпустите, чтобы загрузить файл",
"stopGenerating": "Остановить генерацию"
},
"message": {
"user": "Вы",
"assistant": "Ассистент",
"system": "Система",
"error": "Ошибка в сообщении",
"loading": "Думаю..."
},
"voice": {
"start": "Начать запись",
"stop": "Стоп",
"recording": "Запись",
"processing": "Обработка...",
"error": "Ошибка записи",
"listening": "Слушаю...",
"speaking": "Говорю",
"startRecording": "Начать голосовой ввод",
"liveMode": "Общение в реальном времени"
},
"welcome": {
"title": "Добро пожаловать в AstrBot",
"subtitle": "Ваш умный помощник",
"quickActions": "Быстрые действия",
"examples": "Примеры вопросов"
},
"actions": {
"copy": "Копировать",
"regenerate": "Перегенерировать",
"like": "Нравится",
"dislike": "Не нравится",
"share": "Поделиться",
"newChat": "Новый чат",
"deleteChat": "Удалить чат",
"editTitle": "Изменить заголовок",
"fullscreen": "На весь экран",
"exitFullscreen": "Выход из полноэкранного режима",
"reply": "Ответить",
"providerConfig": "Настройки AI",
"toolsUsed": "Использованные инструменты",
"toolCallUsed": "Использован инструмент {name}",
"pythonCodeAnalysis": "Использован анализ кода Python"
},
"ipython": {
"output": "Вывод"
},
"conversation": {
"newConversation": "Новый чат",
"noHistory": "История диалогов пуста",
"systemStatus": "Статус системы",
"llmService": "Сервис LLM",
"speechToText": "Преобразование речи",
"editDisplayName": "Изменить имя чата",
"displayName": "Имя чата",
"displayNameUpdated": "Имя чата обновлено",
"displayNameUpdateFailed": "Не удалось обновить имя чата",
"confirmDelete": "Вы уверены, что хотите удалить «{name}»? Это действие необратимо."
},
"modes": {
"darkMode": "Темная тема",
"lightMode": "Светлая тема"
},
"shortcuts": {
"help": "Справка",
"voiceRecord": "Запись голоса",
"pasteImage": "Вставить изображение",
"sendKey": {
"title": "Клавиша отправки",
"enterToSend": "Enter для отправки",
"shiftEnterToSend": "Shift+Enter для отправки"
}
},
"streaming": {
"enabled": "Потоковый ответ включен",
"disabled": "Потоковый ответ выключен",
"on": "Поток",
"off": "Обычный"
},
"transport": {
"title": "Протокол передачи",
"sse": "SSE",
"websocket": "WebSocket"
},
"config": {
"title": "Конфигурация"
},
"reasoning": {
"thinking": "Рассуждение"
},
"reply": {
"replyTo": "В ответ на",
"notFound": "Сообщение не найдено"
},
"project": {
"title": "Проект",
"create": "Создать проект",
"edit": "Изменить проект",
"name": "Имя проекта",
"emoji": "Иконка (Emoji)",
"description": "Описание проекта (опционально)",
"noSessions": "В этом проекте пока нет диалогов",
"confirmDelete": "Вы уверены, что хотите удалить проект «{title}»? Диалоги внутри проекта не будут удалены."
},
"time": {
"today": "Сегодня",
"yesterday": "Вчера"
},
"stats": {
"tokens": "Токены",
"inputTokens": "Входящие",
"outputTokens": "Исходящие",
"cachedTokens": "Кэшированные",
"duration": "Время",
"ttft": "Время до первого токена"
},
"refs": {
"title": "Ссылки",
"sources": "Источники"
},
"connection": {
"title": "Статус подключения",
"message": "Системе необходимо переустановить соединение с чатом.",
"reasons": "Это может быть вызвано следующими причинами:",
"reasonWindowResize": "Изменение размера окна (нормально)",
"reasonMultipleTabs": "Страница чата открыта в другой вкладке",
"reasonNetworkIssue": "Временная проблема с сетью",
"notice": "Примечание: для стабильной работы допускается только одно активное соединение. Если вы используете чат в нескольких вкладках, рекомендуем оставить только одну.",
"understand": "Понятно",
"status": {
"reconnecting": "Переподключение...",
"reconnected": "Соединение восстановлено",
"failed": "Ошибка подключения, обновите страницу"
}
},
"errors": {
"sendMessageFailed": "Ошибка отправки сообщения, попробуйте еще раз",
"createSessionFailed": "Ошибка создания сессии, обновите страницу"
}
}
@@ -1,95 +0,0 @@
{
"title": "Управление командами",
"summary": {
"total": "Всего команд",
"disabled": "Отключено",
"conflicts": "Конфликты"
},
"conflictAlert": {
"title": "Обнаружены конфликты команд",
"description": "Сейчас конфликтуют {count} пары команд. Это может привести к одновременному срабатыванию нескольких плагинов и непредсказуемому поведению.",
"hint": "Нажмите «Переименовать», чтобы изменить название конфликтующей команды."
},
"table": {
"headers": {
"command": "Команда",
"type": "Тип",
"plugin": "Плагин",
"description": "Описание",
"permission": "Доступ",
"status": "Статус",
"actions": "Действия"
}
},
"type": {
"command": "Команда",
"group": "Группа команд",
"subCommand": "Под-команда"
},
"status": {
"enabled": "Активна",
"disabled": "Отключена",
"conflict": "Конфликт"
},
"permission": {
"everyone": "Все",
"admin": "Админ"
},
"tooltips": {
"enable": "Включить",
"disable": "Выключить",
"rename": "Переименовать",
"viewDetails": "Подробности"
},
"dialogs": {
"rename": {
"title": "Переименование команды",
"newName": "Новое название",
"aliases": "Управление алиасами",
"addAlias": "Добавить алиас",
"cancel": "Отмена",
"confirm": "Подтвердить"
},
"details": {
"title": "Детали команды",
"type": "Тип команды",
"handler": "Обработчик (Handler)",
"module": "Путь к модулю",
"originalCommand": "Исходная команда",
"effectiveCommand": "Действующая команда",
"parentGroup": "Родительская группа",
"subCommands": "Под-команды",
"aliases": "Алиасы (Синонимы)",
"permission": "Требования прав",
"conflictStatus": "Статус конфликта"
}
},
"messages": {
"toggleSuccess": "Статус команды обновлен",
"toggleFailed": "Не удалось изменить статус команды",
"renameSuccess": "Команда переименована",
"renameFailed": "Ошибка переименования",
"loadFailed": "Ошибка загрузки списка команд",
"updateSuccess": "Обновлено успешно",
"updateFailed": "Ошибка обновления"
},
"search": {
"placeholder": "Поиск команд..."
},
"empty": {
"noCommands": "Команд не найдено",
"noCommandsDesc": "По вашему запросу не найдено ни одной команды"
},
"filters": {
"all": "Все",
"enabled": "Активные",
"disabled": "Отключенные",
"conflict": "Конфликтующие",
"byPlugin": "По плагину",
"byType": "По типу",
"byPermission": "По правам",
"byStatus": "По статусу",
"showSystemPlugins": "Показывать системные плагины",
"systemPluginConflictHint": "Конфликт затрагивает системный плагин, его нельзя скрыть до разрешения конфликта"
}
}
File diff suppressed because it is too large Load Diff
@@ -1,129 +0,0 @@
{
"title": "Конфигурация",
"subtitle": "Управление системными настройками",
"editor": {
"visual": "Визуальный редактор",
"code": "Редактор кода",
"revertCode": "Отменить изменения",
"applyConfig": "Применить",
"applyTip": "Кнопка «Применить» временно фиксирует изменения в визуальном редакторе. Чтобы сохранить их на постоянной основе, нажмите кнопку «Сохранить» в правом нижнем углу."
},
"actions": {
"save": "Сохранить",
"delete": "Удалить",
"add": "Добавить",
"reset": "Сбросить настройки",
"export": "Экспорт",
"import": "Импорт",
"validate": "Проверить"
},
"help": {
"documentation": "Документация",
"support": "Поддержка",
"helpText": "Нужна помощь? См. {documentation} или обратитесь в {support}.",
"helpPrefix": "Нужна помощь? См.",
"helpMiddle": "или обратитесь в",
"helpSuffix": "."
},
"messages": {
"configApplied": "Настройки применены образно. Нажмите «Сохранить» для окончательной записи.",
"configApplyError": "Ошибка применения: некорректный формат JSON.",
"unsavedChangesNotice": "Есть несохраненные изменения. Пожалуйста, нажмите «Сохранить», чтобы они вступили в силу.",
"saveSuccess": "Настройки успешно сохранены",
"saveError": "Ошибка при сохранении",
"loadError": "Ошибка при загрузке настроек",
"deleteSuccess": "Удалено",
"deleteError": "Ошибка удаления",
"updateSuccess": "Обновлено",
"updateError": "Ошибка обновления"
},
"sections": {
"general": "Основные",
"advanced": "Расширенные",
"security": "Безопасность",
"appearance": "Внешний вид",
"notification": "Уведомления"
},
"general": {
"botName": "Имя бота",
"language": "Язык интерфейса",
"timezone": "Часовой пояс",
"autoSave": "Автосохранение",
"debugMode": "Режим отладки"
},
"advanced": {
"logLevel": "Уровень логирования",
"maxConnections": "Макс. соединений",
"timeout": "Тайм-аут",
"retryAttempts": "Попытки повтора",
"cacheSize": "Размер кэша"
},
"security": {
"apiKey": "Ключ API",
"allowedHosts": "Разрешенные хосты",
"rateLimit": "Лимит запросов",
"encryption": "Шифрование"
},
"configSelection": {
"selectConfig": "Выбор конфигурации",
"normalConfig": "Обычная",
"systemConfig": "Системная"
},
"search": {
"placeholder": "Поиск по настройкам (поле/описание/подсказка)",
"noResult": "Совпадений не найдено"
},
"configManagement": {
"title": "Управление конфигурациями",
"description": "AstrBot поддерживает несколько конфигураций для разных ботов. По умолчанию используется «default».",
"newConfig": "Новая конфигурация",
"editConfig": "Изменить конфигурацию",
"manageConfigs": "Управление файлами...",
"configName": "Имя",
"fillConfigName": "Введите имя конфигурации",
"confirmDelete": "Вы уверены, что хотите удалить конфигурацию «{name}»? Это действие необратимо.",
"pleaseEnterName": "Пожалуйста, введите имя",
"createFailed": "Ошибка создания конфигурации",
"deleteFailed": "Ошибка удаления",
"updateFailed": "Ошибка обновления"
},
"buttons": {
"cancel": "Отмена",
"create": "Создать",
"update": "Обновить"
},
"codeEditor": {
"title": "Редактирование файла"
},
"fileUpload": {
"button": "Файлы",
"dialogTitle": "Загруженные файлы",
"dropzone": "Загрузить файлы",
"allowedTypes": "Разрешенные типы: {types}",
"empty": "Файлов нет",
"statusMissing": "Файл отсутствует",
"statusUnconfigured": "Не в конфиге",
"uploadSuccess": "Загружено файлов: {count}",
"uploadFailed": "Ошибка загрузки",
"loadFailed": "Ошибка получения списка файлов",
"fileTooLarge": "Файл слишком велик (макс. {max} МБ): {name}",
"deleteSuccess": "Файл удален",
"deleteFailed": "Ошибка удаления",
"addToConfig": "Добавлено в конфигурацию",
"fileCount": "Файлов: {count}",
"done": "Готово"
},
"unsavedChangesWarning": {
"dialogTitle": "Несохраненные изменения",
"leavePage": "У вас есть несохраненные изменения. Сохранить перед уходом?",
"switchConfig": "Переключение конфигурации приведет к потере несохраненных изменений. Сохранить?",
"options": {
"save": "Сохранить",
"saveAndSwitch": "Сохранить и переключить",
"discardAndSwitch": "Сбросить и переключить",
"closeCard": "Закрыть",
"confirm": "ОК",
"cancel": "Отмена"
}
}
}
@@ -1,18 +0,0 @@
{
"title": "Логи платформы",
"autoScroll": {
"enabled": "Автопрокрутка включена",
"disabled": "Автопрокрутка выключена"
},
"pipInstall": {
"button": "Установить pip-пакет",
"dialogTitle": "Установка Pip-пакета",
"packageLabel": "*Имя пакета, например: llmtuner",
"mirrorLabel": "Использовать зеркало PyPI (опционально)",
"mirrorHint": "Приоритет зеркала PyPI > настройки «Зеркало репозитория PyPI»",
"installButton": "Установить"
},
"debugHint": {
"text": "Для отображения Debug-логов необходимо установить соответствующий уровень в «Конфигурация → Система → Уровень логирования»"
}
}
@@ -1,102 +0,0 @@
{
"title": "Управление диалогами",
"subtitle": "Просмотр и управление историей сообщений",
"filters": {
"title": "Фильтры",
"platform": "ID бота",
"type": "Тип",
"search": "Поиск по ключевым словам",
"reset": "Сбросить"
},
"history": {
"title": "История",
"refresh": "Обновить"
},
"batch": {
"deleteSelected": "Удалить выбранные ({count})",
"exportSelected": "Экспорт выбранных ({count})"
},
"pagination": {
"itemsPerPage": "на странице",
"showingItems": "Показано {start}-{end} из {total}"
},
"table": {
"headers": {
"title": "Заголовок диалога",
"platform": "ID бота",
"type": "Тип сообщения",
"cid": "ID диалога",
"umo": "Источник сообщения",
"sessionId": "ID сессии",
"createdAt": "Создан",
"updatedAt": "Обновлен",
"actions": "Действия"
}
},
"actions": {
"view": "Просмотр",
"edit": "Редактировать",
"delete": "Удалить"
},
"messageTypes": {
"group": "Группа",
"friend": "ЛС",
"unknown": "Неизвестно"
},
"status": {
"noTitle": "Без заголовка",
"unknown": "Неизвестно",
"noData": "История диалогов пуста",
"emptyContent": "Содержимое диалога пусто",
"audioNotSupported": "Ваш браузер не поддерживает воспроизведение аудио."
},
"dialogs": {
"view": {
"title": "Детали диалога",
"editMode": "Режим редактирования",
"previewMode": "Режим просмотра",
"saveChanges": "Сохранить изменения",
"close": "Закрыть",
"confirmClose": "У вас есть несохраненные изменения. Вы уверены, что хотите закрыть?"
},
"edit": {
"title": "Изменить информацию",
"titleLabel": "Заголовок диалога",
"titlePlaceholder": "Введите заголовок",
"cancel": "Отмена",
"save": "Сохранить"
},
"delete": {
"title": "Подтверждение удаления",
"message": "Вы уверены, что хотите удалить диалог «{title}»? Это действие необратимо.",
"cancel": "Отмена",
"confirm": "Удалить"
},
"batchDelete": {
"title": "Массовое удаление",
"message": "Вы уверены, что хотите удалить {count} выбранных диалогов? Это действие необратимо!",
"andMore": "и еще {count}",
"cancel": "Отмена",
"confirm": "Удалить всё",
"warning": "Внимание: удаление нельзя будет отменить!"
}
},
"messages": {
"fetchError": "Не удалось загрузить список диалогов",
"saveSuccess": "Сохранено",
"saveError": "Ошибка сохранения",
"deleteSuccess": "Удалено",
"deleteError": "Ошибка удаления",
"historyError": "Не удалось загрузить историю диалога",
"historySaveSuccess": "История сохранена",
"historySaveError": "Ошибка сохранения истории",
"invalidJson": "Некорректный формат JSON",
"noItemSelected": "Сначала выберите диалоги для удаления",
"batchDeleteSuccess": "Успешно удалено {count} диалогов",
"batchDeleteError": "Ошибка массового удаления",
"batchDeletePartial": "Удаление завершено: успешно {deleted}, ошибок {failed}",
"exportSuccess": "Экспорт завершен",
"exportError": "Ошибка экспорта",
"noItemSelectedForExport": "Сначала выберите диалоги для экспорта"
}
}
@@ -1,66 +0,0 @@
{
"page": {
"title": "Запланированные задачи",
"beta": "Экспериментальные функции",
"subtitle": "Управление будущими задачами AstrBot. Бот автоматически проснется, выполнит задачу и отправит результат. Требуется включить «Проактивные способности» в конфигурации.",
"proactive": {
"supported": "Отправка результатов поддерживается на платформах: {platforms}",
"unsupported": "Нет платформ, поддерживающих проактивные сообщения. Включите их в настройках платформ."
}
},
"actions": {
"create": "Новая задача",
"refresh": "Обновить",
"delete": "Удалить",
"cancel": "Отмена",
"submit": "Создать"
},
"table": {
"title": "Список задач",
"empty": "Задач пока нет.",
"headers": {
"name": "Имя",
"type": "Тип",
"cron": "Cron",
"session": "ID сессии",
"nextRun": "Следующий запуск",
"lastRun": "Последний запуск",
"note": "Описание",
"actions": "Действия"
},
"type": {
"once": "Разовая",
"recurring": "Повторяющаяся",
"activeAgent": "Активный агент",
"workflow": "Рабочий процесс",
"unknown": "{type}"
},
"timezoneLocal": "Местное время",
"notAvailable": "—"
},
"form": {
"title": "Создать задачу",
"chatHint": "Вы можете ставить задачи прямо в чате, AstrBot создаст их автоматически без заполнения этой формы.",
"runOnce": "Разовая задача",
"name": "Имя задачи",
"note": "Описание",
"cron": "Cron-выражения",
"cronPlaceholder": "0 9 * * *",
"runAt": "Время запуска",
"session": "Целевая сессия (platform_id:message_type:session_id)",
"timezone": "Часовой пояс (опционально, напр. Europe/Moscow)",
"enabled": "Включено"
},
"messages": {
"loadFailed": "Ошибка загрузки задач",
"updateFailed": "Ошибка обновления",
"deleteSuccess": "Удалено",
"deleteFailed": "Ошибка удаления",
"sessionRequired": "Укажите сессию",
"noteRequired": "Заполните описание",
"cronRequired": "Укажите Cron-выражение",
"runAtRequired": "Выберите время запуска",
"createSuccess": "Задача создана",
"createFailed": "Ошибка создания"
}
}
@@ -1,65 +0,0 @@
{
"title": "Логи платформы",
"subtitle": "Мониторинг и статистика в реальном времени",
"lastUpdate": "Последнее обновление",
"status": {
"loading": "Загрузка...",
"dataError": "Ошибка получения данных",
"noticeError": "Ошибка получения объявлений",
"online": "В сети",
"uptime": "Время работы",
"memoryUsage": "Память"
},
"stats": {
"totalMessage": {
"title": "Всего сообщений",
"subtitle": "Все сообщения со всех платформ"
},
"onlinePlatform": {
"title": "Платформы",
"subtitle": "Количество подключенных платформ"
},
"runningTime": {
"title": "Время работы",
"subtitle": "Общее время работы системы",
"format": "{hours} ч. {minutes} мин. {seconds} сек."
},
"memoryUsage": {
"title": "Память",
"subtitle": "Использование оперативной памяти",
"cpuLoad": "Загрузка CPU",
"status": {
"good": "Отлично",
"normal": "Нормально",
"high": "Высокая"
}
}
},
"charts": {
"messageTrend": {
"title": "Тренды сообщений",
"subtitle": "Изменение количества сообщений во времени",
"totalMessages": "Всего сообщений",
"dailyAverage": "В среднем за день",
"growthRate": "Скорость роста",
"timeLabel": "Время",
"messageCount": "Кол-во сообщений",
"timeRanges": {
"1day": "За 1 день",
"3days": "За 3 дня",
"1week": "За 7 дней",
"1month": "За 30 дней"
}
},
"platformStat": {
"title": "Статистика по платформам",
"subtitle": "Распределение сообщений по платформам",
"total": "Всего",
"noData": "Нет данных по платформам",
"messageUnit": "шт.",
"platformCount": "Кол-во платформ",
"mostActive": "Самый активный",
"totalPercentage": "Доля от общего числа"
}
}
}
@@ -1,358 +0,0 @@
{
"title": "Плагины",
"subtitle": "Управление и настройка расширений системы",
"tabs": {
"installedPlugins": "Плагины AstrBot",
"market": "Магазин плагинов",
"installedMcpServers": "MCP",
"skills": "Навыки",
"handlersOperation": "Управление поведением"
},
"titles": {
"installedAstrBotPlugins": "Установленные плагины AstrBot"
},
"failedPlugins": {
"title": "Ошибка загрузки ({count})",
"hint": "Эти плагины не удалось загрузить. Вы можете попробовать перезагрузить их или удалить.",
"columns": {
"plugin": "Плагин",
"error": "Ошибка"
}
},
"search": {
"placeholder": "Поиск плагинов...",
"marketPlaceholder": "Поиск в магазине..."
},
"filters": {
"all": "Все"
},
"views": {
"card": "Плитка",
"list": "Список"
},
"buttons": {
"showSystemPlugins": "Показать системные",
"hideSystemPlugins": "Скрыть системные",
"install": "Установить",
"uninstall": "Удалить",
"update": "Обновить",
"reload": "Перезагрузить",
"enable": "Включить",
"disable": "Выключить",
"configure": "Настроить",
"viewInfo": "Детали",
"viewDocs": "Документация",
"viewRepo": "Репозиторий",
"close": "Закрыть",
"save": "Сохранить",
"saveAndClose": "Сохранить и закрыть",
"cancel": "Отмена",
"actions": "Действия",
"back": "Назад",
"selectFile": "Выбрать файл",
"refresh": "Обновить",
"updateAll": "Обновить все",
"deleteSource": "Удалить источник",
"reshuffle": "Мне повезет!"
},
"status": {
"enabled": "Включен",
"disabled": "Выключен",
"system": "Системный",
"loading": "Загрузка...",
"installed": "Установлен",
"unknown": "Неизвестно"
},
"tooltips": {
"enable": "Включить",
"disable": "Выключить",
"reload": "Перезагрузить",
"configure": "Настроить",
"viewInfo": "Просмотр поведения",
"viewDocs": "Документация",
"update": "Обновить",
"uninstall": "Удалить"
},
"table": {
"headers": {
"name": "Имя",
"description": "Описание",
"version": "Версия",
"author": "Автор",
"status": "Статус",
"actions": "Действия",
"stars": "Звезды",
"lastUpdate": "Обновлен",
"tags": "Теги",
"eventType": "Тип события",
"specificType": "Тип",
"trigger": "Триггер"
}
},
"empty": {
"noPlugins": "Плагины не найдены",
"noPluginsDesc": "Попробуйте установить новые плагины или включите отображение системных."
},
"market": {
"recommended": "🥳 Рекомендуем",
"allPlugins": "📦 Все плагины",
"showFullName": "Полное имя",
"devDocs": "Документация для разработчиков",
"submitRepo": "Добавить репозиторий",
"customSource": "Свои источники",
"source": "Источник",
"availableSources": "Доступные источники",
"sourceManagement": "Управление источниками",
"addSource": "Добавить источник",
"sourceName": "Имя",
"sourceUrl": "Исходный URL",
"defaultSource": "Источник по умолчанию",
"removeSource": "Удалить источник",
"confirmRemoveSource": "Вы уверены, что хотите удалить этот источник плагинов?",
"sourceAdded": "Источник успешно добавлен",
"sourceRemoved": "Источник удален",
"sourceError": "Ошибка операции",
"selectSource": "Выбрать источник",
"currentSource": "Текущий источник",
"editSource": "Изменить источник",
"sourceUpdated": "Источник обновлен",
"defaultOfficialSource": "Официальный источник",
"sourceExists": "Этот источник уже есть в списке",
"installPlugin": "Установить плагин",
"randomPlugins": "🎲 Случайные плагины",
"showRandomPlugins": "Показать случайные",
"hideRandomPlugins": "Скрыть случайные",
"sourceSafetyWarning": "Даже при использовании источников по умолчанию мы не можем гарантировать 100% безопасность и стабильность сторонних плагинов. Пожалуйста, будьте внимательны."
},
"sort": {
"by": "Сортировать по",
"default": "По умолчанию",
"installTime": "Дате установки",
"name": "Имени",
"stars": "Звездам",
"author": "Автору",
"updated": "Дате обновления",
"updateStatus": "Статусу обновления",
"ascending": "По возрастанию",
"descending": "По убыванию"
},
"tags": {
"danger": "Опасно"
},
"dialogs": {
"error": {
"title": "Ошибка",
"checkConsole": "Подробности смотрите в логах платформы"
},
"config": {
"title": "Настройка плагина",
"noConfig": "У этого плагина нет настраиваемых параметров"
},
"loading": {
"title": "Загрузка...",
"logs": "Логи"
},
"uninstall": {
"title": "Подтверждение удаления",
"message": "Вы уверены, что хотите удалить этот плагин?",
"deleteConfig": "Удалить файл конфигурации плагина",
"deleteData": "Удалить сохраненные данные плагина",
"configHint": "Конфиг находится в data/config",
"dataHint": "Данные находятся в data/plugin_data и data/plugins_data"
},
"install": {
"title": "Установка плагина",
"fromFile": "Из файла",
"fromUrl": "По ссылке",
"supportPlatformsCount": "Поддерживает платформ: {count}"
},
"danger_warning": {
"title": "Внимание!",
"message": "Этот плагин может содержать небезопасный код или функции, которые могут привести к нестабильности системы или потере данных. Вы уверены, что хотите продолжить установку?",
"confirm": "Продолжить",
"cancel": "Отмена"
},
"versionCompatibility": {
"title": "Предупреждение о версии",
"message": "Требуемая плагином версия AstrBot не совпадает с вашей текущей версией. Вы можете продолжить установку на свой страх и риск.",
"confirm": "Игнорировать и установить",
"cancel": "Отмена"
},
"forceUpdate": {
"title": "Новых версий не найдено",
"message": "Новых версий не обнаружено. Выполнить принудительную переустановку из удаленного репозитория?",
"confirm": "Принудительно"
},
"updateAllConfirm": {
"title": "Обновить всё",
"message": "Обновить все плагины ({count} шт.)? Это может занять некоторое время.",
"confirm": "Подтвердить"
}
},
"messages": {
"uninstalling": "Удаление",
"refreshing": "Обновление списка плагинов...",
"refreshSuccess": "Список плагинов обновлен",
"refreshFailed": "Ошибка при обновлении списка",
"operationFailed": "Ошибка операции",
"reloadSuccess": "Перезагрузка завершена",
"reloadFailed": "Ошибка перезагрузки",
"updateSuccess": "Обновление завершено",
"addSuccess": "Успешно добавлено",
"saveSuccess": "Сохранено",
"deleteSuccess": "Удалено",
"installing": "Установка из файла...",
"installingFromUrl": "Установка по ссылке...",
"installFailed": "Ошибка установки:",
"getMarketDataFailed": "Ошибка получения данных магазина:",
"hasUpdate": "Доступно обновление:",
"confirmDelete": "Вы уверены, что хотите удалить плагин?",
"fillUrlOrFile": "Укажите ссылку или выберите файл",
"dontFillBoth": "Пожалуйста, используйте либо ссылку, либо файл, но не оба сразу",
"supportedFormats": "Поддерживаются файлы плагинов в формате .zip",
"updateAllSuccess": "Все плагины успешно обновлены",
"updateAllFailed": "Ошибок при обновлении: {failed} из {total}:",
"fillSourceNameAndUrl": "Пожалуйста, введите имя и адрес источника",
"invalidUrl": "Введите корректный URL",
"enterJsonUrl": "Введите URL, возвращающий список плагинов в формате JSON"
},
"upload": {
"fromFile": "Загрузить файл",
"fromUrl": "Указать ссылку",
"selectFile": "Выбрать файл",
"enterUrl": "Ссылка на репозиторий"
},
"skills": {
"modeLocal": "Локальные навыки",
"modeNeo": "Навыки Neo",
"actions": "Действия",
"upload": "Загрузить навыки",
"refresh": "Обновить",
"empty": "Навыки не найдены",
"emptyHint": "Пожалуйста, загрузите архив с навыками",
"uploadDialogTitle": "Загрузка навыков",
"uploadHint": "Поддерживается массовая загрузка zip-архивов. Вы также можете перетащить файлы в это окно. Система автоматически проверит структуру каждого архива.",
"structureRequirement": "Архив должен содержать одну корневую папку (например, `skillname/`), внутри которой обязательно должен находиться файл `SKILL.md`.",
"abilityMultiple": "Поддержка массовой загрузки",
"abilityValidate": "Автопроверка `SKILL.md`",
"abilitySkip": "Пропуск дубликатов",
"selectFile": "Выбрать файл",
"selectFiles": "Выбрать файлы",
"dropzoneTitle": "Перетащите zip-файлы сюда",
"dropzoneAction": "или нажмите, чтобы выбрать файлы на компьютере",
"dropzoneHint": "Система проверит структуру архивов перед загрузкой",
"fileListTitle": "Очередь загрузки",
"fileListEmpty": "Здесь будет отображаться статус проверки и загрузки файлов",
"uploading": "Загрузка...",
"batchResultTitle": "Результаты загрузки",
"batchResultSummary": "Всего: {total}, успешно: {success}",
"batchSuccessList": "Успешно загружено",
"batchFailedList": "Ошибка загрузки",
"confirm": "ОК",
"confirmUpload": "Начать загрузку",
"cancel": "Отмена",
"statusWaiting": "В очереди",
"statusUploading": "Загрузка...",
"statusSuccess": "Готово",
"statusError": "Ошибка структуры",
"statusSkipped": "Пропущено",
"summaryTotal": "Всего: {count}",
"summaryReady": "Готовы: {count}",
"summarySuccess": "Успешно: {count}",
"summaryFailed": "Ошибок: {count}",
"summarySkipped": "Дубликатов: {count}",
"validationReady": "Ожидает загрузки (проверка структуры будет выполнена автоматически)",
"validationZipOnly": "Допускаются только zip-архивы",
"validationDuplicate": "Файл уже есть в списке, пропуск",
"validationUploading": "Проверка и загрузка...",
"validationUploadFailed": "Ошибка загрузки, попробуйте еще раз",
"validationUploadedAs": "Установлено как {name}",
"validationNoResult": "Результат не получен, проверьте логи платформы",
"noDescription": "Нет описания",
"path": "Путь",
"uploadSuccess": "Успешно загружено",
"uploadFailed": "Ошибка загрузки",
"download": "Скачать",
"downloadSuccess": "Скачивание начато",
"downloadFailed": "Ошибка скачивания",
"loadFailed": "Не удалось загрузить навыки",
"updateSuccess": "Обновлено",
"updateFailed": "Ошибка обновления",
"deleteTitle": "Подтверждение удаления",
"deleteMessage": "Вы уверены, что хотите удалить этот навык?",
"deleteSuccess": "Удалено",
"deleteFailed": "Ошибка удаления",
"neoSkillKey": "Фильтр по ключу",
"neoStatus": "Статус кандидата",
"neoStage": "Этап публикации",
"neoFilterHint": "Фильтрация записей о публикации",
"neoAll": "Все",
"neoCandidates": "Кандидаты Neo",
"neoReleases": "Релизы Neo",
"neoLoadFailed": "Ошибка загрузки данных Neo Skills",
"neoPass": "Одобрить",
"neoReject": "Отклонить",
"neoEvaluateSuccess": "Оценка обновлена",
"neoEvaluateFailed": "Ошибка обновления оценки",
"neoPromoteSuccess": "Опубликовано",
"neoPromoteFailed": "Ошибка публикации",
"neoRollback": "Откат",
"neoRollbackSuccess": "Откат выполнен",
"neoRollbackFailed": "Ошибка отката",
"neoDeactivate": "Деактивация",
"neoDeactivateSuccess": "Деактивировано",
"neoDeactivateFailed": "Ошибка деактивации",
"neoSync": "Синхронизация",
"neoSyncSuccess": "Синхронизировано",
"neoSyncFailed": "Ошибка синхронизации",
"neoDelete": "Удалить",
"neoDeleteSuccess": "Удалено",
"neoDeleteFailed": "Ошибка удаления",
"neoPayloadTitle": "Детали Neo Payload",
"neoPayloadFailed": "Ошибка чтения Payload",
"runtimeNoneWarning": "Среда выполнения Computer Use не задана. Навыки могут не работать, так как нет активного окружения.",
"runtimeHint": "Установите среду выполнения в «local» или «sandbox» в настройках способностей использования компьютера.",
"neoRuntimeRequired": "Neo Skills доступны только в среде sandbox с драйвером shipyard_neo.",
"sourceLocalOnly": "Локальный навык",
"sourceSandboxOnly": "Предустановленный Sandbox навык",
"sourceBoth": "Локальный + Sandbox",
"sandboxDiscoveryPending": "Предустановленные Sandbox навыки не найдены. Запустите сессию Sandbox хотя бы один раз.",
"sandboxPresetReadonly": "Предустановленные навыки Sandbox доступны только для чтения и не могут быть удалены здесь."
},
"card": {
"actions": {
"pluginConfig": "Настройки",
"uninstallPlugin": "Удалить",
"reloadPlugin": "Перезагрузить",
"togglePlugin": "Плагин",
"viewHandlers": "Действия",
"updateTo": "Обновить до",
"reinstall": "Переустановить"
},
"status": {
"hasUpdate": "Доступно обновление",
"disabled": "Плагин выключен",
"handlersCount": "действий",
"supportPlatform": "Платформы",
"supportPlatformsCount": "Платформ: {count}",
"astrbotVersion": "Требуемая версия AstrBot"
},
"alt": {
"logo": "логотип",
"extensionIcon": "иконка расширения"
},
"errors": {
"confirmNotRegistered": "$confirm не зарегистрирован"
}
},
"conflicts": {
"title": "Конфликт команд",
"message": "Обнаружены конфликтующие команды. Это может привести к некорректной работе. Рекомендуется разрешить конфликты в панели «Управление командами».",
"pairs": "конфликтующих пар",
"goToManage": "Управление",
"later": "Позже"
},
"pluginChangelog": {
"menuTitle": "Журнал изменений"
}
}
@@ -1,118 +0,0 @@
{
"title": "Детали базы знаний",
"backToList": "К списку",
"tabs": {
"overview": "Обзор",
"documents": "Документы",
"retrieval": "Поиск",
"sessions": "Сессии",
"settings": "Настройки"
},
"overview": {
"title": "Информация",
"name": "Название",
"description": "Описание",
"emoji": "Иконка",
"createdAt": "Создана",
"updatedAt": "Обновлена",
"stats": "Статистика",
"docCount": "Количество документов",
"chunkCount": "Количество фрагментов",
"embeddingModel": "Embedding модель",
"rerankModel": "Rerank модель",
"notSet": "не выбрано"
},
"documents": {
"title": "Список документов",
"upload": "Загрузить",
"empty": "Документов нет",
"name": "Имя файла",
"type": "Тип",
"size": "Размер",
"chunks": "Фрагменты",
"createdAt": "Дата загрузки",
"actions": "Действия",
"view": "Смотреть",
"delete": "Удалить",
"deleteConfirm": "Вы уверены, что хотите удалить «{name}»?",
"deleteWarning": "Это удалит файл и все его фрагменты из индекса.",
"uploading": "Загрузка...",
"uploadSuccess": "Файл успешно загружен",
"uploadFailed": "Ошибка загрузки",
"deleteSuccess": "Файл удален",
"deleteFailed": "Ошибка удаления"
},
"upload": {
"title": "Добавление контента",
"selectFile": "Файл",
"dropzone": "Нажмите или перетащите файл сюда",
"supportedFormats": "Форматы: ",
"maxSize": "Максимум: 128MB",
"chunkSettings": "Фрагментация",
"batchSettings": "Пакетная обработка",
"cleaningSettings": "Очистка данных",
"enableCleaning": "Включить очистку контента",
"cleaningProvider": "Сервис для очистки",
"cleaningProviderHint": "LLM провайдер для суммаризации и извлечения смыслов из веб-страниц",
"chunkSize": "Размер чанка",
"chunkSizeHint": "Символов в блоке (по умолчанию: 512)",
"chunkOverlap": "Перекрытие",
"chunkOverlapHint": "Перекрытие между блоками (по умолчанию: 50)",
"batchSize": "Размер пакета",
"batchSizeHint": "Блоков за один запрос (по умолчанию: 32)",
"tasksLimit": "Лимит задач",
"tasksLimitHint": "Макс. параллельных потоков (по умолчанию: 3)",
"maxRetries": "Попытки",
"maxRetriesHint": "Повторов при сбое (по умолчанию: 3)",
"cancel": "Отмена",
"submit": "Загрузить",
"fileRequired": "Пожалуйста, выберите файл",
"fileUpload": "Загрузка файла",
"fromUrl": "Из URL",
"urlPlaceholder": "Ссылка на веб-страницу",
"urlRequired": "Введите URL",
"urlHint": "Контент будет автоматически извлечен со страницы. Убедитесь, что сайт разрешает доступ роботам.",
"beta": "Бета-версия"
},
"retrieval": {
"title": "Поиск и проверка",
"subtitle": "Проверьте качество поиска (Dense & Sparse) по вашей базе знаний",
"query": "Тестовый запрос",
"queryPlaceholder": "Что вы хотите найти?",
"search": "Найти",
"searching": "Ищем...",
"results": "Результаты поиска",
"noResults": "Релевантный контент не найден",
"tryDifferentQuery": "Попробуйте изменить формулировку запроса",
"settings": "Параметры поиска",
"topK": "Количество результатов",
"topKHint": "Сколько фрагментов возвращать",
"enableRerank": "Включить Rerank",
"enableRerankHint": "Применить переранжирование для повышения точности",
"score": "Вес (Score)",
"document": "Документ",
"chunk": "Фрагмент #{index}",
"content": "Текст",
"charCount": "{count} симв.",
"searchSuccess": "Поиск завершен, найдено: {count}",
"searchFailed": "Ошибка выполнения поиска",
"queryRequired": "Введите поисковый запрос"
},
"settings": {
"title": "Общие настройки базы",
"basic": "Основные",
"retrieval": "Поиск",
"chunkSize": "Размер чанка",
"chunkOverlap": "Перекрытие",
"topKDense": "Вернуть (Dense)",
"topKSparse": "Вернуть (Sparse)",
"topMFinal": "Итоговый результат",
"enableRerank": "Включить Rerank",
"embeddingProvider": "Провайдер Embedding",
"rerankProvider": "Провайдер Rerank",
"save": "Сохранить",
"saveSuccess": "Настройки сохранены",
"saveFailed": "Ошибка сохранения",
"tips": "Внимание! Изменение этих параметров повлияет на будущую выдачу базы знаний."
}
}
@@ -1,55 +0,0 @@
{
"title": "Просмотр документа",
"backToKB": "К базе знаний",
"info": {
"title": "Информация о документе",
"name": "Имя файла",
"type": "Формат",
"size": "Размер",
"chunkCount": "Количество фрагментов",
"createdAt": "Загружен"
},
"chunks": {
"title": "Фрагменты текста",
"empty": "Фрагменты не найдены",
"index": "Индекс",
"content": "Текст",
"charCount": "Символов",
"actions": "Действия",
"view": "Детали",
"edit": "Изменить",
"delete": "Удалить",
"preview": "Обзор",
"search": "Поиск по документу",
"searchPlaceholder": "Найти во фрагментах...",
"showing": "Показано",
"deleteConfirm": "Удалить этот фрагмент?",
"deleteSuccess": "Фрагмент удален",
"deleteFailed": "Ошибка удаления"
},
"edit": {
"title": "Редактирование фрагмента",
"content": "Текст",
"cancel": "Отмена",
"save": "Сохранить",
"saveSuccess": "Фрагмент обновлен",
"saveFailed": "Ошибка сохранения"
},
"delete": {
"title": "Удаление",
"confirmText": "Вы уверены?",
"warning": "Удаление фрагмента может ухудшить качество ответов AI по этой теме.",
"cancel": "Отмена",
"confirm": "Удалить",
"deleteSuccess": "Удаление выполнено",
"deleteFailed": "Ошибка удаления"
},
"view": {
"title": "Детальный просмотр",
"index": "Индекс",
"content": "Текст",
"charCount": "Символов",
"vecDocId": "ID вектора",
"close": "Закрыть"
}
}

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