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 / 改动点
|
### Modifications / 改动点
|
||||||
|
|
||||||
<!--请总结你的改动:哪些核心文件被修改了?实现了什么功能?-->
|
|
||||||
<!--Please summarize your changes: What core files were modified? What functionality was implemented?-->
|
<!--Please summarize your changes: What core files were modified? What functionality was implemented?-->
|
||||||
|
<!--请总结你的改动:哪些核心文件被修改了?实现了什么功能?-->
|
||||||
|
|
||||||
- [x] This is NOT a breaking change. / 这不是一个破坏性变更。
|
- [x] This is NOT a breaking change. / 这不是一个破坏性变更。
|
||||||
<!-- If your changes is a breaking change, please uncheck the checkbox above -->
|
<!-- 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.-->
|
<!--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.
|
- [ ] 😊 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**.
|
/ 如果 PR 中有新加入的功能,已经通过 Issue / 邮件等方式和作者讨论过。
|
||||||
- [ ] 🤓 我确保没有引入新依赖库,或者引入了新依赖库的同时将其添加到了 `requirements.txt` 和 `pyproject.toml` 文件相应位置。/ I have ensured that no new dependencies are introduced, OR if new dependencies are introduced, they have been added to the appropriate locations in `requirements.txt` and `pyproject.toml`.
|
|
||||||
- [ ] 😮 我的更改没有引入恶意代码。/ My changes do not introduce malicious code.
|
- [ ] 👀 My changes have been well-tested, **and "Verification Steps" and "Screenshots" have been provided above**.
|
||||||
|
/ 我的更改经过了良好的测试,**并已在上方提供了“验证步骤”和“运行截图”**。
|
||||||
|
|
||||||
|
- [ ] 🤓 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 # 运行环境
|
runs-on: ubuntu-latest # 运行环境
|
||||||
steps:
|
steps:
|
||||||
- name: checkout
|
- name: checkout
|
||||||
uses: actions/checkout@master
|
uses: actions/checkout@v6
|
||||||
- name: nodejs installation
|
- name: nodejs installation
|
||||||
uses: actions/setup-node@v6
|
uses: actions/setup-node@v6
|
||||||
with:
|
with:
|
||||||
@@ -23,7 +23,7 @@ jobs:
|
|||||||
run: npm run docs:build
|
run: npm run docs:build
|
||||||
working-directory: './docs'
|
working-directory: './docs'
|
||||||
- name: scp
|
- name: scp
|
||||||
uses: appleboy/scp-action@master
|
uses: appleboy/scp-action@v1.0.0
|
||||||
with:
|
with:
|
||||||
host: ${{ secrets.HOST_NEKO }}
|
host: ${{ secrets.HOST_NEKO }}
|
||||||
username: ${{ secrets.USERNAME }}
|
username: ${{ secrets.USERNAME }}
|
||||||
@@ -31,7 +31,7 @@ jobs:
|
|||||||
source: 'docs/.vitepress/dist/*'
|
source: 'docs/.vitepress/dist/*'
|
||||||
target: '/tmp/'
|
target: '/tmp/'
|
||||||
- name: script
|
- name: script
|
||||||
uses: appleboy/ssh-action@master
|
uses: appleboy/ssh-action@v1.2.5
|
||||||
with:
|
with:
|
||||||
host: ${{ secrets.HOST_NEKO }}
|
host: ${{ secrets.HOST_NEKO }}
|
||||||
username: ${{ secrets.USERNAME }}
|
username: ${{ secrets.USERNAME }}
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Create GitHub Release
|
- name: Create GitHub Release
|
||||||
if: github.event_name == 'push'
|
if: github.event_name == 'push'
|
||||||
uses: ncipollo/release-action@v1
|
uses: ncipollo/release-action@v1.21.0
|
||||||
with:
|
with:
|
||||||
tag: release-${{ github.sha }}
|
tag: release-${{ github.sha }}
|
||||||
owner: AstrBotDevs
|
owner: AstrBotDevs
|
||||||
|
|||||||
@@ -64,20 +64,20 @@ jobs:
|
|||||||
echo "build_date=$build_date" >> $GITHUB_OUTPUT
|
echo "build_date=$build_date" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
- name: Set QEMU
|
- name: Set QEMU
|
||||||
uses: docker/setup-qemu-action@v4
|
uses: docker/setup-qemu-action@v4.0.0
|
||||||
|
|
||||||
- name: Set Docker Buildx
|
- name: Set Docker Buildx
|
||||||
uses: docker/setup-buildx-action@v4
|
uses: docker/setup-buildx-action@v4.0.0
|
||||||
|
|
||||||
- name: Log in to DockerHub
|
- name: Log in to DockerHub
|
||||||
uses: docker/login-action@v4
|
uses: docker/login-action@v4.0.0
|
||||||
with:
|
with:
|
||||||
username: ${{ secrets.DOCKER_HUB_USERNAME }}
|
username: ${{ secrets.DOCKER_HUB_USERNAME }}
|
||||||
password: ${{ secrets.DOCKER_HUB_PASSWORD }}
|
password: ${{ secrets.DOCKER_HUB_PASSWORD }}
|
||||||
|
|
||||||
- name: Login to GitHub Container Registry
|
- name: Login to GitHub Container Registry
|
||||||
if: env.HAS_GHCR_TOKEN == 'true'
|
if: env.HAS_GHCR_TOKEN == 'true'
|
||||||
uses: docker/login-action@v4
|
uses: docker/login-action@v4.0.0
|
||||||
with:
|
with:
|
||||||
registry: ghcr.io
|
registry: ghcr.io
|
||||||
username: ${{ env.GHCR_OWNER }}
|
username: ${{ env.GHCR_OWNER }}
|
||||||
@@ -98,7 +98,7 @@ jobs:
|
|||||||
echo "EOF" >> $GITHUB_OUTPUT
|
echo "EOF" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
- name: Build and Push Nightly Image
|
- name: Build and Push Nightly Image
|
||||||
uses: docker/build-push-action@v7
|
uses: docker/build-push-action@v7.0.0
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
platforms: linux/amd64,linux/arm64
|
platforms: linux/amd64,linux/arm64
|
||||||
@@ -163,27 +163,27 @@ jobs:
|
|||||||
cp -r dashboard/dist data/
|
cp -r dashboard/dist data/
|
||||||
|
|
||||||
- name: Set QEMU
|
- name: Set QEMU
|
||||||
uses: docker/setup-qemu-action@v4
|
uses: docker/setup-qemu-action@v4.0.0
|
||||||
|
|
||||||
- name: Set Docker Buildx
|
- name: Set Docker Buildx
|
||||||
uses: docker/setup-buildx-action@v4
|
uses: docker/setup-buildx-action@v4.0.0
|
||||||
|
|
||||||
- name: Log in to DockerHub
|
- name: Log in to DockerHub
|
||||||
uses: docker/login-action@v4
|
uses: docker/login-action@v4.0.0
|
||||||
with:
|
with:
|
||||||
username: ${{ secrets.DOCKER_HUB_USERNAME }}
|
username: ${{ secrets.DOCKER_HUB_USERNAME }}
|
||||||
password: ${{ secrets.DOCKER_HUB_PASSWORD }}
|
password: ${{ secrets.DOCKER_HUB_PASSWORD }}
|
||||||
|
|
||||||
- name: Login to GitHub Container Registry
|
- name: Login to GitHub Container Registry
|
||||||
if: env.HAS_GHCR_TOKEN == 'true'
|
if: env.HAS_GHCR_TOKEN == 'true'
|
||||||
uses: docker/login-action@v4
|
uses: docker/login-action@v4.0.0
|
||||||
with:
|
with:
|
||||||
registry: ghcr.io
|
registry: ghcr.io
|
||||||
username: ${{ env.GHCR_OWNER }}
|
username: ${{ env.GHCR_OWNER }}
|
||||||
password: ${{ secrets.GHCR_GITHUB_TOKEN }}
|
password: ${{ secrets.GHCR_GITHUB_TOKEN }}
|
||||||
|
|
||||||
- name: Build and Push Release Image
|
- name: Build and Push Release Image
|
||||||
uses: docker/build-push-action@v7
|
uses: docker/build-push-action@v7.0.0
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
platforms: linux/amd64,linux/arm64
|
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"
|
echo "tag=$tag" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
- name: Setup pnpm
|
- name: Setup pnpm
|
||||||
uses: pnpm/action-setup@v4
|
uses: pnpm/action-setup@v4.4.0
|
||||||
with:
|
with:
|
||||||
version: 10.28.2
|
version: 10.28.2
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -62,4 +62,4 @@ GenieData/
|
|||||||
.opencode/
|
.opencode/
|
||||||
.kilocode/
|
.kilocode/
|
||||||
.worktrees/
|
.worktrees/
|
||||||
docs/plans/
|
|
||||||
|
|||||||
@@ -78,7 +78,7 @@ For users who want to quickly experience AstrBot, are familiar with command-line
|
|||||||
```bash
|
```bash
|
||||||
uv tool install astrbot
|
uv tool install astrbot
|
||||||
astrbot init # Only execute this command for the first time to initialize the environment
|
astrbot init # Only execute this command for the first time to initialize the environment
|
||||||
astrbot
|
astrbot run
|
||||||
```
|
```
|
||||||
|
|
||||||
> Requires [uv](https://docs.astral.sh/uv/) to be installed.
|
> Requires [uv](https://docs.astral.sh/uv/) to be installed.
|
||||||
@@ -234,7 +234,8 @@ pre-commit install
|
|||||||
- Group 7: 743746109
|
- Group 7: 743746109
|
||||||
- Group 8: 1030353265
|
- Group 8: 1030353265
|
||||||
|
|
||||||
- Developer Group: 975206796
|
- Developer Group(Chit-chat): 975206796
|
||||||
|
- Developer Group(Formal): 1039761811
|
||||||
|
|
||||||
### Discord Server
|
### Discord Server
|
||||||
|
|
||||||
|
|||||||
+2
-1
@@ -78,7 +78,7 @@ Pour les utilisateurs qui veulent découvrir AstrBot rapidement, qui sont famili
|
|||||||
```bash
|
```bash
|
||||||
uv tool install astrbot
|
uv tool install astrbot
|
||||||
astrbot init # Exécutez cette commande uniquement la première fois pour initialiser l'environnement
|
astrbot init # Exécutez cette commande uniquement la première fois pour initialiser l'environnement
|
||||||
astrbot
|
astrbot run
|
||||||
```
|
```
|
||||||
|
|
||||||
> [uv](https://docs.astral.sh/uv/) doit être installé.
|
> [uv](https://docs.astral.sh/uv/) doit être installé.
|
||||||
@@ -222,6 +222,7 @@ pre-commit install
|
|||||||
- Groupe 5 : 822130018
|
- Groupe 5 : 822130018
|
||||||
- Groupe 6 : 753075035
|
- Groupe 6 : 753075035
|
||||||
- Groupe développeurs : 975206796
|
- Groupe développeurs : 975206796
|
||||||
|
- Groupe développeurs (officiel) : 1039761811
|
||||||
|
|
||||||
### Serveur Discord
|
### Serveur Discord
|
||||||
|
|
||||||
|
|||||||
+2
-1
@@ -78,7 +78,7 @@ AstrBot を素早く試したいユーザーで、コマンドラインに慣れ
|
|||||||
```bash
|
```bash
|
||||||
uv tool install astrbot
|
uv tool install astrbot
|
||||||
astrbot init # 初回のみ実行して環境を初期化します
|
astrbot init # 初回のみ実行して環境を初期化します
|
||||||
astrbot
|
astrbot run
|
||||||
```
|
```
|
||||||
|
|
||||||
> [uv](https://docs.astral.sh/uv/) のインストールが必要です。
|
> [uv](https://docs.astral.sh/uv/) のインストールが必要です。
|
||||||
@@ -223,6 +223,7 @@ pre-commit install
|
|||||||
- 5群: 822130018
|
- 5群: 822130018
|
||||||
- 6群: 753075035
|
- 6群: 753075035
|
||||||
- 開発者群: 975206796
|
- 開発者群: 975206796
|
||||||
|
- 開発者群(正式): 1039761811
|
||||||
|
|
||||||
### Discord サーバー
|
### Discord サーバー
|
||||||
|
|
||||||
|
|||||||
+2
-1
@@ -78,7 +78,7 @@ AstrBot — это универсальная платформа Agent-чатб
|
|||||||
```bash
|
```bash
|
||||||
uv tool install astrbot
|
uv tool install astrbot
|
||||||
astrbot init # Выполните эту команду только при первом запуске для инициализации окружения
|
astrbot init # Выполните эту команду только при первом запуске для инициализации окружения
|
||||||
astrbot
|
astrbot run
|
||||||
```
|
```
|
||||||
|
|
||||||
> Требуется установленный [uv](https://docs.astral.sh/uv/).
|
> Требуется установленный [uv](https://docs.astral.sh/uv/).
|
||||||
@@ -222,6 +222,7 @@ pre-commit install
|
|||||||
- Группа 5: 822130018
|
- Группа 5: 822130018
|
||||||
- Группа 6: 753075035
|
- Группа 6: 753075035
|
||||||
- Группа разработчиков: 975206796
|
- Группа разработчиков: 975206796
|
||||||
|
- Группа разработчиков (официальная): 1039761811
|
||||||
|
|
||||||
### Сервер Discord
|
### Сервер Discord
|
||||||
|
|
||||||
|
|||||||
+3
-2
@@ -78,7 +78,7 @@ AstrBot 是一個開源的一站式 Agent 聊天機器人平台,可接入主
|
|||||||
```bash
|
```bash
|
||||||
uv tool install astrbot
|
uv tool install astrbot
|
||||||
astrbot init # 僅首次執行此命令以初始化環境
|
astrbot init # 僅首次執行此命令以初始化環境
|
||||||
astrbot
|
astrbot run
|
||||||
```
|
```
|
||||||
|
|
||||||
> 需要安裝 [uv](https://docs.astral.sh/uv/)。
|
> 需要安裝 [uv](https://docs.astral.sh/uv/)。
|
||||||
@@ -225,7 +225,8 @@ pre-commit install
|
|||||||
- 6 群:753075035
|
- 6 群:753075035
|
||||||
- 7 群:743746109
|
- 7 群:743746109
|
||||||
- 8 群:1030353265
|
- 8 群:1030353265
|
||||||
- 開發者群:975206796
|
- 開發者群(闲聊吹水):975206796
|
||||||
|
- 開發者群(正式):1039761811
|
||||||
|
|
||||||
### Discord 群組
|
### Discord 群組
|
||||||
|
|
||||||
|
|||||||
+3
-2
@@ -78,7 +78,7 @@ AstrBot 是一个开源的一站式 Agentic 个人和群聊助手,可在 QQ、
|
|||||||
```bash
|
```bash
|
||||||
uv tool install astrbot
|
uv tool install astrbot
|
||||||
astrbot init # 仅首次执行此命令以初始化环境
|
astrbot init # 仅首次执行此命令以初始化环境
|
||||||
astrbot
|
astrbot run
|
||||||
```
|
```
|
||||||
|
|
||||||
> 需要安装 [uv](https://docs.astral.sh/uv/)。
|
> 需要安装 [uv](https://docs.astral.sh/uv/)。
|
||||||
@@ -226,7 +226,8 @@ pre-commit install
|
|||||||
- 6 群:753075035
|
- 6 群:753075035
|
||||||
- 7 群:743746109
|
- 7 群:743746109
|
||||||
- 8 群:1030353265
|
- 8 群:1030353265
|
||||||
- 开发者群:975206796
|
- 开发者群(偏闲聊吹水):975206796
|
||||||
|
- 开发者群(正式):1039761811
|
||||||
|
|
||||||
### Discord 频道
|
### 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:
|
def default_description(self, agent_name: str | None) -> str:
|
||||||
agent_name = agent_name or "another"
|
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
|
persona_tools = None
|
||||||
pid = a.get("persona_id")
|
pid = a.get("persona_id")
|
||||||
if pid:
|
if pid:
|
||||||
persona_tools = next(
|
persona = plugin_context.persona_manager.get_persona_v3_by_id(pid)
|
||||||
(
|
if persona is not None:
|
||||||
p.get("tools")
|
persona_tools = persona.get("tools")
|
||||||
for p in plugin_context.persona_manager.personas_v3
|
|
||||||
if p["name"] == pid
|
|
||||||
),
|
|
||||||
None,
|
|
||||||
)
|
|
||||||
tools = a.get("tools", [])
|
tools = a.get("tools", [])
|
||||||
if persona_tools is not None:
|
if persona_tools is not None:
|
||||||
tools = persona_tools
|
tools = persona_tools
|
||||||
@@ -778,9 +773,14 @@ def _plugin_tool_fix(event: AstrMessageEvent, req: ProviderRequest) -> None:
|
|||||||
continue
|
continue
|
||||||
mp = tool.handler_module_path
|
mp = tool.handler_module_path
|
||||||
if not mp:
|
if not mp:
|
||||||
|
# 没有 plugin 归属信息的工具(如 subagent transfer_to_*)
|
||||||
|
# 不应受到会话插件过滤影响。
|
||||||
|
new_tool_set.add_tool(tool)
|
||||||
continue
|
continue
|
||||||
plugin = star_map.get(mp)
|
plugin = star_map.get(mp)
|
||||||
if not plugin:
|
if not plugin:
|
||||||
|
# 无法解析插件归属时,保守保留工具,避免误过滤。
|
||||||
|
new_tool_set.add_tool(tool)
|
||||||
continue
|
continue
|
||||||
if plugin.name in event.plugins_name or plugin.reserved:
|
if plugin.name in event.plugins_name or plugin.reserved:
|
||||||
new_tool_set.add_tool(tool)
|
new_tool_set.add_tool(tool)
|
||||||
|
|||||||
@@ -188,7 +188,12 @@ class KnowledgeBaseQueryTool(FunctionTool[AstrAgentContext]):
|
|||||||
@dataclass
|
@dataclass
|
||||||
class SendMessageToUserTool(FunctionTool[AstrAgentContext]):
|
class SendMessageToUserTool(FunctionTool[AstrAgentContext]):
|
||||||
name: str = "send_message_to_user"
|
name: str = "send_message_to_user"
|
||||||
description: str = "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(
|
parameters: dict = Field(
|
||||||
default_factory=lambda: {
|
default_factory=lambda: {
|
||||||
@@ -204,7 +209,7 @@ class SendMessageToUserTool(FunctionTool[AstrAgentContext]):
|
|||||||
"type": "string",
|
"type": "string",
|
||||||
"description": (
|
"description": (
|
||||||
"Component type. One of: "
|
"Component type. One of: "
|
||||||
"plain, image, record, file, mention_user"
|
"plain, image, record, video, file, mention_user. Record is voice message."
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
"text": {
|
"text": {
|
||||||
@@ -320,6 +325,19 @@ class SendMessageToUserTool(FunctionTool[AstrAgentContext]):
|
|||||||
components.append(Comp.Record.fromURL(url=url))
|
components.append(Comp.Record.fromURL(url=url))
|
||||||
else:
|
else:
|
||||||
return f"error: messages[{idx}] must include path or url for record component."
|
return f"error: messages[{idx}] must include path or url for record component."
|
||||||
|
elif msg_type == "video":
|
||||||
|
path = msg.get("path")
|
||||||
|
url = msg.get("url")
|
||||||
|
if path:
|
||||||
|
(
|
||||||
|
local_path,
|
||||||
|
file_from_sandbox,
|
||||||
|
) = await self._resolve_path_from_sandbox(context, path)
|
||||||
|
components.append(Comp.Video.fromFileSystem(path=local_path))
|
||||||
|
elif url:
|
||||||
|
components.append(Comp.Video.fromURL(url=url))
|
||||||
|
else:
|
||||||
|
return f"error: messages[{idx}] must include path or url for video component."
|
||||||
elif msg_type == "file":
|
elif msg_type == "file":
|
||||||
path = msg.get("path")
|
path = msg.get("path")
|
||||||
url = msg.get("url")
|
url = msg.get("url")
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import locale
|
||||||
import os
|
import os
|
||||||
import shutil
|
import shutil
|
||||||
import subprocess
|
import subprocess
|
||||||
@@ -52,6 +53,31 @@ def _ensure_safe_path(path: str) -> str:
|
|||||||
return abs_path
|
return abs_path
|
||||||
|
|
||||||
|
|
||||||
|
def _decode_shell_output(output: bytes | None) -> str:
|
||||||
|
if output is None:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
preferred = locale.getpreferredencoding(False) or "utf-8"
|
||||||
|
try:
|
||||||
|
return output.decode("utf-8")
|
||||||
|
except (LookupError, UnicodeDecodeError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
if os.name == "nt":
|
||||||
|
for encoding in ("mbcs", "cp936", "gbk", "gb18030"):
|
||||||
|
try:
|
||||||
|
return output.decode(encoding)
|
||||||
|
except (LookupError, UnicodeDecodeError):
|
||||||
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
|
return output.decode(preferred)
|
||||||
|
except (LookupError, UnicodeDecodeError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
return output.decode("utf-8", errors="replace")
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class LocalShellComponent(ShellComponent):
|
class LocalShellComponent(ShellComponent):
|
||||||
async def exec(
|
async def exec(
|
||||||
@@ -72,28 +98,32 @@ class LocalShellComponent(ShellComponent):
|
|||||||
run_env.update({str(k): str(v) for k, v in env.items()})
|
run_env.update({str(k): str(v) for k, v in env.items()})
|
||||||
working_dir = _ensure_safe_path(cwd) if cwd else get_astrbot_root()
|
working_dir = _ensure_safe_path(cwd) if cwd else get_astrbot_root()
|
||||||
if background:
|
if background:
|
||||||
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,
|
command,
|
||||||
shell=shell,
|
shell=shell,
|
||||||
cwd=working_dir,
|
cwd=working_dir,
|
||||||
env=run_env,
|
env=run_env,
|
||||||
stdout=subprocess.PIPE,
|
stdout=subprocess.DEVNULL,
|
||||||
stderr=subprocess.PIPE,
|
stderr=subprocess.DEVNULL,
|
||||||
text=True,
|
|
||||||
)
|
)
|
||||||
return {"pid": proc.pid, "stdout": "", "stderr": "", "exit_code": None}
|
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,
|
command,
|
||||||
shell=shell,
|
shell=shell,
|
||||||
cwd=working_dir,
|
cwd=working_dir,
|
||||||
env=run_env,
|
env=run_env,
|
||||||
timeout=timeout,
|
timeout=timeout,
|
||||||
capture_output=True,
|
capture_output=True,
|
||||||
text=True,
|
|
||||||
)
|
)
|
||||||
return {
|
return {
|
||||||
"stdout": result.stdout,
|
"stdout": _decode_shell_output(result.stdout),
|
||||||
"stderr": result.stderr,
|
"stderr": _decode_shell_output(result.stderr),
|
||||||
"exit_code": result.returncode,
|
"exit_code": result.returncode,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -213,13 +213,24 @@ def parse_description(text: str) -> str:
|
|||||||
break
|
break
|
||||||
if end_idx is None:
|
if end_idx is None:
|
||||||
return ""
|
return ""
|
||||||
for line in lines[1:end_idx]:
|
|
||||||
if ":" not in line:
|
frontmatter = "\n".join(lines[1:end_idx])
|
||||||
continue
|
try:
|
||||||
key, value = line.split(":", 1)
|
import yaml
|
||||||
if key.strip().lower() == "description":
|
except ImportError:
|
||||||
return value.strip().strip('"').strip("'")
|
return ""
|
||||||
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]:
|
def load_managed_skills() -> list[str]:
|
||||||
@@ -422,6 +433,12 @@ async def get_booter(
|
|||||||
) -> ComputerBooter:
|
) -> ComputerBooter:
|
||||||
config = context.get_config(umo=session_id)
|
config = context.get_config(umo=session_id)
|
||||||
|
|
||||||
|
runtime = config.get("provider_settings", {}).get("computer_use_runtime", "local")
|
||||||
|
if runtime == "local":
|
||||||
|
return get_local_booter()
|
||||||
|
elif runtime == "none":
|
||||||
|
raise RuntimeError("Sandbox runtime is disabled by configuration.")
|
||||||
|
|
||||||
sandbox_cfg = config.get("provider_settings", {}).get("sandbox", {})
|
sandbox_cfg = config.get("provider_settings", {}).get("sandbox", {})
|
||||||
booter_type = sandbox_cfg.get("booter", "shipyard_neo")
|
booter_type = sandbox_cfg.get("booter", "shipyard_neo")
|
||||||
|
|
||||||
|
|||||||
@@ -164,7 +164,10 @@ class CreateSkillPayloadTool(NeoSkillToolBase):
|
|||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
"payload": {
|
"payload": {
|
||||||
"anyOf": [{"type": "object"}, {"type": "array"}],
|
"anyOf": [
|
||||||
|
{"type": "object"},
|
||||||
|
{"type": "array", "items": {"type": "object"}},
|
||||||
|
],
|
||||||
"description": (
|
"description": (
|
||||||
"Skill payload JSON. Typical schema: {skill_markdown, inputs, outputs, meta}. "
|
"Skill payload JSON. Typical schema: {skill_markdown, inputs, outputs, meta}. "
|
||||||
"This only stores content and returns payload_ref; it does not create a candidate or release."
|
"This only stores content and returns payload_ref; it does not create a candidate or release."
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ from typing import Any, TypedDict
|
|||||||
|
|
||||||
from astrbot.core.utils.astrbot_path import get_astrbot_data_path
|
from astrbot.core.utils.astrbot_path import get_astrbot_data_path
|
||||||
|
|
||||||
VERSION = "4.19.5"
|
VERSION = "4.20.1"
|
||||||
DB_PATH = os.path.join(get_astrbot_data_path(), "data_v4.db")
|
DB_PATH = os.path.join(get_astrbot_data_path(), "data_v4.db")
|
||||||
|
|
||||||
WEBHOOK_SUPPORTED_PLATFORMS = [
|
WEBHOOK_SUPPORTED_PLATFORMS = [
|
||||||
@@ -219,6 +219,9 @@ DEFAULT_CONFIG = {
|
|||||||
"telegram": {
|
"telegram": {
|
||||||
"pre_ack_emoji": {"enable": False, "emojis": ["✍️"]},
|
"pre_ack_emoji": {"enable": False, "emojis": ["✍️"]},
|
||||||
},
|
},
|
||||||
|
"discord": {
|
||||||
|
"pre_ack_emoji": {"enable": False, "emojis": ["🤔"]},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
"wake_prefix": ["/"],
|
"wake_prefix": ["/"],
|
||||||
"log_level": "INFO",
|
"log_level": "INFO",
|
||||||
@@ -460,7 +463,6 @@ CONFIG_METADATA_2 = {
|
|||||||
"type": "kook",
|
"type": "kook",
|
||||||
"enable": False,
|
"enable": False,
|
||||||
"kook_bot_token": "",
|
"kook_bot_token": "",
|
||||||
"kook_bot_nickname": "",
|
|
||||||
"kook_reconnect_delay": 1,
|
"kook_reconnect_delay": 1,
|
||||||
"kook_max_reconnect_delay": 60,
|
"kook_max_reconnect_delay": 60,
|
||||||
"kook_max_retry_delay": 60,
|
"kook_max_retry_delay": 60,
|
||||||
@@ -872,11 +874,6 @@ CONFIG_METADATA_2 = {
|
|||||||
"type": "string",
|
"type": "string",
|
||||||
"hint": "必填项。从 KOOK 开发者平台获取的机器人 Token。",
|
"hint": "必填项。从 KOOK 开发者平台获取的机器人 Token。",
|
||||||
},
|
},
|
||||||
"kook_bot_nickname": {
|
|
||||||
"description": "Bot Nickname",
|
|
||||||
"type": "string",
|
|
||||||
"hint": "可选项。若发送者昵称与此值一致,将忽略该消息以避免广播风暴。",
|
|
||||||
},
|
|
||||||
"kook_reconnect_delay": {
|
"kook_reconnect_delay": {
|
||||||
"description": "重连延迟",
|
"description": "重连延迟",
|
||||||
"type": "int",
|
"type": "int",
|
||||||
@@ -1129,6 +1126,18 @@ CONFIG_METADATA_2 = {
|
|||||||
"proxy": "",
|
"proxy": "",
|
||||||
"custom_headers": {},
|
"custom_headers": {},
|
||||||
},
|
},
|
||||||
|
"MiniMax": {
|
||||||
|
"id": "minimax",
|
||||||
|
"provider": "minimax",
|
||||||
|
"type": "openai_chat_completion",
|
||||||
|
"provider_type": "chat_completion",
|
||||||
|
"enable": True,
|
||||||
|
"key": [],
|
||||||
|
"api_base": "https://api.minimaxi.com/v1",
|
||||||
|
"timeout": 120,
|
||||||
|
"proxy": "",
|
||||||
|
"custom_headers": {},
|
||||||
|
},
|
||||||
"xAI": {
|
"xAI": {
|
||||||
"id": "xai",
|
"id": "xai",
|
||||||
"provider": "xai",
|
"provider": "xai",
|
||||||
|
|||||||
@@ -332,9 +332,9 @@ class CronJobManager:
|
|||||||
cron_job=cron_job_str
|
cron_job=cron_job_str
|
||||||
)
|
)
|
||||||
req.prompt = (
|
req.prompt = (
|
||||||
"You are now responding to a scheduled task"
|
"You are now responding to a scheduled task. "
|
||||||
"Proceed according to your system instructions. "
|
"Proceed according to your system instructions. "
|
||||||
"Output using same language as previous conversation."
|
"Output using same language as previous conversation. "
|
||||||
"After completing your task, summarize and output your actions and results."
|
"After completing your task, summarize and output your actions and results."
|
||||||
)
|
)
|
||||||
if not req.func_tool:
|
if not req.func_tool:
|
||||||
|
|||||||
@@ -33,10 +33,18 @@ class BaseDatabase(abc.ABC):
|
|||||||
DATABASE_URL = ""
|
DATABASE_URL = ""
|
||||||
|
|
||||||
def __init__(self) -> None:
|
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.engine = create_async_engine(
|
||||||
self.DATABASE_URL,
|
self.DATABASE_URL,
|
||||||
echo=False,
|
echo=False,
|
||||||
future=True,
|
future=True,
|
||||||
|
connect_args=connect_args,
|
||||||
)
|
)
|
||||||
self.AsyncSessionLocal = async_sessionmaker(
|
self.AsyncSessionLocal = async_sessionmaker(
|
||||||
self.engine,
|
self.engine,
|
||||||
@@ -647,6 +655,13 @@ class BaseDatabase(abc.ABC):
|
|||||||
"""Get a Platform session by its ID."""
|
"""Get a Platform session by its ID."""
|
||||||
...
|
...
|
||||||
|
|
||||||
|
@abc.abstractmethod
|
||||||
|
async def get_platform_sessions_by_ids(
|
||||||
|
self, session_ids: list[str]
|
||||||
|
) -> list[PlatformSession]:
|
||||||
|
"""Get platform sessions by IDs."""
|
||||||
|
...
|
||||||
|
|
||||||
@abc.abstractmethod
|
@abc.abstractmethod
|
||||||
async def get_platform_sessions_by_creator(
|
async def get_platform_sessions_by_creator(
|
||||||
self,
|
self,
|
||||||
|
|||||||
@@ -1417,6 +1417,21 @@ class SQLiteDatabase(BaseDatabase):
|
|||||||
result = await session.execute(query)
|
result = await session.execute(query)
|
||||||
return result.scalar_one_or_none()
|
return result.scalar_one_or_none()
|
||||||
|
|
||||||
|
async def get_platform_sessions_by_ids(
|
||||||
|
self, session_ids: list[str]
|
||||||
|
) -> list[PlatformSession]:
|
||||||
|
"""Get platform sessions by IDs."""
|
||||||
|
if not session_ids:
|
||||||
|
return []
|
||||||
|
|
||||||
|
async with self.get_db() as session:
|
||||||
|
session: AsyncSession
|
||||||
|
query = select(PlatformSession).where(
|
||||||
|
col(PlatformSession.session_id).in_(session_ids)
|
||||||
|
)
|
||||||
|
result = await session.execute(query)
|
||||||
|
return list(result.scalars().all())
|
||||||
|
|
||||||
async def get_platform_sessions_by_creator(
|
async def get_platform_sessions_by_creator(
|
||||||
self,
|
self,
|
||||||
creator: str,
|
creator: str,
|
||||||
|
|||||||
@@ -96,10 +96,10 @@ class Plain(BaseMessageComponent):
|
|||||||
def __init__(self, text: str, convert: bool = True, **_) -> None:
|
def __init__(self, text: str, convert: bool = True, **_) -> None:
|
||||||
super().__init__(text=text, convert=convert, **_)
|
super().__init__(text=text, convert=convert, **_)
|
||||||
|
|
||||||
def toDict(self):
|
def toDict(self) -> dict:
|
||||||
return {"type": "text", "data": {"text": self.text.strip()}}
|
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}}
|
return {"type": "text", "data": {"text": self.text}}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -44,6 +44,22 @@ class PersonaManager:
|
|||||||
raise ValueError(f"Persona with ID {persona_id} does not exist.")
|
raise ValueError(f"Persona with ID {persona_id} does not exist.")
|
||||||
return persona
|
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(
|
async def get_default_persona_v3(
|
||||||
self,
|
self,
|
||||||
umo: str | MessageSession | None = None,
|
umo: str | MessageSession | None = None,
|
||||||
@@ -54,12 +70,7 @@ class PersonaManager:
|
|||||||
"default_personality",
|
"default_personality",
|
||||||
"default",
|
"default",
|
||||||
)
|
)
|
||||||
if not default_persona_id or default_persona_id == "default":
|
return self.get_persona_v3_by_id(default_persona_id) or DEFAULT_PERSONALITY
|
||||||
return DEFAULT_PERSONALITY
|
|
||||||
try:
|
|
||||||
return next(p for p in self.personas_v3 if p["name"] == default_persona_id)
|
|
||||||
except Exception:
|
|
||||||
return DEFAULT_PERSONALITY
|
|
||||||
|
|
||||||
async def resolve_selected_persona(
|
async def resolve_selected_persona(
|
||||||
self,
|
self,
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ from aiocqhttp import CQHttp, Event
|
|||||||
|
|
||||||
from astrbot.api.event import AstrMessageEvent, MessageChain
|
from astrbot.api.event import AstrMessageEvent, MessageChain
|
||||||
from astrbot.api.message_components import (
|
from astrbot.api.message_components import (
|
||||||
|
At,
|
||||||
BaseMessageComponent,
|
BaseMessageComponent,
|
||||||
File,
|
File,
|
||||||
Image,
|
Image,
|
||||||
@@ -70,11 +71,19 @@ class AiocqhttpMessageEvent(AstrMessageEvent):
|
|||||||
"""解析成 OneBot json 格式"""
|
"""解析成 OneBot json 格式"""
|
||||||
ret = []
|
ret = []
|
||||||
for segment in message_chain.chain:
|
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():
|
if not segment.text.strip():
|
||||||
continue
|
continue
|
||||||
d = await AiocqhttpMessageEvent._from_segment_to_dict(segment)
|
d = await AiocqhttpMessageEvent._from_segment_to_dict(segment)
|
||||||
ret.append(d)
|
ret.append(d)
|
||||||
|
else:
|
||||||
|
d = await AiocqhttpMessageEvent._from_segment_to_dict(segment)
|
||||||
|
ret.append(d)
|
||||||
return ret
|
return ret
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
|
|||||||
@@ -13,11 +13,28 @@ from astrbot.api.platform import (
|
|||||||
PlatformMetadata,
|
PlatformMetadata,
|
||||||
register_platform_adapter,
|
register_platform_adapter,
|
||||||
)
|
)
|
||||||
|
from astrbot.core.message.components import File, Record, Video
|
||||||
from astrbot.core.platform.astr_message_event import MessageSesion
|
from astrbot.core.platform.astr_message_event import MessageSesion
|
||||||
|
|
||||||
from .kook_client import KookClient
|
from .kook_client import KookClient
|
||||||
from .kook_config import KookConfig
|
from .kook_config import KookConfig
|
||||||
from .kook_event import KookEvent
|
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(
|
@register_platform_adapter(
|
||||||
@@ -57,35 +74,26 @@ class KookPlatformAdapter(Platform):
|
|||||||
name="kook", description="KOOK 适配器", id=self.kook_config.id
|
name="kook", description="KOOK 适配器", id=self.kook_config.id
|
||||||
)
|
)
|
||||||
|
|
||||||
def _should_ignore_event_by_bot_nickname(self, payload: dict) -> bool:
|
def _should_ignore_event_by_bot_nickname(self, author_id: str) -> bool:
|
||||||
bot_nickname = self.kook_config.bot_nickname.strip()
|
return self.client.bot_id == author_id
|
||||||
if not bot_nickname:
|
|
||||||
return False
|
|
||||||
|
|
||||||
author = payload.get("extra", {}).get("author", {})
|
async def _on_received(self, event: KookMessageEventData):
|
||||||
if not isinstance(author, dict):
|
logger.debug(
|
||||||
return False
|
f'[KOOK] 收到来自"{event.channel_type.name}"渠道的消息, 消息类型为: {event.type.name}({event.type.value})'
|
||||||
|
)
|
||||||
author_nickname = author.get("nickname") or author.get("username") or ""
|
event_type = event.type
|
||||||
if not isinstance(author_nickname, str):
|
if event_type in (KookMessageType.KMARKDOWN, KookMessageType.CARD):
|
||||||
author_nickname = str(author_nickname)
|
if self._should_ignore_event_by_bot_nickname(event.author_id):
|
||||||
|
logger.debug("[KOOK] 收到来自机器人自身的消息, 忽略此消息")
|
||||||
return author_nickname.strip().casefold() == bot_nickname.casefold()
|
return
|
||||||
|
try:
|
||||||
async def _on_received(self, data: dict):
|
abm = await self.convert_message(event)
|
||||||
logger.debug(f"KOOK 收到数据: {data}")
|
await self.handle_msg(abm)
|
||||||
if "d" in data and data["s"] == 0:
|
except Exception as e:
|
||||||
payload = data["d"]
|
logger.error(f"[KOOK] 消息处理异常: {e}")
|
||||||
event_type = payload.get("type")
|
elif event_type == KookMessageType.SYSTEM:
|
||||||
# 支持type=9(文本)和type=10(卡片)
|
logger.debug(f'[KOOK] 消息为系统通知, 通知类型为: "{event.extra.type}"')
|
||||||
if event_type in (9, 10):
|
logger.debug(f"[KOOK] 原始消息数据: {event.to_json()}")
|
||||||
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 run(self):
|
async def run(self):
|
||||||
"""主运行循环"""
|
"""主运行循环"""
|
||||||
@@ -184,18 +192,26 @@ class KookPlatformAdapter(Platform):
|
|||||||
logger.info("[KOOK] 资源清理完成")
|
logger.info("[KOOK] 资源清理完成")
|
||||||
|
|
||||||
def _parse_kmarkdown_text_message(
|
def _parse_kmarkdown_text_message(
|
||||||
self, data: dict, self_id: str
|
self, data: KookMessageEventData, self_id: str
|
||||||
) -> tuple[list, str]:
|
) -> tuple[list, str]:
|
||||||
kmarkdown = data.get("extra", {}).get("kmarkdown", {})
|
kmarkdown = data.extra.kmarkdown
|
||||||
content = data.get("content") or ""
|
content = data.content or ""
|
||||||
raw_content = kmarkdown.get("raw_content") or content
|
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):
|
if not isinstance(content, str):
|
||||||
content = str(content)
|
content = str(content)
|
||||||
if not isinstance(raw_content, str):
|
if not isinstance(raw_content, str):
|
||||||
raw_content = str(raw_content)
|
raw_content = str(raw_content)
|
||||||
|
|
||||||
|
# TODO 后面的pydantic类型替换,以后再来探索吧 :(
|
||||||
mention_name_map: dict[str, str] = {}
|
mention_name_map: dict[str, str] = {}
|
||||||
mention_part = kmarkdown.get("mention_part", [])
|
mention_part = kmarkdown.mention_part
|
||||||
if isinstance(mention_part, list):
|
if isinstance(mention_part, list):
|
||||||
for item in mention_part:
|
for item in mention_part:
|
||||||
if not isinstance(item, dict):
|
if not isinstance(item, dict):
|
||||||
@@ -207,7 +223,7 @@ class KookPlatformAdapter(Platform):
|
|||||||
|
|
||||||
components = []
|
components = []
|
||||||
cursor = 0
|
cursor = 0
|
||||||
for match in re.finditer(r"\(met\)([^()]+)\(met\)", content):
|
for match in KOOK_AT_SELECTOR_REGEX.finditer(content):
|
||||||
if match.start() > cursor:
|
if match.start() > cursor:
|
||||||
plain_text = content[cursor : match.start()]
|
plain_text = content[cursor : match.start()]
|
||||||
if plain_text:
|
if plain_text:
|
||||||
@@ -254,77 +270,109 @@ class KookPlatformAdapter(Platform):
|
|||||||
|
|
||||||
return components, message_str
|
return components, message_str
|
||||||
|
|
||||||
def _parse_card_message(self, data: dict) -> tuple[list, str]:
|
def _parse_card_message(self, data: KookMessageEventData) -> tuple[list, str]:
|
||||||
content = data.get("content", "[]")
|
content = data.content
|
||||||
if not isinstance(content, str):
|
if not isinstance(content, str):
|
||||||
content = str(content)
|
content = str(content)
|
||||||
card_list = json.loads(content)
|
|
||||||
|
card_list = KookCardMessageContainer.from_dict(json.loads(content))
|
||||||
|
|
||||||
text_parts: list[str] = []
|
text_parts: list[str] = []
|
||||||
images: list[str] = []
|
images: list[str] = []
|
||||||
|
files: list[tuple[KookModuleType, str, str]] = []
|
||||||
|
|
||||||
for card in card_list:
|
for card in card_list:
|
||||||
if not isinstance(card, dict):
|
for module in card.modules:
|
||||||
continue
|
match module:
|
||||||
for module in card.get("modules", []):
|
case SectionModule():
|
||||||
if not isinstance(module, dict):
|
if content := self._handle_section_text(module):
|
||||||
continue
|
text_parts.append(content)
|
||||||
|
|
||||||
module_type = module.get("type")
|
case ContainerModule() | ImageGroupModule():
|
||||||
if module_type == "section":
|
urls = self._handle_image_group(module)
|
||||||
section_text = module.get("text", {}).get("content", "")
|
images.extend(urls)
|
||||||
if section_text:
|
text_parts.append(" [image]" * len(urls))
|
||||||
text_parts.append(str(section_text))
|
|
||||||
continue
|
|
||||||
|
|
||||||
if module_type != "container":
|
case HeaderModule():
|
||||||
continue
|
text_parts.append(module.text.content)
|
||||||
|
|
||||||
for element in module.get("elements", []):
|
case FileModule():
|
||||||
if not isinstance(element, dict):
|
files.append((module.type, module.title, module.src))
|
||||||
continue
|
text_parts.append(f" [{module.type.value}]")
|
||||||
if element.get("type") != "image":
|
|
||||||
continue
|
|
||||||
|
|
||||||
image_src = element.get("src")
|
case _:
|
||||||
if not isinstance(image_src, str):
|
logger.debug(f"[KOOK] 跳过或未处理模块: {module.type}")
|
||||||
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)
|
|
||||||
|
|
||||||
text = "".join(text_parts)
|
text = "".join(text_parts)
|
||||||
message = []
|
message = []
|
||||||
|
|
||||||
if text:
|
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))
|
message.append(Plain(text=text))
|
||||||
|
|
||||||
for img_url in images:
|
for img_url in images:
|
||||||
message.append(Image(file=img_url))
|
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
|
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 = AstrBotMessage()
|
||||||
abm.raw_message = data
|
abm.raw_message = data.to_dict()
|
||||||
abm.self_id = self.client.bot_id
|
abm.self_id = self.client.bot_id
|
||||||
|
|
||||||
channel_type = data.get("channel_type")
|
channel_type = data.channel_type
|
||||||
author_id = data.get("author_id", "unknown")
|
author_id = data.author_id
|
||||||
# channel_type定义: https://developer.kookapp.cn/doc/event/event-introduction
|
# channel_type定义: https://developer.kookapp.cn/doc/event/event-introduction
|
||||||
match channel_type:
|
match channel_type:
|
||||||
case "GROUP":
|
case KookChannelType.GROUP:
|
||||||
session_id = data.get("target_id") or "unknown"
|
session_id = data.target_id or "unknown"
|
||||||
abm.type = MessageType.GROUP_MESSAGE
|
abm.type = MessageType.GROUP_MESSAGE
|
||||||
abm.group_id = session_id
|
abm.group_id = session_id
|
||||||
abm.session_id = session_id
|
abm.session_id = session_id
|
||||||
case "PERSON":
|
case KookChannelType.PERSON:
|
||||||
abm.type = MessageType.FRIEND_MESSAGE
|
abm.type = MessageType.FRIEND_MESSAGE
|
||||||
abm.group_id = ""
|
abm.group_id = ""
|
||||||
abm.session_id = data.get("author_id", "unknown")
|
abm.session_id = data.author_id or "unknown"
|
||||||
case "BROADCAST":
|
case KookChannelType.BROADCAST:
|
||||||
session_id = data.get("target_id") or "unknown"
|
session_id = data.target_id or "unknown"
|
||||||
abm.type = MessageType.OTHER_MESSAGE
|
abm.type = MessageType.OTHER_MESSAGE
|
||||||
abm.group_id = session_id
|
abm.group_id = session_id
|
||||||
abm.session_id = session_id
|
abm.session_id = session_id
|
||||||
@@ -333,28 +381,25 @@ class KookPlatformAdapter(Platform):
|
|||||||
|
|
||||||
abm.sender = MessageMember(
|
abm.sender = MessageMember(
|
||||||
user_id=author_id,
|
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.type == KookMessageType.KMARKDOWN:
|
||||||
if data.get("type") == 9:
|
message, message_str = self._parse_kmarkdown_text_message(data, abm.self_id)
|
||||||
message, message_str = self._parse_kmarkdown_text_message(
|
|
||||||
data, str(abm.self_id)
|
|
||||||
)
|
|
||||||
abm.message = message
|
abm.message = message
|
||||||
abm.message_str = message_str
|
abm.message_str = message_str
|
||||||
# 卡片消息
|
elif data.type == KookMessageType.CARD:
|
||||||
elif data.get("type") == 10:
|
|
||||||
try:
|
try:
|
||||||
abm.message, abm.message_str = self._parse_card_message(data)
|
abm.message, abm.message_str = self._parse_card_message(data)
|
||||||
except Exception as exp:
|
except Exception as exp:
|
||||||
logger.error(f"[KOOK] 卡片消息解析失败: {exp}")
|
logger.error(f"[KOOK] 卡片消息解析失败: {exp}")
|
||||||
|
logger.error(f"[KOOK] 原始消息内容: {data.to_json()}")
|
||||||
abm.message_str = "[卡片消息解析失败]"
|
abm.message_str = "[卡片消息解析失败]"
|
||||||
abm.message = [Plain(text="[卡片消息解析失败]")]
|
abm.message = [Plain(text="[卡片消息解析失败]")]
|
||||||
else:
|
else:
|
||||||
logger.warning(f'[KOOK] 不支持的kook消息类型: "{data.get("type")}"')
|
logger.warning(f'[KOOK] 不支持的kook消息类型: "{data.type.name}"')
|
||||||
abm.message_str = "[不支持的消息类型]"
|
abm.message_str = "[不支持的消息类型]"
|
||||||
abm.message = [Plain(text="[不支持的消息类型]")]
|
abm.message = [Plain(text="[不支持的消息类型]")]
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
import base64
|
import base64
|
||||||
import json
|
|
||||||
import os
|
import os
|
||||||
import random
|
import random
|
||||||
import time
|
import time
|
||||||
@@ -9,13 +8,23 @@ from pathlib import Path
|
|||||||
|
|
||||||
import aiofiles
|
import aiofiles
|
||||||
import aiohttp
|
import aiohttp
|
||||||
|
import pydantic
|
||||||
import websockets
|
import websockets
|
||||||
|
|
||||||
from astrbot import logger
|
from astrbot import logger
|
||||||
from astrbot.core.platform.message_type import MessageType
|
from astrbot.core.platform.message_type import MessageType
|
||||||
|
|
||||||
from .kook_config import KookConfig
|
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:
|
class KookClient:
|
||||||
@@ -23,7 +32,8 @@ class KookClient:
|
|||||||
# 数据字段
|
# 数据字段
|
||||||
self.config = config
|
self.config = config
|
||||||
self._bot_id = ""
|
self._bot_id = ""
|
||||||
self._bot_name = ""
|
self._bot_username = ""
|
||||||
|
self._bot_nickname = ""
|
||||||
|
|
||||||
# 资源字段
|
# 资源字段
|
||||||
self._http_client = aiohttp.ClientSession(
|
self._http_client = aiohttp.ClientSession(
|
||||||
@@ -48,37 +58,50 @@ class KookClient:
|
|||||||
return self._bot_id
|
return self._bot_id
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def bot_name(self):
|
def bot_nickname(self):
|
||||||
return self._bot_name
|
return self._bot_nickname
|
||||||
|
|
||||||
async def get_bot_info(self) -> str:
|
@property
|
||||||
"""获取机器人账号ID"""
|
def bot_username(self):
|
||||||
|
return self._bot_username
|
||||||
|
|
||||||
|
async def get_bot_info(self) -> None:
|
||||||
|
"""获取机器人账号信息"""
|
||||||
url = KookApiPaths.USER_ME
|
url = KookApiPaths.USER_ME
|
||||||
|
|
||||||
try:
|
try:
|
||||||
async with self._http_client.get(url) as resp:
|
async with self._http_client.get(url) as resp:
|
||||||
if resp.status != 200:
|
if resp.status != 200:
|
||||||
logger.error(f"[KOOK] 获取机器人账号ID失败,状态码: {resp.status}")
|
logger.error(
|
||||||
return ""
|
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 not resp_content.success():
|
||||||
if data.get("code") != 0:
|
logger.error(
|
||||||
logger.error(f"[KOOK] 获取机器人账号ID失败: {data}")
|
f"[KOOK] 获取机器人账号信息失败: {resp_content.model_dump_json()}"
|
||||||
return ""
|
)
|
||||||
|
return
|
||||||
|
|
||||||
bot_id: str = data["data"]["id"]
|
bot_id: str = resp_content.data.id
|
||||||
self._bot_id = bot_id
|
self._bot_id = bot_id
|
||||||
logger.info(f"[KOOK] 获取机器人账号ID成功: {bot_id}")
|
logger.info(f"[KOOK] 获取机器人账号ID成功: {bot_id}")
|
||||||
bot_name: str = data["data"]["nickname"] or data["data"]["username"]
|
self._bot_nickname = resp_content.data.nickname
|
||||||
self._bot_name = bot_name
|
self._bot_username = resp_content.data.username
|
||||||
logger.info(f"[KOOK] 获取机器人名称成功: {self._bot_name}")
|
logger.info(f"[KOOK] 获取机器人名称成功: {self._bot_nickname}")
|
||||||
|
|
||||||
return bot_id
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"[KOOK] 获取机器人账号ID异常: {e}")
|
logger.error(f"[KOOK] 获取机器人账号信息异常: {e}")
|
||||||
return ""
|
|
||||||
|
|
||||||
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
|
url = KookApiPaths.GATEWAY_INDEX
|
||||||
|
|
||||||
@@ -96,14 +119,20 @@ class KookClient:
|
|||||||
logger.error(f"[KOOK] 获取gateway失败,状态码: {resp.status}")
|
logger.error(f"[KOOK] 获取gateway失败,状态码: {resp.status}")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
data = await resp.json()
|
resp_content = KookGatewayIndexResponse.from_dict(await resp.json())
|
||||||
if data.get("code") != 0:
|
if not resp_content.success():
|
||||||
logger.error(f"[KOOK] 获取gateway失败: {data}")
|
logger.error(f"[KOOK] 获取gateway失败: {resp_content}")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
gateway_url: str = data["data"]["url"]
|
gateway_url: str = resp_content.data.url
|
||||||
logger.info(f"[KOOK] 获取gateway成功: {gateway_url.split('?')[0]}")
|
logger.info(f"[KOOK] 获取gateway成功: {gateway_url.split('?')[0]}")
|
||||||
return gateway_url
|
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:
|
except Exception as e:
|
||||||
logger.error(f"[KOOK] 获取gateway异常: {e}")
|
logger.error(f"[KOOK] 获取gateway异常: {e}")
|
||||||
return None
|
return None
|
||||||
@@ -156,7 +185,11 @@ class KookClient:
|
|||||||
try:
|
try:
|
||||||
while self.running:
|
while self.running:
|
||||||
try:
|
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):
|
if isinstance(msg, bytes):
|
||||||
try:
|
try:
|
||||||
@@ -166,10 +199,15 @@ class KookClient:
|
|||||||
continue
|
continue
|
||||||
msg = msg.decode("utf-8")
|
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:
|
except asyncio.TimeoutError:
|
||||||
# 超时检查,继续循环
|
# 超时检查,继续循环
|
||||||
@@ -187,38 +225,41 @@ class KookClient:
|
|||||||
self.running = False
|
self.running = False
|
||||||
self._stop_event.set()
|
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: # 事件消息
|
match event.signal:
|
||||||
# 更新消息序号
|
case KookMessageSignal.MESSAGE:
|
||||||
if "sn" in data:
|
if event.sn is not None:
|
||||||
self.last_sn = data["sn"]
|
self.last_sn = event.sn
|
||||||
await self.event_callback(data)
|
await self.event_callback(data)
|
||||||
|
|
||||||
elif signal_type == 1: # HELLO握手
|
case KookMessageSignal.HELLO:
|
||||||
await self._handle_hello(data)
|
assert isinstance(data, KookHelloEventData)
|
||||||
|
await self._handle_hello(data)
|
||||||
|
|
||||||
elif signal_type == 3: # PONG心跳响应
|
case KookMessageSignal.RESUME_ACK:
|
||||||
await self._handle_pong(data)
|
assert isinstance(data, KookResumeAckEventData)
|
||||||
|
await self._handle_resume_ack(data)
|
||||||
|
|
||||||
elif signal_type == 5: # RECONNECT重连指令
|
case KookMessageSignal.PONG:
|
||||||
await self._handle_reconnect(data)
|
await self._handle_pong()
|
||||||
|
|
||||||
elif signal_type == 6: # RESUME ACK
|
case KookMessageSignal.RECONNECT:
|
||||||
await self._handle_resume_ack(data)
|
await self._handle_reconnect()
|
||||||
|
|
||||||
else:
|
case _:
|
||||||
logger.debug(f"[KOOK] 未处理的信令类型: {signal_type}")
|
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握手"""
|
||||||
hello_data = data.get("d", {})
|
code = data.code
|
||||||
code = hello_data.get("code", 0)
|
|
||||||
|
|
||||||
if code == 0:
|
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}")
|
logger.info(f"[KOOK] 握手成功,session_id: {self.session_id}")
|
||||||
# TODO 重置重连延迟
|
# TODO 重置重连延迟
|
||||||
# self.reconnect_delay = 1
|
# self.reconnect_delay = 1
|
||||||
@@ -228,12 +269,12 @@ class KookClient:
|
|||||||
logger.error("[KOOK] Token已过期,需要重新获取")
|
logger.error("[KOOK] Token已过期,需要重新获取")
|
||||||
self.running = False
|
self.running = False
|
||||||
|
|
||||||
async def _handle_pong(self, data):
|
async def _handle_pong(self):
|
||||||
"""处理PONG心跳响应"""
|
"""处理PONG心跳响应"""
|
||||||
self.last_heartbeat_time = time.time()
|
self.last_heartbeat_time = time.time()
|
||||||
self.heartbeat_failed_count = 0
|
self.heartbeat_failed_count = 0
|
||||||
|
|
||||||
async def _handle_reconnect(self, data):
|
async def _handle_reconnect(self):
|
||||||
"""处理重连指令"""
|
"""处理重连指令"""
|
||||||
logger.warning("[KOOK] 收到重连指令")
|
logger.warning("[KOOK] 收到重连指令")
|
||||||
# 清空本地状态
|
# 清空本地状态
|
||||||
@@ -241,10 +282,9 @@ class KookClient:
|
|||||||
self.session_id = None
|
self.session_id = None
|
||||||
self.running = False
|
self.running = False
|
||||||
|
|
||||||
async def _handle_resume_ack(self, data):
|
async def _handle_resume_ack(self, data: KookResumeAckEventData):
|
||||||
"""处理RESUME确认"""
|
"""处理RESUME确认"""
|
||||||
resume_data = data.get("d", {})
|
self.session_id = data.session_id
|
||||||
self.session_id = resume_data.get("session_id")
|
|
||||||
logger.info(f"[KOOK] Resume成功,session_id: {self.session_id}")
|
logger.info(f"[KOOK] Resume成功,session_id: {self.session_id}")
|
||||||
|
|
||||||
async def _heartbeat_loop(self):
|
async def _heartbeat_loop(self):
|
||||||
@@ -292,9 +332,16 @@ class KookClient:
|
|||||||
|
|
||||||
async def _send_ping(self):
|
async def _send_ping(self):
|
||||||
"""发送心跳PING"""
|
"""发送心跳PING"""
|
||||||
|
if self.ws is None:
|
||||||
|
logger.warning("[KOOK] 尚未连接kook WebSocket服务器, 跳过发送心跳包流程")
|
||||||
|
return
|
||||||
try:
|
try:
|
||||||
ping_data = {"s": 2, "sn": self.last_sn}
|
ping_data = KookWebsocketEvent(
|
||||||
await self.ws.send(json.dumps(ping_data)) # type: ignore
|
signal=KookMessageSignal.PING,
|
||||||
|
data=None,
|
||||||
|
sn=self.last_sn,
|
||||||
|
)
|
||||||
|
await self.ws.send(ping_data.to_json())
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"[KOOK] 发送心跳失败: {e}")
|
logger.error(f"[KOOK] 发送心跳失败: {e}")
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ class KookConfig:
|
|||||||
|
|
||||||
# 基础配置
|
# 基础配置
|
||||||
token: str
|
token: str
|
||||||
bot_nickname: str = ""
|
|
||||||
enable: bool = False
|
enable: bool = False
|
||||||
id: str = "kook"
|
id: str = "kook"
|
||||||
|
|
||||||
@@ -41,7 +40,6 @@ class KookConfig:
|
|||||||
# id=config_dict.get("id", "kook"),
|
# id=config_dict.get("id", "kook"),
|
||||||
enable=config_dict.get("enable", False),
|
enable=config_dict.get("enable", False),
|
||||||
token=config_dict.get("kook_bot_token", ""),
|
token=config_dict.get("kook_bot_token", ""),
|
||||||
bot_nickname=config_dict.get("kook_bot_nickname", ""),
|
|
||||||
reconnect_delay=config_dict.get(
|
reconnect_delay=config_dict.get(
|
||||||
"kook_reconnect_delay",
|
"kook_reconnect_delay",
|
||||||
KookConfig.reconnect_delay,
|
KookConfig.reconnect_delay,
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ from .kook_types import (
|
|||||||
KookCardMessage,
|
KookCardMessage,
|
||||||
KookCardMessageContainer,
|
KookCardMessageContainer,
|
||||||
KookMessageType,
|
KookMessageType,
|
||||||
|
KookModuleType,
|
||||||
OrderMessage,
|
OrderMessage,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -111,7 +112,7 @@ class KookEvent(AstrMessageEvent):
|
|||||||
KookCardMessage(
|
KookCardMessage(
|
||||||
modules=[
|
modules=[
|
||||||
FileModule(
|
FileModule(
|
||||||
type="audio",
|
type=KookModuleType.AUDIO,
|
||||||
title=title,
|
title=title,
|
||||||
src=url,
|
src=url,
|
||||||
)
|
)
|
||||||
@@ -182,7 +183,7 @@ class KookEvent(AstrMessageEvent):
|
|||||||
if item.reply_id:
|
if item.reply_id:
|
||||||
reply_id = item.reply_id
|
reply_id = item.reply_id
|
||||||
if not item.text:
|
if not item.text:
|
||||||
logger.debug(f'[Kook] 跳过空消息,类型为"{item.type}"')
|
logger.debug(f'[Kook] 跳过空消息,类型为"{item.type.name}"')
|
||||||
continue
|
continue
|
||||||
try:
|
try:
|
||||||
await self.client.send_text(
|
await self.client.send_text(
|
||||||
|
|||||||
@@ -1,10 +1,8 @@
|
|||||||
import json
|
import json
|
||||||
from dataclasses import field
|
from enum import IntEnum, StrEnum
|
||||||
from enum import IntEnum
|
from typing import Annotated, Any, Literal
|
||||||
from typing import Literal
|
|
||||||
|
|
||||||
from pydantic import BaseModel, ConfigDict
|
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
||||||
from pydantic.dataclasses import dataclass
|
|
||||||
|
|
||||||
|
|
||||||
class KookApiPaths:
|
class KookApiPaths:
|
||||||
@@ -25,8 +23,9 @@ class KookApiPaths:
|
|||||||
DIRECT_MESSAGE_CREATE = f"{BASE_URL}{API_VERSION_PATH}/direct-message/create"
|
DIRECT_MESSAGE_CREATE = f"{BASE_URL}{API_VERSION_PATH}/direct-message/create"
|
||||||
|
|
||||||
|
|
||||||
# 定义参见kook事件结构文档: https://developer.kookapp.cn/doc/event/event-introduction
|
|
||||||
class KookMessageType(IntEnum):
|
class KookMessageType(IntEnum):
|
||||||
|
"""定义参见kook事件结构文档: https://developer.kookapp.cn/doc/event/event-introduction"""
|
||||||
|
|
||||||
TEXT = 1
|
TEXT = 1
|
||||||
IMAGE = 2
|
IMAGE = 2
|
||||||
VIDEO = 3
|
VIDEO = 3
|
||||||
@@ -37,6 +36,26 @@ class KookMessageType(IntEnum):
|
|||||||
SYSTEM = 255
|
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[
|
ThemeType = Literal[
|
||||||
"primary", "success", "danger", "warning", "info", "secondary", "none", "invisible"
|
"primary", "success", "danger", "warning", "info", "secondary", "none", "invisible"
|
||||||
]
|
]
|
||||||
@@ -48,43 +67,81 @@ SectionMode = Literal["left", "right"]
|
|||||||
CountdownMode = Literal["day", "hour", "second"]
|
CountdownMode = Literal["day", "hour", "second"]
|
||||||
|
|
||||||
|
|
||||||
class KookCardColor(str):
|
class KookBaseDataClass(BaseModel):
|
||||||
"""16 进制色值"""
|
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
|
type: str
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class PlainTextElement(KookCardModelBase):
|
class PlainTextElement(KookCardModelBase):
|
||||||
content: str
|
content: str
|
||||||
type: str = "plain-text"
|
type: Literal[KookModuleType.PLAIN_TEXT] = KookModuleType.PLAIN_TEXT
|
||||||
emoji: bool = True
|
emoji: bool = True
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class KmarkdownElement(KookCardModelBase):
|
class KmarkdownElement(KookCardModelBase):
|
||||||
content: str
|
content: str
|
||||||
type: str = "kmarkdown"
|
type: Literal[KookModuleType.KMARKDOWN] = KookModuleType.KMARKDOWN
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class ImageElement(KookCardModelBase):
|
class ImageElement(KookCardModelBase):
|
||||||
src: str
|
src: str
|
||||||
type: str = "image"
|
type: Literal[KookModuleType.IMAGE] = KookModuleType.IMAGE
|
||||||
alt: str = ""
|
alt: str = ""
|
||||||
size: SizeType = "lg"
|
size: SizeType = "lg"
|
||||||
circle: bool = False
|
circle: bool = False
|
||||||
fallbackUrl: str | None = None
|
fallbackUrl: str | None = None
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class ButtonElement(KookCardModelBase):
|
class ButtonElement(KookCardModelBase):
|
||||||
text: str
|
text: str
|
||||||
type: str = "button"
|
type: Literal[KookModuleType.BUTTON] = KookModuleType.BUTTON
|
||||||
theme: ThemeType = "primary"
|
theme: ThemeType = "primary"
|
||||||
value: str = ""
|
value: str = ""
|
||||||
"""当为 link 时,会跳转到 value 代表的链接;
|
"""当为 link 时,会跳转到 value 代表的链接;
|
||||||
@@ -96,93 +153,88 @@ class ButtonElement(KookCardModelBase):
|
|||||||
AnyElement = PlainTextElement | KmarkdownElement | ImageElement | ButtonElement | str
|
AnyElement = PlainTextElement | KmarkdownElement | ImageElement | ButtonElement | str
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class ParagraphStructure(KookCardModelBase):
|
class ParagraphStructure(KookCardModelBase):
|
||||||
fields: list[PlainTextElement | KmarkdownElement]
|
fields: list[PlainTextElement | KmarkdownElement]
|
||||||
type: str = "paragraph"
|
type: Literal["paragraph"] = "paragraph"
|
||||||
cols: int = 1
|
cols: int = 1
|
||||||
"""范围是 1-3 , 移动端忽略此参数"""
|
"""范围是 1-3 , 移动端忽略此参数"""
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class HeaderModule(KookCardModelBase):
|
class HeaderModule(KookCardModelBase):
|
||||||
text: PlainTextElement
|
text: PlainTextElement
|
||||||
type: str = "header"
|
type: Literal[KookModuleType.HEADER] = KookModuleType.HEADER
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class SectionModule(KookCardModelBase):
|
class SectionModule(KookCardModelBase):
|
||||||
text: PlainTextElement | KmarkdownElement | ParagraphStructure
|
text: PlainTextElement | KmarkdownElement | ParagraphStructure
|
||||||
type: str = "section"
|
type: Literal[KookModuleType.SECTION] = KookModuleType.SECTION
|
||||||
mode: SectionMode = "left"
|
mode: SectionMode = "left"
|
||||||
accessory: ImageElement | ButtonElement | None = None
|
accessory: ImageElement | ButtonElement | None = None
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class ImageGroupModule(KookCardModelBase):
|
class ImageGroupModule(KookCardModelBase):
|
||||||
"""1 到多张图片的组合"""
|
"""1 到多张图片的组合"""
|
||||||
|
|
||||||
elements: list[ImageElement]
|
elements: list[ImageElement]
|
||||||
type: str = "image-group"
|
type: Literal[KookModuleType.IMAGE_GROUP] = KookModuleType.IMAGE_GROUP
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class ContainerModule(KookCardModelBase):
|
class ContainerModule(KookCardModelBase):
|
||||||
"""1 到多张图片的组合,与图片组模块(ImageGroupModule)不同,图片并不会裁切为正方形。多张图片会纵向排列。"""
|
"""1 到多张图片的组合,与图片组模块(ImageGroupModule)不同,图片并不会裁切为正方形。多张图片会纵向排列。"""
|
||||||
|
|
||||||
elements: list[ImageElement]
|
elements: list[ImageElement]
|
||||||
type: str = "container"
|
type: Literal[KookModuleType.CONTAINER] = KookModuleType.CONTAINER
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class ActionGroupModule(KookCardModelBase):
|
class ActionGroupModule(KookCardModelBase):
|
||||||
|
"""用来放按钮的模块"""
|
||||||
|
|
||||||
elements: list[ButtonElement]
|
elements: list[ButtonElement]
|
||||||
type: str = "action-group"
|
type: Literal[KookModuleType.ACTION_GROUP] = KookModuleType.ACTION_GROUP
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class ContextModule(KookCardModelBase):
|
class ContextModule(KookCardModelBase):
|
||||||
elements: list[PlainTextElement | KmarkdownElement | ImageElement]
|
elements: list[PlainTextElement | KmarkdownElement | ImageElement]
|
||||||
"""最多包含10个元素"""
|
"""最多包含10个元素"""
|
||||||
type: str = "context"
|
type: Literal[KookModuleType.CONTEXT] = KookModuleType.CONTEXT
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class DividerModule(KookCardModelBase):
|
class DividerModule(KookCardModelBase):
|
||||||
type: str = "divider"
|
"""展示分割线用的"""
|
||||||
|
|
||||||
|
type: Literal[KookModuleType.DIVIDER] = KookModuleType.DIVIDER
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class FileModule(KookCardModelBase):
|
class FileModule(KookCardModelBase):
|
||||||
src: str
|
src: str
|
||||||
title: str = ""
|
title: str = ""
|
||||||
type: Literal["file", "audio", "video"] = "file"
|
type: Literal[KookModuleType.FILE, KookModuleType.AUDIO, KookModuleType.VIDEO] = (
|
||||||
|
KookModuleType.FILE
|
||||||
|
)
|
||||||
cover: str | None = None
|
cover: str | None = None
|
||||||
"""cover 仅音频有效, 是音频的封面图"""
|
"""cover 仅音频有效, 是音频的封面图"""
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class CountdownModule(KookCardModelBase):
|
class CountdownModule(KookCardModelBase):
|
||||||
"""startTime 和 endTime 为毫秒时间戳,startTime 和 endTime 不能小于服务器当前时间戳。"""
|
"""startTime 和 endTime 为毫秒时间戳,startTime 和 endTime 不能小于服务器当前时间戳。"""
|
||||||
|
|
||||||
endTime: int
|
endTime: int
|
||||||
"""毫秒时间戳"""
|
"""毫秒时间戳"""
|
||||||
type: str = "countdown"
|
type: Literal[KookModuleType.COUNTDOWN] = KookModuleType.COUNTDOWN
|
||||||
startTime: int | None = None
|
startTime: int | None = None
|
||||||
"""毫秒时间戳, 仅当mode为second才有这个字段"""
|
"""毫秒时间戳, 仅当mode为second才有这个字段"""
|
||||||
mode: CountdownMode = "day"
|
mode: CountdownMode = "day"
|
||||||
"""mode 主要是倒计时的样式"""
|
"""mode 主要是倒计时的样式"""
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class InviteModule(KookCardModelBase):
|
class InviteModule(KookCardModelBase):
|
||||||
code: str
|
code: str
|
||||||
"""邀请链接或者邀请码"""
|
"""邀请链接或者邀请码"""
|
||||||
type: str = "invite"
|
type: Literal[KookModuleType.INVITE] = KookModuleType.INVITE
|
||||||
|
|
||||||
|
|
||||||
# 所有模块的联合类型
|
# 所有模块的联合类型
|
||||||
AnyModule = (
|
AnyModule = Annotated[
|
||||||
HeaderModule
|
HeaderModule
|
||||||
| SectionModule
|
| SectionModule
|
||||||
| ImageGroupModule
|
| ImageGroupModule
|
||||||
@@ -192,34 +244,29 @@ AnyModule = (
|
|||||||
| DividerModule
|
| DividerModule
|
||||||
| FileModule
|
| FileModule
|
||||||
| CountdownModule
|
| CountdownModule
|
||||||
| InviteModule
|
| InviteModule,
|
||||||
)
|
Field(discriminator="type"),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
class KookCardMessage(BaseModel):
|
class KookCardMessage(KookBaseDataClass):
|
||||||
"""卡片定义文档详见 : https://developer.kookapp.cn/doc/cardmessage
|
"""卡片定义文档详见 : https://developer.kookapp.cn/doc/cardmessage
|
||||||
此类型不能直接to_json后发送,因为kook要求卡片容器json顶层必须是**列表**
|
此类型不能直接to_json后发送,因为kook要求卡片容器json顶层必须是**列表**
|
||||||
若要发送卡片消息,请使用KookCardMessageContainer
|
若要发送卡片消息,请使用KookCardMessageContainer
|
||||||
"""
|
"""
|
||||||
|
|
||||||
model_config = ConfigDict(arbitrary_types_allowed=True)
|
model_config = ConfigDict(arbitrary_types_allowed=True)
|
||||||
type: str = "card"
|
type: Literal[KookModuleType.CARD] = KookModuleType.CARD
|
||||||
theme: ThemeType | None = None
|
theme: ThemeType | None = None
|
||||||
size: SizeType | None = None
|
size: SizeType | None = None
|
||||||
color: KookCardColor | None = None
|
color: str | None = None
|
||||||
modules: list[AnyModule] = field(default_factory=list)
|
"""16 进制色值"""
|
||||||
|
modules: list[AnyModule] = Field(default_factory=list)
|
||||||
"""单个 card 模块数量不限制,但是一条消息中所有卡片的模块数量之和最多是 50"""
|
"""单个 card 模块数量不限制,但是一条消息中所有卡片的模块数量之和最多是 50"""
|
||||||
|
|
||||||
def add_module(self, module: AnyModule):
|
def add_module(self, module: AnyModule):
|
||||||
self.modules.append(module)
|
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]):
|
class KookCardMessageContainer(list[KookCardMessage]):
|
||||||
"""卡片消息容器(列表),此类型可以直接to_json后发送出去"""
|
"""卡片消息容器(列表),此类型可以直接to_json后发送出去"""
|
||||||
@@ -232,10 +279,227 @@ class KookCardMessageContainer(list[KookCardMessage]):
|
|||||||
[i.to_dict() for i in self], indent=indent, ensure_ascii=ensure_ascii
|
[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
|
index: int
|
||||||
text: str
|
text: str
|
||||||
type: KookMessageType
|
type: KookMessageType
|
||||||
reply_id: str | int = ""
|
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 import logger
|
||||||
from astrbot.api.event import AstrMessageEvent, MessageChain
|
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.api.platform import AstrBotMessage, PlatformMetadata
|
||||||
from astrbot.core.utils.astrbot_path import get_astrbot_temp_path
|
from astrbot.core.utils.astrbot_path import get_astrbot_temp_path
|
||||||
from astrbot.core.utils.io import download_image_by_url, file_to_base64
|
from astrbot.core.utils.io import download_image_by_url, file_to_base64
|
||||||
@@ -47,6 +47,11 @@ _patch_qq_botpy_formdata()
|
|||||||
|
|
||||||
class QQOfficialMessageEvent(AstrMessageEvent):
|
class QQOfficialMessageEvent(AstrMessageEvent):
|
||||||
MARKDOWN_NOT_ALLOWED_ERROR = "不允许发送原生 markdown"
|
MARKDOWN_NOT_ALLOWED_ERROR = "不允许发送原生 markdown"
|
||||||
|
IMAGE_FILE_TYPE = 1
|
||||||
|
VIDEO_FILE_TYPE = 2
|
||||||
|
VOICE_FILE_TYPE = 3
|
||||||
|
FILE_FILE_TYPE = 4
|
||||||
|
STREAM_MARKDOWN_NEWLINE_ERROR = "流式消息md分片需要\\n结束"
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
@@ -65,35 +70,71 @@ class QQOfficialMessageEvent(AstrMessageEvent):
|
|||||||
await self._post_send()
|
await self._post_send()
|
||||||
|
|
||||||
async def send_streaming(self, generator, use_fallback: bool = False):
|
async def send_streaming(self, generator, use_fallback: bool = False):
|
||||||
"""流式输出仅支持消息列表私聊"""
|
"""流式输出仅支持消息列表私聊(C2C),其他消息源退化为普通发送"""
|
||||||
|
# 先标记事件层“已执行发送操作”,避免异常路径遗漏
|
||||||
|
await super().send_streaming(generator, use_fallback)
|
||||||
|
# QQ C2C 流式协议:开始/中间分片使用 state=1,结束分片使用 state=10
|
||||||
stream_payload = {"state": 1, "id": None, "index": 0, "reset": False}
|
stream_payload = {"state": 1, "id": None, "index": 0, "reset": False}
|
||||||
last_edit_time = 0 # 上次编辑消息的时间
|
last_edit_time = 0 # 上次发送分片的时间
|
||||||
throttle_interval = 1 # 编辑消息的间隔时间 (秒)
|
throttle_interval = 1 # 分片间最短间隔 (秒)
|
||||||
ret = None
|
ret = None
|
||||||
|
source = (
|
||||||
|
self.message_obj.raw_message
|
||||||
|
) # 提前获取,避免 generator 为空时 NameError
|
||||||
try:
|
try:
|
||||||
async for chain in generator:
|
async for chain in generator:
|
||||||
source = self.message_obj.raw_message
|
source = self.message_obj.raw_message
|
||||||
|
|
||||||
|
if not isinstance(source, botpy.message.C2CMessage):
|
||||||
|
# 非 C2C 场景:直接累积,最后统一发
|
||||||
|
if not self.send_buffer:
|
||||||
|
self.send_buffer = chain
|
||||||
|
else:
|
||||||
|
self.send_buffer.chain.extend(chain.chain)
|
||||||
|
continue
|
||||||
|
|
||||||
|
# ---- C2C 流式场景 ----
|
||||||
|
|
||||||
|
# tool_call break 信号:工具开始执行,先把已有 buffer 以 state=10 结束当前流式段
|
||||||
|
if chain.type == "break":
|
||||||
|
if self.send_buffer:
|
||||||
|
stream_payload["state"] = 10
|
||||||
|
ret = await self._post_send(stream=stream_payload)
|
||||||
|
ret_id = self._extract_response_message_id(ret)
|
||||||
|
if ret_id is not None:
|
||||||
|
stream_payload["id"] = ret_id
|
||||||
|
# 重置 stream_payload,为下一段流式做准备
|
||||||
|
stream_payload = {
|
||||||
|
"state": 1,
|
||||||
|
"id": None,
|
||||||
|
"index": 0,
|
||||||
|
"reset": False,
|
||||||
|
}
|
||||||
|
last_edit_time = 0
|
||||||
|
continue
|
||||||
|
|
||||||
|
# 累积内容
|
||||||
if not self.send_buffer:
|
if not self.send_buffer:
|
||||||
self.send_buffer = chain
|
self.send_buffer = chain
|
||||||
else:
|
else:
|
||||||
self.send_buffer.chain.extend(chain.chain)
|
self.send_buffer.chain.extend(chain.chain)
|
||||||
|
|
||||||
if isinstance(source, botpy.message.C2CMessage):
|
# 节流:按时间间隔发送中间分片
|
||||||
# 真流式传输
|
current_time = asyncio.get_running_loop().time()
|
||||||
current_time = asyncio.get_running_loop().time()
|
if current_time - last_edit_time >= throttle_interval:
|
||||||
time_since_last_edit = current_time - last_edit_time
|
ret = cast(
|
||||||
|
message.Message,
|
||||||
if time_since_last_edit >= throttle_interval:
|
await self._post_send(stream=stream_payload),
|
||||||
ret = cast(
|
)
|
||||||
message.Message,
|
stream_payload["index"] += 1
|
||||||
await self._post_send(stream=stream_payload),
|
ret_id = self._extract_response_message_id(ret)
|
||||||
)
|
if ret_id is not None:
|
||||||
stream_payload["index"] += 1
|
stream_payload["id"] = ret_id
|
||||||
stream_payload["id"] = ret["id"]
|
last_edit_time = asyncio.get_running_loop().time()
|
||||||
last_edit_time = asyncio.get_running_loop().time()
|
self.send_buffer = None # 清空已发送的分片,避免下次重复发送旧内容
|
||||||
|
|
||||||
if isinstance(source, botpy.message.C2CMessage):
|
if isinstance(source, botpy.message.C2CMessage):
|
||||||
# 结束流式对话,并且传输 buffer 中剩余的消息
|
# 结束流式对话,发送 buffer 中剩余内容
|
||||||
stream_payload["state"] = 10
|
stream_payload["state"] = 10
|
||||||
ret = await self._post_send(stream=stream_payload)
|
ret = await self._post_send(stream=stream_payload)
|
||||||
else:
|
else:
|
||||||
@@ -101,9 +142,22 @@ class QQOfficialMessageEvent(AstrMessageEvent):
|
|||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"发送流式消息时出错: {e}", exc_info=True)
|
logger.error(f"发送流式消息时出错: {e}", exc_info=True)
|
||||||
|
# 避免累计内容在异常后被整包重复发送:仅清理缓存,不做非流式整包兜底
|
||||||
|
# 如需兜底,应该只发送未发送 delta(后续可继续优化)
|
||||||
self.send_buffer = None
|
self.send_buffer = None
|
||||||
|
|
||||||
return 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):
|
async def _post_send(self, stream: dict | None = None):
|
||||||
if not self.send_buffer:
|
if not self.send_buffer:
|
||||||
@@ -126,16 +180,37 @@ class QQOfficialMessageEvent(AstrMessageEvent):
|
|||||||
image_base64,
|
image_base64,
|
||||||
image_path,
|
image_path,
|
||||||
record_file_path,
|
record_file_path,
|
||||||
|
video_file_source,
|
||||||
|
file_source,
|
||||||
|
file_name,
|
||||||
) = await QQOfficialMessageEvent._parse_to_qqofficial(self.send_buffer)
|
) = await QQOfficialMessageEvent._parse_to_qqofficial(self.send_buffer)
|
||||||
|
|
||||||
|
# C2C 流式仅用于文本分片,富媒体时降级为普通发送,避免平台侧流式校验报错。
|
||||||
|
if stream and (image_base64 or record_file_path):
|
||||||
|
logger.debug("[QQOfficial] 检测到富媒体,降级为非流式发送。")
|
||||||
|
stream = None
|
||||||
|
|
||||||
if (
|
if (
|
||||||
not plain_text
|
not plain_text
|
||||||
and not image_base64
|
and not image_base64
|
||||||
and not image_path
|
and not image_path
|
||||||
and not record_file_path
|
and not record_file_path
|
||||||
|
and not video_file_source
|
||||||
|
and not file_source
|
||||||
):
|
):
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
# QQ C2C 流式 API 说明:
|
||||||
|
# - 开始/中间分片(state=1):增量追加内容,不需要 \n(加了会导致强制换行)
|
||||||
|
# - 最终分片(state=10):结束流,content 必须以 \n 结尾(QQ API 要求)
|
||||||
|
if (
|
||||||
|
stream
|
||||||
|
and stream.get("state") == 10
|
||||||
|
and plain_text
|
||||||
|
and not plain_text.endswith("\n")
|
||||||
|
):
|
||||||
|
plain_text = plain_text + "\n"
|
||||||
|
|
||||||
payload: dict = {
|
payload: dict = {
|
||||||
# "content": plain_text,
|
# "content": plain_text,
|
||||||
"markdown": MarkdownPayload(content=plain_text) if plain_text else None,
|
"markdown": MarkdownPayload(content=plain_text) if plain_text else None,
|
||||||
@@ -157,7 +232,7 @@ class QQOfficialMessageEvent(AstrMessageEvent):
|
|||||||
if image_base64:
|
if image_base64:
|
||||||
media = await self.upload_group_and_c2c_image(
|
media = await self.upload_group_and_c2c_image(
|
||||||
image_base64,
|
image_base64,
|
||||||
1,
|
self.IMAGE_FILE_TYPE,
|
||||||
group_openid=source.group_openid,
|
group_openid=source.group_openid,
|
||||||
)
|
)
|
||||||
payload["media"] = media
|
payload["media"] = media
|
||||||
@@ -165,15 +240,39 @@ class QQOfficialMessageEvent(AstrMessageEvent):
|
|||||||
payload.pop("markdown", None)
|
payload.pop("markdown", None)
|
||||||
payload["content"] = plain_text or None
|
payload["content"] = plain_text or None
|
||||||
if record_file_path: # group record msg
|
if record_file_path: # group record msg
|
||||||
media = await self.upload_group_and_c2c_record(
|
media = await self.upload_group_and_c2c_media(
|
||||||
record_file_path,
|
record_file_path,
|
||||||
3,
|
self.VOICE_FILE_TYPE,
|
||||||
group_openid=source.group_openid,
|
group_openid=source.group_openid,
|
||||||
)
|
)
|
||||||
payload["media"] = media
|
if media:
|
||||||
payload["msg_type"] = 7
|
payload["media"] = media
|
||||||
payload.pop("markdown", None)
|
payload["msg_type"] = 7
|
||||||
payload["content"] = plain_text or None
|
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(
|
ret = await self._send_with_markdown_fallback(
|
||||||
send_func=lambda retry_payload: self.bot.api.post_group_message(
|
send_func=lambda retry_payload: self.bot.api.post_group_message(
|
||||||
group_openid=source.group_openid, # type: ignore
|
group_openid=source.group_openid, # type: ignore
|
||||||
@@ -181,13 +280,14 @@ class QQOfficialMessageEvent(AstrMessageEvent):
|
|||||||
),
|
),
|
||||||
payload=payload,
|
payload=payload,
|
||||||
plain_text=plain_text,
|
plain_text=plain_text,
|
||||||
|
stream=stream,
|
||||||
)
|
)
|
||||||
|
|
||||||
case botpy.message.C2CMessage():
|
case botpy.message.C2CMessage():
|
||||||
if image_base64:
|
if image_base64:
|
||||||
media = await self.upload_group_and_c2c_image(
|
media = await self.upload_group_and_c2c_image(
|
||||||
image_base64,
|
image_base64,
|
||||||
1,
|
self.IMAGE_FILE_TYPE,
|
||||||
openid=source.author.user_openid,
|
openid=source.author.user_openid,
|
||||||
)
|
)
|
||||||
payload["media"] = media
|
payload["media"] = media
|
||||||
@@ -195,15 +295,39 @@ class QQOfficialMessageEvent(AstrMessageEvent):
|
|||||||
payload.pop("markdown", None)
|
payload.pop("markdown", None)
|
||||||
payload["content"] = plain_text or None
|
payload["content"] = plain_text or None
|
||||||
if record_file_path: # c2c record
|
if record_file_path: # c2c record
|
||||||
media = await self.upload_group_and_c2c_record(
|
media = await self.upload_group_and_c2c_media(
|
||||||
record_file_path,
|
record_file_path,
|
||||||
3,
|
self.VOICE_FILE_TYPE,
|
||||||
openid=source.author.user_openid,
|
openid=source.author.user_openid,
|
||||||
)
|
)
|
||||||
payload["media"] = media
|
if media:
|
||||||
payload["msg_type"] = 7
|
payload["media"] = media
|
||||||
payload.pop("markdown", None)
|
payload["msg_type"] = 7
|
||||||
payload["content"] = plain_text or None
|
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:
|
if stream:
|
||||||
ret = await self._send_with_markdown_fallback(
|
ret = await self._send_with_markdown_fallback(
|
||||||
send_func=lambda retry_payload: self.post_c2c_message(
|
send_func=lambda retry_payload: self.post_c2c_message(
|
||||||
@@ -213,6 +337,7 @@ class QQOfficialMessageEvent(AstrMessageEvent):
|
|||||||
),
|
),
|
||||||
payload=payload,
|
payload=payload,
|
||||||
plain_text=plain_text,
|
plain_text=plain_text,
|
||||||
|
stream=stream,
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
ret = await self._send_with_markdown_fallback(
|
ret = await self._send_with_markdown_fallback(
|
||||||
@@ -222,6 +347,7 @@ class QQOfficialMessageEvent(AstrMessageEvent):
|
|||||||
),
|
),
|
||||||
payload=payload,
|
payload=payload,
|
||||||
plain_text=plain_text,
|
plain_text=plain_text,
|
||||||
|
stream=stream,
|
||||||
)
|
)
|
||||||
logger.debug(f"Message sent to C2C: {ret}")
|
logger.debug(f"Message sent to C2C: {ret}")
|
||||||
|
|
||||||
@@ -237,6 +363,7 @@ class QQOfficialMessageEvent(AstrMessageEvent):
|
|||||||
),
|
),
|
||||||
payload=payload,
|
payload=payload,
|
||||||
plain_text=plain_text,
|
plain_text=plain_text,
|
||||||
|
stream=stream,
|
||||||
)
|
)
|
||||||
|
|
||||||
case botpy.message.DirectMessage():
|
case botpy.message.DirectMessage():
|
||||||
@@ -251,6 +378,7 @@ class QQOfficialMessageEvent(AstrMessageEvent):
|
|||||||
),
|
),
|
||||||
payload=payload,
|
payload=payload,
|
||||||
plain_text=plain_text,
|
plain_text=plain_text,
|
||||||
|
stream=stream,
|
||||||
)
|
)
|
||||||
|
|
||||||
case _:
|
case _:
|
||||||
@@ -267,10 +395,31 @@ class QQOfficialMessageEvent(AstrMessageEvent):
|
|||||||
send_func,
|
send_func,
|
||||||
payload: dict,
|
payload: dict,
|
||||||
plain_text: str,
|
plain_text: str,
|
||||||
|
stream: dict | None = None,
|
||||||
):
|
):
|
||||||
try:
|
try:
|
||||||
return await send_func(payload)
|
return await send_func(payload)
|
||||||
except botpy.errors.ServerError as err:
|
except botpy.errors.ServerError as err:
|
||||||
|
# QQ 流式 markdown 分片校验:内容必须以换行结尾。
|
||||||
|
# 某些边界场景服务端仍可能判定失败,这里做一次修正重试。
|
||||||
|
if stream and self.STREAM_MARKDOWN_NEWLINE_ERROR in str(err):
|
||||||
|
retry_payload = payload.copy()
|
||||||
|
|
||||||
|
markdown_payload = retry_payload.get("markdown")
|
||||||
|
if isinstance(markdown_payload, dict):
|
||||||
|
md_content = cast(str, markdown_payload.get("content", "") or "")
|
||||||
|
if md_content and not md_content.endswith("\n"):
|
||||||
|
retry_payload["markdown"] = {"content": md_content + "\n"}
|
||||||
|
|
||||||
|
content = cast(str | None, retry_payload.get("content"))
|
||||||
|
if content and not content.endswith("\n"):
|
||||||
|
retry_payload["content"] = content + "\n"
|
||||||
|
|
||||||
|
logger.warning(
|
||||||
|
"[QQOfficial] 流式 markdown 分片换行校验失败,已修正后重试一次。"
|
||||||
|
)
|
||||||
|
return await send_func(retry_payload)
|
||||||
|
|
||||||
if (
|
if (
|
||||||
self.MARKDOWN_NOT_ALLOWED_ERROR not in str(err)
|
self.MARKDOWN_NOT_ALLOWED_ERROR not in str(err)
|
||||||
or not payload.get("markdown")
|
or not payload.get("markdown")
|
||||||
@@ -282,10 +431,14 @@ class QQOfficialMessageEvent(AstrMessageEvent):
|
|||||||
"[QQOfficial] markdown 发送被拒绝,回退到 content 模式重试。"
|
"[QQOfficial] markdown 发送被拒绝,回退到 content 模式重试。"
|
||||||
)
|
)
|
||||||
fallback_payload = payload.copy()
|
fallback_payload = payload.copy()
|
||||||
fallback_payload["markdown"] = None
|
fallback_payload.pop("markdown", None)
|
||||||
fallback_payload["content"] = plain_text
|
fallback_payload["content"] = plain_text
|
||||||
if fallback_payload.get("msg_type") == 2:
|
if fallback_payload.get("msg_type") == 2:
|
||||||
fallback_payload["msg_type"] = 0
|
fallback_payload["msg_type"] = 0
|
||||||
|
if stream:
|
||||||
|
fallback_content = cast(str, fallback_payload.get("content") or "")
|
||||||
|
if fallback_content and not fallback_content.endswith("\n"):
|
||||||
|
fallback_payload["content"] = fallback_content + "\n"
|
||||||
return await send_func(fallback_payload)
|
return await send_func(fallback_payload)
|
||||||
|
|
||||||
async def upload_group_and_c2c_image(
|
async def upload_group_and_c2c_image(
|
||||||
@@ -327,16 +480,19 @@ class QQOfficialMessageEvent(AstrMessageEvent):
|
|||||||
ttl=result.get("ttl", 0),
|
ttl=result.get("ttl", 0),
|
||||||
)
|
)
|
||||||
|
|
||||||
async def upload_group_and_c2c_record(
|
async def upload_group_and_c2c_media(
|
||||||
self,
|
self,
|
||||||
file_source: str,
|
file_source: str,
|
||||||
file_type: int,
|
file_type: int,
|
||||||
srv_send_msg: bool = False,
|
srv_send_msg: bool = False,
|
||||||
|
file_name: str | None = None,
|
||||||
**kwargs,
|
**kwargs,
|
||||||
) -> Media | None:
|
) -> Media | None:
|
||||||
"""上传媒体文件"""
|
"""上传媒体文件"""
|
||||||
# 构建基础payload
|
# 构建基础payload
|
||||||
payload = {"file_type": file_type, "srv_send_msg": srv_send_msg}
|
payload = {"file_type": file_type, "srv_send_msg": srv_send_msg}
|
||||||
|
if file_name:
|
||||||
|
payload["file_name"] = file_name
|
||||||
|
|
||||||
# 处理文件数据
|
# 处理文件数据
|
||||||
if os.path.exists(file_source):
|
if os.path.exists(file_source):
|
||||||
@@ -400,13 +556,21 @@ class QQOfficialMessageEvent(AstrMessageEvent):
|
|||||||
) -> message.Message:
|
) -> message.Message:
|
||||||
payload = locals()
|
payload = locals()
|
||||||
payload.pop("self", None)
|
payload.pop("self", None)
|
||||||
|
# QQ API does not accept stream.id=None; remove it when not yet assigned
|
||||||
|
if "stream" in payload and payload["stream"] is not None:
|
||||||
|
stream_data = dict(payload["stream"])
|
||||||
|
if stream_data.get("id") is None:
|
||||||
|
stream_data.pop("id", None)
|
||||||
|
payload["stream"] = stream_data
|
||||||
route = Route("POST", "/v2/users/{openid}/messages", openid=openid)
|
route = Route("POST", "/v2/users/{openid}/messages", openid=openid)
|
||||||
result = await self.bot.api._http.request(route, json=payload)
|
result = await self.bot.api._http.request(route, json=payload)
|
||||||
|
|
||||||
|
if result is None:
|
||||||
|
logger.warning("[QQOfficial] post_c2c_message: API 返回 None,跳过本次发送")
|
||||||
|
return None
|
||||||
if not isinstance(result, dict):
|
if not isinstance(result, dict):
|
||||||
raise RuntimeError(
|
logger.error(f"[QQOfficial] post_c2c_message: 响应不是 dict: {result}")
|
||||||
f"Failed to post c2c message, response is not dict: {result}"
|
return None
|
||||||
)
|
|
||||||
|
|
||||||
return message.Message(**result)
|
return message.Message(**result)
|
||||||
|
|
||||||
@@ -416,6 +580,9 @@ class QQOfficialMessageEvent(AstrMessageEvent):
|
|||||||
image_base64 = None # only one img supported
|
image_base64 = None # only one img supported
|
||||||
image_file_path = None
|
image_file_path = None
|
||||||
record_file_path = None
|
record_file_path = None
|
||||||
|
video_file_source = None
|
||||||
|
file_source = None
|
||||||
|
file_name = None
|
||||||
for i in message.chain:
|
for i in message.chain:
|
||||||
if isinstance(i, Plain):
|
if isinstance(i, Plain):
|
||||||
plain_text += i.text
|
plain_text += i.text
|
||||||
@@ -454,6 +621,30 @@ class QQOfficialMessageEvent(AstrMessageEvent):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"处理语音时出错: {e}")
|
logger.error(f"处理语音时出错: {e}")
|
||||||
record_file_path = None
|
record_file_path = None
|
||||||
|
elif isinstance(i, Video) and not video_file_source:
|
||||||
|
if i.file.startswith("file:///"):
|
||||||
|
video_file_source = i.file[8:]
|
||||||
|
else:
|
||||||
|
video_file_source = i.file
|
||||||
|
elif isinstance(i, File) and not file_source:
|
||||||
|
file_name = i.name
|
||||||
|
if i.file_:
|
||||||
|
file_path = i.file_
|
||||||
|
if file_path.startswith("file:///"):
|
||||||
|
file_path = file_path[8:]
|
||||||
|
elif file_path.startswith("file://"):
|
||||||
|
file_path = file_path[7:]
|
||||||
|
file_source = file_path
|
||||||
|
elif i.url:
|
||||||
|
file_source = i.url
|
||||||
else:
|
else:
|
||||||
logger.debug(f"qq_official 忽略 {i.type}")
|
logger.debug(f"qq_official 忽略 {i.type}")
|
||||||
return 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 asyncio
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
|
import random
|
||||||
import time
|
import time
|
||||||
from typing import cast
|
from types import SimpleNamespace
|
||||||
|
from typing import Any, cast
|
||||||
|
|
||||||
import botpy
|
import botpy
|
||||||
import botpy.message
|
import botpy.message
|
||||||
@@ -12,7 +14,7 @@ from botpy import Client
|
|||||||
|
|
||||||
from astrbot import logger
|
from astrbot import logger
|
||||||
from astrbot.api.event import MessageChain
|
from astrbot.api.event import MessageChain
|
||||||
from astrbot.api.message_components import At, File, Image, Plain
|
from astrbot.api.message_components import At, File, Image, Plain, Record, Video
|
||||||
from astrbot.api.platform import (
|
from astrbot.api.platform import (
|
||||||
AstrBotMessage,
|
AstrBotMessage,
|
||||||
MessageMember,
|
MessageMember,
|
||||||
@@ -46,6 +48,7 @@ class botClient(Client):
|
|||||||
)
|
)
|
||||||
abm.group_id = cast(str, message.group_openid)
|
abm.group_id = cast(str, message.group_openid)
|
||||||
abm.session_id = abm.group_id
|
abm.session_id = abm.group_id
|
||||||
|
self.platform.remember_session_scene(abm.session_id, "group")
|
||||||
self._commit(abm)
|
self._commit(abm)
|
||||||
|
|
||||||
# 收到频道消息
|
# 收到频道消息
|
||||||
@@ -56,6 +59,7 @@ class botClient(Client):
|
|||||||
)
|
)
|
||||||
abm.group_id = message.channel_id
|
abm.group_id = message.channel_id
|
||||||
abm.session_id = abm.group_id
|
abm.session_id = abm.group_id
|
||||||
|
self.platform.remember_session_scene(abm.session_id, "channel")
|
||||||
self._commit(abm)
|
self._commit(abm)
|
||||||
|
|
||||||
# 收到私聊消息
|
# 收到私聊消息
|
||||||
@@ -67,6 +71,7 @@ class botClient(Client):
|
|||||||
MessageType.FRIEND_MESSAGE,
|
MessageType.FRIEND_MESSAGE,
|
||||||
)
|
)
|
||||||
abm.session_id = abm.sender.user_id
|
abm.session_id = abm.sender.user_id
|
||||||
|
self.platform.remember_session_scene(abm.session_id, "friend")
|
||||||
self._commit(abm)
|
self._commit(abm)
|
||||||
|
|
||||||
# 收到 C2C 消息
|
# 收到 C2C 消息
|
||||||
@@ -76,9 +81,11 @@ class botClient(Client):
|
|||||||
MessageType.FRIEND_MESSAGE,
|
MessageType.FRIEND_MESSAGE,
|
||||||
)
|
)
|
||||||
abm.session_id = abm.sender.user_id
|
abm.session_id = abm.sender.user_id
|
||||||
|
self.platform.remember_session_scene(abm.session_id, "friend")
|
||||||
self._commit(abm)
|
self._commit(abm)
|
||||||
|
|
||||||
def _commit(self, abm: AstrBotMessage) -> None:
|
def _commit(self, abm: AstrBotMessage) -> None:
|
||||||
|
self.platform.remember_session_message_id(abm.session_id, abm.message_id)
|
||||||
self.platform.commit_event(
|
self.platform.commit_event(
|
||||||
QQOfficialMessageEvent(
|
QQOfficialMessageEvent(
|
||||||
abm.message_str,
|
abm.message_str,
|
||||||
@@ -124,6 +131,9 @@ class QQOfficialPlatformAdapter(Platform):
|
|||||||
|
|
||||||
self.client.set_platform(self)
|
self.client.set_platform(self)
|
||||||
|
|
||||||
|
self._session_last_message_id: dict[str, str] = {}
|
||||||
|
self._session_scene: dict[str, str] = {}
|
||||||
|
|
||||||
self.test_mode = os.environ.get("TEST_MODE", "off") == "on"
|
self.test_mode = os.environ.get("TEST_MODE", "off") == "on"
|
||||||
|
|
||||||
async def send_by_session(
|
async def send_by_session(
|
||||||
@@ -131,14 +141,191 @@ class QQOfficialPlatformAdapter(Platform):
|
|||||||
session: MessageSesion,
|
session: MessageSesion,
|
||||||
message_chain: MessageChain,
|
message_chain: MessageChain,
|
||||||
) -> None:
|
) -> 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:
|
def meta(self) -> PlatformMetadata:
|
||||||
return PlatformMetadata(
|
return PlatformMetadata(
|
||||||
name="qq_official",
|
name="qq_official",
|
||||||
description="QQ 机器人官方 API 适配器",
|
description="QQ 机器人官方 API 适配器",
|
||||||
id=cast(str, self.config.get("id")),
|
id=cast(str, self.config.get("id")),
|
||||||
support_proactive_message=False,
|
support_proactive_message=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@@ -158,7 +345,10 @@ class QQOfficialPlatformAdapter(Platform):
|
|||||||
return
|
return
|
||||||
|
|
||||||
for attachment in attachments:
|
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(
|
url = QQOfficialPlatformAdapter._normalize_attachment_url(
|
||||||
cast(str | None, getattr(attachment, "url", None))
|
cast(str | None, getattr(attachment, "url", None))
|
||||||
)
|
)
|
||||||
@@ -174,7 +364,73 @@ class QQOfficialPlatformAdapter(Platform):
|
|||||||
or getattr(attachment, "name", None)
|
or getattr(attachment, "name", None)
|
||||||
or "attachment",
|
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
|
@staticmethod
|
||||||
def _parse_from_qqofficial(
|
def _parse_from_qqofficial(
|
||||||
@@ -201,7 +457,10 @@ class QQOfficialPlatformAdapter(Platform):
|
|||||||
abm.group_id = message.group_openid
|
abm.group_id = message.group_openid
|
||||||
else:
|
else:
|
||||||
abm.sender = MessageMember(message.author.user_openid, "")
|
abm.sender = MessageMember(message.author.user_openid, "")
|
||||||
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"
|
abm.self_id = "unknown_selfid"
|
||||||
msg.append(At(qq="qq_official"))
|
msg.append(At(qq="qq_official"))
|
||||||
msg.append(Plain(abm.message_str))
|
msg.append(Plain(abm.message_str))
|
||||||
@@ -217,10 +476,12 @@ class QQOfficialPlatformAdapter(Platform):
|
|||||||
else:
|
else:
|
||||||
abm.self_id = ""
|
abm.self_id = ""
|
||||||
|
|
||||||
plain_content = message.content.replace(
|
plain_content = QQOfficialPlatformAdapter._parse_face_message(
|
||||||
"<@!" + str(abm.self_id) + ">",
|
message.content.replace(
|
||||||
"",
|
"<@!" + str(abm.self_id) + ">",
|
||||||
).strip()
|
"",
|
||||||
|
).strip()
|
||||||
|
)
|
||||||
|
|
||||||
QQOfficialPlatformAdapter._append_attachments(msg, message.attachments)
|
QQOfficialPlatformAdapter._append_attachments(msg, message.attachments)
|
||||||
abm.message = msg
|
abm.message = msg
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
import random
|
|
||||||
from types import SimpleNamespace
|
|
||||||
from typing import Any, cast
|
from typing import Any, cast
|
||||||
|
|
||||||
import botpy
|
import botpy
|
||||||
@@ -15,7 +13,6 @@ from astrbot.core.platform.astr_message_event import MessageSesion
|
|||||||
from astrbot.core.utils.webhook_utils import log_webhook_info
|
from astrbot.core.utils.webhook_utils import log_webhook_info
|
||||||
|
|
||||||
from ...register import register_platform_adapter
|
from ...register import register_platform_adapter
|
||||||
from ..qqofficial.qqofficial_message_event import QQOfficialMessageEvent
|
|
||||||
from ..qqofficial.qqofficial_platform_adapter import QQOfficialPlatformAdapter
|
from ..qqofficial.qqofficial_platform_adapter import QQOfficialPlatformAdapter
|
||||||
from .qo_webhook_event import QQOfficialWebhookMessageEvent
|
from .qo_webhook_event import QQOfficialWebhookMessageEvent
|
||||||
from .qo_webhook_server import QQOfficialWebhook
|
from .qo_webhook_server import QQOfficialWebhook
|
||||||
@@ -123,95 +120,11 @@ class QQOfficialWebhookPlatformAdapter(Platform):
|
|||||||
session: MessageSesion,
|
session: MessageSesion,
|
||||||
message_chain: MessageChain,
|
message_chain: MessageChain,
|
||||||
) -> None:
|
) -> None:
|
||||||
(
|
await QQOfficialPlatformAdapter._send_by_session_common(
|
||||||
plain_text,
|
cast(Any, self),
|
||||||
image_base64,
|
session,
|
||||||
image_path,
|
message_chain,
|
||||||
record_file_path,
|
)
|
||||||
) = await QQOfficialMessageEvent._parse_to_qqofficial(message_chain)
|
|
||||||
if not plain_text and not image_path:
|
|
||||||
return
|
|
||||||
|
|
||||||
msg_id = self._session_last_message_id.get(session.session_id)
|
|
||||||
if not msg_id:
|
|
||||||
logger.warning(
|
|
||||||
"[QQOfficialWebhook] No cached msg_id for session: %s, skip send_by_session",
|
|
||||||
session.session_id,
|
|
||||||
)
|
|
||||||
return
|
|
||||||
|
|
||||||
payload: dict[str, Any] = {"content": plain_text, "msg_id": msg_id}
|
|
||||||
ret: Any = None
|
|
||||||
send_helper = SimpleNamespace(bot=self.client)
|
|
||||||
if session.message_type == MessageType.GROUP_MESSAGE:
|
|
||||||
scene = self._session_scene.get(session.session_id)
|
|
||||||
if scene == "group":
|
|
||||||
payload["msg_seq"] = random.randint(1, 10000)
|
|
||||||
if image_base64:
|
|
||||||
media = await QQOfficialMessageEvent.upload_group_and_c2c_image(
|
|
||||||
send_helper, # type: ignore
|
|
||||||
image_base64,
|
|
||||||
1,
|
|
||||||
group_openid=session.session_id,
|
|
||||||
)
|
|
||||||
payload["media"] = media
|
|
||||||
payload["msg_type"] = 7
|
|
||||||
if record_file_path:
|
|
||||||
media = await QQOfficialMessageEvent.upload_group_and_c2c_record(
|
|
||||||
send_helper, # type: ignore
|
|
||||||
record_file_path,
|
|
||||||
3,
|
|
||||||
group_openid=session.session_id,
|
|
||||||
)
|
|
||||||
payload["media"] = media
|
|
||||||
payload["msg_type"] = 7
|
|
||||||
ret = await self.client.api.post_group_message(
|
|
||||||
group_openid=session.session_id,
|
|
||||||
**payload,
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
if image_path:
|
|
||||||
payload["file_image"] = image_path
|
|
||||||
ret = await self.client.api.post_message(
|
|
||||||
channel_id=session.session_id,
|
|
||||||
**payload,
|
|
||||||
)
|
|
||||||
elif session.message_type == MessageType.FRIEND_MESSAGE:
|
|
||||||
payload["msg_seq"] = random.randint(1, 10000)
|
|
||||||
if image_base64:
|
|
||||||
media = await QQOfficialMessageEvent.upload_group_and_c2c_image(
|
|
||||||
send_helper, # type: ignore
|
|
||||||
image_base64,
|
|
||||||
1,
|
|
||||||
openid=session.session_id,
|
|
||||||
)
|
|
||||||
payload["media"] = media
|
|
||||||
payload["msg_type"] = 7
|
|
||||||
if record_file_path:
|
|
||||||
media = await QQOfficialMessageEvent.upload_group_and_c2c_record(
|
|
||||||
send_helper, # type: ignore
|
|
||||||
record_file_path,
|
|
||||||
3,
|
|
||||||
openid=session.session_id,
|
|
||||||
)
|
|
||||||
payload["media"] = media
|
|
||||||
payload["msg_type"] = 7
|
|
||||||
ret = await QQOfficialMessageEvent.post_c2c_message(
|
|
||||||
send_helper, # type: ignore
|
|
||||||
openid=session.session_id,
|
|
||||||
**payload,
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
logger.warning(
|
|
||||||
"[QQOfficialWebhook] Unsupported message type for send_by_session: %s",
|
|
||||||
session.message_type,
|
|
||||||
)
|
|
||||||
return
|
|
||||||
|
|
||||||
sent_message_id = self._extract_message_id(ret)
|
|
||||||
if sent_message_id:
|
|
||||||
self.remember_session_message_id(session.session_id, sent_message_id)
|
|
||||||
await super().send_by_session(session, message_chain)
|
|
||||||
|
|
||||||
def remember_session_message_id(self, session_id: str, message_id: str) -> None:
|
def remember_session_message_id(self, session_id: str, message_id: str) -> None:
|
||||||
if not session_id or not message_id:
|
if not session_id or not message_id:
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
|
import time
|
||||||
from typing import cast
|
from typing import cast
|
||||||
|
|
||||||
import quart
|
import quart
|
||||||
@@ -39,6 +40,9 @@ class QQOfficialWebhook:
|
|||||||
self.client = botpy_client
|
self.client = botpy_client
|
||||||
self.event_queue = event_queue
|
self.event_queue = event_queue
|
||||||
self.shutdown_event = asyncio.Event()
|
self.shutdown_event = asyncio.Event()
|
||||||
|
# Deduplication cache for webhook retry callbacks.
|
||||||
|
self._seen_event_ids: dict[str, float] = {}
|
||||||
|
self._dedup_ttl: int = 60 # seconds
|
||||||
|
|
||||||
async def initialize(self) -> None:
|
async def initialize(self) -> None:
|
||||||
logger.info("正在登录到 QQ 官方机器人...")
|
logger.info("正在登录到 QQ 官方机器人...")
|
||||||
@@ -106,6 +110,22 @@ class QQOfficialWebhook:
|
|||||||
print(signed)
|
print(signed)
|
||||||
return signed
|
return signed
|
||||||
|
|
||||||
|
event_id = msg.get("id")
|
||||||
|
if event_id:
|
||||||
|
now = time.monotonic()
|
||||||
|
# Lazily evict expired entries to prevent unbounded growth.
|
||||||
|
expired = [
|
||||||
|
k
|
||||||
|
for k, ts in self._seen_event_ids.items()
|
||||||
|
if now - ts > self._dedup_ttl
|
||||||
|
]
|
||||||
|
for k in expired:
|
||||||
|
del self._seen_event_ids[k]
|
||||||
|
if event_id in self._seen_event_ids:
|
||||||
|
logger.debug(f"Duplicate webhook event {event_id!r}, skipping.")
|
||||||
|
return {"opcode": 12}
|
||||||
|
self._seen_event_ids[event_id] = now
|
||||||
|
|
||||||
if event and opcode == BotWebSocket.WS_DISPATCH_EVENT:
|
if event and opcode == BotWebSocket.WS_DISPATCH_EVENT:
|
||||||
event = msg["t"].lower()
|
event = msg["t"].lower()
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -289,8 +289,8 @@ class TelegramPlatformAdapter(Platform):
|
|||||||
else:
|
else:
|
||||||
message.type = MessageType.GROUP_MESSAGE
|
message.type = MessageType.GROUP_MESSAGE
|
||||||
message.group_id = str(update.message.chat.id)
|
message.group_id = str(update.message.chat.id)
|
||||||
if update.message.message_thread_id:
|
if update.message.is_topic_message and update.message.message_thread_id:
|
||||||
# Topic Group
|
# Telegram Topic Group: include thread id to isolate per-topic sessions.
|
||||||
message.group_id += "#" + str(update.message.message_thread_id)
|
message.group_id += "#" + str(update.message.message_thread_id)
|
||||||
message.session_id = message.group_id
|
message.session_id = message.group_id
|
||||||
message.message_id = str(update.message.message_id)
|
message.message_id = str(update.message.message_id)
|
||||||
|
|||||||
@@ -25,6 +25,16 @@ from astrbot.api.platform import AstrBotMessage, MessageType, PlatformMetadata
|
|||||||
from astrbot.core.utils.metrics import Metric
|
from astrbot.core.utils.metrics import Metric
|
||||||
|
|
||||||
|
|
||||||
|
def _is_gif(path: str) -> bool:
|
||||||
|
if path.lower().endswith(".gif"):
|
||||||
|
return True
|
||||||
|
try:
|
||||||
|
with open(path, "rb") as f:
|
||||||
|
return f.read(6) in (b"GIF87a", b"GIF89a")
|
||||||
|
except OSError:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
class TelegramPlatformEvent(AstrMessageEvent):
|
class TelegramPlatformEvent(AstrMessageEvent):
|
||||||
# Telegram 的最大消息长度限制
|
# Telegram 的最大消息长度限制
|
||||||
MAX_MESSAGE_LENGTH = 4096
|
MAX_MESSAGE_LENGTH = 4096
|
||||||
@@ -278,7 +288,6 @@ class TelegramPlatformEvent(AstrMessageEvent):
|
|||||||
try:
|
try:
|
||||||
md_text = telegramify_markdown.markdownify(
|
md_text = telegramify_markdown.markdownify(
|
||||||
chunk,
|
chunk,
|
||||||
normalize_whitespace=False,
|
|
||||||
)
|
)
|
||||||
await client.send_message(
|
await client.send_message(
|
||||||
text=md_text,
|
text=md_text,
|
||||||
@@ -292,7 +301,13 @@ class TelegramPlatformEvent(AstrMessageEvent):
|
|||||||
await client.send_message(text=chunk, **cast(Any, payload))
|
await client.send_message(text=chunk, **cast(Any, payload))
|
||||||
elif isinstance(i, Image):
|
elif isinstance(i, Image):
|
||||||
image_path = await i.convert_to_file_path()
|
image_path = await i.convert_to_file_path()
|
||||||
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):
|
elif isinstance(i, File):
|
||||||
path = await i.get_file()
|
path = await i.get_file()
|
||||||
name = i.name or os.path.basename(path)
|
name = i.name or os.path.basename(path)
|
||||||
@@ -407,12 +422,20 @@ class TelegramPlatformEvent(AstrMessageEvent):
|
|||||||
on_text(i.text)
|
on_text(i.text)
|
||||||
elif isinstance(i, Image):
|
elif isinstance(i, Image):
|
||||||
image_path = await i.convert_to_file_path()
|
image_path = await i.convert_to_file_path()
|
||||||
|
if _is_gif(image_path):
|
||||||
|
action = ChatAction.UPLOAD_VIDEO
|
||||||
|
send_coro = self.client.send_animation
|
||||||
|
media_kwarg = {"animation": image_path}
|
||||||
|
else:
|
||||||
|
action = ChatAction.UPLOAD_PHOTO
|
||||||
|
send_coro = self.client.send_photo
|
||||||
|
media_kwarg = {"photo": image_path}
|
||||||
await self._send_media_with_action(
|
await self._send_media_with_action(
|
||||||
self.client,
|
self.client,
|
||||||
ChatAction.UPLOAD_PHOTO,
|
action,
|
||||||
self.client.send_photo,
|
send_coro,
|
||||||
user_name=user_name,
|
user_name=user_name,
|
||||||
photo=image_path,
|
**media_kwarg,
|
||||||
**cast(Any, payload),
|
**cast(Any, payload),
|
||||||
)
|
)
|
||||||
elif isinstance(i, File):
|
elif isinstance(i, File):
|
||||||
@@ -456,7 +479,6 @@ class TelegramPlatformEvent(AstrMessageEvent):
|
|||||||
try:
|
try:
|
||||||
markdown_text = telegramify_markdown.markdownify(
|
markdown_text = telegramify_markdown.markdownify(
|
||||||
delta,
|
delta,
|
||||||
normalize_whitespace=False,
|
|
||||||
)
|
)
|
||||||
await self.client.send_message(
|
await self.client.send_message(
|
||||||
text=markdown_text,
|
text=markdown_text,
|
||||||
@@ -537,7 +559,6 @@ class TelegramPlatformEvent(AstrMessageEvent):
|
|||||||
try:
|
try:
|
||||||
md = telegramify_markdown.markdownify(
|
md = telegramify_markdown.markdownify(
|
||||||
draft_text,
|
draft_text,
|
||||||
normalize_whitespace=False,
|
|
||||||
)
|
)
|
||||||
await self._send_message_draft(
|
await self._send_message_draft(
|
||||||
user_name,
|
user_name,
|
||||||
@@ -695,7 +716,6 @@ class TelegramPlatformEvent(AstrMessageEvent):
|
|||||||
try:
|
try:
|
||||||
markdown_text = telegramify_markdown.markdownify(
|
markdown_text = telegramify_markdown.markdownify(
|
||||||
delta,
|
delta,
|
||||||
normalize_whitespace=False,
|
|
||||||
)
|
)
|
||||||
await self.client.edit_message_text(
|
await self.client.edit_message_text(
|
||||||
text=markdown_text,
|
text=markdown_text,
|
||||||
|
|||||||
@@ -440,9 +440,16 @@ class WecomAIBotAdapter(Platform):
|
|||||||
)
|
)
|
||||||
|
|
||||||
def _extract_session_id(self, message_data: dict[str, Any]) -> str:
|
def _extract_session_id(self, message_data: dict[str, Any]) -> str:
|
||||||
"""从消息数据中提取会话ID"""
|
"""从消息数据中提取会话ID
|
||||||
user_id = message_data.get("from", {}).get("userid", "default_user")
|
群聊使用 chatid,单聊使用 userid
|
||||||
return format_session_id("wecomai", user_id)
|
"""
|
||||||
|
chattype = message_data.get("chattype", "single")
|
||||||
|
if chattype == "group":
|
||||||
|
chat_id = message_data.get("chatid", "default_group")
|
||||||
|
return format_session_id("wecomai", chat_id)
|
||||||
|
else:
|
||||||
|
user_id = message_data.get("from", {}).get("userid", "default_user")
|
||||||
|
return format_session_id("wecomai", user_id)
|
||||||
|
|
||||||
async def _enqueue_message(
|
async def _enqueue_message(
|
||||||
self,
|
self,
|
||||||
|
|||||||
@@ -808,6 +808,8 @@ class ProviderManager:
|
|||||||
config.save_config()
|
config.save_config()
|
||||||
# load instance
|
# load instance
|
||||||
await self.load_provider(new_config)
|
await self.load_provider(new_config)
|
||||||
|
# sync in-memory config for API queries (e.g., embedding provider list)
|
||||||
|
self.providers_config = astrbot_config["provider"]
|
||||||
|
|
||||||
async def terminate(self) -> None:
|
async def terminate(self) -> None:
|
||||||
if self._mcp_init_task and not self._mcp_init_task.done():
|
if self._mcp_init_task and not self._mcp_init_task.done():
|
||||||
|
|||||||
@@ -13,3 +13,11 @@ class ProviderGroq(ProviderOpenAIOfficial):
|
|||||||
) -> None:
|
) -> None:
|
||||||
super().__init__(provider_config, provider_settings)
|
super().__init__(provider_config, provider_settings)
|
||||||
self.reasoning_key = "reasoning"
|
self.reasoning_key = "reasoning"
|
||||||
|
|
||||||
|
def _finally_convert_payload(self, payloads: dict) -> None:
|
||||||
|
"""Groq rejects assistant history items that include reasoning_content."""
|
||||||
|
super()._finally_convert_payload(payloads)
|
||||||
|
for message in payloads.get("messages", []):
|
||||||
|
if message.get("role") == "assistant":
|
||||||
|
message.pop("reasoning_content", None)
|
||||||
|
message.pop("reasoning", None)
|
||||||
|
|||||||
@@ -40,25 +40,46 @@ class OpenAIEmbeddingProvider(EmbeddingProvider):
|
|||||||
|
|
||||||
async def get_embedding(self, text: str) -> list[float]:
|
async def get_embedding(self, text: str) -> list[float]:
|
||||||
"""获取文本的嵌入"""
|
"""获取文本的嵌入"""
|
||||||
|
kwargs = self._embedding_kwargs()
|
||||||
embedding = await self.client.embeddings.create(
|
embedding = await self.client.embeddings.create(
|
||||||
input=text,
|
input=text,
|
||||||
model=self.model,
|
model=self.model,
|
||||||
dimensions=self.get_dim(),
|
**kwargs,
|
||||||
)
|
)
|
||||||
return embedding.data[0].embedding
|
return embedding.data[0].embedding
|
||||||
|
|
||||||
async def get_embeddings(self, text: list[str]) -> list[list[float]]:
|
async def get_embeddings(self, text: list[str]) -> list[list[float]]:
|
||||||
"""批量获取文本的嵌入"""
|
"""批量获取文本的嵌入"""
|
||||||
|
kwargs = self._embedding_kwargs()
|
||||||
embeddings = await self.client.embeddings.create(
|
embeddings = await self.client.embeddings.create(
|
||||||
input=text,
|
input=text,
|
||||||
model=self.model,
|
model=self.model,
|
||||||
dimensions=self.get_dim(),
|
**kwargs,
|
||||||
)
|
)
|
||||||
return [item.embedding for item in embeddings.data]
|
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:
|
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):
|
async def terminate(self):
|
||||||
if self.client:
|
if self.client:
|
||||||
|
|||||||
@@ -311,7 +311,7 @@ class ProviderOpenAIOfficial(Provider):
|
|||||||
state.handle_chunk(chunk)
|
state.handle_chunk(chunk)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning("Saving chunk state error: " + str(e))
|
logger.warning("Saving chunk state error: " + str(e))
|
||||||
if len(chunk.choices) == 0:
|
if not chunk.choices:
|
||||||
continue
|
continue
|
||||||
delta = chunk.choices[0].delta
|
delta = chunk.choices[0].delta
|
||||||
# logger.debug(f"chunk delta: {delta}")
|
# logger.debug(f"chunk delta: {delta}")
|
||||||
@@ -322,7 +322,7 @@ class ProviderOpenAIOfficial(Provider):
|
|||||||
if reasoning:
|
if reasoning:
|
||||||
llm_response.reasoning_content = reasoning
|
llm_response.reasoning_content = reasoning
|
||||||
_y = True
|
_y = True
|
||||||
if delta.content:
|
if delta and delta.content:
|
||||||
# Don't strip streaming chunks to preserve spaces between words
|
# Don't strip streaming chunks to preserve spaces between words
|
||||||
completion_text = self._normalize_content(delta.content, strip=False)
|
completion_text = self._normalize_content(delta.content, strip=False)
|
||||||
llm_response.result_chain = MessageChain(
|
llm_response.result_chain = MessageChain(
|
||||||
@@ -345,7 +345,7 @@ class ProviderOpenAIOfficial(Provider):
|
|||||||
) -> str:
|
) -> str:
|
||||||
"""Extract reasoning content from OpenAI ChatCompletion if available."""
|
"""Extract reasoning content from OpenAI ChatCompletion if available."""
|
||||||
reasoning_text = ""
|
reasoning_text = ""
|
||||||
if len(completion.choices) == 0:
|
if not completion.choices:
|
||||||
return reasoning_text
|
return reasoning_text
|
||||||
if isinstance(completion, ChatCompletion):
|
if isinstance(completion, ChatCompletion):
|
||||||
choice = completion.choices[0]
|
choice = completion.choices[0]
|
||||||
@@ -468,7 +468,7 @@ class ProviderOpenAIOfficial(Provider):
|
|||||||
"""Parse OpenAI ChatCompletion into LLMResponse"""
|
"""Parse OpenAI ChatCompletion into LLMResponse"""
|
||||||
llm_response = LLMResponse("assistant")
|
llm_response = LLMResponse("assistant")
|
||||||
|
|
||||||
if len(completion.choices) == 0:
|
if not completion.choices:
|
||||||
raise Exception("API 返回的 completion 为空。")
|
raise Exception("API 返回的 completion 为空。")
|
||||||
choice = completion.choices[0]
|
choice = completion.choices[0]
|
||||||
|
|
||||||
@@ -629,7 +629,8 @@ class ProviderOpenAIOfficial(Provider):
|
|||||||
# 最后一次不等待
|
# 最后一次不等待
|
||||||
if retry_cnt < max_retries - 1:
|
if retry_cnt < max_retries - 1:
|
||||||
await asyncio.sleep(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:
|
if len(available_api_keys) > 0:
|
||||||
chosen_key = random.choice(available_api_keys)
|
chosen_key = random.choice(available_api_keys)
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -16,4 +16,7 @@ class ProviderOpenRouter(ProviderOpenAIOfficial):
|
|||||||
self.client._custom_headers["HTTP-Referer"] = ( # type: ignore
|
self.client._custom_headers["HTTP-Referer"] = ( # type: ignore
|
||||||
"https://github.com/AstrBotDevs/AstrBot"
|
"https://github.com/AstrBotDevs/AstrBot"
|
||||||
)
|
)
|
||||||
self.client._custom_headers["X-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 json
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
|
import shlex
|
||||||
import shutil
|
import shutil
|
||||||
import tempfile
|
import tempfile
|
||||||
import zipfile
|
import zipfile
|
||||||
@@ -10,6 +11,8 @@ from dataclasses import dataclass
|
|||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from pathlib import Path, PurePosixPath
|
from pathlib import Path, PurePosixPath
|
||||||
|
|
||||||
|
import yaml
|
||||||
|
|
||||||
from astrbot.core.utils.astrbot_path import (
|
from astrbot.core.utils.astrbot_path import (
|
||||||
get_astrbot_data_path,
|
get_astrbot_data_path,
|
||||||
get_astrbot_skills_path,
|
get_astrbot_skills_path,
|
||||||
@@ -68,18 +71,76 @@ def _parse_frontmatter_description(text: str) -> str:
|
|||||||
break
|
break
|
||||||
if end_idx is None:
|
if end_idx is None:
|
||||||
return ""
|
return ""
|
||||||
for line in lines[1:end_idx]:
|
|
||||||
if ":" not in line:
|
frontmatter = "\n".join(lines[1:end_idx])
|
||||||
continue
|
try:
|
||||||
key, value = line.split(":", 1)
|
payload = yaml.safe_load(frontmatter) or {}
|
||||||
if key.strip().lower() == "description":
|
except yaml.YAMLError:
|
||||||
return value.strip().strip('"').strip("'")
|
return ""
|
||||||
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
|
# Regex for sanitizing paths used in prompt examples — only allow
|
||||||
# safe path characters to prevent prompt injection via crafted skill paths.
|
# safe path characters to prevent prompt injection via crafted skill paths.
|
||||||
_SAFE_PATH_RE = re.compile(r"[^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:
|
def build_skills_prompt(skills: list[SkillInfo]) -> str:
|
||||||
@@ -92,16 +153,37 @@ def build_skills_prompt(skills: list[SkillInfo]) -> str:
|
|||||||
skills_lines: list[str] = []
|
skills_lines: list[str] = []
|
||||||
example_path = ""
|
example_path = ""
|
||||||
for skill in skills:
|
for skill in skills:
|
||||||
|
display_name = _sanitize_skill_display_name(skill.name)
|
||||||
|
|
||||||
description = skill.description or "No description"
|
description = skill.description or "No description"
|
||||||
|
if skill.source_type == "sandbox_only":
|
||||||
|
description = _sanitize_prompt_description(description)
|
||||||
|
if not description:
|
||||||
|
description = "Read SKILL.md for details."
|
||||||
|
|
||||||
|
if skill.source_type == "sandbox_only":
|
||||||
|
rendered_path = (
|
||||||
|
f"{str(SANDBOX_WORKSPACE_ROOT)}/{str(SANDBOX_SKILLS_ROOT)}/"
|
||||||
|
f"{display_name}/SKILL.md"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
rendered_path = _sanitize_prompt_path_for_prompt(skill.path)
|
||||||
|
if not rendered_path:
|
||||||
|
rendered_path = "<skills_root>/<skill_name>/SKILL.md"
|
||||||
|
|
||||||
skills_lines.append(
|
skills_lines.append(
|
||||||
f"- **{skill.name}**: {description}\n File: `{skill.path}`"
|
f"- **{display_name}**: {description}\n File: `{rendered_path}`"
|
||||||
)
|
)
|
||||||
if not example_path:
|
if not example_path:
|
||||||
example_path = skill.path
|
example_path = rendered_path
|
||||||
skills_block = "\n".join(skills_lines)
|
skills_block = "\n".join(skills_lines)
|
||||||
# Sanitize example_path — it may originate from sandbox cache (untrusted)
|
# Sanitize example_path — it may originate from sandbox cache (untrusted)
|
||||||
example_path = _SAFE_PATH_RE.sub("", example_path) if example_path else ""
|
if example_path == "<skills_root>/<skill_name>/SKILL.md":
|
||||||
example_path = example_path or "<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 (
|
return (
|
||||||
"## Skills\n\n"
|
"## 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 "
|
"*Never silently skip a matching skill* — either use it or briefly "
|
||||||
"explain why you chose not to.\n"
|
"explain why you chose not to.\n"
|
||||||
"3. **Mandatory grounding** — Before executing any skill you MUST "
|
"3. **Mandatory grounding** — Before executing any skill you MUST "
|
||||||
"first read its `SKILL.md` by running a shell command with the "
|
"first read its `SKILL.md` by running a shell command compatible "
|
||||||
f"**absolute path** shown above (e.g. `cat {example_path}`). "
|
"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"
|
"Never rely on memory or assumptions about a skill's content.\n"
|
||||||
"4. **Progressive disclosure** — Load only what is directly "
|
"4. **Progressive disclosure** — Load only what is directly "
|
||||||
"referenced from `SKILL.md`:\n"
|
"referenced from `SKILL.md`:\n"
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import re
|
import re
|
||||||
from collections.abc import AsyncGenerator, Awaitable, Callable
|
from collections.abc import AsyncGenerator, Awaitable, Callable
|
||||||
from typing import TYPE_CHECKING, Any
|
from typing import Any
|
||||||
|
|
||||||
import docstring_parser
|
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.func_tool_manager import PY_TO_JSON_TYPE, SUPPORTED_TYPES
|
||||||
from astrbot.core.provider.register import llm_tools
|
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 import CommandFilter
|
||||||
from ..filter.command_group import CommandGroupFilter
|
from ..filter.command_group import CommandGroupFilter
|
||||||
from ..filter.custom_filter import CustomFilterAnd, CustomFilterOr
|
from ..filter.custom_filter import CustomFilterAnd, CustomFilterOr
|
||||||
@@ -619,7 +616,7 @@ class RegisteringAgent:
|
|||||||
kwargs["registering_agent"] = self
|
kwargs["registering_agent"] = self
|
||||||
return register_llm_tool(*args, **kwargs)
|
return register_llm_tool(*args, **kwargs)
|
||||||
|
|
||||||
def __init__(self, agent: Agent[AstrAgentContext]) -> None:
|
def __init__(self, agent: Agent[Any]) -> None:
|
||||||
self._agent = agent
|
self._agent = agent
|
||||||
|
|
||||||
|
|
||||||
@@ -627,7 +624,7 @@ def register_agent(
|
|||||||
name: str,
|
name: str,
|
||||||
instruction: str,
|
instruction: str,
|
||||||
tools: list[str | FunctionTool] | None = None,
|
tools: list[str | FunctionTool] | None = None,
|
||||||
run_hooks: BaseAgentRunHooks[AstrAgentContext] | None = None,
|
run_hooks: BaseAgentRunHooks[Any] | None = None,
|
||||||
):
|
):
|
||||||
"""注册一个 Agent
|
"""注册一个 Agent
|
||||||
|
|
||||||
@@ -641,12 +638,12 @@ def register_agent(
|
|||||||
tools_ = tools or []
|
tools_ = tools or []
|
||||||
|
|
||||||
def decorator(awaitable: Callable[..., Awaitable[Any]]):
|
def decorator(awaitable: Callable[..., Awaitable[Any]]):
|
||||||
AstrAgent = Agent[AstrAgentContext]
|
AstrAgent = Agent[Any]
|
||||||
agent = AstrAgent(
|
agent = AstrAgent(
|
||||||
name=name,
|
name=name,
|
||||||
instructions=instruction,
|
instructions=instruction,
|
||||||
tools=tools_,
|
tools=tools_,
|
||||||
run_hooks=run_hooks or BaseAgentRunHooks[AstrAgentContext](),
|
run_hooks=run_hooks or BaseAgentRunHooks[Any](),
|
||||||
)
|
)
|
||||||
handoff_tool = HandoffTool(agent=agent)
|
handoff_tool = HandoffTool(agent=agent)
|
||||||
handoff_tool.handler = awaitable
|
handoff_tool.handler = awaitable
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
"""插件的重载、启停、安装、卸载等操作。"""
|
"""插件的重载、启停、安装、卸载等操作。"""
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import contextlib
|
||||||
import functools
|
import functools
|
||||||
import inspect
|
import inspect
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
|
import tempfile
|
||||||
import traceback
|
import traceback
|
||||||
from types import ModuleType
|
from types import ModuleType
|
||||||
|
|
||||||
@@ -29,12 +31,12 @@ from astrbot.core.utils.astrbot_path import (
|
|||||||
get_astrbot_config_path,
|
get_astrbot_config_path,
|
||||||
get_astrbot_path,
|
get_astrbot_path,
|
||||||
get_astrbot_plugin_path,
|
get_astrbot_plugin_path,
|
||||||
|
get_astrbot_temp_path,
|
||||||
)
|
)
|
||||||
from astrbot.core.utils.io import remove_dir
|
from astrbot.core.utils.io import remove_dir
|
||||||
from astrbot.core.utils.metrics import Metric
|
from astrbot.core.utils.metrics import Metric
|
||||||
from astrbot.core.utils.requirements_utils import (
|
from astrbot.core.utils.requirements_utils import (
|
||||||
RequirementsPrecheckFailed,
|
plan_missing_requirements_install,
|
||||||
find_missing_requirements_or_raise,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
from . import StarMetadata
|
from . import StarMetadata
|
||||||
@@ -74,30 +76,78 @@ class PluginDependencyInstallError(Exception):
|
|||||||
self.error = error
|
self.error = error
|
||||||
|
|
||||||
|
|
||||||
|
@contextlib.contextmanager
|
||||||
|
def _temporary_filtered_requirements_file(
|
||||||
|
*,
|
||||||
|
install_lines: tuple[str, ...],
|
||||||
|
):
|
||||||
|
filtered_requirements_path: str | None = None
|
||||||
|
temp_dir = get_astrbot_temp_path()
|
||||||
|
|
||||||
|
try:
|
||||||
|
os.makedirs(temp_dir, exist_ok=True)
|
||||||
|
with tempfile.NamedTemporaryFile(
|
||||||
|
mode="w",
|
||||||
|
suffix="_plugin_requirements.txt",
|
||||||
|
delete=False,
|
||||||
|
dir=temp_dir,
|
||||||
|
encoding="utf-8",
|
||||||
|
) as filtered_requirements_file:
|
||||||
|
filtered_requirements_file.write("\n".join(install_lines) + "\n")
|
||||||
|
filtered_requirements_path = filtered_requirements_file.name
|
||||||
|
|
||||||
|
yield filtered_requirements_path
|
||||||
|
finally:
|
||||||
|
if filtered_requirements_path and os.path.exists(filtered_requirements_path):
|
||||||
|
try:
|
||||||
|
os.remove(filtered_requirements_path)
|
||||||
|
except OSError as exc:
|
||||||
|
logger.warning(
|
||||||
|
"删除临时插件依赖文件失败:%s(路径:%s)",
|
||||||
|
exc,
|
||||||
|
filtered_requirements_path,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
async def _install_requirements_with_precheck(
|
async def _install_requirements_with_precheck(
|
||||||
*,
|
*,
|
||||||
plugin_label: str,
|
plugin_label: str,
|
||||||
requirements_path: str,
|
requirements_path: str,
|
||||||
) -> None:
|
) -> None:
|
||||||
try:
|
install_plan = plan_missing_requirements_install(requirements_path)
|
||||||
missing = find_missing_requirements_or_raise(requirements_path)
|
|
||||||
except RequirementsPrecheckFailed:
|
if install_plan is None:
|
||||||
logger.info(
|
logger.info(
|
||||||
f"正在安装插件 {plugin_label} 的依赖库(预检查失败,回退到完整安装): "
|
f"正在安装插件 {plugin_label} 的依赖库(缺失依赖预检查不可裁剪,回退到完整安装): "
|
||||||
f"{requirements_path}"
|
f"{requirements_path}"
|
||||||
)
|
)
|
||||||
await pip_installer.install(requirements_path=requirements_path)
|
await pip_installer.install(requirements_path=requirements_path)
|
||||||
return
|
return
|
||||||
|
|
||||||
if not missing:
|
if not install_plan.missing_names:
|
||||||
logger.info(f"插件 {plugin_label} 的依赖已满足,跳过安装。")
|
logger.info(f"插件 {plugin_label} 的依赖已满足,跳过安装。")
|
||||||
return
|
return
|
||||||
|
|
||||||
|
if not install_plan.install_lines:
|
||||||
|
fallback_reason = install_plan.fallback_reason or "unknown reason"
|
||||||
|
logger.info(
|
||||||
|
"检测到插件 %s 缺失依赖,但无法安全裁剪 requirements,回退到完整安装: %s (%s)",
|
||||||
|
plugin_label,
|
||||||
|
requirements_path,
|
||||||
|
fallback_reason,
|
||||||
|
)
|
||||||
|
await pip_installer.install(requirements_path=requirements_path)
|
||||||
|
return
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
f"检测到插件 {plugin_label} 缺失依赖,正在按 requirements.txt 安装: "
|
f"检测到插件 {plugin_label} 缺失依赖,正在按 requirements.txt 安装: "
|
||||||
f"{requirements_path} -> {sorted(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:
|
class PluginManager:
|
||||||
|
|||||||
@@ -1,13 +1,16 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from typing import Any
|
import copy
|
||||||
|
from typing import TYPE_CHECKING, Any
|
||||||
|
|
||||||
from astrbot import logger
|
from astrbot import logger
|
||||||
from astrbot.core.agent.agent import Agent
|
from astrbot.core.agent.agent import Agent
|
||||||
from astrbot.core.agent.handoff import HandoffTool
|
from astrbot.core.agent.handoff import HandoffTool
|
||||||
from astrbot.core.persona_mgr import PersonaManager
|
|
||||||
from astrbot.core.provider.func_tool_manager import FunctionToolManager
|
from astrbot.core.provider.func_tool_manager import FunctionToolManager
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from astrbot.core.persona_mgr import PersonaManager
|
||||||
|
|
||||||
|
|
||||||
class SubAgentOrchestrator:
|
class SubAgentOrchestrator:
|
||||||
"""Loads subagent definitions from config and registers handoff tools.
|
"""Loads subagent definitions from config and registers handoff tools.
|
||||||
@@ -43,15 +46,14 @@ class SubAgentOrchestrator:
|
|||||||
continue
|
continue
|
||||||
|
|
||||||
persona_id = item.get("persona_id")
|
persona_id = item.get("persona_id")
|
||||||
persona_data = None
|
if persona_id is not None:
|
||||||
if persona_id:
|
persona_id = str(persona_id).strip() or None
|
||||||
try:
|
persona_data = self._persona_mgr.get_persona_v3_by_id(persona_id)
|
||||||
persona_data = await self._persona_mgr.get_persona(persona_id)
|
if persona_id and persona_data is None:
|
||||||
except StopIteration:
|
logger.warning(
|
||||||
logger.warning(
|
"SubAgent persona %s not found, fallback to inline prompt.",
|
||||||
"SubAgent persona %s not found, fallback to inline prompt.",
|
persona_id,
|
||||||
persona_id,
|
)
|
||||||
)
|
|
||||||
|
|
||||||
instructions = str(item.get("system_prompt", "")).strip()
|
instructions = str(item.get("system_prompt", "")).strip()
|
||||||
public_description = str(item.get("public_description", "")).strip()
|
public_description = str(item.get("public_description", "")).strip()
|
||||||
@@ -62,11 +64,15 @@ class SubAgentOrchestrator:
|
|||||||
begin_dialogs = None
|
begin_dialogs = None
|
||||||
|
|
||||||
if persona_data:
|
if persona_data:
|
||||||
instructions = persona_data.system_prompt or instructions
|
prompt = str(persona_data.get("prompt", "")).strip()
|
||||||
begin_dialogs = persona_data.begin_dialogs
|
if prompt:
|
||||||
tools = persona_data.tools
|
instructions = prompt
|
||||||
if public_description == "" and persona_data.system_prompt:
|
begin_dialogs = copy.deepcopy(
|
||||||
public_description = persona_data.system_prompt[:120]
|
persona_data.get("_begin_dialogs_processed")
|
||||||
|
)
|
||||||
|
tools = persona_data.get("tools")
|
||||||
|
if public_description == "" and prompt:
|
||||||
|
public_description = prompt[:120]
|
||||||
if tools is None:
|
if tools is None:
|
||||||
tools = None
|
tools = None
|
||||||
elif not isinstance(tools, list):
|
elif not isinstance(tools, list):
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ class CreateActiveCronTool(FunctionTool[AstrAgentContext]):
|
|||||||
"properties": {
|
"properties": {
|
||||||
"cron_expression": {
|
"cron_expression": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"description": "Cron expression defining recurring schedule (e.g., '0 8 * * *').",
|
"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": {
|
"run_at": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
|
|||||||
@@ -25,12 +25,22 @@ class UmopConfigRouter:
|
|||||||
)
|
)
|
||||||
self.umop_to_conf_id = sp_data
|
self.umop_to_conf_id = sp_data
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _split_umo(umo: str) -> tuple[str, str, str] | None:
|
||||||
|
"""将 UMO 拆分为 3 个部分,同时保留 session_id 中的 ':'"""
|
||||||
|
if not isinstance(umo, str):
|
||||||
|
return None
|
||||||
|
parts = umo.split(":", 2)
|
||||||
|
if len(parts) != 3:
|
||||||
|
return None
|
||||||
|
return parts[0], parts[1], parts[2]
|
||||||
|
|
||||||
def _is_umo_match(self, p1: str, p2: str) -> bool:
|
def _is_umo_match(self, p1: str, p2: str) -> bool:
|
||||||
"""判断 p2 umo 是否逻辑包含于 p1 umo"""
|
"""判断 p2 umo 是否逻辑包含于 p1 umo"""
|
||||||
p1_ls = p1.split(":")
|
p1_ls = self._split_umo(p1)
|
||||||
p2_ls = p2.split(":")
|
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 False # 非法格式
|
||||||
|
|
||||||
return all(p == "" or fnmatch.fnmatchcase(t, p) for p, t in zip(p1_ls, p2_ls))
|
return all(p == "" or fnmatch.fnmatchcase(t, p) for p, t in zip(p1_ls, p2_ls))
|
||||||
@@ -62,7 +72,7 @@ class UmopConfigRouter:
|
|||||||
|
|
||||||
"""
|
"""
|
||||||
for part in new_routing:
|
for part in new_routing:
|
||||||
if not isinstance(part, str) or len(part.split(":")) != 3:
|
if self._split_umo(part) is None:
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
"umop keys must be strings in the format [platform_id]:[message_type]:[session_id], with optional wildcards * or empty for all",
|
"umop keys must be strings in the format [platform_id]:[message_type]:[session_id], with optional wildcards * or empty for all",
|
||||||
)
|
)
|
||||||
@@ -81,7 +91,7 @@ class UmopConfigRouter:
|
|||||||
ValueError: 如果 umo 格式不正确
|
ValueError: 如果 umo 格式不正确
|
||||||
|
|
||||||
"""
|
"""
|
||||||
if not isinstance(umo, str) or len(umo.split(":")) != 3:
|
if self._split_umo(umo) is None:
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
"umop must be a string in the format [platform_id]:[message_type]:[session_id], with optional wildcards * or empty for all",
|
"umop must be a string in the format [platform_id]:[message_type]:[session_id], with optional wildcards * or empty for all",
|
||||||
)
|
)
|
||||||
@@ -99,7 +109,7 @@ class UmopConfigRouter:
|
|||||||
ValueError: 当 umo 格式不正确时抛出
|
ValueError: 当 umo 格式不正确时抛出
|
||||||
"""
|
"""
|
||||||
|
|
||||||
if not isinstance(umo, str) or len(umo.split(":")) != 3:
|
if self._split_umo(umo) is None:
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
"umop must be a string in the format [platform_id]:[message_type]:[session_id], with optional wildcards * or empty for all",
|
"umop must be a string in the format [platform_id]:[message_type]:[session_id], with optional wildcards * or empty for all",
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import os
|
|||||||
import re
|
import re
|
||||||
import shlex
|
import shlex
|
||||||
import sys
|
import sys
|
||||||
from collections.abc import Iterable, Iterator
|
from collections.abc import Iterable, Iterator, Sequence
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
|
||||||
from packaging.requirements import InvalidRequirement, Requirement
|
from packaging.requirements import InvalidRequirement, Requirement
|
||||||
@@ -29,6 +29,13 @@ class ParsedPackageInput:
|
|||||||
requirement_names: frozenset[str]
|
requirement_names: frozenset[str]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class MissingRequirementsPlan:
|
||||||
|
missing_names: frozenset[str]
|
||||||
|
install_lines: tuple[str, ...]
|
||||||
|
fallback_reason: str | None = None
|
||||||
|
|
||||||
|
|
||||||
def canonicalize_distribution_name(name: str) -> str:
|
def canonicalize_distribution_name(name: str) -> str:
|
||||||
return re.sub(r"[-_.]+", "-", name).strip("-").lower()
|
return re.sub(r"[-_.]+", "-", name).strip("-").lower()
|
||||||
|
|
||||||
@@ -364,8 +371,8 @@ def _load_requirement_lines_for_precheck(
|
|||||||
None,
|
None,
|
||||||
)
|
)
|
||||||
if fallback_line is not None:
|
if fallback_line is not None:
|
||||||
logger.warning(
|
logger.info(
|
||||||
"预检查缺失依赖失败,将回退到完整安装: unresolved direct reference in %s: %s",
|
"缺失依赖预检查发现无法安全裁剪的 option/direct-reference 行,将回退到完整安装: %s (%s)",
|
||||||
requirements_path,
|
requirements_path,
|
||||||
fallback_line,
|
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:
|
if not can_precheck or requirement_lines is None:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
return find_missing_requirements_from_lines(requirement_lines)
|
||||||
|
|
||||||
|
|
||||||
|
def find_missing_requirements_from_lines(
|
||||||
|
requirement_lines: Sequence[str],
|
||||||
|
) -> set[str] | None:
|
||||||
|
|
||||||
required = list(iter_requirements(lines=requirement_lines))
|
required = list(iter_requirements(lines=requirement_lines))
|
||||||
if not required:
|
if not required:
|
||||||
return set()
|
return set()
|
||||||
@@ -401,6 +415,70 @@ def find_missing_requirements(requirements_path: str) -> set[str] | None:
|
|||||||
return missing
|
return missing
|
||||||
|
|
||||||
|
|
||||||
|
def build_missing_requirements_install_lines(
|
||||||
|
requirements_path: str,
|
||||||
|
requirement_lines: Sequence[str],
|
||||||
|
missing_names: set[str] | frozenset[str],
|
||||||
|
) -> tuple[str, ...] | None:
|
||||||
|
wanted_names = set(missing_names)
|
||||||
|
install_lines: list[str] = []
|
||||||
|
for line in requirement_lines:
|
||||||
|
parsed = _parse_requirement_line(line)
|
||||||
|
if parsed is None:
|
||||||
|
if looks_like_direct_reference(line) or line.startswith(("-", "--")):
|
||||||
|
logger.debug(
|
||||||
|
"缺失依赖行筛选回退到完整安装:requirements 中包含无法安全裁剪的 option/direct-reference 行: %s (%s)",
|
||||||
|
requirements_path,
|
||||||
|
line,
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
continue
|
||||||
|
|
||||||
|
name, _specifier = parsed
|
||||||
|
if name in wanted_names:
|
||||||
|
install_lines.append(line)
|
||||||
|
|
||||||
|
return tuple(install_lines)
|
||||||
|
|
||||||
|
|
||||||
|
def plan_missing_requirements_install(
|
||||||
|
requirements_path: str,
|
||||||
|
) -> MissingRequirementsPlan | None:
|
||||||
|
can_precheck, requirement_lines = _load_requirement_lines_for_precheck(
|
||||||
|
requirements_path
|
||||||
|
)
|
||||||
|
if not can_precheck or requirement_lines is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
missing = find_missing_requirements_from_lines(requirement_lines)
|
||||||
|
if missing is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
install_lines = build_missing_requirements_install_lines(
|
||||||
|
requirements_path,
|
||||||
|
requirement_lines,
|
||||||
|
missing,
|
||||||
|
)
|
||||||
|
if install_lines is None:
|
||||||
|
return None
|
||||||
|
if missing and not install_lines:
|
||||||
|
logger.warning(
|
||||||
|
"预检查缺失依赖成功,但无法映射到可安装 requirement 行,将回退到完整安装: %s -> %s",
|
||||||
|
requirements_path,
|
||||||
|
sorted(missing),
|
||||||
|
)
|
||||||
|
return MissingRequirementsPlan(
|
||||||
|
missing_names=frozenset(missing),
|
||||||
|
install_lines=(),
|
||||||
|
fallback_reason="unmapped missing requirement names",
|
||||||
|
)
|
||||||
|
|
||||||
|
return MissingRequirementsPlan(
|
||||||
|
missing_names=frozenset(missing),
|
||||||
|
install_lines=install_lines,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def find_missing_requirements_or_raise(requirements_path: str) -> set[str]:
|
def find_missing_requirements_or_raise(requirements_path: str) -> set[str]:
|
||||||
missing = find_missing_requirements(requirements_path)
|
missing = find_missing_requirements(requirements_path)
|
||||||
if missing is None:
|
if missing is None:
|
||||||
|
|||||||
@@ -82,7 +82,8 @@ class AuthRoute(Route):
|
|||||||
def generate_jwt(self, username):
|
def generate_jwt(self, username):
|
||||||
payload = {
|
payload = {
|
||||||
"username": username,
|
"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)
|
jwt_token = self.config["dashboard"].get("jwt_secret", None)
|
||||||
if not jwt_token:
|
if not jwt_token:
|
||||||
|
|||||||
@@ -977,7 +977,17 @@ class BackupRoute(Route):
|
|||||||
if not jwt_secret:
|
if not jwt_secret:
|
||||||
return Response().error("服务器配置错误").__dict__
|
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:
|
except jwt.ExpiredSignatureError:
|
||||||
return Response().error("Token 已过期,请刷新页面后重试").__dict__
|
return Response().error("Token 已过期,请刷新页面后重试").__dict__
|
||||||
except jwt.InvalidTokenError:
|
except jwt.InvalidTokenError:
|
||||||
|
|||||||
@@ -36,6 +36,20 @@ async def track_conversation(convs: dict, conv_id: str):
|
|||||||
convs.pop(conv_id, None)
|
convs.pop(conv_id, None)
|
||||||
|
|
||||||
|
|
||||||
|
async def _poll_webchat_stream_result(back_queue, username: str):
|
||||||
|
try:
|
||||||
|
result = await asyncio.wait_for(back_queue.get(), timeout=1)
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
return None, False
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
logger.debug(f"[WebChat] 用户 {username} 断开聊天长连接。")
|
||||||
|
return None, True
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"WebChat stream error: {e}")
|
||||||
|
return None, False
|
||||||
|
return result, False
|
||||||
|
|
||||||
|
|
||||||
class ChatRoute(Route):
|
class ChatRoute(Route):
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
@@ -51,6 +65,7 @@ class ChatRoute(Route):
|
|||||||
"/chat/get_session": ("GET", self.get_session),
|
"/chat/get_session": ("GET", self.get_session),
|
||||||
"/chat/stop": ("POST", self.stop_session),
|
"/chat/stop": ("POST", self.stop_session),
|
||||||
"/chat/delete_session": ("GET", self.delete_webchat_session),
|
"/chat/delete_session": ("GET", self.delete_webchat_session),
|
||||||
|
"/chat/batch_delete_sessions": ("POST", self.batch_delete_sessions),
|
||||||
"/chat/update_session_display_name": (
|
"/chat/update_session_display_name": (
|
||||||
"POST",
|
"POST",
|
||||||
self.update_session_display_name,
|
self.update_session_display_name,
|
||||||
@@ -342,16 +357,12 @@ class ChatRoute(Route):
|
|||||||
|
|
||||||
async with track_conversation(self.running_convs, webchat_conv_id):
|
async with track_conversation(self.running_convs, webchat_conv_id):
|
||||||
while True:
|
while True:
|
||||||
try:
|
result, should_break = await _poll_webchat_stream_result(
|
||||||
result = await asyncio.wait_for(back_queue.get(), timeout=1)
|
back_queue, username
|
||||||
except asyncio.TimeoutError:
|
)
|
||||||
continue
|
if should_break:
|
||||||
except asyncio.CancelledError:
|
|
||||||
logger.debug(f"[WebChat] 用户 {username} 断开聊天长连接。")
|
|
||||||
client_disconnected = True
|
client_disconnected = True
|
||||||
except Exception as e:
|
break
|
||||||
logger.error(f"WebChat stream error: {e}")
|
|
||||||
|
|
||||||
if not result:
|
if not result:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
@@ -578,19 +589,9 @@ class ChatRoute(Route):
|
|||||||
|
|
||||||
return Response().ok(data={"stopped_count": stopped_count}).__dict__
|
return Response().ok(data={"stopped_count": stopped_count}).__dict__
|
||||||
|
|
||||||
async def delete_webchat_session(self):
|
async def _delete_session_internal(self, session, username: str) -> None:
|
||||||
"""Delete a Platform session and all its related data."""
|
"""Delete a single session and all its related data."""
|
||||||
session_id = request.args.get("session_id")
|
session_id = session.session_id
|
||||||
if not session_id:
|
|
||||||
return Response().error("Missing key: session_id").__dict__
|
|
||||||
username = g.get("username", "guest")
|
|
||||||
|
|
||||||
# 验证会话是否存在且属于当前用户
|
|
||||||
session = await self.db.get_platform_session_by_id(session_id)
|
|
||||||
if not session:
|
|
||||||
return Response().error(f"Session {session_id} not found").__dict__
|
|
||||||
if session.creator != username:
|
|
||||||
return Response().error("Permission denied").__dict__
|
|
||||||
|
|
||||||
# 删除该会话下的所有对话
|
# 删除该会话下的所有对话
|
||||||
message_type = "GroupMessage" if session.is_group else "FriendMessage"
|
message_type = "GroupMessage" if session.is_group else "FriendMessage"
|
||||||
@@ -632,8 +633,70 @@ class ChatRoute(Route):
|
|||||||
# 删除会话
|
# 删除会话
|
||||||
await self.db.delete_platform_session(session_id)
|
await self.db.delete_platform_session(session_id)
|
||||||
|
|
||||||
|
async def delete_webchat_session(self):
|
||||||
|
"""Delete a Platform session and all its related data."""
|
||||||
|
session_id = request.args.get("session_id")
|
||||||
|
if not session_id:
|
||||||
|
return Response().error("Missing key: session_id").__dict__
|
||||||
|
username = g.get("username", "guest")
|
||||||
|
|
||||||
|
session = await self.db.get_platform_session_by_id(session_id)
|
||||||
|
if not session:
|
||||||
|
return Response().error(f"Session {session_id} not found").__dict__
|
||||||
|
if session.creator != username:
|
||||||
|
return Response().error("Permission denied").__dict__
|
||||||
|
|
||||||
|
await self._delete_session_internal(session, username)
|
||||||
|
|
||||||
return Response().ok().__dict__
|
return Response().ok().__dict__
|
||||||
|
|
||||||
|
async def batch_delete_sessions(self):
|
||||||
|
"""Batch delete multiple Platform sessions."""
|
||||||
|
post_data = await request.json
|
||||||
|
if post_data is None:
|
||||||
|
return Response().error("Missing JSON body").__dict__
|
||||||
|
if not isinstance(post_data, dict):
|
||||||
|
return Response().error("Invalid JSON body: expected object").__dict__
|
||||||
|
|
||||||
|
session_ids = post_data.get("session_ids")
|
||||||
|
if not session_ids or not isinstance(session_ids, list):
|
||||||
|
return Response().error("Missing or invalid key: session_ids").__dict__
|
||||||
|
|
||||||
|
username = g.get("username", "guest")
|
||||||
|
sessions = await self.db.get_platform_sessions_by_ids(session_ids)
|
||||||
|
sessions_by_id = {session.session_id: session for session in sessions}
|
||||||
|
deleted_count = 0
|
||||||
|
failed_items = []
|
||||||
|
|
||||||
|
for sid in session_ids:
|
||||||
|
session = sessions_by_id.get(sid)
|
||||||
|
if not session:
|
||||||
|
failed_items.append({"session_id": sid, "reason": "not found"})
|
||||||
|
continue
|
||||||
|
if session.creator != username:
|
||||||
|
failed_items.append({"session_id": sid, "reason": "permission denied"})
|
||||||
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
|
await self._delete_session_internal(session, username)
|
||||||
|
deleted_count += 1
|
||||||
|
sessions_by_id.pop(sid, None)
|
||||||
|
except Exception:
|
||||||
|
logger.warning("Failed to delete session %s", sid)
|
||||||
|
failed_items.append({"session_id": sid, "reason": "internal_error"})
|
||||||
|
|
||||||
|
return (
|
||||||
|
Response()
|
||||||
|
.ok(
|
||||||
|
data={
|
||||||
|
"deleted_count": deleted_count,
|
||||||
|
"failed_count": len(failed_items),
|
||||||
|
"failed_items": failed_items,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.__dict__
|
||||||
|
)
|
||||||
|
|
||||||
def _extract_attachment_ids(self, history_list) -> list[str]:
|
def _extract_attachment_ids(self, history_list) -> list[str]:
|
||||||
"""从消息历史中提取所有 attachment_id"""
|
"""从消息历史中提取所有 attachment_id"""
|
||||||
attachment_ids = []
|
attachment_ids = []
|
||||||
|
|||||||
@@ -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
|
- DEFAULT_SHIP_MEMORY=512m
|
||||||
volumes:
|
volumes:
|
||||||
- ${PWD}/data/shipyard/bay_data:/app/data
|
- ${PWD}/data/shipyard/bay_data:/app/data
|
||||||
|
- ${PWD}/data/temp:/AstrBot/data/temp # Bind the local temp directory to the sandbox so that the uploaded file can be accessed in the sandbox
|
||||||
- /var/run/docker.sock:/var/run/docker.sock:ro
|
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||||
networks:
|
networks:
|
||||||
- astrbot_network
|
- astrbot_network
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
version: '3.8'
|
|
||||||
|
|
||||||
# 当接入 QQ NapCat 时,请使用这个 compose 文件一键部署: https://github.com/NapNeko/NapCat-Docker/blob/main/compose/astrbot.yml
|
# 当接入 QQ NapCat 时,请使用这个 compose 文件一键部署: https://github.com/NapNeko/NapCat-Docker/blob/main/compose/astrbot.yml
|
||||||
|
|
||||||
services:
|
services:
|
||||||
|
|||||||
+13
-8
@@ -17,17 +17,17 @@
|
|||||||
"@tiptap/starter-kit": "2.1.7",
|
"@tiptap/starter-kit": "2.1.7",
|
||||||
"@tiptap/vue-3": "2.1.7",
|
"@tiptap/vue-3": "2.1.7",
|
||||||
"apexcharts": "3.42.0",
|
"apexcharts": "3.42.0",
|
||||||
"axios": ">=1.6.2 <1.10.0 || >1.10.0 <2.0.0",
|
"axios": "1.13.5",
|
||||||
"axios-mock-adapter": "^1.22.0",
|
"axios-mock-adapter": "^1.22.0",
|
||||||
"chance": "1.1.11",
|
"chance": "1.1.11",
|
||||||
"date-fns": "2.30.0",
|
"date-fns": "2.30.0",
|
||||||
"dompurify": "^3.3.1",
|
"dompurify": "^3.3.2",
|
||||||
"event-source-polyfill": "^1.0.31",
|
"event-source-polyfill": "^1.0.31",
|
||||||
"highlight.js": "^11.11.1",
|
"highlight.js": "^11.11.1",
|
||||||
"js-md5": "^0.8.3",
|
"js-md5": "^0.8.3",
|
||||||
"katex": "^0.16.27",
|
"katex": "^0.16.27",
|
||||||
"lodash": "4.17.21",
|
"lodash": "4.17.23",
|
||||||
"markdown-it": "^14.1.0",
|
"markdown-it": "^14.1.1",
|
||||||
"markstream-vue": "^0.0.6",
|
"markstream-vue": "^0.0.6",
|
||||||
"mermaid": "^11.12.2",
|
"mermaid": "^11.12.2",
|
||||||
"monaco-editor": "^0.52.2",
|
"monaco-editor": "^0.52.2",
|
||||||
@@ -36,9 +36,8 @@
|
|||||||
"remixicon": "3.5.0",
|
"remixicon": "3.5.0",
|
||||||
"shiki": "^3.20.0",
|
"shiki": "^3.20.0",
|
||||||
"stream-markdown": "^0.0.13",
|
"stream-markdown": "^0.0.13",
|
||||||
"stream-monaco": "^0.0.17",
|
|
||||||
"vee-validate": "4.11.3",
|
"vee-validate": "4.11.3",
|
||||||
"vite-plugin-vuetify": "1.0.2",
|
"vite-plugin-vuetify": "2.1.3",
|
||||||
"vue": "3.3.4",
|
"vue": "3.3.4",
|
||||||
"vue-i18n": "^11.1.5",
|
"vue-i18n": "^11.1.5",
|
||||||
"vue-router": "4.2.4",
|
"vue-router": "4.2.4",
|
||||||
@@ -54,7 +53,7 @@
|
|||||||
"@types/dompurify": "^3.0.5",
|
"@types/dompurify": "^3.0.5",
|
||||||
"@types/markdown-it": "^14.1.2",
|
"@types/markdown-it": "^14.1.2",
|
||||||
"@types/node": "^20.5.7",
|
"@types/node": "^20.5.7",
|
||||||
"@vitejs/plugin-vue": "4.3.3",
|
"@vitejs/plugin-vue": "5.2.4",
|
||||||
"@vue/eslint-config-prettier": "8.0.0",
|
"@vue/eslint-config-prettier": "8.0.0",
|
||||||
"@vue/eslint-config-typescript": "11.0.3",
|
"@vue/eslint-config-typescript": "11.0.3",
|
||||||
"@vue/tsconfig": "^0.4.0",
|
"@vue/tsconfig": "^0.4.0",
|
||||||
@@ -64,9 +63,15 @@
|
|||||||
"sass": "1.66.1",
|
"sass": "1.66.1",
|
||||||
"sass-loader": "13.3.2",
|
"sass-loader": "13.3.2",
|
||||||
"typescript": "5.1.6",
|
"typescript": "5.1.6",
|
||||||
"vite": "4.4.9",
|
"vite": "6.4.1",
|
||||||
"vue-cli-plugin-vuetify": "2.5.8",
|
"vue-cli-plugin-vuetify": "2.5.8",
|
||||||
"vue-tsc": "1.8.8",
|
"vue-tsc": "1.8.8",
|
||||||
"vuetify-loader": "^2.0.0-alpha.9"
|
"vuetify-loader": "^2.0.0-alpha.9"
|
||||||
|
},
|
||||||
|
"pnpm": {
|
||||||
|
"overrides": {
|
||||||
|
"immutable": "4.3.8",
|
||||||
|
"lodash-es": "4.17.23"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Generated
+601
-271
File diff suppressed because it is too large
Load Diff
@@ -11,6 +11,7 @@
|
|||||||
:currSessionId="currSessionId"
|
:currSessionId="currSessionId"
|
||||||
:selectedProjectId="selectedProjectId"
|
:selectedProjectId="selectedProjectId"
|
||||||
:transportMode="transportMode"
|
:transportMode="transportMode"
|
||||||
|
:sendShortcut="sendShortcut"
|
||||||
:isDark="isDark"
|
:isDark="isDark"
|
||||||
:chatboxMode="chatboxMode"
|
:chatboxMode="chatboxMode"
|
||||||
:isMobile="isMobile"
|
:isMobile="isMobile"
|
||||||
@@ -20,6 +21,7 @@
|
|||||||
@selectConversation="handleSelectConversation"
|
@selectConversation="handleSelectConversation"
|
||||||
@editTitle="showEditTitleDialog"
|
@editTitle="showEditTitleDialog"
|
||||||
@deleteConversation="handleDeleteConversation"
|
@deleteConversation="handleDeleteConversation"
|
||||||
|
@batchDeleteConversations="handleBatchDeleteConversations"
|
||||||
@closeMobileSidebar="closeMobileSidebar"
|
@closeMobileSidebar="closeMobileSidebar"
|
||||||
@toggleTheme="toggleTheme"
|
@toggleTheme="toggleTheme"
|
||||||
@toggleFullscreen="toggleFullscreen"
|
@toggleFullscreen="toggleFullscreen"
|
||||||
@@ -28,6 +30,7 @@
|
|||||||
@editProject="showEditProjectDialog"
|
@editProject="showEditProjectDialog"
|
||||||
@deleteProject="handleDeleteProject"
|
@deleteProject="handleDeleteProject"
|
||||||
@updateTransportMode="setTransportMode"
|
@updateTransportMode="setTransportMode"
|
||||||
|
@updateSendShortcut="setSendShortcut"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<!-- 右侧聊天内容区域 -->
|
<!-- 右侧聊天内容区域 -->
|
||||||
@@ -71,13 +74,14 @@
|
|||||||
:stagedImagesUrl="stagedImagesUrl"
|
:stagedImagesUrl="stagedImagesUrl"
|
||||||
:stagedAudioUrl="stagedAudioUrl"
|
:stagedAudioUrl="stagedAudioUrl"
|
||||||
:stagedFiles="stagedNonImageFiles"
|
:stagedFiles="stagedNonImageFiles"
|
||||||
:disabled="isStreaming"
|
:disabled="false"
|
||||||
:is-running="isStreaming || isConvRunning"
|
:is-running="isStreaming || isConvRunning"
|
||||||
:enableStreaming="enableStreaming"
|
:enableStreaming="enableStreaming"
|
||||||
:isRecording="isRecording"
|
:isRecording="isRecording"
|
||||||
:session-id="currSessionId || null"
|
:session-id="currSessionId || null"
|
||||||
:current-session="getCurrentSession"
|
:current-session="getCurrentSession"
|
||||||
:replyTo="replyTo"
|
:replyTo="replyTo"
|
||||||
|
:send-shortcut="sendShortcut"
|
||||||
@send="handleSendMessage"
|
@send="handleSendMessage"
|
||||||
@stop="handleStopMessage"
|
@stop="handleStopMessage"
|
||||||
@toggleStreaming="toggleStreaming"
|
@toggleStreaming="toggleStreaming"
|
||||||
@@ -102,13 +106,14 @@
|
|||||||
:stagedImagesUrl="stagedImagesUrl"
|
:stagedImagesUrl="stagedImagesUrl"
|
||||||
:stagedAudioUrl="stagedAudioUrl"
|
:stagedAudioUrl="stagedAudioUrl"
|
||||||
:stagedFiles="stagedNonImageFiles"
|
:stagedFiles="stagedNonImageFiles"
|
||||||
:disabled="isStreaming"
|
:disabled="false"
|
||||||
:is-running="isStreaming || isConvRunning"
|
:is-running="isStreaming || isConvRunning"
|
||||||
:enableStreaming="enableStreaming"
|
:enableStreaming="enableStreaming"
|
||||||
:isRecording="isRecording"
|
:isRecording="isRecording"
|
||||||
:session-id="currSessionId || null"
|
:session-id="currSessionId || null"
|
||||||
:current-session="getCurrentSession"
|
:current-session="getCurrentSession"
|
||||||
:replyTo="replyTo"
|
:replyTo="replyTo"
|
||||||
|
:send-shortcut="sendShortcut"
|
||||||
@send="handleSendMessage"
|
@send="handleSendMessage"
|
||||||
@stop="handleStopMessage"
|
@stop="handleStopMessage"
|
||||||
@toggleStreaming="toggleStreaming"
|
@toggleStreaming="toggleStreaming"
|
||||||
@@ -132,13 +137,14 @@
|
|||||||
:stagedImagesUrl="stagedImagesUrl"
|
:stagedImagesUrl="stagedImagesUrl"
|
||||||
:stagedAudioUrl="stagedAudioUrl"
|
:stagedAudioUrl="stagedAudioUrl"
|
||||||
:stagedFiles="stagedNonImageFiles"
|
:stagedFiles="stagedNonImageFiles"
|
||||||
:disabled="isStreaming"
|
:disabled="false"
|
||||||
:is-running="isStreaming || isConvRunning"
|
:is-running="isStreaming || isConvRunning"
|
||||||
:enableStreaming="enableStreaming"
|
:enableStreaming="enableStreaming"
|
||||||
:isRecording="isRecording"
|
:isRecording="isRecording"
|
||||||
:session-id="currSessionId || null"
|
:session-id="currSessionId || null"
|
||||||
:current-session="getCurrentSession"
|
:current-session="getCurrentSession"
|
||||||
:replyTo="replyTo"
|
:replyTo="replyTo"
|
||||||
|
:send-shortcut="sendShortcut"
|
||||||
@send="handleSendMessage"
|
@send="handleSendMessage"
|
||||||
@stop="handleStopMessage"
|
@stop="handleStopMessage"
|
||||||
@toggleStreaming="toggleStreaming"
|
@toggleStreaming="toggleStreaming"
|
||||||
@@ -220,10 +226,13 @@ import { useMediaHandling } from '@/composables/useMediaHandling';
|
|||||||
import { useProjects } from '@/composables/useProjects';
|
import { useProjects } from '@/composables/useProjects';
|
||||||
import type { Project } from '@/components/chat/ProjectList.vue';
|
import type { Project } from '@/components/chat/ProjectList.vue';
|
||||||
import { useRecording } from '@/composables/useRecording';
|
import { useRecording } from '@/composables/useRecording';
|
||||||
|
import { useToast } from '@/utils/toast';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
chatboxMode?: boolean;
|
chatboxMode?: boolean;
|
||||||
}
|
}
|
||||||
|
type SendShortcut = 'enter' | 'shift_enter';
|
||||||
|
const SEND_SHORTCUT_STORAGE_KEY = 'chat_send_shortcut';
|
||||||
|
|
||||||
const props = withDefaults(defineProps<Props>(), {
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
chatboxMode: false
|
chatboxMode: false
|
||||||
@@ -233,6 +242,7 @@ const router = useRouter();
|
|||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const { tm } = useModuleI18n('features/chat');
|
const { tm } = useModuleI18n('features/chat');
|
||||||
|
const { warning: toastWarning } = useToast();
|
||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
const customizer = useCustomizerStore();
|
const customizer = useCustomizerStore();
|
||||||
|
|
||||||
@@ -257,6 +267,7 @@ const {
|
|||||||
getSessions,
|
getSessions,
|
||||||
newSession,
|
newSession,
|
||||||
deleteSession: deleteSessionFn,
|
deleteSession: deleteSessionFn,
|
||||||
|
batchDeleteSessions,
|
||||||
showEditTitleDialog,
|
showEditTitleDialog,
|
||||||
saveTitle,
|
saveTitle,
|
||||||
updateSessionTitle,
|
updateSessionTitle,
|
||||||
@@ -330,6 +341,18 @@ interface ReplyInfo {
|
|||||||
const replyTo = ref<ReplyInfo | null>(null);
|
const replyTo = ref<ReplyInfo | null>(null);
|
||||||
|
|
||||||
const isDark = computed(() => useCustomizerStore().uiTheme === 'PurpleThemeDark');
|
const isDark = computed(() => useCustomizerStore().uiTheme === 'PurpleThemeDark');
|
||||||
|
const sendShortcut = ref<SendShortcut>('shift_enter');
|
||||||
|
|
||||||
|
function setSendShortcut(mode: SendShortcut) {
|
||||||
|
sendShortcut.value = mode;
|
||||||
|
localStorage.setItem(SEND_SHORTCUT_STORAGE_KEY, mode);
|
||||||
|
}
|
||||||
|
|
||||||
|
function focusChatInput() {
|
||||||
|
nextTick(() => {
|
||||||
|
chatInputRef.value?.focusInput?.();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// 检测是否为手机端
|
// 检测是否为手机端
|
||||||
function checkMobile() {
|
function checkMobile() {
|
||||||
@@ -488,6 +511,7 @@ async function handleSelectConversation(sessionIds: string[]) {
|
|||||||
nextTick(() => {
|
nextTick(() => {
|
||||||
messageList.value?.scrollToBottom();
|
messageList.value?.scrollToBottom();
|
||||||
});
|
});
|
||||||
|
focusChatInput();
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleNewChat() {
|
function handleNewChat() {
|
||||||
@@ -497,6 +521,7 @@ function handleNewChat() {
|
|||||||
// 退出项目视图
|
// 退出项目视图
|
||||||
selectedProjectId.value = null;
|
selectedProjectId.value = null;
|
||||||
projectSessions.value = [];
|
projectSessions.value = [];
|
||||||
|
focusChatInput();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleDeleteConversation(sessionId: string) {
|
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) {
|
async function handleSelectProject(projectId: string) {
|
||||||
selectedProjectId.value = projectId;
|
selectedProjectId.value = projectId;
|
||||||
const sessions = await getProjectSessions(projectId);
|
const sessions = await getProjectSessions(projectId);
|
||||||
@@ -627,6 +679,11 @@ async function handleSendMessage() {
|
|||||||
const selectedProviderId = selection?.providerId || '';
|
const selectedProviderId = selection?.providerId || '';
|
||||||
const selectedModelName = selection?.modelName || '';
|
const selectedModelName = selection?.modelName || '';
|
||||||
|
|
||||||
|
// 点击发送后立即将消息区滚到底部,确保用户看到最新消息
|
||||||
|
nextTick(() => {
|
||||||
|
messageList.value?.scrollToBottom();
|
||||||
|
});
|
||||||
|
|
||||||
await sendMsg(
|
await sendMsg(
|
||||||
promptToSend,
|
promptToSend,
|
||||||
filesToSend,
|
filesToSend,
|
||||||
@@ -636,6 +693,11 @@ async function handleSendMessage() {
|
|||||||
replyToSend
|
replyToSend
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// 发送流程结束后再兜底一次,处理异步渲染场景
|
||||||
|
nextTick(() => {
|
||||||
|
messageList.value?.scrollToBottom();
|
||||||
|
});
|
||||||
|
|
||||||
// 如果在项目中创建了新会话,将其添加到项目
|
// 如果在项目中创建了新会话,将其添加到项目
|
||||||
if (isCreatingNewSession && currentProjectId && currSessionId.value) {
|
if (isCreatingNewSession && currentProjectId && currSessionId.value) {
|
||||||
await addSessionToProject(currSessionId.value, currentProjectId);
|
await addSessionToProject(currSessionId.value, currentProjectId);
|
||||||
@@ -694,6 +756,10 @@ watch(sessions, (newSessions) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
|
const storedShortcut = localStorage.getItem(SEND_SHORTCUT_STORAGE_KEY);
|
||||||
|
if (storedShortcut === 'enter' || storedShortcut === 'shift_enter') {
|
||||||
|
sendShortcut.value = storedShortcut;
|
||||||
|
}
|
||||||
checkMobile();
|
checkMobile();
|
||||||
window.addEventListener('resize', checkMobile);
|
window.addEventListener('resize', checkMobile);
|
||||||
getSessions();
|
getSessions();
|
||||||
|
|||||||
@@ -15,7 +15,7 @@
|
|||||||
<transition name="fade">
|
<transition name="fade">
|
||||||
<div v-if="isDragging" class="drop-overlay">
|
<div v-if="isDragging" class="drop-overlay">
|
||||||
<div class="drop-overlay-content">
|
<div class="drop-overlay-content">
|
||||||
<v-icon size="48" color="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>
|
<span class="drop-text">{{ tm('input.dropToUpload') }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -41,7 +41,7 @@
|
|||||||
<!-- Settings Menu -->
|
<!-- Settings Menu -->
|
||||||
<StyledMenu offset="8" location="top start" :close-on-content-click="false">
|
<StyledMenu offset="8" location="top start" :close-on-content-click="false">
|
||||||
<template v-slot:activator="{ props: activatorProps }">
|
<template v-slot:activator="{ props: activatorProps }">
|
||||||
<v-btn v-bind="activatorProps" icon="mdi-plus" variant="text" color="deep-purple" />
|
<v-btn v-bind="activatorProps" icon="mdi-plus" variant="text" color="primary" />
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<!-- Upload Files -->
|
<!-- Upload Files -->
|
||||||
@@ -87,7 +87,7 @@
|
|||||||
{{ tm('voice.liveMode') }}
|
{{ tm('voice.liveMode') }}
|
||||||
</v-tooltip>
|
</v-tooltip>
|
||||||
</v-btn> -->
|
</v-btn> -->
|
||||||
<v-btn @click="handleRecordClick" icon variant="text" :color="isRecording ? 'error' : 'deep-purple'"
|
<v-btn @click="handleRecordClick" icon variant="text" :color="isRecording ? 'error' : 'primary'"
|
||||||
class="record-btn">
|
class="record-btn">
|
||||||
<v-icon :icon="isRecording ? 'mdi-stop-circle' : 'mdi-microphone'" variant="text"
|
<v-icon :icon="isRecording ? 'mdi-stop-circle' : 'mdi-microphone'" variant="text"
|
||||||
plain></v-icon>
|
plain></v-icon>
|
||||||
@@ -95,13 +95,13 @@
|
|||||||
{{ isRecording ? tm('voice.speaking') : tm('voice.startRecording') }}
|
{{ isRecording ? tm('voice.speaking') : tm('voice.startRecording') }}
|
||||||
</v-tooltip>
|
</v-tooltip>
|
||||||
</v-btn>
|
</v-btn>
|
||||||
<v-btn icon v-if="isRunning" @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-icon icon="mdi-stop" variant="text" plain></v-icon>
|
||||||
<v-tooltip activator="parent" location="top">
|
<v-tooltip activator="parent" location="top">
|
||||||
{{ tm('input.stopGenerating') }}
|
{{ tm('input.stopGenerating') }}
|
||||||
</v-tooltip>
|
</v-tooltip>
|
||||||
</v-btn>
|
</v-btn>
|
||||||
<v-btn v-else @click="$emit('send')" icon="mdi-send" variant="tonal" color="deep-purple"
|
<v-btn v-else @click="$emit('send')" icon="mdi-send" variant="tonal" color="primary"
|
||||||
:disabled="!canSend" class="send-btn" />
|
:disabled="!canSend" class="send-btn" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -117,7 +117,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="stagedAudioUrl" class="audio-preview">
|
<div v-if="stagedAudioUrl" class="audio-preview">
|
||||||
<v-chip color="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>
|
<v-icon start icon="mdi-microphone" size="small"></v-icon>
|
||||||
{{ tm('voice.recording') }}
|
{{ tm('voice.recording') }}
|
||||||
</v-chip>
|
</v-chip>
|
||||||
@@ -126,7 +126,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-for="(file, index) in stagedFiles" :key="'file-' + index" class="file-preview">
|
<div v-for="(file, index) in stagedFiles" :key="'file-' + index" class="file-preview">
|
||||||
<v-chip color="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>
|
<v-icon start icon="mdi-file-document-outline" size="small"></v-icon>
|
||||||
<span class="file-name-preview">{{ file.original_name }}</span>
|
<span class="file-name-preview">{{ file.original_name }}</span>
|
||||||
</v-chip>
|
</v-chip>
|
||||||
@@ -173,6 +173,7 @@ interface Props {
|
|||||||
currentSession?: Session | null;
|
currentSession?: Session | null;
|
||||||
configId?: string | null;
|
configId?: string | null;
|
||||||
replyTo?: ReplyInfo | null;
|
replyTo?: ReplyInfo | null;
|
||||||
|
sendShortcut?: 'enter' | 'shift_enter';
|
||||||
}
|
}
|
||||||
|
|
||||||
const props = withDefaults(defineProps<Props>(), {
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
@@ -180,7 +181,8 @@ const props = withDefaults(defineProps<Props>(), {
|
|||||||
currentSession: null,
|
currentSession: null,
|
||||||
configId: null,
|
configId: null,
|
||||||
stagedFiles: () => [],
|
stagedFiles: () => [],
|
||||||
replyTo: null
|
replyTo: null,
|
||||||
|
sendShortcut: 'shift_enter'
|
||||||
});
|
});
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
@@ -253,9 +255,29 @@ watch(localPrompt, () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
function handleKeyDown(e: KeyboardEvent) {
|
function handleKeyDown(e: KeyboardEvent) {
|
||||||
// Enter 插入换行(桌面和手机端均如此,发送通过右下角发送按鈕)
|
const isEnter = e.key === 'Enter';
|
||||||
// Shift+Enter 发送(Ctrl+Enter / Cmd+Enter 也保留)
|
if (!isEnter) {
|
||||||
if (e.keyCode === 13 && (e.shiftKey || e.ctrlKey || e.metaKey)) {
|
// 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();
|
e.preventDefault();
|
||||||
if (localPrompt.value.trim() === '/astr_live_dev') {
|
if (localPrompt.value.trim() === '/astr_live_dev') {
|
||||||
emit('openLiveMode');
|
emit('openLiveMode');
|
||||||
@@ -267,19 +289,6 @@ function handleKeyDown(e: KeyboardEvent) {
|
|||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ctrl+B 录音
|
|
||||||
if (e.ctrlKey && e.keyCode === 66) {
|
|
||||||
e.preventDefault();
|
|
||||||
if (ctrlKeyDown.value) return;
|
|
||||||
|
|
||||||
ctrlKeyDown.value = true;
|
|
||||||
ctrlKeyTimer.value = window.setTimeout(() => {
|
|
||||||
if (ctrlKeyDown.value && !props.isRecording) {
|
|
||||||
emit('startRecording');
|
|
||||||
}
|
|
||||||
}, ctrlKeyLongPressThreshold);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleKeyUp(e: KeyboardEvent) {
|
function handleKeyUp(e: KeyboardEvent) {
|
||||||
@@ -364,6 +373,11 @@ function getCurrentSelection() {
|
|||||||
return providerModelMenuRef.value?.getCurrentSelection();
|
return providerModelMenuRef.value?.getCurrentSelection();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function focusInput() {
|
||||||
|
if (!inputField.value) return;
|
||||||
|
inputField.value.focus();
|
||||||
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
if (inputField.value) {
|
if (inputField.value) {
|
||||||
inputField.value.addEventListener('paste', handlePaste);
|
inputField.value.addEventListener('paste', handlePaste);
|
||||||
@@ -379,7 +393,8 @@ onBeforeUnmount(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
defineExpose({
|
defineExpose({
|
||||||
getCurrentSelection
|
getCurrentSelection,
|
||||||
|
focusInput
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -399,8 +414,8 @@ defineExpose({
|
|||||||
left: 0;
|
left: 0;
|
||||||
right: 0;
|
right: 0;
|
||||||
bottom: 0;
|
bottom: 0;
|
||||||
background-color: rgba(103, 58, 183, 0.15);
|
background-color: rgba(var(--v-theme-primary), 0.12);
|
||||||
border: 2px dashed rgba(103, 58, 183, 0.5);
|
border: 2px dashed rgba(var(--v-theme-primary), 0.45);
|
||||||
border-radius: 24px;
|
border-radius: 24px;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -419,7 +434,7 @@ defineExpose({
|
|||||||
.drop-text {
|
.drop-text {
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
color: #673ab7;
|
color: rgb(var(--v-theme-primary));
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Fade transition for drop overlay */
|
/* Fade transition for drop overlay */
|
||||||
@@ -439,7 +454,7 @@ defineExpose({
|
|||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
padding: 8px 16px;
|
padding: 8px 16px;
|
||||||
margin: 8px 8px 0 8px;
|
margin: 8px 8px 0 8px;
|
||||||
background-color: rgba(103, 58, 183, 0.06);
|
background-color: rgba(var(--v-theme-primary), 0.06);
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
max-height: 500px;
|
max-height: 500px;
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
'mobile-sidebar-open': isMobile && mobileMenuOpen,
|
'mobile-sidebar-open': isMobile && mobileMenuOpen,
|
||||||
'mobile-sidebar': isMobile
|
'mobile-sidebar': isMobile
|
||||||
}"
|
}"
|
||||||
:style="{ '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">
|
<div class="sidebar-collapse-btn-container" v-if="!isMobile">
|
||||||
<v-btn icon class="sidebar-collapse-btn" @click="toggleSidebar" variant="text" color="deep-purple">
|
<v-btn icon class="sidebar-collapse-btn" @click="toggleSidebar" variant="text" color="deep-purple">
|
||||||
@@ -21,12 +21,31 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style="padding: 8px; opacity: 0.6;">
|
<div style="padding: 8px; opacity: 0.6;">
|
||||||
<v-btn block variant="text" class="new-chat-btn" @click="$emit('newChat')" :disabled="!currSessionId && !selectedProjectId"
|
<div class="new-chat-row" v-if="!sidebarCollapsed || isMobile">
|
||||||
v-if="!sidebarCollapsed || isMobile" prepend-icon="mdi-square-edit-outline">{{ tm('actions.newChat') }}</v-btn>
|
<v-btn block variant="text" class="new-chat-btn" @click="$emit('newChat')" :disabled="!currSessionId && !selectedProjectId"
|
||||||
<v-btn icon="mdi-square-edit-outline" rounded="xl" @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>
|
v-if="sidebarCollapsed && !isMobile" elevation="0"></v-btn>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Batch action bar -->
|
||||||
|
<div v-if="batchMode && (!sidebarCollapsed || isMobile)" class="batch-action-bar">
|
||||||
|
<v-btn size="x-small" variant="text" @click="toggleSelectAll">
|
||||||
|
{{ isAllSelected ? tm('batch.deselectAll') : tm('batch.selectAll') }}
|
||||||
|
</v-btn>
|
||||||
|
<span class="batch-selected-count">{{ tm('batch.selected', { count: batchSelected.length }) }}</span>
|
||||||
|
<v-spacer />
|
||||||
|
<v-btn size="x-small" variant="text" color="error" :disabled="batchSelected.length === 0"
|
||||||
|
@click="handleBatchDelete">
|
||||||
|
{{ tm('batch.delete') }}
|
||||||
|
</v-btn>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- 项目列表组件 -->
|
<!-- 项目列表组件 -->
|
||||||
<ProjectList
|
<ProjectList
|
||||||
v-if="!sidebarCollapsed || isMobile"
|
v-if="!sidebarCollapsed || isMobile"
|
||||||
@@ -41,19 +60,34 @@
|
|||||||
v-if="!sidebarCollapsed || isMobile">
|
v-if="!sidebarCollapsed || isMobile">
|
||||||
<v-card v-if="sessions.length > 0" flat style="background-color: transparent;">
|
<v-card v-if="sessions.length > 0" flat style="background-color: transparent;">
|
||||||
<v-list density="compact" nav class="conversation-list"
|
<v-list density="compact" nav class="conversation-list"
|
||||||
style="background-color: transparent;" :selected="selectedSessions"
|
style="background-color: transparent;" :selected="batchMode ? [] : selectedSessions"
|
||||||
@update:selected="$emit('selectConversation', $event)">
|
@update:selected="handleListSelect">
|
||||||
<v-list-item v-for="item in sessions" :key="item.session_id" :value="item.session_id"
|
<v-list-item v-for="item in sessions" :key="item.session_id" :value="item.session_id"
|
||||||
rounded="lg" class="conversation-item" active-color="secondary">
|
rounded="lg" class="conversation-item" active-color="secondary"
|
||||||
|
@click="batchMode ? toggleBatchItem(item.session_id) : undefined">
|
||||||
|
|
||||||
|
<template v-slot:prepend>
|
||||||
|
<div class="batch-checkbox-slot" :class="{ 'batch-checkbox-slot--active': batchMode }">
|
||||||
|
<v-checkbox-btn
|
||||||
|
:model-value="batchSelected.includes(item.session_id)"
|
||||||
|
@update:model-value="toggleBatchItem(item.session_id)"
|
||||||
|
@click.stop
|
||||||
|
density="compact"
|
||||||
|
hide-details
|
||||||
|
class="batch-checkbox"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
<v-list-item-title v-if="!sidebarCollapsed || isMobile" class="conversation-title"
|
<v-list-item-title v-if="!sidebarCollapsed || isMobile" class="conversation-title"
|
||||||
:style="{ color: isDark ? '#ffffff' : '#000000' }">
|
:style="{ color: 'rgb(var(--v-theme-primaryText))' }">
|
||||||
{{ item.display_name || tm('conversation.newConversation') }}
|
{{ item.display_name || tm('conversation.newConversation') }}
|
||||||
</v-list-item-title>
|
</v-list-item-title>
|
||||||
<!-- <v-list-item-subtitle v-if="!sidebarCollapsed || isMobile" class="timestamp">
|
<!-- <v-list-item-subtitle v-if="!sidebarCollapsed || isMobile" class="timestamp">
|
||||||
{{ new Date(item.updated_at).toLocaleString() }}
|
{{ new Date(item.updated_at).toLocaleString() }}
|
||||||
</v-list-item-subtitle> -->
|
</v-list-item-subtitle> -->
|
||||||
|
|
||||||
<template v-if="!sidebarCollapsed || isMobile" v-slot:append>
|
<template v-if="!batchMode && (!sidebarCollapsed || isMobile)" v-slot:append>
|
||||||
<div class="conversation-actions">
|
<div class="conversation-actions">
|
||||||
<v-btn icon="mdi-pencil" size="x-small" variant="text"
|
<v-btn icon="mdi-pencil" size="x-small" variant="text"
|
||||||
class="edit-title-btn"
|
class="edit-title-btn"
|
||||||
@@ -98,16 +132,52 @@
|
|||||||
</v-btn>
|
</v-btn>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<!-- 语言切换 -->
|
<!-- 语言切换(分组) -->
|
||||||
<v-list-item class="styled-menu-item">
|
<v-menu
|
||||||
<template v-slot:prepend>
|
:open-on-hover="!isMobile"
|
||||||
<v-icon>mdi-translate</v-icon>
|
: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>
|
</template>
|
||||||
<v-list-item-title>{{ t('core.common.language') }}</v-list-item-title>
|
|
||||||
<template v-slot:append>
|
<v-card class="styled-menu-card" style="min-width: 180px;" elevation="8" rounded="lg">
|
||||||
<LanguageSwitcher variant="chatbox" />
|
<v-list density="compact" class="styled-menu-list pa-1">
|
||||||
</template>
|
<v-list-item
|
||||||
</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')">
|
<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-title>{{ isDark ? tm('modes.lightMode') : tm('modes.darkMode') }}</v-list-item-title>
|
||||||
</v-list-item>
|
</v-list-item>
|
||||||
|
|
||||||
<!-- 通信传输模式 -->
|
<!-- 通信传输模式(分组) -->
|
||||||
<v-list-item class="styled-menu-item">
|
<v-menu
|
||||||
<template v-slot:prepend>
|
:open-on-hover="!isMobile"
|
||||||
<v-icon>mdi-lan-connect</v-icon>
|
: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>
|
</template>
|
||||||
<v-list-item-title>{{ tm('transport.title') }}</v-list-item-title>
|
|
||||||
<template v-slot:append>
|
<v-card class="styled-menu-card" style="min-width: 220px;" elevation="8" rounded="lg">
|
||||||
<v-select
|
<v-list density="compact" class="styled-menu-list pa-1">
|
||||||
:model-value="transportMode"
|
<v-list-item
|
||||||
:items="transportOptions"
|
v-for="opt in transportOptions"
|
||||||
item-title="label"
|
:key="opt.value"
|
||||||
item-value="value"
|
:value="opt.value"
|
||||||
density="compact"
|
@click="handleTransportModeChange(opt.value)"
|
||||||
variant="underlined"
|
:class="{ 'styled-menu-item-active': transportMode === opt.value }"
|
||||||
hide-details
|
class="styled-menu-item"
|
||||||
class="transport-mode-select"
|
rounded="md"
|
||||||
@update:model-value="handleTransportModeChange"
|
>
|
||||||
/>
|
<v-list-item-title>{{ opt.label }}</v-list-item-title>
|
||||||
|
</v-list-item>
|
||||||
|
</v-list>
|
||||||
|
</v-card>
|
||||||
|
</v-menu>
|
||||||
|
|
||||||
|
<!-- 发送快捷键(分组) -->
|
||||||
|
<v-menu
|
||||||
|
:open-on-hover="!isMobile"
|
||||||
|
:open-on-click="isMobile"
|
||||||
|
:open-delay="!isMobile ? 60 : 0"
|
||||||
|
:close-delay="!isMobile ? 120 : 0"
|
||||||
|
:location="isMobile ? 'bottom' : 'end center'"
|
||||||
|
offset="8"
|
||||||
|
close-on-content-click
|
||||||
|
>
|
||||||
|
<template v-slot:activator="{ props: sendShortcutMenuProps }">
|
||||||
|
<v-list-item
|
||||||
|
v-bind="sendShortcutMenuProps"
|
||||||
|
class="styled-menu-item chat-settings-group-trigger"
|
||||||
|
rounded="md"
|
||||||
|
>
|
||||||
|
<template v-slot:prepend>
|
||||||
|
<v-icon>mdi-keyboard-outline</v-icon>
|
||||||
|
</template>
|
||||||
|
<v-list-item-title>{{ tm('shortcuts.sendKey.title') }}</v-list-item-title>
|
||||||
|
<template v-slot:append>
|
||||||
|
<span class="chat-settings-group-current chat-settings-transport-current">{{ currentSendShortcutLabel }}</span>
|
||||||
|
<v-icon size="18" class="chat-settings-group-arrow">mdi-chevron-right</v-icon>
|
||||||
|
</template>
|
||||||
|
</v-list-item>
|
||||||
</template>
|
</template>
|
||||||
</v-list-item>
|
|
||||||
|
<v-card class="styled-menu-card" style="min-width: 220px;" elevation="8" rounded="lg">
|
||||||
|
<v-list density="compact" class="styled-menu-list pa-1">
|
||||||
|
<v-list-item
|
||||||
|
v-for="opt in sendShortcutOptions"
|
||||||
|
:key="opt.value"
|
||||||
|
:value="opt.value"
|
||||||
|
@click="handleSendShortcutChange(opt.value)"
|
||||||
|
:class="{ 'styled-menu-item-active': props.sendShortcut === opt.value }"
|
||||||
|
class="styled-menu-item"
|
||||||
|
rounded="md"
|
||||||
|
>
|
||||||
|
<v-list-item-title>{{ opt.label }}</v-list-item-title>
|
||||||
|
</v-list-item>
|
||||||
|
</v-list>
|
||||||
|
</v-card>
|
||||||
|
</v-menu>
|
||||||
|
|
||||||
<!-- 全屏/退出全屏 -->
|
<!-- 全屏/退出全屏 -->
|
||||||
<v-list-item class="styled-menu-item" @click="$emit('toggleFullscreen')">
|
<v-list-item class="styled-menu-item" @click="$emit('toggleFullscreen')">
|
||||||
@@ -162,15 +299,16 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref } from 'vue';
|
import { ref, computed } from 'vue';
|
||||||
import { useI18n, useModuleI18n } from '@/i18n/composables';
|
import { useI18n, useModuleI18n } from '@/i18n/composables';
|
||||||
import type { Session } from '@/composables/useSessions';
|
import type { Session } from '@/composables/useSessions';
|
||||||
import { askForConfirmation, useConfirmDialog } from '@/utils/confirmDialog';
|
import { askForConfirmation, useConfirmDialog } from '@/utils/confirmDialog';
|
||||||
import LanguageSwitcher from '@/components/shared/LanguageSwitcher.vue';
|
|
||||||
import StyledMenu from '@/components/shared/StyledMenu.vue';
|
import StyledMenu from '@/components/shared/StyledMenu.vue';
|
||||||
import ProviderConfigDialog from '@/components/chat/ProviderConfigDialog.vue';
|
import ProviderConfigDialog from '@/components/chat/ProviderConfigDialog.vue';
|
||||||
import ProjectList from '@/components/chat/ProjectList.vue';
|
import ProjectList from '@/components/chat/ProjectList.vue';
|
||||||
import type { Project } from '@/components/chat/ProjectList.vue';
|
import type { Project } from '@/components/chat/ProjectList.vue';
|
||||||
|
import { useLanguageSwitcher } from '@/i18n/composables';
|
||||||
|
import type { Locale } from '@/i18n/types';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
sessions: Session[];
|
sessions: Session[];
|
||||||
@@ -183,6 +321,7 @@ interface Props {
|
|||||||
isMobile: boolean;
|
isMobile: boolean;
|
||||||
mobileMenuOpen: boolean;
|
mobileMenuOpen: boolean;
|
||||||
projects?: Project[];
|
projects?: Project[];
|
||||||
|
sendShortcut: 'enter' | 'shift_enter';
|
||||||
}
|
}
|
||||||
|
|
||||||
const props = withDefaults(defineProps<Props>(), {
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
@@ -194,6 +333,7 @@ const emit = defineEmits<{
|
|||||||
selectConversation: [sessionIds: string[]];
|
selectConversation: [sessionIds: string[]];
|
||||||
editTitle: [sessionId: string, title: string];
|
editTitle: [sessionId: string, title: string];
|
||||||
deleteConversation: [sessionId: string];
|
deleteConversation: [sessionId: string];
|
||||||
|
batchDeleteConversations: [sessionIds: string[]];
|
||||||
closeMobileSidebar: [];
|
closeMobileSidebar: [];
|
||||||
toggleTheme: [];
|
toggleTheme: [];
|
||||||
toggleFullscreen: [];
|
toggleFullscreen: [];
|
||||||
@@ -202,6 +342,7 @@ const emit = defineEmits<{
|
|||||||
editProject: [project: Project];
|
editProject: [project: Project];
|
||||||
deleteProject: [projectId: string];
|
deleteProject: [projectId: string];
|
||||||
updateTransportMode: [mode: 'sse' | 'websocket'];
|
updateTransportMode: [mode: 'sse' | 'websocket'];
|
||||||
|
updateSendShortcut: [mode: 'enter' | 'shift_enter'];
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
@@ -211,10 +352,84 @@ const confirmDialog = useConfirmDialog();
|
|||||||
|
|
||||||
const sidebarCollapsed = ref(true);
|
const sidebarCollapsed = ref(true);
|
||||||
const showProviderConfigDialog = ref(false);
|
const showProviderConfigDialog = ref(false);
|
||||||
|
|
||||||
|
// Batch mode state
|
||||||
|
const batchMode = ref(false);
|
||||||
|
const batchSelected = ref<string[]>([]);
|
||||||
|
|
||||||
|
const isAllSelected = computed(() =>
|
||||||
|
props.sessions.length > 0 && batchSelected.value.length === props.sessions.length
|
||||||
|
);
|
||||||
|
|
||||||
|
function toggleBatchMode() {
|
||||||
|
batchMode.value = !batchMode.value;
|
||||||
|
batchSelected.value = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleBatchItem(sessionId: string) {
|
||||||
|
const idx = batchSelected.value.indexOf(sessionId);
|
||||||
|
if (idx >= 0) {
|
||||||
|
batchSelected.value.splice(idx, 1);
|
||||||
|
} else {
|
||||||
|
batchSelected.value.push(sessionId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleSelectAll() {
|
||||||
|
if (isAllSelected.value) {
|
||||||
|
batchSelected.value = [];
|
||||||
|
} else {
|
||||||
|
batchSelected.value = props.sessions.map(s => s.session_id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleBatchDelete() {
|
||||||
|
const count = batchSelected.value.length;
|
||||||
|
if (count === 0) return;
|
||||||
|
const message = tm('batch.confirmDelete', { count });
|
||||||
|
if (await askForConfirmation(message, confirmDialog)) {
|
||||||
|
emit('batchDeleteConversations', [...batchSelected.value]);
|
||||||
|
batchSelected.value = [];
|
||||||
|
batchMode.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleListSelect(sessionIds: string[]) {
|
||||||
|
if (!batchMode.value) {
|
||||||
|
emit('selectConversation', sessionIds);
|
||||||
|
}
|
||||||
|
}
|
||||||
const transportOptions = [
|
const transportOptions = [
|
||||||
{ label: tm('transport.sse'), value: 'sse' as const },
|
{ label: tm('transport.sse'), value: 'sse' as const },
|
||||||
{ label: tm('transport.websocket'), value: 'websocket' as const }
|
{ label: tm('transport.websocket'), value: 'websocket' as const }
|
||||||
];
|
];
|
||||||
|
const sendShortcutOptions = [
|
||||||
|
{ label: tm('shortcuts.sendKey.enterToSend'), value: 'enter' as const },
|
||||||
|
{ label: tm('shortcuts.sendKey.shiftEnterToSend'), value: 'shift_enter' as const }
|
||||||
|
];
|
||||||
|
|
||||||
|
// Language switcher
|
||||||
|
const { languageOptions, currentLanguage, switchLanguage, locale } = useLanguageSwitcher();
|
||||||
|
const languages = computed(() =>
|
||||||
|
languageOptions.value.map(lang => ({
|
||||||
|
code: lang.value,
|
||||||
|
name: lang.label,
|
||||||
|
flag: lang.flag
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
const currentLocale = computed(() => locale.value);
|
||||||
|
const changeLanguage = async (langCode: string) => {
|
||||||
|
await switchLanguage(langCode as Locale);
|
||||||
|
};
|
||||||
|
|
||||||
|
const currentTransportLabel = computed(() => {
|
||||||
|
const found = transportOptions.find(opt => opt.value === props.transportMode);
|
||||||
|
return found?.label ?? '';
|
||||||
|
});
|
||||||
|
const currentSendShortcutLabel = computed(() => {
|
||||||
|
const found = sendShortcutOptions.find(opt => opt.value === props.sendShortcut);
|
||||||
|
return found?.label ?? '';
|
||||||
|
});
|
||||||
|
|
||||||
// 从 localStorage 读取侧边栏折叠状态
|
// 从 localStorage 读取侧边栏折叠状态
|
||||||
const savedCollapsedState = localStorage.getItem('sidebarCollapsed');
|
const savedCollapsedState = localStorage.getItem('sidebarCollapsed');
|
||||||
@@ -242,6 +457,12 @@ function handleTransportModeChange(mode: string | null) {
|
|||||||
emit('updateTransportMode', mode);
|
emit('updateTransportMode', mode);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleSendShortcutChange(mode: string | null) {
|
||||||
|
if (mode === 'enter' || mode === 'shift_enter') {
|
||||||
|
emit('updateSendShortcut', mode);
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
@@ -310,7 +531,7 @@ function handleTransportModeChange(mode: string | null) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.conversation-item:hover {
|
.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 {
|
.conversation-item:hover .conversation-actions {
|
||||||
@@ -402,7 +623,74 @@ function handleTransportModeChange(mode: string | null) {
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.transport-mode-select {
|
.chat-settings-group-trigger :deep(.v-list-item__append) {
|
||||||
min-width: 120px;
|
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>
|
</style>
|
||||||
|
|||||||
@@ -180,7 +180,7 @@
|
|||||||
|
|
||||||
<script>
|
<script>
|
||||||
import { useI18n, useModuleI18n } from '@/i18n/composables';
|
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 'markstream-vue/index.css'
|
||||||
import 'katex/dist/katex.min.css'
|
import 'katex/dist/katex.min.css'
|
||||||
import 'highlight.js/styles/github.css';
|
import 'highlight.js/styles/github.css';
|
||||||
@@ -194,8 +194,11 @@ import ActionRef from './message_list_comps/ActionRef.vue';
|
|||||||
enableKatex();
|
enableKatex();
|
||||||
enableMermaid();
|
enableMermaid();
|
||||||
|
|
||||||
// 注册自定义 ref 组件
|
// 注册 message-list 专用组件:引用节点 + Shiki 代码块渲染
|
||||||
setCustomComponents('message-list', { ref: RefNode });
|
setCustomComponents('message-list', {
|
||||||
|
ref: RefNode,
|
||||||
|
code_block: MarkdownCodeBlockNode
|
||||||
|
});
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: 'MessageList',
|
name: 'MessageList',
|
||||||
|
|||||||
@@ -22,7 +22,7 @@
|
|||||||
v-model:prompt="prompt"
|
v-model:prompt="prompt"
|
||||||
:stagedImagesUrl="stagedImagesUrl"
|
:stagedImagesUrl="stagedImagesUrl"
|
||||||
:stagedAudioUrl="stagedAudioUrl"
|
:stagedAudioUrl="stagedAudioUrl"
|
||||||
:disabled="isStreaming"
|
:disabled="false"
|
||||||
:is-running="isStreaming || isConvRunning"
|
:is-running="isStreaming || isConvRunning"
|
||||||
:enableStreaming="enableStreaming"
|
:enableStreaming="enableStreaming"
|
||||||
:isRecording="isRecording"
|
:isRecording="isRecording"
|
||||||
|
|||||||
@@ -63,8 +63,9 @@
|
|||||||
<!-- Text (Markdown) -->
|
<!-- Text (Markdown) -->
|
||||||
<MarkdownRender
|
<MarkdownRender
|
||||||
v-else-if="renderPart.part.type === 'plain' && renderPart.part.text && renderPart.part.text.trim()"
|
v-else-if="renderPart.part.type === 'plain' && renderPart.part.text && renderPart.part.text.trim()"
|
||||||
|
:key="`${renderPart.key}-${isDark ? 'dark' : 'light'}`"
|
||||||
custom-id="message-list" :custom-html-tags="['ref']" :content="renderPart.part.text" :typewriter="false"
|
custom-id="message-list" :custom-html-tags="['ref']" :content="renderPart.part.text" :typewriter="false"
|
||||||
class="markdown-content" :is-dark="isDark" :monacoOptions="{ theme: isDark ? 'vs-dark' : 'vs-light' }" />
|
class="markdown-content" :is-dark="isDark" />
|
||||||
|
|
||||||
<!-- Image -->
|
<!-- Image -->
|
||||||
<div v-else-if="renderPart.part.type === 'image' && renderPart.part.embedded_url" class="embedded-images">
|
<div v-else-if="renderPart.part.type === 'image' && renderPart.part.embedded_url" class="embedded-images">
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="isExpanded" class="reasoning-content animate-fade-in">
|
<div v-if="isExpanded" class="reasoning-content animate-fade-in">
|
||||||
<MarkdownRender :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' } : {}" />
|
:typewriter="false" :is-dark="isDark" :style="isDark ? { opacity: '0.85' } : {}" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
<template>
|
<template>
|
||||||
<v-chip v-if="domain" class="ref-chip" size="x-small" variant="flat"
|
<v-chip v-if="domain" class="ref-chip" size="x-small" variant="flat"
|
||||||
:style="{ backgroundColor: isDark ? '#303030' : '#f4f4f4', color: isDark ? '#999' : '#666' }" :href="url"
|
:style="chipStyle" :href="url"
|
||||||
target="_blank" clickable>
|
target="_blank" clickable>
|
||||||
<v-icon start size="x-small" color>mdi-link-variant</v-icon>
|
<v-icon start size="x-small" color>mdi-link-variant</v-icon>
|
||||||
<span>{{ domain }}</span>
|
<span>{{ domain }}</span>
|
||||||
|
|
||||||
</v-chip>
|
</v-chip>
|
||||||
<span v-else class="ref-fallback" :style="{ color: isDark ? '#999' : '#666' }">{{ 'site' }}</span>
|
<span v-else class="ref-fallback" :style="fallbackStyle">{{ 'site' }}</span>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
@@ -46,6 +46,15 @@ const domain = computed(() => {
|
|||||||
return ''
|
return ''
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const chipStyle = computed(() => ({
|
||||||
|
backgroundColor: isDark ? 'rgba(var(--v-theme-on-surface), 0.08)' : 'rgba(var(--v-theme-on-surface), 0.04)',
|
||||||
|
color: isDark ? 'rgba(var(--v-theme-on-surface), 0.62)' : 'rgba(var(--v-theme-on-surface), 0.72)'
|
||||||
|
}))
|
||||||
|
|
||||||
|
const fallbackStyle = computed(() => ({
|
||||||
|
color: isDark ? 'rgba(var(--v-theme-on-surface), 0.62)' : 'rgba(var(--v-theme-on-surface), 0.72)'
|
||||||
|
}))
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed } from 'vue';
|
import { computed } from 'vue';
|
||||||
import { useModuleI18n } from '@/i18n/composables';
|
import { useModuleI18n } from '@/i18n/composables';
|
||||||
|
import { normalizeTextInput } from '@/utils/inputValue';
|
||||||
|
|
||||||
const { tm } = useModuleI18n('features/command');
|
const { tm } = useModuleI18n('features/command');
|
||||||
|
|
||||||
@@ -52,6 +53,7 @@ const statusItems = [
|
|||||||
{ title: tm('filters.disabled'), value: 'disabled' },
|
{ title: tm('filters.disabled'), value: 'disabled' },
|
||||||
{ title: tm('filters.conflict'), value: 'conflict' }
|
{ title: tm('filters.conflict'), value: 'conflict' }
|
||||||
];
|
];
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -108,10 +110,11 @@ const statusItems = [
|
|||||||
<div style="min-width: 200px; max-width: 350px; flex: 1; border: 1px solid #B9B9B9; border-radius: 16px;">
|
<div style="min-width: 200px; max-width: 350px; flex: 1; border: 1px solid #B9B9B9; border-radius: 16px;">
|
||||||
<v-text-field
|
<v-text-field
|
||||||
:model-value="searchQuery"
|
:model-value="searchQuery"
|
||||||
@update:model-value="emit('update:searchQuery', $event)"
|
@update:model-value="emit('update:searchQuery', normalizeTextInput($event))"
|
||||||
density="compact"
|
density="compact"
|
||||||
:label="tm('search.placeholder')"
|
:label="tm('search.placeholder')"
|
||||||
prepend-inner-icon="mdi-magnify"
|
prepend-inner-icon="mdi-magnify"
|
||||||
|
clearable
|
||||||
variant="solo-filled"
|
variant="solo-filled"
|
||||||
flat
|
flat
|
||||||
hide-details
|
hide-details
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
*/
|
*/
|
||||||
import { ref, computed, type Ref } from 'vue';
|
import { ref, computed, type Ref } from 'vue';
|
||||||
import type { CommandItem, FilterState } from '../types';
|
import type { CommandItem, FilterState } from '../types';
|
||||||
|
import { normalizeTextInput } from '@/utils/inputValue';
|
||||||
|
|
||||||
export function useCommandFilters(commands: Ref<CommandItem[]>) {
|
export function useCommandFilters(commands: Ref<CommandItem[]>) {
|
||||||
// 过滤状态
|
// 过滤状态
|
||||||
@@ -95,7 +96,7 @@ export function useCommandFilters(commands: Ref<CommandItem[]>) {
|
|||||||
* 过滤后的指令列表(支持层级结构)
|
* 过滤后的指令列表(支持层级结构)
|
||||||
*/
|
*/
|
||||||
const filteredCommands = computed(() => {
|
const filteredCommands = computed(() => {
|
||||||
const query = searchQuery.value.toLowerCase();
|
const query = normalizeTextInput(searchQuery.value).toLowerCase();
|
||||||
const conflictCmds: CommandItem[] = [];
|
const conflictCmds: CommandItem[] = [];
|
||||||
const normalCmds: CommandItem[] = [];
|
const normalCmds: CommandItem[] = [];
|
||||||
|
|
||||||
@@ -184,4 +185,3 @@ export function useCommandFilters(commands: Ref<CommandItem[]>) {
|
|||||||
isGroupExpanded
|
isGroupExpanded
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,7 @@
|
|||||||
import { computed, onActivated, onMounted, ref, watch} from 'vue';
|
import { computed, onActivated, onMounted, ref, watch} from 'vue';
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import { useModuleI18n } from '@/i18n/composables';
|
import { useModuleI18n } from '@/i18n/composables';
|
||||||
|
import { normalizeTextInput } from '@/utils/inputValue';
|
||||||
|
|
||||||
// Composables
|
// Composables
|
||||||
import { useComponentData } from './composables/useComponentData';
|
import { useComponentData } from './composables/useComponentData';
|
||||||
@@ -83,7 +84,7 @@ const {
|
|||||||
} = useCommandActions(toast, () => fetchCommands(tm('messages.loadFailed')));
|
} = useCommandActions(toast, () => fetchCommands(tm('messages.loadFailed')));
|
||||||
|
|
||||||
const filteredTools = computed(() => {
|
const filteredTools = computed(() => {
|
||||||
const query = toolSearch.value.trim().toLowerCase();
|
const query = normalizeTextInput(toolSearch.value).trim().toLowerCase();
|
||||||
if (!query) return tools.value;
|
if (!query) return tools.value;
|
||||||
return tools.value.filter(tool =>
|
return tools.value.filter(tool =>
|
||||||
tool.name?.toLowerCase().includes(query) ||
|
tool.name?.toLowerCase().includes(query) ||
|
||||||
@@ -253,7 +254,8 @@ watch(viewMode, async (mode) => {
|
|||||||
<div class="d-flex flex-wrap align-center ga-3 mb-4">
|
<div class="d-flex flex-wrap align-center ga-3 mb-4">
|
||||||
<div style="min-width: 240px; max-width: 380px; flex: 1;">
|
<div style="min-width: 240px; max-width: 380px; flex: 1;">
|
||||||
<v-text-field
|
<v-text-field
|
||||||
v-model="toolSearch"
|
:model-value="toolSearch"
|
||||||
|
@update:model-value="toolSearch = normalizeTextInput($event)"
|
||||||
prepend-inner-icon="mdi-magnify"
|
prepend-inner-icon="mdi-magnify"
|
||||||
:label="tmTool('functionTools.search')"
|
:label="tmTool('functionTools.search')"
|
||||||
variant="outlined"
|
variant="outlined"
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
v-model="modelSearchProxy"
|
v-model="modelSearchProxy"
|
||||||
density="compact"
|
density="compact"
|
||||||
prepend-inner-icon="mdi-magnify"
|
prepend-inner-icon="mdi-magnify"
|
||||||
|
clearable
|
||||||
hide-details
|
hide-details
|
||||||
variant="solo-filled"
|
variant="solo-filled"
|
||||||
flat
|
flat
|
||||||
@@ -161,6 +162,7 @@
|
|||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { computed } from 'vue'
|
import { computed } from 'vue'
|
||||||
|
import { normalizeTextInput } from '@/utils/inputValue'
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
entries: {
|
entries: {
|
||||||
@@ -222,7 +224,7 @@ const emit = defineEmits([
|
|||||||
|
|
||||||
const modelSearchProxy = computed({
|
const modelSearchProxy = computed({
|
||||||
get: () => props.modelSearch,
|
get: () => props.modelSearch,
|
||||||
set: (val) => emit('update:modelSearch', val)
|
set: (val) => emit('update:modelSearch', normalizeTextInput(val))
|
||||||
})
|
})
|
||||||
|
|
||||||
const isProviderTesting = (providerId) => props.testingProviders.includes(providerId)
|
const isProviderTesting = (providerId) => props.testingProviders.includes(providerId)
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
>
|
>
|
||||||
<v-icon
|
<v-icon
|
||||||
size="18"
|
size="18"
|
||||||
:color="props.variant === 'default' ? (useCustomizerStore().uiTheme === 'PurpleTheme' ? '#5e35b1' : '#d7c5fa') : undefined"
|
:color="props.variant === 'default' ? 'rgb(var(--v-theme-primary))' : undefined"
|
||||||
>
|
>
|
||||||
mdi-translate
|
mdi-translate
|
||||||
</v-icon>
|
</v-icon>
|
||||||
@@ -42,7 +42,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed } from 'vue'
|
import { computed } from 'vue'
|
||||||
import { useI18n, useLanguageSwitcher } from '@/i18n/composables'
|
import { useI18n, useLanguageSwitcher } from '@/i18n/composables'
|
||||||
import { useCustomizerStore } from '@/stores/customizer'
|
|
||||||
import type { Locale } from '@/i18n/types'
|
import type { Locale } from '@/i18n/types'
|
||||||
import StyledMenu from '@/components/shared/StyledMenu.vue'
|
import StyledMenu from '@/components/shared/StyledMenu.vue'
|
||||||
|
|
||||||
@@ -90,7 +89,7 @@ const changeLanguage = async (langCode: string) => {
|
|||||||
|
|
||||||
.language-switcher--default:hover {
|
.language-switcher--default:hover {
|
||||||
transform: scale(1.05);
|
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的默认样式 */
|
/* Header变体样式 - 完全继承Vuetify和action-btn的默认样式 */
|
||||||
@@ -103,8 +102,4 @@ const changeLanguage = async (langCode: string) => {
|
|||||||
/* 继承action-btn样式,与工具栏主题按钮保持一致 */
|
/* 继承action-btn样式,与工具栏主题按钮保持一致 */
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 深色模式下的悬停效果(仅对default变体) */
|
|
||||||
:deep(.v-theme--PurpleThemeDark) .language-switcher--default:hover {
|
|
||||||
background: rgba(114, 46, 209, 0.12) !important;
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
@@ -6,11 +6,11 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="logo-text">
|
<div class="logo-text">
|
||||||
<h2
|
<h2
|
||||||
:style="{color: useCustomizerStore().uiTheme === 'PurpleTheme' ? '#5e35b1' : '#d7c5fa'}"
|
:style="{ color: 'rgb(var(--v-theme-primary))' }"
|
||||||
v-html="formatTitle(title || t('core.header.logoTitle'))"
|
v-html="formatTitle(title || t('core.header.logoTitle'))"
|
||||||
></h2>
|
></h2>
|
||||||
<!-- 父子组件传递css变量可能会出错,暂时使用十六进制颜色值 -->
|
<!-- 父子组件传递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>
|
class="hint-text">{{ subtitle || t('core.header.accountDialog.title') }}</h4>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -18,7 +18,6 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { useCustomizerStore } from "@/stores/customizer";
|
|
||||||
import { useI18n } from '@/i18n/composables';
|
import { useI18n } from '@/i18n/composables';
|
||||||
|
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
|
|||||||
@@ -48,6 +48,24 @@ const loading = ref(false);
|
|||||||
const isEmpty = ref(false);
|
const isEmpty = ref(false);
|
||||||
const copyFeedbackTimer = ref(null);
|
const copyFeedbackTimer = ref(null);
|
||||||
const lastRequestId = ref(0);
|
const lastRequestId = ref(0);
|
||||||
|
const scrollContainer = ref(null);
|
||||||
|
|
||||||
|
function slugifyHeading(text, slugCounts) {
|
||||||
|
const base = (text || "")
|
||||||
|
.trim()
|
||||||
|
.toLowerCase()
|
||||||
|
.normalize("NFKD")
|
||||||
|
.replace(/[\u0300-\u036f]/g, "")
|
||||||
|
.replace(/[^\p{Letter}\p{Number}\s-]/gu, "")
|
||||||
|
.replace(/\s+/g, "-")
|
||||||
|
.replace(/-+/g, "-");
|
||||||
|
|
||||||
|
if (!base) return "";
|
||||||
|
|
||||||
|
const count = slugCounts.get(base) || 0;
|
||||||
|
slugCounts.set(base, count + 1);
|
||||||
|
return count === 0 ? base : `${base}-${count}`;
|
||||||
|
}
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
if (copyFeedbackTimer.value) clearTimeout(copyFeedbackTimer.value);
|
if (copyFeedbackTimer.value) clearTimeout(copyFeedbackTimer.value);
|
||||||
@@ -153,6 +171,18 @@ const renderedHtml = computed(() => {
|
|||||||
// 3. 后处理方案:完全隔离,安全性最高
|
// 3. 后处理方案:完全隔离,安全性最高
|
||||||
const tempDiv = document.createElement("div");
|
const tempDiv = document.createElement("div");
|
||||||
tempDiv.innerHTML = cleanHtml;
|
tempDiv.innerHTML = cleanHtml;
|
||||||
|
|
||||||
|
const slugCounts = new Map();
|
||||||
|
tempDiv.querySelectorAll("h1, h2, h3, h4, h5, h6").forEach((heading) => {
|
||||||
|
if (heading.id) {
|
||||||
|
slugCounts.set(heading.id, (slugCounts.get(heading.id) || 0) + 1);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const slug = slugifyHeading(heading.textContent, slugCounts);
|
||||||
|
if (slug) heading.id = slug;
|
||||||
|
});
|
||||||
|
|
||||||
tempDiv.querySelectorAll("a").forEach((link) => {
|
tempDiv.querySelectorAll("a").forEach((link) => {
|
||||||
const href = link.getAttribute("href");
|
const href = link.getAttribute("href");
|
||||||
// 强制所有外部链接使用安全的 _blank 策略
|
// 强制所有外部链接使用安全的 _blank 策略
|
||||||
@@ -251,18 +281,35 @@ watch(
|
|||||||
|
|
||||||
function handleContainerClick(event) {
|
function handleContainerClick(event) {
|
||||||
const btn = event.target.closest(".copy-code-btn");
|
const btn = event.target.closest(".copy-code-btn");
|
||||||
if (!btn) return;
|
if (btn) {
|
||||||
const code = btn.closest(".code-block-wrapper")?.querySelector("code");
|
const code = btn.closest(".code-block-wrapper")?.querySelector("code");
|
||||||
if (code) {
|
if (code) {
|
||||||
if (navigator.clipboard?.writeText) {
|
if (navigator.clipboard?.writeText) {
|
||||||
navigator.clipboard
|
navigator.clipboard
|
||||||
.writeText(code.textContent)
|
.writeText(code.textContent)
|
||||||
.then(() => showCopyFeedback(btn, true))
|
.then(() => showCopyFeedback(btn, true))
|
||||||
.catch(() => tryFallbackCopy(code.textContent, btn));
|
.catch(() => tryFallbackCopy(code.textContent, btn));
|
||||||
} else {
|
} else {
|
||||||
tryFallbackCopy(code.textContent, btn);
|
tryFallbackCopy(code.textContent, btn);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const anchor = event.target.closest('a[href^="#"]');
|
||||||
|
if (!anchor) return;
|
||||||
|
|
||||||
|
const rawHref = anchor.getAttribute("href");
|
||||||
|
const targetId = rawHref ? decodeURIComponent(rawHref.slice(1)) : "";
|
||||||
|
if (!targetId) return;
|
||||||
|
|
||||||
|
const target = scrollContainer.value?.querySelector(
|
||||||
|
`#${CSS.escape(targetId)}`,
|
||||||
|
);
|
||||||
|
if (!target) return;
|
||||||
|
|
||||||
|
event.preventDefault();
|
||||||
|
target.scrollIntoView({ behavior: "smooth", block: "start" });
|
||||||
}
|
}
|
||||||
|
|
||||||
function tryFallbackCopy(text, btn) {
|
function tryFallbackCopy(text, btn) {
|
||||||
@@ -326,7 +373,7 @@ const showActionArea = computed(() => {
|
|||||||
<v-icon>mdi-close</v-icon>
|
<v-icon>mdi-close</v-icon>
|
||||||
</v-btn>
|
</v-btn>
|
||||||
</v-card-title>
|
</v-card-title>
|
||||||
<v-card-text 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">
|
<div v-if="showActionArea" class="d-flex justify-space-between mb-4">
|
||||||
<v-btn
|
<v-btn
|
||||||
v-if="modeConfig.showGithubButton && repoUrl"
|
v-if="modeConfig.showGithubButton && repoUrl"
|
||||||
@@ -436,6 +483,7 @@ const showActionArea = computed(() => {
|
|||||||
margin-bottom: 16px;
|
margin-bottom: 16px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
line-height: 1.25;
|
line-height: 1.25;
|
||||||
|
scroll-margin-top: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
:deep(.markdown-body h1) {
|
:deep(.markdown-body h1) {
|
||||||
|
|||||||
@@ -24,12 +24,12 @@ withDefaults(defineProps<{
|
|||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style>
|
||||||
.styled-menu-card {
|
.styled-menu-card {
|
||||||
min-width: 100px;
|
min-width: 100px;
|
||||||
width: fit-content;
|
width: fit-content;
|
||||||
border: 1px solid rgba(94, 53, 177, 0.15) !important;
|
border: 1px solid rgba(var(--v-theme-primary), 0.15) !important;
|
||||||
background: #f8f6fc !important;
|
background: rgba(var(--v-theme-surface), 0.98) !important;
|
||||||
backdrop-filter: blur(10px);
|
backdrop-filter: blur(10px);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -37,43 +37,41 @@ withDefaults(defineProps<{
|
|||||||
background: transparent !important;
|
background: transparent !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
:deep(.styled-menu-item) {
|
.styled-menu-item {
|
||||||
margin: 2px 0;
|
margin: 2px 0;
|
||||||
transition: all 0.2s ease;
|
transition: all 0.2s ease;
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
:deep(.styled-menu-item:hover) {
|
.styled-menu-item:hover {
|
||||||
background: rgba(94, 53, 177, 0.08) !important;
|
background: rgba(var(--v-theme-primary), 0.08) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
:deep(.styled-menu-item-active) {
|
.styled-menu-item-active {
|
||||||
background: rgba(94, 53, 177, 0.15) !important;
|
background: rgba(var(--v-theme-primary), 0.15) !important;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
|
|
||||||
:deep(.styled-menu-item-active:hover) {
|
.styled-menu-item-active:hover {
|
||||||
background: rgba(94, 53, 177, 0.2) !important;
|
background: rgba(var(--v-theme-primary), 0.2) !important;
|
||||||
}
|
}
|
||||||
</style>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
/* 深色模式下的下拉框样式 - 需要全局样式才能检测主题 */
|
/* 深色模式下的下拉框样式 - 需要全局样式才能检测主题 */
|
||||||
.v-theme--PurpleThemeDark .styled-menu-card {
|
.v-theme--PurpleThemeDark .styled-menu-card {
|
||||||
background: #2a2733 !important;
|
background: rgba(var(--v-theme-surface), 0.98) !important;
|
||||||
border: 1px solid rgba(110, 60, 180, 0.692) !important;
|
border: 1px solid rgba(var(--v-theme-primary), 0.2) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 深色模式下的列表项悬停效果 */
|
/* 深色模式下的列表项悬停效果 */
|
||||||
.v-theme--PurpleThemeDark .styled-menu-item:hover {
|
.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 {
|
.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 {
|
.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>
|
</style>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { ref, computed, onMounted, nextTick, watch } from 'vue'
|
|||||||
import axios from 'axios'
|
import axios from 'axios'
|
||||||
import { getProviderIcon } from '@/utils/providerUtils'
|
import { getProviderIcon } from '@/utils/providerUtils'
|
||||||
import { askForConfirmation as askForConfirmationDialog, useConfirmDialog } from '@/utils/confirmDialog'
|
import { askForConfirmation as askForConfirmationDialog, useConfirmDialog } from '@/utils/confirmDialog'
|
||||||
|
import { normalizeTextInput } from '@/utils/inputValue'
|
||||||
|
|
||||||
export interface UseProviderSourcesOptions {
|
export interface UseProviderSourcesOptions {
|
||||||
defaultTab?: string
|
defaultTab?: string
|
||||||
@@ -157,7 +158,7 @@ export function useProviderSources(options: UseProviderSourcesOptions) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const filteredMergedModelEntries = computed(() => {
|
const filteredMergedModelEntries = computed(() => {
|
||||||
const term = modelSearch.value.trim().toLowerCase()
|
const term = normalizeTextInput(modelSearch.value).trim().toLowerCase()
|
||||||
if (!term) return mergedModelEntries.value
|
if (!term) return mergedModelEntries.value
|
||||||
|
|
||||||
return mergedModelEntries.value.filter((entry: any) => {
|
return mergedModelEntries.value.filter((entry: any) => {
|
||||||
@@ -589,9 +590,11 @@ export function useProviderSources(options: UseProviderSourcesOptions) {
|
|||||||
async function testProvider(provider: any) {
|
async function testProvider(provider: any) {
|
||||||
testingProviders.value.push(provider.id)
|
testingProviders.value.push(provider.id)
|
||||||
try {
|
try {
|
||||||
|
const startTime = performance.now()
|
||||||
const response = await axios.get('/api/config/provider/check_one', { params: { id: provider.id } })
|
const response = await axios.get('/api/config/provider/check_one', { params: { id: provider.id } })
|
||||||
if (response.data.status === 'ok' && response.data.data.error === null) {
|
if (response.data.status === 'ok' && response.data.data.error === null) {
|
||||||
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 {
|
} else {
|
||||||
throw new Error(response.data.data.error || tm('models.testError'))
|
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) {
|
function showEditTitleDialog(sessionId: string, title: string) {
|
||||||
editingSessionId.value = sessionId;
|
editingSessionId.value = sessionId;
|
||||||
editingTitle.value = title || '';
|
editingTitle.value = title || '';
|
||||||
@@ -167,6 +234,7 @@ export function useSessions(chatboxMode: boolean = false) {
|
|||||||
getSessions,
|
getSessions,
|
||||||
newSession,
|
newSession,
|
||||||
deleteSession,
|
deleteSession,
|
||||||
|
batchDeleteSessions,
|
||||||
showEditTitleDialog,
|
showEditTitleDialog,
|
||||||
saveTitle,
|
saveTitle,
|
||||||
updateSessionTitle,
|
updateSessionTitle,
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ const translations = ref<Record<string, any>>({});
|
|||||||
*/
|
*/
|
||||||
export async function initI18n(locale: Locale = 'zh-CN') {
|
export async function initI18n(locale: Locale = 'zh-CN') {
|
||||||
currentLocale.value = locale;
|
currentLocale.value = locale;
|
||||||
|
|
||||||
// 加载静态翻译数据
|
// 加载静态翻译数据
|
||||||
loadTranslations(locale);
|
loadTranslations(locale);
|
||||||
}
|
}
|
||||||
@@ -50,7 +50,7 @@ export function useI18n() {
|
|||||||
const t = (key: string, params?: Record<string, string | number>): string => {
|
const t = (key: string, params?: Record<string, string | number>): string => {
|
||||||
const keys = key.split('.');
|
const keys = key.split('.');
|
||||||
let value: any = translations.value;
|
let value: any = translations.value;
|
||||||
|
|
||||||
// 遍历键路径
|
// 遍历键路径
|
||||||
for (const k of keys) {
|
for (const k of keys) {
|
||||||
if (value && typeof value === 'object' && k in value) {
|
if (value && typeof value === 'object' && k in value) {
|
||||||
@@ -61,35 +61,35 @@ export function useI18n() {
|
|||||||
return `[MISSING: ${key}]`;
|
return `[MISSING: ${key}]`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (typeof value !== 'string') {
|
if (typeof value !== 'string') {
|
||||||
console.warn(`Translation value is not string: ${key}`, value);
|
console.warn(`Translation value is not string: ${key}`, value);
|
||||||
// 返回带括号的键名,便于在开发时识别类型错误的翻译
|
// 返回带括号的键名,便于在开发时识别类型错误的翻译
|
||||||
return `[INVALID: ${key}]`;
|
return `[INVALID: ${key}]`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 此时value确定是string类型
|
// 此时value确定是string类型
|
||||||
let result: string = value;
|
let result: string = value;
|
||||||
|
|
||||||
// 处理参数插值
|
// 处理参数插值
|
||||||
if (params) {
|
if (params) {
|
||||||
result = result.replace(/\{(\w+)\}/g, (match: string, paramKey: string) => {
|
result = result.replace(/\{(\w+)\}/g, (match: string, paramKey: string) => {
|
||||||
return params[paramKey]?.toString() || match;
|
return params[paramKey]?.toString() || match;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
};
|
};
|
||||||
|
|
||||||
// 切换语言
|
// 切换语言
|
||||||
const setLocale = async (newLocale: Locale) => {
|
const setLocale = async (newLocale: Locale) => {
|
||||||
if (newLocale !== currentLocale.value) {
|
if (newLocale !== currentLocale.value) {
|
||||||
currentLocale.value = newLocale;
|
currentLocale.value = newLocale;
|
||||||
loadTranslations(newLocale);
|
loadTranslations(newLocale);
|
||||||
|
|
||||||
// 保存到localStorage
|
// 保存到localStorage
|
||||||
localStorage.setItem('astrbot-locale', newLocale);
|
localStorage.setItem('astrbot-locale', newLocale);
|
||||||
|
|
||||||
// 触发自定义事件,通知相关页面重新加载配置数据
|
// 触发自定义事件,通知相关页面重新加载配置数据
|
||||||
// 这是因为插件适配器的 i18n 数据是通过后端 API 注入的,
|
// 这是因为插件适配器的 i18n 数据是通过后端 API 注入的,
|
||||||
// 需要根据 Accept-Language 头重新获取
|
// 需要根据 Accept-Language 头重新获取
|
||||||
@@ -98,16 +98,16 @@ export function useI18n() {
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// 获取当前语言
|
// 获取当前语言
|
||||||
const locale = computed(() => currentLocale.value);
|
const locale = computed(() => currentLocale.value);
|
||||||
|
|
||||||
// 获取可用语言列表
|
// 获取可用语言列表
|
||||||
const availableLocales: Locale[] = ['zh-CN', 'en-US'];
|
const availableLocales: Locale[] = ['zh-CN', 'en-US', 'ru-RU'];
|
||||||
|
|
||||||
// 检查是否已加载
|
// 检查是否已加载
|
||||||
const isLoaded = computed(() => Object.keys(translations.value).length > 0);
|
const isLoaded = computed(() => Object.keys(translations.value).length > 0);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
t,
|
t,
|
||||||
locale,
|
locale,
|
||||||
@@ -122,13 +122,13 @@ export function useI18n() {
|
|||||||
*/
|
*/
|
||||||
export function useModuleI18n(moduleName: string) {
|
export function useModuleI18n(moduleName: string) {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
|
|
||||||
const tm = (key: string, params?: Record<string, string | number>): string => {
|
const tm = (key: string, params?: Record<string, string | number>): string => {
|
||||||
// 将斜杠转换为点号以匹配嵌套对象结构
|
// 将斜杠转换为点号以匹配嵌套对象结构
|
||||||
const normalizedModuleName = moduleName.replace(/\//g, '.');
|
const normalizedModuleName = moduleName.replace(/\//g, '.');
|
||||||
return t(`${normalizedModuleName}.${key}`, params);
|
return t(`${normalizedModuleName}.${key}`, params);
|
||||||
};
|
};
|
||||||
|
|
||||||
// 获取原始翻译值(可能是字符串、数组或对象)
|
// 获取原始翻译值(可能是字符串、数组或对象)
|
||||||
const getRaw = (key: string): any => {
|
const getRaw = (key: string): any => {
|
||||||
const normalizedModuleName = moduleName.replace(/\//g, '.');
|
const normalizedModuleName = moduleName.replace(/\//g, '.');
|
||||||
@@ -143,10 +143,10 @@ export function useModuleI18n(moduleName: string) {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return value;
|
return value;
|
||||||
};
|
};
|
||||||
|
|
||||||
return { tm, getRaw };
|
return { tm, getRaw };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -155,20 +155,21 @@ export function useModuleI18n(moduleName: string) {
|
|||||||
*/
|
*/
|
||||||
export function useLanguageSwitcher() {
|
export function useLanguageSwitcher() {
|
||||||
const { locale, setLocale, availableLocales } = useI18n();
|
const { locale, setLocale, availableLocales } = useI18n();
|
||||||
|
|
||||||
const languageOptions = computed(() => [
|
const languageOptions = computed(() => [
|
||||||
{ value: 'zh-CN', label: '简体中文', flag: '🇨🇳' },
|
{ value: 'zh-CN', label: '简体中文', flag: '🇨🇳' },
|
||||||
{ value: 'en-US', label: 'English', flag: '🇺🇸' }
|
{ value: 'en-US', label: 'English', flag: '🇺🇸' },
|
||||||
|
{ value: 'ru-RU', label: 'Русский', flag: '🇷🇺' }
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const currentLanguage = computed(() => {
|
const currentLanguage = computed(() => {
|
||||||
return languageOptions.value.find(lang => lang.value === locale.value);
|
return languageOptions.value.find(lang => lang.value === locale.value);
|
||||||
});
|
});
|
||||||
|
|
||||||
const switchLanguage = async (newLocale: Locale) => {
|
const switchLanguage = async (newLocale: Locale) => {
|
||||||
await setLocale(newLocale);
|
await setLocale(newLocale);
|
||||||
};
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
locale,
|
locale,
|
||||||
languageOptions,
|
languageOptions,
|
||||||
@@ -220,9 +221,9 @@ function deepMerge(target: Record<string, any>, source: Record<string, any>) {
|
|||||||
export async function setupI18n() {
|
export async function setupI18n() {
|
||||||
// 从localStorage获取保存的语言设置
|
// 从localStorage获取保存的语言设置
|
||||||
const savedLocale = localStorage.getItem('astrbot-locale') as Locale;
|
const savedLocale = localStorage.getItem('astrbot-locale') as Locale;
|
||||||
const initialLocale = savedLocale && ['zh-CN', 'en-US'].includes(savedLocale)
|
const initialLocale = savedLocale && ['zh-CN', 'en-US', 'ru-RU'].includes(savedLocale)
|
||||||
? savedLocale
|
? savedLocale
|
||||||
: 'zh-CN';
|
: 'zh-CN';
|
||||||
|
|
||||||
await initI18n(initialLocale);
|
await initI18n(initialLocale);
|
||||||
}
|
}
|
||||||
@@ -96,6 +96,7 @@
|
|||||||
"save": "Save",
|
"save": "Save",
|
||||||
"livePreview": "Live Preview (may differ)",
|
"livePreview": "Live Preview (may differ)",
|
||||||
"refreshPreview": "Refresh Preview",
|
"refreshPreview": "Refresh Preview",
|
||||||
|
"previewText": "This is a sample text used to preview the template output.\n\nIt can contain multiple lines and various formatting.",
|
||||||
"syntaxHint": "Supports jinja2 syntax. Available variables: text | safe (text to render), version (AstrBot version)",
|
"syntaxHint": "Supports jinja2 syntax. Available variables: text | safe (text to render), version (AstrBot version)",
|
||||||
"saveAndApply": "Save and Apply Current Template",
|
"saveAndApply": "Save and Apply Current Template",
|
||||||
"confirmReset": "Confirm Reset",
|
"confirmReset": "Confirm Reset",
|
||||||
|
|||||||
@@ -71,10 +71,16 @@
|
|||||||
"modes": {
|
"modes": {
|
||||||
"darkMode": "Switch to Dark Mode",
|
"darkMode": "Switch to Dark Mode",
|
||||||
"lightMode": "Switch to Light Mode"
|
"lightMode": "Switch to Light Mode"
|
||||||
}, "shortcuts": {
|
},
|
||||||
|
"shortcuts": {
|
||||||
"help": "Get Help",
|
"help": "Get Help",
|
||||||
"voiceRecord": "Record Voice",
|
"voiceRecord": "Record Voice",
|
||||||
"pasteImage": "Paste Image"
|
"pasteImage": "Paste Image",
|
||||||
|
"sendKey": {
|
||||||
|
"title": "Send Shortcut",
|
||||||
|
"enterToSend": "Enter to send",
|
||||||
|
"shiftEnterToSend": "Shift+Enter to send"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"streaming": {
|
"streaming": {
|
||||||
"enabled": "Streaming enabled",
|
"enabled": "Streaming enabled",
|
||||||
@@ -141,5 +147,15 @@
|
|||||||
"errors": {
|
"errors": {
|
||||||
"sendMessageFailed": "Failed to send message, please try again",
|
"sendMessageFailed": "Failed to send message, please try again",
|
||||||
"createSessionFailed": "Failed to create session, please refresh the page"
|
"createSessionFailed": "Failed to create session, please refresh the page"
|
||||||
|
},
|
||||||
|
"batch": {
|
||||||
|
"selected": "{count} selected",
|
||||||
|
"confirmDelete": "Are you sure you want to delete {count} conversation(s)? This action cannot be undone.",
|
||||||
|
"selectAll": "Select All",
|
||||||
|
"deselectAll": "Deselect All",
|
||||||
|
"delete": "Delete",
|
||||||
|
"exit": "Exit",
|
||||||
|
"partialFailure": "{failed} of {total} conversations failed to delete",
|
||||||
|
"requestFailed": "Failed to delete conversations. Please try again."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -78,6 +78,7 @@
|
|||||||
},
|
},
|
||||||
"persona": {
|
"persona": {
|
||||||
"description": "Persona",
|
"description": "Persona",
|
||||||
|
"hint": "Set the default persona for AI conversations. Personas can be managed in the Persona tab.",
|
||||||
"provider_settings": {
|
"provider_settings": {
|
||||||
"default_personality": {
|
"default_personality": {
|
||||||
"description": "Default Persona"
|
"description": "Default Persona"
|
||||||
@@ -618,11 +619,6 @@
|
|||||||
"type": "string",
|
"type": "string",
|
||||||
"hint": "Required. The Bot Token obtained from the KOOK Developer Platform."
|
"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": {
|
"kook_reconnect_delay": {
|
||||||
"description": "Reconnect Delay",
|
"description": "Reconnect Delay",
|
||||||
"type": "int",
|
"type": "int",
|
||||||
@@ -850,7 +846,7 @@
|
|||||||
},
|
},
|
||||||
"interval_method": {
|
"interval_method": {
|
||||||
"description": "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": {
|
"interval": {
|
||||||
"description": "Random Interval Time",
|
"description": "Random Interval Time",
|
||||||
@@ -873,7 +869,8 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"regex": {
|
"regex": {
|
||||||
"description": "Segmentation Regular Expression"
|
"description": "Segmentation Regular Expression",
|
||||||
|
"hint": "Used to identify split points with a regular expression. Prefer patterns that match separators."
|
||||||
},
|
},
|
||||||
"split_words": {
|
"split_words": {
|
||||||
"description": "Split Word List",
|
"description": "Split Word List",
|
||||||
|
|||||||
@@ -132,6 +132,7 @@
|
|||||||
"deleteSuccess": "Model deleted successfully",
|
"deleteSuccess": "Model deleted successfully",
|
||||||
"deleteError": "Failed to delete model",
|
"deleteError": "Failed to delete model",
|
||||||
"testSuccess": "Model {id} test passed",
|
"testSuccess": "Model {id} test passed",
|
||||||
|
"testSuccessWithLatency": "Model {id} test passed, latency {latency} ms",
|
||||||
"testError": "Model test failed",
|
"testError": "Model test failed",
|
||||||
"searchPlaceholder": "Search models or ID",
|
"searchPlaceholder": "Search models or ID",
|
||||||
"manualAddButton": "Custom Model",
|
"manualAddButton": "Custom Model",
|
||||||
|
|||||||
@@ -93,24 +93,6 @@
|
|||||||
"batchDeleteConfirm": {
|
"batchDeleteConfirm": {
|
||||||
"title": "Confirm Batch Delete",
|
"title": "Confirm Batch Delete",
|
||||||
"message": "Are you sure you want to delete {count} selected rules? Global settings will be used after deletion."
|
"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": {
|
"batchOperations": {
|
||||||
"title": "Batch Operations",
|
"title": "Batch Operations",
|
||||||
@@ -126,6 +108,25 @@
|
|||||||
"ttsProvider": "TTS Model",
|
"ttsProvider": "TTS Model",
|
||||||
"apply": "Apply Changes"
|
"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": {
|
"status": {
|
||||||
"enabled": "Enabled",
|
"enabled": "Enabled",
|
||||||
"disabled": "Disabled"
|
"disabled": "Disabled"
|
||||||
@@ -142,7 +143,16 @@
|
|||||||
"noChanges": "No changes to save",
|
"noChanges": "No changes to save",
|
||||||
"batchDeleteSuccess": "Batch delete successful",
|
"batchDeleteSuccess": "Batch delete successful",
|
||||||
"batchDeleteError": "Batch delete failed",
|
"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",
|
"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
@@ -0,0 +1,129 @@
|
|||||||
|
{
|
||||||
|
"title": "Конфигурация",
|
||||||
|
"subtitle": "Управление системными настройками",
|
||||||
|
"editor": {
|
||||||
|
"visual": "Визуальный редактор",
|
||||||
|
"code": "Редактор кода",
|
||||||
|
"revertCode": "Отменить изменения",
|
||||||
|
"applyConfig": "Применить",
|
||||||
|
"applyTip": "Кнопка «Применить» временно фиксирует изменения в визуальном редакторе. Чтобы сохранить их на постоянной основе, нажмите кнопку «Сохранить» в правом нижнем углу."
|
||||||
|
},
|
||||||
|
"actions": {
|
||||||
|
"save": "Сохранить",
|
||||||
|
"delete": "Удалить",
|
||||||
|
"add": "Добавить",
|
||||||
|
"reset": "Сбросить настройки",
|
||||||
|
"export": "Экспорт",
|
||||||
|
"import": "Импорт",
|
||||||
|
"validate": "Проверить"
|
||||||
|
},
|
||||||
|
"help": {
|
||||||
|
"documentation": "Документация",
|
||||||
|
"support": "Поддержка",
|
||||||
|
"helpText": "Нужна помощь? См. {documentation} или обратитесь в {support}.",
|
||||||
|
"helpPrefix": "Нужна помощь? См.",
|
||||||
|
"helpMiddle": "или обратитесь в",
|
||||||
|
"helpSuffix": "."
|
||||||
|
},
|
||||||
|
"messages": {
|
||||||
|
"configApplied": "Настройки применены образно. Нажмите «Сохранить» для окончательной записи.",
|
||||||
|
"configApplyError": "Ошибка применения: некорректный формат JSON.",
|
||||||
|
"unsavedChangesNotice": "Есть несохраненные изменения. Пожалуйста, нажмите «Сохранить», чтобы они вступили в силу.",
|
||||||
|
"saveSuccess": "Настройки успешно сохранены",
|
||||||
|
"saveError": "Ошибка при сохранении",
|
||||||
|
"loadError": "Ошибка при загрузке настроек",
|
||||||
|
"deleteSuccess": "Удалено",
|
||||||
|
"deleteError": "Ошибка удаления",
|
||||||
|
"updateSuccess": "Обновлено",
|
||||||
|
"updateError": "Ошибка обновления"
|
||||||
|
},
|
||||||
|
"sections": {
|
||||||
|
"general": "Основные",
|
||||||
|
"advanced": "Расширенные",
|
||||||
|
"security": "Безопасность",
|
||||||
|
"appearance": "Внешний вид",
|
||||||
|
"notification": "Уведомления"
|
||||||
|
},
|
||||||
|
"general": {
|
||||||
|
"botName": "Имя бота",
|
||||||
|
"language": "Язык интерфейса",
|
||||||
|
"timezone": "Часовой пояс",
|
||||||
|
"autoSave": "Автосохранение",
|
||||||
|
"debugMode": "Режим отладки"
|
||||||
|
},
|
||||||
|
"advanced": {
|
||||||
|
"logLevel": "Уровень логирования",
|
||||||
|
"maxConnections": "Макс. соединений",
|
||||||
|
"timeout": "Тайм-аут",
|
||||||
|
"retryAttempts": "Попытки повтора",
|
||||||
|
"cacheSize": "Размер кэша"
|
||||||
|
},
|
||||||
|
"security": {
|
||||||
|
"apiKey": "Ключ API",
|
||||||
|
"allowedHosts": "Разрешенные хосты",
|
||||||
|
"rateLimit": "Лимит запросов",
|
||||||
|
"encryption": "Шифрование"
|
||||||
|
},
|
||||||
|
"configSelection": {
|
||||||
|
"selectConfig": "Выбор конфигурации",
|
||||||
|
"normalConfig": "Обычная",
|
||||||
|
"systemConfig": "Системная"
|
||||||
|
},
|
||||||
|
"search": {
|
||||||
|
"placeholder": "Поиск по настройкам (поле/описание/подсказка)",
|
||||||
|
"noResult": "Совпадений не найдено"
|
||||||
|
},
|
||||||
|
"configManagement": {
|
||||||
|
"title": "Управление конфигурациями",
|
||||||
|
"description": "AstrBot поддерживает несколько конфигураций для разных ботов. По умолчанию используется «default».",
|
||||||
|
"newConfig": "Новая конфигурация",
|
||||||
|
"editConfig": "Изменить конфигурацию",
|
||||||
|
"manageConfigs": "Управление файлами...",
|
||||||
|
"configName": "Имя",
|
||||||
|
"fillConfigName": "Введите имя конфигурации",
|
||||||
|
"confirmDelete": "Вы уверены, что хотите удалить конфигурацию «{name}»? Это действие необратимо.",
|
||||||
|
"pleaseEnterName": "Пожалуйста, введите имя",
|
||||||
|
"createFailed": "Ошибка создания конфигурации",
|
||||||
|
"deleteFailed": "Ошибка удаления",
|
||||||
|
"updateFailed": "Ошибка обновления"
|
||||||
|
},
|
||||||
|
"buttons": {
|
||||||
|
"cancel": "Отмена",
|
||||||
|
"create": "Создать",
|
||||||
|
"update": "Обновить"
|
||||||
|
},
|
||||||
|
"codeEditor": {
|
||||||
|
"title": "Редактирование файла"
|
||||||
|
},
|
||||||
|
"fileUpload": {
|
||||||
|
"button": "Файлы",
|
||||||
|
"dialogTitle": "Загруженные файлы",
|
||||||
|
"dropzone": "Загрузить файлы",
|
||||||
|
"allowedTypes": "Разрешенные типы: {types}",
|
||||||
|
"empty": "Файлов нет",
|
||||||
|
"statusMissing": "Файл отсутствует",
|
||||||
|
"statusUnconfigured": "Не в конфиге",
|
||||||
|
"uploadSuccess": "Загружено файлов: {count}",
|
||||||
|
"uploadFailed": "Ошибка загрузки",
|
||||||
|
"loadFailed": "Ошибка получения списка файлов",
|
||||||
|
"fileTooLarge": "Файл слишком велик (макс. {max} МБ): {name}",
|
||||||
|
"deleteSuccess": "Файл удален",
|
||||||
|
"deleteFailed": "Ошибка удаления",
|
||||||
|
"addToConfig": "Добавлено в конфигурацию",
|
||||||
|
"fileCount": "Файлов: {count}",
|
||||||
|
"done": "Готово"
|
||||||
|
},
|
||||||
|
"unsavedChangesWarning": {
|
||||||
|
"dialogTitle": "Несохраненные изменения",
|
||||||
|
"leavePage": "У вас есть несохраненные изменения. Сохранить перед уходом?",
|
||||||
|
"switchConfig": "Переключение конфигурации приведет к потере несохраненных изменений. Сохранить?",
|
||||||
|
"options": {
|
||||||
|
"save": "Сохранить",
|
||||||
|
"saveAndSwitch": "Сохранить и переключить",
|
||||||
|
"discardAndSwitch": "Сбросить и переключить",
|
||||||
|
"closeCard": "Закрыть",
|
||||||
|
"confirm": "ОК",
|
||||||
|
"cancel": "Отмена"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user