Compare commits
94 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f5ba1a026a | |||
| dcffb5269a | |||
| ebd232ec8e | |||
| 1fd3d4ce0e | |||
| 26d69c96d1 | |||
| 3dcdb8b29c | |||
| 437adead28 | |||
| d5b98b353c | |||
| acbc5150cf | |||
| 85cfd62014 | |||
| 1c7c2ee0cd | |||
| ed47420678 | |||
| 6d687691a2 | |||
| 0c71d351ee | |||
| f00ba5adc6 | |||
| d3d4e1db7b | |||
| 78b3e12c66 | |||
| c42ac87ee1 | |||
| 3fbd16b211 | |||
| e77500ff69 | |||
| 2c49ac0dcf | |||
| 65decfbe87 | |||
| 92c31192de | |||
| b795f804a7 | |||
| bc3b5e58a4 | |||
| 7e3c32b828 | |||
| ceb32dce9f | |||
| 84e880af5f | |||
| 9909d774ed | |||
| 6b3868b4be | |||
| 11c840953a | |||
| 2bbca887ce | |||
| dd89a4b334 | |||
| a3fa8a5a7c | |||
| aa60467782 | |||
| d936bb0a10 | |||
| 64e0183b55 | |||
| 420d82df11 | |||
| d87cf897da | |||
| 2f51916a73 | |||
| b0e10cf479 | |||
| 20efaa5320 | |||
| 3ccd70cd4e | |||
| da520e573a | |||
| 6d055e81e9 | |||
| d41ccb70c5 | |||
| 18a99a25c2 | |||
| 96cafe001d | |||
| 29d100dd83 | |||
| 14f3701c4a | |||
| 1044fc48ca | |||
| 693c2ca818 | |||
| b1c486ba98 | |||
| 9363fb824a | |||
| 044b361ac5 | |||
| 06fd2d2428 | |||
| dd6bc1dcdb | |||
| 52d5258b10 | |||
| 91933bbd19 | |||
| f8d075b5d3 | |||
| 86ef758a9a | |||
| 1a03180643 | |||
| 326183a3fd | |||
| 08fc657755 | |||
| 0ff9539599 | |||
| 38f5e077ee | |||
| 89fbd75e7a | |||
| 493662524a | |||
| 1afbb357db | |||
| 8d2140f607 | |||
| 97732987d9 | |||
| a60a40bca3 | |||
| a8ff2b3d9c | |||
| a21bb5b234 | |||
| 994d39241e | |||
| e6c1164755 | |||
| 89cc8a1a65 | |||
| c0e4f1e114 | |||
| 7b43448ce4 | |||
| bdac0b65f4 | |||
| cf9ee6f20c | |||
| 01eae72a64 | |||
| bca1476eab | |||
| fbcbde0a4b | |||
| 3914d766db | |||
| 3e2cb6a2ab | |||
| 25830524f3 | |||
| 304094630c | |||
| 5c3643c54c | |||
| 589cce18af | |||
| e254caf82d | |||
| 7efcd242d6 | |||
| 5d811d3949 | |||
| 8e6aaee10c |
@@ -3,8 +3,8 @@
|
||||
|
||||
### Modifications / 改动点
|
||||
|
||||
<!--请总结你的改动:哪些核心文件被修改了?实现了什么功能?-->
|
||||
<!--Please summarize your changes: What core files were modified? What functionality was implemented?-->
|
||||
<!--请总结你的改动:哪些核心文件被修改了?实现了什么功能?-->
|
||||
|
||||
- [x] This is NOT a breaking change. / 这不是一个破坏性变更。
|
||||
<!-- If your changes is a breaking change, please uncheck the checkbox above -->
|
||||
@@ -21,7 +21,14 @@
|
||||
<!--If merged, your code will serve tens of thousands of users! Please double-check the following items before submitting.-->
|
||||
<!--如果分支被合并,您的代码将服务于数万名用户!在提交前,请核查一下几点内容。-->
|
||||
|
||||
- [ ] 😊 如果 PR 中有新加入的功能,已经通过 Issue / 邮件等方式和作者讨论过。/ If there are new features added in the PR, I have discussed it with the authors through issues/emails, etc.
|
||||
- [ ] 👀 我的更改经过了良好的测试,**并已在上方提供了“验证步骤”和“运行截图”**。/ My changes have been well-tested, **and "Verification Steps" and "Screenshots" have been provided above**.
|
||||
- [ ] 🤓 我确保没有引入新依赖库,或者引入了新依赖库的同时将其添加到了 `requirements.txt` 和 `pyproject.toml` 文件相应位置。/ I have ensured that no new dependencies are introduced, OR if new dependencies are introduced, they have been added to the appropriate locations in `requirements.txt` and `pyproject.toml`.
|
||||
- [ ] 😮 我的更改没有引入恶意代码。/ My changes do not introduce malicious code.
|
||||
- [ ] 😊 If there are new features added in the PR, I have discussed it with the authors through issues/emails, etc.
|
||||
/ 如果 PR 中有新加入的功能,已经通过 Issue / 邮件等方式和作者讨论过。
|
||||
|
||||
- [ ] 👀 My changes have been well-tested, **and "Verification Steps" and "Screenshots" have been provided above**.
|
||||
/ 我的更改经过了良好的测试,**并已在上方提供了“验证步骤”和“运行截图”**。
|
||||
|
||||
- [ ] 🤓 I have ensured that no new dependencies are introduced, OR if new dependencies are introduced, they have been added to the appropriate locations in `requirements.txt` and `pyproject.toml`.
|
||||
/ 我确保没有引入新依赖库,或者引入了新依赖库的同时将其添加到 `requirements.txt` 和 `pyproject.toml` 文件相应位置。
|
||||
|
||||
- [ ] 😮 My changes do not introduce malicious code.
|
||||
/ 我的更改没有引入恶意代码。
|
||||
|
||||
@@ -11,7 +11,7 @@ jobs:
|
||||
runs-on: ubuntu-latest # 运行环境
|
||||
steps:
|
||||
- name: checkout
|
||||
uses: actions/checkout@master
|
||||
uses: actions/checkout@v6
|
||||
- name: nodejs installation
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
@@ -23,7 +23,7 @@ jobs:
|
||||
run: npm run docs:build
|
||||
working-directory: './docs'
|
||||
- name: scp
|
||||
uses: appleboy/scp-action@master
|
||||
uses: appleboy/scp-action@v1.0.0
|
||||
with:
|
||||
host: ${{ secrets.HOST_NEKO }}
|
||||
username: ${{ secrets.USERNAME }}
|
||||
@@ -31,7 +31,7 @@ jobs:
|
||||
source: 'docs/.vitepress/dist/*'
|
||||
target: '/tmp/'
|
||||
- name: script
|
||||
uses: appleboy/ssh-action@master
|
||||
uses: appleboy/ssh-action@v1.2.5
|
||||
with:
|
||||
host: ${{ secrets.HOST_NEKO }}
|
||||
username: ${{ secrets.USERNAME }}
|
||||
|
||||
@@ -45,7 +45,7 @@ jobs:
|
||||
|
||||
- name: Create GitHub Release
|
||||
if: github.event_name == 'push'
|
||||
uses: ncipollo/release-action@v1
|
||||
uses: ncipollo/release-action@v1.21.0
|
||||
with:
|
||||
tag: release-${{ github.sha }}
|
||||
owner: AstrBotDevs
|
||||
|
||||
@@ -64,20 +64,20 @@ jobs:
|
||||
echo "build_date=$build_date" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Set QEMU
|
||||
uses: docker/setup-qemu-action@v4
|
||||
uses: docker/setup-qemu-action@v4.0.0
|
||||
|
||||
- name: Set Docker Buildx
|
||||
uses: docker/setup-buildx-action@v4
|
||||
uses: docker/setup-buildx-action@v4.0.0
|
||||
|
||||
- name: Log in to DockerHub
|
||||
uses: docker/login-action@v4
|
||||
uses: docker/login-action@v4.0.0
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_HUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_HUB_PASSWORD }}
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
if: env.HAS_GHCR_TOKEN == 'true'
|
||||
uses: docker/login-action@v4
|
||||
uses: docker/login-action@v4.0.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ env.GHCR_OWNER }}
|
||||
@@ -98,7 +98,7 @@ jobs:
|
||||
echo "EOF" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Build and Push Nightly Image
|
||||
uses: docker/build-push-action@v7
|
||||
uses: docker/build-push-action@v7.0.0
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/amd64,linux/arm64
|
||||
@@ -163,27 +163,27 @@ jobs:
|
||||
cp -r dashboard/dist data/
|
||||
|
||||
- name: Set QEMU
|
||||
uses: docker/setup-qemu-action@v4
|
||||
uses: docker/setup-qemu-action@v4.0.0
|
||||
|
||||
- name: Set Docker Buildx
|
||||
uses: docker/setup-buildx-action@v4
|
||||
uses: docker/setup-buildx-action@v4.0.0
|
||||
|
||||
- name: Log in to DockerHub
|
||||
uses: docker/login-action@v4
|
||||
uses: docker/login-action@v4.0.0
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_HUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_HUB_PASSWORD }}
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
if: env.HAS_GHCR_TOKEN == 'true'
|
||||
uses: docker/login-action@v4
|
||||
uses: docker/login-action@v4.0.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ env.GHCR_OWNER }}
|
||||
password: ${{ secrets.GHCR_GITHUB_TOKEN }}
|
||||
|
||||
- name: Build and Push Release Image
|
||||
uses: docker/build-push-action@v7
|
||||
uses: docker/build-push-action@v7.0.0
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/amd64,linux/arm64
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
name: PR Title Check
|
||||
|
||||
on:
|
||||
pull_request_target:
|
||||
types: [opened, edited, reopened, synchronize]
|
||||
|
||||
jobs:
|
||||
title-format:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
pull-requests: write
|
||||
issues: write
|
||||
|
||||
steps:
|
||||
- name: Validate PR title
|
||||
uses: actions/github-script@v8
|
||||
with:
|
||||
script: |
|
||||
const title = (context.payload.pull_request.title || "").trim();
|
||||
// allow only:
|
||||
// feat: xxx
|
||||
// feat(scope): xxx
|
||||
const pattern = /^(feat)(\([a-z0-9-]+\))?:\s.+$/i;
|
||||
const isValid = pattern.test(title);
|
||||
const isSameRepo =
|
||||
context.payload.pull_request.head.repo.full_name === context.payload.repository.full_name;
|
||||
|
||||
if (!isValid) {
|
||||
if (isSameRepo) {
|
||||
try {
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.payload.pull_request.number,
|
||||
body: [
|
||||
"⚠️ PR title format check failed.",
|
||||
"Required formats:",
|
||||
"- `feat: xxx`",
|
||||
"- `feat(scope): xxx`",
|
||||
"Please update your PR title and push again."
|
||||
].join("\n")
|
||||
});
|
||||
} catch (e) {
|
||||
core.warning(`Failed to post PR title comment: ${e.message}`);
|
||||
}
|
||||
} else {
|
||||
core.warning("Fork PR: comment permission is restricted; skip posting review comment.");
|
||||
}
|
||||
}
|
||||
|
||||
if (!isValid) {
|
||||
core.setFailed("Invalid PR title. Expected format: feat: xxx or feat(scope): xxx.");
|
||||
}
|
||||
@@ -50,7 +50,7 @@ jobs:
|
||||
echo "tag=$tag" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
uses: pnpm/action-setup@v4.4.0
|
||||
with:
|
||||
version: 10.28.2
|
||||
|
||||
|
||||
+1
-1
@@ -62,4 +62,4 @@ GenieData/
|
||||
.opencode/
|
||||
.kilocode/
|
||||
.worktrees/
|
||||
docs/plans/
|
||||
|
||||
|
||||
@@ -78,7 +78,7 @@ For users who want to quickly experience AstrBot, are familiar with command-line
|
||||
```bash
|
||||
uv tool install astrbot
|
||||
astrbot init # Only execute this command for the first time to initialize the environment
|
||||
astrbot
|
||||
astrbot run
|
||||
```
|
||||
|
||||
> Requires [uv](https://docs.astral.sh/uv/) to be installed.
|
||||
@@ -234,7 +234,8 @@ pre-commit install
|
||||
- Group 7: 743746109
|
||||
- Group 8: 1030353265
|
||||
|
||||
- Developer Group: 975206796
|
||||
- Developer Group(Chit-chat): 975206796
|
||||
- Developer Group(Formal): 1039761811
|
||||
|
||||
### Discord Server
|
||||
|
||||
|
||||
+2
-1
@@ -78,7 +78,7 @@ Pour les utilisateurs qui veulent découvrir AstrBot rapidement, qui sont famili
|
||||
```bash
|
||||
uv tool install astrbot
|
||||
astrbot init # Exécutez cette commande uniquement la première fois pour initialiser l'environnement
|
||||
astrbot
|
||||
astrbot run
|
||||
```
|
||||
|
||||
> [uv](https://docs.astral.sh/uv/) doit être installé.
|
||||
@@ -222,6 +222,7 @@ pre-commit install
|
||||
- Groupe 5 : 822130018
|
||||
- Groupe 6 : 753075035
|
||||
- Groupe développeurs : 975206796
|
||||
- Groupe développeurs (officiel) : 1039761811
|
||||
|
||||
### Serveur Discord
|
||||
|
||||
|
||||
+2
-1
@@ -78,7 +78,7 @@ AstrBot を素早く試したいユーザーで、コマンドラインに慣れ
|
||||
```bash
|
||||
uv tool install astrbot
|
||||
astrbot init # 初回のみ実行して環境を初期化します
|
||||
astrbot
|
||||
astrbot run
|
||||
```
|
||||
|
||||
> [uv](https://docs.astral.sh/uv/) のインストールが必要です。
|
||||
@@ -223,6 +223,7 @@ pre-commit install
|
||||
- 5群: 822130018
|
||||
- 6群: 753075035
|
||||
- 開発者群: 975206796
|
||||
- 開発者群(正式): 1039761811
|
||||
|
||||
### Discord サーバー
|
||||
|
||||
|
||||
+2
-1
@@ -78,7 +78,7 @@ AstrBot — это универсальная платформа Agent-чатб
|
||||
```bash
|
||||
uv tool install astrbot
|
||||
astrbot init # Выполните эту команду только при первом запуске для инициализации окружения
|
||||
astrbot
|
||||
astrbot run
|
||||
```
|
||||
|
||||
> Требуется установленный [uv](https://docs.astral.sh/uv/).
|
||||
@@ -222,6 +222,7 @@ pre-commit install
|
||||
- Группа 5: 822130018
|
||||
- Группа 6: 753075035
|
||||
- Группа разработчиков: 975206796
|
||||
- Группа разработчиков (официальная): 1039761811
|
||||
|
||||
### Сервер Discord
|
||||
|
||||
|
||||
+3
-2
@@ -78,7 +78,7 @@ AstrBot 是一個開源的一站式 Agent 聊天機器人平台,可接入主
|
||||
```bash
|
||||
uv tool install astrbot
|
||||
astrbot init # 僅首次執行此命令以初始化環境
|
||||
astrbot
|
||||
astrbot run
|
||||
```
|
||||
|
||||
> 需要安裝 [uv](https://docs.astral.sh/uv/)。
|
||||
@@ -225,7 +225,8 @@ pre-commit install
|
||||
- 6 群:753075035
|
||||
- 7 群:743746109
|
||||
- 8 群:1030353265
|
||||
- 開發者群:975206796
|
||||
- 開發者群(闲聊吹水):975206796
|
||||
- 開發者群(正式):1039761811
|
||||
|
||||
### Discord 群組
|
||||
|
||||
|
||||
+3
-2
@@ -78,7 +78,7 @@ AstrBot 是一个开源的一站式 Agentic 个人和群聊助手,可在 QQ、
|
||||
```bash
|
||||
uv tool install astrbot
|
||||
astrbot init # 仅首次执行此命令以初始化环境
|
||||
astrbot
|
||||
astrbot run
|
||||
```
|
||||
|
||||
> 需要安装 [uv](https://docs.astral.sh/uv/)。
|
||||
@@ -226,7 +226,8 @@ pre-commit install
|
||||
- 6 群:753075035
|
||||
- 7 群:743746109
|
||||
- 8 群:1030353265
|
||||
- 开发者群:975206796
|
||||
- 开发者群(偏闲聊吹水):975206796
|
||||
- 开发者群(正式):1039761811
|
||||
|
||||
### Discord 频道
|
||||
|
||||
|
||||
@@ -1 +1 @@
|
||||
__version__ = "4.19.5"
|
||||
__version__ = "4.20.1"
|
||||
|
||||
@@ -62,4 +62,4 @@ class HandoffTool(FunctionTool, Generic[TContext]):
|
||||
|
||||
def default_description(self, agent_name: str | None) -> str:
|
||||
agent_name = agent_name or "another"
|
||||
return f"Delegate tasks to {self.name} agent to handle the request."
|
||||
return f"Delegate tasks to {agent_name} agent to handle the request."
|
||||
|
||||
@@ -390,14 +390,9 @@ async def _ensure_persona_and_skills(
|
||||
persona_tools = None
|
||||
pid = a.get("persona_id")
|
||||
if pid:
|
||||
persona_tools = next(
|
||||
(
|
||||
p.get("tools")
|
||||
for p in plugin_context.persona_manager.personas_v3
|
||||
if p["name"] == pid
|
||||
),
|
||||
None,
|
||||
)
|
||||
persona = plugin_context.persona_manager.get_persona_v3_by_id(pid)
|
||||
if persona is not None:
|
||||
persona_tools = persona.get("tools")
|
||||
tools = a.get("tools", [])
|
||||
if persona_tools is not None:
|
||||
tools = persona_tools
|
||||
@@ -778,9 +773,14 @@ def _plugin_tool_fix(event: AstrMessageEvent, req: ProviderRequest) -> None:
|
||||
continue
|
||||
mp = tool.handler_module_path
|
||||
if not mp:
|
||||
# 没有 plugin 归属信息的工具(如 subagent transfer_to_*)
|
||||
# 不应受到会话插件过滤影响。
|
||||
new_tool_set.add_tool(tool)
|
||||
continue
|
||||
plugin = star_map.get(mp)
|
||||
if not plugin:
|
||||
# 无法解析插件归属时,保守保留工具,避免误过滤。
|
||||
new_tool_set.add_tool(tool)
|
||||
continue
|
||||
if plugin.name in event.plugins_name or plugin.reserved:
|
||||
new_tool_set.add_tool(tool)
|
||||
|
||||
@@ -188,7 +188,12 @@ class KnowledgeBaseQueryTool(FunctionTool[AstrAgentContext]):
|
||||
@dataclass
|
||||
class SendMessageToUserTool(FunctionTool[AstrAgentContext]):
|
||||
name: str = "send_message_to_user"
|
||||
description: str = "Directly send message to the user. Only use this tool when you need to proactively message the user. Otherwise you can directly output the reply in the conversation."
|
||||
description: str = (
|
||||
"Send message to the user. "
|
||||
"Supports various message types including `plain`, `image`, `record`, `video`, `file`, and `mention_user`. "
|
||||
"Use this tool to send media files (`image`, `record`, `video`, `file`), "
|
||||
"or when you need to proactively message the user(such as cron job). For normal text replies, you can output directly."
|
||||
)
|
||||
|
||||
parameters: dict = Field(
|
||||
default_factory=lambda: {
|
||||
@@ -204,7 +209,7 @@ class SendMessageToUserTool(FunctionTool[AstrAgentContext]):
|
||||
"type": "string",
|
||||
"description": (
|
||||
"Component type. One of: "
|
||||
"plain, image, record, file, mention_user"
|
||||
"plain, image, record, video, file, mention_user. Record is voice message."
|
||||
),
|
||||
},
|
||||
"text": {
|
||||
@@ -320,6 +325,19 @@ class SendMessageToUserTool(FunctionTool[AstrAgentContext]):
|
||||
components.append(Comp.Record.fromURL(url=url))
|
||||
else:
|
||||
return f"error: messages[{idx}] must include path or url for record component."
|
||||
elif msg_type == "video":
|
||||
path = msg.get("path")
|
||||
url = msg.get("url")
|
||||
if path:
|
||||
(
|
||||
local_path,
|
||||
file_from_sandbox,
|
||||
) = await self._resolve_path_from_sandbox(context, path)
|
||||
components.append(Comp.Video.fromFileSystem(path=local_path))
|
||||
elif url:
|
||||
components.append(Comp.Video.fromURL(url=url))
|
||||
else:
|
||||
return f"error: messages[{idx}] must include path or url for video component."
|
||||
elif msg_type == "file":
|
||||
path = msg.get("path")
|
||||
url = msg.get("url")
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import locale
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
@@ -52,6 +53,31 @@ def _ensure_safe_path(path: str) -> str:
|
||||
return abs_path
|
||||
|
||||
|
||||
def _decode_shell_output(output: bytes | None) -> str:
|
||||
if output is None:
|
||||
return ""
|
||||
|
||||
preferred = locale.getpreferredencoding(False) or "utf-8"
|
||||
try:
|
||||
return output.decode("utf-8")
|
||||
except (LookupError, UnicodeDecodeError):
|
||||
pass
|
||||
|
||||
if os.name == "nt":
|
||||
for encoding in ("mbcs", "cp936", "gbk", "gb18030"):
|
||||
try:
|
||||
return output.decode(encoding)
|
||||
except (LookupError, UnicodeDecodeError):
|
||||
continue
|
||||
|
||||
try:
|
||||
return output.decode(preferred)
|
||||
except (LookupError, UnicodeDecodeError):
|
||||
pass
|
||||
|
||||
return output.decode("utf-8", errors="replace")
|
||||
|
||||
|
||||
@dataclass
|
||||
class LocalShellComponent(ShellComponent):
|
||||
async def exec(
|
||||
@@ -72,28 +98,32 @@ class LocalShellComponent(ShellComponent):
|
||||
run_env.update({str(k): str(v) for k, v in env.items()})
|
||||
working_dir = _ensure_safe_path(cwd) if cwd else get_astrbot_root()
|
||||
if background:
|
||||
proc = subprocess.Popen(
|
||||
# `command` is intentionally executed through the current shell so
|
||||
# local computer-use behavior matches existing tool semantics.
|
||||
# Safety relies on `_is_safe_command()` and the allowed-root checks.
|
||||
proc = subprocess.Popen( # noqa: S602 # nosemgrep: python.lang.security.audit.dangerous-subprocess-use-audit
|
||||
command,
|
||||
shell=shell,
|
||||
cwd=working_dir,
|
||||
env=run_env,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
text=True,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
)
|
||||
return {"pid": proc.pid, "stdout": "", "stderr": "", "exit_code": None}
|
||||
result = subprocess.run(
|
||||
# `command` is intentionally executed through the current shell so
|
||||
# local computer-use behavior matches existing tool semantics.
|
||||
# Safety relies on `_is_safe_command()` and the allowed-root checks.
|
||||
result = subprocess.run( # noqa: S602 # nosemgrep: python.lang.security.audit.dangerous-subprocess-use-audit
|
||||
command,
|
||||
shell=shell,
|
||||
cwd=working_dir,
|
||||
env=run_env,
|
||||
timeout=timeout,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
return {
|
||||
"stdout": result.stdout,
|
||||
"stderr": result.stderr,
|
||||
"stdout": _decode_shell_output(result.stdout),
|
||||
"stderr": _decode_shell_output(result.stderr),
|
||||
"exit_code": result.returncode,
|
||||
}
|
||||
|
||||
|
||||
@@ -213,13 +213,24 @@ def parse_description(text: str) -> str:
|
||||
break
|
||||
if end_idx is None:
|
||||
return ""
|
||||
for line in lines[1:end_idx]:
|
||||
if ":" not in line:
|
||||
continue
|
||||
key, value = line.split(":", 1)
|
||||
if key.strip().lower() == "description":
|
||||
return value.strip().strip('"').strip("'")
|
||||
return ""
|
||||
|
||||
frontmatter = "\n".join(lines[1:end_idx])
|
||||
try:
|
||||
import yaml
|
||||
except ImportError:
|
||||
return ""
|
||||
|
||||
try:
|
||||
payload = yaml.safe_load(frontmatter) or dict()
|
||||
except yaml.YAMLError:
|
||||
return ""
|
||||
if not isinstance(payload, dict):
|
||||
return ""
|
||||
|
||||
description = payload.get("description", "")
|
||||
if not isinstance(description, str):
|
||||
return ""
|
||||
return description.strip()
|
||||
|
||||
|
||||
def load_managed_skills() -> list[str]:
|
||||
@@ -422,6 +433,12 @@ async def get_booter(
|
||||
) -> ComputerBooter:
|
||||
config = context.get_config(umo=session_id)
|
||||
|
||||
runtime = config.get("provider_settings", {}).get("computer_use_runtime", "local")
|
||||
if runtime == "local":
|
||||
return get_local_booter()
|
||||
elif runtime == "none":
|
||||
raise RuntimeError("Sandbox runtime is disabled by configuration.")
|
||||
|
||||
sandbox_cfg = config.get("provider_settings", {}).get("sandbox", {})
|
||||
booter_type = sandbox_cfg.get("booter", "shipyard_neo")
|
||||
|
||||
|
||||
@@ -164,7 +164,10 @@ class CreateSkillPayloadTool(NeoSkillToolBase):
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"payload": {
|
||||
"anyOf": [{"type": "object"}, {"type": "array"}],
|
||||
"anyOf": [
|
||||
{"type": "object"},
|
||||
{"type": "array", "items": {"type": "object"}},
|
||||
],
|
||||
"description": (
|
||||
"Skill payload JSON. Typical schema: {skill_markdown, inputs, outputs, meta}. "
|
||||
"This only stores content and returns payload_ref; it does not create a candidate or release."
|
||||
|
||||
@@ -5,7 +5,7 @@ from typing import Any, TypedDict
|
||||
|
||||
from astrbot.core.utils.astrbot_path import get_astrbot_data_path
|
||||
|
||||
VERSION = "4.19.5"
|
||||
VERSION = "4.20.1"
|
||||
DB_PATH = os.path.join(get_astrbot_data_path(), "data_v4.db")
|
||||
|
||||
WEBHOOK_SUPPORTED_PLATFORMS = [
|
||||
@@ -219,6 +219,9 @@ DEFAULT_CONFIG = {
|
||||
"telegram": {
|
||||
"pre_ack_emoji": {"enable": False, "emojis": ["✍️"]},
|
||||
},
|
||||
"discord": {
|
||||
"pre_ack_emoji": {"enable": False, "emojis": ["🤔"]},
|
||||
},
|
||||
},
|
||||
"wake_prefix": ["/"],
|
||||
"log_level": "INFO",
|
||||
@@ -460,7 +463,6 @@ CONFIG_METADATA_2 = {
|
||||
"type": "kook",
|
||||
"enable": False,
|
||||
"kook_bot_token": "",
|
||||
"kook_bot_nickname": "",
|
||||
"kook_reconnect_delay": 1,
|
||||
"kook_max_reconnect_delay": 60,
|
||||
"kook_max_retry_delay": 60,
|
||||
@@ -872,11 +874,6 @@ CONFIG_METADATA_2 = {
|
||||
"type": "string",
|
||||
"hint": "必填项。从 KOOK 开发者平台获取的机器人 Token。",
|
||||
},
|
||||
"kook_bot_nickname": {
|
||||
"description": "Bot Nickname",
|
||||
"type": "string",
|
||||
"hint": "可选项。若发送者昵称与此值一致,将忽略该消息以避免广播风暴。",
|
||||
},
|
||||
"kook_reconnect_delay": {
|
||||
"description": "重连延迟",
|
||||
"type": "int",
|
||||
@@ -1129,6 +1126,18 @@ CONFIG_METADATA_2 = {
|
||||
"proxy": "",
|
||||
"custom_headers": {},
|
||||
},
|
||||
"MiniMax": {
|
||||
"id": "minimax",
|
||||
"provider": "minimax",
|
||||
"type": "openai_chat_completion",
|
||||
"provider_type": "chat_completion",
|
||||
"enable": True,
|
||||
"key": [],
|
||||
"api_base": "https://api.minimaxi.com/v1",
|
||||
"timeout": 120,
|
||||
"proxy": "",
|
||||
"custom_headers": {},
|
||||
},
|
||||
"xAI": {
|
||||
"id": "xai",
|
||||
"provider": "xai",
|
||||
|
||||
@@ -332,9 +332,9 @@ class CronJobManager:
|
||||
cron_job=cron_job_str
|
||||
)
|
||||
req.prompt = (
|
||||
"You are now responding to a scheduled task"
|
||||
"You are now responding to a scheduled task. "
|
||||
"Proceed according to your system instructions. "
|
||||
"Output using same language as previous conversation."
|
||||
"Output using same language as previous conversation. "
|
||||
"After completing your task, summarize and output your actions and results."
|
||||
)
|
||||
if not req.func_tool:
|
||||
|
||||
@@ -33,10 +33,18 @@ class BaseDatabase(abc.ABC):
|
||||
DATABASE_URL = ""
|
||||
|
||||
def __init__(self) -> None:
|
||||
# SQLite only supports a single writer at a time. Without a busy
|
||||
# timeout the driver raises "database is locked" instantly when a
|
||||
# second write is attempted. Setting timeout=30 tells SQLite to
|
||||
# wait up to 30 s for the lock, which is enough to ride out brief
|
||||
# write bursts from concurrent agent/metrics/session operations.
|
||||
is_sqlite = "sqlite" in self.DATABASE_URL
|
||||
connect_args = {"timeout": 30} if is_sqlite else {}
|
||||
self.engine = create_async_engine(
|
||||
self.DATABASE_URL,
|
||||
echo=False,
|
||||
future=True,
|
||||
connect_args=connect_args,
|
||||
)
|
||||
self.AsyncSessionLocal = async_sessionmaker(
|
||||
self.engine,
|
||||
@@ -647,6 +655,13 @@ class BaseDatabase(abc.ABC):
|
||||
"""Get a Platform session by its ID."""
|
||||
...
|
||||
|
||||
@abc.abstractmethod
|
||||
async def get_platform_sessions_by_ids(
|
||||
self, session_ids: list[str]
|
||||
) -> list[PlatformSession]:
|
||||
"""Get platform sessions by IDs."""
|
||||
...
|
||||
|
||||
@abc.abstractmethod
|
||||
async def get_platform_sessions_by_creator(
|
||||
self,
|
||||
|
||||
@@ -1417,6 +1417,21 @@ class SQLiteDatabase(BaseDatabase):
|
||||
result = await session.execute(query)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
async def get_platform_sessions_by_ids(
|
||||
self, session_ids: list[str]
|
||||
) -> list[PlatformSession]:
|
||||
"""Get platform sessions by IDs."""
|
||||
if not session_ids:
|
||||
return []
|
||||
|
||||
async with self.get_db() as session:
|
||||
session: AsyncSession
|
||||
query = select(PlatformSession).where(
|
||||
col(PlatformSession.session_id).in_(session_ids)
|
||||
)
|
||||
result = await session.execute(query)
|
||||
return list(result.scalars().all())
|
||||
|
||||
async def get_platform_sessions_by_creator(
|
||||
self,
|
||||
creator: str,
|
||||
|
||||
@@ -96,10 +96,10 @@ class Plain(BaseMessageComponent):
|
||||
def __init__(self, text: str, convert: bool = True, **_) -> None:
|
||||
super().__init__(text=text, convert=convert, **_)
|
||||
|
||||
def toDict(self):
|
||||
return {"type": "text", "data": {"text": self.text.strip()}}
|
||||
def toDict(self) -> dict:
|
||||
return {"type": "text", "data": {"text": self.text}}
|
||||
|
||||
async def to_dict(self):
|
||||
async def to_dict(self) -> dict:
|
||||
return {"type": "text", "data": {"text": self.text}}
|
||||
|
||||
|
||||
|
||||
@@ -44,6 +44,22 @@ class PersonaManager:
|
||||
raise ValueError(f"Persona with ID {persona_id} does not exist.")
|
||||
return persona
|
||||
|
||||
def get_persona_v3_by_id(self, persona_id: str | None) -> Personality | None:
|
||||
"""Resolve a v3 persona object by id.
|
||||
|
||||
- None/empty id returns None.
|
||||
- "default" maps to in-memory DEFAULT_PERSONALITY.
|
||||
- Otherwise search in personas_v3 by persona name.
|
||||
"""
|
||||
if not persona_id:
|
||||
return None
|
||||
if persona_id == "default":
|
||||
return DEFAULT_PERSONALITY
|
||||
return next(
|
||||
(persona for persona in self.personas_v3 if persona["name"] == persona_id),
|
||||
None,
|
||||
)
|
||||
|
||||
async def get_default_persona_v3(
|
||||
self,
|
||||
umo: str | MessageSession | None = None,
|
||||
@@ -54,12 +70,7 @@ class PersonaManager:
|
||||
"default_personality",
|
||||
"default",
|
||||
)
|
||||
if not default_persona_id or default_persona_id == "default":
|
||||
return DEFAULT_PERSONALITY
|
||||
try:
|
||||
return next(p for p in self.personas_v3 if p["name"] == default_persona_id)
|
||||
except Exception:
|
||||
return DEFAULT_PERSONALITY
|
||||
return self.get_persona_v3_by_id(default_persona_id) or DEFAULT_PERSONALITY
|
||||
|
||||
async def resolve_selected_persona(
|
||||
self,
|
||||
|
||||
@@ -6,6 +6,7 @@ from aiocqhttp import CQHttp, Event
|
||||
|
||||
from astrbot.api.event import AstrMessageEvent, MessageChain
|
||||
from astrbot.api.message_components import (
|
||||
At,
|
||||
BaseMessageComponent,
|
||||
File,
|
||||
Image,
|
||||
@@ -70,11 +71,19 @@ class AiocqhttpMessageEvent(AstrMessageEvent):
|
||||
"""解析成 OneBot json 格式"""
|
||||
ret = []
|
||||
for segment in message_chain.chain:
|
||||
if isinstance(segment, Plain):
|
||||
if isinstance(segment, At):
|
||||
# At 组件后插入一个空格,避免与后续文本粘连
|
||||
d = await AiocqhttpMessageEvent._from_segment_to_dict(segment)
|
||||
ret.append(d)
|
||||
ret.append({"type": "text", "data": {"text": " "}})
|
||||
elif isinstance(segment, Plain):
|
||||
if not segment.text.strip():
|
||||
continue
|
||||
d = await AiocqhttpMessageEvent._from_segment_to_dict(segment)
|
||||
ret.append(d)
|
||||
d = await AiocqhttpMessageEvent._from_segment_to_dict(segment)
|
||||
ret.append(d)
|
||||
else:
|
||||
d = await AiocqhttpMessageEvent._from_segment_to_dict(segment)
|
||||
ret.append(d)
|
||||
return ret
|
||||
|
||||
@classmethod
|
||||
|
||||
@@ -13,11 +13,28 @@ from astrbot.api.platform import (
|
||||
PlatformMetadata,
|
||||
register_platform_adapter,
|
||||
)
|
||||
from astrbot.core.message.components import File, Record, Video
|
||||
from astrbot.core.platform.astr_message_event import MessageSesion
|
||||
|
||||
from .kook_client import KookClient
|
||||
from .kook_config import KookConfig
|
||||
from .kook_event import KookEvent
|
||||
from .kook_types import (
|
||||
ContainerModule,
|
||||
FileModule,
|
||||
HeaderModule,
|
||||
ImageGroupModule,
|
||||
KmarkdownElement,
|
||||
KookCardMessageContainer,
|
||||
KookChannelType,
|
||||
KookMessageEventData,
|
||||
KookMessageType,
|
||||
KookModuleType,
|
||||
PlainTextElement,
|
||||
SectionModule,
|
||||
)
|
||||
|
||||
KOOK_AT_SELECTOR_REGEX = re.compile(r"\(met\)([^()]+)\(met\)")
|
||||
|
||||
|
||||
@register_platform_adapter(
|
||||
@@ -57,35 +74,26 @@ class KookPlatformAdapter(Platform):
|
||||
name="kook", description="KOOK 适配器", id=self.kook_config.id
|
||||
)
|
||||
|
||||
def _should_ignore_event_by_bot_nickname(self, payload: dict) -> bool:
|
||||
bot_nickname = self.kook_config.bot_nickname.strip()
|
||||
if not bot_nickname:
|
||||
return False
|
||||
def _should_ignore_event_by_bot_nickname(self, author_id: str) -> bool:
|
||||
return self.client.bot_id == author_id
|
||||
|
||||
author = payload.get("extra", {}).get("author", {})
|
||||
if not isinstance(author, dict):
|
||||
return False
|
||||
|
||||
author_nickname = author.get("nickname") or author.get("username") or ""
|
||||
if not isinstance(author_nickname, str):
|
||||
author_nickname = str(author_nickname)
|
||||
|
||||
return author_nickname.strip().casefold() == bot_nickname.casefold()
|
||||
|
||||
async def _on_received(self, data: dict):
|
||||
logger.debug(f"KOOK 收到数据: {data}")
|
||||
if "d" in data and data["s"] == 0:
|
||||
payload = data["d"]
|
||||
event_type = payload.get("type")
|
||||
# 支持type=9(文本)和type=10(卡片)
|
||||
if event_type in (9, 10):
|
||||
if self._should_ignore_event_by_bot_nickname(payload):
|
||||
return
|
||||
try:
|
||||
abm = await self.convert_message(payload)
|
||||
await self.handle_msg(abm)
|
||||
except Exception as e:
|
||||
logger.error(f"[KOOK] 消息处理异常: {e}")
|
||||
async def _on_received(self, event: KookMessageEventData):
|
||||
logger.debug(
|
||||
f'[KOOK] 收到来自"{event.channel_type.name}"渠道的消息, 消息类型为: {event.type.name}({event.type.value})'
|
||||
)
|
||||
event_type = event.type
|
||||
if event_type in (KookMessageType.KMARKDOWN, KookMessageType.CARD):
|
||||
if self._should_ignore_event_by_bot_nickname(event.author_id):
|
||||
logger.debug("[KOOK] 收到来自机器人自身的消息, 忽略此消息")
|
||||
return
|
||||
try:
|
||||
abm = await self.convert_message(event)
|
||||
await self.handle_msg(abm)
|
||||
except Exception as e:
|
||||
logger.error(f"[KOOK] 消息处理异常: {e}")
|
||||
elif event_type == KookMessageType.SYSTEM:
|
||||
logger.debug(f'[KOOK] 消息为系统通知, 通知类型为: "{event.extra.type}"')
|
||||
logger.debug(f"[KOOK] 原始消息数据: {event.to_json()}")
|
||||
|
||||
async def run(self):
|
||||
"""主运行循环"""
|
||||
@@ -184,18 +192,26 @@ class KookPlatformAdapter(Platform):
|
||||
logger.info("[KOOK] 资源清理完成")
|
||||
|
||||
def _parse_kmarkdown_text_message(
|
||||
self, data: dict, self_id: str
|
||||
self, data: KookMessageEventData, self_id: str
|
||||
) -> tuple[list, str]:
|
||||
kmarkdown = data.get("extra", {}).get("kmarkdown", {})
|
||||
content = data.get("content") or ""
|
||||
raw_content = kmarkdown.get("raw_content") or content
|
||||
kmarkdown = data.extra.kmarkdown
|
||||
content = data.content or ""
|
||||
if kmarkdown is None:
|
||||
logger.error(
|
||||
f'[KOOK] 无法转换"{KookMessageType.KMARKDOWN.name}"消息, 消息中找不到kmarkdown字段'
|
||||
)
|
||||
logger.error(f"[KOOK] 原始消息内容: {data.to_json()}")
|
||||
return [], ""
|
||||
|
||||
raw_content = kmarkdown.raw_content or content
|
||||
if not isinstance(content, str):
|
||||
content = str(content)
|
||||
if not isinstance(raw_content, str):
|
||||
raw_content = str(raw_content)
|
||||
|
||||
# TODO 后面的pydantic类型替换,以后再来探索吧 :(
|
||||
mention_name_map: dict[str, str] = {}
|
||||
mention_part = kmarkdown.get("mention_part", [])
|
||||
mention_part = kmarkdown.mention_part
|
||||
if isinstance(mention_part, list):
|
||||
for item in mention_part:
|
||||
if not isinstance(item, dict):
|
||||
@@ -207,7 +223,7 @@ class KookPlatformAdapter(Platform):
|
||||
|
||||
components = []
|
||||
cursor = 0
|
||||
for match in re.finditer(r"\(met\)([^()]+)\(met\)", content):
|
||||
for match in KOOK_AT_SELECTOR_REGEX.finditer(content):
|
||||
if match.start() > cursor:
|
||||
plain_text = content[cursor : match.start()]
|
||||
if plain_text:
|
||||
@@ -254,77 +270,109 @@ class KookPlatformAdapter(Platform):
|
||||
|
||||
return components, message_str
|
||||
|
||||
def _parse_card_message(self, data: dict) -> tuple[list, str]:
|
||||
content = data.get("content", "[]")
|
||||
def _parse_card_message(self, data: KookMessageEventData) -> tuple[list, str]:
|
||||
content = data.content
|
||||
if not isinstance(content, str):
|
||||
content = str(content)
|
||||
card_list = json.loads(content)
|
||||
|
||||
card_list = KookCardMessageContainer.from_dict(json.loads(content))
|
||||
|
||||
text_parts: list[str] = []
|
||||
images: list[str] = []
|
||||
files: list[tuple[KookModuleType, str, str]] = []
|
||||
|
||||
for card in card_list:
|
||||
if not isinstance(card, dict):
|
||||
continue
|
||||
for module in card.get("modules", []):
|
||||
if not isinstance(module, dict):
|
||||
continue
|
||||
for module in card.modules:
|
||||
match module:
|
||||
case SectionModule():
|
||||
if content := self._handle_section_text(module):
|
||||
text_parts.append(content)
|
||||
|
||||
module_type = module.get("type")
|
||||
if module_type == "section":
|
||||
section_text = module.get("text", {}).get("content", "")
|
||||
if section_text:
|
||||
text_parts.append(str(section_text))
|
||||
continue
|
||||
case ContainerModule() | ImageGroupModule():
|
||||
urls = self._handle_image_group(module)
|
||||
images.extend(urls)
|
||||
text_parts.append(" [image]" * len(urls))
|
||||
|
||||
if module_type != "container":
|
||||
continue
|
||||
case HeaderModule():
|
||||
text_parts.append(module.text.content)
|
||||
|
||||
for element in module.get("elements", []):
|
||||
if not isinstance(element, dict):
|
||||
continue
|
||||
if element.get("type") != "image":
|
||||
continue
|
||||
case FileModule():
|
||||
files.append((module.type, module.title, module.src))
|
||||
text_parts.append(f" [{module.type.value}]")
|
||||
|
||||
image_src = element.get("src")
|
||||
if not isinstance(image_src, str):
|
||||
logger.warning(
|
||||
f'[KOOK] 处理卡片中的图片时发生错误,图片url "{image_src}" 应该为str类型, 而不是 "{type(image_src)}" '
|
||||
)
|
||||
continue
|
||||
if not image_src.startswith(("http://", "https://")):
|
||||
logger.warning(f"[KOOK] 屏蔽非http图片url: {image_src}")
|
||||
continue
|
||||
images.append(image_src)
|
||||
case _:
|
||||
logger.debug(f"[KOOK] 跳过或未处理模块: {module.type}")
|
||||
|
||||
text = "".join(text_parts)
|
||||
message = []
|
||||
|
||||
if text:
|
||||
for search in KOOK_AT_SELECTOR_REGEX.finditer(text):
|
||||
search_text = search.group(1).strip()
|
||||
if search_text == "all":
|
||||
message.append(AtAll())
|
||||
continue
|
||||
message.append(At(qq=search_text))
|
||||
text = text.replace(f"(met){search_text}(met)", "")
|
||||
|
||||
message.append(Plain(text=text))
|
||||
|
||||
for img_url in images:
|
||||
message.append(Image(file=img_url))
|
||||
for file in files:
|
||||
file_type = file[0]
|
||||
file_name = file[1]
|
||||
file_url = file[2]
|
||||
if file_type == KookModuleType.FILE:
|
||||
message.append(File(name=file_name, file=file_url))
|
||||
elif file_type == KookModuleType.VIDEO:
|
||||
message.append(Video(file=file_url))
|
||||
elif file_type == KookModuleType.AUDIO:
|
||||
message.append(Record(file=file_url))
|
||||
else:
|
||||
logger.warning(f"[KOOK] 跳过未知文件类型: {file_type.name}")
|
||||
|
||||
return message, text
|
||||
|
||||
async def convert_message(self, data: dict) -> AstrBotMessage:
|
||||
def _handle_section_text(self, module: SectionModule) -> str:
|
||||
"""专门处理 Section 里的文本提取"""
|
||||
if isinstance(module.text, (KmarkdownElement, PlainTextElement)):
|
||||
return module.text.content or ""
|
||||
return ""
|
||||
|
||||
def _handle_image_group(
|
||||
self, module: ContainerModule | ImageGroupModule
|
||||
) -> list[str]:
|
||||
"""专门处理图片组/容器里的合法 URL 提取"""
|
||||
valid_urls = []
|
||||
for el in module.elements:
|
||||
image_src = el.src
|
||||
if not el.src.startswith(("http://", "https://")):
|
||||
logger.warning(f"[KOOK] 屏蔽非http图片url: {image_src}")
|
||||
continue
|
||||
valid_urls.append(el.src)
|
||||
return valid_urls
|
||||
|
||||
async def convert_message(self, data: KookMessageEventData) -> AstrBotMessage:
|
||||
abm = AstrBotMessage()
|
||||
abm.raw_message = data
|
||||
abm.raw_message = data.to_dict()
|
||||
abm.self_id = self.client.bot_id
|
||||
|
||||
channel_type = data.get("channel_type")
|
||||
author_id = data.get("author_id", "unknown")
|
||||
channel_type = data.channel_type
|
||||
author_id = data.author_id
|
||||
# channel_type定义: https://developer.kookapp.cn/doc/event/event-introduction
|
||||
match channel_type:
|
||||
case "GROUP":
|
||||
session_id = data.get("target_id") or "unknown"
|
||||
case KookChannelType.GROUP:
|
||||
session_id = data.target_id or "unknown"
|
||||
abm.type = MessageType.GROUP_MESSAGE
|
||||
abm.group_id = session_id
|
||||
abm.session_id = session_id
|
||||
case "PERSON":
|
||||
case KookChannelType.PERSON:
|
||||
abm.type = MessageType.FRIEND_MESSAGE
|
||||
abm.group_id = ""
|
||||
abm.session_id = data.get("author_id", "unknown")
|
||||
case "BROADCAST":
|
||||
session_id = data.get("target_id") or "unknown"
|
||||
abm.session_id = data.author_id or "unknown"
|
||||
case KookChannelType.BROADCAST:
|
||||
session_id = data.target_id or "unknown"
|
||||
abm.type = MessageType.OTHER_MESSAGE
|
||||
abm.group_id = session_id
|
||||
abm.session_id = session_id
|
||||
@@ -333,28 +381,25 @@ class KookPlatformAdapter(Platform):
|
||||
|
||||
abm.sender = MessageMember(
|
||||
user_id=author_id,
|
||||
nickname=data.get("extra", {}).get("author", {}).get("username", ""),
|
||||
nickname=data.extra.author.username if data.extra.author else "unknown",
|
||||
)
|
||||
|
||||
abm.message_id = data.get("msg_id", "unknown")
|
||||
abm.message_id = data.msg_id or "unknown"
|
||||
|
||||
# 普通文本消息
|
||||
if data.get("type") == 9:
|
||||
message, message_str = self._parse_kmarkdown_text_message(
|
||||
data, str(abm.self_id)
|
||||
)
|
||||
if data.type == KookMessageType.KMARKDOWN:
|
||||
message, message_str = self._parse_kmarkdown_text_message(data, abm.self_id)
|
||||
abm.message = message
|
||||
abm.message_str = message_str
|
||||
# 卡片消息
|
||||
elif data.get("type") == 10:
|
||||
elif data.type == KookMessageType.CARD:
|
||||
try:
|
||||
abm.message, abm.message_str = self._parse_card_message(data)
|
||||
except Exception as exp:
|
||||
logger.error(f"[KOOK] 卡片消息解析失败: {exp}")
|
||||
logger.error(f"[KOOK] 原始消息内容: {data.to_json()}")
|
||||
abm.message_str = "[卡片消息解析失败]"
|
||||
abm.message = [Plain(text="[卡片消息解析失败]")]
|
||||
else:
|
||||
logger.warning(f'[KOOK] 不支持的kook消息类型: "{data.get("type")}"')
|
||||
logger.warning(f'[KOOK] 不支持的kook消息类型: "{data.type.name}"')
|
||||
abm.message_str = "[不支持的消息类型]"
|
||||
abm.message = [Plain(text="[不支持的消息类型]")]
|
||||
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import asyncio
|
||||
import base64
|
||||
import json
|
||||
import os
|
||||
import random
|
||||
import time
|
||||
@@ -9,13 +8,23 @@ from pathlib import Path
|
||||
|
||||
import aiofiles
|
||||
import aiohttp
|
||||
import pydantic
|
||||
import websockets
|
||||
|
||||
from astrbot import logger
|
||||
from astrbot.core.platform.message_type import MessageType
|
||||
|
||||
from .kook_config import KookConfig
|
||||
from .kook_types import KookApiPaths, KookMessageType
|
||||
from .kook_types import (
|
||||
KookApiPaths,
|
||||
KookGatewayIndexResponse,
|
||||
KookHelloEventData,
|
||||
KookMessageSignal,
|
||||
KookMessageType,
|
||||
KookResumeAckEventData,
|
||||
KookUserMeResponse,
|
||||
KookWebsocketEvent,
|
||||
)
|
||||
|
||||
|
||||
class KookClient:
|
||||
@@ -23,7 +32,8 @@ class KookClient:
|
||||
# 数据字段
|
||||
self.config = config
|
||||
self._bot_id = ""
|
||||
self._bot_name = ""
|
||||
self._bot_username = ""
|
||||
self._bot_nickname = ""
|
||||
|
||||
# 资源字段
|
||||
self._http_client = aiohttp.ClientSession(
|
||||
@@ -48,37 +58,50 @@ class KookClient:
|
||||
return self._bot_id
|
||||
|
||||
@property
|
||||
def bot_name(self):
|
||||
return self._bot_name
|
||||
def bot_nickname(self):
|
||||
return self._bot_nickname
|
||||
|
||||
async def get_bot_info(self) -> str:
|
||||
"""获取机器人账号ID"""
|
||||
@property
|
||||
def bot_username(self):
|
||||
return self._bot_username
|
||||
|
||||
async def get_bot_info(self) -> None:
|
||||
"""获取机器人账号信息"""
|
||||
url = KookApiPaths.USER_ME
|
||||
|
||||
try:
|
||||
async with self._http_client.get(url) as resp:
|
||||
if resp.status != 200:
|
||||
logger.error(f"[KOOK] 获取机器人账号ID失败,状态码: {resp.status}")
|
||||
return ""
|
||||
logger.error(
|
||||
f"[KOOK] 获取机器人账号信息失败,状态码: {resp.status} , {await resp.text()}"
|
||||
)
|
||||
return
|
||||
try:
|
||||
resp_content = KookUserMeResponse.from_dict(await resp.json())
|
||||
except pydantic.ValidationError as e:
|
||||
logger.error(
|
||||
f"[KOOK] 获取机器人账号信息失败, 响应数据格式错误: \n{e}"
|
||||
)
|
||||
logger.error(f"[KOOK] 响应内容: {await resp.text()}")
|
||||
return
|
||||
|
||||
data = await resp.json()
|
||||
if data.get("code") != 0:
|
||||
logger.error(f"[KOOK] 获取机器人账号ID失败: {data}")
|
||||
return ""
|
||||
if not resp_content.success():
|
||||
logger.error(
|
||||
f"[KOOK] 获取机器人账号信息失败: {resp_content.model_dump_json()}"
|
||||
)
|
||||
return
|
||||
|
||||
bot_id: str = data["data"]["id"]
|
||||
bot_id: str = resp_content.data.id
|
||||
self._bot_id = bot_id
|
||||
logger.info(f"[KOOK] 获取机器人账号ID成功: {bot_id}")
|
||||
bot_name: str = data["data"]["nickname"] or data["data"]["username"]
|
||||
self._bot_name = bot_name
|
||||
logger.info(f"[KOOK] 获取机器人名称成功: {self._bot_name}")
|
||||
self._bot_nickname = resp_content.data.nickname
|
||||
self._bot_username = resp_content.data.username
|
||||
logger.info(f"[KOOK] 获取机器人名称成功: {self._bot_nickname}")
|
||||
|
||||
return bot_id
|
||||
except Exception as e:
|
||||
logger.error(f"[KOOK] 获取机器人账号ID异常: {e}")
|
||||
return ""
|
||||
logger.error(f"[KOOK] 获取机器人账号信息异常: {e}")
|
||||
|
||||
async def get_gateway_url(self, resume=False, sn=0, session_id=None):
|
||||
async def get_gateway_url(self, resume=False, sn=0, session_id=None) -> str | None:
|
||||
"""获取网关连接地址"""
|
||||
url = KookApiPaths.GATEWAY_INDEX
|
||||
|
||||
@@ -96,14 +119,20 @@ class KookClient:
|
||||
logger.error(f"[KOOK] 获取gateway失败,状态码: {resp.status}")
|
||||
return None
|
||||
|
||||
data = await resp.json()
|
||||
if data.get("code") != 0:
|
||||
logger.error(f"[KOOK] 获取gateway失败: {data}")
|
||||
resp_content = KookGatewayIndexResponse.from_dict(await resp.json())
|
||||
if not resp_content.success():
|
||||
logger.error(f"[KOOK] 获取gateway失败: {resp_content}")
|
||||
return None
|
||||
|
||||
gateway_url: str = data["data"]["url"]
|
||||
gateway_url: str = resp_content.data.url
|
||||
logger.info(f"[KOOK] 获取gateway成功: {gateway_url.split('?')[0]}")
|
||||
return gateway_url
|
||||
|
||||
except pydantic.ValidationError as e:
|
||||
logger.error(f"[KOOK] 获取gateway失败, 响应数据格式错误: \n{e}")
|
||||
logger.error(f"[KOOK] 原始响应内容: {await resp.text()}")
|
||||
return None
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[KOOK] 获取gateway异常: {e}")
|
||||
return None
|
||||
@@ -156,7 +185,11 @@ class KookClient:
|
||||
try:
|
||||
while self.running:
|
||||
try:
|
||||
msg = await asyncio.wait_for(self.ws.recv(), timeout=10) # type: ignore
|
||||
if self.ws is None:
|
||||
logger.error("[KOOK] WebSocket 对象丢失,结束监听流程。")
|
||||
break
|
||||
|
||||
msg = await asyncio.wait_for(self.ws.recv(), timeout=10)
|
||||
|
||||
if isinstance(msg, bytes):
|
||||
try:
|
||||
@@ -166,10 +199,15 @@ class KookClient:
|
||||
continue
|
||||
msg = msg.decode("utf-8")
|
||||
|
||||
data = json.loads(msg)
|
||||
event = KookWebsocketEvent.from_json(msg)
|
||||
|
||||
# 处理不同类型的信令
|
||||
await self._handle_signal(data)
|
||||
await self._handle_signal(event)
|
||||
|
||||
except pydantic.ValidationError as e:
|
||||
logger.error(f"[KOOK] 解析WebSocket事件数据格式失败: \n{e}")
|
||||
logger.error(f"[KOOK] 原始响应内容: {msg}")
|
||||
continue
|
||||
|
||||
except asyncio.TimeoutError:
|
||||
# 超时检查,继续循环
|
||||
@@ -187,38 +225,41 @@ class KookClient:
|
||||
self.running = False
|
||||
self._stop_event.set()
|
||||
|
||||
async def _handle_signal(self, data):
|
||||
async def _handle_signal(self, event: KookWebsocketEvent):
|
||||
"""处理不同类型的信令"""
|
||||
signal_type = data.get("s")
|
||||
data = event.data
|
||||
|
||||
if signal_type == 0: # 事件消息
|
||||
# 更新消息序号
|
||||
if "sn" in data:
|
||||
self.last_sn = data["sn"]
|
||||
await self.event_callback(data)
|
||||
match event.signal:
|
||||
case KookMessageSignal.MESSAGE:
|
||||
if event.sn is not None:
|
||||
self.last_sn = event.sn
|
||||
await self.event_callback(data)
|
||||
|
||||
elif signal_type == 1: # HELLO握手
|
||||
await self._handle_hello(data)
|
||||
case KookMessageSignal.HELLO:
|
||||
assert isinstance(data, KookHelloEventData)
|
||||
await self._handle_hello(data)
|
||||
|
||||
elif signal_type == 3: # PONG心跳响应
|
||||
await self._handle_pong(data)
|
||||
case KookMessageSignal.RESUME_ACK:
|
||||
assert isinstance(data, KookResumeAckEventData)
|
||||
await self._handle_resume_ack(data)
|
||||
|
||||
elif signal_type == 5: # RECONNECT重连指令
|
||||
await self._handle_reconnect(data)
|
||||
case KookMessageSignal.PONG:
|
||||
await self._handle_pong()
|
||||
|
||||
elif signal_type == 6: # RESUME ACK
|
||||
await self._handle_resume_ack(data)
|
||||
case KookMessageSignal.RECONNECT:
|
||||
await self._handle_reconnect()
|
||||
|
||||
else:
|
||||
logger.debug(f"[KOOK] 未处理的信令类型: {signal_type}")
|
||||
case _:
|
||||
logger.debug(
|
||||
f"[KOOK] 未处理的信令类型: {event.signal.name}({event.signal.value})"
|
||||
)
|
||||
|
||||
async def _handle_hello(self, data):
|
||||
async def _handle_hello(self, data: KookHelloEventData):
|
||||
"""处理HELLO握手"""
|
||||
hello_data = data.get("d", {})
|
||||
code = hello_data.get("code", 0)
|
||||
code = data.code
|
||||
|
||||
if code == 0:
|
||||
self.session_id = hello_data.get("session_id")
|
||||
self.session_id = data.session_id
|
||||
logger.info(f"[KOOK] 握手成功,session_id: {self.session_id}")
|
||||
# TODO 重置重连延迟
|
||||
# self.reconnect_delay = 1
|
||||
@@ -228,12 +269,12 @@ class KookClient:
|
||||
logger.error("[KOOK] Token已过期,需要重新获取")
|
||||
self.running = False
|
||||
|
||||
async def _handle_pong(self, data):
|
||||
async def _handle_pong(self):
|
||||
"""处理PONG心跳响应"""
|
||||
self.last_heartbeat_time = time.time()
|
||||
self.heartbeat_failed_count = 0
|
||||
|
||||
async def _handle_reconnect(self, data):
|
||||
async def _handle_reconnect(self):
|
||||
"""处理重连指令"""
|
||||
logger.warning("[KOOK] 收到重连指令")
|
||||
# 清空本地状态
|
||||
@@ -241,10 +282,9 @@ class KookClient:
|
||||
self.session_id = None
|
||||
self.running = False
|
||||
|
||||
async def _handle_resume_ack(self, data):
|
||||
async def _handle_resume_ack(self, data: KookResumeAckEventData):
|
||||
"""处理RESUME确认"""
|
||||
resume_data = data.get("d", {})
|
||||
self.session_id = resume_data.get("session_id")
|
||||
self.session_id = data.session_id
|
||||
logger.info(f"[KOOK] Resume成功,session_id: {self.session_id}")
|
||||
|
||||
async def _heartbeat_loop(self):
|
||||
@@ -292,9 +332,16 @@ class KookClient:
|
||||
|
||||
async def _send_ping(self):
|
||||
"""发送心跳PING"""
|
||||
if self.ws is None:
|
||||
logger.warning("[KOOK] 尚未连接kook WebSocket服务器, 跳过发送心跳包流程")
|
||||
return
|
||||
try:
|
||||
ping_data = {"s": 2, "sn": self.last_sn}
|
||||
await self.ws.send(json.dumps(ping_data)) # type: ignore
|
||||
ping_data = KookWebsocketEvent(
|
||||
signal=KookMessageSignal.PING,
|
||||
data=None,
|
||||
sn=self.last_sn,
|
||||
)
|
||||
await self.ws.send(ping_data.to_json())
|
||||
except Exception as e:
|
||||
logger.error(f"[KOOK] 发送心跳失败: {e}")
|
||||
|
||||
|
||||
@@ -9,7 +9,6 @@ class KookConfig:
|
||||
|
||||
# 基础配置
|
||||
token: str
|
||||
bot_nickname: str = ""
|
||||
enable: bool = False
|
||||
id: str = "kook"
|
||||
|
||||
@@ -41,7 +40,6 @@ class KookConfig:
|
||||
# id=config_dict.get("id", "kook"),
|
||||
enable=config_dict.get("enable", False),
|
||||
token=config_dict.get("kook_bot_token", ""),
|
||||
bot_nickname=config_dict.get("kook_bot_nickname", ""),
|
||||
reconnect_delay=config_dict.get(
|
||||
"kook_reconnect_delay",
|
||||
KookConfig.reconnect_delay,
|
||||
|
||||
@@ -27,6 +27,7 @@ from .kook_types import (
|
||||
KookCardMessage,
|
||||
KookCardMessageContainer,
|
||||
KookMessageType,
|
||||
KookModuleType,
|
||||
OrderMessage,
|
||||
)
|
||||
|
||||
@@ -111,7 +112,7 @@ class KookEvent(AstrMessageEvent):
|
||||
KookCardMessage(
|
||||
modules=[
|
||||
FileModule(
|
||||
type="audio",
|
||||
type=KookModuleType.AUDIO,
|
||||
title=title,
|
||||
src=url,
|
||||
)
|
||||
@@ -182,7 +183,7 @@ class KookEvent(AstrMessageEvent):
|
||||
if item.reply_id:
|
||||
reply_id = item.reply_id
|
||||
if not item.text:
|
||||
logger.debug(f'[Kook] 跳过空消息,类型为"{item.type}"')
|
||||
logger.debug(f'[Kook] 跳过空消息,类型为"{item.type.name}"')
|
||||
continue
|
||||
try:
|
||||
await self.client.send_text(
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
import json
|
||||
from dataclasses import field
|
||||
from enum import IntEnum
|
||||
from typing import Literal
|
||||
from enum import IntEnum, StrEnum
|
||||
from typing import Annotated, Any, Literal
|
||||
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
from pydantic.dataclasses import dataclass
|
||||
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
||||
|
||||
|
||||
class KookApiPaths:
|
||||
@@ -25,8 +23,9 @@ class KookApiPaths:
|
||||
DIRECT_MESSAGE_CREATE = f"{BASE_URL}{API_VERSION_PATH}/direct-message/create"
|
||||
|
||||
|
||||
# 定义参见kook事件结构文档: https://developer.kookapp.cn/doc/event/event-introduction
|
||||
class KookMessageType(IntEnum):
|
||||
"""定义参见kook事件结构文档: https://developer.kookapp.cn/doc/event/event-introduction"""
|
||||
|
||||
TEXT = 1
|
||||
IMAGE = 2
|
||||
VIDEO = 3
|
||||
@@ -37,6 +36,26 @@ class KookMessageType(IntEnum):
|
||||
SYSTEM = 255
|
||||
|
||||
|
||||
class KookModuleType(StrEnum):
|
||||
PLAIN_TEXT = "plain-text"
|
||||
KMARKDOWN = "kmarkdown"
|
||||
IMAGE = "image"
|
||||
BUTTON = "button"
|
||||
HEADER = "header"
|
||||
SECTION = "section"
|
||||
IMAGE_GROUP = "image-group"
|
||||
CONTAINER = "container"
|
||||
ACTION_GROUP = "action-group"
|
||||
CONTEXT = "context"
|
||||
DIVIDER = "divider"
|
||||
FILE = "file"
|
||||
AUDIO = "audio"
|
||||
VIDEO = "video"
|
||||
COUNTDOWN = "countdown"
|
||||
INVITE = "invite"
|
||||
CARD = "card"
|
||||
|
||||
|
||||
ThemeType = Literal[
|
||||
"primary", "success", "danger", "warning", "info", "secondary", "none", "invisible"
|
||||
]
|
||||
@@ -48,43 +67,81 @@ SectionMode = Literal["left", "right"]
|
||||
CountdownMode = Literal["day", "hour", "second"]
|
||||
|
||||
|
||||
class KookCardColor(str):
|
||||
"""16 进制色值"""
|
||||
class KookBaseDataClass(BaseModel):
|
||||
model_config = ConfigDict(
|
||||
extra="allow",
|
||||
arbitrary_types_allowed=True,
|
||||
populate_by_name=True,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, raw_data: dict):
|
||||
return cls.model_validate(raw_data)
|
||||
|
||||
@classmethod
|
||||
def from_json(cls, raw_data: str | bytes | bytearray):
|
||||
return cls.model_validate_json(raw_data)
|
||||
|
||||
def to_dict(
|
||||
self,
|
||||
mode: Literal["json", "python"] | str = "python",
|
||||
by_alias=True,
|
||||
exclude_none=True,
|
||||
exclude_unset=False,
|
||||
) -> dict:
|
||||
return self.model_dump(
|
||||
by_alias=by_alias,
|
||||
exclude_none=exclude_none,
|
||||
mode=mode,
|
||||
exclude_unset=exclude_unset,
|
||||
)
|
||||
|
||||
def to_json(
|
||||
self,
|
||||
indent: int | None = None,
|
||||
ensure_ascii=False,
|
||||
by_alias=True,
|
||||
exclude_none=True,
|
||||
exclude_unset=False,
|
||||
) -> str:
|
||||
return self.model_dump_json(
|
||||
indent=indent,
|
||||
ensure_ascii=ensure_ascii,
|
||||
by_alias=by_alias,
|
||||
exclude_none=exclude_none,
|
||||
exclude_unset=exclude_unset,
|
||||
)
|
||||
|
||||
|
||||
class KookCardModelBase:
|
||||
class KookCardModelBase(KookBaseDataClass):
|
||||
"""卡片模块基类"""
|
||||
|
||||
type: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class PlainTextElement(KookCardModelBase):
|
||||
content: str
|
||||
type: str = "plain-text"
|
||||
type: Literal[KookModuleType.PLAIN_TEXT] = KookModuleType.PLAIN_TEXT
|
||||
emoji: bool = True
|
||||
|
||||
|
||||
@dataclass
|
||||
class KmarkdownElement(KookCardModelBase):
|
||||
content: str
|
||||
type: str = "kmarkdown"
|
||||
type: Literal[KookModuleType.KMARKDOWN] = KookModuleType.KMARKDOWN
|
||||
|
||||
|
||||
@dataclass
|
||||
class ImageElement(KookCardModelBase):
|
||||
src: str
|
||||
type: str = "image"
|
||||
type: Literal[KookModuleType.IMAGE] = KookModuleType.IMAGE
|
||||
alt: str = ""
|
||||
size: SizeType = "lg"
|
||||
circle: bool = False
|
||||
fallbackUrl: str | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class ButtonElement(KookCardModelBase):
|
||||
text: str
|
||||
type: str = "button"
|
||||
type: Literal[KookModuleType.BUTTON] = KookModuleType.BUTTON
|
||||
theme: ThemeType = "primary"
|
||||
value: str = ""
|
||||
"""当为 link 时,会跳转到 value 代表的链接;
|
||||
@@ -96,93 +153,88 @@ class ButtonElement(KookCardModelBase):
|
||||
AnyElement = PlainTextElement | KmarkdownElement | ImageElement | ButtonElement | str
|
||||
|
||||
|
||||
@dataclass
|
||||
class ParagraphStructure(KookCardModelBase):
|
||||
fields: list[PlainTextElement | KmarkdownElement]
|
||||
type: str = "paragraph"
|
||||
type: Literal["paragraph"] = "paragraph"
|
||||
cols: int = 1
|
||||
"""范围是 1-3 , 移动端忽略此参数"""
|
||||
|
||||
|
||||
@dataclass
|
||||
class HeaderModule(KookCardModelBase):
|
||||
text: PlainTextElement
|
||||
type: str = "header"
|
||||
type: Literal[KookModuleType.HEADER] = KookModuleType.HEADER
|
||||
|
||||
|
||||
@dataclass
|
||||
class SectionModule(KookCardModelBase):
|
||||
text: PlainTextElement | KmarkdownElement | ParagraphStructure
|
||||
type: str = "section"
|
||||
type: Literal[KookModuleType.SECTION] = KookModuleType.SECTION
|
||||
mode: SectionMode = "left"
|
||||
accessory: ImageElement | ButtonElement | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class ImageGroupModule(KookCardModelBase):
|
||||
"""1 到多张图片的组合"""
|
||||
|
||||
elements: list[ImageElement]
|
||||
type: str = "image-group"
|
||||
type: Literal[KookModuleType.IMAGE_GROUP] = KookModuleType.IMAGE_GROUP
|
||||
|
||||
|
||||
@dataclass
|
||||
class ContainerModule(KookCardModelBase):
|
||||
"""1 到多张图片的组合,与图片组模块(ImageGroupModule)不同,图片并不会裁切为正方形。多张图片会纵向排列。"""
|
||||
|
||||
elements: list[ImageElement]
|
||||
type: str = "container"
|
||||
type: Literal[KookModuleType.CONTAINER] = KookModuleType.CONTAINER
|
||||
|
||||
|
||||
@dataclass
|
||||
class ActionGroupModule(KookCardModelBase):
|
||||
"""用来放按钮的模块"""
|
||||
|
||||
elements: list[ButtonElement]
|
||||
type: str = "action-group"
|
||||
type: Literal[KookModuleType.ACTION_GROUP] = KookModuleType.ACTION_GROUP
|
||||
|
||||
|
||||
@dataclass
|
||||
class ContextModule(KookCardModelBase):
|
||||
elements: list[PlainTextElement | KmarkdownElement | ImageElement]
|
||||
"""最多包含10个元素"""
|
||||
type: str = "context"
|
||||
type: Literal[KookModuleType.CONTEXT] = KookModuleType.CONTEXT
|
||||
|
||||
|
||||
@dataclass
|
||||
class DividerModule(KookCardModelBase):
|
||||
type: str = "divider"
|
||||
"""展示分割线用的"""
|
||||
|
||||
type: Literal[KookModuleType.DIVIDER] = KookModuleType.DIVIDER
|
||||
|
||||
|
||||
@dataclass
|
||||
class FileModule(KookCardModelBase):
|
||||
src: str
|
||||
title: str = ""
|
||||
type: Literal["file", "audio", "video"] = "file"
|
||||
type: Literal[KookModuleType.FILE, KookModuleType.AUDIO, KookModuleType.VIDEO] = (
|
||||
KookModuleType.FILE
|
||||
)
|
||||
cover: str | None = None
|
||||
"""cover 仅音频有效, 是音频的封面图"""
|
||||
|
||||
|
||||
@dataclass
|
||||
class CountdownModule(KookCardModelBase):
|
||||
"""startTime 和 endTime 为毫秒时间戳,startTime 和 endTime 不能小于服务器当前时间戳。"""
|
||||
|
||||
endTime: int
|
||||
"""毫秒时间戳"""
|
||||
type: str = "countdown"
|
||||
type: Literal[KookModuleType.COUNTDOWN] = KookModuleType.COUNTDOWN
|
||||
startTime: int | None = None
|
||||
"""毫秒时间戳, 仅当mode为second才有这个字段"""
|
||||
mode: CountdownMode = "day"
|
||||
"""mode 主要是倒计时的样式"""
|
||||
|
||||
|
||||
@dataclass
|
||||
class InviteModule(KookCardModelBase):
|
||||
code: str
|
||||
"""邀请链接或者邀请码"""
|
||||
type: str = "invite"
|
||||
type: Literal[KookModuleType.INVITE] = KookModuleType.INVITE
|
||||
|
||||
|
||||
# 所有模块的联合类型
|
||||
AnyModule = (
|
||||
AnyModule = Annotated[
|
||||
HeaderModule
|
||||
| SectionModule
|
||||
| ImageGroupModule
|
||||
@@ -192,34 +244,29 @@ AnyModule = (
|
||||
| DividerModule
|
||||
| FileModule
|
||||
| CountdownModule
|
||||
| InviteModule
|
||||
)
|
||||
| InviteModule,
|
||||
Field(discriminator="type"),
|
||||
]
|
||||
|
||||
|
||||
class KookCardMessage(BaseModel):
|
||||
class KookCardMessage(KookBaseDataClass):
|
||||
"""卡片定义文档详见 : https://developer.kookapp.cn/doc/cardmessage
|
||||
此类型不能直接to_json后发送,因为kook要求卡片容器json顶层必须是**列表**
|
||||
若要发送卡片消息,请使用KookCardMessageContainer
|
||||
"""
|
||||
|
||||
model_config = ConfigDict(arbitrary_types_allowed=True)
|
||||
type: str = "card"
|
||||
type: Literal[KookModuleType.CARD] = KookModuleType.CARD
|
||||
theme: ThemeType | None = None
|
||||
size: SizeType | None = None
|
||||
color: KookCardColor | None = None
|
||||
modules: list[AnyModule] = field(default_factory=list)
|
||||
color: str | None = None
|
||||
"""16 进制色值"""
|
||||
modules: list[AnyModule] = Field(default_factory=list)
|
||||
"""单个 card 模块数量不限制,但是一条消息中所有卡片的模块数量之和最多是 50"""
|
||||
|
||||
def add_module(self, module: AnyModule):
|
||||
self.modules.append(module)
|
||||
|
||||
def to_dict(self, exclude_none: bool = True):
|
||||
"""exclude_none:去掉值为 None 字段,保留结构"""
|
||||
return self.model_dump(exclude_none=exclude_none)
|
||||
|
||||
def to_json(self, indent: int | None = None, ensure_ascii: bool = True):
|
||||
return json.dumps(self.to_dict(), indent=indent, ensure_ascii=ensure_ascii)
|
||||
|
||||
|
||||
class KookCardMessageContainer(list[KookCardMessage]):
|
||||
"""卡片消息容器(列表),此类型可以直接to_json后发送出去"""
|
||||
@@ -232,10 +279,227 @@ class KookCardMessageContainer(list[KookCardMessage]):
|
||||
[i.to_dict() for i in self], indent=indent, ensure_ascii=ensure_ascii
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, raw_data: list[dict[str, Any]]):
|
||||
return cls(KookCardMessage.from_dict(item) for item in raw_data)
|
||||
|
||||
@dataclass
|
||||
class OrderMessage:
|
||||
|
||||
class OrderMessage(BaseModel):
|
||||
index: int
|
||||
text: str
|
||||
type: KookMessageType
|
||||
reply_id: str | int = ""
|
||||
|
||||
|
||||
class KookMessageSignal(IntEnum):
|
||||
"""KOOK WebSocket 信令类型
|
||||
ws文档: https://developer.kookapp.cn/doc/websocket""" # noqa: W291
|
||||
|
||||
MESSAGE = 0
|
||||
"""server->client 消息(s包含聊天和通知消息)"""
|
||||
HELLO = 1
|
||||
"""server->client 客户端连接 ws 时, 服务端返回握手结果"""
|
||||
PING = 2
|
||||
"""client->server 心跳,ping"""
|
||||
PONG = 3
|
||||
"""server->client 心跳,pong"""
|
||||
RESUME = 4
|
||||
"""client->server resume, 恢复会话"""
|
||||
RECONNECT = 5
|
||||
"""server->client reconnect, 要求客户端断开当前连接重新连接"""
|
||||
RESUME_ACK = 6
|
||||
"""server->client resume ack"""
|
||||
|
||||
|
||||
class KookChannelType(StrEnum):
|
||||
GROUP = "GROUP"
|
||||
PERSON = "PERSON"
|
||||
BROADCAST = "BROADCAST"
|
||||
|
||||
|
||||
class KookAuthor(KookBaseDataClass):
|
||||
id: str
|
||||
username: str
|
||||
identify_num: str
|
||||
nickname: str
|
||||
bot: bool
|
||||
online: bool
|
||||
avatar: str | None = None
|
||||
vip_avatar: str | None = None
|
||||
status: int
|
||||
roles: list[int] = Field(default_factory=list)
|
||||
|
||||
|
||||
class KookKMarkdown(KookBaseDataClass):
|
||||
raw_content: str
|
||||
mention_part: list[Any] = Field(default_factory=list)
|
||||
mention_role_part: list[Any] = Field(default_factory=list)
|
||||
|
||||
|
||||
class KookExtra(KookBaseDataClass):
|
||||
type: int | str
|
||||
code: str | None = None
|
||||
body: dict[str, Any] | None = None
|
||||
author: KookAuthor | None = None
|
||||
kmarkdown: KookKMarkdown | None = None
|
||||
last_msg_content: str | None = None
|
||||
mention: list[str] = Field(default_factory=list)
|
||||
mention_all: bool = False
|
||||
mention_here: bool = False
|
||||
|
||||
|
||||
class KookMessageEventData(KookBaseDataClass):
|
||||
signal: Literal[KookMessageSignal.MESSAGE] = Field(
|
||||
KookMessageSignal.MESSAGE, exclude=True
|
||||
)
|
||||
"""only for type hint"""
|
||||
|
||||
channel_type: KookChannelType
|
||||
type: KookMessageType
|
||||
target_id: str
|
||||
author_id: str
|
||||
content: str | dict[str, Any]
|
||||
msg_id: str
|
||||
msg_timestamp: int
|
||||
nonce: str
|
||||
from_type: int
|
||||
extra: KookExtra
|
||||
|
||||
|
||||
class KookHelloEventData(KookBaseDataClass):
|
||||
signal: Literal[KookMessageSignal.HELLO] = Field(
|
||||
KookMessageSignal.HELLO, exclude=True
|
||||
)
|
||||
"""only for type hint"""
|
||||
|
||||
code: int
|
||||
session_id: str
|
||||
|
||||
|
||||
class KookPingEventData(KookBaseDataClass):
|
||||
signal: Literal[KookMessageSignal.PING] = Field(
|
||||
KookMessageSignal.PING, exclude=True
|
||||
)
|
||||
"""only for type hint"""
|
||||
|
||||
|
||||
class KookPongEventData(KookBaseDataClass):
|
||||
signal: Literal[KookMessageSignal.PONG] = Field(
|
||||
KookMessageSignal.PONG, exclude=True
|
||||
)
|
||||
"""only for type hint"""
|
||||
|
||||
|
||||
class KookResumeEventData(KookBaseDataClass):
|
||||
signal: Literal[KookMessageSignal.RESUME] = Field(
|
||||
KookMessageSignal.RESUME, exclude=True
|
||||
)
|
||||
"""only for type hint"""
|
||||
|
||||
|
||||
class KookReconnectEventData(KookBaseDataClass):
|
||||
signal: Literal[KookMessageSignal.RECONNECT] = Field(
|
||||
KookMessageSignal.RECONNECT, exclude=True
|
||||
)
|
||||
"""only for type hint"""
|
||||
|
||||
code: int
|
||||
err: str
|
||||
|
||||
|
||||
class KookResumeAckEventData(KookBaseDataClass):
|
||||
signal: Literal[KookMessageSignal.RESUME_ACK] = Field(
|
||||
KookMessageSignal.RESUME_ACK, exclude=True
|
||||
)
|
||||
"""only for type hint"""
|
||||
|
||||
session_id: str
|
||||
|
||||
|
||||
class KookWebsocketEvent(KookBaseDataClass):
|
||||
"""KOOK WebSocket 原始推送结构"""
|
||||
|
||||
signal: KookMessageSignal = Field(
|
||||
..., validation_alias="s", serialization_alias="s"
|
||||
)
|
||||
"""信令类型"""
|
||||
data: Annotated[
|
||||
KookMessageEventData
|
||||
| KookHelloEventData
|
||||
| KookPingEventData
|
||||
| KookPongEventData
|
||||
| KookResumeEventData
|
||||
| KookReconnectEventData
|
||||
| KookResumeAckEventData
|
||||
| None,
|
||||
Field(discriminator="signal"),
|
||||
] = Field(None, validation_alias="d", serialization_alias="d")
|
||||
"""数据事件主体,对应原字段是'd'"""
|
||||
sn: int | None = None
|
||||
"""消息序号 , 用来确定消息顺序和ws重连时使用
|
||||
详见ws连接流程文档: https://developer.kookapp.cn/doc/websocket#%E8%BF%9E%E6%8E%A5%E6%B5%81%E7%A8%8B""" # noqa: W291
|
||||
|
||||
@model_validator(mode="before")
|
||||
@classmethod
|
||||
def _inject_signal_into_data(cls, data: Any) -> Any:
|
||||
"""在解析前,把外层的 s 同步到内层的 d 中,供 discriminator 使用"""
|
||||
if isinstance(data, dict):
|
||||
s_value = data.get("s")
|
||||
d_value = data.get("d")
|
||||
if s_value is not None and isinstance(d_value, dict):
|
||||
d_value["signal"] = s_value
|
||||
return data
|
||||
|
||||
|
||||
class KookUserTag(KookBaseDataClass):
|
||||
color: str
|
||||
bg_color: str
|
||||
text: str
|
||||
|
||||
|
||||
class KookApiResponseBase(KookBaseDataClass):
|
||||
code: int
|
||||
message: str
|
||||
data: Any
|
||||
|
||||
def success(self) -> bool:
|
||||
return self.code == 0
|
||||
|
||||
|
||||
class KookUserMeData(KookBaseDataClass):
|
||||
"""USER_ME 接口返回的 'data' 字段主体"""
|
||||
|
||||
id: str
|
||||
username: str
|
||||
identify_num: str
|
||||
nickname: str
|
||||
bot: bool
|
||||
online: bool
|
||||
status: int
|
||||
bot_status: int
|
||||
avatar: str
|
||||
vip_avatar: str | None = None
|
||||
banner: str | None = None
|
||||
roles: list[Any] = Field(default_factory=list)
|
||||
is_vip: bool
|
||||
vip_amp: bool
|
||||
wealth_level: int
|
||||
mobile_verified: bool
|
||||
client_id: str
|
||||
tag_info: KookUserTag | None = None
|
||||
|
||||
|
||||
class KookUserMeResponse(KookApiResponseBase):
|
||||
"""USER_ME 完整响应结构"""
|
||||
|
||||
data: KookUserMeData
|
||||
|
||||
|
||||
class KookGatewayIndexData(KookBaseDataClass):
|
||||
url: str
|
||||
|
||||
|
||||
class KookGatewayIndexResponse(KookApiResponseBase):
|
||||
"""USER_ME 完整响应结构"""
|
||||
|
||||
data: KookGatewayIndexData
|
||||
|
||||
@@ -18,7 +18,7 @@ from botpy.types.message import MarkdownPayload, Media
|
||||
|
||||
from astrbot.api import logger
|
||||
from astrbot.api.event import AstrMessageEvent, MessageChain
|
||||
from astrbot.api.message_components import Image, Plain, Record
|
||||
from astrbot.api.message_components import File, Image, Plain, Record, Video
|
||||
from astrbot.api.platform import AstrBotMessage, PlatformMetadata
|
||||
from astrbot.core.utils.astrbot_path import get_astrbot_temp_path
|
||||
from astrbot.core.utils.io import download_image_by_url, file_to_base64
|
||||
@@ -47,6 +47,11 @@ _patch_qq_botpy_formdata()
|
||||
|
||||
class QQOfficialMessageEvent(AstrMessageEvent):
|
||||
MARKDOWN_NOT_ALLOWED_ERROR = "不允许发送原生 markdown"
|
||||
IMAGE_FILE_TYPE = 1
|
||||
VIDEO_FILE_TYPE = 2
|
||||
VOICE_FILE_TYPE = 3
|
||||
FILE_FILE_TYPE = 4
|
||||
STREAM_MARKDOWN_NEWLINE_ERROR = "流式消息md分片需要\\n结束"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
@@ -65,35 +70,71 @@ class QQOfficialMessageEvent(AstrMessageEvent):
|
||||
await self._post_send()
|
||||
|
||||
async def send_streaming(self, generator, use_fallback: bool = False):
|
||||
"""流式输出仅支持消息列表私聊"""
|
||||
"""流式输出仅支持消息列表私聊(C2C),其他消息源退化为普通发送"""
|
||||
# 先标记事件层“已执行发送操作”,避免异常路径遗漏
|
||||
await super().send_streaming(generator, use_fallback)
|
||||
# QQ C2C 流式协议:开始/中间分片使用 state=1,结束分片使用 state=10
|
||||
stream_payload = {"state": 1, "id": None, "index": 0, "reset": False}
|
||||
last_edit_time = 0 # 上次编辑消息的时间
|
||||
throttle_interval = 1 # 编辑消息的间隔时间 (秒)
|
||||
last_edit_time = 0 # 上次发送分片的时间
|
||||
throttle_interval = 1 # 分片间最短间隔 (秒)
|
||||
ret = None
|
||||
source = (
|
||||
self.message_obj.raw_message
|
||||
) # 提前获取,避免 generator 为空时 NameError
|
||||
try:
|
||||
async for chain in generator:
|
||||
source = self.message_obj.raw_message
|
||||
|
||||
if not isinstance(source, botpy.message.C2CMessage):
|
||||
# 非 C2C 场景:直接累积,最后统一发
|
||||
if not self.send_buffer:
|
||||
self.send_buffer = chain
|
||||
else:
|
||||
self.send_buffer.chain.extend(chain.chain)
|
||||
continue
|
||||
|
||||
# ---- C2C 流式场景 ----
|
||||
|
||||
# tool_call break 信号:工具开始执行,先把已有 buffer 以 state=10 结束当前流式段
|
||||
if chain.type == "break":
|
||||
if self.send_buffer:
|
||||
stream_payload["state"] = 10
|
||||
ret = await self._post_send(stream=stream_payload)
|
||||
ret_id = self._extract_response_message_id(ret)
|
||||
if ret_id is not None:
|
||||
stream_payload["id"] = ret_id
|
||||
# 重置 stream_payload,为下一段流式做准备
|
||||
stream_payload = {
|
||||
"state": 1,
|
||||
"id": None,
|
||||
"index": 0,
|
||||
"reset": False,
|
||||
}
|
||||
last_edit_time = 0
|
||||
continue
|
||||
|
||||
# 累积内容
|
||||
if not self.send_buffer:
|
||||
self.send_buffer = chain
|
||||
else:
|
||||
self.send_buffer.chain.extend(chain.chain)
|
||||
|
||||
if isinstance(source, botpy.message.C2CMessage):
|
||||
# 真流式传输
|
||||
current_time = asyncio.get_running_loop().time()
|
||||
time_since_last_edit = current_time - last_edit_time
|
||||
|
||||
if time_since_last_edit >= throttle_interval:
|
||||
ret = cast(
|
||||
message.Message,
|
||||
await self._post_send(stream=stream_payload),
|
||||
)
|
||||
stream_payload["index"] += 1
|
||||
stream_payload["id"] = ret["id"]
|
||||
last_edit_time = asyncio.get_running_loop().time()
|
||||
# 节流:按时间间隔发送中间分片
|
||||
current_time = asyncio.get_running_loop().time()
|
||||
if current_time - last_edit_time >= throttle_interval:
|
||||
ret = cast(
|
||||
message.Message,
|
||||
await self._post_send(stream=stream_payload),
|
||||
)
|
||||
stream_payload["index"] += 1
|
||||
ret_id = self._extract_response_message_id(ret)
|
||||
if ret_id is not None:
|
||||
stream_payload["id"] = ret_id
|
||||
last_edit_time = asyncio.get_running_loop().time()
|
||||
self.send_buffer = None # 清空已发送的分片,避免下次重复发送旧内容
|
||||
|
||||
if isinstance(source, botpy.message.C2CMessage):
|
||||
# 结束流式对话,并且传输 buffer 中剩余的消息
|
||||
# 结束流式对话,发送 buffer 中剩余内容
|
||||
stream_payload["state"] = 10
|
||||
ret = await self._post_send(stream=stream_payload)
|
||||
else:
|
||||
@@ -101,9 +142,22 @@ class QQOfficialMessageEvent(AstrMessageEvent):
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"发送流式消息时出错: {e}", exc_info=True)
|
||||
# 避免累计内容在异常后被整包重复发送:仅清理缓存,不做非流式整包兜底
|
||||
# 如需兜底,应该只发送未发送 delta(后续可继续优化)
|
||||
self.send_buffer = None
|
||||
|
||||
return await super().send_streaming(generator, use_fallback)
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _extract_response_message_id(ret) -> str | None:
|
||||
"""兼容 qq-botpy 返回 Message 对象或 dict 两种形态。"""
|
||||
if ret is None:
|
||||
return None
|
||||
if isinstance(ret, dict):
|
||||
ret_id = ret.get("id")
|
||||
return str(ret_id) if ret_id is not None else None
|
||||
ret_id = getattr(ret, "id", None)
|
||||
return str(ret_id) if ret_id is not None else None
|
||||
|
||||
async def _post_send(self, stream: dict | None = None):
|
||||
if not self.send_buffer:
|
||||
@@ -126,16 +180,37 @@ class QQOfficialMessageEvent(AstrMessageEvent):
|
||||
image_base64,
|
||||
image_path,
|
||||
record_file_path,
|
||||
video_file_source,
|
||||
file_source,
|
||||
file_name,
|
||||
) = await QQOfficialMessageEvent._parse_to_qqofficial(self.send_buffer)
|
||||
|
||||
# C2C 流式仅用于文本分片,富媒体时降级为普通发送,避免平台侧流式校验报错。
|
||||
if stream and (image_base64 or record_file_path):
|
||||
logger.debug("[QQOfficial] 检测到富媒体,降级为非流式发送。")
|
||||
stream = None
|
||||
|
||||
if (
|
||||
not plain_text
|
||||
and not image_base64
|
||||
and not image_path
|
||||
and not record_file_path
|
||||
and not video_file_source
|
||||
and not file_source
|
||||
):
|
||||
return None
|
||||
|
||||
# QQ C2C 流式 API 说明:
|
||||
# - 开始/中间分片(state=1):增量追加内容,不需要 \n(加了会导致强制换行)
|
||||
# - 最终分片(state=10):结束流,content 必须以 \n 结尾(QQ API 要求)
|
||||
if (
|
||||
stream
|
||||
and stream.get("state") == 10
|
||||
and plain_text
|
||||
and not plain_text.endswith("\n")
|
||||
):
|
||||
plain_text = plain_text + "\n"
|
||||
|
||||
payload: dict = {
|
||||
# "content": plain_text,
|
||||
"markdown": MarkdownPayload(content=plain_text) if plain_text else None,
|
||||
@@ -157,7 +232,7 @@ class QQOfficialMessageEvent(AstrMessageEvent):
|
||||
if image_base64:
|
||||
media = await self.upload_group_and_c2c_image(
|
||||
image_base64,
|
||||
1,
|
||||
self.IMAGE_FILE_TYPE,
|
||||
group_openid=source.group_openid,
|
||||
)
|
||||
payload["media"] = media
|
||||
@@ -165,15 +240,39 @@ class QQOfficialMessageEvent(AstrMessageEvent):
|
||||
payload.pop("markdown", None)
|
||||
payload["content"] = plain_text or None
|
||||
if record_file_path: # group record msg
|
||||
media = await self.upload_group_and_c2c_record(
|
||||
media = await self.upload_group_and_c2c_media(
|
||||
record_file_path,
|
||||
3,
|
||||
self.VOICE_FILE_TYPE,
|
||||
group_openid=source.group_openid,
|
||||
)
|
||||
payload["media"] = media
|
||||
payload["msg_type"] = 7
|
||||
payload.pop("markdown", None)
|
||||
payload["content"] = plain_text or None
|
||||
if media:
|
||||
payload["media"] = media
|
||||
payload["msg_type"] = 7
|
||||
payload.pop("markdown", None)
|
||||
payload["content"] = plain_text or None
|
||||
if video_file_source:
|
||||
media = await self.upload_group_and_c2c_media(
|
||||
video_file_source,
|
||||
self.VIDEO_FILE_TYPE,
|
||||
group_openid=source.group_openid,
|
||||
)
|
||||
if media:
|
||||
payload["media"] = media
|
||||
payload["msg_type"] = 7
|
||||
payload.pop("markdown", None)
|
||||
payload["content"] = plain_text or None
|
||||
if file_source:
|
||||
media = await self.upload_group_and_c2c_media(
|
||||
file_source,
|
||||
self.FILE_FILE_TYPE,
|
||||
file_name=file_name,
|
||||
group_openid=source.group_openid,
|
||||
)
|
||||
if media:
|
||||
payload["media"] = media
|
||||
payload["msg_type"] = 7
|
||||
payload.pop("markdown", None)
|
||||
payload["content"] = plain_text or None
|
||||
ret = await self._send_with_markdown_fallback(
|
||||
send_func=lambda retry_payload: self.bot.api.post_group_message(
|
||||
group_openid=source.group_openid, # type: ignore
|
||||
@@ -181,13 +280,14 @@ class QQOfficialMessageEvent(AstrMessageEvent):
|
||||
),
|
||||
payload=payload,
|
||||
plain_text=plain_text,
|
||||
stream=stream,
|
||||
)
|
||||
|
||||
case botpy.message.C2CMessage():
|
||||
if image_base64:
|
||||
media = await self.upload_group_and_c2c_image(
|
||||
image_base64,
|
||||
1,
|
||||
self.IMAGE_FILE_TYPE,
|
||||
openid=source.author.user_openid,
|
||||
)
|
||||
payload["media"] = media
|
||||
@@ -195,15 +295,39 @@ class QQOfficialMessageEvent(AstrMessageEvent):
|
||||
payload.pop("markdown", None)
|
||||
payload["content"] = plain_text or None
|
||||
if record_file_path: # c2c record
|
||||
media = await self.upload_group_and_c2c_record(
|
||||
media = await self.upload_group_and_c2c_media(
|
||||
record_file_path,
|
||||
3,
|
||||
self.VOICE_FILE_TYPE,
|
||||
openid=source.author.user_openid,
|
||||
)
|
||||
payload["media"] = media
|
||||
payload["msg_type"] = 7
|
||||
payload.pop("markdown", None)
|
||||
payload["content"] = plain_text or None
|
||||
if media:
|
||||
payload["media"] = media
|
||||
payload["msg_type"] = 7
|
||||
payload.pop("markdown", None)
|
||||
payload["content"] = plain_text or None
|
||||
if video_file_source:
|
||||
media = await self.upload_group_and_c2c_media(
|
||||
video_file_source,
|
||||
self.VIDEO_FILE_TYPE,
|
||||
openid=source.author.user_openid,
|
||||
)
|
||||
if media:
|
||||
payload["media"] = media
|
||||
payload["msg_type"] = 7
|
||||
payload.pop("markdown", None)
|
||||
payload["content"] = plain_text or None
|
||||
if file_source:
|
||||
media = await self.upload_group_and_c2c_media(
|
||||
file_source,
|
||||
self.FILE_FILE_TYPE,
|
||||
file_name=file_name,
|
||||
openid=source.author.user_openid,
|
||||
)
|
||||
if media:
|
||||
payload["media"] = media
|
||||
payload["msg_type"] = 7
|
||||
payload.pop("markdown", None)
|
||||
payload["content"] = plain_text or None
|
||||
if stream:
|
||||
ret = await self._send_with_markdown_fallback(
|
||||
send_func=lambda retry_payload: self.post_c2c_message(
|
||||
@@ -213,6 +337,7 @@ class QQOfficialMessageEvent(AstrMessageEvent):
|
||||
),
|
||||
payload=payload,
|
||||
plain_text=plain_text,
|
||||
stream=stream,
|
||||
)
|
||||
else:
|
||||
ret = await self._send_with_markdown_fallback(
|
||||
@@ -222,6 +347,7 @@ class QQOfficialMessageEvent(AstrMessageEvent):
|
||||
),
|
||||
payload=payload,
|
||||
plain_text=plain_text,
|
||||
stream=stream,
|
||||
)
|
||||
logger.debug(f"Message sent to C2C: {ret}")
|
||||
|
||||
@@ -237,6 +363,7 @@ class QQOfficialMessageEvent(AstrMessageEvent):
|
||||
),
|
||||
payload=payload,
|
||||
plain_text=plain_text,
|
||||
stream=stream,
|
||||
)
|
||||
|
||||
case botpy.message.DirectMessage():
|
||||
@@ -251,6 +378,7 @@ class QQOfficialMessageEvent(AstrMessageEvent):
|
||||
),
|
||||
payload=payload,
|
||||
plain_text=plain_text,
|
||||
stream=stream,
|
||||
)
|
||||
|
||||
case _:
|
||||
@@ -267,10 +395,31 @@ class QQOfficialMessageEvent(AstrMessageEvent):
|
||||
send_func,
|
||||
payload: dict,
|
||||
plain_text: str,
|
||||
stream: dict | None = None,
|
||||
):
|
||||
try:
|
||||
return await send_func(payload)
|
||||
except botpy.errors.ServerError as err:
|
||||
# QQ 流式 markdown 分片校验:内容必须以换行结尾。
|
||||
# 某些边界场景服务端仍可能判定失败,这里做一次修正重试。
|
||||
if stream and self.STREAM_MARKDOWN_NEWLINE_ERROR in str(err):
|
||||
retry_payload = payload.copy()
|
||||
|
||||
markdown_payload = retry_payload.get("markdown")
|
||||
if isinstance(markdown_payload, dict):
|
||||
md_content = cast(str, markdown_payload.get("content", "") or "")
|
||||
if md_content and not md_content.endswith("\n"):
|
||||
retry_payload["markdown"] = {"content": md_content + "\n"}
|
||||
|
||||
content = cast(str | None, retry_payload.get("content"))
|
||||
if content and not content.endswith("\n"):
|
||||
retry_payload["content"] = content + "\n"
|
||||
|
||||
logger.warning(
|
||||
"[QQOfficial] 流式 markdown 分片换行校验失败,已修正后重试一次。"
|
||||
)
|
||||
return await send_func(retry_payload)
|
||||
|
||||
if (
|
||||
self.MARKDOWN_NOT_ALLOWED_ERROR not in str(err)
|
||||
or not payload.get("markdown")
|
||||
@@ -282,10 +431,14 @@ class QQOfficialMessageEvent(AstrMessageEvent):
|
||||
"[QQOfficial] markdown 发送被拒绝,回退到 content 模式重试。"
|
||||
)
|
||||
fallback_payload = payload.copy()
|
||||
fallback_payload["markdown"] = None
|
||||
fallback_payload.pop("markdown", None)
|
||||
fallback_payload["content"] = plain_text
|
||||
if fallback_payload.get("msg_type") == 2:
|
||||
fallback_payload["msg_type"] = 0
|
||||
if stream:
|
||||
fallback_content = cast(str, fallback_payload.get("content") or "")
|
||||
if fallback_content and not fallback_content.endswith("\n"):
|
||||
fallback_payload["content"] = fallback_content + "\n"
|
||||
return await send_func(fallback_payload)
|
||||
|
||||
async def upload_group_and_c2c_image(
|
||||
@@ -327,16 +480,19 @@ class QQOfficialMessageEvent(AstrMessageEvent):
|
||||
ttl=result.get("ttl", 0),
|
||||
)
|
||||
|
||||
async def upload_group_and_c2c_record(
|
||||
async def upload_group_and_c2c_media(
|
||||
self,
|
||||
file_source: str,
|
||||
file_type: int,
|
||||
srv_send_msg: bool = False,
|
||||
file_name: str | None = None,
|
||||
**kwargs,
|
||||
) -> Media | None:
|
||||
"""上传媒体文件"""
|
||||
# 构建基础payload
|
||||
payload = {"file_type": file_type, "srv_send_msg": srv_send_msg}
|
||||
if file_name:
|
||||
payload["file_name"] = file_name
|
||||
|
||||
# 处理文件数据
|
||||
if os.path.exists(file_source):
|
||||
@@ -400,13 +556,21 @@ class QQOfficialMessageEvent(AstrMessageEvent):
|
||||
) -> message.Message:
|
||||
payload = locals()
|
||||
payload.pop("self", None)
|
||||
# QQ API does not accept stream.id=None; remove it when not yet assigned
|
||||
if "stream" in payload and payload["stream"] is not None:
|
||||
stream_data = dict(payload["stream"])
|
||||
if stream_data.get("id") is None:
|
||||
stream_data.pop("id", None)
|
||||
payload["stream"] = stream_data
|
||||
route = Route("POST", "/v2/users/{openid}/messages", openid=openid)
|
||||
result = await self.bot.api._http.request(route, json=payload)
|
||||
|
||||
if result is None:
|
||||
logger.warning("[QQOfficial] post_c2c_message: API 返回 None,跳过本次发送")
|
||||
return None
|
||||
if not isinstance(result, dict):
|
||||
raise RuntimeError(
|
||||
f"Failed to post c2c message, response is not dict: {result}"
|
||||
)
|
||||
logger.error(f"[QQOfficial] post_c2c_message: 响应不是 dict: {result}")
|
||||
return None
|
||||
|
||||
return message.Message(**result)
|
||||
|
||||
@@ -416,6 +580,9 @@ class QQOfficialMessageEvent(AstrMessageEvent):
|
||||
image_base64 = None # only one img supported
|
||||
image_file_path = None
|
||||
record_file_path = None
|
||||
video_file_source = None
|
||||
file_source = None
|
||||
file_name = None
|
||||
for i in message.chain:
|
||||
if isinstance(i, Plain):
|
||||
plain_text += i.text
|
||||
@@ -454,6 +621,30 @@ class QQOfficialMessageEvent(AstrMessageEvent):
|
||||
except Exception as e:
|
||||
logger.error(f"处理语音时出错: {e}")
|
||||
record_file_path = None
|
||||
elif isinstance(i, Video) and not video_file_source:
|
||||
if i.file.startswith("file:///"):
|
||||
video_file_source = i.file[8:]
|
||||
else:
|
||||
video_file_source = i.file
|
||||
elif isinstance(i, File) and not file_source:
|
||||
file_name = i.name
|
||||
if i.file_:
|
||||
file_path = i.file_
|
||||
if file_path.startswith("file:///"):
|
||||
file_path = file_path[8:]
|
||||
elif file_path.startswith("file://"):
|
||||
file_path = file_path[7:]
|
||||
file_source = file_path
|
||||
elif i.url:
|
||||
file_source = i.url
|
||||
else:
|
||||
logger.debug(f"qq_official 忽略 {i.type}")
|
||||
return plain_text, image_base64, image_file_path, record_file_path
|
||||
return (
|
||||
plain_text,
|
||||
image_base64,
|
||||
image_file_path,
|
||||
record_file_path,
|
||||
video_file_source,
|
||||
file_source,
|
||||
file_name,
|
||||
)
|
||||
|
||||
@@ -3,8 +3,10 @@ from __future__ import annotations
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
import random
|
||||
import time
|
||||
from typing import cast
|
||||
from types import SimpleNamespace
|
||||
from typing import Any, cast
|
||||
|
||||
import botpy
|
||||
import botpy.message
|
||||
@@ -12,7 +14,7 @@ from botpy import Client
|
||||
|
||||
from astrbot import logger
|
||||
from astrbot.api.event import MessageChain
|
||||
from astrbot.api.message_components import At, File, Image, Plain
|
||||
from astrbot.api.message_components import At, File, Image, Plain, Record, Video
|
||||
from astrbot.api.platform import (
|
||||
AstrBotMessage,
|
||||
MessageMember,
|
||||
@@ -46,6 +48,7 @@ class botClient(Client):
|
||||
)
|
||||
abm.group_id = cast(str, message.group_openid)
|
||||
abm.session_id = abm.group_id
|
||||
self.platform.remember_session_scene(abm.session_id, "group")
|
||||
self._commit(abm)
|
||||
|
||||
# 收到频道消息
|
||||
@@ -56,6 +59,7 @@ class botClient(Client):
|
||||
)
|
||||
abm.group_id = message.channel_id
|
||||
abm.session_id = abm.group_id
|
||||
self.platform.remember_session_scene(abm.session_id, "channel")
|
||||
self._commit(abm)
|
||||
|
||||
# 收到私聊消息
|
||||
@@ -67,6 +71,7 @@ class botClient(Client):
|
||||
MessageType.FRIEND_MESSAGE,
|
||||
)
|
||||
abm.session_id = abm.sender.user_id
|
||||
self.platform.remember_session_scene(abm.session_id, "friend")
|
||||
self._commit(abm)
|
||||
|
||||
# 收到 C2C 消息
|
||||
@@ -76,9 +81,11 @@ class botClient(Client):
|
||||
MessageType.FRIEND_MESSAGE,
|
||||
)
|
||||
abm.session_id = abm.sender.user_id
|
||||
self.platform.remember_session_scene(abm.session_id, "friend")
|
||||
self._commit(abm)
|
||||
|
||||
def _commit(self, abm: AstrBotMessage) -> None:
|
||||
self.platform.remember_session_message_id(abm.session_id, abm.message_id)
|
||||
self.platform.commit_event(
|
||||
QQOfficialMessageEvent(
|
||||
abm.message_str,
|
||||
@@ -124,6 +131,9 @@ class QQOfficialPlatformAdapter(Platform):
|
||||
|
||||
self.client.set_platform(self)
|
||||
|
||||
self._session_last_message_id: dict[str, str] = {}
|
||||
self._session_scene: dict[str, str] = {}
|
||||
|
||||
self.test_mode = os.environ.get("TEST_MODE", "off") == "on"
|
||||
|
||||
async def send_by_session(
|
||||
@@ -131,14 +141,191 @@ class QQOfficialPlatformAdapter(Platform):
|
||||
session: MessageSesion,
|
||||
message_chain: MessageChain,
|
||||
) -> None:
|
||||
raise NotImplementedError("QQ 机器人官方 API 适配器不支持 send_by_session")
|
||||
await self._send_by_session_common(session, message_chain)
|
||||
|
||||
async def _send_by_session_common(
|
||||
self,
|
||||
session: MessageSesion,
|
||||
message_chain: MessageChain,
|
||||
) -> None:
|
||||
(
|
||||
plain_text,
|
||||
image_base64,
|
||||
image_path,
|
||||
record_file_path,
|
||||
video_file_source,
|
||||
file_source,
|
||||
file_name,
|
||||
) = await QQOfficialMessageEvent._parse_to_qqofficial(message_chain)
|
||||
if (
|
||||
not plain_text
|
||||
and not image_path
|
||||
and not image_base64
|
||||
and not record_file_path
|
||||
and not video_file_source
|
||||
and not file_source
|
||||
):
|
||||
return
|
||||
|
||||
msg_id = self._session_last_message_id.get(session.session_id)
|
||||
if not msg_id:
|
||||
logger.warning(
|
||||
"[QQOfficial] No cached msg_id for session: %s, skip send_by_session",
|
||||
session.session_id,
|
||||
)
|
||||
return
|
||||
|
||||
payload: dict[str, Any] = {"content": plain_text, "msg_id": msg_id}
|
||||
ret: Any = None
|
||||
send_helper = SimpleNamespace(bot=self.client)
|
||||
|
||||
if session.message_type == MessageType.GROUP_MESSAGE:
|
||||
scene = self._session_scene.get(session.session_id)
|
||||
if scene == "group":
|
||||
payload["msg_seq"] = random.randint(1, 10000)
|
||||
if image_base64:
|
||||
media = await QQOfficialMessageEvent.upload_group_and_c2c_image(
|
||||
send_helper, # type: ignore
|
||||
image_base64,
|
||||
QQOfficialMessageEvent.IMAGE_FILE_TYPE,
|
||||
group_openid=session.session_id,
|
||||
)
|
||||
payload["media"] = media
|
||||
payload["msg_type"] = 7
|
||||
if record_file_path:
|
||||
media = await QQOfficialMessageEvent.upload_group_and_c2c_media(
|
||||
send_helper, # type: ignore
|
||||
record_file_path,
|
||||
QQOfficialMessageEvent.VOICE_FILE_TYPE,
|
||||
group_openid=session.session_id,
|
||||
)
|
||||
if media:
|
||||
payload["media"] = media
|
||||
payload["msg_type"] = 7
|
||||
if video_file_source:
|
||||
media = await QQOfficialMessageEvent.upload_group_and_c2c_media(
|
||||
send_helper, # type: ignore
|
||||
video_file_source,
|
||||
QQOfficialMessageEvent.VIDEO_FILE_TYPE,
|
||||
group_openid=session.session_id,
|
||||
)
|
||||
if media:
|
||||
payload["media"] = media
|
||||
payload["msg_type"] = 7
|
||||
payload.pop("msg_id", None)
|
||||
if file_source:
|
||||
media = await QQOfficialMessageEvent.upload_group_and_c2c_media(
|
||||
send_helper, # type: ignore
|
||||
file_source,
|
||||
QQOfficialMessageEvent.FILE_FILE_TYPE,
|
||||
file_name=file_name,
|
||||
group_openid=session.session_id,
|
||||
)
|
||||
if media:
|
||||
payload["media"] = media
|
||||
payload["msg_type"] = 7
|
||||
payload.pop("msg_id", None)
|
||||
ret = await self.client.api.post_group_message(
|
||||
group_openid=session.session_id,
|
||||
**payload,
|
||||
)
|
||||
else:
|
||||
if image_path:
|
||||
payload["file_image"] = image_path
|
||||
ret = await self.client.api.post_message(
|
||||
channel_id=session.session_id,
|
||||
**payload,
|
||||
)
|
||||
|
||||
elif session.message_type == MessageType.FRIEND_MESSAGE:
|
||||
payload["msg_seq"] = random.randint(1, 10000)
|
||||
if image_base64:
|
||||
media = await QQOfficialMessageEvent.upload_group_and_c2c_image(
|
||||
send_helper, # type: ignore
|
||||
image_base64,
|
||||
QQOfficialMessageEvent.IMAGE_FILE_TYPE,
|
||||
openid=session.session_id,
|
||||
)
|
||||
payload["media"] = media
|
||||
payload["msg_type"] = 7
|
||||
if record_file_path:
|
||||
media = await QQOfficialMessageEvent.upload_group_and_c2c_media(
|
||||
send_helper, # type: ignore
|
||||
record_file_path,
|
||||
QQOfficialMessageEvent.VOICE_FILE_TYPE,
|
||||
openid=session.session_id,
|
||||
)
|
||||
if media:
|
||||
payload["media"] = media
|
||||
payload["msg_type"] = 7
|
||||
if video_file_source:
|
||||
media = await QQOfficialMessageEvent.upload_group_and_c2c_media(
|
||||
send_helper, # type: ignore
|
||||
video_file_source,
|
||||
QQOfficialMessageEvent.VIDEO_FILE_TYPE,
|
||||
openid=session.session_id,
|
||||
)
|
||||
if media:
|
||||
payload["media"] = media
|
||||
payload["msg_type"] = 7
|
||||
# QQ API rejects msg_id for media (video/file) messages sent
|
||||
# via the proactive tool-call path; remove it to avoid 越权 error.
|
||||
payload.pop("msg_id", None)
|
||||
if file_source:
|
||||
media = await QQOfficialMessageEvent.upload_group_and_c2c_media(
|
||||
send_helper, # type: ignore
|
||||
file_source,
|
||||
QQOfficialMessageEvent.FILE_FILE_TYPE,
|
||||
file_name=file_name,
|
||||
openid=session.session_id,
|
||||
)
|
||||
if media:
|
||||
payload["media"] = media
|
||||
payload["msg_type"] = 7
|
||||
payload.pop("msg_id", None)
|
||||
|
||||
ret = await QQOfficialMessageEvent.post_c2c_message(
|
||||
send_helper, # type: ignore
|
||||
openid=session.session_id,
|
||||
**payload,
|
||||
)
|
||||
else:
|
||||
logger.warning(
|
||||
"[QQOfficial] Unsupported message type for send_by_session: %s",
|
||||
session.message_type,
|
||||
)
|
||||
return
|
||||
|
||||
sent_message_id = self._extract_message_id(ret)
|
||||
if sent_message_id:
|
||||
self.remember_session_message_id(session.session_id, sent_message_id)
|
||||
await super().send_by_session(session, message_chain)
|
||||
|
||||
def remember_session_message_id(self, session_id: str, message_id: str) -> None:
|
||||
if not session_id or not message_id:
|
||||
return
|
||||
self._session_last_message_id[session_id] = message_id
|
||||
|
||||
def remember_session_scene(self, session_id: str, scene: str) -> None:
|
||||
if not session_id or not scene:
|
||||
return
|
||||
self._session_scene[session_id] = scene
|
||||
|
||||
def _extract_message_id(self, ret: Any) -> str | None:
|
||||
if isinstance(ret, dict):
|
||||
message_id = ret.get("id")
|
||||
return str(message_id) if message_id else None
|
||||
message_id = getattr(ret, "id", None)
|
||||
if message_id:
|
||||
return str(message_id)
|
||||
return None
|
||||
|
||||
def meta(self) -> PlatformMetadata:
|
||||
return PlatformMetadata(
|
||||
name="qq_official",
|
||||
description="QQ 机器人官方 API 适配器",
|
||||
id=cast(str, self.config.get("id")),
|
||||
support_proactive_message=False,
|
||||
support_proactive_message=True,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
@@ -158,7 +345,10 @@ class QQOfficialPlatformAdapter(Platform):
|
||||
return
|
||||
|
||||
for attachment in attachments:
|
||||
content_type = cast(str, getattr(attachment, "content_type", "") or "")
|
||||
content_type = cast(
|
||||
str,
|
||||
getattr(attachment, "content_type", "") or "",
|
||||
).lower()
|
||||
url = QQOfficialPlatformAdapter._normalize_attachment_url(
|
||||
cast(str | None, getattr(attachment, "url", None))
|
||||
)
|
||||
@@ -174,7 +364,73 @@ class QQOfficialPlatformAdapter(Platform):
|
||||
or getattr(attachment, "name", None)
|
||||
or "attachment",
|
||||
)
|
||||
msg.append(File(name=filename, file=url, url=url))
|
||||
ext = os.path.splitext(filename)[1].lower()
|
||||
image_exts = {".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp"}
|
||||
audio_exts = {
|
||||
".mp3",
|
||||
".wav",
|
||||
".ogg",
|
||||
".m4a",
|
||||
".amr",
|
||||
".silk",
|
||||
}
|
||||
video_exts = {
|
||||
".mp4",
|
||||
".mov",
|
||||
".avi",
|
||||
".mkv",
|
||||
".webm",
|
||||
}
|
||||
|
||||
if content_type.startswith("audio") or ext in audio_exts:
|
||||
msg.append(Record.fromURL(url))
|
||||
elif content_type.startswith("video") or ext in video_exts:
|
||||
msg.append(Video.fromURL(url))
|
||||
elif content_type.startswith("image") or ext in image_exts:
|
||||
msg.append(Image.fromURL(url))
|
||||
else:
|
||||
msg.append(File(name=filename, file=url, url=url))
|
||||
|
||||
@staticmethod
|
||||
def _parse_face_message(content: str) -> str:
|
||||
"""Parse QQ official face message format and convert to readable text.
|
||||
|
||||
QQ official face message format:
|
||||
<faceType=4,faceId="",ext="eyJ0ZXh0IjoiW+a7oeWktOmXruWPt10ifQ==">
|
||||
|
||||
The ext field contains base64-encoded JSON with a 'text' field
|
||||
describing the emoji (e.g., '[满头问号]').
|
||||
|
||||
Args:
|
||||
content: The message content that may contain face tags.
|
||||
|
||||
Returns:
|
||||
Content with face tags replaced by readable emoji descriptions.
|
||||
"""
|
||||
import base64
|
||||
import json
|
||||
import re
|
||||
|
||||
def replace_face(match):
|
||||
face_tag = match.group(0)
|
||||
# Extract ext field from the face tag
|
||||
ext_match = re.search(r'ext="([^"]*)"', face_tag)
|
||||
if ext_match:
|
||||
try:
|
||||
ext_encoded = ext_match.group(1)
|
||||
# Decode base64 and parse JSON
|
||||
ext_decoded = base64.b64decode(ext_encoded).decode("utf-8")
|
||||
ext_data = json.loads(ext_decoded)
|
||||
emoji_text = ext_data.get("text", "")
|
||||
if emoji_text:
|
||||
return f"[表情:{emoji_text}]"
|
||||
except Exception:
|
||||
pass
|
||||
# Fallback if parsing fails
|
||||
return "[表情]"
|
||||
|
||||
# Match face tags: <faceType=...>
|
||||
return re.sub(r"<faceType=\d+[^>]*>", replace_face, content)
|
||||
|
||||
@staticmethod
|
||||
def _parse_from_qqofficial(
|
||||
@@ -201,7 +457,10 @@ class QQOfficialPlatformAdapter(Platform):
|
||||
abm.group_id = message.group_openid
|
||||
else:
|
||||
abm.sender = MessageMember(message.author.user_openid, "")
|
||||
abm.message_str = message.content.strip()
|
||||
# Parse face messages to readable text
|
||||
abm.message_str = QQOfficialPlatformAdapter._parse_face_message(
|
||||
message.content.strip()
|
||||
)
|
||||
abm.self_id = "unknown_selfid"
|
||||
msg.append(At(qq="qq_official"))
|
||||
msg.append(Plain(abm.message_str))
|
||||
@@ -217,10 +476,12 @@ class QQOfficialPlatformAdapter(Platform):
|
||||
else:
|
||||
abm.self_id = ""
|
||||
|
||||
plain_content = message.content.replace(
|
||||
"<@!" + str(abm.self_id) + ">",
|
||||
"",
|
||||
).strip()
|
||||
plain_content = QQOfficialPlatformAdapter._parse_face_message(
|
||||
message.content.replace(
|
||||
"<@!" + str(abm.self_id) + ">",
|
||||
"",
|
||||
).strip()
|
||||
)
|
||||
|
||||
QQOfficialPlatformAdapter._append_attachments(msg, message.attachments)
|
||||
abm.message = msg
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import asyncio
|
||||
import logging
|
||||
import random
|
||||
from types import SimpleNamespace
|
||||
from typing import Any, cast
|
||||
|
||||
import botpy
|
||||
@@ -15,7 +13,6 @@ from astrbot.core.platform.astr_message_event import MessageSesion
|
||||
from astrbot.core.utils.webhook_utils import log_webhook_info
|
||||
|
||||
from ...register import register_platform_adapter
|
||||
from ..qqofficial.qqofficial_message_event import QQOfficialMessageEvent
|
||||
from ..qqofficial.qqofficial_platform_adapter import QQOfficialPlatformAdapter
|
||||
from .qo_webhook_event import QQOfficialWebhookMessageEvent
|
||||
from .qo_webhook_server import QQOfficialWebhook
|
||||
@@ -123,95 +120,11 @@ class QQOfficialWebhookPlatformAdapter(Platform):
|
||||
session: MessageSesion,
|
||||
message_chain: MessageChain,
|
||||
) -> None:
|
||||
(
|
||||
plain_text,
|
||||
image_base64,
|
||||
image_path,
|
||||
record_file_path,
|
||||
) = await QQOfficialMessageEvent._parse_to_qqofficial(message_chain)
|
||||
if not plain_text and not image_path:
|
||||
return
|
||||
|
||||
msg_id = self._session_last_message_id.get(session.session_id)
|
||||
if not msg_id:
|
||||
logger.warning(
|
||||
"[QQOfficialWebhook] No cached msg_id for session: %s, skip send_by_session",
|
||||
session.session_id,
|
||||
)
|
||||
return
|
||||
|
||||
payload: dict[str, Any] = {"content": plain_text, "msg_id": msg_id}
|
||||
ret: Any = None
|
||||
send_helper = SimpleNamespace(bot=self.client)
|
||||
if session.message_type == MessageType.GROUP_MESSAGE:
|
||||
scene = self._session_scene.get(session.session_id)
|
||||
if scene == "group":
|
||||
payload["msg_seq"] = random.randint(1, 10000)
|
||||
if image_base64:
|
||||
media = await QQOfficialMessageEvent.upload_group_and_c2c_image(
|
||||
send_helper, # type: ignore
|
||||
image_base64,
|
||||
1,
|
||||
group_openid=session.session_id,
|
||||
)
|
||||
payload["media"] = media
|
||||
payload["msg_type"] = 7
|
||||
if record_file_path:
|
||||
media = await QQOfficialMessageEvent.upload_group_and_c2c_record(
|
||||
send_helper, # type: ignore
|
||||
record_file_path,
|
||||
3,
|
||||
group_openid=session.session_id,
|
||||
)
|
||||
payload["media"] = media
|
||||
payload["msg_type"] = 7
|
||||
ret = await self.client.api.post_group_message(
|
||||
group_openid=session.session_id,
|
||||
**payload,
|
||||
)
|
||||
else:
|
||||
if image_path:
|
||||
payload["file_image"] = image_path
|
||||
ret = await self.client.api.post_message(
|
||||
channel_id=session.session_id,
|
||||
**payload,
|
||||
)
|
||||
elif session.message_type == MessageType.FRIEND_MESSAGE:
|
||||
payload["msg_seq"] = random.randint(1, 10000)
|
||||
if image_base64:
|
||||
media = await QQOfficialMessageEvent.upload_group_and_c2c_image(
|
||||
send_helper, # type: ignore
|
||||
image_base64,
|
||||
1,
|
||||
openid=session.session_id,
|
||||
)
|
||||
payload["media"] = media
|
||||
payload["msg_type"] = 7
|
||||
if record_file_path:
|
||||
media = await QQOfficialMessageEvent.upload_group_and_c2c_record(
|
||||
send_helper, # type: ignore
|
||||
record_file_path,
|
||||
3,
|
||||
openid=session.session_id,
|
||||
)
|
||||
payload["media"] = media
|
||||
payload["msg_type"] = 7
|
||||
ret = await QQOfficialMessageEvent.post_c2c_message(
|
||||
send_helper, # type: ignore
|
||||
openid=session.session_id,
|
||||
**payload,
|
||||
)
|
||||
else:
|
||||
logger.warning(
|
||||
"[QQOfficialWebhook] Unsupported message type for send_by_session: %s",
|
||||
session.message_type,
|
||||
)
|
||||
return
|
||||
|
||||
sent_message_id = self._extract_message_id(ret)
|
||||
if sent_message_id:
|
||||
self.remember_session_message_id(session.session_id, sent_message_id)
|
||||
await super().send_by_session(session, message_chain)
|
||||
await QQOfficialPlatformAdapter._send_by_session_common(
|
||||
cast(Any, self),
|
||||
session,
|
||||
message_chain,
|
||||
)
|
||||
|
||||
def remember_session_message_id(self, session_id: str, message_id: str) -> None:
|
||||
if not session_id or not message_id:
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import asyncio
|
||||
import logging
|
||||
import time
|
||||
from typing import cast
|
||||
|
||||
import quart
|
||||
@@ -39,6 +40,9 @@ class QQOfficialWebhook:
|
||||
self.client = botpy_client
|
||||
self.event_queue = event_queue
|
||||
self.shutdown_event = asyncio.Event()
|
||||
# Deduplication cache for webhook retry callbacks.
|
||||
self._seen_event_ids: dict[str, float] = {}
|
||||
self._dedup_ttl: int = 60 # seconds
|
||||
|
||||
async def initialize(self) -> None:
|
||||
logger.info("正在登录到 QQ 官方机器人...")
|
||||
@@ -106,6 +110,22 @@ class QQOfficialWebhook:
|
||||
print(signed)
|
||||
return signed
|
||||
|
||||
event_id = msg.get("id")
|
||||
if event_id:
|
||||
now = time.monotonic()
|
||||
# Lazily evict expired entries to prevent unbounded growth.
|
||||
expired = [
|
||||
k
|
||||
for k, ts in self._seen_event_ids.items()
|
||||
if now - ts > self._dedup_ttl
|
||||
]
|
||||
for k in expired:
|
||||
del self._seen_event_ids[k]
|
||||
if event_id in self._seen_event_ids:
|
||||
logger.debug(f"Duplicate webhook event {event_id!r}, skipping.")
|
||||
return {"opcode": 12}
|
||||
self._seen_event_ids[event_id] = now
|
||||
|
||||
if event and opcode == BotWebSocket.WS_DISPATCH_EVENT:
|
||||
event = msg["t"].lower()
|
||||
try:
|
||||
|
||||
@@ -289,8 +289,8 @@ class TelegramPlatformAdapter(Platform):
|
||||
else:
|
||||
message.type = MessageType.GROUP_MESSAGE
|
||||
message.group_id = str(update.message.chat.id)
|
||||
if update.message.message_thread_id:
|
||||
# Topic Group
|
||||
if update.message.is_topic_message and update.message.message_thread_id:
|
||||
# Telegram Topic Group: include thread id to isolate per-topic sessions.
|
||||
message.group_id += "#" + str(update.message.message_thread_id)
|
||||
message.session_id = message.group_id
|
||||
message.message_id = str(update.message.message_id)
|
||||
|
||||
@@ -25,6 +25,16 @@ from astrbot.api.platform import AstrBotMessage, MessageType, PlatformMetadata
|
||||
from astrbot.core.utils.metrics import Metric
|
||||
|
||||
|
||||
def _is_gif(path: str) -> bool:
|
||||
if path.lower().endswith(".gif"):
|
||||
return True
|
||||
try:
|
||||
with open(path, "rb") as f:
|
||||
return f.read(6) in (b"GIF87a", b"GIF89a")
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
|
||||
class TelegramPlatformEvent(AstrMessageEvent):
|
||||
# Telegram 的最大消息长度限制
|
||||
MAX_MESSAGE_LENGTH = 4096
|
||||
@@ -278,7 +288,6 @@ class TelegramPlatformEvent(AstrMessageEvent):
|
||||
try:
|
||||
md_text = telegramify_markdown.markdownify(
|
||||
chunk,
|
||||
normalize_whitespace=False,
|
||||
)
|
||||
await client.send_message(
|
||||
text=md_text,
|
||||
@@ -292,7 +301,13 @@ class TelegramPlatformEvent(AstrMessageEvent):
|
||||
await client.send_message(text=chunk, **cast(Any, payload))
|
||||
elif isinstance(i, Image):
|
||||
image_path = await i.convert_to_file_path()
|
||||
await client.send_photo(photo=image_path, **cast(Any, payload))
|
||||
if _is_gif(image_path):
|
||||
send_coro = client.send_animation
|
||||
media_kwarg = {"animation": image_path}
|
||||
else:
|
||||
send_coro = client.send_photo
|
||||
media_kwarg = {"photo": image_path}
|
||||
await send_coro(**media_kwarg, **cast(Any, payload))
|
||||
elif isinstance(i, File):
|
||||
path = await i.get_file()
|
||||
name = i.name or os.path.basename(path)
|
||||
@@ -407,12 +422,20 @@ class TelegramPlatformEvent(AstrMessageEvent):
|
||||
on_text(i.text)
|
||||
elif isinstance(i, Image):
|
||||
image_path = await i.convert_to_file_path()
|
||||
if _is_gif(image_path):
|
||||
action = ChatAction.UPLOAD_VIDEO
|
||||
send_coro = self.client.send_animation
|
||||
media_kwarg = {"animation": image_path}
|
||||
else:
|
||||
action = ChatAction.UPLOAD_PHOTO
|
||||
send_coro = self.client.send_photo
|
||||
media_kwarg = {"photo": image_path}
|
||||
await self._send_media_with_action(
|
||||
self.client,
|
||||
ChatAction.UPLOAD_PHOTO,
|
||||
self.client.send_photo,
|
||||
action,
|
||||
send_coro,
|
||||
user_name=user_name,
|
||||
photo=image_path,
|
||||
**media_kwarg,
|
||||
**cast(Any, payload),
|
||||
)
|
||||
elif isinstance(i, File):
|
||||
@@ -456,7 +479,6 @@ class TelegramPlatformEvent(AstrMessageEvent):
|
||||
try:
|
||||
markdown_text = telegramify_markdown.markdownify(
|
||||
delta,
|
||||
normalize_whitespace=False,
|
||||
)
|
||||
await self.client.send_message(
|
||||
text=markdown_text,
|
||||
@@ -537,7 +559,6 @@ class TelegramPlatformEvent(AstrMessageEvent):
|
||||
try:
|
||||
md = telegramify_markdown.markdownify(
|
||||
draft_text,
|
||||
normalize_whitespace=False,
|
||||
)
|
||||
await self._send_message_draft(
|
||||
user_name,
|
||||
@@ -695,7 +716,6 @@ class TelegramPlatformEvent(AstrMessageEvent):
|
||||
try:
|
||||
markdown_text = telegramify_markdown.markdownify(
|
||||
delta,
|
||||
normalize_whitespace=False,
|
||||
)
|
||||
await self.client.edit_message_text(
|
||||
text=markdown_text,
|
||||
|
||||
@@ -440,9 +440,16 @@ class WecomAIBotAdapter(Platform):
|
||||
)
|
||||
|
||||
def _extract_session_id(self, message_data: dict[str, Any]) -> str:
|
||||
"""从消息数据中提取会话ID"""
|
||||
user_id = message_data.get("from", {}).get("userid", "default_user")
|
||||
return format_session_id("wecomai", user_id)
|
||||
"""从消息数据中提取会话ID
|
||||
群聊使用 chatid,单聊使用 userid
|
||||
"""
|
||||
chattype = message_data.get("chattype", "single")
|
||||
if chattype == "group":
|
||||
chat_id = message_data.get("chatid", "default_group")
|
||||
return format_session_id("wecomai", chat_id)
|
||||
else:
|
||||
user_id = message_data.get("from", {}).get("userid", "default_user")
|
||||
return format_session_id("wecomai", user_id)
|
||||
|
||||
async def _enqueue_message(
|
||||
self,
|
||||
|
||||
@@ -808,6 +808,8 @@ class ProviderManager:
|
||||
config.save_config()
|
||||
# load instance
|
||||
await self.load_provider(new_config)
|
||||
# sync in-memory config for API queries (e.g., embedding provider list)
|
||||
self.providers_config = astrbot_config["provider"]
|
||||
|
||||
async def terminate(self) -> None:
|
||||
if self._mcp_init_task and not self._mcp_init_task.done():
|
||||
|
||||
@@ -13,3 +13,11 @@ class ProviderGroq(ProviderOpenAIOfficial):
|
||||
) -> None:
|
||||
super().__init__(provider_config, provider_settings)
|
||||
self.reasoning_key = "reasoning"
|
||||
|
||||
def _finally_convert_payload(self, payloads: dict) -> None:
|
||||
"""Groq rejects assistant history items that include reasoning_content."""
|
||||
super()._finally_convert_payload(payloads)
|
||||
for message in payloads.get("messages", []):
|
||||
if message.get("role") == "assistant":
|
||||
message.pop("reasoning_content", None)
|
||||
message.pop("reasoning", None)
|
||||
|
||||
@@ -40,25 +40,46 @@ class OpenAIEmbeddingProvider(EmbeddingProvider):
|
||||
|
||||
async def get_embedding(self, text: str) -> list[float]:
|
||||
"""获取文本的嵌入"""
|
||||
kwargs = self._embedding_kwargs()
|
||||
embedding = await self.client.embeddings.create(
|
||||
input=text,
|
||||
model=self.model,
|
||||
dimensions=self.get_dim(),
|
||||
**kwargs,
|
||||
)
|
||||
return embedding.data[0].embedding
|
||||
|
||||
async def get_embeddings(self, text: list[str]) -> list[list[float]]:
|
||||
"""批量获取文本的嵌入"""
|
||||
kwargs = self._embedding_kwargs()
|
||||
embeddings = await self.client.embeddings.create(
|
||||
input=text,
|
||||
model=self.model,
|
||||
dimensions=self.get_dim(),
|
||||
**kwargs,
|
||||
)
|
||||
return [item.embedding for item in embeddings.data]
|
||||
|
||||
def _embedding_kwargs(self) -> dict:
|
||||
"""构建嵌入请求的可选参数"""
|
||||
kwargs = {}
|
||||
if "embedding_dimensions" in self.provider_config:
|
||||
try:
|
||||
kwargs["dimensions"] = int(self.provider_config["embedding_dimensions"])
|
||||
except (ValueError, TypeError):
|
||||
logger.warning(
|
||||
f"embedding_dimensions in embedding configs is not a valid integer: '{self.provider_config['embedding_dimensions']}', ignored."
|
||||
)
|
||||
return kwargs
|
||||
|
||||
def get_dim(self) -> int:
|
||||
"""获取向量的维度"""
|
||||
return int(self.provider_config.get("embedding_dimensions", 1024))
|
||||
if "embedding_dimensions" in self.provider_config:
|
||||
try:
|
||||
return int(self.provider_config["embedding_dimensions"])
|
||||
except (ValueError, TypeError):
|
||||
logger.warning(
|
||||
f"embedding_dimensions in embedding configs is not a valid integer: '{self.provider_config['embedding_dimensions']}', ignored."
|
||||
)
|
||||
return 0
|
||||
|
||||
async def terminate(self):
|
||||
if self.client:
|
||||
|
||||
@@ -311,7 +311,7 @@ class ProviderOpenAIOfficial(Provider):
|
||||
state.handle_chunk(chunk)
|
||||
except Exception as e:
|
||||
logger.warning("Saving chunk state error: " + str(e))
|
||||
if len(chunk.choices) == 0:
|
||||
if not chunk.choices:
|
||||
continue
|
||||
delta = chunk.choices[0].delta
|
||||
# logger.debug(f"chunk delta: {delta}")
|
||||
@@ -322,7 +322,7 @@ class ProviderOpenAIOfficial(Provider):
|
||||
if reasoning:
|
||||
llm_response.reasoning_content = reasoning
|
||||
_y = True
|
||||
if delta.content:
|
||||
if delta and delta.content:
|
||||
# Don't strip streaming chunks to preserve spaces between words
|
||||
completion_text = self._normalize_content(delta.content, strip=False)
|
||||
llm_response.result_chain = MessageChain(
|
||||
@@ -345,7 +345,7 @@ class ProviderOpenAIOfficial(Provider):
|
||||
) -> str:
|
||||
"""Extract reasoning content from OpenAI ChatCompletion if available."""
|
||||
reasoning_text = ""
|
||||
if len(completion.choices) == 0:
|
||||
if not completion.choices:
|
||||
return reasoning_text
|
||||
if isinstance(completion, ChatCompletion):
|
||||
choice = completion.choices[0]
|
||||
@@ -468,7 +468,7 @@ class ProviderOpenAIOfficial(Provider):
|
||||
"""Parse OpenAI ChatCompletion into LLMResponse"""
|
||||
llm_response = LLMResponse("assistant")
|
||||
|
||||
if len(completion.choices) == 0:
|
||||
if not completion.choices:
|
||||
raise Exception("API 返回的 completion 为空。")
|
||||
choice = completion.choices[0]
|
||||
|
||||
@@ -629,7 +629,8 @@ class ProviderOpenAIOfficial(Provider):
|
||||
# 最后一次不等待
|
||||
if retry_cnt < max_retries - 1:
|
||||
await asyncio.sleep(1)
|
||||
available_api_keys.remove(chosen_key)
|
||||
if chosen_key in available_api_keys:
|
||||
available_api_keys.remove(chosen_key)
|
||||
if len(available_api_keys) > 0:
|
||||
chosen_key = random.choice(available_api_keys)
|
||||
return (
|
||||
|
||||
@@ -16,4 +16,7 @@ class ProviderOpenRouter(ProviderOpenAIOfficial):
|
||||
self.client._custom_headers["HTTP-Referer"] = ( # type: ignore
|
||||
"https://github.com/AstrBotDevs/AstrBot"
|
||||
)
|
||||
self.client._custom_headers["X-TITLE"] = "AstrBot" # type: ignore
|
||||
self.client._custom_headers["X-OpenRouter-Title"] = "AstrBot" # type: ignore
|
||||
self.client._custom_headers["X-OpenRouter-Categories"] = (
|
||||
"general-chat,personal-agent" # type: ignore
|
||||
)
|
||||
|
||||
@@ -3,6 +3,7 @@ from __future__ import annotations
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import shlex
|
||||
import shutil
|
||||
import tempfile
|
||||
import zipfile
|
||||
@@ -10,6 +11,8 @@ from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path, PurePosixPath
|
||||
|
||||
import yaml
|
||||
|
||||
from astrbot.core.utils.astrbot_path import (
|
||||
get_astrbot_data_path,
|
||||
get_astrbot_skills_path,
|
||||
@@ -68,18 +71,76 @@ def _parse_frontmatter_description(text: str) -> str:
|
||||
break
|
||||
if end_idx is None:
|
||||
return ""
|
||||
for line in lines[1:end_idx]:
|
||||
if ":" not in line:
|
||||
continue
|
||||
key, value = line.split(":", 1)
|
||||
if key.strip().lower() == "description":
|
||||
return value.strip().strip('"').strip("'")
|
||||
return ""
|
||||
|
||||
frontmatter = "\n".join(lines[1:end_idx])
|
||||
try:
|
||||
payload = yaml.safe_load(frontmatter) or {}
|
||||
except yaml.YAMLError:
|
||||
return ""
|
||||
if not isinstance(payload, dict):
|
||||
return ""
|
||||
|
||||
description = payload.get("description", "")
|
||||
if not isinstance(description, str):
|
||||
return ""
|
||||
return description.strip()
|
||||
|
||||
|
||||
# Regex for sanitizing paths used in prompt examples — only allow
|
||||
# safe path characters to prevent prompt injection via crafted skill paths.
|
||||
_SAFE_PATH_RE = re.compile(r"[^A-Za-z0-9_./ -]")
|
||||
_SAFE_PATH_RE = re.compile(r"[^\w./ ,()'\-]", re.UNICODE)
|
||||
_WINDOWS_DRIVE_PATH_RE = re.compile(r"^[A-Za-z]:(?:/|\\)")
|
||||
_WINDOWS_UNC_PATH_RE = re.compile(r"^(//|\\\\)[^/\\]+[/\\][^/\\]+")
|
||||
_CONTROL_CHARS_RE = re.compile(r"[\x00-\x1F\x7F]")
|
||||
|
||||
|
||||
def _is_windows_prompt_path(path: str) -> bool:
|
||||
if os.name != "nt":
|
||||
return False
|
||||
return bool(_WINDOWS_DRIVE_PATH_RE.match(path) or _WINDOWS_UNC_PATH_RE.match(path))
|
||||
|
||||
|
||||
def _sanitize_prompt_path_for_prompt(path: str) -> str:
|
||||
if not path:
|
||||
return ""
|
||||
|
||||
if _WINDOWS_DRIVE_PATH_RE.match(path) or _WINDOWS_UNC_PATH_RE.match(path):
|
||||
path = path.replace("\\", "/")
|
||||
|
||||
drive_prefix = ""
|
||||
if _WINDOWS_DRIVE_PATH_RE.match(path):
|
||||
drive_prefix = path[:2]
|
||||
path = path[2:]
|
||||
|
||||
path = path.replace("`", "")
|
||||
path = _CONTROL_CHARS_RE.sub("", path)
|
||||
sanitized = _SAFE_PATH_RE.sub("", path)
|
||||
return f"{drive_prefix}{sanitized}"
|
||||
|
||||
|
||||
def _sanitize_prompt_description(description: str) -> str:
|
||||
description = description.replace("`", "")
|
||||
description = _CONTROL_CHARS_RE.sub(" ", description)
|
||||
description = " ".join(description.split())
|
||||
return description
|
||||
|
||||
|
||||
def _sanitize_skill_display_name(name: str) -> str:
|
||||
if _SKILL_NAME_RE.fullmatch(name):
|
||||
return name
|
||||
return "<invalid_skill_name>"
|
||||
|
||||
|
||||
def _build_skill_read_command_example(path: str) -> str:
|
||||
if path == "<skills_root>/<skill_name>/SKILL.md":
|
||||
return f"cat {path}"
|
||||
if _is_windows_prompt_path(path):
|
||||
command = "type"
|
||||
path_arg = f'"{os.path.normpath(path)}"'
|
||||
else:
|
||||
command = "cat"
|
||||
path_arg = shlex.quote(path)
|
||||
return f"{command} {path_arg}"
|
||||
|
||||
|
||||
def build_skills_prompt(skills: list[SkillInfo]) -> str:
|
||||
@@ -92,16 +153,37 @@ def build_skills_prompt(skills: list[SkillInfo]) -> str:
|
||||
skills_lines: list[str] = []
|
||||
example_path = ""
|
||||
for skill in skills:
|
||||
display_name = _sanitize_skill_display_name(skill.name)
|
||||
|
||||
description = skill.description or "No description"
|
||||
if skill.source_type == "sandbox_only":
|
||||
description = _sanitize_prompt_description(description)
|
||||
if not description:
|
||||
description = "Read SKILL.md for details."
|
||||
|
||||
if skill.source_type == "sandbox_only":
|
||||
rendered_path = (
|
||||
f"{str(SANDBOX_WORKSPACE_ROOT)}/{str(SANDBOX_SKILLS_ROOT)}/"
|
||||
f"{display_name}/SKILL.md"
|
||||
)
|
||||
else:
|
||||
rendered_path = _sanitize_prompt_path_for_prompt(skill.path)
|
||||
if not rendered_path:
|
||||
rendered_path = "<skills_root>/<skill_name>/SKILL.md"
|
||||
|
||||
skills_lines.append(
|
||||
f"- **{skill.name}**: {description}\n File: `{skill.path}`"
|
||||
f"- **{display_name}**: {description}\n File: `{rendered_path}`"
|
||||
)
|
||||
if not example_path:
|
||||
example_path = skill.path
|
||||
example_path = rendered_path
|
||||
skills_block = "\n".join(skills_lines)
|
||||
# Sanitize example_path — it may originate from sandbox cache (untrusted)
|
||||
example_path = _SAFE_PATH_RE.sub("", example_path) if example_path else ""
|
||||
example_path = example_path or "<skills_root>/<skill_name>/SKILL.md"
|
||||
if example_path == "<skills_root>/<skill_name>/SKILL.md":
|
||||
example_path = "<skills_root>/<skill_name>/SKILL.md"
|
||||
else:
|
||||
example_path = _sanitize_prompt_path_for_prompt(example_path)
|
||||
example_path = example_path or "<skills_root>/<skill_name>/SKILL.md"
|
||||
example_command = _build_skill_read_command_example(example_path)
|
||||
|
||||
return (
|
||||
"## Skills\n\n"
|
||||
@@ -119,8 +201,9 @@ def build_skills_prompt(skills: list[SkillInfo]) -> str:
|
||||
"*Never silently skip a matching skill* — either use it or briefly "
|
||||
"explain why you chose not to.\n"
|
||||
"3. **Mandatory grounding** — Before executing any skill you MUST "
|
||||
"first read its `SKILL.md` by running a shell command with the "
|
||||
f"**absolute path** shown above (e.g. `cat {example_path}`). "
|
||||
"first read its `SKILL.md` by running a shell command compatible "
|
||||
"with the current runtime shell and using the **absolute path** "
|
||||
f"shown above (e.g. `{example_command}`). "
|
||||
"Never rely on memory or assumptions about a skill's content.\n"
|
||||
"4. **Progressive disclosure** — Load only what is directly "
|
||||
"referenced from `SKILL.md`:\n"
|
||||
|
||||
@@ -2,7 +2,7 @@ from __future__ import annotations
|
||||
|
||||
import re
|
||||
from collections.abc import AsyncGenerator, Awaitable, Callable
|
||||
from typing import TYPE_CHECKING, Any
|
||||
from typing import Any
|
||||
|
||||
import docstring_parser
|
||||
|
||||
@@ -15,9 +15,6 @@ from astrbot.core.message.message_event_result import MessageEventResult
|
||||
from astrbot.core.provider.func_tool_manager import PY_TO_JSON_TYPE, SUPPORTED_TYPES
|
||||
from astrbot.core.provider.register import llm_tools
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from astrbot.core.astr_agent_context import AstrAgentContext
|
||||
|
||||
from ..filter.command import CommandFilter
|
||||
from ..filter.command_group import CommandGroupFilter
|
||||
from ..filter.custom_filter import CustomFilterAnd, CustomFilterOr
|
||||
@@ -619,7 +616,7 @@ class RegisteringAgent:
|
||||
kwargs["registering_agent"] = self
|
||||
return register_llm_tool(*args, **kwargs)
|
||||
|
||||
def __init__(self, agent: Agent[AstrAgentContext]) -> None:
|
||||
def __init__(self, agent: Agent[Any]) -> None:
|
||||
self._agent = agent
|
||||
|
||||
|
||||
@@ -627,7 +624,7 @@ def register_agent(
|
||||
name: str,
|
||||
instruction: str,
|
||||
tools: list[str | FunctionTool] | None = None,
|
||||
run_hooks: BaseAgentRunHooks[AstrAgentContext] | None = None,
|
||||
run_hooks: BaseAgentRunHooks[Any] | None = None,
|
||||
):
|
||||
"""注册一个 Agent
|
||||
|
||||
@@ -641,12 +638,12 @@ def register_agent(
|
||||
tools_ = tools or []
|
||||
|
||||
def decorator(awaitable: Callable[..., Awaitable[Any]]):
|
||||
AstrAgent = Agent[AstrAgentContext]
|
||||
AstrAgent = Agent[Any]
|
||||
agent = AstrAgent(
|
||||
name=name,
|
||||
instructions=instruction,
|
||||
tools=tools_,
|
||||
run_hooks=run_hooks or BaseAgentRunHooks[AstrAgentContext](),
|
||||
run_hooks=run_hooks or BaseAgentRunHooks[Any](),
|
||||
)
|
||||
handoff_tool = HandoffTool(agent=agent)
|
||||
handoff_tool.handler = awaitable
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
"""插件的重载、启停、安装、卸载等操作。"""
|
||||
|
||||
import asyncio
|
||||
import contextlib
|
||||
import functools
|
||||
import inspect
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
import traceback
|
||||
from types import ModuleType
|
||||
|
||||
@@ -29,12 +31,12 @@ from astrbot.core.utils.astrbot_path import (
|
||||
get_astrbot_config_path,
|
||||
get_astrbot_path,
|
||||
get_astrbot_plugin_path,
|
||||
get_astrbot_temp_path,
|
||||
)
|
||||
from astrbot.core.utils.io import remove_dir
|
||||
from astrbot.core.utils.metrics import Metric
|
||||
from astrbot.core.utils.requirements_utils import (
|
||||
RequirementsPrecheckFailed,
|
||||
find_missing_requirements_or_raise,
|
||||
plan_missing_requirements_install,
|
||||
)
|
||||
|
||||
from . import StarMetadata
|
||||
@@ -74,30 +76,78 @@ class PluginDependencyInstallError(Exception):
|
||||
self.error = error
|
||||
|
||||
|
||||
@contextlib.contextmanager
|
||||
def _temporary_filtered_requirements_file(
|
||||
*,
|
||||
install_lines: tuple[str, ...],
|
||||
):
|
||||
filtered_requirements_path: str | None = None
|
||||
temp_dir = get_astrbot_temp_path()
|
||||
|
||||
try:
|
||||
os.makedirs(temp_dir, exist_ok=True)
|
||||
with tempfile.NamedTemporaryFile(
|
||||
mode="w",
|
||||
suffix="_plugin_requirements.txt",
|
||||
delete=False,
|
||||
dir=temp_dir,
|
||||
encoding="utf-8",
|
||||
) as filtered_requirements_file:
|
||||
filtered_requirements_file.write("\n".join(install_lines) + "\n")
|
||||
filtered_requirements_path = filtered_requirements_file.name
|
||||
|
||||
yield filtered_requirements_path
|
||||
finally:
|
||||
if filtered_requirements_path and os.path.exists(filtered_requirements_path):
|
||||
try:
|
||||
os.remove(filtered_requirements_path)
|
||||
except OSError as exc:
|
||||
logger.warning(
|
||||
"删除临时插件依赖文件失败:%s(路径:%s)",
|
||||
exc,
|
||||
filtered_requirements_path,
|
||||
)
|
||||
|
||||
|
||||
async def _install_requirements_with_precheck(
|
||||
*,
|
||||
plugin_label: str,
|
||||
requirements_path: str,
|
||||
) -> None:
|
||||
try:
|
||||
missing = find_missing_requirements_or_raise(requirements_path)
|
||||
except RequirementsPrecheckFailed:
|
||||
install_plan = plan_missing_requirements_install(requirements_path)
|
||||
|
||||
if install_plan is None:
|
||||
logger.info(
|
||||
f"正在安装插件 {plugin_label} 的依赖库(预检查失败,回退到完整安装): "
|
||||
f"正在安装插件 {plugin_label} 的依赖库(缺失依赖预检查不可裁剪,回退到完整安装): "
|
||||
f"{requirements_path}"
|
||||
)
|
||||
await pip_installer.install(requirements_path=requirements_path)
|
||||
return
|
||||
|
||||
if not missing:
|
||||
if not install_plan.missing_names:
|
||||
logger.info(f"插件 {plugin_label} 的依赖已满足,跳过安装。")
|
||||
return
|
||||
|
||||
if not install_plan.install_lines:
|
||||
fallback_reason = install_plan.fallback_reason or "unknown reason"
|
||||
logger.info(
|
||||
"检测到插件 %s 缺失依赖,但无法安全裁剪 requirements,回退到完整安装: %s (%s)",
|
||||
plugin_label,
|
||||
requirements_path,
|
||||
fallback_reason,
|
||||
)
|
||||
await pip_installer.install(requirements_path=requirements_path)
|
||||
return
|
||||
|
||||
logger.info(
|
||||
f"检测到插件 {plugin_label} 缺失依赖,正在按 requirements.txt 安装: "
|
||||
f"{requirements_path} -> {sorted(missing)}"
|
||||
f"{requirements_path} -> {sorted(install_plan.missing_names)}"
|
||||
)
|
||||
await pip_installer.install(requirements_path=requirements_path)
|
||||
|
||||
with _temporary_filtered_requirements_file(
|
||||
install_lines=install_plan.install_lines,
|
||||
) as filtered_requirements_path:
|
||||
await pip_installer.install(requirements_path=filtered_requirements_path)
|
||||
|
||||
|
||||
class PluginManager:
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
import copy
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from astrbot import logger
|
||||
from astrbot.core.agent.agent import Agent
|
||||
from astrbot.core.agent.handoff import HandoffTool
|
||||
from astrbot.core.persona_mgr import PersonaManager
|
||||
from astrbot.core.provider.func_tool_manager import FunctionToolManager
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from astrbot.core.persona_mgr import PersonaManager
|
||||
|
||||
|
||||
class SubAgentOrchestrator:
|
||||
"""Loads subagent definitions from config and registers handoff tools.
|
||||
@@ -43,15 +46,14 @@ class SubAgentOrchestrator:
|
||||
continue
|
||||
|
||||
persona_id = item.get("persona_id")
|
||||
persona_data = None
|
||||
if persona_id:
|
||||
try:
|
||||
persona_data = await self._persona_mgr.get_persona(persona_id)
|
||||
except StopIteration:
|
||||
logger.warning(
|
||||
"SubAgent persona %s not found, fallback to inline prompt.",
|
||||
persona_id,
|
||||
)
|
||||
if persona_id is not None:
|
||||
persona_id = str(persona_id).strip() or None
|
||||
persona_data = self._persona_mgr.get_persona_v3_by_id(persona_id)
|
||||
if persona_id and persona_data is None:
|
||||
logger.warning(
|
||||
"SubAgent persona %s not found, fallback to inline prompt.",
|
||||
persona_id,
|
||||
)
|
||||
|
||||
instructions = str(item.get("system_prompt", "")).strip()
|
||||
public_description = str(item.get("public_description", "")).strip()
|
||||
@@ -62,11 +64,15 @@ class SubAgentOrchestrator:
|
||||
begin_dialogs = None
|
||||
|
||||
if persona_data:
|
||||
instructions = persona_data.system_prompt or instructions
|
||||
begin_dialogs = persona_data.begin_dialogs
|
||||
tools = persona_data.tools
|
||||
if public_description == "" and persona_data.system_prompt:
|
||||
public_description = persona_data.system_prompt[:120]
|
||||
prompt = str(persona_data.get("prompt", "")).strip()
|
||||
if prompt:
|
||||
instructions = prompt
|
||||
begin_dialogs = copy.deepcopy(
|
||||
persona_data.get("_begin_dialogs_processed")
|
||||
)
|
||||
tools = persona_data.get("tools")
|
||||
if public_description == "" and prompt:
|
||||
public_description = prompt[:120]
|
||||
if tools is None:
|
||||
tools = None
|
||||
elif not isinstance(tools, list):
|
||||
|
||||
@@ -30,7 +30,7 @@ class CreateActiveCronTool(FunctionTool[AstrAgentContext]):
|
||||
"properties": {
|
||||
"cron_expression": {
|
||||
"type": "string",
|
||||
"description": "Cron expression defining recurring schedule (e.g., '0 8 * * *').",
|
||||
"description": "Cron expression defining recurring schedule (e.g., '0 8 * * *' or '0 23 * * mon-fri'). Prefer named weekdays like 'mon-fri' or 'sat,sun' instead of numeric day-of-week ranges such as '1-5' to avoid ambiguity across cron implementations.",
|
||||
},
|
||||
"run_at": {
|
||||
"type": "string",
|
||||
|
||||
@@ -25,12 +25,22 @@ class UmopConfigRouter:
|
||||
)
|
||||
self.umop_to_conf_id = sp_data
|
||||
|
||||
@staticmethod
|
||||
def _split_umo(umo: str) -> tuple[str, str, str] | None:
|
||||
"""将 UMO 拆分为 3 个部分,同时保留 session_id 中的 ':'"""
|
||||
if not isinstance(umo, str):
|
||||
return None
|
||||
parts = umo.split(":", 2)
|
||||
if len(parts) != 3:
|
||||
return None
|
||||
return parts[0], parts[1], parts[2]
|
||||
|
||||
def _is_umo_match(self, p1: str, p2: str) -> bool:
|
||||
"""判断 p2 umo 是否逻辑包含于 p1 umo"""
|
||||
p1_ls = p1.split(":")
|
||||
p2_ls = p2.split(":")
|
||||
p1_ls = self._split_umo(p1)
|
||||
p2_ls = self._split_umo(p2)
|
||||
|
||||
if len(p1_ls) != 3 or len(p2_ls) != 3:
|
||||
if p1_ls is None or p2_ls is None:
|
||||
return False # 非法格式
|
||||
|
||||
return all(p == "" or fnmatch.fnmatchcase(t, p) for p, t in zip(p1_ls, p2_ls))
|
||||
@@ -62,7 +72,7 @@ class UmopConfigRouter:
|
||||
|
||||
"""
|
||||
for part in new_routing:
|
||||
if not isinstance(part, str) or len(part.split(":")) != 3:
|
||||
if self._split_umo(part) is None:
|
||||
raise ValueError(
|
||||
"umop keys must be strings in the format [platform_id]:[message_type]:[session_id], with optional wildcards * or empty for all",
|
||||
)
|
||||
@@ -81,7 +91,7 @@ class UmopConfigRouter:
|
||||
ValueError: 如果 umo 格式不正确
|
||||
|
||||
"""
|
||||
if not isinstance(umo, str) or len(umo.split(":")) != 3:
|
||||
if self._split_umo(umo) is None:
|
||||
raise ValueError(
|
||||
"umop must be a string in the format [platform_id]:[message_type]:[session_id], with optional wildcards * or empty for all",
|
||||
)
|
||||
@@ -99,7 +109,7 @@ class UmopConfigRouter:
|
||||
ValueError: 当 umo 格式不正确时抛出
|
||||
"""
|
||||
|
||||
if not isinstance(umo, str) or len(umo.split(":")) != 3:
|
||||
if self._split_umo(umo) is None:
|
||||
raise ValueError(
|
||||
"umop must be a string in the format [platform_id]:[message_type]:[session_id], with optional wildcards * or empty for all",
|
||||
)
|
||||
|
||||
@@ -4,7 +4,7 @@ import os
|
||||
import re
|
||||
import shlex
|
||||
import sys
|
||||
from collections.abc import Iterable, Iterator
|
||||
from collections.abc import Iterable, Iterator, Sequence
|
||||
from dataclasses import dataclass
|
||||
|
||||
from packaging.requirements import InvalidRequirement, Requirement
|
||||
@@ -29,6 +29,13 @@ class ParsedPackageInput:
|
||||
requirement_names: frozenset[str]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class MissingRequirementsPlan:
|
||||
missing_names: frozenset[str]
|
||||
install_lines: tuple[str, ...]
|
||||
fallback_reason: str | None = None
|
||||
|
||||
|
||||
def canonicalize_distribution_name(name: str) -> str:
|
||||
return re.sub(r"[-_.]+", "-", name).strip("-").lower()
|
||||
|
||||
@@ -364,8 +371,8 @@ def _load_requirement_lines_for_precheck(
|
||||
None,
|
||||
)
|
||||
if fallback_line is not None:
|
||||
logger.warning(
|
||||
"预检查缺失依赖失败,将回退到完整安装: unresolved direct reference in %s: %s",
|
||||
logger.info(
|
||||
"缺失依赖预检查发现无法安全裁剪的 option/direct-reference 行,将回退到完整安装: %s (%s)",
|
||||
requirements_path,
|
||||
fallback_line,
|
||||
)
|
||||
@@ -381,6 +388,13 @@ def find_missing_requirements(requirements_path: str) -> set[str] | None:
|
||||
if not can_precheck or requirement_lines is None:
|
||||
return None
|
||||
|
||||
return find_missing_requirements_from_lines(requirement_lines)
|
||||
|
||||
|
||||
def find_missing_requirements_from_lines(
|
||||
requirement_lines: Sequence[str],
|
||||
) -> set[str] | None:
|
||||
|
||||
required = list(iter_requirements(lines=requirement_lines))
|
||||
if not required:
|
||||
return set()
|
||||
@@ -401,6 +415,70 @@ def find_missing_requirements(requirements_path: str) -> set[str] | None:
|
||||
return missing
|
||||
|
||||
|
||||
def build_missing_requirements_install_lines(
|
||||
requirements_path: str,
|
||||
requirement_lines: Sequence[str],
|
||||
missing_names: set[str] | frozenset[str],
|
||||
) -> tuple[str, ...] | None:
|
||||
wanted_names = set(missing_names)
|
||||
install_lines: list[str] = []
|
||||
for line in requirement_lines:
|
||||
parsed = _parse_requirement_line(line)
|
||||
if parsed is None:
|
||||
if looks_like_direct_reference(line) or line.startswith(("-", "--")):
|
||||
logger.debug(
|
||||
"缺失依赖行筛选回退到完整安装:requirements 中包含无法安全裁剪的 option/direct-reference 行: %s (%s)",
|
||||
requirements_path,
|
||||
line,
|
||||
)
|
||||
return None
|
||||
continue
|
||||
|
||||
name, _specifier = parsed
|
||||
if name in wanted_names:
|
||||
install_lines.append(line)
|
||||
|
||||
return tuple(install_lines)
|
||||
|
||||
|
||||
def plan_missing_requirements_install(
|
||||
requirements_path: str,
|
||||
) -> MissingRequirementsPlan | None:
|
||||
can_precheck, requirement_lines = _load_requirement_lines_for_precheck(
|
||||
requirements_path
|
||||
)
|
||||
if not can_precheck or requirement_lines is None:
|
||||
return None
|
||||
|
||||
missing = find_missing_requirements_from_lines(requirement_lines)
|
||||
if missing is None:
|
||||
return None
|
||||
|
||||
install_lines = build_missing_requirements_install_lines(
|
||||
requirements_path,
|
||||
requirement_lines,
|
||||
missing,
|
||||
)
|
||||
if install_lines is None:
|
||||
return None
|
||||
if missing and not install_lines:
|
||||
logger.warning(
|
||||
"预检查缺失依赖成功,但无法映射到可安装 requirement 行,将回退到完整安装: %s -> %s",
|
||||
requirements_path,
|
||||
sorted(missing),
|
||||
)
|
||||
return MissingRequirementsPlan(
|
||||
missing_names=frozenset(missing),
|
||||
install_lines=(),
|
||||
fallback_reason="unmapped missing requirement names",
|
||||
)
|
||||
|
||||
return MissingRequirementsPlan(
|
||||
missing_names=frozenset(missing),
|
||||
install_lines=install_lines,
|
||||
)
|
||||
|
||||
|
||||
def find_missing_requirements_or_raise(requirements_path: str) -> set[str]:
|
||||
missing = find_missing_requirements(requirements_path)
|
||||
if missing is None:
|
||||
|
||||
@@ -82,7 +82,8 @@ class AuthRoute(Route):
|
||||
def generate_jwt(self, username):
|
||||
payload = {
|
||||
"username": username,
|
||||
"exp": datetime.datetime.utcnow() + datetime.timedelta(days=7),
|
||||
"exp": datetime.datetime.now(datetime.timezone.utc)
|
||||
+ datetime.timedelta(days=7),
|
||||
}
|
||||
jwt_token = self.config["dashboard"].get("jwt_secret", None)
|
||||
if not jwt_token:
|
||||
|
||||
@@ -977,7 +977,17 @@ class BackupRoute(Route):
|
||||
if not jwt_secret:
|
||||
return Response().error("服务器配置错误").__dict__
|
||||
|
||||
jwt.decode(token, jwt_secret, algorithms=["HS256"])
|
||||
# Verify JWT token with strict security options
|
||||
jwt.decode(
|
||||
token,
|
||||
jwt_secret,
|
||||
algorithms=["HS256"],
|
||||
options={
|
||||
"require": ["exp"], # Require expiration claim
|
||||
"verify_signature": True, # Explicitly verify signature
|
||||
"verify_exp": True, # Verify expiration
|
||||
},
|
||||
)
|
||||
except jwt.ExpiredSignatureError:
|
||||
return Response().error("Token 已过期,请刷新页面后重试").__dict__
|
||||
except jwt.InvalidTokenError:
|
||||
|
||||
@@ -36,6 +36,20 @@ async def track_conversation(convs: dict, conv_id: str):
|
||||
convs.pop(conv_id, None)
|
||||
|
||||
|
||||
async def _poll_webchat_stream_result(back_queue, username: str):
|
||||
try:
|
||||
result = await asyncio.wait_for(back_queue.get(), timeout=1)
|
||||
except asyncio.TimeoutError:
|
||||
return None, False
|
||||
except asyncio.CancelledError:
|
||||
logger.debug(f"[WebChat] 用户 {username} 断开聊天长连接。")
|
||||
return None, True
|
||||
except Exception as e:
|
||||
logger.error(f"WebChat stream error: {e}")
|
||||
return None, False
|
||||
return result, False
|
||||
|
||||
|
||||
class ChatRoute(Route):
|
||||
def __init__(
|
||||
self,
|
||||
@@ -51,6 +65,7 @@ class ChatRoute(Route):
|
||||
"/chat/get_session": ("GET", self.get_session),
|
||||
"/chat/stop": ("POST", self.stop_session),
|
||||
"/chat/delete_session": ("GET", self.delete_webchat_session),
|
||||
"/chat/batch_delete_sessions": ("POST", self.batch_delete_sessions),
|
||||
"/chat/update_session_display_name": (
|
||||
"POST",
|
||||
self.update_session_display_name,
|
||||
@@ -342,16 +357,12 @@ class ChatRoute(Route):
|
||||
|
||||
async with track_conversation(self.running_convs, webchat_conv_id):
|
||||
while True:
|
||||
try:
|
||||
result = await asyncio.wait_for(back_queue.get(), timeout=1)
|
||||
except asyncio.TimeoutError:
|
||||
continue
|
||||
except asyncio.CancelledError:
|
||||
logger.debug(f"[WebChat] 用户 {username} 断开聊天长连接。")
|
||||
result, should_break = await _poll_webchat_stream_result(
|
||||
back_queue, username
|
||||
)
|
||||
if should_break:
|
||||
client_disconnected = True
|
||||
except Exception as e:
|
||||
logger.error(f"WebChat stream error: {e}")
|
||||
|
||||
break
|
||||
if not result:
|
||||
continue
|
||||
|
||||
@@ -578,19 +589,9 @@ class ChatRoute(Route):
|
||||
|
||||
return Response().ok(data={"stopped_count": stopped_count}).__dict__
|
||||
|
||||
async def delete_webchat_session(self):
|
||||
"""Delete a Platform session and all its related data."""
|
||||
session_id = request.args.get("session_id")
|
||||
if not session_id:
|
||||
return Response().error("Missing key: session_id").__dict__
|
||||
username = g.get("username", "guest")
|
||||
|
||||
# 验证会话是否存在且属于当前用户
|
||||
session = await self.db.get_platform_session_by_id(session_id)
|
||||
if not session:
|
||||
return Response().error(f"Session {session_id} not found").__dict__
|
||||
if session.creator != username:
|
||||
return Response().error("Permission denied").__dict__
|
||||
async def _delete_session_internal(self, session, username: str) -> None:
|
||||
"""Delete a single session and all its related data."""
|
||||
session_id = session.session_id
|
||||
|
||||
# 删除该会话下的所有对话
|
||||
message_type = "GroupMessage" if session.is_group else "FriendMessage"
|
||||
@@ -632,8 +633,70 @@ class ChatRoute(Route):
|
||||
# 删除会话
|
||||
await self.db.delete_platform_session(session_id)
|
||||
|
||||
async def delete_webchat_session(self):
|
||||
"""Delete a Platform session and all its related data."""
|
||||
session_id = request.args.get("session_id")
|
||||
if not session_id:
|
||||
return Response().error("Missing key: session_id").__dict__
|
||||
username = g.get("username", "guest")
|
||||
|
||||
session = await self.db.get_platform_session_by_id(session_id)
|
||||
if not session:
|
||||
return Response().error(f"Session {session_id} not found").__dict__
|
||||
if session.creator != username:
|
||||
return Response().error("Permission denied").__dict__
|
||||
|
||||
await self._delete_session_internal(session, username)
|
||||
|
||||
return Response().ok().__dict__
|
||||
|
||||
async def batch_delete_sessions(self):
|
||||
"""Batch delete multiple Platform sessions."""
|
||||
post_data = await request.json
|
||||
if post_data is None:
|
||||
return Response().error("Missing JSON body").__dict__
|
||||
if not isinstance(post_data, dict):
|
||||
return Response().error("Invalid JSON body: expected object").__dict__
|
||||
|
||||
session_ids = post_data.get("session_ids")
|
||||
if not session_ids or not isinstance(session_ids, list):
|
||||
return Response().error("Missing or invalid key: session_ids").__dict__
|
||||
|
||||
username = g.get("username", "guest")
|
||||
sessions = await self.db.get_platform_sessions_by_ids(session_ids)
|
||||
sessions_by_id = {session.session_id: session for session in sessions}
|
||||
deleted_count = 0
|
||||
failed_items = []
|
||||
|
||||
for sid in session_ids:
|
||||
session = sessions_by_id.get(sid)
|
||||
if not session:
|
||||
failed_items.append({"session_id": sid, "reason": "not found"})
|
||||
continue
|
||||
if session.creator != username:
|
||||
failed_items.append({"session_id": sid, "reason": "permission denied"})
|
||||
continue
|
||||
|
||||
try:
|
||||
await self._delete_session_internal(session, username)
|
||||
deleted_count += 1
|
||||
sessions_by_id.pop(sid, None)
|
||||
except Exception:
|
||||
logger.warning("Failed to delete session %s", sid)
|
||||
failed_items.append({"session_id": sid, "reason": "internal_error"})
|
||||
|
||||
return (
|
||||
Response()
|
||||
.ok(
|
||||
data={
|
||||
"deleted_count": deleted_count,
|
||||
"failed_count": len(failed_items),
|
||||
"failed_items": failed_items,
|
||||
}
|
||||
)
|
||||
.__dict__
|
||||
)
|
||||
|
||||
def _extract_attachment_ids(self, history_list) -> list[str]:
|
||||
"""从消息历史中提取所有 attachment_id"""
|
||||
attachment_ids = []
|
||||
|
||||
@@ -43,7 +43,6 @@ class SessionManagementRoute(Route):
|
||||
"/session/group/create": ("POST", self.create_group),
|
||||
"/session/group/update": ("POST", self.update_group),
|
||||
"/session/group/delete": ("POST", self.delete_group),
|
||||
"/session/group/update-config": ("POST", self.update_group_config),
|
||||
}
|
||||
self.conv_mgr = core_lifecycle.conversation_manager
|
||||
self.core_lifecycle = core_lifecycle
|
||||
@@ -146,20 +145,9 @@ class SessionManagementRoute(Route):
|
||||
page=page, page_size=page_size, search=search
|
||||
)
|
||||
|
||||
# 收集属于有配置分组的 UMO,避免重复显示
|
||||
grouped_umos = set()
|
||||
groups = self._get_groups()
|
||||
for group_data in groups.values():
|
||||
if group_data.get("config"):
|
||||
grouped_umos.update(group_data.get("umos", []))
|
||||
|
||||
# 构建规则列表(排除已被分组管理的 UMO)
|
||||
# 构建规则列表
|
||||
rules_list = []
|
||||
filtered_count = 0
|
||||
for umo, rules in umo_rules.items():
|
||||
if umo in grouped_umos:
|
||||
filtered_count += 1
|
||||
continue
|
||||
rule_info = {
|
||||
"umo": umo,
|
||||
"rules": rules,
|
||||
@@ -171,7 +159,6 @@ class SessionManagementRoute(Route):
|
||||
rule_info["message_type"] = parts[1]
|
||||
rule_info["session_id"] = parts[2]
|
||||
rules_list.append(rule_info)
|
||||
total -= filtered_count
|
||||
|
||||
# 获取可用的 providers 和 personas
|
||||
provider_manager = self.core_lifecycle.provider_manager
|
||||
@@ -253,7 +240,6 @@ class SessionManagementRoute(Route):
|
||||
"available_plugins": available_plugins,
|
||||
"available_kbs": available_kbs,
|
||||
"available_rule_keys": AVAILABLE_SESSION_RULE_KEYS,
|
||||
"group_rules": self._get_group_rules(),
|
||||
}
|
||||
)
|
||||
.__dict__
|
||||
@@ -807,51 +793,6 @@ class SessionManagementRoute(Route):
|
||||
"""保存分组"""
|
||||
sp.put("session_groups", groups)
|
||||
|
||||
def _get_group_rules(self) -> list:
|
||||
"""获取有配置的分组列表,用于在规则列表中显示"""
|
||||
groups = self._get_groups()
|
||||
group_rules = []
|
||||
for group_id, group_data in groups.items():
|
||||
config = group_data.get("config", {})
|
||||
if config: # 只返回有配置的分组
|
||||
group_rules.append(
|
||||
{
|
||||
"group_id": group_id,
|
||||
"name": group_data.get("name", ""),
|
||||
"umo_count": len(group_data.get("umos", [])),
|
||||
"config": config,
|
||||
}
|
||||
)
|
||||
return group_rules
|
||||
|
||||
async def _sync_group_config_to_umos(
|
||||
self, config: dict, umos: list[str]
|
||||
) -> tuple[int, list[str]]:
|
||||
"""将分组配置同步到指定的 UMO 列表
|
||||
|
||||
Returns:
|
||||
(success_count, failed_umos)
|
||||
"""
|
||||
success_count = 0
|
||||
failed_umos = []
|
||||
for umo in umos:
|
||||
try:
|
||||
for rule_key, rule_value in config.items():
|
||||
if rule_key not in AVAILABLE_SESSION_RULE_KEYS:
|
||||
continue
|
||||
if rule_value is None:
|
||||
continue
|
||||
if rule_key == "session_plugin_config":
|
||||
# session_plugin_config 需要包裹 umo key
|
||||
await sp.session_put(umo, rule_key, {umo: rule_value})
|
||||
else:
|
||||
await sp.session_put(umo, rule_key, rule_value)
|
||||
success_count += 1
|
||||
except Exception as e:
|
||||
logger.error(f"同步配置到 {umo} 失败: {e!s}")
|
||||
failed_umos.append(umo)
|
||||
return success_count, failed_umos
|
||||
|
||||
async def list_groups(self):
|
||||
"""获取所有分组列表"""
|
||||
try:
|
||||
@@ -865,7 +806,6 @@ class SessionManagementRoute(Route):
|
||||
"name": group_data.get("name", ""),
|
||||
"umos": group_data.get("umos", []),
|
||||
"umo_count": len(group_data.get("umos", [])),
|
||||
"config": group_data.get("config", {}),
|
||||
}
|
||||
)
|
||||
return Response().ok({"groups": groups_list}).__dict__
|
||||
@@ -935,7 +875,6 @@ class SessionManagementRoute(Route):
|
||||
return Response().error(f"分组 '{group_id}' 不存在").__dict__
|
||||
|
||||
group = groups[group_id]
|
||||
old_umos = set(group.get("umos", []))
|
||||
|
||||
# 更新名称
|
||||
if name is not None:
|
||||
@@ -944,7 +883,6 @@ class SessionManagementRoute(Route):
|
||||
# 直接设置 umos 列表
|
||||
if umos is not None:
|
||||
group["umos"] = umos
|
||||
new_umos = set(umos)
|
||||
else:
|
||||
# 增量更新
|
||||
current_umos = set(group.get("umos", []))
|
||||
@@ -953,21 +891,9 @@ class SessionManagementRoute(Route):
|
||||
if remove_umos:
|
||||
current_umos.difference_update(remove_umos)
|
||||
group["umos"] = list(current_umos)
|
||||
new_umos = current_umos
|
||||
|
||||
self._save_groups(groups)
|
||||
|
||||
# 自动同步分组配置给新加入的成员
|
||||
group_config = group.get("config", {})
|
||||
newly_added = new_umos - old_umos
|
||||
if group_config and newly_added:
|
||||
sync_count, _ = await self._sync_group_config_to_umos(
|
||||
group_config, list(newly_added)
|
||||
)
|
||||
logger.info(
|
||||
f"自动同步分组 '{group['name']}' 配置到 {sync_count} 个新成员"
|
||||
)
|
||||
|
||||
return (
|
||||
Response()
|
||||
.ok(
|
||||
@@ -1010,81 +936,3 @@ class SessionManagementRoute(Route):
|
||||
except Exception as e:
|
||||
logger.error(f"删除分组失败: {e!s}")
|
||||
return Response().error(f"删除分组失败: {e!s}").__dict__
|
||||
|
||||
async def update_group_config(self):
|
||||
"""更新分组的配置,并同步到所有成员 UMO
|
||||
|
||||
请求体:
|
||||
{
|
||||
"group_id": "分组ID",
|
||||
"config": {
|
||||
"session_service_config": {...},
|
||||
"session_plugin_config": {...},
|
||||
"kb_config": {...},
|
||||
"provider_perf_chat_completion": ...,
|
||||
"provider_perf_speech_to_text": ...,
|
||||
"provider_perf_text_to_speech": ...
|
||||
}
|
||||
}
|
||||
"""
|
||||
try:
|
||||
data = await request.get_json()
|
||||
group_id = data.get("group_id")
|
||||
config = data.get("config", {})
|
||||
|
||||
if not group_id:
|
||||
return Response().error("缺少必要参数: group_id").__dict__
|
||||
|
||||
groups = self._get_groups()
|
||||
|
||||
if group_id not in groups:
|
||||
return Response().error(f"分组 '{group_id}' 不存在").__dict__
|
||||
|
||||
group = groups[group_id]
|
||||
|
||||
# 保存配置到分组
|
||||
group["config"] = config
|
||||
self._save_groups(groups)
|
||||
|
||||
# 同步到所有成员 UMO
|
||||
umos = group.get("umos", [])
|
||||
|
||||
if not config:
|
||||
# 空配置 → 清除成员上的所有分组下发规则
|
||||
success_count = 0
|
||||
failed_umos = []
|
||||
for umo in umos:
|
||||
try:
|
||||
for rule_key in AVAILABLE_SESSION_RULE_KEYS:
|
||||
try:
|
||||
await sp.session_remove(umo, rule_key)
|
||||
except Exception:
|
||||
pass
|
||||
success_count += 1
|
||||
except Exception as e:
|
||||
logger.error(f"清除 {umo} 规则失败: {e!s}")
|
||||
failed_umos.append(umo)
|
||||
else:
|
||||
success_count, failed_umos = await self._sync_group_config_to_umos(
|
||||
config, umos
|
||||
)
|
||||
|
||||
msg = f"分组 '{group['name']}' 配置已保存并同步到 {success_count}/{len(umos)} 个会话"
|
||||
if failed_umos:
|
||||
msg += f",{len(failed_umos)} 个失败"
|
||||
|
||||
return (
|
||||
Response()
|
||||
.ok(
|
||||
{
|
||||
"message": msg,
|
||||
"success_count": success_count,
|
||||
"failed_count": len(failed_umos),
|
||||
"failed_umos": failed_umos,
|
||||
}
|
||||
)
|
||||
.__dict__
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"更新分组配置失败: {e!s}")
|
||||
return Response().error(f"更新分组配置失败: {e!s}").__dict__
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
## What's Changed
|
||||
|
||||
### 新增
|
||||
|
||||
- 新增俄语翻译([#6081](https://github.com/AstrBotDevs/AstrBot/pull/6081))。
|
||||
- QQ 官方 Bot 新增文件、语音、视频消息支持(含 WebSocket 模式)([#6063](https://github.com/AstrBotDevs/AstrBot/pull/6063))。
|
||||
|
||||
### 优化
|
||||
|
||||
- 优化 QQ 官方 Bot 的流式消息投递可靠性与主动媒体发送能力([#6131](https://github.com/AstrBotDevs/AstrBot/pull/6131))。
|
||||
- 优化边界场景下 booter 选择逻辑与消息发送工具([#6064](https://github.com/AstrBotDevs/AstrBot/pull/6064))。
|
||||
|
||||
### 修复
|
||||
|
||||
- 修复 Dashboard README 对话框锚点导航失效([#6083](https://github.com/AstrBotDevs/AstrBot/pull/6083))。
|
||||
- 优先使用具名 weekday 的 cron 示例,避免歧义([#6091](https://github.com/AstrBotDevs/AstrBot/pull/6091))。
|
||||
- 修复插件市场安装后状态未及时刷新的问题([#6124](https://github.com/AstrBotDevs/AstrBot/pull/6124))。
|
||||
- 修复插件依赖安装逻辑:仅安装缺失依赖([#6088](https://github.com/AstrBotDevs/AstrBot/pull/6088))。
|
||||
- 移除 Telegram 适配器中已废弃的 `normalize_whitespace` 参数([#6044](https://github.com/AstrBotDevs/AstrBot/pull/6044))。
|
||||
- 修复 Windows 本地 skill 文件读取问题([#6028](https://github.com/AstrBotDevs/AstrBot/pull/6028))。
|
||||
- 修复 Discord pre-ack emoji 配置重启后不持久化的问题([#6031](https://github.com/AstrBotDevs/AstrBot/pull/6031))。
|
||||
- 统一 WebUI 搜索框清空行为([#6017](https://github.com/AstrBotDevs/AstrBot/pull/6017))。
|
||||
- 优化插件依赖自动安装流程与 Dashboard 安装体验([#5954](https://github.com/AstrBotDevs/AstrBot/pull/5954))。
|
||||
|
||||
|
||||
### 文档
|
||||
|
||||
- 新增 Astrbook 和玖帕喵社区链接([#6135](https://github.com/AstrBotDevs/AstrBot/pull/6135))。
|
||||
- 修正文档 `docker.md` 与 `napcat.md` 中的拼写错误([#6048](https://github.com/AstrBotDevs/AstrBot/pull/6048))。
|
||||
- 在多语言 README 中补充官方开发群号,并改进配置元数据中的正则说明。
|
||||
- 更新编辑链接模式并移除过时仓库引用。
|
||||
|
||||
---
|
||||
|
||||
## What's Changed (EN)
|
||||
|
||||
### New Features
|
||||
|
||||
- Added Russian translation support ([#6081](https://github.com/AstrBotDevs/AstrBot/pull/6081)).
|
||||
- Added file, voice, and video message support for QQ Official Bot (including WebSocket mode) ([#6063](https://github.com/AstrBotDevs/AstrBot/pull/6063)).
|
||||
|
||||
### Improvements
|
||||
|
||||
- Improved streaming message delivery reliability and proactive media sending for QQ Official API ([#6131](https://github.com/AstrBotDevs/AstrBot/pull/6131)).
|
||||
- Optimized booter selection logic in edge cases and message sending tooling ([#6064](https://github.com/AstrBotDevs/AstrBot/pull/6064)).
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Fixed broken README dialog anchor navigation in the Dashboard ([#6083](https://github.com/AstrBotDevs/AstrBot/pull/6083)).
|
||||
- Preferred named weekday cron examples to reduce ambiguity ([#6091](https://github.com/AstrBotDevs/AstrBot/pull/6091)).
|
||||
- Fixed plugin market install-state refresh after installation ([#6124](https://github.com/AstrBotDevs/AstrBot/pull/6124)).
|
||||
- Fixed plugin dependency installation logic to install only missing packages ([#6088](https://github.com/AstrBotDevs/AstrBot/pull/6088)).
|
||||
- Removed deprecated `normalize_whitespace` parameter in the Telegram adapter ([#6044](https://github.com/AstrBotDevs/AstrBot/pull/6044)).
|
||||
- Fixed local skill file reading issues on Windows ([#6028](https://github.com/AstrBotDevs/AstrBot/pull/6028)).
|
||||
- Fixed Discord pre-ack emoji config not being persisted across restarts ([#6031](https://github.com/AstrBotDevs/AstrBot/pull/6031)).
|
||||
- Unified WebUI search input clear behavior ([#6017](https://github.com/AstrBotDevs/AstrBot/pull/6017)).
|
||||
- Improved plugin dependency auto-install flow and Dashboard installation experience ([#5954](https://github.com/AstrBotDevs/AstrBot/pull/5954)).
|
||||
|
||||
### Documentation
|
||||
|
||||
- Added Astrbook and Jiupa Miao community links ([#6135](https://github.com/AstrBotDevs/AstrBot/pull/6135)).
|
||||
- Fixed typos in `docker.md` and `napcat.md` ([#6048](https://github.com/AstrBotDevs/AstrBot/pull/6048)).
|
||||
- Added official developer group IDs to multilingual READMEs and improved regex description in config metadata.
|
||||
- Updated edit-link patterns and removed obsolete repository references.
|
||||
@@ -0,0 +1,93 @@
|
||||
## What's Changed
|
||||
|
||||
### 新增
|
||||
|
||||
- 补充 MiniMax Provider。([#6318](https://github.com/AstrBotDevs/AstrBot/pull/6318))
|
||||
- 新增 WebUI ChatUI 页面的会话批量删除功能。([#6160](https://github.com/AstrBotDevs/AstrBot/pull/6160))
|
||||
- 新增 WebUI ChatUI 配置发送快捷键。([#6272](https://github.com/AstrBotDevs/AstrBot/pull/6272))
|
||||
|
||||
### 优化
|
||||
|
||||
- 优化 UMO 处理兼容性。([#5996](https://github.com/AstrBotDevs/AstrBot/pull/5996))
|
||||
- 重构 `_extract_session_id`,改进聊天类型分支处理。(#5775)
|
||||
- 优化聊天组件行为,使用 `shiki` 进行代码块渲染。([#6286](https://github.com/AstrBotDevs/AstrBot/pull/6286))
|
||||
- 优化 WebUI 主题配色与视觉体验。([#6263](https://github.com/AstrBotDevs/AstrBot/pull/6263))
|
||||
- 优化 OneBot @ 组件后处理,避免消息文本解析空格问题。([#6238](https://github.com/AstrBotDevs/AstrBot/pull/6238))
|
||||
|
||||
### 修复
|
||||
|
||||
- 修复创建新 Provider 后未同步 `providers_config` 的问题。([#6388](https://github.com/AstrBotDevs/AstrBot/pull/6388))
|
||||
- 修复 API 返回 `null choices` 时的 `TypeError`。([#6313](https://github.com/AstrBotDevs/AstrBot/pull/6313))
|
||||
- 修复 QQ Webhook 重试回调重复触发的问题。([#6320](https://github.com/AstrBotDevs/AstrBot/pull/6320))
|
||||
- 修复流式模式下 `delta` 为 `None` 导致工具调用时报错的问题。([#6365](https://github.com/AstrBotDevs/AstrBot/pull/6365))
|
||||
- 修复模型服务链接说明文字错误。([#6296](https://github.com/AstrBotDevs/AstrBot/pull/6296))
|
||||
- 修复 AI 在 tool-calling 模式设为 `skills-like` 时发送媒体失败的问题。([#6317](https://github.com/AstrBotDevs/AstrBot/pull/6317))
|
||||
- 修复 Telegram 适配器中 GIF 被错误转成静态图的问题。([#6329](https://github.com/AstrBotDevs/AstrBot/pull/6329))
|
||||
- 将 Provider 图标来源替换为 jsDelivr CDN 地址,修复部分环境下图标加载问题。([#6340](https://github.com/AstrBotDevs/AstrBot/pull/6340))
|
||||
- 修复 QQ 官方表情消息未解析为可读文本的问题。([#6355](https://github.com/AstrBotDevs/AstrBot/pull/6355))
|
||||
- 修复 WebChat 队列异常时流式结果页面崩溃的问题。([#6123](https://github.com/AstrBotDevs/AstrBot/pull/6123))
|
||||
- 修复子代理 handoff 工具在插件过滤时丢失的问题。([#6155](https://github.com/AstrBotDevs/AstrBot/pull/6155))
|
||||
- 修复 Cron 提示文案缺少空格及 `utcnow()` 的弃用警告问题。([#6192](https://github.com/AstrBotDevs/AstrBot/pull/6192))
|
||||
- 修复 WebUI 启动时 Sidebar hash 导航抖动/定位问题。([#6159](https://github.com/AstrBotDevs/AstrBot/pull/6159))
|
||||
- 修复启动重试过程中移除已移除 API Key 的 `ValueError` 报错。([#6193](https://github.com/AstrBotDevs/AstrBot/pull/6193))
|
||||
- 修复 README 启动命令引用更新为 `astrbot run`。([#6189](https://github.com/AstrBotDevs/AstrBot/pull/6189))
|
||||
- 修复 `Plain.toDict()` 在 `@` 提及场景下空白字符丢失的问题。([#6244](https://github.com/AstrBotDevs/AstrBot/pull/6244))
|
||||
- 修复 provider 依赖重复定义问题。([#6247](https://github.com/AstrBotDevs/AstrBot/pull/6247))
|
||||
- 修复 Telegram 中普通回复被误判为线程的处理问题。([#6174](https://github.com/AstrBotDevs/AstrBot/pull/6174))
|
||||
|
||||
### 其他
|
||||
|
||||
- 调整 `astrbot.service` 及 CI 配置,升级 GitHub Actions 版本。
|
||||
|
||||
---
|
||||
|
||||
## What's Changed (EN)
|
||||
|
||||
### New Features
|
||||
|
||||
- Added OpenRouter chat completion provider adapter with support for custom headers ([#6436](https://github.com/AstrBotDevs/AstrBot/pull/6436)).
|
||||
- Added MiniMax provider ([#6318](https://github.com/AstrBotDevs/AstrBot/pull/6318)).
|
||||
- Added batch conversation deletion in WebChat ([#6160](https://github.com/AstrBotDevs/AstrBot/pull/6160)).
|
||||
- Added send shortcut settings and localization support for WebChat input ([#6272](https://github.com/AstrBotDevs/AstrBot/pull/6272)).
|
||||
- Added local temporary directory binding in YAML config ([#6191](https://github.com/AstrBotDevs/AstrBot/pull/6191)).
|
||||
|
||||
### Improvements
|
||||
|
||||
- Improved UMO processing compatibility ([#5996](https://github.com/AstrBotDevs/AstrBot/pull/5996)).
|
||||
- Refactored `_extract_session_id` for chat type handling (#5775).
|
||||
- Improved chat component behavior and uses `shiki` for code-block rendering ([#6286](https://github.com/AstrBotDevs/AstrBot/pull/6286)).
|
||||
- Improved WebUI theme color and visual behavior ([#6263](https://github.com/AstrBotDevs/AstrBot/pull/6263)).
|
||||
- Improved OneBot `@` component spacing handling ([#6238](https://github.com/AstrBotDevs/AstrBot/pull/6238)).
|
||||
- Improved PR checklist validation and closure messaging.
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Fixed missing `providers_config` sync after creating new providers ([#6388](https://github.com/AstrBotDevs/AstrBot/pull/6388)).
|
||||
- Fixed `TypeError` when API returns null choices ([#6313](https://github.com/AstrBotDevs/AstrBot/pull/6313)).
|
||||
- Fixed repeated QQ webhook retry callbacks ([#6320](https://github.com/AstrBotDevs/AstrBot/pull/6320)).
|
||||
- Fixed tool-calling streaming null `delta` handling to prevent `AttributeError` ([#6365](https://github.com/AstrBotDevs/AstrBot/pull/6365)).
|
||||
- Fixed model service link wording in docs/config ([#6296](https://github.com/AstrBotDevs/AstrBot/pull/6296)).
|
||||
- Fixed AI media sending failure when tool-calling mode is set to `skills-like` ([#6317](https://github.com/AstrBotDevs/AstrBot/pull/6317)).
|
||||
- Fixed GIF being sent as static image in Telegram adapter ([#6329](https://github.com/AstrBotDevs/AstrBot/pull/6329)).
|
||||
- Replaced npm registry URLs with jsDelivr CDN for provider icons ([#6340](https://github.com/AstrBotDevs/AstrBot/pull/6340)).
|
||||
- Fixed QQ official face message parsing to readable text ([#6355](https://github.com/AstrBotDevs/AstrBot/pull/6355)).
|
||||
- Fixed WebChat stream-result crash on queue errors ([#6123](https://github.com/AstrBotDevs/AstrBot/pull/6123)).
|
||||
- Preserved subagent handoff tools during plugin filtering ([#6155](https://github.com/AstrBotDevs/AstrBot/pull/6155)).
|
||||
- Fixed cron prompt spacing and deprecated `utcnow()` usage ([#6192](https://github.com/AstrBotDevs/AstrBot/pull/6192)).
|
||||
- Fixed unstable sidebar hash navigation on startup ([#6159](https://github.com/AstrBotDevs/AstrBot/pull/6159)).
|
||||
- Fixed `ValueError` in retry loop when removing an already removed API key ([#6193](https://github.com/AstrBotDevs/AstrBot/pull/6193)).
|
||||
- Updated startup command to `astrbot run` across READMEs ([#6189](https://github.com/AstrBotDevs/AstrBot/pull/6189)).
|
||||
- Preserved whitespace in `Plain.toDict()` for @ mentions ([#6244](https://github.com/AstrBotDevs/AstrBot/pull/6244)).
|
||||
- Removed duplicate dependencies entries ([#6247](https://github.com/AstrBotDevs/AstrBot/pull/6247)).
|
||||
- Fixed Telegram normal reply being treated as topic thread ([#6174](https://github.com/AstrBotDevs/AstrBot/pull/6174)).
|
||||
|
||||
### Documentation
|
||||
|
||||
- Updated `rainyun` backup/access documentation ([#6427](https://github.com/AstrBotDevs/AstrBot/pull/6427)).
|
||||
- Updated `package.md` and platform docs, including Matrix and Wecom AI bot documentation.
|
||||
- Fixed Discord invite link in community docs.
|
||||
|
||||
### Chores
|
||||
|
||||
- Updated PR templates/checklist workflow, repository service config, and automated checks.
|
||||
- Refreshed repository automation and formatting maintenance, and removed obsolete changelog scripts.
|
||||
@@ -37,6 +37,7 @@ services:
|
||||
- DEFAULT_SHIP_MEMORY=512m
|
||||
volumes:
|
||||
- ${PWD}/data/shipyard/bay_data:/app/data
|
||||
- ${PWD}/data/temp:/AstrBot/data/temp # Bind the local temp directory to the sandbox so that the uploaded file can be accessed in the sandbox
|
||||
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||
networks:
|
||||
- astrbot_network
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
version: '3.8'
|
||||
|
||||
# 当接入 QQ NapCat 时,请使用这个 compose 文件一键部署: https://github.com/NapNeko/NapCat-Docker/blob/main/compose/astrbot.yml
|
||||
|
||||
services:
|
||||
|
||||
+13
-8
@@ -17,17 +17,17 @@
|
||||
"@tiptap/starter-kit": "2.1.7",
|
||||
"@tiptap/vue-3": "2.1.7",
|
||||
"apexcharts": "3.42.0",
|
||||
"axios": ">=1.6.2 <1.10.0 || >1.10.0 <2.0.0",
|
||||
"axios": "1.13.5",
|
||||
"axios-mock-adapter": "^1.22.0",
|
||||
"chance": "1.1.11",
|
||||
"date-fns": "2.30.0",
|
||||
"dompurify": "^3.3.1",
|
||||
"dompurify": "^3.3.2",
|
||||
"event-source-polyfill": "^1.0.31",
|
||||
"highlight.js": "^11.11.1",
|
||||
"js-md5": "^0.8.3",
|
||||
"katex": "^0.16.27",
|
||||
"lodash": "4.17.21",
|
||||
"markdown-it": "^14.1.0",
|
||||
"lodash": "4.17.23",
|
||||
"markdown-it": "^14.1.1",
|
||||
"markstream-vue": "^0.0.6",
|
||||
"mermaid": "^11.12.2",
|
||||
"monaco-editor": "^0.52.2",
|
||||
@@ -36,9 +36,8 @@
|
||||
"remixicon": "3.5.0",
|
||||
"shiki": "^3.20.0",
|
||||
"stream-markdown": "^0.0.13",
|
||||
"stream-monaco": "^0.0.17",
|
||||
"vee-validate": "4.11.3",
|
||||
"vite-plugin-vuetify": "1.0.2",
|
||||
"vite-plugin-vuetify": "2.1.3",
|
||||
"vue": "3.3.4",
|
||||
"vue-i18n": "^11.1.5",
|
||||
"vue-router": "4.2.4",
|
||||
@@ -54,7 +53,7 @@
|
||||
"@types/dompurify": "^3.0.5",
|
||||
"@types/markdown-it": "^14.1.2",
|
||||
"@types/node": "^20.5.7",
|
||||
"@vitejs/plugin-vue": "4.3.3",
|
||||
"@vitejs/plugin-vue": "5.2.4",
|
||||
"@vue/eslint-config-prettier": "8.0.0",
|
||||
"@vue/eslint-config-typescript": "11.0.3",
|
||||
"@vue/tsconfig": "^0.4.0",
|
||||
@@ -64,9 +63,15 @@
|
||||
"sass": "1.66.1",
|
||||
"sass-loader": "13.3.2",
|
||||
"typescript": "5.1.6",
|
||||
"vite": "4.4.9",
|
||||
"vite": "6.4.1",
|
||||
"vue-cli-plugin-vuetify": "2.5.8",
|
||||
"vue-tsc": "1.8.8",
|
||||
"vuetify-loader": "^2.0.0-alpha.9"
|
||||
},
|
||||
"pnpm": {
|
||||
"overrides": {
|
||||
"immutable": "4.3.8",
|
||||
"lodash-es": "4.17.23"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+601
-271
File diff suppressed because it is too large
Load Diff
@@ -11,6 +11,7 @@
|
||||
:currSessionId="currSessionId"
|
||||
:selectedProjectId="selectedProjectId"
|
||||
:transportMode="transportMode"
|
||||
:sendShortcut="sendShortcut"
|
||||
:isDark="isDark"
|
||||
:chatboxMode="chatboxMode"
|
||||
:isMobile="isMobile"
|
||||
@@ -20,6 +21,7 @@
|
||||
@selectConversation="handleSelectConversation"
|
||||
@editTitle="showEditTitleDialog"
|
||||
@deleteConversation="handleDeleteConversation"
|
||||
@batchDeleteConversations="handleBatchDeleteConversations"
|
||||
@closeMobileSidebar="closeMobileSidebar"
|
||||
@toggleTheme="toggleTheme"
|
||||
@toggleFullscreen="toggleFullscreen"
|
||||
@@ -28,6 +30,7 @@
|
||||
@editProject="showEditProjectDialog"
|
||||
@deleteProject="handleDeleteProject"
|
||||
@updateTransportMode="setTransportMode"
|
||||
@updateSendShortcut="setSendShortcut"
|
||||
/>
|
||||
|
||||
<!-- 右侧聊天内容区域 -->
|
||||
@@ -71,13 +74,14 @@
|
||||
:stagedImagesUrl="stagedImagesUrl"
|
||||
:stagedAudioUrl="stagedAudioUrl"
|
||||
:stagedFiles="stagedNonImageFiles"
|
||||
:disabled="isStreaming"
|
||||
:disabled="false"
|
||||
:is-running="isStreaming || isConvRunning"
|
||||
:enableStreaming="enableStreaming"
|
||||
:isRecording="isRecording"
|
||||
:session-id="currSessionId || null"
|
||||
:current-session="getCurrentSession"
|
||||
:replyTo="replyTo"
|
||||
:send-shortcut="sendShortcut"
|
||||
@send="handleSendMessage"
|
||||
@stop="handleStopMessage"
|
||||
@toggleStreaming="toggleStreaming"
|
||||
@@ -102,13 +106,14 @@
|
||||
:stagedImagesUrl="stagedImagesUrl"
|
||||
:stagedAudioUrl="stagedAudioUrl"
|
||||
:stagedFiles="stagedNonImageFiles"
|
||||
:disabled="isStreaming"
|
||||
:disabled="false"
|
||||
:is-running="isStreaming || isConvRunning"
|
||||
:enableStreaming="enableStreaming"
|
||||
:isRecording="isRecording"
|
||||
:session-id="currSessionId || null"
|
||||
:current-session="getCurrentSession"
|
||||
:replyTo="replyTo"
|
||||
:send-shortcut="sendShortcut"
|
||||
@send="handleSendMessage"
|
||||
@stop="handleStopMessage"
|
||||
@toggleStreaming="toggleStreaming"
|
||||
@@ -132,13 +137,14 @@
|
||||
:stagedImagesUrl="stagedImagesUrl"
|
||||
:stagedAudioUrl="stagedAudioUrl"
|
||||
:stagedFiles="stagedNonImageFiles"
|
||||
:disabled="isStreaming"
|
||||
:disabled="false"
|
||||
:is-running="isStreaming || isConvRunning"
|
||||
:enableStreaming="enableStreaming"
|
||||
:isRecording="isRecording"
|
||||
:session-id="currSessionId || null"
|
||||
:current-session="getCurrentSession"
|
||||
:replyTo="replyTo"
|
||||
:send-shortcut="sendShortcut"
|
||||
@send="handleSendMessage"
|
||||
@stop="handleStopMessage"
|
||||
@toggleStreaming="toggleStreaming"
|
||||
@@ -220,10 +226,13 @@ import { useMediaHandling } from '@/composables/useMediaHandling';
|
||||
import { useProjects } from '@/composables/useProjects';
|
||||
import type { Project } from '@/components/chat/ProjectList.vue';
|
||||
import { useRecording } from '@/composables/useRecording';
|
||||
import { useToast } from '@/utils/toast';
|
||||
|
||||
interface Props {
|
||||
chatboxMode?: boolean;
|
||||
}
|
||||
type SendShortcut = 'enter' | 'shift_enter';
|
||||
const SEND_SHORTCUT_STORAGE_KEY = 'chat_send_shortcut';
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
chatboxMode: false
|
||||
@@ -233,6 +242,7 @@ const router = useRouter();
|
||||
const route = useRoute();
|
||||
const { t } = useI18n();
|
||||
const { tm } = useModuleI18n('features/chat');
|
||||
const { warning: toastWarning } = useToast();
|
||||
const theme = useTheme();
|
||||
const customizer = useCustomizerStore();
|
||||
|
||||
@@ -257,6 +267,7 @@ const {
|
||||
getSessions,
|
||||
newSession,
|
||||
deleteSession: deleteSessionFn,
|
||||
batchDeleteSessions,
|
||||
showEditTitleDialog,
|
||||
saveTitle,
|
||||
updateSessionTitle,
|
||||
@@ -330,6 +341,18 @@ interface ReplyInfo {
|
||||
const replyTo = ref<ReplyInfo | null>(null);
|
||||
|
||||
const isDark = computed(() => useCustomizerStore().uiTheme === 'PurpleThemeDark');
|
||||
const sendShortcut = ref<SendShortcut>('shift_enter');
|
||||
|
||||
function setSendShortcut(mode: SendShortcut) {
|
||||
sendShortcut.value = mode;
|
||||
localStorage.setItem(SEND_SHORTCUT_STORAGE_KEY, mode);
|
||||
}
|
||||
|
||||
function focusChatInput() {
|
||||
nextTick(() => {
|
||||
chatInputRef.value?.focusInput?.();
|
||||
});
|
||||
}
|
||||
|
||||
// 检测是否为手机端
|
||||
function checkMobile() {
|
||||
@@ -488,6 +511,7 @@ async function handleSelectConversation(sessionIds: string[]) {
|
||||
nextTick(() => {
|
||||
messageList.value?.scrollToBottom();
|
||||
});
|
||||
focusChatInput();
|
||||
}
|
||||
|
||||
function handleNewChat() {
|
||||
@@ -497,6 +521,7 @@ function handleNewChat() {
|
||||
// 退出项目视图
|
||||
selectedProjectId.value = null;
|
||||
projectSessions.value = [];
|
||||
focusChatInput();
|
||||
}
|
||||
|
||||
async function handleDeleteConversation(sessionId: string) {
|
||||
@@ -510,6 +535,33 @@ async function handleDeleteConversation(sessionId: string) {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleBatchDeleteConversations(sessionIds: string[]) {
|
||||
try {
|
||||
const result = await batchDeleteSessions(sessionIds);
|
||||
|
||||
// 仅在当前会话成功删除时清除信息
|
||||
if (result.currentSessionDeleted) {
|
||||
messages.value = [];
|
||||
}
|
||||
|
||||
// 失败处理
|
||||
if (result.failed_count > 0) {
|
||||
toastWarning(
|
||||
tm('batch.partialFailure', { failed: result.failed_count, total: sessionIds.length })
|
||||
);
|
||||
}
|
||||
|
||||
// 如果在项目视图中,刷新项目会话列表
|
||||
if (selectedProjectId.value) {
|
||||
const sessions = await getProjectSessions(selectedProjectId.value);
|
||||
projectSessions.value = sessions;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Batch delete sessions failed:', err);
|
||||
toastWarning(tm('batch.requestFailed'));
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSelectProject(projectId: string) {
|
||||
selectedProjectId.value = projectId;
|
||||
const sessions = await getProjectSessions(projectId);
|
||||
@@ -627,6 +679,11 @@ async function handleSendMessage() {
|
||||
const selectedProviderId = selection?.providerId || '';
|
||||
const selectedModelName = selection?.modelName || '';
|
||||
|
||||
// 点击发送后立即将消息区滚到底部,确保用户看到最新消息
|
||||
nextTick(() => {
|
||||
messageList.value?.scrollToBottom();
|
||||
});
|
||||
|
||||
await sendMsg(
|
||||
promptToSend,
|
||||
filesToSend,
|
||||
@@ -636,6 +693,11 @@ async function handleSendMessage() {
|
||||
replyToSend
|
||||
);
|
||||
|
||||
// 发送流程结束后再兜底一次,处理异步渲染场景
|
||||
nextTick(() => {
|
||||
messageList.value?.scrollToBottom();
|
||||
});
|
||||
|
||||
// 如果在项目中创建了新会话,将其添加到项目
|
||||
if (isCreatingNewSession && currentProjectId && currSessionId.value) {
|
||||
await addSessionToProject(currSessionId.value, currentProjectId);
|
||||
@@ -694,6 +756,10 @@ watch(sessions, (newSessions) => {
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
const storedShortcut = localStorage.getItem(SEND_SHORTCUT_STORAGE_KEY);
|
||||
if (storedShortcut === 'enter' || storedShortcut === 'shift_enter') {
|
||||
sendShortcut.value = storedShortcut;
|
||||
}
|
||||
checkMobile();
|
||||
window.addEventListener('resize', checkMobile);
|
||||
getSessions();
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
<transition name="fade">
|
||||
<div v-if="isDragging" class="drop-overlay">
|
||||
<div class="drop-overlay-content">
|
||||
<v-icon size="48" color="deep-purple">mdi-cloud-upload</v-icon>
|
||||
<v-icon size="48" color="primary">mdi-cloud-upload</v-icon>
|
||||
<span class="drop-text">{{ tm('input.dropToUpload') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -41,7 +41,7 @@
|
||||
<!-- Settings Menu -->
|
||||
<StyledMenu offset="8" location="top start" :close-on-content-click="false">
|
||||
<template v-slot:activator="{ props: activatorProps }">
|
||||
<v-btn v-bind="activatorProps" icon="mdi-plus" variant="text" color="deep-purple" />
|
||||
<v-btn v-bind="activatorProps" icon="mdi-plus" variant="text" color="primary" />
|
||||
</template>
|
||||
|
||||
<!-- Upload Files -->
|
||||
@@ -87,7 +87,7 @@
|
||||
{{ tm('voice.liveMode') }}
|
||||
</v-tooltip>
|
||||
</v-btn> -->
|
||||
<v-btn @click="handleRecordClick" icon variant="text" :color="isRecording ? 'error' : 'deep-purple'"
|
||||
<v-btn @click="handleRecordClick" icon variant="text" :color="isRecording ? 'error' : 'primary'"
|
||||
class="record-btn">
|
||||
<v-icon :icon="isRecording ? 'mdi-stop-circle' : 'mdi-microphone'" variant="text"
|
||||
plain></v-icon>
|
||||
@@ -95,13 +95,13 @@
|
||||
{{ isRecording ? tm('voice.speaking') : tm('voice.startRecording') }}
|
||||
</v-tooltip>
|
||||
</v-btn>
|
||||
<v-btn icon v-if="isRunning" @click="$emit('stop')" variant="tonal" color="deep-purple" class="send-btn">
|
||||
<v-btn icon v-if="isRunning && !canSend" @click="$emit('stop')" variant="tonal" color="primary" class="send-btn">
|
||||
<v-icon icon="mdi-stop" variant="text" plain></v-icon>
|
||||
<v-tooltip activator="parent" location="top">
|
||||
{{ tm('input.stopGenerating') }}
|
||||
</v-tooltip>
|
||||
</v-btn>
|
||||
<v-btn v-else @click="$emit('send')" icon="mdi-send" variant="tonal" color="deep-purple"
|
||||
<v-btn v-else @click="$emit('send')" icon="mdi-send" variant="tonal" color="primary"
|
||||
:disabled="!canSend" class="send-btn" />
|
||||
</div>
|
||||
</div>
|
||||
@@ -117,7 +117,7 @@
|
||||
</div>
|
||||
|
||||
<div v-if="stagedAudioUrl" class="audio-preview">
|
||||
<v-chip color="deep-purple-lighten-4" class="audio-chip">
|
||||
<v-chip color="primary" variant="tonal" class="audio-chip">
|
||||
<v-icon start icon="mdi-microphone" size="small"></v-icon>
|
||||
{{ tm('voice.recording') }}
|
||||
</v-chip>
|
||||
@@ -126,7 +126,7 @@
|
||||
</div>
|
||||
|
||||
<div v-for="(file, index) in stagedFiles" :key="'file-' + index" class="file-preview">
|
||||
<v-chip color="blue-grey-lighten-4" class="file-chip">
|
||||
<v-chip color="primary" variant="tonal" class="file-chip">
|
||||
<v-icon start icon="mdi-file-document-outline" size="small"></v-icon>
|
||||
<span class="file-name-preview">{{ file.original_name }}</span>
|
||||
</v-chip>
|
||||
@@ -173,6 +173,7 @@ interface Props {
|
||||
currentSession?: Session | null;
|
||||
configId?: string | null;
|
||||
replyTo?: ReplyInfo | null;
|
||||
sendShortcut?: 'enter' | 'shift_enter';
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
@@ -180,7 +181,8 @@ const props = withDefaults(defineProps<Props>(), {
|
||||
currentSession: null,
|
||||
configId: null,
|
||||
stagedFiles: () => [],
|
||||
replyTo: null
|
||||
replyTo: null,
|
||||
sendShortcut: 'shift_enter'
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -253,9 +255,29 @@ watch(localPrompt, () => {
|
||||
});
|
||||
|
||||
function handleKeyDown(e: KeyboardEvent) {
|
||||
// Enter 插入换行(桌面和手机端均如此,发送通过右下角发送按鈕)
|
||||
// Shift+Enter 发送(Ctrl+Enter / Cmd+Enter 也保留)
|
||||
if (e.keyCode === 13 && (e.shiftKey || e.ctrlKey || e.metaKey)) {
|
||||
const isEnter = e.key === 'Enter';
|
||||
if (!isEnter) {
|
||||
// Ctrl+B 录音
|
||||
if (e.ctrlKey && e.keyCode === 66) {
|
||||
e.preventDefault();
|
||||
if (ctrlKeyDown.value) return;
|
||||
|
||||
ctrlKeyDown.value = true;
|
||||
ctrlKeyTimer.value = window.setTimeout(() => {
|
||||
if (ctrlKeyDown.value && !props.isRecording) {
|
||||
emit('startRecording');
|
||||
}
|
||||
}, ctrlKeyLongPressThreshold);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const isSendHotkey =
|
||||
e.ctrlKey ||
|
||||
e.metaKey ||
|
||||
(props.sendShortcut === 'enter' ? !e.shiftKey : e.shiftKey);
|
||||
|
||||
if (isSendHotkey) {
|
||||
e.preventDefault();
|
||||
if (localPrompt.value.trim() === '/astr_live_dev') {
|
||||
emit('openLiveMode');
|
||||
@@ -267,19 +289,6 @@ function handleKeyDown(e: KeyboardEvent) {
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Ctrl+B 录音
|
||||
if (e.ctrlKey && e.keyCode === 66) {
|
||||
e.preventDefault();
|
||||
if (ctrlKeyDown.value) return;
|
||||
|
||||
ctrlKeyDown.value = true;
|
||||
ctrlKeyTimer.value = window.setTimeout(() => {
|
||||
if (ctrlKeyDown.value && !props.isRecording) {
|
||||
emit('startRecording');
|
||||
}
|
||||
}, ctrlKeyLongPressThreshold);
|
||||
}
|
||||
}
|
||||
|
||||
function handleKeyUp(e: KeyboardEvent) {
|
||||
@@ -364,6 +373,11 @@ function getCurrentSelection() {
|
||||
return providerModelMenuRef.value?.getCurrentSelection();
|
||||
}
|
||||
|
||||
function focusInput() {
|
||||
if (!inputField.value) return;
|
||||
inputField.value.focus();
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (inputField.value) {
|
||||
inputField.value.addEventListener('paste', handlePaste);
|
||||
@@ -379,7 +393,8 @@ onBeforeUnmount(() => {
|
||||
});
|
||||
|
||||
defineExpose({
|
||||
getCurrentSelection
|
||||
getCurrentSelection,
|
||||
focusInput
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -399,8 +414,8 @@ defineExpose({
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-color: rgba(103, 58, 183, 0.15);
|
||||
border: 2px dashed rgba(103, 58, 183, 0.5);
|
||||
background-color: rgba(var(--v-theme-primary), 0.12);
|
||||
border: 2px dashed rgba(var(--v-theme-primary), 0.45);
|
||||
border-radius: 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -419,7 +434,7 @@ defineExpose({
|
||||
.drop-text {
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
color: #673ab7;
|
||||
color: rgb(var(--v-theme-primary));
|
||||
}
|
||||
|
||||
/* Fade transition for drop overlay */
|
||||
@@ -439,7 +454,7 @@ defineExpose({
|
||||
justify-content: space-between;
|
||||
padding: 8px 16px;
|
||||
margin: 8px 8px 0 8px;
|
||||
background-color: rgba(103, 58, 183, 0.06);
|
||||
background-color: rgba(var(--v-theme-primary), 0.06);
|
||||
border-radius: 12px;
|
||||
gap: 8px;
|
||||
max-height: 500px;
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
'mobile-sidebar-open': isMobile && mobileMenuOpen,
|
||||
'mobile-sidebar': isMobile
|
||||
}"
|
||||
:style="{ 'background-color': isDark ? sidebarCollapsed ? '#1e1e1e' : '#2d2d2d' : sidebarCollapsed ? '#ffffff' : '#f1f4f9' }">
|
||||
:style="{ backgroundColor: sidebarCollapsed && !isMobile ? 'rgb(var(--v-theme-surface))' : 'rgb(var(--v-theme-mcpCardBg))' }">
|
||||
|
||||
<div class="sidebar-collapse-btn-container" v-if="!isMobile">
|
||||
<v-btn icon class="sidebar-collapse-btn" @click="toggleSidebar" variant="text" color="deep-purple">
|
||||
@@ -21,12 +21,31 @@
|
||||
</div>
|
||||
|
||||
<div style="padding: 8px; opacity: 0.6;">
|
||||
<v-btn block variant="text" class="new-chat-btn" @click="$emit('newChat')" :disabled="!currSessionId && !selectedProjectId"
|
||||
v-if="!sidebarCollapsed || isMobile" prepend-icon="mdi-square-edit-outline">{{ tm('actions.newChat') }}</v-btn>
|
||||
<v-btn icon="mdi-square-edit-outline" rounded="xl" @click="$emit('newChat')" :disabled="!currSessionId && !selectedProjectId"
|
||||
<div class="new-chat-row" v-if="!sidebarCollapsed || isMobile">
|
||||
<v-btn block variant="text" class="new-chat-btn" @click="$emit('newChat')" :disabled="!currSessionId && !selectedProjectId"
|
||||
prepend-icon="mdi-square-edit-outline">{{ tm('actions.newChat') }}</v-btn>
|
||||
<v-btn v-if="sessions.length > 0" icon size="small" variant="text" @click="toggleBatchMode"
|
||||
:color="batchMode ? 'primary' : undefined">
|
||||
<v-icon>mdi-checkbox-multiple-marked-outline</v-icon>
|
||||
</v-btn>
|
||||
</div>
|
||||
<v-btn icon="mdi-square-edit-outline" rounded="xl" @click="$emit('newChat')" :disabled="!currSessionId && !selectedProjectId"
|
||||
v-if="sidebarCollapsed && !isMobile" elevation="0"></v-btn>
|
||||
</div>
|
||||
|
||||
<!-- Batch action bar -->
|
||||
<div v-if="batchMode && (!sidebarCollapsed || isMobile)" class="batch-action-bar">
|
||||
<v-btn size="x-small" variant="text" @click="toggleSelectAll">
|
||||
{{ isAllSelected ? tm('batch.deselectAll') : tm('batch.selectAll') }}
|
||||
</v-btn>
|
||||
<span class="batch-selected-count">{{ tm('batch.selected', { count: batchSelected.length }) }}</span>
|
||||
<v-spacer />
|
||||
<v-btn size="x-small" variant="text" color="error" :disabled="batchSelected.length === 0"
|
||||
@click="handleBatchDelete">
|
||||
{{ tm('batch.delete') }}
|
||||
</v-btn>
|
||||
</div>
|
||||
|
||||
<!-- 项目列表组件 -->
|
||||
<ProjectList
|
||||
v-if="!sidebarCollapsed || isMobile"
|
||||
@@ -41,19 +60,34 @@
|
||||
v-if="!sidebarCollapsed || isMobile">
|
||||
<v-card v-if="sessions.length > 0" flat style="background-color: transparent;">
|
||||
<v-list density="compact" nav class="conversation-list"
|
||||
style="background-color: transparent;" :selected="selectedSessions"
|
||||
@update:selected="$emit('selectConversation', $event)">
|
||||
style="background-color: transparent;" :selected="batchMode ? [] : selectedSessions"
|
||||
@update:selected="handleListSelect">
|
||||
<v-list-item v-for="item in sessions" :key="item.session_id" :value="item.session_id"
|
||||
rounded="lg" class="conversation-item" active-color="secondary">
|
||||
rounded="lg" class="conversation-item" active-color="secondary"
|
||||
@click="batchMode ? toggleBatchItem(item.session_id) : undefined">
|
||||
|
||||
<template v-slot:prepend>
|
||||
<div class="batch-checkbox-slot" :class="{ 'batch-checkbox-slot--active': batchMode }">
|
||||
<v-checkbox-btn
|
||||
:model-value="batchSelected.includes(item.session_id)"
|
||||
@update:model-value="toggleBatchItem(item.session_id)"
|
||||
@click.stop
|
||||
density="compact"
|
||||
hide-details
|
||||
class="batch-checkbox"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<v-list-item-title v-if="!sidebarCollapsed || isMobile" class="conversation-title"
|
||||
:style="{ color: isDark ? '#ffffff' : '#000000' }">
|
||||
:style="{ color: 'rgb(var(--v-theme-primaryText))' }">
|
||||
{{ item.display_name || tm('conversation.newConversation') }}
|
||||
</v-list-item-title>
|
||||
<!-- <v-list-item-subtitle v-if="!sidebarCollapsed || isMobile" class="timestamp">
|
||||
{{ new Date(item.updated_at).toLocaleString() }}
|
||||
</v-list-item-subtitle> -->
|
||||
|
||||
<template v-if="!sidebarCollapsed || isMobile" v-slot:append>
|
||||
<template v-if="!batchMode && (!sidebarCollapsed || isMobile)" v-slot:append>
|
||||
<div class="conversation-actions">
|
||||
<v-btn icon="mdi-pencil" size="x-small" variant="text"
|
||||
class="edit-title-btn"
|
||||
@@ -98,16 +132,52 @@
|
||||
</v-btn>
|
||||
</template>
|
||||
|
||||
<!-- 语言切换 -->
|
||||
<v-list-item class="styled-menu-item">
|
||||
<template v-slot:prepend>
|
||||
<v-icon>mdi-translate</v-icon>
|
||||
<!-- 语言切换(分组) -->
|
||||
<v-menu
|
||||
:open-on-hover="!isMobile"
|
||||
:open-on-click="isMobile"
|
||||
:open-delay="!isMobile ? 60 : 0"
|
||||
:close-delay="!isMobile ? 120 : 0"
|
||||
:location="isMobile ? 'bottom' : 'end center'"
|
||||
offset="8"
|
||||
close-on-content-click
|
||||
>
|
||||
<template v-slot:activator="{ props: languageMenuProps }">
|
||||
<v-list-item
|
||||
v-bind="languageMenuProps"
|
||||
class="styled-menu-item chat-settings-group-trigger"
|
||||
rounded="md"
|
||||
>
|
||||
<template v-slot:prepend>
|
||||
<v-icon>mdi-translate</v-icon>
|
||||
</template>
|
||||
<v-list-item-title>{{ t('core.common.language') }}</v-list-item-title>
|
||||
<template v-slot:append>
|
||||
<span class="chat-settings-group-current">{{ currentLanguage?.flag }}</span>
|
||||
<v-icon size="18" class="chat-settings-group-arrow">mdi-chevron-right</v-icon>
|
||||
</template>
|
||||
</v-list-item>
|
||||
</template>
|
||||
<v-list-item-title>{{ t('core.common.language') }}</v-list-item-title>
|
||||
<template v-slot:append>
|
||||
<LanguageSwitcher variant="chatbox" />
|
||||
</template>
|
||||
</v-list-item>
|
||||
|
||||
<v-card class="styled-menu-card" style="min-width: 180px;" elevation="8" rounded="lg">
|
||||
<v-list density="compact" class="styled-menu-list pa-1">
|
||||
<v-list-item
|
||||
v-for="lang in languages"
|
||||
:key="lang.code"
|
||||
:value="lang.code"
|
||||
@click="changeLanguage(lang.code)"
|
||||
:class="{ 'styled-menu-item-active': currentLocale === lang.code }"
|
||||
class="styled-menu-item"
|
||||
rounded="md"
|
||||
>
|
||||
<template v-slot:prepend>
|
||||
<span class="language-flag">{{ lang.flag }}</span>
|
||||
</template>
|
||||
<v-list-item-title>{{ lang.name }}</v-list-item-title>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
</v-card>
|
||||
</v-menu>
|
||||
|
||||
<!-- 主题切换 -->
|
||||
<v-list-item class="styled-menu-item" @click="$emit('toggleTheme')">
|
||||
@@ -117,26 +187,93 @@
|
||||
<v-list-item-title>{{ isDark ? tm('modes.lightMode') : tm('modes.darkMode') }}</v-list-item-title>
|
||||
</v-list-item>
|
||||
|
||||
<!-- 通信传输模式 -->
|
||||
<v-list-item class="styled-menu-item">
|
||||
<template v-slot:prepend>
|
||||
<v-icon>mdi-lan-connect</v-icon>
|
||||
<!-- 通信传输模式(分组) -->
|
||||
<v-menu
|
||||
:open-on-hover="!isMobile"
|
||||
:open-on-click="isMobile"
|
||||
:open-delay="!isMobile ? 60 : 0"
|
||||
:close-delay="!isMobile ? 120 : 0"
|
||||
:location="isMobile ? 'bottom' : 'end center'"
|
||||
offset="8"
|
||||
close-on-content-click
|
||||
>
|
||||
<template v-slot:activator="{ props: transportMenuProps }">
|
||||
<v-list-item
|
||||
v-bind="transportMenuProps"
|
||||
class="styled-menu-item chat-settings-group-trigger"
|
||||
rounded="md"
|
||||
>
|
||||
<template v-slot:prepend>
|
||||
<v-icon>mdi-lan-connect</v-icon>
|
||||
</template>
|
||||
<v-list-item-title>{{ tm('transport.title') }}</v-list-item-title>
|
||||
<template v-slot:append>
|
||||
<span class="chat-settings-group-current chat-settings-transport-current">{{ currentTransportLabel }}</span>
|
||||
<v-icon size="18" class="chat-settings-group-arrow">mdi-chevron-right</v-icon>
|
||||
</template>
|
||||
</v-list-item>
|
||||
</template>
|
||||
<v-list-item-title>{{ tm('transport.title') }}</v-list-item-title>
|
||||
<template v-slot:append>
|
||||
<v-select
|
||||
:model-value="transportMode"
|
||||
:items="transportOptions"
|
||||
item-title="label"
|
||||
item-value="value"
|
||||
density="compact"
|
||||
variant="underlined"
|
||||
hide-details
|
||||
class="transport-mode-select"
|
||||
@update:model-value="handleTransportModeChange"
|
||||
/>
|
||||
|
||||
<v-card class="styled-menu-card" style="min-width: 220px;" elevation="8" rounded="lg">
|
||||
<v-list density="compact" class="styled-menu-list pa-1">
|
||||
<v-list-item
|
||||
v-for="opt in transportOptions"
|
||||
:key="opt.value"
|
||||
:value="opt.value"
|
||||
@click="handleTransportModeChange(opt.value)"
|
||||
:class="{ 'styled-menu-item-active': transportMode === opt.value }"
|
||||
class="styled-menu-item"
|
||||
rounded="md"
|
||||
>
|
||||
<v-list-item-title>{{ opt.label }}</v-list-item-title>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
</v-card>
|
||||
</v-menu>
|
||||
|
||||
<!-- 发送快捷键(分组) -->
|
||||
<v-menu
|
||||
:open-on-hover="!isMobile"
|
||||
:open-on-click="isMobile"
|
||||
:open-delay="!isMobile ? 60 : 0"
|
||||
:close-delay="!isMobile ? 120 : 0"
|
||||
:location="isMobile ? 'bottom' : 'end center'"
|
||||
offset="8"
|
||||
close-on-content-click
|
||||
>
|
||||
<template v-slot:activator="{ props: sendShortcutMenuProps }">
|
||||
<v-list-item
|
||||
v-bind="sendShortcutMenuProps"
|
||||
class="styled-menu-item chat-settings-group-trigger"
|
||||
rounded="md"
|
||||
>
|
||||
<template v-slot:prepend>
|
||||
<v-icon>mdi-keyboard-outline</v-icon>
|
||||
</template>
|
||||
<v-list-item-title>{{ tm('shortcuts.sendKey.title') }}</v-list-item-title>
|
||||
<template v-slot:append>
|
||||
<span class="chat-settings-group-current chat-settings-transport-current">{{ currentSendShortcutLabel }}</span>
|
||||
<v-icon size="18" class="chat-settings-group-arrow">mdi-chevron-right</v-icon>
|
||||
</template>
|
||||
</v-list-item>
|
||||
</template>
|
||||
</v-list-item>
|
||||
|
||||
<v-card class="styled-menu-card" style="min-width: 220px;" elevation="8" rounded="lg">
|
||||
<v-list density="compact" class="styled-menu-list pa-1">
|
||||
<v-list-item
|
||||
v-for="opt in sendShortcutOptions"
|
||||
:key="opt.value"
|
||||
:value="opt.value"
|
||||
@click="handleSendShortcutChange(opt.value)"
|
||||
:class="{ 'styled-menu-item-active': props.sendShortcut === opt.value }"
|
||||
class="styled-menu-item"
|
||||
rounded="md"
|
||||
>
|
||||
<v-list-item-title>{{ opt.label }}</v-list-item-title>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
</v-card>
|
||||
</v-menu>
|
||||
|
||||
<!-- 全屏/退出全屏 -->
|
||||
<v-list-item class="styled-menu-item" @click="$emit('toggleFullscreen')">
|
||||
@@ -162,15 +299,16 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
import { ref, computed } from 'vue';
|
||||
import { useI18n, useModuleI18n } from '@/i18n/composables';
|
||||
import type { Session } from '@/composables/useSessions';
|
||||
import { askForConfirmation, useConfirmDialog } from '@/utils/confirmDialog';
|
||||
import LanguageSwitcher from '@/components/shared/LanguageSwitcher.vue';
|
||||
import StyledMenu from '@/components/shared/StyledMenu.vue';
|
||||
import ProviderConfigDialog from '@/components/chat/ProviderConfigDialog.vue';
|
||||
import ProjectList from '@/components/chat/ProjectList.vue';
|
||||
import type { Project } from '@/components/chat/ProjectList.vue';
|
||||
import { useLanguageSwitcher } from '@/i18n/composables';
|
||||
import type { Locale } from '@/i18n/types';
|
||||
|
||||
interface Props {
|
||||
sessions: Session[];
|
||||
@@ -183,6 +321,7 @@ interface Props {
|
||||
isMobile: boolean;
|
||||
mobileMenuOpen: boolean;
|
||||
projects?: Project[];
|
||||
sendShortcut: 'enter' | 'shift_enter';
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
@@ -194,6 +333,7 @@ const emit = defineEmits<{
|
||||
selectConversation: [sessionIds: string[]];
|
||||
editTitle: [sessionId: string, title: string];
|
||||
deleteConversation: [sessionId: string];
|
||||
batchDeleteConversations: [sessionIds: string[]];
|
||||
closeMobileSidebar: [];
|
||||
toggleTheme: [];
|
||||
toggleFullscreen: [];
|
||||
@@ -202,6 +342,7 @@ const emit = defineEmits<{
|
||||
editProject: [project: Project];
|
||||
deleteProject: [projectId: string];
|
||||
updateTransportMode: [mode: 'sse' | 'websocket'];
|
||||
updateSendShortcut: [mode: 'enter' | 'shift_enter'];
|
||||
}>();
|
||||
|
||||
const { t } = useI18n();
|
||||
@@ -211,10 +352,84 @@ const confirmDialog = useConfirmDialog();
|
||||
|
||||
const sidebarCollapsed = ref(true);
|
||||
const showProviderConfigDialog = ref(false);
|
||||
|
||||
// Batch mode state
|
||||
const batchMode = ref(false);
|
||||
const batchSelected = ref<string[]>([]);
|
||||
|
||||
const isAllSelected = computed(() =>
|
||||
props.sessions.length > 0 && batchSelected.value.length === props.sessions.length
|
||||
);
|
||||
|
||||
function toggleBatchMode() {
|
||||
batchMode.value = !batchMode.value;
|
||||
batchSelected.value = [];
|
||||
}
|
||||
|
||||
function toggleBatchItem(sessionId: string) {
|
||||
const idx = batchSelected.value.indexOf(sessionId);
|
||||
if (idx >= 0) {
|
||||
batchSelected.value.splice(idx, 1);
|
||||
} else {
|
||||
batchSelected.value.push(sessionId);
|
||||
}
|
||||
}
|
||||
|
||||
function toggleSelectAll() {
|
||||
if (isAllSelected.value) {
|
||||
batchSelected.value = [];
|
||||
} else {
|
||||
batchSelected.value = props.sessions.map(s => s.session_id);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleBatchDelete() {
|
||||
const count = batchSelected.value.length;
|
||||
if (count === 0) return;
|
||||
const message = tm('batch.confirmDelete', { count });
|
||||
if (await askForConfirmation(message, confirmDialog)) {
|
||||
emit('batchDeleteConversations', [...batchSelected.value]);
|
||||
batchSelected.value = [];
|
||||
batchMode.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleListSelect(sessionIds: string[]) {
|
||||
if (!batchMode.value) {
|
||||
emit('selectConversation', sessionIds);
|
||||
}
|
||||
}
|
||||
const transportOptions = [
|
||||
{ label: tm('transport.sse'), value: 'sse' as const },
|
||||
{ label: tm('transport.websocket'), value: 'websocket' as const }
|
||||
];
|
||||
const sendShortcutOptions = [
|
||||
{ label: tm('shortcuts.sendKey.enterToSend'), value: 'enter' as const },
|
||||
{ label: tm('shortcuts.sendKey.shiftEnterToSend'), value: 'shift_enter' as const }
|
||||
];
|
||||
|
||||
// Language switcher
|
||||
const { languageOptions, currentLanguage, switchLanguage, locale } = useLanguageSwitcher();
|
||||
const languages = computed(() =>
|
||||
languageOptions.value.map(lang => ({
|
||||
code: lang.value,
|
||||
name: lang.label,
|
||||
flag: lang.flag
|
||||
}))
|
||||
);
|
||||
const currentLocale = computed(() => locale.value);
|
||||
const changeLanguage = async (langCode: string) => {
|
||||
await switchLanguage(langCode as Locale);
|
||||
};
|
||||
|
||||
const currentTransportLabel = computed(() => {
|
||||
const found = transportOptions.find(opt => opt.value === props.transportMode);
|
||||
return found?.label ?? '';
|
||||
});
|
||||
const currentSendShortcutLabel = computed(() => {
|
||||
const found = sendShortcutOptions.find(opt => opt.value === props.sendShortcut);
|
||||
return found?.label ?? '';
|
||||
});
|
||||
|
||||
// 从 localStorage 读取侧边栏折叠状态
|
||||
const savedCollapsedState = localStorage.getItem('sidebarCollapsed');
|
||||
@@ -242,6 +457,12 @@ function handleTransportModeChange(mode: string | null) {
|
||||
emit('updateTransportMode', mode);
|
||||
}
|
||||
}
|
||||
|
||||
function handleSendShortcutChange(mode: string | null) {
|
||||
if (mode === 'enter' || mode === 'shift_enter') {
|
||||
emit('updateSendShortcut', mode);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@@ -310,7 +531,7 @@ function handleTransportModeChange(mode: string | null) {
|
||||
}
|
||||
|
||||
.conversation-item:hover {
|
||||
background-color: rgba(103, 58, 183, 0.05);
|
||||
background-color: rgba(var(--v-theme-primary), 0.05);
|
||||
}
|
||||
|
||||
.conversation-item:hover .conversation-actions {
|
||||
@@ -402,7 +623,74 @@ function handleTransportModeChange(mode: string | null) {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.transport-mode-select {
|
||||
min-width: 120px;
|
||||
.chat-settings-group-trigger :deep(.v-list-item__append) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.chat-settings-group-current {
|
||||
font-size: 14px;
|
||||
line-height: 1;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.chat-settings-transport-current {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.chat-settings-group-arrow {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.language-flag {
|
||||
font-size: 16px;
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.new-chat-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.new-chat-row .new-chat-btn {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.batch-action-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 4px 12px;
|
||||
gap: 4px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.batch-selected-count {
|
||||
font-size: 12px;
|
||||
opacity: 0.7;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.batch-checkbox {
|
||||
flex: none;
|
||||
transition: opacity 0.2s ease, transform 0.2s ease;
|
||||
}
|
||||
|
||||
.batch-checkbox-slot {
|
||||
width: 0;
|
||||
opacity: 0;
|
||||
overflow: hidden;
|
||||
pointer-events: none;
|
||||
transform: translateX(-8px);
|
||||
transition: width 0.2s ease, opacity 0.2s ease, transform 0.2s ease;
|
||||
}
|
||||
|
||||
.batch-checkbox-slot--active {
|
||||
width: 28px;
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
transform: translateX(0);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -180,7 +180,7 @@
|
||||
|
||||
<script>
|
||||
import { useI18n, useModuleI18n } from '@/i18n/composables';
|
||||
import { enableKatex, enableMermaid, setCustomComponents } from 'markstream-vue'
|
||||
import { enableKatex, enableMermaid, MarkdownCodeBlockNode, setCustomComponents } from 'markstream-vue'
|
||||
import 'markstream-vue/index.css'
|
||||
import 'katex/dist/katex.min.css'
|
||||
import 'highlight.js/styles/github.css';
|
||||
@@ -194,8 +194,11 @@ import ActionRef from './message_list_comps/ActionRef.vue';
|
||||
enableKatex();
|
||||
enableMermaid();
|
||||
|
||||
// 注册自定义 ref 组件
|
||||
setCustomComponents('message-list', { ref: RefNode });
|
||||
// 注册 message-list 专用组件:引用节点 + Shiki 代码块渲染
|
||||
setCustomComponents('message-list', {
|
||||
ref: RefNode,
|
||||
code_block: MarkdownCodeBlockNode
|
||||
});
|
||||
|
||||
export default {
|
||||
name: 'MessageList',
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
v-model:prompt="prompt"
|
||||
:stagedImagesUrl="stagedImagesUrl"
|
||||
:stagedAudioUrl="stagedAudioUrl"
|
||||
:disabled="isStreaming"
|
||||
:disabled="false"
|
||||
:is-running="isStreaming || isConvRunning"
|
||||
:enableStreaming="enableStreaming"
|
||||
:isRecording="isRecording"
|
||||
|
||||
@@ -63,8 +63,9 @@
|
||||
<!-- Text (Markdown) -->
|
||||
<MarkdownRender
|
||||
v-else-if="renderPart.part.type === 'plain' && renderPart.part.text && renderPart.part.text.trim()"
|
||||
:key="`${renderPart.key}-${isDark ? 'dark' : 'light'}`"
|
||||
custom-id="message-list" :custom-html-tags="['ref']" :content="renderPart.part.text" :typewriter="false"
|
||||
class="markdown-content" :is-dark="isDark" :monacoOptions="{ theme: isDark ? 'vs-dark' : 'vs-light' }" />
|
||||
class="markdown-content" :is-dark="isDark" />
|
||||
|
||||
<!-- Image -->
|
||||
<div v-else-if="renderPart.part.type === 'image' && renderPart.part.embedded_url" class="embedded-images">
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="isExpanded" class="reasoning-content animate-fade-in">
|
||||
<MarkdownRender :content="reasoning" class="reasoning-text markdown-content"
|
||||
<MarkdownRender :key="`reasoning-${isDark ? 'dark' : 'light'}`" :content="reasoning" class="reasoning-text markdown-content"
|
||||
:typewriter="false" :is-dark="isDark" :style="isDark ? { opacity: '0.85' } : {}" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
<template>
|
||||
<v-chip v-if="domain" class="ref-chip" size="x-small" variant="flat"
|
||||
:style="{ backgroundColor: isDark ? '#303030' : '#f4f4f4', color: isDark ? '#999' : '#666' }" :href="url"
|
||||
:style="chipStyle" :href="url"
|
||||
target="_blank" clickable>
|
||||
<v-icon start size="x-small" color>mdi-link-variant</v-icon>
|
||||
<span>{{ domain }}</span>
|
||||
|
||||
</v-chip>
|
||||
<span v-else class="ref-fallback" :style="{ color: isDark ? '#999' : '#666' }">{{ 'site' }}</span>
|
||||
<span v-else class="ref-fallback" :style="fallbackStyle">{{ 'site' }}</span>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
@@ -46,6 +46,15 @@ const domain = computed(() => {
|
||||
return ''
|
||||
}
|
||||
})
|
||||
|
||||
const chipStyle = computed(() => ({
|
||||
backgroundColor: isDark ? 'rgba(var(--v-theme-on-surface), 0.08)' : 'rgba(var(--v-theme-on-surface), 0.04)',
|
||||
color: isDark ? 'rgba(var(--v-theme-on-surface), 0.62)' : 'rgba(var(--v-theme-on-surface), 0.72)'
|
||||
}))
|
||||
|
||||
const fallbackStyle = computed(() => ({
|
||||
color: isDark ? 'rgba(var(--v-theme-on-surface), 0.62)' : 'rgba(var(--v-theme-on-surface), 0.72)'
|
||||
}))
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { useModuleI18n } from '@/i18n/composables';
|
||||
import { normalizeTextInput } from '@/utils/inputValue';
|
||||
|
||||
const { tm } = useModuleI18n('features/command');
|
||||
|
||||
@@ -52,6 +53,7 @@ const statusItems = [
|
||||
{ title: tm('filters.disabled'), value: 'disabled' },
|
||||
{ title: tm('filters.conflict'), value: 'conflict' }
|
||||
];
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -108,10 +110,11 @@ const statusItems = [
|
||||
<div style="min-width: 200px; max-width: 350px; flex: 1; border: 1px solid #B9B9B9; border-radius: 16px;">
|
||||
<v-text-field
|
||||
:model-value="searchQuery"
|
||||
@update:model-value="emit('update:searchQuery', $event)"
|
||||
@update:model-value="emit('update:searchQuery', normalizeTextInput($event))"
|
||||
density="compact"
|
||||
:label="tm('search.placeholder')"
|
||||
prepend-inner-icon="mdi-magnify"
|
||||
clearable
|
||||
variant="solo-filled"
|
||||
flat
|
||||
hide-details
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
*/
|
||||
import { ref, computed, type Ref } from 'vue';
|
||||
import type { CommandItem, FilterState } from '../types';
|
||||
import { normalizeTextInput } from '@/utils/inputValue';
|
||||
|
||||
export function useCommandFilters(commands: Ref<CommandItem[]>) {
|
||||
// 过滤状态
|
||||
@@ -95,7 +96,7 @@ export function useCommandFilters(commands: Ref<CommandItem[]>) {
|
||||
* 过滤后的指令列表(支持层级结构)
|
||||
*/
|
||||
const filteredCommands = computed(() => {
|
||||
const query = searchQuery.value.toLowerCase();
|
||||
const query = normalizeTextInput(searchQuery.value).toLowerCase();
|
||||
const conflictCmds: CommandItem[] = [];
|
||||
const normalCmds: CommandItem[] = [];
|
||||
|
||||
@@ -184,4 +185,3 @@ export function useCommandFilters(commands: Ref<CommandItem[]>) {
|
||||
isGroupExpanded
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
import { computed, onActivated, onMounted, ref, watch} from 'vue';
|
||||
import axios from 'axios';
|
||||
import { useModuleI18n } from '@/i18n/composables';
|
||||
import { normalizeTextInput } from '@/utils/inputValue';
|
||||
|
||||
// Composables
|
||||
import { useComponentData } from './composables/useComponentData';
|
||||
@@ -83,7 +84,7 @@ const {
|
||||
} = useCommandActions(toast, () => fetchCommands(tm('messages.loadFailed')));
|
||||
|
||||
const filteredTools = computed(() => {
|
||||
const query = toolSearch.value.trim().toLowerCase();
|
||||
const query = normalizeTextInput(toolSearch.value).trim().toLowerCase();
|
||||
if (!query) return tools.value;
|
||||
return tools.value.filter(tool =>
|
||||
tool.name?.toLowerCase().includes(query) ||
|
||||
@@ -253,7 +254,8 @@ watch(viewMode, async (mode) => {
|
||||
<div class="d-flex flex-wrap align-center ga-3 mb-4">
|
||||
<div style="min-width: 240px; max-width: 380px; flex: 1;">
|
||||
<v-text-field
|
||||
v-model="toolSearch"
|
||||
:model-value="toolSearch"
|
||||
@update:model-value="toolSearch = normalizeTextInput($event)"
|
||||
prepend-inner-icon="mdi-magnify"
|
||||
:label="tmTool('functionTools.search')"
|
||||
variant="outlined"
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
v-model="modelSearchProxy"
|
||||
density="compact"
|
||||
prepend-inner-icon="mdi-magnify"
|
||||
clearable
|
||||
hide-details
|
||||
variant="solo-filled"
|
||||
flat
|
||||
@@ -161,6 +162,7 @@
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import { normalizeTextInput } from '@/utils/inputValue'
|
||||
|
||||
const props = defineProps({
|
||||
entries: {
|
||||
@@ -222,7 +224,7 @@ const emit = defineEmits([
|
||||
|
||||
const modelSearchProxy = computed({
|
||||
get: () => props.modelSearch,
|
||||
set: (val) => emit('update:modelSearch', val)
|
||||
set: (val) => emit('update:modelSearch', normalizeTextInput(val))
|
||||
})
|
||||
|
||||
const isProviderTesting = (providerId) => props.testingProviders.includes(providerId)
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
>
|
||||
<v-icon
|
||||
size="18"
|
||||
:color="props.variant === 'default' ? (useCustomizerStore().uiTheme === 'PurpleTheme' ? '#5e35b1' : '#d7c5fa') : undefined"
|
||||
:color="props.variant === 'default' ? 'rgb(var(--v-theme-primary))' : undefined"
|
||||
>
|
||||
mdi-translate
|
||||
</v-icon>
|
||||
@@ -42,7 +42,6 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useI18n, useLanguageSwitcher } from '@/i18n/composables'
|
||||
import { useCustomizerStore } from '@/stores/customizer'
|
||||
import type { Locale } from '@/i18n/types'
|
||||
import StyledMenu from '@/components/shared/StyledMenu.vue'
|
||||
|
||||
@@ -90,7 +89,7 @@ const changeLanguage = async (langCode: string) => {
|
||||
|
||||
.language-switcher--default:hover {
|
||||
transform: scale(1.05);
|
||||
background: rgba(94, 53, 177, 0.08) !important;
|
||||
background: rgba(var(--v-theme-primary), 0.08) !important;
|
||||
}
|
||||
|
||||
/* Header变体样式 - 完全继承Vuetify和action-btn的默认样式 */
|
||||
@@ -103,8 +102,4 @@ const changeLanguage = async (langCode: string) => {
|
||||
/* 继承action-btn样式,与工具栏主题按钮保持一致 */
|
||||
}
|
||||
|
||||
/* 深色模式下的悬停效果(仅对default变体) */
|
||||
:deep(.v-theme--PurpleThemeDark) .language-switcher--default:hover {
|
||||
background: rgba(114, 46, 209, 0.12) !important;
|
||||
}
|
||||
</style>
|
||||
@@ -6,11 +6,11 @@
|
||||
</div>
|
||||
<div class="logo-text">
|
||||
<h2
|
||||
:style="{color: useCustomizerStore().uiTheme === 'PurpleTheme' ? '#5e35b1' : '#d7c5fa'}"
|
||||
:style="{ color: 'rgb(var(--v-theme-primary))' }"
|
||||
v-html="formatTitle(title || t('core.header.logoTitle'))"
|
||||
></h2>
|
||||
<!-- 父子组件传递css变量可能会出错,暂时使用十六进制颜色值 -->
|
||||
<h4 :style="{color: useCustomizerStore().uiTheme === 'PurpleTheme' ? '#000000aa' : '#ffffffcc'}"
|
||||
<h4 :style="{ color: 'rgba(var(--v-theme-on-surface), 0.72)' }"
|
||||
class="hint-text">{{ subtitle || t('core.header.accountDialog.title') }}</h4>
|
||||
</div>
|
||||
</div>
|
||||
@@ -18,7 +18,6 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useCustomizerStore } from "@/stores/customizer";
|
||||
import { useI18n } from '@/i18n/composables';
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
@@ -48,6 +48,24 @@ const loading = ref(false);
|
||||
const isEmpty = ref(false);
|
||||
const copyFeedbackTimer = ref(null);
|
||||
const lastRequestId = ref(0);
|
||||
const scrollContainer = ref(null);
|
||||
|
||||
function slugifyHeading(text, slugCounts) {
|
||||
const base = (text || "")
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.normalize("NFKD")
|
||||
.replace(/[\u0300-\u036f]/g, "")
|
||||
.replace(/[^\p{Letter}\p{Number}\s-]/gu, "")
|
||||
.replace(/\s+/g, "-")
|
||||
.replace(/-+/g, "-");
|
||||
|
||||
if (!base) return "";
|
||||
|
||||
const count = slugCounts.get(base) || 0;
|
||||
slugCounts.set(base, count + 1);
|
||||
return count === 0 ? base : `${base}-${count}`;
|
||||
}
|
||||
|
||||
onUnmounted(() => {
|
||||
if (copyFeedbackTimer.value) clearTimeout(copyFeedbackTimer.value);
|
||||
@@ -153,6 +171,18 @@ const renderedHtml = computed(() => {
|
||||
// 3. 后处理方案:完全隔离,安全性最高
|
||||
const tempDiv = document.createElement("div");
|
||||
tempDiv.innerHTML = cleanHtml;
|
||||
|
||||
const slugCounts = new Map();
|
||||
tempDiv.querySelectorAll("h1, h2, h3, h4, h5, h6").forEach((heading) => {
|
||||
if (heading.id) {
|
||||
slugCounts.set(heading.id, (slugCounts.get(heading.id) || 0) + 1);
|
||||
return;
|
||||
}
|
||||
|
||||
const slug = slugifyHeading(heading.textContent, slugCounts);
|
||||
if (slug) heading.id = slug;
|
||||
});
|
||||
|
||||
tempDiv.querySelectorAll("a").forEach((link) => {
|
||||
const href = link.getAttribute("href");
|
||||
// 强制所有外部链接使用安全的 _blank 策略
|
||||
@@ -251,18 +281,35 @@ watch(
|
||||
|
||||
function handleContainerClick(event) {
|
||||
const btn = event.target.closest(".copy-code-btn");
|
||||
if (!btn) return;
|
||||
const code = btn.closest(".code-block-wrapper")?.querySelector("code");
|
||||
if (code) {
|
||||
if (navigator.clipboard?.writeText) {
|
||||
navigator.clipboard
|
||||
.writeText(code.textContent)
|
||||
.then(() => showCopyFeedback(btn, true))
|
||||
.catch(() => tryFallbackCopy(code.textContent, btn));
|
||||
} else {
|
||||
tryFallbackCopy(code.textContent, btn);
|
||||
if (btn) {
|
||||
const code = btn.closest(".code-block-wrapper")?.querySelector("code");
|
||||
if (code) {
|
||||
if (navigator.clipboard?.writeText) {
|
||||
navigator.clipboard
|
||||
.writeText(code.textContent)
|
||||
.then(() => showCopyFeedback(btn, true))
|
||||
.catch(() => tryFallbackCopy(code.textContent, btn));
|
||||
} else {
|
||||
tryFallbackCopy(code.textContent, btn);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const anchor = event.target.closest('a[href^="#"]');
|
||||
if (!anchor) return;
|
||||
|
||||
const rawHref = anchor.getAttribute("href");
|
||||
const targetId = rawHref ? decodeURIComponent(rawHref.slice(1)) : "";
|
||||
if (!targetId) return;
|
||||
|
||||
const target = scrollContainer.value?.querySelector(
|
||||
`#${CSS.escape(targetId)}`,
|
||||
);
|
||||
if (!target) return;
|
||||
|
||||
event.preventDefault();
|
||||
target.scrollIntoView({ behavior: "smooth", block: "start" });
|
||||
}
|
||||
|
||||
function tryFallbackCopy(text, btn) {
|
||||
@@ -326,7 +373,7 @@ const showActionArea = computed(() => {
|
||||
<v-icon>mdi-close</v-icon>
|
||||
</v-btn>
|
||||
</v-card-title>
|
||||
<v-card-text style="overflow-y: auto">
|
||||
<v-card-text ref="scrollContainer" style="overflow-y: auto">
|
||||
<div v-if="showActionArea" class="d-flex justify-space-between mb-4">
|
||||
<v-btn
|
||||
v-if="modeConfig.showGithubButton && repoUrl"
|
||||
@@ -436,6 +483,7 @@ const showActionArea = computed(() => {
|
||||
margin-bottom: 16px;
|
||||
font-weight: 600;
|
||||
line-height: 1.25;
|
||||
scroll-margin-top: 12px;
|
||||
}
|
||||
|
||||
:deep(.markdown-body h1) {
|
||||
|
||||
@@ -24,12 +24,12 @@ withDefaults(defineProps<{
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
<style>
|
||||
.styled-menu-card {
|
||||
min-width: 100px;
|
||||
width: fit-content;
|
||||
border: 1px solid rgba(94, 53, 177, 0.15) !important;
|
||||
background: #f8f6fc !important;
|
||||
border: 1px solid rgba(var(--v-theme-primary), 0.15) !important;
|
||||
background: rgba(var(--v-theme-surface), 0.98) !important;
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
@@ -37,43 +37,41 @@ withDefaults(defineProps<{
|
||||
background: transparent !important;
|
||||
}
|
||||
|
||||
:deep(.styled-menu-item) {
|
||||
.styled-menu-item {
|
||||
margin: 2px 0;
|
||||
transition: all 0.2s ease;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
:deep(.styled-menu-item:hover) {
|
||||
background: rgba(94, 53, 177, 0.08) !important;
|
||||
.styled-menu-item:hover {
|
||||
background: rgba(var(--v-theme-primary), 0.08) !important;
|
||||
}
|
||||
|
||||
:deep(.styled-menu-item-active) {
|
||||
background: rgba(94, 53, 177, 0.15) !important;
|
||||
.styled-menu-item-active {
|
||||
background: rgba(var(--v-theme-primary), 0.15) !important;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
:deep(.styled-menu-item-active:hover) {
|
||||
background: rgba(94, 53, 177, 0.2) !important;
|
||||
.styled-menu-item-active:hover {
|
||||
background: rgba(var(--v-theme-primary), 0.2) !important;
|
||||
}
|
||||
</style>
|
||||
|
||||
<style>
|
||||
/* 深色模式下的下拉框样式 - 需要全局样式才能检测主题 */
|
||||
.v-theme--PurpleThemeDark .styled-menu-card {
|
||||
background: #2a2733 !important;
|
||||
border: 1px solid rgba(110, 60, 180, 0.692) !important;
|
||||
background: rgba(var(--v-theme-surface), 0.98) !important;
|
||||
border: 1px solid rgba(var(--v-theme-primary), 0.2) !important;
|
||||
}
|
||||
|
||||
/* 深色模式下的列表项悬停效果 */
|
||||
.v-theme--PurpleThemeDark .styled-menu-item:hover {
|
||||
background: rgba(114, 46, 209, 0.12) !important;
|
||||
background: rgba(var(--v-theme-primary), 0.12) !important;
|
||||
}
|
||||
|
||||
.v-theme--PurpleThemeDark .styled-menu-item-active {
|
||||
background: rgba(114, 46, 209, 0.2) !important;
|
||||
background: rgba(var(--v-theme-primary), 0.2) !important;
|
||||
}
|
||||
|
||||
.v-theme--PurpleThemeDark .styled-menu-item-active:hover {
|
||||
background: rgba(114, 46, 209, 0.25) !important;
|
||||
background: rgba(var(--v-theme-primary), 0.25) !important;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -2,6 +2,7 @@ import { ref, computed, onMounted, nextTick, watch } from 'vue'
|
||||
import axios from 'axios'
|
||||
import { getProviderIcon } from '@/utils/providerUtils'
|
||||
import { askForConfirmation as askForConfirmationDialog, useConfirmDialog } from '@/utils/confirmDialog'
|
||||
import { normalizeTextInput } from '@/utils/inputValue'
|
||||
|
||||
export interface UseProviderSourcesOptions {
|
||||
defaultTab?: string
|
||||
@@ -157,7 +158,7 @@ export function useProviderSources(options: UseProviderSourcesOptions) {
|
||||
})
|
||||
|
||||
const filteredMergedModelEntries = computed(() => {
|
||||
const term = modelSearch.value.trim().toLowerCase()
|
||||
const term = normalizeTextInput(modelSearch.value).trim().toLowerCase()
|
||||
if (!term) return mergedModelEntries.value
|
||||
|
||||
return mergedModelEntries.value.filter((entry: any) => {
|
||||
@@ -589,9 +590,11 @@ export function useProviderSources(options: UseProviderSourcesOptions) {
|
||||
async function testProvider(provider: any) {
|
||||
testingProviders.value.push(provider.id)
|
||||
try {
|
||||
const startTime = performance.now()
|
||||
const response = await axios.get('/api/config/provider/check_one', { params: { id: provider.id } })
|
||||
if (response.data.status === 'ok' && response.data.data.error === null) {
|
||||
showMessage(tm('models.testSuccess', { id: provider.id }))
|
||||
const latency = Math.max(0, Math.round(performance.now() - startTime))
|
||||
showMessage(tm('models.testSuccessWithLatency', { id: provider.id, latency }))
|
||||
} else {
|
||||
throw new Error(response.data.data.error || tm('models.testError'))
|
||||
}
|
||||
|
||||
@@ -109,6 +109,73 @@ export function useSessions(chatboxMode: boolean = false) {
|
||||
}
|
||||
}
|
||||
|
||||
interface BatchDeleteFailedItem {
|
||||
session_id: string;
|
||||
reason: string;
|
||||
}
|
||||
|
||||
interface BatchDeleteResult {
|
||||
deleted_count: number;
|
||||
failed_count: number;
|
||||
failed_items: BatchDeleteFailedItem[];
|
||||
currentSessionDeleted: boolean;
|
||||
}
|
||||
|
||||
function isBatchDeleteResponseData(data: unknown): data is {
|
||||
deleted_count: number;
|
||||
failed_count: number;
|
||||
failed_items: BatchDeleteFailedItem[];
|
||||
} {
|
||||
if (!data || typeof data !== 'object') {
|
||||
return false;
|
||||
}
|
||||
const payload = data as Record<string, unknown>;
|
||||
return (
|
||||
typeof payload.deleted_count === 'number' &&
|
||||
typeof payload.failed_count === 'number' &&
|
||||
Array.isArray(payload.failed_items)
|
||||
);
|
||||
}
|
||||
|
||||
async function batchDeleteSessions(sessionIds: string[]): Promise<BatchDeleteResult> {
|
||||
try {
|
||||
const currentSessionId = currSessionId.value;
|
||||
const response = await axios.post('/api/chat/batch_delete_sessions', { session_ids: sessionIds });
|
||||
if (response.data?.status !== 'ok') {
|
||||
throw new Error(response.data?.message || 'Failed to batch delete sessions');
|
||||
}
|
||||
|
||||
const data = response.data?.data;
|
||||
if (!isBatchDeleteResponseData(data)) {
|
||||
throw new Error('Invalid batch delete response payload');
|
||||
}
|
||||
|
||||
const failedItems = data.failed_items;
|
||||
const failedSessionIds = new Set(failedItems.map(item => item.session_id));
|
||||
const currentSessionDeleted = Boolean(
|
||||
currentSessionId &&
|
||||
sessionIds.includes(currentSessionId) &&
|
||||
!failedSessionIds.has(currentSessionId)
|
||||
);
|
||||
|
||||
if (currentSessionDeleted) {
|
||||
currSessionId.value = '';
|
||||
selectedSessions.value = [];
|
||||
}
|
||||
await getSessions();
|
||||
|
||||
return {
|
||||
deleted_count: data.deleted_count,
|
||||
failed_count: data.failed_count,
|
||||
failed_items: failedItems,
|
||||
currentSessionDeleted,
|
||||
};
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
function showEditTitleDialog(sessionId: string, title: string) {
|
||||
editingSessionId.value = sessionId;
|
||||
editingTitle.value = title || '';
|
||||
@@ -167,6 +234,7 @@ export function useSessions(chatboxMode: boolean = false) {
|
||||
getSessions,
|
||||
newSession,
|
||||
deleteSession,
|
||||
batchDeleteSessions,
|
||||
showEditTitleDialog,
|
||||
saveTitle,
|
||||
updateSessionTitle,
|
||||
|
||||
@@ -11,7 +11,7 @@ const translations = ref<Record<string, any>>({});
|
||||
*/
|
||||
export async function initI18n(locale: Locale = 'zh-CN') {
|
||||
currentLocale.value = locale;
|
||||
|
||||
|
||||
// 加载静态翻译数据
|
||||
loadTranslations(locale);
|
||||
}
|
||||
@@ -50,7 +50,7 @@ export function useI18n() {
|
||||
const t = (key: string, params?: Record<string, string | number>): string => {
|
||||
const keys = key.split('.');
|
||||
let value: any = translations.value;
|
||||
|
||||
|
||||
// 遍历键路径
|
||||
for (const k of keys) {
|
||||
if (value && typeof value === 'object' && k in value) {
|
||||
@@ -61,35 +61,35 @@ export function useI18n() {
|
||||
return `[MISSING: ${key}]`;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
if (typeof value !== 'string') {
|
||||
console.warn(`Translation value is not string: ${key}`, value);
|
||||
// 返回带括号的键名,便于在开发时识别类型错误的翻译
|
||||
return `[INVALID: ${key}]`;
|
||||
}
|
||||
|
||||
|
||||
// 此时value确定是string类型
|
||||
let result: string = value;
|
||||
|
||||
|
||||
// 处理参数插值
|
||||
if (params) {
|
||||
result = result.replace(/\{(\w+)\}/g, (match: string, paramKey: string) => {
|
||||
return params[paramKey]?.toString() || match;
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
|
||||
// 切换语言
|
||||
const setLocale = async (newLocale: Locale) => {
|
||||
if (newLocale !== currentLocale.value) {
|
||||
currentLocale.value = newLocale;
|
||||
loadTranslations(newLocale);
|
||||
|
||||
|
||||
// 保存到localStorage
|
||||
localStorage.setItem('astrbot-locale', newLocale);
|
||||
|
||||
|
||||
// 触发自定义事件,通知相关页面重新加载配置数据
|
||||
// 这是因为插件适配器的 i18n 数据是通过后端 API 注入的,
|
||||
// 需要根据 Accept-Language 头重新获取
|
||||
@@ -98,16 +98,16 @@ export function useI18n() {
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
// 获取当前语言
|
||||
const locale = computed(() => currentLocale.value);
|
||||
|
||||
|
||||
// 获取可用语言列表
|
||||
const availableLocales: Locale[] = ['zh-CN', 'en-US'];
|
||||
|
||||
const availableLocales: Locale[] = ['zh-CN', 'en-US', 'ru-RU'];
|
||||
|
||||
// 检查是否已加载
|
||||
const isLoaded = computed(() => Object.keys(translations.value).length > 0);
|
||||
|
||||
|
||||
return {
|
||||
t,
|
||||
locale,
|
||||
@@ -122,13 +122,13 @@ export function useI18n() {
|
||||
*/
|
||||
export function useModuleI18n(moduleName: string) {
|
||||
const { t } = useI18n();
|
||||
|
||||
|
||||
const tm = (key: string, params?: Record<string, string | number>): string => {
|
||||
// 将斜杠转换为点号以匹配嵌套对象结构
|
||||
const normalizedModuleName = moduleName.replace(/\//g, '.');
|
||||
return t(`${normalizedModuleName}.${key}`, params);
|
||||
};
|
||||
|
||||
|
||||
// 获取原始翻译值(可能是字符串、数组或对象)
|
||||
const getRaw = (key: string): any => {
|
||||
const normalizedModuleName = moduleName.replace(/\//g, '.');
|
||||
@@ -143,10 +143,10 @@ export function useModuleI18n(moduleName: string) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
return value;
|
||||
};
|
||||
|
||||
|
||||
return { tm, getRaw };
|
||||
}
|
||||
|
||||
@@ -155,20 +155,21 @@ export function useModuleI18n(moduleName: string) {
|
||||
*/
|
||||
export function useLanguageSwitcher() {
|
||||
const { locale, setLocale, availableLocales } = useI18n();
|
||||
|
||||
|
||||
const languageOptions = computed(() => [
|
||||
{ value: 'zh-CN', label: '简体中文', flag: '🇨🇳' },
|
||||
{ value: 'en-US', label: 'English', flag: '🇺🇸' }
|
||||
{ value: 'en-US', label: 'English', flag: '🇺🇸' },
|
||||
{ value: 'ru-RU', label: 'Русский', flag: '🇷🇺' }
|
||||
]);
|
||||
|
||||
|
||||
const currentLanguage = computed(() => {
|
||||
return languageOptions.value.find(lang => lang.value === locale.value);
|
||||
});
|
||||
|
||||
|
||||
const switchLanguage = async (newLocale: Locale) => {
|
||||
await setLocale(newLocale);
|
||||
};
|
||||
|
||||
|
||||
return {
|
||||
locale,
|
||||
languageOptions,
|
||||
@@ -220,9 +221,9 @@ function deepMerge(target: Record<string, any>, source: Record<string, any>) {
|
||||
export async function setupI18n() {
|
||||
// 从localStorage获取保存的语言设置
|
||||
const savedLocale = localStorage.getItem('astrbot-locale') as Locale;
|
||||
const initialLocale = savedLocale && ['zh-CN', 'en-US'].includes(savedLocale)
|
||||
? savedLocale
|
||||
const initialLocale = savedLocale && ['zh-CN', 'en-US', 'ru-RU'].includes(savedLocale)
|
||||
? savedLocale
|
||||
: 'zh-CN';
|
||||
|
||||
|
||||
await initI18n(initialLocale);
|
||||
}
|
||||
@@ -96,6 +96,7 @@
|
||||
"save": "Save",
|
||||
"livePreview": "Live Preview (may differ)",
|
||||
"refreshPreview": "Refresh Preview",
|
||||
"previewText": "This is a sample text used to preview the template output.\n\nIt can contain multiple lines and various formatting.",
|
||||
"syntaxHint": "Supports jinja2 syntax. Available variables: text | safe (text to render), version (AstrBot version)",
|
||||
"saveAndApply": "Save and Apply Current Template",
|
||||
"confirmReset": "Confirm Reset",
|
||||
|
||||
@@ -71,10 +71,16 @@
|
||||
"modes": {
|
||||
"darkMode": "Switch to Dark Mode",
|
||||
"lightMode": "Switch to Light Mode"
|
||||
}, "shortcuts": {
|
||||
},
|
||||
"shortcuts": {
|
||||
"help": "Get Help",
|
||||
"voiceRecord": "Record Voice",
|
||||
"pasteImage": "Paste Image"
|
||||
"pasteImage": "Paste Image",
|
||||
"sendKey": {
|
||||
"title": "Send Shortcut",
|
||||
"enterToSend": "Enter to send",
|
||||
"shiftEnterToSend": "Shift+Enter to send"
|
||||
}
|
||||
},
|
||||
"streaming": {
|
||||
"enabled": "Streaming enabled",
|
||||
@@ -141,5 +147,15 @@
|
||||
"errors": {
|
||||
"sendMessageFailed": "Failed to send message, please try again",
|
||||
"createSessionFailed": "Failed to create session, please refresh the page"
|
||||
},
|
||||
"batch": {
|
||||
"selected": "{count} selected",
|
||||
"confirmDelete": "Are you sure you want to delete {count} conversation(s)? This action cannot be undone.",
|
||||
"selectAll": "Select All",
|
||||
"deselectAll": "Deselect All",
|
||||
"delete": "Delete",
|
||||
"exit": "Exit",
|
||||
"partialFailure": "{failed} of {total} conversations failed to delete",
|
||||
"requestFailed": "Failed to delete conversations. Please try again."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -78,6 +78,7 @@
|
||||
},
|
||||
"persona": {
|
||||
"description": "Persona",
|
||||
"hint": "Set the default persona for AI conversations. Personas can be managed in the Persona tab.",
|
||||
"provider_settings": {
|
||||
"default_personality": {
|
||||
"description": "Default Persona"
|
||||
@@ -618,11 +619,6 @@
|
||||
"type": "string",
|
||||
"hint": "Required. The Bot Token obtained from the KOOK Developer Platform."
|
||||
},
|
||||
"kook_bot_nickname": {
|
||||
"description": "Bot Nickname",
|
||||
"type": "string",
|
||||
"hint": "Optional. If the sender nickname matches this value, the message will be ignored to prevent broadcast storms."
|
||||
},
|
||||
"kook_reconnect_delay": {
|
||||
"description": "Reconnect Delay",
|
||||
"type": "int",
|
||||
@@ -850,7 +846,7 @@
|
||||
},
|
||||
"interval_method": {
|
||||
"description": "Interval Method",
|
||||
"hint": "random 为随机时间,log 为根据消息长度计算,$y=log_<log_base>(x)$,x为字数,y的单位为秒。"
|
||||
"hint": "random uses a random delay. log calculates delay by message length: $y=log_{log\\_base}(x)$, where x is word count and y is in seconds."
|
||||
},
|
||||
"interval": {
|
||||
"description": "Random Interval Time",
|
||||
@@ -873,7 +869,8 @@
|
||||
]
|
||||
},
|
||||
"regex": {
|
||||
"description": "Segmentation Regular Expression"
|
||||
"description": "Segmentation Regular Expression",
|
||||
"hint": "Used to identify split points with a regular expression. Prefer patterns that match separators."
|
||||
},
|
||||
"split_words": {
|
||||
"description": "Split Word List",
|
||||
|
||||
@@ -132,6 +132,7 @@
|
||||
"deleteSuccess": "Model deleted successfully",
|
||||
"deleteError": "Failed to delete model",
|
||||
"testSuccess": "Model {id} test passed",
|
||||
"testSuccessWithLatency": "Model {id} test passed, latency {latency} ms",
|
||||
"testError": "Model test failed",
|
||||
"searchPlaceholder": "Search models or ID",
|
||||
"manualAddButton": "Custom Model",
|
||||
|
||||
@@ -93,24 +93,6 @@
|
||||
"batchDeleteConfirm": {
|
||||
"title": "Confirm Batch Delete",
|
||||
"message": "Are you sure you want to delete {count} selected rules? Global settings will be used after deletion."
|
||||
},
|
||||
"batchOperations": {
|
||||
"title": "Batch Operations",
|
||||
"hint": "Quick batch modify session settings",
|
||||
"scope": "Apply to",
|
||||
"scopeSelected": "Selected sessions",
|
||||
"scopeAll": "All sessions",
|
||||
"scopeGroup": "All groups",
|
||||
"scopePrivate": "All private chats",
|
||||
"llmStatus": "LLM Status",
|
||||
"ttsStatus": "TTS Status",
|
||||
"chatProvider": "Chat Model",
|
||||
"ttsProvider": "TTS Model",
|
||||
"apply": "Apply Changes"
|
||||
},
|
||||
"status": {
|
||||
"enabled": "Enabled",
|
||||
"disabled": "Disabled"
|
||||
},
|
||||
"batchOperations": {
|
||||
"title": "Batch Operations",
|
||||
@@ -126,6 +108,25 @@
|
||||
"ttsProvider": "TTS Model",
|
||||
"apply": "Apply Changes"
|
||||
},
|
||||
"groups": {
|
||||
"title": "Group Management",
|
||||
"count": "{count} groups",
|
||||
"addToGroup": "Add to Group",
|
||||
"create": "Create Group",
|
||||
"edit": "Edit Group",
|
||||
"name": "Group Name",
|
||||
"sessionsCount": "{count} sessions",
|
||||
"empty": "No groups yet. Click 'Create Group' to create one.",
|
||||
"availableSessions": "Available Sessions ({count})",
|
||||
"selectedSessions": "Selected Sessions ({count})",
|
||||
"searchPlaceholder": "Search...",
|
||||
"noMatch": "No matches",
|
||||
"noMembers": "No members",
|
||||
"customGroupDivider": "── Custom Groups ──",
|
||||
"customGroupOption": "📁 {name} ({count})",
|
||||
"groupOption": "{name} ({count} sessions)",
|
||||
"deleteConfirm": "Are you sure you want to delete group \"{name}\"?"
|
||||
},
|
||||
"status": {
|
||||
"enabled": "Enabled",
|
||||
"disabled": "Disabled"
|
||||
@@ -142,7 +143,16 @@
|
||||
"noChanges": "No changes to save",
|
||||
"batchDeleteSuccess": "Batch delete successful",
|
||||
"batchDeleteError": "Batch delete failed",
|
||||
"selectSessionsFirst": "Please select sessions first",
|
||||
"selectAtLeastOneConfig": "Please select at least one setting to modify",
|
||||
"batchUpdateSuccess": "Batch update successful",
|
||||
"partialUpdateFailed": "Some updates failed",
|
||||
"batchUpdateError": "Batch update failed",
|
||||
"batchUpdateSuccess": "Batch update success"
|
||||
"groupNameRequired": "Group name cannot be empty",
|
||||
"saveGroupError": "Failed to save group",
|
||||
"deleteGroupError": "Failed to delete group",
|
||||
"selectSessionsToAddFirst": "Please select sessions to add first",
|
||||
"addToGroupSuccess": "Added {count} sessions to the group",
|
||||
"addToGroupError": "Failed to add to group"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"create": "Создать",
|
||||
"read": "Чтение",
|
||||
"update": "Обновить",
|
||||
"delete": "Удалить",
|
||||
"search": "Поиск",
|
||||
"filter": "Фильтр",
|
||||
"sort": "Сортировка",
|
||||
"export": "Экспорт",
|
||||
"import": "Импорт",
|
||||
"backup": "Резервное копирование",
|
||||
"restore": "Восстановление",
|
||||
"copy": "Копировать",
|
||||
"paste": "Вставить",
|
||||
"cut": "Вырезать",
|
||||
"undo": "Отменить",
|
||||
"redo": "Повторить",
|
||||
"refresh": "Обновить",
|
||||
"submit": "Отправить",
|
||||
"reset": "Сбросить",
|
||||
"clear": "Очистить",
|
||||
"save": "Сохранить",
|
||||
"close": "Закрыть"
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
{
|
||||
"save": "Сохранить",
|
||||
"cancel": "Отмена",
|
||||
"close": "Закрыть",
|
||||
"copy": "Копировать",
|
||||
"copied": "Скопировано",
|
||||
"copyFailed": "Ошибка копирования",
|
||||
"delete": "Удалить",
|
||||
"edit": "Редактировать",
|
||||
"add": "Добавить",
|
||||
"confirm": "Подтвердить",
|
||||
"loading": "Загрузка...",
|
||||
"success": "Успешно",
|
||||
"error": "Ошибка",
|
||||
"warning": "Внимание",
|
||||
"info": "Информация",
|
||||
"name": "Имя",
|
||||
"description": "Описание",
|
||||
"author": "Автор",
|
||||
"status": "Статус",
|
||||
"actions": "Действия",
|
||||
"enable": "Включить",
|
||||
"disable": "Выключить",
|
||||
"enabled": "Включено",
|
||||
"disabled": "Выключено",
|
||||
"reload": "Перезагрузить",
|
||||
"configure": "Настроить",
|
||||
"install": "Установить",
|
||||
"uninstall": "Удалить",
|
||||
"update": "Обновить",
|
||||
"language": "Язык",
|
||||
"settings": "Настройки",
|
||||
"locale": "JSON",
|
||||
"type": "Тип",
|
||||
"press": "Нажмите",
|
||||
"longPress": "Долгое нажатие",
|
||||
"yes": "Да",
|
||||
"no": "Нет",
|
||||
"imagePreview": "Предпросмотр изображения",
|
||||
"autoDetect": "Автоопределение",
|
||||
"dialog": {
|
||||
"confirmTitle": "Подтверждение",
|
||||
"confirmMessage": "Вы уверены, что хотите выполнить это действие?",
|
||||
"confirmButton": "ОК",
|
||||
"cancelButton": "Отмена"
|
||||
},
|
||||
"restart": {
|
||||
"waiting": "Ожидание перезагрузки AstrBot...",
|
||||
"maxRetriesReached": "Превышено количество попыток проверки статуса. Пожалуйста, проверьте вручную."
|
||||
},
|
||||
"readme": {
|
||||
"title": "Документация плагина",
|
||||
"buttons": {
|
||||
"viewOnGithub": "Открыть репозиторий на GitHub",
|
||||
"refresh": "Обновить"
|
||||
},
|
||||
"loading": "Загрузка README...",
|
||||
"errors": {
|
||||
"fetchFailed": "Не удалось загрузить README",
|
||||
"fetchError": "Произошла ошибка при загрузке README"
|
||||
},
|
||||
"empty": {
|
||||
"title": "У этого плагина нет ссылки на документацию или репозиторий GitHub.",
|
||||
"subtitle": "Пожалуйста, посетите магазин плагинов или свяжитесь с автором для получения дополнительной информации."
|
||||
}
|
||||
},
|
||||
"changelog": {
|
||||
"title": "Журнал изменений",
|
||||
"loading": "Загрузка журнала изменений...",
|
||||
"empty": {
|
||||
"title": "У этого плагина нет журнала изменений",
|
||||
"subtitle": "Разработчики могут добавить файл CHANGELOG.md в директорию плагина"
|
||||
}
|
||||
},
|
||||
"editor": {
|
||||
"fullscreen": "На весь экран",
|
||||
"editingTitle": "Редактирование содержимого"
|
||||
},
|
||||
"templateList": {
|
||||
"addEntry": "Добавить запись",
|
||||
"empty": "Записей нет, выберите шаблон для добавления",
|
||||
"missingTemplate": "Шаблон не найден, пожалуйста, удалите и добавьте заново.",
|
||||
"unknownTemplate": "Неизвестный шаблон"
|
||||
},
|
||||
"list": {
|
||||
"addItemPlaceholder": "Добавьте новый элемент и нажмите Enter",
|
||||
"addButton": "Добавить",
|
||||
"addMore": "Добавить еще",
|
||||
"batchImport": "Массовый импорт",
|
||||
"batchImportTitle": "Массовый импорт",
|
||||
"batchImportLabel": "Один элемент на строку",
|
||||
"batchImportPlaceholder": "Например:\nЭлемент 1\nЭлемент 2\nЭлемент 3",
|
||||
"batchImportHint": "Каждая строка будет считаться отдельным элементом. Пустые строки игнорируются.",
|
||||
"batchImportButton": "Импортировать {count} эл.",
|
||||
"noItems": "Список пуст",
|
||||
"noItemsHint": "Элементов нет. Напишите что-нибудь выше и нажмите Enter.",
|
||||
"inputPlaceholder": "Введите текст и нажмите Enter",
|
||||
"editTitle": "Изменить элемент",
|
||||
"modifyButton": "Изменить"
|
||||
},
|
||||
"itemCard": {
|
||||
"enabled": "Включено",
|
||||
"disabled": "Выключено",
|
||||
"delete": "Удалить",
|
||||
"edit": "Изменить",
|
||||
"copy": "Копировать",
|
||||
"noData": "Нет данных"
|
||||
},
|
||||
"objectEditor": {
|
||||
"dialogTitle": "Изменение пар ключ-значение",
|
||||
"noItems": "Нет элементов",
|
||||
"noParams": "Нет параметров",
|
||||
"presets": "Пресеты",
|
||||
"newKeyLabel": "Имя ключа",
|
||||
"valueTypeLabel": "Тип значения",
|
||||
"keyExists": "Ключ уже существует",
|
||||
"invalidJson": "Некорректный формат JSON",
|
||||
"placeholders": {
|
||||
"keyName": "Ключ",
|
||||
"stringValue": "Строка",
|
||||
"numberValue": "Число",
|
||||
"jsonValue": "JSON"
|
||||
}
|
||||
},
|
||||
"firstNotice": {
|
||||
"title": "Первичная информация",
|
||||
"loading": "Загрузка информации...",
|
||||
"empty": {
|
||||
"title": "Нет информации для отображения",
|
||||
"subtitle": "Файл FIRST_NOTICE.md не найден или пуст."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
{
|
||||
"logoTitle": "Панель управления AstrBot",
|
||||
"version": {
|
||||
"hasNewVersion": "Доступна новая версия AstrBot!",
|
||||
"dashboardHasNewVersion": "Доступна новая версия WebUI!"
|
||||
},
|
||||
"buttons": {
|
||||
"update": "Обновить",
|
||||
"account": "Аккаунт",
|
||||
"theme": {
|
||||
"light": "Светлая тема",
|
||||
"dark": "Темная тема"
|
||||
}
|
||||
},
|
||||
"updateDialog": {
|
||||
"title": "Обновить AstrBot",
|
||||
"currentVersion": "Текущая версия",
|
||||
"status": {
|
||||
"checking": "Проверка обновлений...",
|
||||
"switching": "Переключение версии...",
|
||||
"updating": "Обновление..."
|
||||
},
|
||||
"tabs": {
|
||||
"release": "😊 Релиз"
|
||||
},
|
||||
"updateToLatest": "Обновить до последней версии",
|
||||
"preRelease": "Предварительная версия",
|
||||
"preReleaseWarning": {
|
||||
"title": "Внимание: предварительная версия",
|
||||
"description": "Версии с меткой Pre-release могут содержать неизвестные ошибки. Не рекомендуется использовать в рабочих средах. Если вы обнаружили ошибку, пожалуйста, сообщите о ней в ",
|
||||
"issueLink": "GitHub Issues"
|
||||
},
|
||||
"tip": "💡 ПОДСКАЗКА: ",
|
||||
"tipContinue": "По умолчанию при переключении версии загружаются соответствующие файлы WebUI. Код WebUI находится в директории dashboard, вы можете собрать его самостоятельно с помощью npm.",
|
||||
"dockerTip": "При переключении версии будет предпринята попытка обновить как основной процесс бота, так и панель управления. Если вы используете Docker, вы также можете обновить образ или использовать",
|
||||
"dockerTipLink": "watchtower",
|
||||
"dockerTipContinue": "для автоматического мониторинга и обновления.",
|
||||
"table": {
|
||||
"tag": "Тег",
|
||||
"publishDate": "Дата публикации",
|
||||
"content": "Содержание",
|
||||
"sourceUrl": "Исходный код",
|
||||
"actions": "Действия",
|
||||
"view": "Просмотр",
|
||||
"switch": "Переключить"
|
||||
},
|
||||
"releaseNotes": {
|
||||
"title": "Журнал изменений"
|
||||
},
|
||||
"redirectConfirm": {
|
||||
"title": "Переход по ссылке",
|
||||
"message": "Вы будете перенаправлены на страницу GitHub Releases. Продолжить?",
|
||||
"latestLabel": "Последняя версия",
|
||||
"targetVersion": "Целевая версия:",
|
||||
"currentVersion": "Текущая версия:",
|
||||
"guideTitle": "Рекомендации после перехода:",
|
||||
"guideStep1": "Загрузите пакет, соответствующий архитектуре вашей системы.",
|
||||
"guideStep2": "После завершения установки перезапустите AstrBot.",
|
||||
"guideStep3": "Если вы используете Docker, отдайте приоритет обновлению через образ."
|
||||
},
|
||||
"desktopApp": {
|
||||
"title": "Обновить десктопное приложение",
|
||||
"message": "Проверка и обновление десктопной версии AstrBot.",
|
||||
"currentVersion": "Текущая версия:",
|
||||
"latestVersion": "Последняя версия:",
|
||||
"checking": "Проверка обновлений десктопного приложения...",
|
||||
"hasNewVersion": "Найдена новая версия. Нажмите для подтверждения обновления.",
|
||||
"isLatest": "Установлена последняя версия",
|
||||
"installing": "Загрузка и установка обновления... Приложение будет перезапущено автоматически.",
|
||||
"checkFailed": "Ошибка проверки обновлений. Попробуйте позже.",
|
||||
"installFailed": "Ошибка обновления. Попробуйте позже."
|
||||
},
|
||||
"dashboardUpdate": {
|
||||
"title": "Обновить только панель управления",
|
||||
"currentVersion": "Текущая версия",
|
||||
"hasNewVersion": "Доступна новая версия!",
|
||||
"isLatest": "Установлена последняя версия.",
|
||||
"downloadAndUpdate": "Скачать и обновить"
|
||||
}
|
||||
},
|
||||
"accountDialog": {
|
||||
"title": "Изменить аккаунт",
|
||||
"securityWarning": "Безопасность: Пожалуйста, смените пароль по умолчанию для защиты аккаунта",
|
||||
"form": {
|
||||
"currentPassword": "Текущий пароль",
|
||||
"newPassword": "Новый пароль",
|
||||
"confirmPassword": "Подтвердите новый пароль",
|
||||
"newUsername": "Новое имя пользователя (опционально)",
|
||||
"passwordHint": "Пароль должен быть не менее 8 символов",
|
||||
"confirmPasswordHint": "Введите новый пароль еще раз",
|
||||
"usernameHint": "Оставьте пустым, если не хотите менять имя пользователя",
|
||||
"defaultCredentials": "Логин и пароль по умолчанию: astrbot"
|
||||
},
|
||||
"validation": {
|
||||
"passwordRequired": "Введите пароль",
|
||||
"passwordMinLength": "Пароль должен быть не менее 8 символов",
|
||||
"passwordMatch": "Паролы не совпадают",
|
||||
"usernameMinLength": "Имя пользователя должно быть не менее 3 символов"
|
||||
},
|
||||
"actions": {
|
||||
"save": "Сохранить изменения",
|
||||
"cancel": "Отмена"
|
||||
},
|
||||
"messages": {
|
||||
"updateFailed": "Ошибка обновления, попробуйте еще раз"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
{
|
||||
"welcome": "Добро пожаловать",
|
||||
"dashboard": "Статистика",
|
||||
"platforms": "Боты",
|
||||
"providers": "Провайдеры моделей",
|
||||
"commands": "Команды",
|
||||
"persona": "Персонажи",
|
||||
"subagent": "Субагенты",
|
||||
"toolUse": "Инструменты MCP",
|
||||
"extension": "Плагины",
|
||||
"extensionTabs": {
|
||||
"installed": "Плагины AstrBot",
|
||||
"market": "Магазин плагинов",
|
||||
"mcp": "Серверы MCP",
|
||||
"skills": "Навыки",
|
||||
"components": "Управление поведением"
|
||||
},
|
||||
"config": "Конфигурация",
|
||||
"chat": "Чат",
|
||||
"cron": "Запланированные задачи",
|
||||
"conversation": "Данные диалогов",
|
||||
"sessionManagement": "Пользовательские правила",
|
||||
"console": "Логи платформы",
|
||||
"trace": "Трассировка",
|
||||
"alkaid": "Alkaid Lab",
|
||||
"knowledgeBase": "База знаний",
|
||||
"about": "О программе",
|
||||
"settings": "Настройки",
|
||||
"changelog": "Журнал изменений",
|
||||
"documentation": "Документация",
|
||||
"faq": "FAQ",
|
||||
"github": "GitHub",
|
||||
"drag": "Перетащить",
|
||||
"groups": {
|
||||
"more": "Дополнительно"
|
||||
},
|
||||
"changelogDialog": {
|
||||
"title": "Журнал изменений",
|
||||
"loading": "Загрузка...",
|
||||
"error": "Ошибка загрузки",
|
||||
"notFound": "Журнал изменений для этой версии не найден",
|
||||
"selectVersion": "Выберите версию",
|
||||
"current": "Текущая"
|
||||
},
|
||||
"configTabs": {
|
||||
"normal": "Обычная конфигурация",
|
||||
"system": "Системная конфигурация"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
{
|
||||
"knowledgeBaseSelector": {
|
||||
"notSelected": "Не выбрано",
|
||||
"buttonText": "Выбрать базу знаний...",
|
||||
"dialogTitle": "Выбор базы знаний",
|
||||
"loading": "Загрузка...",
|
||||
"noKnowledgeBases": "Базы знаний не найдены",
|
||||
"createKnowledgeBase": "Создать базу знаний",
|
||||
"selectedCount": "Выбрано баз знаний: {count}",
|
||||
"confirmSelection": "ОК",
|
||||
"cancelSelection": "Отмена",
|
||||
"noDescription": "Нет описания",
|
||||
"documentCount": "Документов: {count}",
|
||||
"chunkCount": "Фрагментов: {count}"
|
||||
},
|
||||
"pluginSetSelector": {
|
||||
"notSelected": "Плагины не включены",
|
||||
"allPlugins": "Включить все плагины (*)",
|
||||
"selectedCount": "Выбрано плагинов: {count}",
|
||||
"buttonText": "Выбрать набор плагинов...",
|
||||
"dialogTitle": "Выбор набора плагинов",
|
||||
"loading": "Загрузка...",
|
||||
"enableAll": "Включить все",
|
||||
"enableNone": "Ничего не включать",
|
||||
"customSelect": "Настроить выбор",
|
||||
"noPlugins": "Доступных плагинов нет",
|
||||
"confirmSelection": "ОК",
|
||||
"cancelSelection": "Отмена",
|
||||
"noDescription": "Нет описания",
|
||||
"notActivated": "Не активирован",
|
||||
"note": "*Системные и уже выключенные в настройках плагины не отображаются.",
|
||||
"selectedPluginsLabel": "Выбранные плагины:",
|
||||
"allPluginsLabel": "Все плагины"
|
||||
},
|
||||
"providerSelector": {
|
||||
"notSelected": "Не выбрано",
|
||||
"buttonText": "Выбрать провайдера...",
|
||||
"dialogTitle": "Выбор провайдера",
|
||||
"loading": "Загрузка...",
|
||||
"noProviders": "Доступных провайдеров нет",
|
||||
"confirmSelection": "ОК",
|
||||
"cancelSelection": "Отмена",
|
||||
"clearSelection": "Сбросить выбор",
|
||||
"clearSelectionSubtitle": "Очистить текущий выбор",
|
||||
"unknownType": "Неизвестный тип",
|
||||
"createProvider": "Создать провайдера",
|
||||
"manageProviders": "Управление провайдерами",
|
||||
"selectProviderPool": "Выбрать пул провайдеров...",
|
||||
"selectedCount": "Выбрано провайдеров: {count}"
|
||||
},
|
||||
"personaSelector": {
|
||||
"notSelected": "Не выбрано",
|
||||
"defaultPersona": "Персонаж по умолчанию",
|
||||
"buttonText": "Выбрать персонажа...",
|
||||
"editPersona": "Изменить текущего персонажа",
|
||||
"dialogTitle": "Выбор персонажа",
|
||||
"noDescription": "Нет описания",
|
||||
"noPersonas": "Доступных персонажей нет",
|
||||
"createPersona": "Создать персонажа",
|
||||
"cancelSelection": "Отмена",
|
||||
"confirmSelection": "ОК",
|
||||
"selectPersonaPool": "Выбрать пул персонажей...",
|
||||
"rootFolder": "Все персонажи",
|
||||
"emptyFolder": "Папка пуста"
|
||||
},
|
||||
"personaQuickPreview": {
|
||||
"title": "Быстрый просмотр",
|
||||
"loading": "Загрузка...",
|
||||
"noPersonaSelected": "Персонаж не выбран",
|
||||
"personaNotFound": "Информация о персонаже не найдена",
|
||||
"systemPromptLabel": "Системный промпт",
|
||||
"toolsLabel": "Инструменты",
|
||||
"skillsLabel": "Навыки (Skills)",
|
||||
"originLabel": "Источник",
|
||||
"originNameLabel": "Имя источника",
|
||||
"toolInactive": "Выключено",
|
||||
"toolInactiveTooltip": "Этот инструмент выключен. Включите его в Плагины -> Управление поведением -> Функции.",
|
||||
"allTools": "Доступны все инструменты",
|
||||
"allToolsWithCount": "Доступны все инструменты ({count})",
|
||||
"noTools": "Инструменты не настроены",
|
||||
"allSkills": "Доступны все навыки (Skills)",
|
||||
"allSkillsWithCount": "Доступны все навыки ({count})",
|
||||
"noSkills": "Навыки (Skills) не настроены"
|
||||
},
|
||||
"t2iTemplateEditor": {
|
||||
"buttonText": "Настроить T2I шаблон",
|
||||
"dialogTitle": "Настройка HTML шаблона Text-to-Image",
|
||||
"newTemplateNameLabel": "Введите имя нового шаблона",
|
||||
"nameRequired": "Имя обязательно для заполнения",
|
||||
"selectTemplateLabel": "Выбрать шаблон",
|
||||
"applied": "Применено",
|
||||
"apply": "Применить",
|
||||
"templateEditor": "Редактор шаблона",
|
||||
"new": "Создать",
|
||||
"resetBase": "Сбросить 'base'",
|
||||
"delete": "Удалить",
|
||||
"save": "Сохранить",
|
||||
"livePreview": "Предпросмотр (может отличаться)",
|
||||
"refreshPreview": "Обновить",
|
||||
"previewText": "Это пример текста для предпросмотра результата шаблона.\n\nОн может содержать несколько строк и различные форматы.",
|
||||
"syntaxHint": "Поддерживается синтаксис jinja2. Переменные: text | safe (текст для рендеринга), version (версия AstrBot)",
|
||||
"saveAndApply": "Сохранить и применить текущий шаблон",
|
||||
"confirmReset": "Подтверждение сброса",
|
||||
"confirmResetMessage": "Вы уверены, что хотите сбросить шаблон 'base' до значений по умолчанию? Все несохраненные изменения будут потеряны. Это действие необратимо.",
|
||||
"confirmResetButton": "Сбросить",
|
||||
"confirmDelete": "Подтверждение удаления",
|
||||
"confirmDeleteMessage": "Вы уверены, что хотите удалить шаблон '{name}'? Это действие необратимо.",
|
||||
"confirmDeleteButton": "Удалить",
|
||||
"confirmAction": "Подтверждение действия",
|
||||
"confirmApplyMessage": "Вы уверены, что хотите сохранить изменения в '{name}' и сделать его активным шаблоном?"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"loading": "Загрузка",
|
||||
"success": "Успешно",
|
||||
"error": "Ошибка",
|
||||
"warning": "Внимание",
|
||||
"info": "Информация",
|
||||
"pending": "В ожидании",
|
||||
"processing": "В процессе",
|
||||
"completed": "Завершено",
|
||||
"failed": "Ошибка",
|
||||
"cancelled": "Отменено",
|
||||
"timeout": "Тайм-аут",
|
||||
"connecting": "Подключение",
|
||||
"connected": "Подключено",
|
||||
"disconnected": "Отключено",
|
||||
"online": "В сети",
|
||||
"offline": "Не в сети",
|
||||
"active": "Активен",
|
||||
"inactive": "Неактивен",
|
||||
"ready": "Готов",
|
||||
"busy": "Занят"
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"hero": {
|
||||
"title": "AstrBot",
|
||||
"subtitle": "Проект, рожденный из интереса и любви ❤️",
|
||||
"starButton": "Star этот проект! 🌟",
|
||||
"issueButton": "Сообщить об ошибке"
|
||||
},
|
||||
"contributors": {
|
||||
"title": "Контрибьюторы",
|
||||
"description": "Этот проект поддерживается участниками open-source сообщества. Спасибо каждому за вклад!",
|
||||
"viewLink": "Посмотреть всех участников"
|
||||
},
|
||||
"stats": {
|
||||
"title": "Глобальное развертывание",
|
||||
"license": "AstrBot распространяется по лицензии AGPL v3"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
{
|
||||
"title": "Лаборатория Alkaid",
|
||||
"subtitle": "Исследуйте передовые возможности AI",
|
||||
"comingSoon": "Этот мир еще впереди, заходите позже!",
|
||||
"page": {
|
||||
"title": "Проект Alkaid.",
|
||||
"subtitle": "AstrBot Alpha Project",
|
||||
"navigation": {
|
||||
"knowledgeBase": "База знаний (Плагин)",
|
||||
"longTermMemory": "Долгосрочная память",
|
||||
"other": "..."
|
||||
}
|
||||
},
|
||||
"features": {
|
||||
"knowledgeBase": "База знаний",
|
||||
"longTermMemory": "Долгосрочная память",
|
||||
"advancedChat": "Продвинутый чат",
|
||||
"multiModal": "Мультимодальность"
|
||||
},
|
||||
"status": {
|
||||
"experimental": "Экспериментально",
|
||||
"beta": "Бета",
|
||||
"stable": "Стабильно",
|
||||
"deprecated": "Устарело"
|
||||
},
|
||||
"sigma": {
|
||||
"subtitle": "Экспериментальный проект AstrBot",
|
||||
"visualization": "Визуализация",
|
||||
"filterUserId": "Фильтр по User ID",
|
||||
"filter": "Фильтр",
|
||||
"resetFilter": "Сброс",
|
||||
"refreshGraph": "Обновить граф",
|
||||
"nodeDetails": "Детали узла",
|
||||
"id": "ID",
|
||||
"type": "Тип",
|
||||
"name": "Имя",
|
||||
"userId": "ID пользователя",
|
||||
"timestamp": "Метка времени",
|
||||
"graphStats": "Статистика графа",
|
||||
"nodeCount": "Узлов",
|
||||
"edgeCount": "Связей",
|
||||
"inDevelopment": "В разработке"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
{
|
||||
"title": "База знаний",
|
||||
"subtitle": "Управление контентом базы знаний и поиск",
|
||||
"documents": {
|
||||
"title": "Список документов",
|
||||
"name": "Имя файла",
|
||||
"size": "Размер",
|
||||
"uploadTime": "Дата загрузки",
|
||||
"status": "Статус",
|
||||
"actions": "Действия"
|
||||
},
|
||||
"management": {
|
||||
"delete": "Удалить",
|
||||
"preview": "Предпросмотр",
|
||||
"download": "Скачать",
|
||||
"reindex": "Переиндексировать"
|
||||
},
|
||||
"notInstalled": {
|
||||
"title": "Плагин базы знаний не установлен",
|
||||
"install": "Установить сейчас"
|
||||
},
|
||||
"empty": {
|
||||
"title": "База знаний пуста. Создайте свою первую базу! 🙂",
|
||||
"create": "Создать базу знаний"
|
||||
},
|
||||
"list": {
|
||||
"title": "Список баз знаний",
|
||||
"create": "Создать базу знаний",
|
||||
"config": "Настройка",
|
||||
"checkUpdate": "Проверить обновления плагина",
|
||||
"updatePlugin": "Обновить плагин до версии {version}",
|
||||
"knowledgeCount": "записей",
|
||||
"tips": "Совет: используйте команду /kb в чате, чтобы узнать, как пользоваться базой!"
|
||||
},
|
||||
"createDialog": {
|
||||
"title": "Создание базы знаний",
|
||||
"nameLabel": "Название",
|
||||
"descriptionLabel": "Описание",
|
||||
"descriptionPlaceholder": "Краткое описание...",
|
||||
"embeddingModelLabel": "Embedding модель",
|
||||
"rerankModelLabel": "Rerank модель",
|
||||
"providerInfo": "Провайдер: {id} | Размерность: {dimensions}",
|
||||
"rerankProviderInfo": "Провайдер: {id}",
|
||||
"tips": "Совет: после выбора Embedding модели не рекомендуется менять провайдера или размерность векторов, так как это сделает текущий индекс нечитаемым.",
|
||||
"cancel": "Отмена",
|
||||
"create": "Создать"
|
||||
},
|
||||
"emojiPicker": {
|
||||
"title": "Выберите иконку",
|
||||
"close": "Закрыть",
|
||||
"categories": {
|
||||
"emotions": "Смайлы",
|
||||
"animals": "Животные и природа",
|
||||
"food": "Еда и напитки",
|
||||
"activities": "Занятия и вещи",
|
||||
"travel": "Места и путешествия",
|
||||
"symbols": "Символы и флаги"
|
||||
}
|
||||
},
|
||||
"contentDialog": {
|
||||
"title": "Управление базой знаний",
|
||||
"embeddingModel": "Embedding модель",
|
||||
"vectorDimension": "Размерность",
|
||||
"usage": "Использование: введите «/kb use {name}» в чате",
|
||||
"tabs": {
|
||||
"upload": "Загрузка файлов",
|
||||
"search": "Поиск",
|
||||
"fromURL": "Импорт из URL"
|
||||
}
|
||||
},
|
||||
"upload": {
|
||||
"title": "Загрузка файлов",
|
||||
"subtitle": "Поддерживаются форматы txt, pdf, word, excel и др.",
|
||||
"dropzone": "Перетащите файлы сюда или нажмите для выбора",
|
||||
"chunkSettings": {
|
||||
"title": "Настройка фрагментации (Chunking)",
|
||||
"tooltip": "Размер фрагмента определяет объем текста в одном блоке. Перекрытие позволяет сохранить контекст между соседними блоками.\nМаленькие фрагменты точнее, но увеличивают объем базы.",
|
||||
"chunkSizeLabel": "Размер фрагмента",
|
||||
"chunkSizeHint": "Длина текста в одном блоке (пусто = по умолчанию)",
|
||||
"overlapLabel": "Перекрытие",
|
||||
"overlapHint": "Нахлест между соседними блоками (пусто = по умолчанию)"
|
||||
},
|
||||
"upload": "Начать загрузку",
|
||||
"uploading": "Загрузка..."
|
||||
},
|
||||
"search": {
|
||||
"queryLabel": "Поиск по базе знаний",
|
||||
"queryPlaceholder": "Введите ключевые слова...",
|
||||
"resultCountLabel": "Количество результатов",
|
||||
"searching": "Поиск...",
|
||||
"resultsTitle": "Результаты поиска",
|
||||
"relevance": "Релевантность",
|
||||
"noResults": "Совпадений не найдено"
|
||||
},
|
||||
"deleteDialog": {
|
||||
"title": "Подтверждение удаления",
|
||||
"confirmText": "Вы уверены, что хотите удалить базу знаний «{name}»?",
|
||||
"warning": "Это действие необратимо. Весь контент базы знаний будет навсегда удален.",
|
||||
"cancel": "Отмена",
|
||||
"delete": "Удалить"
|
||||
},
|
||||
"messages": {
|
||||
"pluginNotAvailable": "Плагин не установлен или недоступен",
|
||||
"pluginNotActivated": "Плагин astrbot_plugin_knowledge_base не включен. Пожалуйста, активируйте его в разделе плагинов и перезапустите AstrBot.",
|
||||
"checkPluginFailed": "Не удалось проверить плагин",
|
||||
"installFailed": "Ошибка установки",
|
||||
"installPluginFailed": "Не удалось установить плагин",
|
||||
"getKnowledgeBaseListFailed": "Ошибка получения списка баз знаний",
|
||||
"knowledgeBaseCreated": "База знаний создана",
|
||||
"createFailed": "Ошибка создания",
|
||||
"createKnowledgeBaseFailed": "Не удалось создать базу знаний",
|
||||
"pleaseEnterKnowledgeBaseName": "Укажите название базы знаний",
|
||||
"pleaseSelectFile": "Пожалуйста, сначала выберите файл",
|
||||
"operationSuccess": "Успешно: {message}",
|
||||
"uploadFailed": "Ошибка загрузки",
|
||||
"fileUploadFailed": "Не удалось загрузить файл",
|
||||
"pleaseEnterSearchContent": "Введите текст для поиска",
|
||||
"noMatchingContent": "Ничего не найдено",
|
||||
"searchFailed": "Ошибка поиска",
|
||||
"searchKnowledgeBaseFailed": "Не удалось выполнить поиск",
|
||||
"deleteTargetNotExists": "Объект для удаления не найден",
|
||||
"knowledgeBaseDeleted": "База знаний удалена",
|
||||
"deleteFailed": "Ошибка удаления",
|
||||
"deleteKnowledgeBaseFailed": "Не удалось удалить базу знаний",
|
||||
"getEmbeddingModelListFailed": "Не удалось загрузить список Embedding моделей",
|
||||
"updateAvailable": "Доступна новая версия: {current} -> {latest}",
|
||||
"pluginUpToDate": "У вас последняя версия плагина",
|
||||
"pluginNotFoundInMarket": "Плагин не найден в магазине",
|
||||
"checkUpdateFailed": "Ошибка проверки обновлений",
|
||||
"updateSuccess": "Плагин успешно обновлен",
|
||||
"updateFailed": "Ошибка обновления",
|
||||
"updatePluginFailed": "Не удалось обновить плагин"
|
||||
},
|
||||
"importFromUrl": {
|
||||
"title": "Импорт из URL",
|
||||
"urlLabel": "Адрес страницы",
|
||||
"urlPlaceholder": "Введите URL для извлечения знаний",
|
||||
"optionsTitle": "Настройки импорта",
|
||||
"tooltip": "Эти параметры управляют извлечением текста из URL.\nЕсли оставить пустыми, будут использованы настройки по умолчанию.\nТекстовая очистка через LLM может занять время.",
|
||||
"useLlmRepairLabel": "Исправление текста через LLM",
|
||||
"useClusteringSummaryLabel": "Кластеризация и суммаризация",
|
||||
"repairLlmProviderIdLabel": "Модель для очистки",
|
||||
"summarizeLlmProviderIdLabel": "Модель для суммаризации",
|
||||
"embeddingProviderIdLabel": "Embedding модель",
|
||||
"chunkSizeLabel": "Размер фрагмента",
|
||||
"chunkOverlapLabel": "Перекрытие",
|
||||
"startImport": "Начать импорт",
|
||||
"importing": "Импорт...",
|
||||
"importSuccess": "Импортировано успешно",
|
||||
"importFailed": "Ошибка импорта",
|
||||
"uploadingChunks": "Текст извлечен, загрузка фрагментов...",
|
||||
"preRequisite": "Примечание: сначала установите плагин astrbot_plugin_url_2_knowledge_base и выполните установку playwright согласно документации.",
|
||||
"allChunksUploaded": "Все фрагменты успешно загружены"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
{
|
||||
"title": "Долгосрочная память",
|
||||
"subtitle": "Управление памятью вашего AI-помощника",
|
||||
"memories": {
|
||||
"title": "Список воспоминаний",
|
||||
"content": "Содержание",
|
||||
"importance": "Важность",
|
||||
"createTime": "Дата создания",
|
||||
"lastAccess": "Последнее обращение",
|
||||
"category": "Категория"
|
||||
},
|
||||
"categories": {
|
||||
"personal": "Личное",
|
||||
"preferences": "Предпочтения",
|
||||
"conversations": "История диалогов",
|
||||
"facts": "Факты",
|
||||
"skills": "Навыки"
|
||||
},
|
||||
"importance": {
|
||||
"high": "Высокая",
|
||||
"medium": "Средняя",
|
||||
"low": "Низкая"
|
||||
},
|
||||
"actions": {
|
||||
"view": "Детали",
|
||||
"edit": "Изменить",
|
||||
"delete": "Удалить",
|
||||
"pin": "Закрепить",
|
||||
"unpin": "Открепить"
|
||||
},
|
||||
"filters": {
|
||||
"all": "Все",
|
||||
"category": "По категории",
|
||||
"importance": "По важности",
|
||||
"dateRange": "По периоду",
|
||||
"title": "Фильтр",
|
||||
"userIdLabel": "Фильтр по User ID",
|
||||
"filterButton": "Применить",
|
||||
"resetButton": "Сбросить",
|
||||
"refreshButton": "Обновить граф"
|
||||
},
|
||||
"search": {
|
||||
"title": "Поиск по памяти",
|
||||
"userIdLabel": "ID пользователя",
|
||||
"queryLabel": "Ключевое слово",
|
||||
"searchButton": "Поиск",
|
||||
"resultsTitle": "Результаты поиска",
|
||||
"noResults": "Ничего не найдено",
|
||||
"similarity": "Сходство",
|
||||
"noTextContent": "Нет текста"
|
||||
},
|
||||
"addMemory": {
|
||||
"title": "Добавить данные в память",
|
||||
"textLabel": "Текст воспоминания",
|
||||
"userIdLabel": "ID пользователя",
|
||||
"summarizeLabel": "Нужна суммаризация",
|
||||
"addButton": "Добавить"
|
||||
},
|
||||
"nodeDetails": {
|
||||
"title": "Детали узла",
|
||||
"id": "ID",
|
||||
"type": "Тип",
|
||||
"name": "Имя",
|
||||
"userId": "ID пользователя",
|
||||
"timestamp": "Метка времени"
|
||||
},
|
||||
"graphStats": {
|
||||
"title": "Статистика графа",
|
||||
"nodeCount": "Узлов",
|
||||
"edgeCount": "Связей"
|
||||
},
|
||||
"factDialog": {
|
||||
"title": "Факт из памяти",
|
||||
"id": "ID",
|
||||
"docId": "ID документа",
|
||||
"createdAt": "Создано",
|
||||
"updatedAt": "Обновлено",
|
||||
"metadata": "Метаданные",
|
||||
"metadataKey": "Ключ",
|
||||
"metadataValue": "Значение",
|
||||
"loading": "Загрузка...",
|
||||
"close": "Закрыть",
|
||||
"noValue": "нет",
|
||||
"unknown": "неизвестно"
|
||||
},
|
||||
"messages": {
|
||||
"searchQueryRequired": "Пожалуйста, введите запрос",
|
||||
"searchSuccess": "Найдено записей: {count}",
|
||||
"searchNoResults": "В памяти ничего не найдено",
|
||||
"searchError": "Ошибка поиска",
|
||||
"addSuccess": "Данные успешно добавлены в память!",
|
||||
"addError": "Не удалось добавить данные",
|
||||
"factDetailsError": "Ошибка загрузки деталей",
|
||||
"metadataParseError": "Не удалось разобрать метаданные",
|
||||
"relationNoMemoryData": "У этой связи нет ассоциированных данных"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"login": "Вход",
|
||||
"username": "Имя пользователя",
|
||||
"password": "Пароль",
|
||||
"defaultHint": "Логин и пароль по умолчанию: astrbot",
|
||||
"logo": {
|
||||
"title": "Панель управления AstrBot",
|
||||
"subtitle": "Добро пожаловать"
|
||||
},
|
||||
"theme": {
|
||||
"switchToDark": "Перейти на темную тему",
|
||||
"switchToLight": "Перейти на светлую тему"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"messageCount": "Количество сообщений",
|
||||
"time": "Время"
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
{
|
||||
"title": "Давай пообщаемся!",
|
||||
"subtitle": "Общение с AI-помощником",
|
||||
"input": {
|
||||
"placeholder": "Введите сообщение...",
|
||||
"send": "Отправить",
|
||||
"clear": "Очистить",
|
||||
"upload": "Загрузить файл",
|
||||
"voice": "Голосовой ввод",
|
||||
"recordingPrompt": "Запись... говорите",
|
||||
"chatPrompt": "Давай пообщаемся!",
|
||||
"dropToUpload": "Отпустите, чтобы загрузить файл",
|
||||
"stopGenerating": "Остановить генерацию"
|
||||
},
|
||||
"message": {
|
||||
"user": "Вы",
|
||||
"assistant": "Ассистент",
|
||||
"system": "Система",
|
||||
"error": "Ошибка в сообщении",
|
||||
"loading": "Думаю..."
|
||||
},
|
||||
"voice": {
|
||||
"start": "Начать запись",
|
||||
"stop": "Стоп",
|
||||
"recording": "Запись",
|
||||
"processing": "Обработка...",
|
||||
"error": "Ошибка записи",
|
||||
"listening": "Слушаю...",
|
||||
"speaking": "Говорю",
|
||||
"startRecording": "Начать голосовой ввод",
|
||||
"liveMode": "Общение в реальном времени"
|
||||
},
|
||||
"welcome": {
|
||||
"title": "Добро пожаловать в AstrBot",
|
||||
"subtitle": "Ваш умный помощник",
|
||||
"quickActions": "Быстрые действия",
|
||||
"examples": "Примеры вопросов"
|
||||
},
|
||||
"actions": {
|
||||
"copy": "Копировать",
|
||||
"regenerate": "Перегенерировать",
|
||||
"like": "Нравится",
|
||||
"dislike": "Не нравится",
|
||||
"share": "Поделиться",
|
||||
"newChat": "Новый чат",
|
||||
"deleteChat": "Удалить чат",
|
||||
"editTitle": "Изменить заголовок",
|
||||
"fullscreen": "На весь экран",
|
||||
"exitFullscreen": "Выход из полноэкранного режима",
|
||||
"reply": "Ответить",
|
||||
"providerConfig": "Настройки AI",
|
||||
"toolsUsed": "Использованные инструменты",
|
||||
"toolCallUsed": "Использован инструмент {name}",
|
||||
"pythonCodeAnalysis": "Использован анализ кода Python"
|
||||
},
|
||||
"ipython": {
|
||||
"output": "Вывод"
|
||||
},
|
||||
"conversation": {
|
||||
"newConversation": "Новый чат",
|
||||
"noHistory": "История диалогов пуста",
|
||||
"systemStatus": "Статус системы",
|
||||
"llmService": "Сервис LLM",
|
||||
"speechToText": "Преобразование речи",
|
||||
"editDisplayName": "Изменить имя чата",
|
||||
"displayName": "Имя чата",
|
||||
"displayNameUpdated": "Имя чата обновлено",
|
||||
"displayNameUpdateFailed": "Не удалось обновить имя чата",
|
||||
"confirmDelete": "Вы уверены, что хотите удалить «{name}»? Это действие необратимо."
|
||||
},
|
||||
"modes": {
|
||||
"darkMode": "Темная тема",
|
||||
"lightMode": "Светлая тема"
|
||||
},
|
||||
"shortcuts": {
|
||||
"help": "Справка",
|
||||
"voiceRecord": "Запись голоса",
|
||||
"pasteImage": "Вставить изображение",
|
||||
"sendKey": {
|
||||
"title": "Клавиша отправки",
|
||||
"enterToSend": "Enter для отправки",
|
||||
"shiftEnterToSend": "Shift+Enter для отправки"
|
||||
}
|
||||
},
|
||||
"streaming": {
|
||||
"enabled": "Потоковый ответ включен",
|
||||
"disabled": "Потоковый ответ выключен",
|
||||
"on": "Поток",
|
||||
"off": "Обычный"
|
||||
},
|
||||
"transport": {
|
||||
"title": "Протокол передачи",
|
||||
"sse": "SSE",
|
||||
"websocket": "WebSocket"
|
||||
},
|
||||
"config": {
|
||||
"title": "Конфигурация"
|
||||
},
|
||||
"reasoning": {
|
||||
"thinking": "Рассуждение"
|
||||
},
|
||||
"reply": {
|
||||
"replyTo": "В ответ на",
|
||||
"notFound": "Сообщение не найдено"
|
||||
},
|
||||
"project": {
|
||||
"title": "Проект",
|
||||
"create": "Создать проект",
|
||||
"edit": "Изменить проект",
|
||||
"name": "Имя проекта",
|
||||
"emoji": "Иконка (Emoji)",
|
||||
"description": "Описание проекта (опционально)",
|
||||
"noSessions": "В этом проекте пока нет диалогов",
|
||||
"confirmDelete": "Вы уверены, что хотите удалить проект «{title}»? Диалоги внутри проекта не будут удалены."
|
||||
},
|
||||
"time": {
|
||||
"today": "Сегодня",
|
||||
"yesterday": "Вчера"
|
||||
},
|
||||
"stats": {
|
||||
"tokens": "Токены",
|
||||
"inputTokens": "Входящие",
|
||||
"outputTokens": "Исходящие",
|
||||
"cachedTokens": "Кэшированные",
|
||||
"duration": "Время",
|
||||
"ttft": "Время до первого токена"
|
||||
},
|
||||
"refs": {
|
||||
"title": "Ссылки",
|
||||
"sources": "Источники"
|
||||
},
|
||||
"connection": {
|
||||
"title": "Статус подключения",
|
||||
"message": "Системе необходимо переустановить соединение с чатом.",
|
||||
"reasons": "Это может быть вызвано следующими причинами:",
|
||||
"reasonWindowResize": "Изменение размера окна (нормально)",
|
||||
"reasonMultipleTabs": "Страница чата открыта в другой вкладке",
|
||||
"reasonNetworkIssue": "Временная проблема с сетью",
|
||||
"notice": "Примечание: для стабильной работы допускается только одно активное соединение. Если вы используете чат в нескольких вкладках, рекомендуем оставить только одну.",
|
||||
"understand": "Понятно",
|
||||
"status": {
|
||||
"reconnecting": "Переподключение...",
|
||||
"reconnected": "Соединение восстановлено",
|
||||
"failed": "Ошибка подключения, обновите страницу"
|
||||
}
|
||||
},
|
||||
"errors": {
|
||||
"sendMessageFailed": "Ошибка отправки сообщения, попробуйте еще раз",
|
||||
"createSessionFailed": "Ошибка создания сессии, обновите страницу"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
{
|
||||
"title": "Управление командами",
|
||||
"summary": {
|
||||
"total": "Всего команд",
|
||||
"disabled": "Отключено",
|
||||
"conflicts": "Конфликты"
|
||||
},
|
||||
"conflictAlert": {
|
||||
"title": "Обнаружены конфликты команд",
|
||||
"description": "Сейчас конфликтуют {count} пары команд. Это может привести к одновременному срабатыванию нескольких плагинов и непредсказуемому поведению.",
|
||||
"hint": "Нажмите «Переименовать», чтобы изменить название конфликтующей команды."
|
||||
},
|
||||
"table": {
|
||||
"headers": {
|
||||
"command": "Команда",
|
||||
"type": "Тип",
|
||||
"plugin": "Плагин",
|
||||
"description": "Описание",
|
||||
"permission": "Доступ",
|
||||
"status": "Статус",
|
||||
"actions": "Действия"
|
||||
}
|
||||
},
|
||||
"type": {
|
||||
"command": "Команда",
|
||||
"group": "Группа команд",
|
||||
"subCommand": "Под-команда"
|
||||
},
|
||||
"status": {
|
||||
"enabled": "Активна",
|
||||
"disabled": "Отключена",
|
||||
"conflict": "Конфликт"
|
||||
},
|
||||
"permission": {
|
||||
"everyone": "Все",
|
||||
"admin": "Админ"
|
||||
},
|
||||
"tooltips": {
|
||||
"enable": "Включить",
|
||||
"disable": "Выключить",
|
||||
"rename": "Переименовать",
|
||||
"viewDetails": "Подробности"
|
||||
},
|
||||
"dialogs": {
|
||||
"rename": {
|
||||
"title": "Переименование команды",
|
||||
"newName": "Новое название",
|
||||
"aliases": "Управление алиасами",
|
||||
"addAlias": "Добавить алиас",
|
||||
"cancel": "Отмена",
|
||||
"confirm": "Подтвердить"
|
||||
},
|
||||
"details": {
|
||||
"title": "Детали команды",
|
||||
"type": "Тип команды",
|
||||
"handler": "Обработчик (Handler)",
|
||||
"module": "Путь к модулю",
|
||||
"originalCommand": "Исходная команда",
|
||||
"effectiveCommand": "Действующая команда",
|
||||
"parentGroup": "Родительская группа",
|
||||
"subCommands": "Под-команды",
|
||||
"aliases": "Алиасы (Синонимы)",
|
||||
"permission": "Требования прав",
|
||||
"conflictStatus": "Статус конфликта"
|
||||
}
|
||||
},
|
||||
"messages": {
|
||||
"toggleSuccess": "Статус команды обновлен",
|
||||
"toggleFailed": "Не удалось изменить статус команды",
|
||||
"renameSuccess": "Команда переименована",
|
||||
"renameFailed": "Ошибка переименования",
|
||||
"loadFailed": "Ошибка загрузки списка команд",
|
||||
"updateSuccess": "Обновлено успешно",
|
||||
"updateFailed": "Ошибка обновления"
|
||||
},
|
||||
"search": {
|
||||
"placeholder": "Поиск команд..."
|
||||
},
|
||||
"empty": {
|
||||
"noCommands": "Команд не найдено",
|
||||
"noCommandsDesc": "По вашему запросу не найдено ни одной команды"
|
||||
},
|
||||
"filters": {
|
||||
"all": "Все",
|
||||
"enabled": "Активные",
|
||||
"disabled": "Отключенные",
|
||||
"conflict": "Конфликтующие",
|
||||
"byPlugin": "По плагину",
|
||||
"byType": "По типу",
|
||||
"byPermission": "По правам",
|
||||
"byStatus": "По статусу",
|
||||
"showSystemPlugins": "Показывать системные плагины",
|
||||
"systemPluginConflictHint": "Конфликт затрагивает системный плагин, его нельзя скрыть до разрешения конфликта"
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user