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.--> <!--If merged, your code will serve tens of thousands of users! Please double-check the following items before submitting.-->
<!--如果分支被合并,您的代码将服务于数万名用户!在提交前,请核查一下几点内容。--> <!--如果分支被合并,您的代码将服务于数万名用户!在提交前,请核查一下几点内容。-->
- [ ] 😊 如果 PR 中有新加入的功能,已经通过 Issue / 邮件等方式和作者讨论过。 - [ ] 😊 如果 PR 中有新加入的功能,已经通过 Issue / 邮件等方式和作者讨论过。/ If there are new features added in the PR, I have discussed it with the authors through issues/emails, etc.
/ 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.
/ 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.
+3 -3
View File
@@ -11,7 +11,7 @@ jobs:
runs-on: ubuntu-latest # 运行环境 runs-on: ubuntu-latest # 运行环境
steps: steps:
- name: checkout - name: checkout
uses: actions/checkout@v6 uses: actions/checkout@master
- name: nodejs installation - name: nodejs installation
uses: actions/setup-node@v6 uses: actions/setup-node@v6
with: with:
@@ -23,7 +23,7 @@ jobs:
run: npm run docs:build run: npm run docs:build
working-directory: './docs' working-directory: './docs'
- name: scp - name: scp
uses: appleboy/scp-action@v1.0.0 uses: appleboy/scp-action@master
with: with:
host: ${{ secrets.HOST_NEKO }} host: ${{ secrets.HOST_NEKO }}
username: ${{ secrets.USERNAME }} username: ${{ secrets.USERNAME }}
@@ -31,7 +31,7 @@ jobs:
source: 'docs/.vitepress/dist/*' source: 'docs/.vitepress/dist/*'
target: '/tmp/' target: '/tmp/'
- name: script - name: script
uses: appleboy/ssh-action@v1.2.5 uses: appleboy/ssh-action@master
with: with:
host: ${{ secrets.HOST_NEKO }} host: ${{ secrets.HOST_NEKO }}
username: ${{ secrets.USERNAME }} username: ${{ secrets.USERNAME }}
+1 -1
View File
@@ -45,7 +45,7 @@ jobs:
- name: Create GitHub Release - name: Create GitHub Release
if: github.event_name == 'push' if: github.event_name == 'push'
uses: ncipollo/release-action@v1.20.0 uses: ncipollo/release-action@v1
with: with:
tag: release-${{ github.sha }} tag: release-${{ github.sha }}
owner: AstrBotDevs owner: AstrBotDevs
+10 -10
View File
@@ -64,20 +64,20 @@ jobs:
echo "build_date=$build_date" >> $GITHUB_OUTPUT echo "build_date=$build_date" >> $GITHUB_OUTPUT
- name: Set QEMU - name: Set QEMU
uses: docker/setup-qemu-action@v4.0.0 uses: docker/setup-qemu-action@v4
- name: Set Docker Buildx - name: Set Docker Buildx
uses: docker/setup-buildx-action@v4.0.0 uses: docker/setup-buildx-action@v4
- name: Log in to DockerHub - name: Log in to DockerHub
uses: docker/login-action@v4.0.0 uses: docker/login-action@v4
with: with:
username: ${{ secrets.DOCKER_HUB_USERNAME }} username: ${{ secrets.DOCKER_HUB_USERNAME }}
password: ${{ secrets.DOCKER_HUB_PASSWORD }} password: ${{ secrets.DOCKER_HUB_PASSWORD }}
- name: Login to GitHub Container Registry - name: Login to GitHub Container Registry
if: env.HAS_GHCR_TOKEN == 'true' if: env.HAS_GHCR_TOKEN == 'true'
uses: docker/login-action@v4.0.0 uses: docker/login-action@v4
with: with:
registry: ghcr.io registry: ghcr.io
username: ${{ env.GHCR_OWNER }} username: ${{ env.GHCR_OWNER }}
@@ -98,7 +98,7 @@ jobs:
echo "EOF" >> $GITHUB_OUTPUT echo "EOF" >> $GITHUB_OUTPUT
- name: Build and Push Nightly Image - name: Build and Push Nightly Image
uses: docker/build-push-action@v7.0.0 uses: docker/build-push-action@v7
with: with:
context: . context: .
platforms: linux/amd64,linux/arm64 platforms: linux/amd64,linux/arm64
@@ -163,27 +163,27 @@ jobs:
cp -r dashboard/dist data/ cp -r dashboard/dist data/
- name: Set QEMU - name: Set QEMU
uses: docker/setup-qemu-action@v4.0.0 uses: docker/setup-qemu-action@v4
- name: Set Docker Buildx - name: Set Docker Buildx
uses: docker/setup-buildx-action@v4.0.0 uses: docker/setup-buildx-action@v4
- name: Log in to DockerHub - name: Log in to DockerHub
uses: docker/login-action@v4.0.0 uses: docker/login-action@v4
with: with:
username: ${{ secrets.DOCKER_HUB_USERNAME }} username: ${{ secrets.DOCKER_HUB_USERNAME }}
password: ${{ secrets.DOCKER_HUB_PASSWORD }} password: ${{ secrets.DOCKER_HUB_PASSWORD }}
- name: Login to GitHub Container Registry - name: Login to GitHub Container Registry
if: env.HAS_GHCR_TOKEN == 'true' if: env.HAS_GHCR_TOKEN == 'true'
uses: docker/login-action@v4.0.0 uses: docker/login-action@v4
with: with:
registry: ghcr.io registry: ghcr.io
username: ${{ env.GHCR_OWNER }} username: ${{ env.GHCR_OWNER }}
password: ${{ secrets.GHCR_GITHUB_TOKEN }} password: ${{ secrets.GHCR_GITHUB_TOKEN }}
- name: Build and Push Release Image - name: Build and Push Release Image
uses: docker/build-push-action@v7.0.0 uses: docker/build-push-action@v7
with: with:
context: . context: .
platforms: linux/amd64,linux/arm64 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" echo "tag=$tag" >> "$GITHUB_OUTPUT"
- name: Setup pnpm - name: Setup pnpm
uses: pnpm/action-setup@v4.4.0 uses: pnpm/action-setup@v4
with: with:
version: 10.28.2 version: 10.28.2
+1 -1
View File
@@ -62,4 +62,4 @@ GenieData/
.opencode/ .opencode/
.kilocode/ .kilocode/
.worktrees/ .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 ```bash
uv tool install astrbot uv tool install astrbot
astrbot init # Only execute this command for the first time to initialize the environment 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. > Requires [uv](https://docs.astral.sh/uv/) to be installed.
@@ -234,8 +234,7 @@ pre-commit install
- Group 7: 743746109 - Group 7: 743746109
- Group 8: 1030353265 - Group 8: 1030353265
- Developer Group(Chit-chat): 975206796 - Developer Group: 975206796
- Developer Group(Formal): 1039761811
### Discord Server ### Discord Server
+1 -2
View File
@@ -78,7 +78,7 @@ Pour les utilisateurs qui veulent découvrir AstrBot rapidement, qui sont famili
```bash ```bash
uv tool install astrbot uv tool install astrbot
astrbot init # Exécutez cette commande uniquement la première fois pour initialiser l'environnement 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é. > [uv](https://docs.astral.sh/uv/) doit être installé.
@@ -222,7 +222,6 @@ pre-commit install
- Groupe 5 : 822130018 - Groupe 5 : 822130018
- Groupe 6 : 753075035 - Groupe 6 : 753075035
- Groupe développeurs : 975206796 - Groupe développeurs : 975206796
- Groupe développeurs (officiel) : 1039761811
### Serveur Discord ### Serveur Discord
+1 -2
View File
@@ -78,7 +78,7 @@ AstrBot を素早く試したいユーザーで、コマンドラインに慣れ
```bash ```bash
uv tool install astrbot uv tool install astrbot
astrbot init # 初回のみ実行して環境を初期化します astrbot init # 初回のみ実行して環境を初期化します
astrbot run astrbot
``` ```
> [uv](https://docs.astral.sh/uv/) のインストールが必要です。 > [uv](https://docs.astral.sh/uv/) のインストールが必要です。
@@ -223,7 +223,6 @@ pre-commit install
- 5群: 822130018 - 5群: 822130018
- 6群: 753075035 - 6群: 753075035
- 開発者群: 975206796 - 開発者群: 975206796
- 開発者群(正式): 1039761811
### Discord サーバー ### Discord サーバー
+1 -2
View File
@@ -78,7 +78,7 @@ AstrBot — это универсальная платформа Agent-чатб
```bash ```bash
uv tool install astrbot uv tool install astrbot
astrbot init # Выполните эту команду только при первом запуске для инициализации окружения astrbot init # Выполните эту команду только при первом запуске для инициализации окружения
astrbot run astrbot
``` ```
> Требуется установленный [uv](https://docs.astral.sh/uv/). > Требуется установленный [uv](https://docs.astral.sh/uv/).
@@ -222,7 +222,6 @@ pre-commit install
- Группа 5: 822130018 - Группа 5: 822130018
- Группа 6: 753075035 - Группа 6: 753075035
- Группа разработчиков: 975206796 - Группа разработчиков: 975206796
- Группа разработчиков (официальная): 1039761811
### Сервер Discord ### Сервер Discord
+2 -3
View File
@@ -78,7 +78,7 @@ AstrBot 是一個開源的一站式 Agent 聊天機器人平台,可接入主
```bash ```bash
uv tool install astrbot uv tool install astrbot
astrbot init # 僅首次執行此命令以初始化環境 astrbot init # 僅首次執行此命令以初始化環境
astrbot run astrbot
``` ```
> 需要安裝 [uv](https://docs.astral.sh/uv/)。 > 需要安裝 [uv](https://docs.astral.sh/uv/)。
@@ -225,8 +225,7 @@ pre-commit install
- 6 群:753075035 - 6 群:753075035
- 7 群:743746109 - 7 群:743746109
- 8 群:1030353265 - 8 群:1030353265
- 開發者群(闲聊吹水)975206796 - 開發者群:975206796
- 開發者群(正式):1039761811
### Discord 群組 ### Discord 群組
+2 -3
View File
@@ -78,7 +78,7 @@ AstrBot 是一个开源的一站式 Agentic 个人和群聊助手,可在 QQ、
```bash ```bash
uv tool install astrbot uv tool install astrbot
astrbot init # 仅首次执行此命令以初始化环境 astrbot init # 仅首次执行此命令以初始化环境
astrbot run astrbot
``` ```
> 需要安装 [uv](https://docs.astral.sh/uv/)。 > 需要安装 [uv](https://docs.astral.sh/uv/)。
@@ -226,8 +226,7 @@ pre-commit install
- 6 群:753075035 - 6 群:753075035
- 7 群:743746109 - 7 群:743746109
- 8 群:1030353265 - 8 群:1030353265
- 开发者群(偏闲聊吹水)975206796 - 开发者群:975206796
- 开发者群(正式):1039761811
### Discord 频道 ### 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() 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 stored bytes or (text, bytes)
audio_queue: asyncio.Queue[bytes | tuple[str, bytes] | None] = asyncio.Queue() audio_queue: asyncio.Queue[bytes | tuple[str, bytes] | None] = asyncio.Queue()
@@ -335,7 +334,6 @@ async def run_live_agent(
_run_agent_feeder( _run_agent_feeder(
agent_runner, agent_runner,
text_queue, text_queue,
delta_queue,
max_step, max_step,
show_tool_use, show_tool_use,
show_tool_call_result, show_tool_call_result,
@@ -355,63 +353,32 @@ async def run_live_agent(
# 3. 主循环:从 audio_queue 读取音频并 yield # 3. 主循环:从 audio_queue 读取音频并 yield
try: try:
delta_done = False while True:
audio_done = False queue_item = await audio_queue.get()
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"
done, pending = await asyncio.wait( if queue_item is None:
list(task_sources), break
return_when=asyncio.FIRST_COMPLETED,
)
for task in pending: text = None
task.cancel() if isinstance(queue_item, tuple):
if pending: text, audio_data = queue_item
await asyncio.gather(*pending, return_exceptions=True) else:
audio_data = queue_item
for task in done: if not first_chunk_received:
source = task_sources[task] # 记录首帧延迟(从开始处理到收到第一个音频块)
queue_item = task.result() tts_first_frame_time = time.time() - tts_start_time
if source == "delta": first_chunk_received = True
if queue_item is None:
delta_done = True
continue
yield MessageChain(
chain=[Plain(queue_item)], type="live_text_delta"
)
continue
if queue_item is None: # 将音频数据封装为 MessageChain
audio_done = True import base64
continue
text = None audio_b64 = base64.b64encode(audio_data).decode("utf-8")
if isinstance(queue_item, tuple): comps: list[BaseMessageComponent] = [Plain(audio_b64)]
text, audio_data = queue_item if text:
else: comps.append(Json(data={"text": text}))
audio_data = queue_item chain = MessageChain(chain=comps, type="audio_chunk")
yield chain
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
except Exception as e: except Exception as e:
logger.error(f"[Live Agent] 运行时发生错误: {e}", exc_info=True) logger.error(f"[Live Agent] 运行时发生错误: {e}", exc_info=True)
@@ -454,7 +421,6 @@ async def run_live_agent(
async def _run_agent_feeder( async def _run_agent_feeder(
agent_runner: AgentRunner, agent_runner: AgentRunner,
text_queue: asyncio.Queue, text_queue: asyncio.Queue,
delta_queue: asyncio.Queue,
max_step: int, max_step: int,
show_tool_use: bool, show_tool_use: bool,
show_tool_call_result: bool, show_tool_call_result: bool,
@@ -474,13 +440,9 @@ async def _run_agent_feeder(
if chain is None: if chain is None:
continue continue
if chain.type == "reasoning":
continue
# 提取文本 # 提取文本
text = chain.get_plain_text() text = chain.get_plain_text()
if text: if text:
await delta_queue.put(text)
buffer += text buffer += text
# 分句逻辑:匹配标点符号 # 分句逻辑:匹配标点符号
@@ -515,7 +477,6 @@ async def _run_agent_feeder(
finally: finally:
# 发送结束信号 # 发送结束信号
await text_queue.put(None) await text_queue.put(None)
await delta_queue.put(None)
async def _safe_tts_stream_wrapper( async def _safe_tts_stream_wrapper(
-5
View File
@@ -778,14 +778,9 @@ def _plugin_tool_fix(event: AstrMessageEvent, req: ProviderRequest) -> None:
continue continue
mp = tool.handler_module_path mp = tool.handler_module_path
if not mp: if not mp:
# 没有 plugin 归属信息的工具(如 subagent transfer_to_*
# 不应受到会话插件过滤影响。
new_tool_set.add_tool(tool)
continue continue
plugin = star_map.get(mp) plugin = star_map.get(mp)
if not plugin: if not plugin:
# 无法解析插件归属时,保守保留工具,避免误过滤。
new_tool_set.add_tool(tool)
continue continue
if plugin.name in event.plugins_name or plugin.reserved: if plugin.name in event.plugins_name or plugin.reserved:
new_tool_set.add_tool(tool) new_tool_set.add_tool(tool)
+2 -20
View File
@@ -188,12 +188,7 @@ class KnowledgeBaseQueryTool(FunctionTool[AstrAgentContext]):
@dataclass @dataclass
class SendMessageToUserTool(FunctionTool[AstrAgentContext]): class SendMessageToUserTool(FunctionTool[AstrAgentContext]):
name: str = "send_message_to_user" name: str = "send_message_to_user"
description: str = ( 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."
"Send message to the user. "
"Supports various message types including `plain`, `image`, `record`, `video`, `file`, and `mention_user`. "
"Use this tool to send media files (`image`, `record`, `video`, `file`), "
"or when you need to proactively message the user(such as cron job). For normal text replies, you can output directly."
)
parameters: dict = Field( parameters: dict = Field(
default_factory=lambda: { default_factory=lambda: {
@@ -209,7 +204,7 @@ class SendMessageToUserTool(FunctionTool[AstrAgentContext]):
"type": "string", "type": "string",
"description": ( "description": (
"Component type. One of: " "Component type. One of: "
"plain, image, record, video, file, mention_user. Record is voice message." "plain, image, record, file, mention_user"
), ),
}, },
"text": { "text": {
@@ -325,19 +320,6 @@ class SendMessageToUserTool(FunctionTool[AstrAgentContext]):
components.append(Comp.Record.fromURL(url=url)) components.append(Comp.Record.fromURL(url=url))
else: else:
return f"error: messages[{idx}] must include path or url for record component." 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": elif msg_type == "file":
path = msg.get("path") path = msg.get("path")
url = msg.get("url") url = msg.get("url")
+8 -38
View File
@@ -1,7 +1,6 @@
from __future__ import annotations from __future__ import annotations
import asyncio import asyncio
import locale
import os import os
import shutil import shutil
import subprocess import subprocess
@@ -53,31 +52,6 @@ def _ensure_safe_path(path: str) -> str:
return abs_path 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 @dataclass
class LocalShellComponent(ShellComponent): class LocalShellComponent(ShellComponent):
async def exec( async def exec(
@@ -98,32 +72,28 @@ class LocalShellComponent(ShellComponent):
run_env.update({str(k): str(v) for k, v in env.items()}) 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() working_dir = _ensure_safe_path(cwd) if cwd else get_astrbot_root()
if background: if background:
# `command` is intentionally executed through the current shell so proc = subprocess.Popen(
# local computer-use behavior matches existing tool semantics.
# Safety relies on `_is_safe_command()` and the allowed-root checks.
proc = subprocess.Popen( # noqa: S602 # nosemgrep: python.lang.security.audit.dangerous-subprocess-use-audit
command, command,
shell=shell, shell=shell,
cwd=working_dir, cwd=working_dir,
env=run_env, env=run_env,
stdout=subprocess.DEVNULL, stdout=subprocess.PIPE,
stderr=subprocess.DEVNULL, stderr=subprocess.PIPE,
text=True,
) )
return {"pid": proc.pid, "stdout": "", "stderr": "", "exit_code": None} return {"pid": proc.pid, "stdout": "", "stderr": "", "exit_code": None}
# `command` is intentionally executed through the current shell so result = subprocess.run(
# local computer-use behavior matches existing tool semantics.
# Safety relies on `_is_safe_command()` and the allowed-root checks.
result = subprocess.run( # noqa: S602 # nosemgrep: python.lang.security.audit.dangerous-subprocess-use-audit
command, command,
shell=shell, shell=shell,
cwd=working_dir, cwd=working_dir,
env=run_env, env=run_env,
timeout=timeout, timeout=timeout,
capture_output=True, capture_output=True,
text=True,
) )
return { return {
"stdout": _decode_shell_output(result.stdout), "stdout": result.stdout,
"stderr": _decode_shell_output(result.stderr), "stderr": result.stderr,
"exit_code": result.returncode, "exit_code": result.returncode,
} }
-6
View File
@@ -422,12 +422,6 @@ async def get_booter(
) -> ComputerBooter: ) -> ComputerBooter:
config = context.get_config(umo=session_id) 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", {}) sandbox_cfg = config.get("provider_settings", {}).get("sandbox", {})
booter_type = sandbox_cfg.get("booter", "shipyard_neo") booter_type = sandbox_cfg.get("booter", "shipyard_neo")
+1 -4
View File
@@ -164,10 +164,7 @@ class CreateSkillPayloadTool(NeoSkillToolBase):
"type": "object", "type": "object",
"properties": { "properties": {
"payload": { "payload": {
"anyOf": [ "anyOf": [{"type": "object"}, {"type": "array"}],
{"type": "object"},
{"type": "array", "items": {"type": "object"}},
],
"description": ( "description": (
"Skill payload JSON. Typical schema: {skill_markdown, inputs, outputs, meta}. " "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." "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 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") DB_PATH = os.path.join(get_astrbot_data_path(), "data_v4.db")
WEBHOOK_SUPPORTED_PLATFORMS = [ WEBHOOK_SUPPORTED_PLATFORMS = [
@@ -219,9 +219,6 @@ DEFAULT_CONFIG = {
"telegram": { "telegram": {
"pre_ack_emoji": {"enable": False, "emojis": ["✍️"]}, "pre_ack_emoji": {"enable": False, "emojis": ["✍️"]},
}, },
"discord": {
"pre_ack_emoji": {"enable": False, "emojis": ["🤔"]},
},
}, },
"wake_prefix": ["/"], "wake_prefix": ["/"],
"log_level": "INFO", "log_level": "INFO",
@@ -1132,18 +1129,6 @@ CONFIG_METADATA_2 = {
"proxy": "", "proxy": "",
"custom_headers": {}, "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": { "xAI": {
"id": "xai", "id": "xai",
"provider": "xai", "provider": "xai",
+2 -2
View File
@@ -332,9 +332,9 @@ class CronJobManager:
cron_job=cron_job_str cron_job=cron_job_str
) )
req.prompt = ( req.prompt = (
"You are now responding to a scheduled task. " "You are now responding to a scheduled task"
"Proceed according to your system instructions. " "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." "After completing your task, summarize and output your actions and results."
) )
if not req.func_tool: if not req.func_tool:
-7
View File
@@ -647,13 +647,6 @@ class BaseDatabase(abc.ABC):
"""Get a Platform session by its ID.""" """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 @abc.abstractmethod
async def get_platform_sessions_by_creator( async def get_platform_sessions_by_creator(
self, self,
-15
View File
@@ -1417,21 +1417,6 @@ class SQLiteDatabase(BaseDatabase):
result = await session.execute(query) result = await session.execute(query)
return result.scalar_one_or_none() 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( async def get_platform_sessions_by_creator(
self, self,
creator: str, creator: str,
+3 -3
View File
@@ -96,10 +96,10 @@ class Plain(BaseMessageComponent):
def __init__(self, text: str, convert: bool = True, **_) -> None: def __init__(self, text: str, convert: bool = True, **_) -> None:
super().__init__(text=text, convert=convert, **_) super().__init__(text=text, convert=convert, **_)
def toDict(self) -> dict: def toDict(self):
return {"type": "text", "data": {"text": self.text}} 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}} 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.event import AstrMessageEvent, MessageChain
from astrbot.api.message_components import ( from astrbot.api.message_components import (
At,
BaseMessageComponent, BaseMessageComponent,
File, File,
Image, Image,
@@ -71,19 +70,11 @@ class AiocqhttpMessageEvent(AstrMessageEvent):
"""解析成 OneBot json 格式""" """解析成 OneBot json 格式"""
ret = [] ret = []
for segment in message_chain.chain: for segment in message_chain.chain:
if isinstance(segment, At): if isinstance(segment, Plain):
# At 组件后插入一个空格,避免与后续文本粘连
d = await AiocqhttpMessageEvent._from_segment_to_dict(segment)
ret.append(d)
ret.append({"type": "text", "data": {"text": " "}})
elif isinstance(segment, Plain):
if not segment.text.strip(): if not segment.text.strip():
continue continue
d = await AiocqhttpMessageEvent._from_segment_to_dict(segment) d = await AiocqhttpMessageEvent._from_segment_to_dict(segment)
ret.append(d) ret.append(d)
else:
d = await AiocqhttpMessageEvent._from_segment_to_dict(segment)
ret.append(d)
return ret return ret
@classmethod @classmethod
@@ -18,7 +18,7 @@ from botpy.types.message import MarkdownPayload, Media
from astrbot.api import logger from astrbot.api import logger
from astrbot.api.event import AstrMessageEvent, MessageChain 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.api.platform import AstrBotMessage, PlatformMetadata
from astrbot.core.utils.astrbot_path import get_astrbot_temp_path from astrbot.core.utils.astrbot_path import get_astrbot_temp_path
from astrbot.core.utils.io import download_image_by_url, file_to_base64 from astrbot.core.utils.io import download_image_by_url, file_to_base64
@@ -47,11 +47,6 @@ _patch_qq_botpy_formdata()
class QQOfficialMessageEvent(AstrMessageEvent): class QQOfficialMessageEvent(AstrMessageEvent):
MARKDOWN_NOT_ALLOWED_ERROR = "不允许发送原生 markdown" 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__( def __init__(
self, self,
@@ -70,71 +65,35 @@ class QQOfficialMessageEvent(AstrMessageEvent):
await self._post_send() await self._post_send()
async def send_streaming(self, generator, use_fallback: bool = False): 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} stream_payload = {"state": 1, "id": None, "index": 0, "reset": False}
last_edit_time = 0 # 上次发送分片的时间 last_edit_time = 0 # 上次编辑消息的时间
throttle_interval = 1 # 分片间最短间隔 (秒) throttle_interval = 1 # 编辑消息的间隔时间 (秒)
ret = None ret = None
source = (
self.message_obj.raw_message
) # 提前获取,避免 generator 为空时 NameError
try: try:
async for chain in generator: async for chain in generator:
source = self.message_obj.raw_message 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: if not self.send_buffer:
self.send_buffer = chain self.send_buffer = chain
else: else:
self.send_buffer.chain.extend(chain.chain) self.send_buffer.chain.extend(chain.chain)
# 节流:按时间间隔发送中间分片 if isinstance(source, botpy.message.C2CMessage):
current_time = asyncio.get_running_loop().time() # 真流式传输
if current_time - last_edit_time >= throttle_interval: current_time = asyncio.get_running_loop().time()
ret = cast( time_since_last_edit = current_time - last_edit_time
message.Message,
await self._post_send(stream=stream_payload), if time_since_last_edit >= throttle_interval:
) ret = cast(
stream_payload["index"] += 1 message.Message,
ret_id = self._extract_response_message_id(ret) await self._post_send(stream=stream_payload),
if ret_id is not None: )
stream_payload["id"] = ret_id stream_payload["index"] += 1
last_edit_time = asyncio.get_running_loop().time() stream_payload["id"] = ret["id"]
self.send_buffer = None # 清空已发送的分片,避免下次重复发送旧内容 last_edit_time = asyncio.get_running_loop().time()
if isinstance(source, botpy.message.C2CMessage): if isinstance(source, botpy.message.C2CMessage):
# 结束流式对话,发送 buffer 中剩余内容 # 结束流式对话,并且传输 buffer 中剩余的消息
stream_payload["state"] = 10 stream_payload["state"] = 10
ret = await self._post_send(stream=stream_payload) ret = await self._post_send(stream=stream_payload)
else: else:
@@ -142,22 +101,9 @@ class QQOfficialMessageEvent(AstrMessageEvent):
except Exception as e: except Exception as e:
logger.error(f"发送流式消息时出错: {e}", exc_info=True) logger.error(f"发送流式消息时出错: {e}", exc_info=True)
# 避免累计内容在异常后被整包重复发送:仅清理缓存,不做非流式整包兜底
# 如需兜底,应该只发送未发送 delta(后续可继续优化)
self.send_buffer = None self.send_buffer = None
return None return await super().send_streaming(generator, use_fallback)
@staticmethod
def _extract_response_message_id(ret) -> str | None:
"""兼容 qq-botpy 返回 Message 对象或 dict 两种形态。"""
if ret is None:
return None
if isinstance(ret, dict):
ret_id = ret.get("id")
return str(ret_id) if ret_id is not None else None
ret_id = getattr(ret, "id", None)
return str(ret_id) if ret_id is not None else None
async def _post_send(self, stream: dict | None = None): async def _post_send(self, stream: dict | None = None):
if not self.send_buffer: if not self.send_buffer:
@@ -180,37 +126,16 @@ class QQOfficialMessageEvent(AstrMessageEvent):
image_base64, image_base64,
image_path, image_path,
record_file_path, record_file_path,
video_file_source,
file_source,
file_name,
) = await QQOfficialMessageEvent._parse_to_qqofficial(self.send_buffer) ) = await QQOfficialMessageEvent._parse_to_qqofficial(self.send_buffer)
# C2C 流式仅用于文本分片,富媒体时降级为普通发送,避免平台侧流式校验报错。
if stream and (image_base64 or record_file_path):
logger.debug("[QQOfficial] 检测到富媒体,降级为非流式发送。")
stream = None
if ( if (
not plain_text not plain_text
and not image_base64 and not image_base64
and not image_path and not image_path
and not record_file_path and not record_file_path
and not video_file_source
and not file_source
): ):
return None 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 = { payload: dict = {
# "content": plain_text, # "content": plain_text,
"markdown": MarkdownPayload(content=plain_text) if plain_text else None, "markdown": MarkdownPayload(content=plain_text) if plain_text else None,
@@ -232,7 +157,7 @@ class QQOfficialMessageEvent(AstrMessageEvent):
if image_base64: if image_base64:
media = await self.upload_group_and_c2c_image( media = await self.upload_group_and_c2c_image(
image_base64, image_base64,
self.IMAGE_FILE_TYPE, 1,
group_openid=source.group_openid, group_openid=source.group_openid,
) )
payload["media"] = media payload["media"] = media
@@ -240,39 +165,15 @@ class QQOfficialMessageEvent(AstrMessageEvent):
payload.pop("markdown", None) payload.pop("markdown", None)
payload["content"] = plain_text or None payload["content"] = plain_text or None
if record_file_path: # group record msg 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, record_file_path,
self.VOICE_FILE_TYPE, 3,
group_openid=source.group_openid, group_openid=source.group_openid,
) )
if media: payload["media"] = media
payload["media"] = media payload["msg_type"] = 7
payload["msg_type"] = 7 payload.pop("markdown", None)
payload.pop("markdown", None) payload["content"] = plain_text or None
payload["content"] = plain_text or None
if video_file_source:
media = await self.upload_group_and_c2c_media(
video_file_source,
self.VIDEO_FILE_TYPE,
group_openid=source.group_openid,
)
if media:
payload["media"] = media
payload["msg_type"] = 7
payload.pop("markdown", None)
payload["content"] = plain_text or None
if file_source:
media = await self.upload_group_and_c2c_media(
file_source,
self.FILE_FILE_TYPE,
file_name=file_name,
group_openid=source.group_openid,
)
if media:
payload["media"] = media
payload["msg_type"] = 7
payload.pop("markdown", None)
payload["content"] = plain_text or None
ret = await self._send_with_markdown_fallback( ret = await self._send_with_markdown_fallback(
send_func=lambda retry_payload: self.bot.api.post_group_message( send_func=lambda retry_payload: self.bot.api.post_group_message(
group_openid=source.group_openid, # type: ignore group_openid=source.group_openid, # type: ignore
@@ -280,14 +181,13 @@ class QQOfficialMessageEvent(AstrMessageEvent):
), ),
payload=payload, payload=payload,
plain_text=plain_text, plain_text=plain_text,
stream=stream,
) )
case botpy.message.C2CMessage(): case botpy.message.C2CMessage():
if image_base64: if image_base64:
media = await self.upload_group_and_c2c_image( media = await self.upload_group_and_c2c_image(
image_base64, image_base64,
self.IMAGE_FILE_TYPE, 1,
openid=source.author.user_openid, openid=source.author.user_openid,
) )
payload["media"] = media payload["media"] = media
@@ -295,39 +195,15 @@ class QQOfficialMessageEvent(AstrMessageEvent):
payload.pop("markdown", None) payload.pop("markdown", None)
payload["content"] = plain_text or None payload["content"] = plain_text or None
if record_file_path: # c2c record 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, record_file_path,
self.VOICE_FILE_TYPE, 3,
openid=source.author.user_openid, openid=source.author.user_openid,
) )
if media: payload["media"] = media
payload["media"] = media payload["msg_type"] = 7
payload["msg_type"] = 7 payload.pop("markdown", None)
payload.pop("markdown", None) payload["content"] = plain_text or None
payload["content"] = plain_text or None
if video_file_source:
media = await self.upload_group_and_c2c_media(
video_file_source,
self.VIDEO_FILE_TYPE,
openid=source.author.user_openid,
)
if media:
payload["media"] = media
payload["msg_type"] = 7
payload.pop("markdown", None)
payload["content"] = plain_text or None
if file_source:
media = await self.upload_group_and_c2c_media(
file_source,
self.FILE_FILE_TYPE,
file_name=file_name,
openid=source.author.user_openid,
)
if media:
payload["media"] = media
payload["msg_type"] = 7
payload.pop("markdown", None)
payload["content"] = plain_text or None
if stream: if stream:
ret = await self._send_with_markdown_fallback( ret = await self._send_with_markdown_fallback(
send_func=lambda retry_payload: self.post_c2c_message( send_func=lambda retry_payload: self.post_c2c_message(
@@ -337,7 +213,6 @@ class QQOfficialMessageEvent(AstrMessageEvent):
), ),
payload=payload, payload=payload,
plain_text=plain_text, plain_text=plain_text,
stream=stream,
) )
else: else:
ret = await self._send_with_markdown_fallback( ret = await self._send_with_markdown_fallback(
@@ -347,7 +222,6 @@ class QQOfficialMessageEvent(AstrMessageEvent):
), ),
payload=payload, payload=payload,
plain_text=plain_text, plain_text=plain_text,
stream=stream,
) )
logger.debug(f"Message sent to C2C: {ret}") logger.debug(f"Message sent to C2C: {ret}")
@@ -363,7 +237,6 @@ class QQOfficialMessageEvent(AstrMessageEvent):
), ),
payload=payload, payload=payload,
plain_text=plain_text, plain_text=plain_text,
stream=stream,
) )
case botpy.message.DirectMessage(): case botpy.message.DirectMessage():
@@ -378,7 +251,6 @@ class QQOfficialMessageEvent(AstrMessageEvent):
), ),
payload=payload, payload=payload,
plain_text=plain_text, plain_text=plain_text,
stream=stream,
) )
case _: case _:
@@ -395,31 +267,10 @@ class QQOfficialMessageEvent(AstrMessageEvent):
send_func, send_func,
payload: dict, payload: dict,
plain_text: str, plain_text: str,
stream: dict | None = None,
): ):
try: try:
return await send_func(payload) return await send_func(payload)
except botpy.errors.ServerError as err: 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 ( if (
self.MARKDOWN_NOT_ALLOWED_ERROR not in str(err) self.MARKDOWN_NOT_ALLOWED_ERROR not in str(err)
or not payload.get("markdown") or not payload.get("markdown")
@@ -431,14 +282,10 @@ class QQOfficialMessageEvent(AstrMessageEvent):
"[QQOfficial] markdown 发送被拒绝,回退到 content 模式重试。" "[QQOfficial] markdown 发送被拒绝,回退到 content 模式重试。"
) )
fallback_payload = payload.copy() fallback_payload = payload.copy()
fallback_payload.pop("markdown", None) fallback_payload["markdown"] = None
fallback_payload["content"] = plain_text fallback_payload["content"] = plain_text
if fallback_payload.get("msg_type") == 2: if fallback_payload.get("msg_type") == 2:
fallback_payload["msg_type"] = 0 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) return await send_func(fallback_payload)
async def upload_group_and_c2c_image( async def upload_group_and_c2c_image(
@@ -480,19 +327,16 @@ class QQOfficialMessageEvent(AstrMessageEvent):
ttl=result.get("ttl", 0), ttl=result.get("ttl", 0),
) )
async def upload_group_and_c2c_media( async def upload_group_and_c2c_record(
self, self,
file_source: str, file_source: str,
file_type: int, file_type: int,
srv_send_msg: bool = False, srv_send_msg: bool = False,
file_name: str | None = None,
**kwargs, **kwargs,
) -> Media | None: ) -> Media | None:
"""上传媒体文件""" """上传媒体文件"""
# 构建基础payload # 构建基础payload
payload = {"file_type": file_type, "srv_send_msg": srv_send_msg} 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): if os.path.exists(file_source):
@@ -556,21 +400,13 @@ class QQOfficialMessageEvent(AstrMessageEvent):
) -> message.Message: ) -> message.Message:
payload = locals() payload = locals()
payload.pop("self", None) 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) route = Route("POST", "/v2/users/{openid}/messages", openid=openid)
result = await self.bot.api._http.request(route, json=payload) 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): if not isinstance(result, dict):
logger.error(f"[QQOfficial] post_c2c_message: 响应不是 dict: {result}") raise RuntimeError(
return None f"Failed to post c2c message, response is not dict: {result}"
)
return message.Message(**result) return message.Message(**result)
@@ -580,9 +416,6 @@ class QQOfficialMessageEvent(AstrMessageEvent):
image_base64 = None # only one img supported image_base64 = None # only one img supported
image_file_path = None image_file_path = None
record_file_path = None record_file_path = None
video_file_source = None
file_source = None
file_name = None
for i in message.chain: for i in message.chain:
if isinstance(i, Plain): if isinstance(i, Plain):
plain_text += i.text plain_text += i.text
@@ -621,30 +454,6 @@ class QQOfficialMessageEvent(AstrMessageEvent):
except Exception as e: except Exception as e:
logger.error(f"处理语音时出错: {e}") logger.error(f"处理语音时出错: {e}")
record_file_path = None 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: else:
logger.debug(f"qq_official 忽略 {i.type}") logger.debug(f"qq_official 忽略 {i.type}")
return ( return plain_text, image_base64, image_file_path, record_file_path
plain_text,
image_base64,
image_file_path,
record_file_path,
video_file_source,
file_source,
file_name,
)
@@ -3,10 +3,8 @@ from __future__ import annotations
import asyncio import asyncio
import logging import logging
import os import os
import random
import time import time
from types import SimpleNamespace from typing import cast
from typing import Any, cast
import botpy import botpy
import botpy.message import botpy.message
@@ -14,7 +12,7 @@ from botpy import Client
from astrbot import logger from astrbot import logger
from astrbot.api.event import MessageChain 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 ( from astrbot.api.platform import (
AstrBotMessage, AstrBotMessage,
MessageMember, MessageMember,
@@ -48,7 +46,6 @@ class botClient(Client):
) )
abm.group_id = cast(str, message.group_openid) abm.group_id = cast(str, message.group_openid)
abm.session_id = abm.group_id abm.session_id = abm.group_id
self.platform.remember_session_scene(abm.session_id, "group")
self._commit(abm) self._commit(abm)
# 收到频道消息 # 收到频道消息
@@ -59,7 +56,6 @@ class botClient(Client):
) )
abm.group_id = message.channel_id abm.group_id = message.channel_id
abm.session_id = abm.group_id abm.session_id = abm.group_id
self.platform.remember_session_scene(abm.session_id, "channel")
self._commit(abm) self._commit(abm)
# 收到私聊消息 # 收到私聊消息
@@ -71,7 +67,6 @@ class botClient(Client):
MessageType.FRIEND_MESSAGE, MessageType.FRIEND_MESSAGE,
) )
abm.session_id = abm.sender.user_id abm.session_id = abm.sender.user_id
self.platform.remember_session_scene(abm.session_id, "friend")
self._commit(abm) self._commit(abm)
# 收到 C2C 消息 # 收到 C2C 消息
@@ -81,11 +76,9 @@ class botClient(Client):
MessageType.FRIEND_MESSAGE, MessageType.FRIEND_MESSAGE,
) )
abm.session_id = abm.sender.user_id abm.session_id = abm.sender.user_id
self.platform.remember_session_scene(abm.session_id, "friend")
self._commit(abm) self._commit(abm)
def _commit(self, abm: AstrBotMessage) -> None: def _commit(self, abm: AstrBotMessage) -> None:
self.platform.remember_session_message_id(abm.session_id, abm.message_id)
self.platform.commit_event( self.platform.commit_event(
QQOfficialMessageEvent( QQOfficialMessageEvent(
abm.message_str, abm.message_str,
@@ -131,9 +124,6 @@ class QQOfficialPlatformAdapter(Platform):
self.client.set_platform(self) 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" self.test_mode = os.environ.get("TEST_MODE", "off") == "on"
async def send_by_session( async def send_by_session(
@@ -141,191 +131,14 @@ class QQOfficialPlatformAdapter(Platform):
session: MessageSesion, session: MessageSesion,
message_chain: MessageChain, message_chain: MessageChain,
) -> None: ) -> None:
await self._send_by_session_common(session, message_chain) raise NotImplementedError("QQ 机器人官方 API 适配器不支持 send_by_session")
async def _send_by_session_common(
self,
session: MessageSesion,
message_chain: MessageChain,
) -> None:
(
plain_text,
image_base64,
image_path,
record_file_path,
video_file_source,
file_source,
file_name,
) = await QQOfficialMessageEvent._parse_to_qqofficial(message_chain)
if (
not plain_text
and not image_path
and not image_base64
and not record_file_path
and not video_file_source
and not file_source
):
return
msg_id = self._session_last_message_id.get(session.session_id)
if not msg_id:
logger.warning(
"[QQOfficial] No cached msg_id for session: %s, skip send_by_session",
session.session_id,
)
return
payload: dict[str, Any] = {"content": plain_text, "msg_id": msg_id}
ret: Any = None
send_helper = SimpleNamespace(bot=self.client)
if session.message_type == MessageType.GROUP_MESSAGE:
scene = self._session_scene.get(session.session_id)
if scene == "group":
payload["msg_seq"] = random.randint(1, 10000)
if image_base64:
media = await QQOfficialMessageEvent.upload_group_and_c2c_image(
send_helper, # type: ignore
image_base64,
QQOfficialMessageEvent.IMAGE_FILE_TYPE,
group_openid=session.session_id,
)
payload["media"] = media
payload["msg_type"] = 7
if record_file_path:
media = await QQOfficialMessageEvent.upload_group_and_c2c_media(
send_helper, # type: ignore
record_file_path,
QQOfficialMessageEvent.VOICE_FILE_TYPE,
group_openid=session.session_id,
)
if media:
payload["media"] = media
payload["msg_type"] = 7
if video_file_source:
media = await QQOfficialMessageEvent.upload_group_and_c2c_media(
send_helper, # type: ignore
video_file_source,
QQOfficialMessageEvent.VIDEO_FILE_TYPE,
group_openid=session.session_id,
)
if media:
payload["media"] = media
payload["msg_type"] = 7
payload.pop("msg_id", None)
if file_source:
media = await QQOfficialMessageEvent.upload_group_and_c2c_media(
send_helper, # type: ignore
file_source,
QQOfficialMessageEvent.FILE_FILE_TYPE,
file_name=file_name,
group_openid=session.session_id,
)
if media:
payload["media"] = media
payload["msg_type"] = 7
payload.pop("msg_id", None)
ret = await self.client.api.post_group_message(
group_openid=session.session_id,
**payload,
)
else:
if image_path:
payload["file_image"] = image_path
ret = await self.client.api.post_message(
channel_id=session.session_id,
**payload,
)
elif session.message_type == MessageType.FRIEND_MESSAGE:
payload["msg_seq"] = random.randint(1, 10000)
if image_base64:
media = await QQOfficialMessageEvent.upload_group_and_c2c_image(
send_helper, # type: ignore
image_base64,
QQOfficialMessageEvent.IMAGE_FILE_TYPE,
openid=session.session_id,
)
payload["media"] = media
payload["msg_type"] = 7
if record_file_path:
media = await QQOfficialMessageEvent.upload_group_and_c2c_media(
send_helper, # type: ignore
record_file_path,
QQOfficialMessageEvent.VOICE_FILE_TYPE,
openid=session.session_id,
)
if media:
payload["media"] = media
payload["msg_type"] = 7
if video_file_source:
media = await QQOfficialMessageEvent.upload_group_and_c2c_media(
send_helper, # type: ignore
video_file_source,
QQOfficialMessageEvent.VIDEO_FILE_TYPE,
openid=session.session_id,
)
if media:
payload["media"] = media
payload["msg_type"] = 7
# QQ API rejects msg_id for media (video/file) messages sent
# via the proactive tool-call path; remove it to avoid 越权 error.
payload.pop("msg_id", None)
if file_source:
media = await QQOfficialMessageEvent.upload_group_and_c2c_media(
send_helper, # type: ignore
file_source,
QQOfficialMessageEvent.FILE_FILE_TYPE,
file_name=file_name,
openid=session.session_id,
)
if media:
payload["media"] = media
payload["msg_type"] = 7
payload.pop("msg_id", None)
ret = await QQOfficialMessageEvent.post_c2c_message(
send_helper, # type: ignore
openid=session.session_id,
**payload,
)
else:
logger.warning(
"[QQOfficial] Unsupported message type for send_by_session: %s",
session.message_type,
)
return
sent_message_id = self._extract_message_id(ret)
if sent_message_id:
self.remember_session_message_id(session.session_id, sent_message_id)
await super().send_by_session(session, message_chain)
def remember_session_message_id(self, session_id: str, message_id: str) -> None:
if not session_id or not message_id:
return
self._session_last_message_id[session_id] = message_id
def remember_session_scene(self, session_id: str, scene: str) -> None:
if not session_id or not scene:
return
self._session_scene[session_id] = scene
def _extract_message_id(self, ret: Any) -> str | None:
if isinstance(ret, dict):
message_id = ret.get("id")
return str(message_id) if message_id else None
message_id = getattr(ret, "id", None)
if message_id:
return str(message_id)
return None
def meta(self) -> PlatformMetadata: def meta(self) -> PlatformMetadata:
return PlatformMetadata( return PlatformMetadata(
name="qq_official", name="qq_official",
description="QQ 机器人官方 API 适配器", description="QQ 机器人官方 API 适配器",
id=cast(str, self.config.get("id")), id=cast(str, self.config.get("id")),
support_proactive_message=True, support_proactive_message=False,
) )
@staticmethod @staticmethod
@@ -345,10 +158,7 @@ class QQOfficialPlatformAdapter(Platform):
return return
for attachment in attachments: for attachment in attachments:
content_type = cast( content_type = cast(str, getattr(attachment, "content_type", "") or "")
str,
getattr(attachment, "content_type", "") or "",
).lower()
url = QQOfficialPlatformAdapter._normalize_attachment_url( url = QQOfficialPlatformAdapter._normalize_attachment_url(
cast(str | None, getattr(attachment, "url", None)) cast(str | None, getattr(attachment, "url", None))
) )
@@ -364,73 +174,7 @@ class QQOfficialPlatformAdapter(Platform):
or getattr(attachment, "name", None) or getattr(attachment, "name", None)
or "attachment", or "attachment",
) )
ext = os.path.splitext(filename)[1].lower() msg.append(File(name=filename, file=url, url=url))
image_exts = {".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp"}
audio_exts = {
".mp3",
".wav",
".ogg",
".m4a",
".amr",
".silk",
}
video_exts = {
".mp4",
".mov",
".avi",
".mkv",
".webm",
}
if content_type.startswith("audio") or ext in audio_exts:
msg.append(Record.fromURL(url))
elif content_type.startswith("video") or ext in video_exts:
msg.append(Video.fromURL(url))
elif content_type.startswith("image") or ext in image_exts:
msg.append(Image.fromURL(url))
else:
msg.append(File(name=filename, file=url, url=url))
@staticmethod
def _parse_face_message(content: str) -> str:
"""Parse QQ official face message format and convert to readable text.
QQ official face message format:
<faceType=4,faceId="",ext="eyJ0ZXh0IjoiW+a7oeWktOmXruWPt10ifQ==">
The ext field contains base64-encoded JSON with a 'text' field
describing the emoji (e.g., '[满头问号]').
Args:
content: The message content that may contain face tags.
Returns:
Content with face tags replaced by readable emoji descriptions.
"""
import base64
import json
import re
def replace_face(match):
face_tag = match.group(0)
# Extract ext field from the face tag
ext_match = re.search(r'ext="([^"]*)"', face_tag)
if ext_match:
try:
ext_encoded = ext_match.group(1)
# Decode base64 and parse JSON
ext_decoded = base64.b64decode(ext_encoded).decode("utf-8")
ext_data = json.loads(ext_decoded)
emoji_text = ext_data.get("text", "")
if emoji_text:
return f"[表情:{emoji_text}]"
except Exception:
pass
# Fallback if parsing fails
return "[表情]"
# Match face tags: <faceType=...>
return re.sub(r"<faceType=\d+[^>]*>", replace_face, content)
@staticmethod @staticmethod
def _parse_from_qqofficial( def _parse_from_qqofficial(
@@ -457,10 +201,7 @@ class QQOfficialPlatformAdapter(Platform):
abm.group_id = message.group_openid abm.group_id = message.group_openid
else: else:
abm.sender = MessageMember(message.author.user_openid, "") abm.sender = MessageMember(message.author.user_openid, "")
# Parse face messages to readable text abm.message_str = message.content.strip()
abm.message_str = QQOfficialPlatformAdapter._parse_face_message(
message.content.strip()
)
abm.self_id = "unknown_selfid" abm.self_id = "unknown_selfid"
msg.append(At(qq="qq_official")) msg.append(At(qq="qq_official"))
msg.append(Plain(abm.message_str)) msg.append(Plain(abm.message_str))
@@ -476,12 +217,10 @@ class QQOfficialPlatformAdapter(Platform):
else: else:
abm.self_id = "" abm.self_id = ""
plain_content = QQOfficialPlatformAdapter._parse_face_message( plain_content = message.content.replace(
message.content.replace( "<@!" + str(abm.self_id) + ">",
"<@!" + str(abm.self_id) + ">", "",
"", ).strip()
).strip()
)
QQOfficialPlatformAdapter._append_attachments(msg, message.attachments) QQOfficialPlatformAdapter._append_attachments(msg, message.attachments)
abm.message = msg abm.message = msg
@@ -1,5 +1,7 @@
import asyncio import asyncio
import logging import logging
import random
from types import SimpleNamespace
from typing import Any, cast from typing import Any, cast
import botpy 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 astrbot.core.utils.webhook_utils import log_webhook_info
from ...register import register_platform_adapter from ...register import register_platform_adapter
from ..qqofficial.qqofficial_message_event import QQOfficialMessageEvent
from ..qqofficial.qqofficial_platform_adapter import QQOfficialPlatformAdapter from ..qqofficial.qqofficial_platform_adapter import QQOfficialPlatformAdapter
from .qo_webhook_event import QQOfficialWebhookMessageEvent from .qo_webhook_event import QQOfficialWebhookMessageEvent
from .qo_webhook_server import QQOfficialWebhook from .qo_webhook_server import QQOfficialWebhook
@@ -120,11 +123,95 @@ class QQOfficialWebhookPlatformAdapter(Platform):
session: MessageSesion, session: MessageSesion,
message_chain: MessageChain, message_chain: MessageChain,
) -> None: ) -> None:
await QQOfficialPlatformAdapter._send_by_session_common( (
cast(Any, self), plain_text,
session, image_base64,
message_chain, 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: def remember_session_message_id(self, session_id: str, message_id: str) -> None:
if not session_id or not message_id: if not session_id or not message_id:
@@ -1,6 +1,5 @@
import asyncio import asyncio
import logging import logging
import time
from typing import cast from typing import cast
import quart import quart
@@ -40,9 +39,6 @@ class QQOfficialWebhook:
self.client = botpy_client self.client = botpy_client
self.event_queue = event_queue self.event_queue = event_queue
self.shutdown_event = asyncio.Event() 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: async def initialize(self) -> None:
logger.info("正在登录到 QQ 官方机器人...") logger.info("正在登录到 QQ 官方机器人...")
@@ -110,22 +106,6 @@ class QQOfficialWebhook:
print(signed) print(signed)
return 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: if event and opcode == BotWebSocket.WS_DISPATCH_EVENT:
event = msg["t"].lower() event = msg["t"].lower()
try: try:
@@ -289,8 +289,8 @@ class TelegramPlatformAdapter(Platform):
else: else:
message.type = MessageType.GROUP_MESSAGE message.type = MessageType.GROUP_MESSAGE
message.group_id = str(update.message.chat.id) message.group_id = str(update.message.chat.id)
if update.message.is_topic_message and update.message.message_thread_id: if update.message.message_thread_id:
# Telegram Topic Group: include thread id to isolate per-topic sessions. # Topic Group
message.group_id += "#" + str(update.message.message_thread_id) message.group_id += "#" + str(update.message.message_thread_id)
message.session_id = message.group_id message.session_id = message.group_id
message.message_id = str(update.message.message_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 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): class TelegramPlatformEvent(AstrMessageEvent):
# Telegram 的最大消息长度限制 # Telegram 的最大消息长度限制
MAX_MESSAGE_LENGTH = 4096 MAX_MESSAGE_LENGTH = 4096
@@ -288,6 +278,7 @@ class TelegramPlatformEvent(AstrMessageEvent):
try: try:
md_text = telegramify_markdown.markdownify( md_text = telegramify_markdown.markdownify(
chunk, chunk,
normalize_whitespace=False,
) )
await client.send_message( await client.send_message(
text=md_text, text=md_text,
@@ -301,13 +292,7 @@ class TelegramPlatformEvent(AstrMessageEvent):
await client.send_message(text=chunk, **cast(Any, payload)) await client.send_message(text=chunk, **cast(Any, payload))
elif isinstance(i, Image): elif isinstance(i, Image):
image_path = await i.convert_to_file_path() image_path = await i.convert_to_file_path()
if _is_gif(image_path): await client.send_photo(photo=image_path, **cast(Any, payload))
send_coro = client.send_animation
media_kwarg = {"animation": image_path}
else:
send_coro = client.send_photo
media_kwarg = {"photo": image_path}
await send_coro(**media_kwarg, **cast(Any, payload))
elif isinstance(i, File): elif isinstance(i, File):
path = await i.get_file() path = await i.get_file()
name = i.name or os.path.basename(path) name = i.name or os.path.basename(path)
@@ -422,20 +407,12 @@ class TelegramPlatformEvent(AstrMessageEvent):
on_text(i.text) on_text(i.text)
elif isinstance(i, Image): elif isinstance(i, Image):
image_path = await i.convert_to_file_path() 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( await self._send_media_with_action(
self.client, self.client,
action, ChatAction.UPLOAD_PHOTO,
send_coro, self.client.send_photo,
user_name=user_name, user_name=user_name,
**media_kwarg, photo=image_path,
**cast(Any, payload), **cast(Any, payload),
) )
elif isinstance(i, File): elif isinstance(i, File):
@@ -479,6 +456,7 @@ class TelegramPlatformEvent(AstrMessageEvent):
try: try:
markdown_text = telegramify_markdown.markdownify( markdown_text = telegramify_markdown.markdownify(
delta, delta,
normalize_whitespace=False,
) )
await self.client.send_message( await self.client.send_message(
text=markdown_text, text=markdown_text,
@@ -559,6 +537,7 @@ class TelegramPlatformEvent(AstrMessageEvent):
try: try:
md = telegramify_markdown.markdownify( md = telegramify_markdown.markdownify(
draft_text, draft_text,
normalize_whitespace=False,
) )
await self._send_message_draft( await self._send_message_draft(
user_name, user_name,
@@ -716,6 +695,7 @@ class TelegramPlatformEvent(AstrMessageEvent):
try: try:
markdown_text = telegramify_markdown.markdownify( markdown_text = telegramify_markdown.markdownify(
delta, delta,
normalize_whitespace=False,
) )
await self.client.edit_message_text( await self.client.edit_message_text(
text=markdown_text, text=markdown_text,
@@ -440,16 +440,9 @@ class WecomAIBotAdapter(Platform):
) )
def _extract_session_id(self, message_data: dict[str, Any]) -> str: def _extract_session_id(self, message_data: dict[str, Any]) -> str:
"""从消息数据中提取会话ID """从消息数据中提取会话ID"""
群聊使用 chatid单聊使用 userid user_id = message_data.get("from", {}).get("userid", "default_user")
""" return format_session_id("wecomai", user_id)
chattype = message_data.get("chattype", "single")
if chattype == "group":
chat_id = message_data.get("chatid", "default_group")
return format_session_id("wecomai", chat_id)
else:
user_id = message_data.get("from", {}).get("userid", "default_user")
return format_session_id("wecomai", user_id)
async def _enqueue_message( async def _enqueue_message(
self, self,
-2
View File
@@ -808,8 +808,6 @@ class ProviderManager:
config.save_config() config.save_config()
# load instance # load instance
await self.load_provider(new_config) 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: async def terminate(self) -> None:
if self._mcp_init_task and not self._mcp_init_task.done(): if self._mcp_init_task and not self._mcp_init_task.done():
@@ -13,11 +13,3 @@ class ProviderGroq(ProviderOpenAIOfficial):
) -> None: ) -> None:
super().__init__(provider_config, provider_settings) super().__init__(provider_config, provider_settings)
self.reasoning_key = "reasoning" 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) state.handle_chunk(chunk)
except Exception as e: except Exception as e:
logger.warning("Saving chunk state error: " + str(e)) logger.warning("Saving chunk state error: " + str(e))
if not chunk.choices: if len(chunk.choices) == 0:
continue continue
delta = chunk.choices[0].delta delta = chunk.choices[0].delta
# logger.debug(f"chunk delta: {delta}") # logger.debug(f"chunk delta: {delta}")
@@ -322,7 +322,7 @@ class ProviderOpenAIOfficial(Provider):
if reasoning: if reasoning:
llm_response.reasoning_content = reasoning llm_response.reasoning_content = reasoning
_y = True _y = True
if delta and delta.content: if delta.content:
# Don't strip streaming chunks to preserve spaces between words # Don't strip streaming chunks to preserve spaces between words
completion_text = self._normalize_content(delta.content, strip=False) completion_text = self._normalize_content(delta.content, strip=False)
llm_response.result_chain = MessageChain( llm_response.result_chain = MessageChain(
@@ -345,7 +345,7 @@ class ProviderOpenAIOfficial(Provider):
) -> str: ) -> str:
"""Extract reasoning content from OpenAI ChatCompletion if available.""" """Extract reasoning content from OpenAI ChatCompletion if available."""
reasoning_text = "" reasoning_text = ""
if not completion.choices: if len(completion.choices) == 0:
return reasoning_text return reasoning_text
if isinstance(completion, ChatCompletion): if isinstance(completion, ChatCompletion):
choice = completion.choices[0] choice = completion.choices[0]
@@ -468,7 +468,7 @@ class ProviderOpenAIOfficial(Provider):
"""Parse OpenAI ChatCompletion into LLMResponse""" """Parse OpenAI ChatCompletion into LLMResponse"""
llm_response = LLMResponse("assistant") llm_response = LLMResponse("assistant")
if not completion.choices: if len(completion.choices) == 0:
raise Exception("API 返回的 completion 为空。") raise Exception("API 返回的 completion 为空。")
choice = completion.choices[0] choice = completion.choices[0]
@@ -629,8 +629,7 @@ class ProviderOpenAIOfficial(Provider):
# 最后一次不等待 # 最后一次不等待
if retry_cnt < max_retries - 1: if retry_cnt < max_retries - 1:
await asyncio.sleep(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: if len(available_api_keys) > 0:
chosen_key = random.choice(available_api_keys) chosen_key = random.choice(available_api_keys)
return ( return (
@@ -16,7 +16,4 @@ class ProviderOpenRouter(ProviderOpenAIOfficial):
self.client._custom_headers["HTTP-Referer"] = ( # type: ignore self.client._custom_headers["HTTP-Referer"] = ( # type: ignore
"https://github.com/AstrBotDevs/AstrBot" "https://github.com/AstrBotDevs/AstrBot"
) )
self.client._custom_headers["X-OpenRouter-Title"] = "AstrBot" # type: ignore self.client._custom_headers["X-TITLE"] = "AstrBot" # type: ignore
self.client._custom_headers["X-OpenRouter-Categories"] = (
"general-chat,personal-agent" # type: ignore
)
+7 -82
View File
@@ -3,7 +3,6 @@ from __future__ import annotations
import json import json
import os import os
import re import re
import shlex
import shutil import shutil
import tempfile import tempfile
import zipfile import zipfile
@@ -80,59 +79,7 @@ def _parse_frontmatter_description(text: str) -> str:
# Regex for sanitizing paths used in prompt examples — only allow # Regex for sanitizing paths used in prompt examples — only allow
# safe path characters to prevent prompt injection via crafted skill paths. # safe path characters to prevent prompt injection via crafted skill paths.
_SAFE_PATH_RE = re.compile(r"[^\w./ ,()'\-]", re.UNICODE) _SAFE_PATH_RE = re.compile(r"[^A-Za-z0-9_./ -]")
_WINDOWS_DRIVE_PATH_RE = re.compile(r"^[A-Za-z]:(?:/|\\)")
_WINDOWS_UNC_PATH_RE = re.compile(r"^(//|\\\\)[^/\\]+[/\\][^/\\]+")
_CONTROL_CHARS_RE = re.compile(r"[\x00-\x1F\x7F]")
def _is_windows_prompt_path(path: str) -> bool:
if os.name != "nt":
return False
return bool(_WINDOWS_DRIVE_PATH_RE.match(path) or _WINDOWS_UNC_PATH_RE.match(path))
def _sanitize_prompt_path_for_prompt(path: str) -> str:
if not path:
return ""
if _WINDOWS_DRIVE_PATH_RE.match(path) or _WINDOWS_UNC_PATH_RE.match(path):
path = path.replace("\\", "/")
drive_prefix = ""
if _WINDOWS_DRIVE_PATH_RE.match(path):
drive_prefix = path[:2]
path = path[2:]
path = path.replace("`", "")
path = _CONTROL_CHARS_RE.sub("", path)
sanitized = _SAFE_PATH_RE.sub("", path)
return f"{drive_prefix}{sanitized}"
def _sanitize_prompt_description(description: str) -> str:
description = description.replace("`", "")
description = _CONTROL_CHARS_RE.sub(" ", description)
description = " ".join(description.split())
return description
def _sanitize_skill_display_name(name: str) -> str:
if _SKILL_NAME_RE.fullmatch(name):
return name
return "<invalid_skill_name>"
def _build_skill_read_command_example(path: str) -> str:
if path == "<skills_root>/<skill_name>/SKILL.md":
return f"cat {path}"
if _is_windows_prompt_path(path):
command = "type"
path_arg = f'"{path}"'
else:
command = "cat"
path_arg = shlex.quote(path)
return f"{command} {path_arg}"
def build_skills_prompt(skills: list[SkillInfo]) -> str: def build_skills_prompt(skills: list[SkillInfo]) -> str:
@@ -145,37 +92,16 @@ def build_skills_prompt(skills: list[SkillInfo]) -> str:
skills_lines: list[str] = [] skills_lines: list[str] = []
example_path = "" example_path = ""
for skill in skills: for skill in skills:
display_name = _sanitize_skill_display_name(skill.name)
description = skill.description or "No description" 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( skills_lines.append(
f"- **{display_name}**: {description}\n File: `{rendered_path}`" f"- **{skill.name}**: {description}\n File: `{skill.path}`"
) )
if not example_path: if not example_path:
example_path = rendered_path example_path = skill.path
skills_block = "\n".join(skills_lines) skills_block = "\n".join(skills_lines)
# Sanitize example_path — it may originate from sandbox cache (untrusted) # Sanitize example_path — it may originate from sandbox cache (untrusted)
if example_path == "<skills_root>/<skill_name>/SKILL.md": example_path = _SAFE_PATH_RE.sub("", example_path) if example_path else ""
example_path = "<skills_root>/<skill_name>/SKILL.md" example_path = example_path or "<skills_root>/<skill_name>/SKILL.md"
else:
example_path = _sanitize_prompt_path_for_prompt(example_path)
example_path = example_path or "<skills_root>/<skill_name>/SKILL.md"
example_command = _build_skill_read_command_example(example_path)
return ( return (
"## Skills\n\n" "## 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 " "*Never silently skip a matching skill* — either use it or briefly "
"explain why you chose not to.\n" "explain why you chose not to.\n"
"3. **Mandatory grounding** — Before executing any skill you MUST " "3. **Mandatory grounding** — Before executing any skill you MUST "
"first read its `SKILL.md` by running a shell command compatible " "first read its `SKILL.md` by running a shell command with the "
"with the current runtime shell and using the **absolute path** " f"**absolute path** shown above (e.g. `cat {example_path}`). "
f"shown above (e.g. `{example_command}`). "
"Never rely on memory or assumptions about a skill's content.\n" "Never rely on memory or assumptions about a skill's content.\n"
"4. **Progressive disclosure** — Load only what is directly " "4. **Progressive disclosure** — Load only what is directly "
"referenced from `SKILL.md`:\n" "referenced from `SKILL.md`:\n"
+9 -59
View File
@@ -1,14 +1,12 @@
"""插件的重载、启停、安装、卸载等操作。""" """插件的重载、启停、安装、卸载等操作。"""
import asyncio import asyncio
import contextlib
import functools import functools
import inspect import inspect
import json import json
import logging import logging
import os import os
import sys import sys
import tempfile
import traceback import traceback
from types import ModuleType from types import ModuleType
@@ -31,12 +29,12 @@ from astrbot.core.utils.astrbot_path import (
get_astrbot_config_path, get_astrbot_config_path,
get_astrbot_path, get_astrbot_path,
get_astrbot_plugin_path, get_astrbot_plugin_path,
get_astrbot_temp_path,
) )
from astrbot.core.utils.io import remove_dir from astrbot.core.utils.io import remove_dir
from astrbot.core.utils.metrics import Metric from astrbot.core.utils.metrics import Metric
from astrbot.core.utils.requirements_utils import ( from astrbot.core.utils.requirements_utils import (
plan_missing_requirements_install, RequirementsPrecheckFailed,
find_missing_requirements_or_raise,
) )
from . import StarMetadata from . import StarMetadata
@@ -76,78 +74,30 @@ class PluginDependencyInstallError(Exception):
self.error = error 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( async def _install_requirements_with_precheck(
*, *,
plugin_label: str, plugin_label: str,
requirements_path: str, requirements_path: str,
) -> None: ) -> None:
install_plan = plan_missing_requirements_install(requirements_path) try:
missing = find_missing_requirements_or_raise(requirements_path)
if install_plan is None: except RequirementsPrecheckFailed:
logger.info( logger.info(
f"正在安装插件 {plugin_label} 的依赖库(缺失依赖预检查不可裁剪,回退到完整安装): " f"正在安装插件 {plugin_label} 的依赖库(预检查失败,回退到完整安装): "
f"{requirements_path}" f"{requirements_path}"
) )
await pip_installer.install(requirements_path=requirements_path) await pip_installer.install(requirements_path=requirements_path)
return return
if not install_plan.missing_names: if not missing:
logger.info(f"插件 {plugin_label} 的依赖已满足,跳过安装。") logger.info(f"插件 {plugin_label} 的依赖已满足,跳过安装。")
return 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( logger.info(
f"检测到插件 {plugin_label} 缺失依赖,正在按 requirements.txt 安装: " f"检测到插件 {plugin_label} 缺失依赖,正在按 requirements.txt 安装: "
f"{requirements_path} -> {sorted(install_plan.missing_names)}" f"{requirements_path} -> {sorted(missing)}"
) )
await pip_installer.install(requirements_path=requirements_path)
with _temporary_filtered_requirements_file(
install_lines=install_plan.install_lines,
) as filtered_requirements_path:
await pip_installer.install(requirements_path=filtered_requirements_path)
class PluginManager: class PluginManager:
+1 -1
View File
@@ -30,7 +30,7 @@ class CreateActiveCronTool(FunctionTool[AstrAgentContext]):
"properties": { "properties": {
"cron_expression": { "cron_expression": {
"type": "string", "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": { "run_at": {
"type": "string", "type": "string",
+6 -16
View File
@@ -25,22 +25,12 @@ class UmopConfigRouter:
) )
self.umop_to_conf_id = sp_data 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: def _is_umo_match(self, p1: str, p2: str) -> bool:
"""判断 p2 umo 是否逻辑包含于 p1 umo""" """判断 p2 umo 是否逻辑包含于 p1 umo"""
p1_ls = self._split_umo(p1) p1_ls = p1.split(":")
p2_ls = self._split_umo(p2) 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 False # 非法格式
return all(p == "" or fnmatch.fnmatchcase(t, p) for p, t in zip(p1_ls, p2_ls)) 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: for part in new_routing:
if self._split_umo(part) is None: if not isinstance(part, str) or len(part.split(":")) != 3:
raise ValueError( raise ValueError(
"umop keys must be strings in the format [platform_id]:[message_type]:[session_id], with optional wildcards * or empty for all", "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 格式不正确 ValueError: 如果 umo 格式不正确
""" """
if self._split_umo(umo) is None: if not isinstance(umo, str) or len(umo.split(":")) != 3:
raise ValueError( raise ValueError(
"umop must be a string in the format [platform_id]:[message_type]:[session_id], with optional wildcards * or empty for all", "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 格式不正确时抛出 ValueError: umo 格式不正确时抛出
""" """
if self._split_umo(umo) is None: if not isinstance(umo, str) or len(umo.split(":")) != 3:
raise ValueError( raise ValueError(
"umop must be a string in the format [platform_id]:[message_type]:[session_id], with optional wildcards * or empty for all", "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 re
import shlex import shlex
import sys import sys
from collections.abc import Iterable, Iterator, Sequence from collections.abc import Iterable, Iterator
from dataclasses import dataclass from dataclasses import dataclass
from packaging.requirements import InvalidRequirement, Requirement from packaging.requirements import InvalidRequirement, Requirement
@@ -29,13 +29,6 @@ class ParsedPackageInput:
requirement_names: frozenset[str] requirement_names: frozenset[str]
@dataclass(frozen=True)
class MissingRequirementsPlan:
missing_names: frozenset[str]
install_lines: tuple[str, ...]
fallback_reason: str | None = None
def canonicalize_distribution_name(name: str) -> str: def canonicalize_distribution_name(name: str) -> str:
return re.sub(r"[-_.]+", "-", name).strip("-").lower() return re.sub(r"[-_.]+", "-", name).strip("-").lower()
@@ -371,8 +364,8 @@ def _load_requirement_lines_for_precheck(
None, None,
) )
if fallback_line is not None: if fallback_line is not None:
logger.info( logger.warning(
"缺失依赖预检查发现无法安全裁剪的 option/direct-reference 行,将回退到完整安装: %s (%s)", "预检查缺失依赖失败,将回退到完整安装: unresolved direct reference in %s: %s",
requirements_path, requirements_path,
fallback_line, 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: if not can_precheck or requirement_lines is None:
return 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)) required = list(iter_requirements(lines=requirement_lines))
if not required: if not required:
return set() return set()
@@ -415,70 +401,6 @@ def find_missing_requirements_from_lines(
return missing 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]: def find_missing_requirements_or_raise(requirements_path: str) -> set[str]:
missing = find_missing_requirements(requirements_path) missing = find_missing_requirements(requirements_path)
if missing is None: if missing is None:
+1 -2
View File
@@ -82,8 +82,7 @@ class AuthRoute(Route):
def generate_jwt(self, username): def generate_jwt(self, username):
payload = { payload = {
"username": username, "username": username,
"exp": datetime.datetime.now(datetime.timezone.utc) "exp": datetime.datetime.utcnow() + datetime.timedelta(days=7),
+ datetime.timedelta(days=7),
} }
jwt_token = self.config["dashboard"].get("jwt_secret", None) jwt_token = self.config["dashboard"].get("jwt_secret", None)
if not jwt_token: if not jwt_token:
+1 -11
View File
@@ -977,17 +977,7 @@ class BackupRoute(Route):
if not jwt_secret: if not jwt_secret:
return Response().error("服务器配置错误").__dict__ return Response().error("服务器配置错误").__dict__
# Verify JWT token with strict security options jwt.decode(token, jwt_secret, algorithms=["HS256"])
jwt.decode(
token,
jwt_secret,
algorithms=["HS256"],
options={
"require": ["exp"], # Require expiration claim
"verify_signature": True, # Explicitly verify signature
"verify_exp": True, # Verify expiration
},
)
except jwt.ExpiredSignatureError: except jwt.ExpiredSignatureError:
return Response().error("Token 已过期,请刷新页面后重试").__dict__ return Response().error("Token 已过期,请刷新页面后重试").__dict__
except jwt.InvalidTokenError: 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) 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): class ChatRoute(Route):
def __init__( def __init__(
self, self,
@@ -65,7 +51,6 @@ class ChatRoute(Route):
"/chat/get_session": ("GET", self.get_session), "/chat/get_session": ("GET", self.get_session),
"/chat/stop": ("POST", self.stop_session), "/chat/stop": ("POST", self.stop_session),
"/chat/delete_session": ("GET", self.delete_webchat_session), "/chat/delete_session": ("GET", self.delete_webchat_session),
"/chat/batch_delete_sessions": ("POST", self.batch_delete_sessions),
"/chat/update_session_display_name": ( "/chat/update_session_display_name": (
"POST", "POST",
self.update_session_display_name, self.update_session_display_name,
@@ -357,12 +342,16 @@ class ChatRoute(Route):
async with track_conversation(self.running_convs, webchat_conv_id): async with track_conversation(self.running_convs, webchat_conv_id):
while True: while True:
result, should_break = await _poll_webchat_stream_result( try:
back_queue, username result = await asyncio.wait_for(back_queue.get(), timeout=1)
) except asyncio.TimeoutError:
if should_break: continue
except asyncio.CancelledError:
logger.debug(f"[WebChat] 用户 {username} 断开聊天长连接。")
client_disconnected = True client_disconnected = True
break except Exception as e:
logger.error(f"WebChat stream error: {e}")
if not result: if not result:
continue continue
@@ -589,9 +578,19 @@ class ChatRoute(Route):
return Response().ok(data={"stopped_count": stopped_count}).__dict__ return Response().ok(data={"stopped_count": stopped_count}).__dict__
async def _delete_session_internal(self, session, username: str) -> None: async def delete_webchat_session(self):
"""Delete a single session and all its related data.""" """Delete a Platform session and all its related data."""
session_id = session.session_id 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" message_type = "GroupMessage" if session.is_group else "FriendMessage"
@@ -633,70 +632,8 @@ class ChatRoute(Route):
# 删除会话 # 删除会话
await self.db.delete_platform_session(session_id) 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__ 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]: def _extract_attachment_ids(self, history_list) -> list[str]:
"""从消息历史中提取所有 attachment_id""" """从消息历史中提取所有 attachment_id"""
attachment_ids = [] attachment_ids = []
+57 -169
View File
@@ -130,51 +130,21 @@ class LiveChatRoute(Route):
async def live_chat_ws(self) -> None: async def live_chat_ws(self) -> None:
"""Legacy Live Chat WebSocket 处理器(默认 ct=live""" """Legacy Live Chat WebSocket 处理器(默认 ct=live"""
token = websocket.args.get("token") await self._unified_ws_loop(force_ct="live")
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")
async def unified_chat_ws(self) -> None: async def unified_chat_ws(self) -> None:
"""Unified Chat WebSocket 处理器(支持 ct=live/chat""" """Unified Chat WebSocket 处理器(支持 ct=live/chat"""
token = websocket.args.get("token") await self._unified_ws_loop(force_ct=None)
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)
async def _unified_ws_loop(self, force_ct: str | None = None) -> None: async def _unified_ws_loop(self, force_ct: str | None = None) -> None:
"""统一 WebSocket 循环""" """统一 WebSocket 循环"""
# Keep the legacy entry point for internal call sites. # WebSocket 不能通过 header 传递 token,需要从 query 参数获取
# 注意:WebSocket 上下文使用 websocket.args 而不是 request.args
token = websocket.args.get("token") token = websocket.args.get("token")
if not token: if not token:
await websocket.close(1008, "Missing authentication token") await websocket.close(1008, "Missing authentication token")
return return
try: try:
jwt_secret = self.config["dashboard"].get("jwt_secret") jwt_secret = self.config["dashboard"].get("jwt_secret")
payload = jwt.decode(token, jwt_secret, algorithms=["HS256"]) payload = jwt.decode(token, jwt_secret, algorithms=["HS256"])
@@ -185,10 +155,7 @@ class LiveChatRoute(Route):
except jwt.InvalidTokenError: except jwt.InvalidTokenError:
await websocket.close(1008, "Invalid token") await websocket.close(1008, "Invalid token")
return 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()}" session_id = f"webchat_live!{username}!{uuid.uuid4()}"
live_session = LiveChatSession(session_id, username) live_session = LiveChatSession(session_id, username)
self.sessions[session_id] = live_session self.sessions[session_id] = live_session
@@ -723,16 +690,6 @@ class LiveChatRoute(Route):
elif msg_type == "end_speaking": 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") stamp = message.get("stamp")
if not stamp: if not stamp:
logger.warning("[Live Chat] end_speaking 缺少 stamp") logger.warning("[Live Chat] end_speaking 缺少 stamp")
@@ -746,59 +703,45 @@ class LiveChatRoute(Route):
# 处理音频:STT -> LLM -> TTS # 处理音频:STT -> LLM -> TTS
await self._process_audio(session, audio_path, assemble_duration) 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": elif msg_type == "interrupt":
# 用户打断 # 用户打断
session.should_interrupt = True session.should_interrupt = True
logger.info(f"[Live Chat] 用户打断: {session.username}") logger.info(f"[Live Chat] 用户打断: {session.username}")
async def _process_live_user_text( async def _process_audio(
self, self, session: LiveChatSession, audio_path: str, assemble_duration: float
session: LiveChatSession,
user_text: str,
initial_metrics: dict[str, Any] | None = None,
processing_start_time: float | None = None,
) -> None: ) -> None:
"""处理 Live 用户文本:走 run_live_agent pipeline 并回传流式 TTS.""" """处理音频:STT -> LLM -> 流式 TTS"""
try: try:
if initial_metrics: # 发送 WAV 组装耗时
await websocket.send_json({"t": "metrics", "data": initial_metrics}) 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.is_processing = True
session.should_interrupt = False 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( await websocket.send_json(
{ {
"t": "user_msg", "t": "user_msg",
@@ -818,6 +761,7 @@ class LiveChatRoute(Route):
"action_type": "live", # 标记为 live mode "action_type": "live", # 标记为 live mode
} }
# 将消息放入队列
await queue.put((session.username, cid, payload)) await queue.put((session.username, cid, payload))
# 3. 等待响应并流式发送 TTS 音频 # 3. 等待响应并流式发送 TTS 音频
@@ -832,9 +776,11 @@ class LiveChatRoute(Route):
# 用户打断,停止处理 # 用户打断,停止处理
logger.info("[Live Chat] 检测到用户打断") logger.info("[Live Chat] 检测到用户打断")
await websocket.send_json({"t": "stop_play"}) await websocket.send_json({"t": "stop_play"})
# 保存消息并标记为被打断
await self._save_interrupted_message( await self._save_interrupted_message(
session, user_text, bot_text session, user_text, bot_text
) )
# 清空队列中未处理的消息
while not back_queue.empty(): while not back_queue.empty():
try: try:
back_queue.get_nowait() back_queue.get_nowait()
@@ -859,7 +805,6 @@ class LiveChatRoute(Route):
result_type = result.get("type") result_type = result.get("type")
result_chain_type = result.get("chain_type") result_chain_type = result.get("chain_type")
result_streaming = bool(result.get("streaming", False))
data = result.get("data", "") data = result.get("data", "")
if result_chain_type == "agent_stats": if result_chain_type == "agent_stats":
@@ -882,41 +827,29 @@ class LiveChatRoute(Route):
if result_chain_type == "tts_stats": if result_chain_type == "tts_stats":
try: try:
stats = json.loads(data) stats = json.loads(data)
await websocket.send_json({"t": "metrics", "data": stats}) await websocket.send_json(
{
"t": "metrics",
"data": stats,
}
)
except Exception as e: except Exception as e:
logger.error(f"[Live Chat] 解析 TTSStats 失败: {e}") logger.error(f"[Live Chat] 解析 TTSStats 失败: {e}")
continue 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_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 bot_text += data
elif result_type == "audio_chunk": elif result_type == "audio_chunk":
# 流式音频数据
if not audio_playing: if not audio_playing:
audio_playing = True audio_playing = True
logger.debug("[Live Chat] 开始播放音频流") logger.debug("[Live Chat] 开始播放音频流")
# Calculate latency from wav assembly finish to first audio chunk
speak_to_first_frame_latency = ( speak_to_first_frame_latency = (
time.time() - processing_start time.time() - wav_assembly_finish_time
) )
await websocket.send_json( await websocket.send_json(
{ {
@@ -936,15 +869,19 @@ class LiveChatRoute(Route):
} }
) )
# 发送音频数据给前端
await websocket.send_json( await websocket.send_json(
{ {
"t": "response", "t": "response",
"data": data, "data": data, # base64 编码的音频数据
} }
) )
elif result_type in ["complete", "end"]: elif result_type in ["complete", "end"]:
# 处理完成
logger.info(f"[Live Chat] Bot 回复完成: {bot_text}") logger.info(f"[Live Chat] Bot 回复完成: {bot_text}")
# 如果没有音频流,发送 bot 消息文本
if not audio_playing: if not audio_playing:
await websocket.send_json( await websocket.send_json(
{ {
@@ -956,8 +893,11 @@ class LiveChatRoute(Route):
} }
) )
# 发送结束标记
await websocket.send_json({"t": "end"}) 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( await websocket.send_json(
{ {
"t": "metrics", "t": "metrics",
@@ -969,65 +909,13 @@ class LiveChatRoute(Route):
webchat_queue_mgr.remove_back_queue(message_id) webchat_queue_mgr.remove_back_queue(message_id)
except Exception as e: 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)}"}) await websocket.send_json({"t": "error", "data": f"处理失败: {str(e)}"})
finally: finally:
session.is_processing = False session.is_processing = False
session.should_interrupt = 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( async def _save_interrupted_message(
self, session: LiveChatSession, user_text: str, bot_text: str self, session: LiveChatSession, user_text: str, bot_text: str
) -> None: ) -> 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 .api_key import ALL_OPEN_API_SCOPES
from .chat import ChatRoute from .chat import ChatRoute
from .live_chat import LiveChatRoute
from .route import Response, Route, RouteContext from .route import Response, Route, RouteContext
@@ -30,14 +29,12 @@ class OpenApiRoute(Route):
db: BaseDatabase, db: BaseDatabase,
core_lifecycle: AstrBotCoreLifecycle, core_lifecycle: AstrBotCoreLifecycle,
chat_route: ChatRoute, chat_route: ChatRoute,
live_chat_route: LiveChatRoute,
) -> None: ) -> None:
super().__init__(context) super().__init__(context)
self.db = db self.db = db
self.core_lifecycle = core_lifecycle self.core_lifecycle = core_lifecycle
self.platform_manager = core_lifecycle.platform_manager self.platform_manager = core_lifecycle.platform_manager
self.chat_route = chat_route self.chat_route = chat_route
self.live_chat_route = live_chat_route
self.routes = { self.routes = {
"/v1/chat": ("POST", self.chat_send), "/v1/chat": ("POST", self.chat_send),
@@ -49,7 +46,6 @@ class OpenApiRoute(Route):
} }
self.register_routes() self.register_routes()
self.app.websocket("/api/v1/chat/ws")(self.chat_ws) self.app.websocket("/api/v1/chat/ws")(self.chat_ws)
self.app.websocket("/api/v1/live/ws")(self.live_ws)
@staticmethod @staticmethod
def _resolve_open_username( def _resolve_open_username(
@@ -538,39 +534,6 @@ class OpenApiRoute(Route):
except Exception as e: except Exception as e:
logger.debug("Open API WS connection closed: %s", 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): async def upload_file(self):
return await self.chat_route.post_file() 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/create": ("POST", self.create_group),
"/session/group/update": ("POST", self.update_group), "/session/group/update": ("POST", self.update_group),
"/session/group/delete": ("POST", self.delete_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.conv_mgr = core_lifecycle.conversation_manager
self.core_lifecycle = core_lifecycle self.core_lifecycle = core_lifecycle
@@ -145,9 +146,20 @@ class SessionManagementRoute(Route):
page=page, page_size=page_size, search=search 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 = [] rules_list = []
filtered_count = 0
for umo, rules in umo_rules.items(): for umo, rules in umo_rules.items():
if umo in grouped_umos:
filtered_count += 1
continue
rule_info = { rule_info = {
"umo": umo, "umo": umo,
"rules": rules, "rules": rules,
@@ -159,6 +171,7 @@ class SessionManagementRoute(Route):
rule_info["message_type"] = parts[1] rule_info["message_type"] = parts[1]
rule_info["session_id"] = parts[2] rule_info["session_id"] = parts[2]
rules_list.append(rule_info) rules_list.append(rule_info)
total -= filtered_count
# 获取可用的 providers 和 personas # 获取可用的 providers 和 personas
provider_manager = self.core_lifecycle.provider_manager provider_manager = self.core_lifecycle.provider_manager
@@ -240,6 +253,7 @@ class SessionManagementRoute(Route):
"available_plugins": available_plugins, "available_plugins": available_plugins,
"available_kbs": available_kbs, "available_kbs": available_kbs,
"available_rule_keys": AVAILABLE_SESSION_RULE_KEYS, "available_rule_keys": AVAILABLE_SESSION_RULE_KEYS,
"group_rules": self._get_group_rules(),
} }
) )
.__dict__ .__dict__
@@ -793,6 +807,51 @@ class SessionManagementRoute(Route):
"""保存分组""" """保存分组"""
sp.put("session_groups", groups) 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): async def list_groups(self):
"""获取所有分组列表""" """获取所有分组列表"""
try: try:
@@ -806,6 +865,7 @@ class SessionManagementRoute(Route):
"name": group_data.get("name", ""), "name": group_data.get("name", ""),
"umos": group_data.get("umos", []), "umos": group_data.get("umos", []),
"umo_count": len(group_data.get("umos", [])), "umo_count": len(group_data.get("umos", [])),
"config": group_data.get("config", {}),
} }
) )
return Response().ok({"groups": groups_list}).__dict__ return Response().ok({"groups": groups_list}).__dict__
@@ -875,6 +935,7 @@ class SessionManagementRoute(Route):
return Response().error(f"分组 '{group_id}' 不存在").__dict__ return Response().error(f"分组 '{group_id}' 不存在").__dict__
group = groups[group_id] group = groups[group_id]
old_umos = set(group.get("umos", []))
# 更新名称 # 更新名称
if name is not None: if name is not None:
@@ -883,6 +944,7 @@ class SessionManagementRoute(Route):
# 直接设置 umos 列表 # 直接设置 umos 列表
if umos is not None: if umos is not None:
group["umos"] = umos group["umos"] = umos
new_umos = set(umos)
else: else:
# 增量更新 # 增量更新
current_umos = set(group.get("umos", [])) current_umos = set(group.get("umos", []))
@@ -891,9 +953,21 @@ class SessionManagementRoute(Route):
if remove_umos: if remove_umos:
current_umos.difference_update(remove_umos) current_umos.difference_update(remove_umos)
group["umos"] = list(current_umos) group["umos"] = list(current_umos)
new_umos = current_umos
self._save_groups(groups) 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 ( return (
Response() Response()
.ok( .ok(
@@ -936,3 +1010,81 @@ class SessionManagementRoute(Route):
except Exception as e: except Exception as e:
logger.error(f"删除分组失败: {e!s}") logger.error(f"删除分组失败: {e!s}")
return Response().error(f"删除分组失败: {e!s}").__dict__ 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.ar = AuthRoute(self.context)
self.api_key_route = ApiKeyRoute(self.context, db) self.api_key_route = ApiKeyRoute(self.context, db)
self.chat_route = ChatRoute(self.context, db, core_lifecycle) 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.open_api_route = OpenApiRoute(
self.context, self.context,
db, db,
core_lifecycle, core_lifecycle,
self.chat_route, self.chat_route,
self.live_chat_route,
) )
self.chatui_project_route = ChatUIProjectRoute(self.context, db) self.chatui_project_route = ChatUIProjectRoute(self.context, db)
self.tools_root = ToolsRoute(self.context, core_lifecycle) self.tools_root = ToolsRoute(self.context, core_lifecycle)
@@ -140,6 +138,7 @@ class AstrBotDashboard:
self.kb_route = KnowledgeBaseRoute(self.context, core_lifecycle) self.kb_route = KnowledgeBaseRoute(self.context, core_lifecycle)
self.platform_route = PlatformRoute(self.context, core_lifecycle) self.platform_route = PlatformRoute(self.context, core_lifecycle)
self.backup_route = BackupRoute(self.context, db, 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( self.app.add_url_rule(
"/api/plug/<path:subpath>", "/api/plug/<path:subpath>",
@@ -245,7 +244,6 @@ class AstrBotDashboard:
scope_map = { scope_map = {
"/api/v1/chat": "chat", "/api/v1/chat": "chat",
"/api/v1/chat/ws": "chat", "/api/v1/chat/ws": "chat",
"/api/v1/live/ws": "chat",
"/api/v1/chat/sessions": "chat", "/api/v1/chat/sessions": "chat",
"/api/v1/configs": "config", "/api/v1/configs": "config",
"/api/v1/file": "file", "/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 - DEFAULT_SHIP_MEMORY=512m
volumes: volumes:
- ${PWD}/data/shipyard/bay_data:/app/data - ${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 - /var/run/docker.sock:/var/run/docker.sock:ro
networks: networks:
- astrbot_network - astrbot_network
+8 -13
View File
@@ -17,17 +17,17 @@
"@tiptap/starter-kit": "2.1.7", "@tiptap/starter-kit": "2.1.7",
"@tiptap/vue-3": "2.1.7", "@tiptap/vue-3": "2.1.7",
"apexcharts": "3.42.0", "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", "axios-mock-adapter": "^1.22.0",
"chance": "1.1.11", "chance": "1.1.11",
"date-fns": "2.30.0", "date-fns": "2.30.0",
"dompurify": "^3.3.2", "dompurify": "^3.3.1",
"event-source-polyfill": "^1.0.31", "event-source-polyfill": "^1.0.31",
"highlight.js": "^11.11.1", "highlight.js": "^11.11.1",
"js-md5": "^0.8.3", "js-md5": "^0.8.3",
"katex": "^0.16.27", "katex": "^0.16.27",
"lodash": "4.17.23", "lodash": "4.17.21",
"markdown-it": "^14.1.1", "markdown-it": "^14.1.0",
"markstream-vue": "^0.0.6", "markstream-vue": "^0.0.6",
"mermaid": "^11.12.2", "mermaid": "^11.12.2",
"monaco-editor": "^0.52.2", "monaco-editor": "^0.52.2",
@@ -36,8 +36,9 @@
"remixicon": "3.5.0", "remixicon": "3.5.0",
"shiki": "^3.20.0", "shiki": "^3.20.0",
"stream-markdown": "^0.0.13", "stream-markdown": "^0.0.13",
"stream-monaco": "^0.0.17",
"vee-validate": "4.11.3", "vee-validate": "4.11.3",
"vite-plugin-vuetify": "2.1.3", "vite-plugin-vuetify": "1.0.2",
"vue": "3.3.4", "vue": "3.3.4",
"vue-i18n": "^11.1.5", "vue-i18n": "^11.1.5",
"vue-router": "4.2.4", "vue-router": "4.2.4",
@@ -53,7 +54,7 @@
"@types/dompurify": "^3.0.5", "@types/dompurify": "^3.0.5",
"@types/markdown-it": "^14.1.2", "@types/markdown-it": "^14.1.2",
"@types/node": "^20.5.7", "@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-prettier": "8.0.0",
"@vue/eslint-config-typescript": "11.0.3", "@vue/eslint-config-typescript": "11.0.3",
"@vue/tsconfig": "^0.4.0", "@vue/tsconfig": "^0.4.0",
@@ -63,15 +64,9 @@
"sass": "1.66.1", "sass": "1.66.1",
"sass-loader": "13.3.2", "sass-loader": "13.3.2",
"typescript": "5.1.6", "typescript": "5.1.6",
"vite": "6.4.1", "vite": "4.4.9",
"vue-cli-plugin-vuetify": "2.5.8", "vue-cli-plugin-vuetify": "2.5.8",
"vue-tsc": "1.8.8", "vue-tsc": "1.8.8",
"vuetify-loader": "^2.0.0-alpha.9" "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" :currSessionId="currSessionId"
:selectedProjectId="selectedProjectId" :selectedProjectId="selectedProjectId"
:transportMode="transportMode" :transportMode="transportMode"
:sendShortcut="sendShortcut"
:isDark="isDark" :isDark="isDark"
:chatboxMode="chatboxMode" :chatboxMode="chatboxMode"
:isMobile="isMobile" :isMobile="isMobile"
@@ -21,7 +20,6 @@
@selectConversation="handleSelectConversation" @selectConversation="handleSelectConversation"
@editTitle="showEditTitleDialog" @editTitle="showEditTitleDialog"
@deleteConversation="handleDeleteConversation" @deleteConversation="handleDeleteConversation"
@batchDeleteConversations="handleBatchDeleteConversations"
@closeMobileSidebar="closeMobileSidebar" @closeMobileSidebar="closeMobileSidebar"
@toggleTheme="toggleTheme" @toggleTheme="toggleTheme"
@toggleFullscreen="toggleFullscreen" @toggleFullscreen="toggleFullscreen"
@@ -30,7 +28,6 @@
@editProject="showEditProjectDialog" @editProject="showEditProjectDialog"
@deleteProject="handleDeleteProject" @deleteProject="handleDeleteProject"
@updateTransportMode="setTransportMode" @updateTransportMode="setTransportMode"
@updateSendShortcut="setSendShortcut"
/> />
<!-- 右侧聊天内容区域 --> <!-- 右侧聊天内容区域 -->
@@ -74,14 +71,13 @@
:stagedImagesUrl="stagedImagesUrl" :stagedImagesUrl="stagedImagesUrl"
:stagedAudioUrl="stagedAudioUrl" :stagedAudioUrl="stagedAudioUrl"
:stagedFiles="stagedNonImageFiles" :stagedFiles="stagedNonImageFiles"
:disabled="false" :disabled="isStreaming"
:is-running="isStreaming || isConvRunning" :is-running="isStreaming || isConvRunning"
:enableStreaming="enableStreaming" :enableStreaming="enableStreaming"
:isRecording="isRecording" :isRecording="isRecording"
:session-id="currSessionId || null" :session-id="currSessionId || null"
:current-session="getCurrentSession" :current-session="getCurrentSession"
:replyTo="replyTo" :replyTo="replyTo"
:send-shortcut="sendShortcut"
@send="handleSendMessage" @send="handleSendMessage"
@stop="handleStopMessage" @stop="handleStopMessage"
@toggleStreaming="toggleStreaming" @toggleStreaming="toggleStreaming"
@@ -106,14 +102,13 @@
:stagedImagesUrl="stagedImagesUrl" :stagedImagesUrl="stagedImagesUrl"
:stagedAudioUrl="stagedAudioUrl" :stagedAudioUrl="stagedAudioUrl"
:stagedFiles="stagedNonImageFiles" :stagedFiles="stagedNonImageFiles"
:disabled="false" :disabled="isStreaming"
:is-running="isStreaming || isConvRunning" :is-running="isStreaming || isConvRunning"
:enableStreaming="enableStreaming" :enableStreaming="enableStreaming"
:isRecording="isRecording" :isRecording="isRecording"
:session-id="currSessionId || null" :session-id="currSessionId || null"
:current-session="getCurrentSession" :current-session="getCurrentSession"
:replyTo="replyTo" :replyTo="replyTo"
:send-shortcut="sendShortcut"
@send="handleSendMessage" @send="handleSendMessage"
@stop="handleStopMessage" @stop="handleStopMessage"
@toggleStreaming="toggleStreaming" @toggleStreaming="toggleStreaming"
@@ -137,14 +132,13 @@
:stagedImagesUrl="stagedImagesUrl" :stagedImagesUrl="stagedImagesUrl"
:stagedAudioUrl="stagedAudioUrl" :stagedAudioUrl="stagedAudioUrl"
:stagedFiles="stagedNonImageFiles" :stagedFiles="stagedNonImageFiles"
:disabled="false" :disabled="isStreaming"
:is-running="isStreaming || isConvRunning" :is-running="isStreaming || isConvRunning"
:enableStreaming="enableStreaming" :enableStreaming="enableStreaming"
:isRecording="isRecording" :isRecording="isRecording"
:session-id="currSessionId || null" :session-id="currSessionId || null"
:current-session="getCurrentSession" :current-session="getCurrentSession"
:replyTo="replyTo" :replyTo="replyTo"
:send-shortcut="sendShortcut"
@send="handleSendMessage" @send="handleSendMessage"
@stop="handleStopMessage" @stop="handleStopMessage"
@toggleStreaming="toggleStreaming" @toggleStreaming="toggleStreaming"
@@ -226,13 +220,10 @@ import { useMediaHandling } from '@/composables/useMediaHandling';
import { useProjects } from '@/composables/useProjects'; import { useProjects } from '@/composables/useProjects';
import type { Project } from '@/components/chat/ProjectList.vue'; import type { Project } from '@/components/chat/ProjectList.vue';
import { useRecording } from '@/composables/useRecording'; import { useRecording } from '@/composables/useRecording';
import { useToast } from '@/utils/toast';
interface Props { interface Props {
chatboxMode?: boolean; chatboxMode?: boolean;
} }
type SendShortcut = 'enter' | 'shift_enter';
const SEND_SHORTCUT_STORAGE_KEY = 'chat_send_shortcut';
const props = withDefaults(defineProps<Props>(), { const props = withDefaults(defineProps<Props>(), {
chatboxMode: false chatboxMode: false
@@ -242,7 +233,6 @@ const router = useRouter();
const route = useRoute(); const route = useRoute();
const { t } = useI18n(); const { t } = useI18n();
const { tm } = useModuleI18n('features/chat'); const { tm } = useModuleI18n('features/chat');
const { warning: toastWarning } = useToast();
const theme = useTheme(); const theme = useTheme();
const customizer = useCustomizerStore(); const customizer = useCustomizerStore();
@@ -267,7 +257,6 @@ const {
getSessions, getSessions,
newSession, newSession,
deleteSession: deleteSessionFn, deleteSession: deleteSessionFn,
batchDeleteSessions,
showEditTitleDialog, showEditTitleDialog,
saveTitle, saveTitle,
updateSessionTitle, updateSessionTitle,
@@ -341,18 +330,6 @@ interface ReplyInfo {
const replyTo = ref<ReplyInfo | null>(null); const replyTo = ref<ReplyInfo | null>(null);
const isDark = computed(() => useCustomizerStore().uiTheme === 'PurpleThemeDark'); 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() { function checkMobile() {
@@ -511,7 +488,6 @@ async function handleSelectConversation(sessionIds: string[]) {
nextTick(() => { nextTick(() => {
messageList.value?.scrollToBottom(); messageList.value?.scrollToBottom();
}); });
focusChatInput();
} }
function handleNewChat() { function handleNewChat() {
@@ -521,7 +497,6 @@ function handleNewChat() {
// 退 // 退
selectedProjectId.value = null; selectedProjectId.value = null;
projectSessions.value = []; projectSessions.value = [];
focusChatInput();
} }
async function handleDeleteConversation(sessionId: string) { 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) { async function handleSelectProject(projectId: string) {
selectedProjectId.value = projectId; selectedProjectId.value = projectId;
const sessions = await getProjectSessions(projectId); const sessions = await getProjectSessions(projectId);
@@ -679,11 +627,6 @@ async function handleSendMessage() {
const selectedProviderId = selection?.providerId || ''; const selectedProviderId = selection?.providerId || '';
const selectedModelName = selection?.modelName || ''; const selectedModelName = selection?.modelName || '';
//
nextTick(() => {
messageList.value?.scrollToBottom();
});
await sendMsg( await sendMsg(
promptToSend, promptToSend,
filesToSend, filesToSend,
@@ -693,11 +636,6 @@ async function handleSendMessage() {
replyToSend replyToSend
); );
//
nextTick(() => {
messageList.value?.scrollToBottom();
});
// //
if (isCreatingNewSession && currentProjectId && currSessionId.value) { if (isCreatingNewSession && currentProjectId && currSessionId.value) {
await addSessionToProject(currSessionId.value, currentProjectId); await addSessionToProject(currSessionId.value, currentProjectId);
@@ -756,10 +694,6 @@ watch(sessions, (newSessions) => {
}); });
onMounted(() => { onMounted(() => {
const storedShortcut = localStorage.getItem(SEND_SHORTCUT_STORAGE_KEY);
if (storedShortcut === 'enter' || storedShortcut === 'shift_enter') {
sendShortcut.value = storedShortcut;
}
checkMobile(); checkMobile();
window.addEventListener('resize', checkMobile); window.addEventListener('resize', checkMobile);
getSessions(); getSessions();
+29 -44
View File
@@ -15,7 +15,7 @@
<transition name="fade"> <transition name="fade">
<div v-if="isDragging" class="drop-overlay"> <div v-if="isDragging" class="drop-overlay">
<div class="drop-overlay-content"> <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> <span class="drop-text">{{ tm('input.dropToUpload') }}</span>
</div> </div>
</div> </div>
@@ -41,7 +41,7 @@
<!-- Settings Menu --> <!-- Settings Menu -->
<StyledMenu offset="8" location="top start" :close-on-content-click="false"> <StyledMenu offset="8" location="top start" :close-on-content-click="false">
<template v-slot:activator="{ props: activatorProps }"> <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> </template>
<!-- Upload Files --> <!-- Upload Files -->
@@ -87,7 +87,7 @@
{{ tm('voice.liveMode') }} {{ tm('voice.liveMode') }}
</v-tooltip> </v-tooltip>
</v-btn> --> </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"> class="record-btn">
<v-icon :icon="isRecording ? 'mdi-stop-circle' : 'mdi-microphone'" variant="text" <v-icon :icon="isRecording ? 'mdi-stop-circle' : 'mdi-microphone'" variant="text"
plain></v-icon> plain></v-icon>
@@ -95,13 +95,13 @@
{{ isRecording ? tm('voice.speaking') : tm('voice.startRecording') }} {{ isRecording ? tm('voice.speaking') : tm('voice.startRecording') }}
</v-tooltip> </v-tooltip>
</v-btn> </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-icon icon="mdi-stop" variant="text" plain></v-icon>
<v-tooltip activator="parent" location="top"> <v-tooltip activator="parent" location="top">
{{ tm('input.stopGenerating') }} {{ tm('input.stopGenerating') }}
</v-tooltip> </v-tooltip>
</v-btn> </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" /> :disabled="!canSend" class="send-btn" />
</div> </div>
</div> </div>
@@ -117,7 +117,7 @@
</div> </div>
<div v-if="stagedAudioUrl" class="audio-preview"> <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> <v-icon start icon="mdi-microphone" size="small"></v-icon>
{{ tm('voice.recording') }} {{ tm('voice.recording') }}
</v-chip> </v-chip>
@@ -126,7 +126,7 @@
</div> </div>
<div v-for="(file, index) in stagedFiles" :key="'file-' + index" class="file-preview"> <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> <v-icon start icon="mdi-file-document-outline" size="small"></v-icon>
<span class="file-name-preview">{{ file.original_name }}</span> <span class="file-name-preview">{{ file.original_name }}</span>
</v-chip> </v-chip>
@@ -173,7 +173,6 @@ interface Props {
currentSession?: Session | null; currentSession?: Session | null;
configId?: string | null; configId?: string | null;
replyTo?: ReplyInfo | null; replyTo?: ReplyInfo | null;
sendShortcut?: 'enter' | 'shift_enter';
} }
const props = withDefaults(defineProps<Props>(), { const props = withDefaults(defineProps<Props>(), {
@@ -181,8 +180,7 @@ const props = withDefaults(defineProps<Props>(), {
currentSession: null, currentSession: null,
configId: null, configId: null,
stagedFiles: () => [], stagedFiles: () => [],
replyTo: null, replyTo: null
sendShortcut: 'shift_enter'
}); });
const emit = defineEmits<{ const emit = defineEmits<{
@@ -255,29 +253,9 @@ watch(localPrompt, () => {
}); });
function handleKeyDown(e: KeyboardEvent) { function handleKeyDown(e: KeyboardEvent) {
const isEnter = e.key === 'Enter'; // Enter
if (!isEnter) { // Shift+Enter Ctrl+Enter / Cmd+Enter
// Ctrl+B if (e.keyCode === 13 && (e.shiftKey || e.ctrlKey || e.metaKey)) {
if (e.ctrlKey && e.keyCode === 66) {
e.preventDefault();
if (ctrlKeyDown.value) return;
ctrlKeyDown.value = true;
ctrlKeyTimer.value = window.setTimeout(() => {
if (ctrlKeyDown.value && !props.isRecording) {
emit('startRecording');
}
}, ctrlKeyLongPressThreshold);
}
return;
}
const isSendHotkey =
e.ctrlKey ||
e.metaKey ||
(props.sendShortcut === 'enter' ? !e.shiftKey : e.shiftKey);
if (isSendHotkey) {
e.preventDefault(); e.preventDefault();
if (localPrompt.value.trim() === '/astr_live_dev') { if (localPrompt.value.trim() === '/astr_live_dev') {
emit('openLiveMode'); emit('openLiveMode');
@@ -289,6 +267,19 @@ function handleKeyDown(e: KeyboardEvent) {
} }
return; 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) { function handleKeyUp(e: KeyboardEvent) {
@@ -373,11 +364,6 @@ function getCurrentSelection() {
return providerModelMenuRef.value?.getCurrentSelection(); return providerModelMenuRef.value?.getCurrentSelection();
} }
function focusInput() {
if (!inputField.value) return;
inputField.value.focus();
}
onMounted(() => { onMounted(() => {
if (inputField.value) { if (inputField.value) {
inputField.value.addEventListener('paste', handlePaste); inputField.value.addEventListener('paste', handlePaste);
@@ -393,8 +379,7 @@ onBeforeUnmount(() => {
}); });
defineExpose({ defineExpose({
getCurrentSelection, getCurrentSelection
focusInput
}); });
</script> </script>
@@ -414,8 +399,8 @@ defineExpose({
left: 0; left: 0;
right: 0; right: 0;
bottom: 0; bottom: 0;
background-color: rgba(var(--v-theme-primary), 0.12); background-color: rgba(103, 58, 183, 0.15);
border: 2px dashed rgba(var(--v-theme-primary), 0.45); border: 2px dashed rgba(103, 58, 183, 0.5);
border-radius: 24px; border-radius: 24px;
display: flex; display: flex;
align-items: center; align-items: center;
@@ -434,7 +419,7 @@ defineExpose({
.drop-text { .drop-text {
font-size: 16px; font-size: 16px;
font-weight: 500; font-weight: 500;
color: rgb(var(--v-theme-primary)); color: #673ab7;
} }
/* Fade transition for drop overlay */ /* Fade transition for drop overlay */
@@ -454,7 +439,7 @@ defineExpose({
justify-content: space-between; justify-content: space-between;
padding: 8px 16px; padding: 8px 16px;
margin: 8px 8px 0 8px; 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; border-radius: 12px;
gap: 8px; gap: 8px;
max-height: 500px; max-height: 500px;
@@ -5,7 +5,7 @@
'mobile-sidebar-open': isMobile && mobileMenuOpen, 'mobile-sidebar-open': isMobile && mobileMenuOpen,
'mobile-sidebar': isMobile '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"> <div class="sidebar-collapse-btn-container" v-if="!isMobile">
<v-btn icon class="sidebar-collapse-btn" @click="toggleSidebar" variant="text" color="deep-purple"> <v-btn icon class="sidebar-collapse-btn" @click="toggleSidebar" variant="text" color="deep-purple">
@@ -21,31 +21,12 @@
</div> </div>
<div style="padding: 8px; opacity: 0.6;"> <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"
<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>
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-btn v-if="sessions.length > 0" icon size="small" variant="text" @click="toggleBatchMode"
:color="batchMode ? 'primary' : undefined">
<v-icon>mdi-checkbox-multiple-marked-outline</v-icon>
</v-btn>
</div>
<v-btn icon="mdi-square-edit-outline" rounded="xl" @click="$emit('newChat')" :disabled="!currSessionId && !selectedProjectId"
v-if="sidebarCollapsed && !isMobile" elevation="0"></v-btn> v-if="sidebarCollapsed && !isMobile" elevation="0"></v-btn>
</div> </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 <ProjectList
v-if="!sidebarCollapsed || isMobile" v-if="!sidebarCollapsed || isMobile"
@@ -60,34 +41,19 @@
v-if="!sidebarCollapsed || isMobile"> v-if="!sidebarCollapsed || isMobile">
<v-card v-if="sessions.length > 0" flat style="background-color: transparent;"> <v-card v-if="sessions.length > 0" flat style="background-color: transparent;">
<v-list density="compact" nav class="conversation-list" <v-list density="compact" nav class="conversation-list"
style="background-color: transparent;" :selected="batchMode ? [] : selectedSessions" style="background-color: transparent;" :selected="selectedSessions"
@update:selected="handleListSelect"> @update:selected="$emit('selectConversation', $event)">
<v-list-item v-for="item in sessions" :key="item.session_id" :value="item.session_id" <v-list-item v-for="item in sessions" :key="item.session_id" :value="item.session_id"
rounded="lg" class="conversation-item" active-color="secondary" rounded="lg" class="conversation-item" active-color="secondary">
@click="batchMode ? toggleBatchItem(item.session_id) : undefined">
<template v-slot:prepend>
<div class="batch-checkbox-slot" :class="{ 'batch-checkbox-slot--active': batchMode }">
<v-checkbox-btn
:model-value="batchSelected.includes(item.session_id)"
@update:model-value="toggleBatchItem(item.session_id)"
@click.stop
density="compact"
hide-details
class="batch-checkbox"
/>
</div>
</template>
<v-list-item-title v-if="!sidebarCollapsed || isMobile" class="conversation-title" <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') }} {{ item.display_name || tm('conversation.newConversation') }}
</v-list-item-title> </v-list-item-title>
<!-- <v-list-item-subtitle v-if="!sidebarCollapsed || isMobile" class="timestamp"> <!-- <v-list-item-subtitle v-if="!sidebarCollapsed || isMobile" class="timestamp">
{{ new Date(item.updated_at).toLocaleString() }} {{ new Date(item.updated_at).toLocaleString() }}
</v-list-item-subtitle> --> </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"> <div class="conversation-actions">
<v-btn icon="mdi-pencil" size="x-small" variant="text" <v-btn icon="mdi-pencil" size="x-small" variant="text"
class="edit-title-btn" class="edit-title-btn"
@@ -132,52 +98,16 @@
</v-btn> </v-btn>
</template> </template>
<!-- 语言切换分组 --> <!-- 语言切换 -->
<v-menu <v-list-item class="styled-menu-item">
:open-on-hover="!isMobile" <template v-slot:prepend>
:open-on-click="isMobile" <v-icon>mdi-translate</v-icon>
:open-delay="!isMobile ? 60 : 0"
:close-delay="!isMobile ? 120 : 0"
:location="isMobile ? 'bottom' : 'end center'"
offset="8"
close-on-content-click
>
<template v-slot:activator="{ props: languageMenuProps }">
<v-list-item
v-bind="languageMenuProps"
class="styled-menu-item chat-settings-group-trigger"
rounded="md"
>
<template v-slot:prepend>
<v-icon>mdi-translate</v-icon>
</template>
<v-list-item-title>{{ t('core.common.language') }}</v-list-item-title>
<template v-slot:append>
<span class="chat-settings-group-current">{{ currentLanguage?.flag }}</span>
<v-icon size="18" class="chat-settings-group-arrow">mdi-chevron-right</v-icon>
</template>
</v-list-item>
</template> </template>
<v-list-item-title>{{ t('core.common.language') }}</v-list-item-title>
<v-card class="styled-menu-card" style="min-width: 180px;" elevation="8" rounded="lg"> <template v-slot:append>
<v-list density="compact" class="styled-menu-list pa-1"> <LanguageSwitcher variant="chatbox" />
<v-list-item </template>
v-for="lang in languages" </v-list-item>
:key="lang.code"
:value="lang.code"
@click="changeLanguage(lang.code)"
:class="{ 'styled-menu-item-active': currentLocale === lang.code }"
class="styled-menu-item"
rounded="md"
>
<template v-slot:prepend>
<span class="language-flag">{{ lang.flag }}</span>
</template>
<v-list-item-title>{{ lang.name }}</v-list-item-title>
</v-list-item>
</v-list>
</v-card>
</v-menu>
<!-- 主题切换 --> <!-- 主题切换 -->
<v-list-item class="styled-menu-item" @click="$emit('toggleTheme')"> <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-title>{{ isDark ? tm('modes.lightMode') : tm('modes.darkMode') }}</v-list-item-title>
</v-list-item> </v-list-item>
<!-- 通信传输模式分组 --> <!-- 通信传输模式 -->
<v-menu <v-list-item class="styled-menu-item">
:open-on-hover="!isMobile" <template v-slot:prepend>
:open-on-click="isMobile" <v-icon>mdi-lan-connect</v-icon>
:open-delay="!isMobile ? 60 : 0"
:close-delay="!isMobile ? 120 : 0"
:location="isMobile ? 'bottom' : 'end center'"
offset="8"
close-on-content-click
>
<template v-slot:activator="{ props: transportMenuProps }">
<v-list-item
v-bind="transportMenuProps"
class="styled-menu-item chat-settings-group-trigger"
rounded="md"
>
<template v-slot:prepend>
<v-icon>mdi-lan-connect</v-icon>
</template>
<v-list-item-title>{{ tm('transport.title') }}</v-list-item-title>
<template v-slot:append>
<span class="chat-settings-group-current chat-settings-transport-current">{{ currentTransportLabel }}</span>
<v-icon size="18" class="chat-settings-group-arrow">mdi-chevron-right</v-icon>
</template>
</v-list-item>
</template> </template>
<v-list-item-title>{{ tm('transport.title') }}</v-list-item-title>
<v-card class="styled-menu-card" style="min-width: 220px;" elevation="8" rounded="lg"> <template v-slot:append>
<v-list density="compact" class="styled-menu-list pa-1"> <v-select
<v-list-item :model-value="transportMode"
v-for="opt in transportOptions" :items="transportOptions"
:key="opt.value" item-title="label"
:value="opt.value" item-value="value"
@click="handleTransportModeChange(opt.value)" density="compact"
:class="{ 'styled-menu-item-active': transportMode === opt.value }" variant="underlined"
class="styled-menu-item" hide-details
rounded="md" class="transport-mode-select"
> @update:model-value="handleTransportModeChange"
<v-list-item-title>{{ opt.label }}</v-list-item-title> />
</v-list-item>
</v-list>
</v-card>
</v-menu>
<!-- 发送快捷键分组 -->
<v-menu
:open-on-hover="!isMobile"
:open-on-click="isMobile"
:open-delay="!isMobile ? 60 : 0"
:close-delay="!isMobile ? 120 : 0"
:location="isMobile ? 'bottom' : 'end center'"
offset="8"
close-on-content-click
>
<template v-slot:activator="{ props: sendShortcutMenuProps }">
<v-list-item
v-bind="sendShortcutMenuProps"
class="styled-menu-item chat-settings-group-trigger"
rounded="md"
>
<template v-slot:prepend>
<v-icon>mdi-keyboard-outline</v-icon>
</template>
<v-list-item-title>{{ tm('shortcuts.sendKey.title') }}</v-list-item-title>
<template v-slot:append>
<span class="chat-settings-group-current chat-settings-transport-current">{{ currentSendShortcutLabel }}</span>
<v-icon size="18" class="chat-settings-group-arrow">mdi-chevron-right</v-icon>
</template>
</v-list-item>
</template> </template>
</v-list-item>
<v-card class="styled-menu-card" style="min-width: 220px;" elevation="8" rounded="lg">
<v-list density="compact" class="styled-menu-list pa-1">
<v-list-item
v-for="opt in sendShortcutOptions"
:key="opt.value"
:value="opt.value"
@click="handleSendShortcutChange(opt.value)"
:class="{ 'styled-menu-item-active': props.sendShortcut === opt.value }"
class="styled-menu-item"
rounded="md"
>
<v-list-item-title>{{ opt.label }}</v-list-item-title>
</v-list-item>
</v-list>
</v-card>
</v-menu>
<!-- 全屏/退出全屏 --> <!-- 全屏/退出全屏 -->
<v-list-item class="styled-menu-item" @click="$emit('toggleFullscreen')"> <v-list-item class="styled-menu-item" @click="$emit('toggleFullscreen')">
@@ -299,16 +162,15 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed } from 'vue'; import { ref } from 'vue';
import { useI18n, useModuleI18n } from '@/i18n/composables'; import { useI18n, useModuleI18n } from '@/i18n/composables';
import type { Session } from '@/composables/useSessions'; import type { Session } from '@/composables/useSessions';
import { askForConfirmation, useConfirmDialog } from '@/utils/confirmDialog'; import { askForConfirmation, useConfirmDialog } from '@/utils/confirmDialog';
import LanguageSwitcher from '@/components/shared/LanguageSwitcher.vue';
import StyledMenu from '@/components/shared/StyledMenu.vue'; import StyledMenu from '@/components/shared/StyledMenu.vue';
import ProviderConfigDialog from '@/components/chat/ProviderConfigDialog.vue'; import ProviderConfigDialog from '@/components/chat/ProviderConfigDialog.vue';
import ProjectList from '@/components/chat/ProjectList.vue'; import ProjectList from '@/components/chat/ProjectList.vue';
import type { Project } 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 { interface Props {
sessions: Session[]; sessions: Session[];
@@ -321,7 +183,6 @@ interface Props {
isMobile: boolean; isMobile: boolean;
mobileMenuOpen: boolean; mobileMenuOpen: boolean;
projects?: Project[]; projects?: Project[];
sendShortcut: 'enter' | 'shift_enter';
} }
const props = withDefaults(defineProps<Props>(), { const props = withDefaults(defineProps<Props>(), {
@@ -333,7 +194,6 @@ const emit = defineEmits<{
selectConversation: [sessionIds: string[]]; selectConversation: [sessionIds: string[]];
editTitle: [sessionId: string, title: string]; editTitle: [sessionId: string, title: string];
deleteConversation: [sessionId: string]; deleteConversation: [sessionId: string];
batchDeleteConversations: [sessionIds: string[]];
closeMobileSidebar: []; closeMobileSidebar: [];
toggleTheme: []; toggleTheme: [];
toggleFullscreen: []; toggleFullscreen: [];
@@ -342,7 +202,6 @@ const emit = defineEmits<{
editProject: [project: Project]; editProject: [project: Project];
deleteProject: [projectId: string]; deleteProject: [projectId: string];
updateTransportMode: [mode: 'sse' | 'websocket']; updateTransportMode: [mode: 'sse' | 'websocket'];
updateSendShortcut: [mode: 'enter' | 'shift_enter'];
}>(); }>();
const { t } = useI18n(); const { t } = useI18n();
@@ -352,84 +211,10 @@ const confirmDialog = useConfirmDialog();
const sidebarCollapsed = ref(true); const sidebarCollapsed = ref(true);
const showProviderConfigDialog = ref(false); 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 = [ const transportOptions = [
{ label: tm('transport.sse'), value: 'sse' as const }, { label: tm('transport.sse'), value: 'sse' as const },
{ label: tm('transport.websocket'), value: 'websocket' 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 // localStorage
const savedCollapsedState = localStorage.getItem('sidebarCollapsed'); const savedCollapsedState = localStorage.getItem('sidebarCollapsed');
@@ -457,12 +242,6 @@ function handleTransportModeChange(mode: string | null) {
emit('updateTransportMode', mode); emit('updateTransportMode', mode);
} }
} }
function handleSendShortcutChange(mode: string | null) {
if (mode === 'enter' || mode === 'shift_enter') {
emit('updateSendShortcut', mode);
}
}
</script> </script>
<style scoped> <style scoped>
@@ -531,7 +310,7 @@ function handleSendShortcutChange(mode: string | null) {
} }
.conversation-item:hover { .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 { .conversation-item:hover .conversation-actions {
@@ -623,74 +402,7 @@ function handleSendShortcutChange(mode: string | null) {
justify-content: center; justify-content: center;
} }
.chat-settings-group-trigger :deep(.v-list-item__append) { .transport-mode-select {
display: flex; min-width: 120px;
align-items: center;
gap: 6px;
}
.chat-settings-group-current {
font-size: 14px;
line-height: 1;
opacity: 0.8;
}
.chat-settings-transport-current {
font-size: 12px;
}
.chat-settings-group-arrow {
opacity: 0.7;
}
.language-flag {
font-size: 16px;
margin-right: 8px;
}
.new-chat-row {
display: flex;
align-items: center;
gap: 4px;
}
.new-chat-row .new-chat-btn {
flex: 1;
min-width: 0;
}
.batch-action-bar {
display: flex;
align-items: center;
padding: 4px 12px;
gap: 4px;
flex-shrink: 0;
}
.batch-selected-count {
font-size: 12px;
opacity: 0.7;
white-space: nowrap;
}
.batch-checkbox {
flex: none;
transition: opacity 0.2s ease, transform 0.2s ease;
}
.batch-checkbox-slot {
width: 0;
opacity: 0;
overflow: hidden;
pointer-events: none;
transform: translateX(-8px);
transition: width 0.2s ease, opacity 0.2s ease, transform 0.2s ease;
}
.batch-checkbox-slot--active {
width: 28px;
opacity: 1;
pointer-events: auto;
transform: translateX(0);
} }
</style> </style>
File diff suppressed because it is too large Load Diff
@@ -180,7 +180,7 @@
<script> <script>
import { useI18n, useModuleI18n } from '@/i18n/composables'; 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 'markstream-vue/index.css'
import 'katex/dist/katex.min.css' import 'katex/dist/katex.min.css'
import 'highlight.js/styles/github.css'; import 'highlight.js/styles/github.css';
@@ -194,11 +194,8 @@ import ActionRef from './message_list_comps/ActionRef.vue';
enableKatex(); enableKatex();
enableMermaid(); enableMermaid();
// message-list + Shiki // ref
setCustomComponents('message-list', { setCustomComponents('message-list', { ref: RefNode });
ref: RefNode,
code_block: MarkdownCodeBlockNode
});
export default { export default {
name: 'MessageList', name: 'MessageList',
@@ -22,7 +22,7 @@
v-model:prompt="prompt" v-model:prompt="prompt"
:stagedImagesUrl="stagedImagesUrl" :stagedImagesUrl="stagedImagesUrl"
:stagedAudioUrl="stagedAudioUrl" :stagedAudioUrl="stagedAudioUrl"
:disabled="false" :disabled="isStreaming"
:is-running="isStreaming || isConvRunning" :is-running="isStreaming || isConvRunning"
:enableStreaming="enableStreaming" :enableStreaming="enableStreaming"
:isRecording="isRecording" :isRecording="isRecording"
@@ -63,9 +63,8 @@
<!-- Text (Markdown) --> <!-- Text (Markdown) -->
<MarkdownRender <MarkdownRender
v-else-if="renderPart.part.type === 'plain' && renderPart.part.text && renderPart.part.text.trim()" 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" 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 --> <!-- Image -->
<div v-else-if="renderPart.part.type === 'image' && renderPart.part.embedded_url" class="embedded-images"> <div v-else-if="renderPart.part.type === 'image' && renderPart.part.embedded_url" class="embedded-images">
@@ -9,7 +9,7 @@
</span> </span>
</div> </div>
<div v-if="isExpanded" class="reasoning-content animate-fade-in"> <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' } : {}" /> :typewriter="false" :is-dark="isDark" :style="isDark ? { opacity: '0.85' } : {}" />
</div> </div>
</div> </div>
@@ -1,12 +1,12 @@
<template> <template>
<v-chip v-if="domain" class="ref-chip" size="x-small" variant="flat" <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> target="_blank" clickable>
<v-icon start size="x-small" color>mdi-link-variant</v-icon> <v-icon start size="x-small" color>mdi-link-variant</v-icon>
<span>{{ domain }}</span> <span>{{ domain }}</span>
</v-chip> </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> </template>
<script setup> <script setup>
@@ -46,15 +46,6 @@ const domain = computed(() => {
return '' 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> </script>
<style scoped> <style scoped>
@@ -1,7 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue'; import { computed } from 'vue';
import { useModuleI18n } from '@/i18n/composables'; import { useModuleI18n } from '@/i18n/composables';
import { normalizeTextInput } from '@/utils/inputValue';
const { tm } = useModuleI18n('features/command'); const { tm } = useModuleI18n('features/command');
@@ -53,7 +52,6 @@ const statusItems = [
{ title: tm('filters.disabled'), value: 'disabled' }, { title: tm('filters.disabled'), value: 'disabled' },
{ title: tm('filters.conflict'), value: 'conflict' } { title: tm('filters.conflict'), value: 'conflict' }
]; ];
</script> </script>
<template> <template>
@@ -110,11 +108,10 @@ const statusItems = [
<div style="min-width: 200px; max-width: 350px; flex: 1; border: 1px solid #B9B9B9; border-radius: 16px;"> <div style="min-width: 200px; max-width: 350px; flex: 1; border: 1px solid #B9B9B9; border-radius: 16px;">
<v-text-field <v-text-field
:model-value="searchQuery" :model-value="searchQuery"
@update:model-value="emit('update:searchQuery', normalizeTextInput($event))" @update:model-value="emit('update:searchQuery', $event)"
density="compact" density="compact"
:label="tm('search.placeholder')" :label="tm('search.placeholder')"
prepend-inner-icon="mdi-magnify" prepend-inner-icon="mdi-magnify"
clearable
variant="solo-filled" variant="solo-filled"
flat flat
hide-details hide-details
@@ -3,7 +3,6 @@
*/ */
import { ref, computed, type Ref } from 'vue'; import { ref, computed, type Ref } from 'vue';
import type { CommandItem, FilterState } from '../types'; import type { CommandItem, FilterState } from '../types';
import { normalizeTextInput } from '@/utils/inputValue';
export function useCommandFilters(commands: Ref<CommandItem[]>) { export function useCommandFilters(commands: Ref<CommandItem[]>) {
// 过滤状态 // 过滤状态
@@ -96,7 +95,7 @@ export function useCommandFilters(commands: Ref<CommandItem[]>) {
* *
*/ */
const filteredCommands = computed(() => { const filteredCommands = computed(() => {
const query = normalizeTextInput(searchQuery.value).toLowerCase(); const query = searchQuery.value.toLowerCase();
const conflictCmds: CommandItem[] = []; const conflictCmds: CommandItem[] = [];
const normalCmds: CommandItem[] = []; const normalCmds: CommandItem[] = [];
@@ -185,3 +184,4 @@ export function useCommandFilters(commands: Ref<CommandItem[]>) {
isGroupExpanded isGroupExpanded
}; };
} }
@@ -15,7 +15,6 @@
import { computed, onActivated, onMounted, ref, watch} from 'vue'; import { computed, onActivated, onMounted, ref, watch} from 'vue';
import axios from 'axios'; import axios from 'axios';
import { useModuleI18n } from '@/i18n/composables'; import { useModuleI18n } from '@/i18n/composables';
import { normalizeTextInput } from '@/utils/inputValue';
// Composables // Composables
import { useComponentData } from './composables/useComponentData'; import { useComponentData } from './composables/useComponentData';
@@ -84,7 +83,7 @@ const {
} = useCommandActions(toast, () => fetchCommands(tm('messages.loadFailed'))); } = useCommandActions(toast, () => fetchCommands(tm('messages.loadFailed')));
const filteredTools = computed(() => { const filteredTools = computed(() => {
const query = normalizeTextInput(toolSearch.value).trim().toLowerCase(); const query = toolSearch.value.trim().toLowerCase();
if (!query) return tools.value; if (!query) return tools.value;
return tools.value.filter(tool => return tools.value.filter(tool =>
tool.name?.toLowerCase().includes(query) || 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 class="d-flex flex-wrap align-center ga-3 mb-4">
<div style="min-width: 240px; max-width: 380px; flex: 1;"> <div style="min-width: 240px; max-width: 380px; flex: 1;">
<v-text-field <v-text-field
:model-value="toolSearch" v-model="toolSearch"
@update:model-value="toolSearch = normalizeTextInput($event)"
prepend-inner-icon="mdi-magnify" prepend-inner-icon="mdi-magnify"
:label="tmTool('functionTools.search')" :label="tmTool('functionTools.search')"
variant="outlined" variant="outlined"
@@ -7,7 +7,6 @@
v-model="modelSearchProxy" v-model="modelSearchProxy"
density="compact" density="compact"
prepend-inner-icon="mdi-magnify" prepend-inner-icon="mdi-magnify"
clearable
hide-details hide-details
variant="solo-filled" variant="solo-filled"
flat flat
@@ -162,7 +161,6 @@
<script setup> <script setup>
import { computed } from 'vue' import { computed } from 'vue'
import { normalizeTextInput } from '@/utils/inputValue'
const props = defineProps({ const props = defineProps({
entries: { entries: {
@@ -224,7 +222,7 @@ const emit = defineEmits([
const modelSearchProxy = computed({ const modelSearchProxy = computed({
get: () => props.modelSearch, get: () => props.modelSearch,
set: (val) => emit('update:modelSearch', normalizeTextInput(val)) set: (val) => emit('update:modelSearch', val)
}) })
const isProviderTesting = (providerId) => props.testingProviders.includes(providerId) const isProviderTesting = (providerId) => props.testingProviders.includes(providerId)
@@ -12,7 +12,7 @@
> >
<v-icon <v-icon
size="18" size="18"
:color="props.variant === 'default' ? 'rgb(var(--v-theme-primary))' : undefined" :color="props.variant === 'default' ? (useCustomizerStore().uiTheme === 'PurpleTheme' ? '#5e35b1' : '#d7c5fa') : undefined"
> >
mdi-translate mdi-translate
</v-icon> </v-icon>
@@ -42,6 +42,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue' import { computed } from 'vue'
import { useI18n, useLanguageSwitcher } from '@/i18n/composables' import { useI18n, useLanguageSwitcher } from '@/i18n/composables'
import { useCustomizerStore } from '@/stores/customizer'
import type { Locale } from '@/i18n/types' import type { Locale } from '@/i18n/types'
import StyledMenu from '@/components/shared/StyledMenu.vue' import StyledMenu from '@/components/shared/StyledMenu.vue'
@@ -89,7 +90,7 @@ const changeLanguage = async (langCode: string) => {
.language-switcher--default:hover { .language-switcher--default:hover {
transform: scale(1.05); 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的默认样式 */ /* Header变体样式 - 完全继承Vuetify和action-btn的默认样式 */
@@ -102,4 +103,8 @@ const changeLanguage = async (langCode: string) => {
/* 继承action-btn样式,与工具栏主题按钮保持一致 */ /* 继承action-btn样式,与工具栏主题按钮保持一致 */
} }
/* 深色模式下的悬停效果(仅对default变体) */
:deep(.v-theme--PurpleThemeDark) .language-switcher--default:hover {
background: rgba(114, 46, 209, 0.12) !important;
}
</style> </style>
+3 -2
View File
@@ -6,11 +6,11 @@
</div> </div>
<div class="logo-text"> <div class="logo-text">
<h2 <h2
:style="{ color: 'rgb(var(--v-theme-primary))' }" :style="{color: useCustomizerStore().uiTheme === 'PurpleTheme' ? '#5e35b1' : '#d7c5fa'}"
v-html="formatTitle(title || t('core.header.logoTitle'))" v-html="formatTitle(title || t('core.header.logoTitle'))"
></h2> ></h2>
<!-- 父子组件传递css变量可能会出错暂时使用十六进制颜色值 --> <!-- 父子组件传递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> class="hint-text">{{ subtitle || t('core.header.accountDialog.title') }}</h4>
</div> </div>
</div> </div>
@@ -18,6 +18,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { useCustomizerStore } from "@/stores/customizer";
import { useI18n } from '@/i18n/composables'; import { useI18n } from '@/i18n/composables';
const { t } = useI18n(); const { t } = useI18n();
@@ -48,24 +48,6 @@ const loading = ref(false);
const isEmpty = ref(false); const isEmpty = ref(false);
const copyFeedbackTimer = ref(null); const copyFeedbackTimer = ref(null);
const lastRequestId = ref(0); 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(() => { onUnmounted(() => {
if (copyFeedbackTimer.value) clearTimeout(copyFeedbackTimer.value); if (copyFeedbackTimer.value) clearTimeout(copyFeedbackTimer.value);
@@ -171,18 +153,6 @@ const renderedHtml = computed(() => {
// 3. // 3.
const tempDiv = document.createElement("div"); const tempDiv = document.createElement("div");
tempDiv.innerHTML = cleanHtml; 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) => { tempDiv.querySelectorAll("a").forEach((link) => {
const href = link.getAttribute("href"); const href = link.getAttribute("href");
// 使 _blank // 使 _blank
@@ -281,35 +251,18 @@ watch(
function handleContainerClick(event) { function handleContainerClick(event) {
const btn = event.target.closest(".copy-code-btn"); const btn = event.target.closest(".copy-code-btn");
if (btn) { if (!btn) return;
const code = btn.closest(".code-block-wrapper")?.querySelector("code"); const code = btn.closest(".code-block-wrapper")?.querySelector("code");
if (code) { if (code) {
if (navigator.clipboard?.writeText) { if (navigator.clipboard?.writeText) {
navigator.clipboard navigator.clipboard
.writeText(code.textContent) .writeText(code.textContent)
.then(() => showCopyFeedback(btn, true)) .then(() => showCopyFeedback(btn, true))
.catch(() => tryFallbackCopy(code.textContent, btn)); .catch(() => tryFallbackCopy(code.textContent, btn));
} else { } else {
tryFallbackCopy(code.textContent, btn); 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) { function tryFallbackCopy(text, btn) {
@@ -373,7 +326,7 @@ const showActionArea = computed(() => {
<v-icon>mdi-close</v-icon> <v-icon>mdi-close</v-icon>
</v-btn> </v-btn>
</v-card-title> </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"> <div v-if="showActionArea" class="d-flex justify-space-between mb-4">
<v-btn <v-btn
v-if="modeConfig.showGithubButton && repoUrl" v-if="modeConfig.showGithubButton && repoUrl"
@@ -483,7 +436,6 @@ const showActionArea = computed(() => {
margin-bottom: 16px; margin-bottom: 16px;
font-weight: 600; font-weight: 600;
line-height: 1.25; line-height: 1.25;
scroll-margin-top: 12px;
} }
:deep(.markdown-body h1) { :deep(.markdown-body h1) {
+17 -15
View File
@@ -24,12 +24,12 @@ withDefaults(defineProps<{
}) })
</script> </script>
<style> <style scoped>
.styled-menu-card { .styled-menu-card {
min-width: 100px; min-width: 100px;
width: fit-content; width: fit-content;
border: 1px solid rgba(var(--v-theme-primary), 0.15) !important; border: 1px solid rgba(94, 53, 177, 0.15) !important;
background: rgba(var(--v-theme-surface), 0.98) !important; background: #f8f6fc !important;
backdrop-filter: blur(10px); backdrop-filter: blur(10px);
} }
@@ -37,41 +37,43 @@ withDefaults(defineProps<{
background: transparent !important; background: transparent !important;
} }
.styled-menu-item { :deep(.styled-menu-item) {
margin: 2px 0; margin: 2px 0;
transition: all 0.2s ease; transition: all 0.2s ease;
border-radius: 6px; border-radius: 6px;
} }
.styled-menu-item:hover { :deep(.styled-menu-item:hover) {
background: rgba(var(--v-theme-primary), 0.08) !important; background: rgba(94, 53, 177, 0.08) !important;
} }
.styled-menu-item-active { :deep(.styled-menu-item-active) {
background: rgba(var(--v-theme-primary), 0.15) !important; background: rgba(94, 53, 177, 0.15) !important;
font-weight: 500; font-weight: 500;
} }
.styled-menu-item-active:hover { :deep(.styled-menu-item-active:hover) {
background: rgba(var(--v-theme-primary), 0.2) !important; background: rgba(94, 53, 177, 0.2) !important;
} }
</style>
<style>
/* 深色模式下的下拉框样式 - 需要全局样式才能检测主题 */ /* 深色模式下的下拉框样式 - 需要全局样式才能检测主题 */
.v-theme--PurpleThemeDark .styled-menu-card { .v-theme--PurpleThemeDark .styled-menu-card {
background: rgba(var(--v-theme-surface), 0.98) !important; background: #2a2733 !important;
border: 1px solid rgba(var(--v-theme-primary), 0.2) !important; border: 1px solid rgba(110, 60, 180, 0.692) !important;
} }
/* 深色模式下的列表项悬停效果 */ /* 深色模式下的列表项悬停效果 */
.v-theme--PurpleThemeDark .styled-menu-item:hover { .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 { .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 { .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> </style>
@@ -2,7 +2,6 @@ import { ref, computed, onMounted, nextTick, watch } from 'vue'
import axios from 'axios' import axios from 'axios'
import { getProviderIcon } from '@/utils/providerUtils' import { getProviderIcon } from '@/utils/providerUtils'
import { askForConfirmation as askForConfirmationDialog, useConfirmDialog } from '@/utils/confirmDialog' import { askForConfirmation as askForConfirmationDialog, useConfirmDialog } from '@/utils/confirmDialog'
import { normalizeTextInput } from '@/utils/inputValue'
export interface UseProviderSourcesOptions { export interface UseProviderSourcesOptions {
defaultTab?: string defaultTab?: string
@@ -158,7 +157,7 @@ export function useProviderSources(options: UseProviderSourcesOptions) {
}) })
const filteredMergedModelEntries = computed(() => { const filteredMergedModelEntries = computed(() => {
const term = normalizeTextInput(modelSearch.value).trim().toLowerCase() const term = modelSearch.value.trim().toLowerCase()
if (!term) return mergedModelEntries.value if (!term) return mergedModelEntries.value
return mergedModelEntries.value.filter((entry: any) => { return mergedModelEntries.value.filter((entry: any) => {
@@ -590,11 +589,9 @@ export function useProviderSources(options: UseProviderSourcesOptions) {
async function testProvider(provider: any) { async function testProvider(provider: any) {
testingProviders.value.push(provider.id) testingProviders.value.push(provider.id)
try { try {
const startTime = performance.now()
const response = await axios.get('/api/config/provider/check_one', { params: { id: provider.id } }) const response = await axios.get('/api/config/provider/check_one', { params: { id: provider.id } })
if (response.data.status === 'ok' && response.data.data.error === null) { if (response.data.status === 'ok' && response.data.data.error === null) {
const latency = Math.max(0, Math.round(performance.now() - startTime)) showMessage(tm('models.testSuccess', { id: provider.id }))
showMessage(tm('models.testSuccessWithLatency', { id: provider.id, latency }))
} else { } else {
throw new Error(response.data.data.error || tm('models.testError')) 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) { function showEditTitleDialog(sessionId: string, title: string) {
editingSessionId.value = sessionId; editingSessionId.value = sessionId;
editingTitle.value = title || ''; editingTitle.value = title || '';
@@ -234,7 +167,6 @@ export function useSessions(chatboxMode: boolean = false) {
getSessions, getSessions,
newSession, newSession,
deleteSession, deleteSession,
batchDeleteSessions,
showEditTitleDialog, showEditTitleDialog,
saveTitle, saveTitle,
updateSessionTitle, updateSessionTitle,
+26 -27
View File
@@ -11,7 +11,7 @@ const translations = ref<Record<string, any>>({});
*/ */
export async function initI18n(locale: Locale = 'zh-CN') { export async function initI18n(locale: Locale = 'zh-CN') {
currentLocale.value = locale; currentLocale.value = locale;
// 加载静态翻译数据 // 加载静态翻译数据
loadTranslations(locale); loadTranslations(locale);
} }
@@ -50,7 +50,7 @@ export function useI18n() {
const t = (key: string, params?: Record<string, string | number>): string => { const t = (key: string, params?: Record<string, string | number>): string => {
const keys = key.split('.'); const keys = key.split('.');
let value: any = translations.value; let value: any = translations.value;
// 遍历键路径 // 遍历键路径
for (const k of keys) { for (const k of keys) {
if (value && typeof value === 'object' && k in value) { if (value && typeof value === 'object' && k in value) {
@@ -61,35 +61,35 @@ export function useI18n() {
return `[MISSING: ${key}]`; return `[MISSING: ${key}]`;
} }
} }
if (typeof value !== 'string') { if (typeof value !== 'string') {
console.warn(`Translation value is not string: ${key}`, value); console.warn(`Translation value is not string: ${key}`, value);
// 返回带括号的键名,便于在开发时识别类型错误的翻译 // 返回带括号的键名,便于在开发时识别类型错误的翻译
return `[INVALID: ${key}]`; return `[INVALID: ${key}]`;
} }
// 此时value确定是string类型 // 此时value确定是string类型
let result: string = value; let result: string = value;
// 处理参数插值 // 处理参数插值
if (params) { if (params) {
result = result.replace(/\{(\w+)\}/g, (match: string, paramKey: string) => { result = result.replace(/\{(\w+)\}/g, (match: string, paramKey: string) => {
return params[paramKey]?.toString() || match; return params[paramKey]?.toString() || match;
}); });
} }
return result; return result;
}; };
// 切换语言 // 切换语言
const setLocale = async (newLocale: Locale) => { const setLocale = async (newLocale: Locale) => {
if (newLocale !== currentLocale.value) { if (newLocale !== currentLocale.value) {
currentLocale.value = newLocale; currentLocale.value = newLocale;
loadTranslations(newLocale); loadTranslations(newLocale);
// 保存到localStorage // 保存到localStorage
localStorage.setItem('astrbot-locale', newLocale); localStorage.setItem('astrbot-locale', newLocale);
// 触发自定义事件,通知相关页面重新加载配置数据 // 触发自定义事件,通知相关页面重新加载配置数据
// 这是因为插件适配器的 i18n 数据是通过后端 API 注入的, // 这是因为插件适配器的 i18n 数据是通过后端 API 注入的,
// 需要根据 Accept-Language 头重新获取 // 需要根据 Accept-Language 头重新获取
@@ -98,16 +98,16 @@ export function useI18n() {
})); }));
} }
}; };
// 获取当前语言 // 获取当前语言
const locale = computed(() => currentLocale.value); 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); const isLoaded = computed(() => Object.keys(translations.value).length > 0);
return { return {
t, t,
locale, locale,
@@ -122,13 +122,13 @@ export function useI18n() {
*/ */
export function useModuleI18n(moduleName: string) { export function useModuleI18n(moduleName: string) {
const { t } = useI18n(); const { t } = useI18n();
const tm = (key: string, params?: Record<string, string | number>): string => { const tm = (key: string, params?: Record<string, string | number>): string => {
// 将斜杠转换为点号以匹配嵌套对象结构 // 将斜杠转换为点号以匹配嵌套对象结构
const normalizedModuleName = moduleName.replace(/\//g, '.'); const normalizedModuleName = moduleName.replace(/\//g, '.');
return t(`${normalizedModuleName}.${key}`, params); return t(`${normalizedModuleName}.${key}`, params);
}; };
// 获取原始翻译值(可能是字符串、数组或对象) // 获取原始翻译值(可能是字符串、数组或对象)
const getRaw = (key: string): any => { const getRaw = (key: string): any => {
const normalizedModuleName = moduleName.replace(/\//g, '.'); const normalizedModuleName = moduleName.replace(/\//g, '.');
@@ -143,10 +143,10 @@ export function useModuleI18n(moduleName: string) {
return null; return null;
} }
} }
return value; return value;
}; };
return { tm, getRaw }; return { tm, getRaw };
} }
@@ -155,21 +155,20 @@ export function useModuleI18n(moduleName: string) {
*/ */
export function useLanguageSwitcher() { export function useLanguageSwitcher() {
const { locale, setLocale, availableLocales } = useI18n(); const { locale, setLocale, availableLocales } = useI18n();
const languageOptions = computed(() => [ const languageOptions = computed(() => [
{ value: 'zh-CN', label: '简体中文', flag: '🇨🇳' }, { value: 'zh-CN', label: '简体中文', flag: '🇨🇳' },
{ value: 'en-US', label: 'English', flag: '🇺🇸' }, { value: 'en-US', label: 'English', flag: '🇺🇸' }
{ value: 'ru-RU', label: 'Русский', flag: '🇷🇺' }
]); ]);
const currentLanguage = computed(() => { const currentLanguage = computed(() => {
return languageOptions.value.find(lang => lang.value === locale.value); return languageOptions.value.find(lang => lang.value === locale.value);
}); });
const switchLanguage = async (newLocale: Locale) => { const switchLanguage = async (newLocale: Locale) => {
await setLocale(newLocale); await setLocale(newLocale);
}; };
return { return {
locale, locale,
languageOptions, languageOptions,
@@ -221,9 +220,9 @@ function deepMerge(target: Record<string, any>, source: Record<string, any>) {
export async function setupI18n() { export async function setupI18n() {
// 从localStorage获取保存的语言设置 // 从localStorage获取保存的语言设置
const savedLocale = localStorage.getItem('astrbot-locale') as Locale; const savedLocale = localStorage.getItem('astrbot-locale') as Locale;
const initialLocale = savedLocale && ['zh-CN', 'en-US', 'ru-RU'].includes(savedLocale) const initialLocale = savedLocale && ['zh-CN', 'en-US'].includes(savedLocale)
? savedLocale ? savedLocale
: 'zh-CN'; : 'zh-CN';
await initI18n(initialLocale); await initI18n(initialLocale);
} }
@@ -96,7 +96,6 @@
"save": "Save", "save": "Save",
"livePreview": "Live Preview (may differ)", "livePreview": "Live Preview (may differ)",
"refreshPreview": "Refresh Preview", "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)", "syntaxHint": "Supports jinja2 syntax. Available variables: text | safe (text to render), version (AstrBot version)",
"saveAndApply": "Save and Apply Current Template", "saveAndApply": "Save and Apply Current Template",
"confirmReset": "Confirm Reset", "confirmReset": "Confirm Reset",
@@ -71,16 +71,10 @@
"modes": { "modes": {
"darkMode": "Switch to Dark Mode", "darkMode": "Switch to Dark Mode",
"lightMode": "Switch to Light Mode" "lightMode": "Switch to Light Mode"
}, }, "shortcuts": {
"shortcuts": {
"help": "Get Help", "help": "Get Help",
"voiceRecord": "Record Voice", "voiceRecord": "Record Voice",
"pasteImage": "Paste Image", "pasteImage": "Paste Image"
"sendKey": {
"title": "Send Shortcut",
"enterToSend": "Enter to send",
"shiftEnterToSend": "Shift+Enter to send"
}
}, },
"streaming": { "streaming": {
"enabled": "Streaming enabled", "enabled": "Streaming enabled",
@@ -147,15 +141,5 @@
"errors": { "errors": {
"sendMessageFailed": "Failed to send message, please try again", "sendMessageFailed": "Failed to send message, please try again",
"createSessionFailed": "Failed to create session, please refresh the page" "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": { "persona": {
"description": "Persona", "description": "Persona",
"hint": "Set the default persona for AI conversations. Personas can be managed in the Persona tab.",
"provider_settings": { "provider_settings": {
"default_personality": { "default_personality": {
"description": "Default Persona" "description": "Default Persona"
@@ -874,8 +873,7 @@
] ]
}, },
"regex": { "regex": {
"description": "Segmentation Regular Expression", "description": "Segmentation Regular Expression"
"hint": "Used to identify split points with a regular expression. Prefer patterns that match separators."
}, },
"split_words": { "split_words": {
"description": "Split Word List", "description": "Split Word List",
@@ -132,7 +132,6 @@
"deleteSuccess": "Model deleted successfully", "deleteSuccess": "Model deleted successfully",
"deleteError": "Failed to delete model", "deleteError": "Failed to delete model",
"testSuccess": "Model {id} test passed", "testSuccess": "Model {id} test passed",
"testSuccessWithLatency": "Model {id} test passed, latency {latency} ms",
"testError": "Model test failed", "testError": "Model test failed",
"searchPlaceholder": "Search models or ID", "searchPlaceholder": "Search models or ID",
"manualAddButton": "Custom Model", "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