Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 55e1431084 |
@@ -1,43 +0,0 @@
|
||||
name: release
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest # 运行环境
|
||||
steps:
|
||||
- name: checkout
|
||||
uses: actions/checkout@master
|
||||
- name: nodejs installation
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: "18"
|
||||
- name: npm install
|
||||
run: npm add -D vitepress
|
||||
working-directory: './docs' # working-directory 指定 shell 命令运行目录
|
||||
- name: npm run build
|
||||
run: npm run docs:build
|
||||
working-directory: './docs'
|
||||
- name: scp
|
||||
uses: appleboy/scp-action@master
|
||||
with:
|
||||
host: ${{ secrets.HOST_NEKO }}
|
||||
username: ${{ secrets.USERNAME }}
|
||||
password: ${{ secrets.PASSWORDNEKO }}
|
||||
source: 'docs/.vitepress/dist/*'
|
||||
target: '/tmp/'
|
||||
- name: script
|
||||
uses: appleboy/ssh-action@master
|
||||
with:
|
||||
host: ${{ secrets.HOST_NEKO }}
|
||||
username: ${{ secrets.USERNAME }}
|
||||
password: ${{ secrets.PASSWORDNEKO }}
|
||||
script: |
|
||||
mkdir -p /root/docker_data/caddy/caddy_data/static_site/abv4/
|
||||
rm -rf /root/docker_data/caddy/caddy_data/static_site/abv4/*
|
||||
mv /tmp/docs/.vitepress/dist/* /root/docker_data/caddy/caddy_data/static_site/abv4/
|
||||
rm -rf /tmp/docs/
|
||||
@@ -64,20 +64,20 @@ jobs:
|
||||
echo "build_date=$build_date" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Set QEMU
|
||||
uses: docker/setup-qemu-action@v4
|
||||
uses: docker/setup-qemu-action@v3
|
||||
|
||||
- name: Set Docker Buildx
|
||||
uses: docker/setup-buildx-action@v4
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Log in to DockerHub
|
||||
uses: docker/login-action@v4
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_HUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_HUB_PASSWORD }}
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
if: env.HAS_GHCR_TOKEN == 'true'
|
||||
uses: docker/login-action@v4
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ env.GHCR_OWNER }}
|
||||
@@ -98,7 +98,7 @@ jobs:
|
||||
echo "EOF" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Build and Push Nightly Image
|
||||
uses: docker/build-push-action@v7
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/amd64,linux/arm64
|
||||
@@ -163,27 +163,27 @@ jobs:
|
||||
cp -r dashboard/dist data/
|
||||
|
||||
- name: Set QEMU
|
||||
uses: docker/setup-qemu-action@v4
|
||||
uses: docker/setup-qemu-action@v3
|
||||
|
||||
- name: Set Docker Buildx
|
||||
uses: docker/setup-buildx-action@v4
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Log in to DockerHub
|
||||
uses: docker/login-action@v4
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_HUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_HUB_PASSWORD }}
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
if: env.HAS_GHCR_TOKEN == 'true'
|
||||
uses: docker/login-action@v4
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ env.GHCR_OWNER }}
|
||||
password: ${{ secrets.GHCR_GITHUB_TOKEN }}
|
||||
|
||||
- name: Build and Push Release Image
|
||||
uses: docker/build-push-action@v7
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/amd64,linux/arm64
|
||||
|
||||
@@ -184,8 +184,7 @@ jobs:
|
||||
publish-pypi:
|
||||
name: Publish PyPI
|
||||
runs-on: ubuntu-24.04
|
||||
needs:
|
||||
- publish-release
|
||||
needs: publish-release
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
@@ -193,36 +192,6 @@ jobs:
|
||||
fetch-depth: 0
|
||||
ref: ${{ inputs.ref || github.ref }}
|
||||
|
||||
- name: Resolve tag
|
||||
id: tag
|
||||
shell: bash
|
||||
run: |
|
||||
if [ "${{ github.event_name }}" = "push" ]; then
|
||||
tag="${GITHUB_REF_NAME}"
|
||||
elif [ -n "${{ inputs.tag }}" ]; then
|
||||
tag="${{ inputs.tag }}"
|
||||
else
|
||||
tag="$(git describe --tags --abbrev=0)"
|
||||
fi
|
||||
if [ -z "$tag" ]; then
|
||||
echo "Failed to resolve tag." >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "tag=$tag" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Download dashboard artifact
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: Dashboard-${{ steps.tag.outputs.tag }}
|
||||
path: dashboard-artifact
|
||||
|
||||
- name: Unpack dashboard dist into package tree
|
||||
shell: bash
|
||||
run: |
|
||||
mkdir -p astrbot/dashboard/dist
|
||||
unzip -q "dashboard-artifact/AstrBot-${{ steps.tag.outputs.tag }}-dashboard.zip" -d dashboard-artifact/unpacked
|
||||
cp -r dashboard-artifact/unpacked/dist/. astrbot/dashboard/dist/
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
@@ -234,8 +203,6 @@ jobs:
|
||||
|
||||
- name: Build package
|
||||
shell: bash
|
||||
# Dashboard assets are already in astrbot/dashboard/dist/;
|
||||
# ASTRBOT_BUILD_DASHBOARD is intentionally unset so the hatch hook skips npm.
|
||||
run: uv build
|
||||
|
||||
- name: Publish to PyPI
|
||||
|
||||
@@ -1,68 +0,0 @@
|
||||
name: sync wiki
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
paths:
|
||||
- '.github/workflows/sync-wiki.yml'
|
||||
- 'docs/scripts/sync_docs_to_wiki.py'
|
||||
- 'docs/tests/test_sync_docs_to_wiki.py'
|
||||
- 'docs/zh/**'
|
||||
- 'docs/en/**'
|
||||
|
||||
concurrency:
|
||||
group: sync-wiki-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
sync:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
steps:
|
||||
- name: Validate manual ref
|
||||
if: github.event_name == 'workflow_dispatch' && github.ref != 'refs/heads/master'
|
||||
run: |
|
||||
echo "This workflow only publishes from refs/heads/master. Re-run it from the master branch."
|
||||
exit 1
|
||||
|
||||
- name: Check out docs repository
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: '3.11'
|
||||
|
||||
- name: Run sync unit tests
|
||||
working-directory: docs
|
||||
run: python -m unittest discover -s tests -p 'test_sync_docs_to_wiki.py' -v
|
||||
|
||||
- name: Validate internal doc links
|
||||
run: python docs/scripts/sync_docs_to_wiki.py --source-root docs --check-links-only
|
||||
|
||||
- name: Clone AstrBot wiki
|
||||
env:
|
||||
WIKI_TOKEN: ${{ secrets.ASTRBOT_WIKI_TOKEN }}
|
||||
run: |
|
||||
test -n "$WIKI_TOKEN"
|
||||
git clone "https://x-access-token:${WIKI_TOKEN}@github.com/AstrBotDevs/AstrBot.wiki.git" wiki
|
||||
|
||||
- name: Generate wiki pages
|
||||
run: python docs/scripts/sync_docs_to_wiki.py --source-root docs --wiki-root wiki
|
||||
|
||||
- name: Commit and push wiki changes
|
||||
working-directory: wiki
|
||||
run: |
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
||||
git add .
|
||||
if git diff --cached --quiet; then
|
||||
echo "No wiki changes to push"
|
||||
exit 0
|
||||
fi
|
||||
git commit -m "docs: sync wiki from AstrBot-1/docs"
|
||||
git push
|
||||
@@ -73,7 +73,7 @@ AstrBot is an open-source all-in-one Agent chatbot platform that integrates with
|
||||
|
||||
### One-Click Deployment
|
||||
|
||||
For users who want to quickly experience AstrBot, are familiar with command-line usage, and can install a `uv` environment on their own, we recommend the `uv` one-click deployment method ⚡️:
|
||||
For users who want to quickly experience AstrBot, we recommend using the one-click deployment method with `uv` ⚡️:
|
||||
|
||||
```bash
|
||||
uv tool install astrbot
|
||||
@@ -83,58 +83,47 @@ astrbot
|
||||
|
||||
> Requires [uv](https://docs.astral.sh/uv/) to be installed.
|
||||
|
||||
> [!NOTE]
|
||||
> For macOS user: due to macOS security checks, the first run of the `astrbot` command may take longer (about 10-20s).
|
||||
|
||||
Update `astrbot`:
|
||||
|
||||
```bash
|
||||
uv tool upgrade astrbot
|
||||
```
|
||||
|
||||
### Docker Deployment
|
||||
|
||||
For users familiar with containers and looking for a more stable, production-ready deployment method, we recommend deploying AstrBot with Docker / Docker Compose.
|
||||
For users who want a more stable and production-ready deployment, we recommend using Docker / Docker Compose to deploy AstrBot.
|
||||
|
||||
Please refer to the official documentation: [Deploy AstrBot with Docker](https://astrbot.app/deploy/astrbot/docker.html#%E4%BD%BF%E7%94%A8-docker-%E9%83%A8%E7%BD%B2-astrbot).
|
||||
|
||||
### Deploy on RainYun
|
||||
|
||||
For users who want one-click deployment and do not want to manage servers themselves, we recommend RainYun's one-click cloud deployment service ☁️:
|
||||
For users who want to deploy AstrBot with one-click and don't want to manage the server, we recommend using RainYun's one-click cloud deployment service ☁️:
|
||||
|
||||
[](https://app.rainyun.com/apps/rca/store/5994?ref=NjU1ODg0)
|
||||
|
||||
### Desktop Application Deployment
|
||||
### Desktop Application (Tauri)
|
||||
|
||||
For users who want to use AstrBot on desktop and mainly use ChatUI, we recommend AstrBot App.
|
||||
For users who want to deploy AstrBot on their desktop, primarily using AstrBot ChatUI, rarely use AstrBot plugins, we recommend using the AstrBot App:
|
||||
|
||||
Visit [AstrBot-desktop](https://github.com/AstrBotDevs/AstrBot-desktop) to download and install; this method is designed for desktop usage and is not recommended for server scenarios.
|
||||
Desktop repository: [AstrBot-desktop](https://github.com/AstrBotDevs/AstrBot-desktop).
|
||||
|
||||
### Launcher Deployment
|
||||
Supports multiple system architectures, direct package installation, and out-of-the-box usage. A convenient one-click desktop deployment option for beginners.
|
||||
|
||||
For desktop users who also want fast deployment and isolated multi-instance usage, we recommend AstrBot Launcher.
|
||||
### One-Click Launcher Deployment (AstrBot Launcher)
|
||||
|
||||
Visit [AstrBot Launcher](https://github.com/Raven95676/astrbot-launcher) to download and install.
|
||||
For users who want a quick deployment and multi-instance solution with environment isolation, we recommend using the AstrBot Launcher:
|
||||
|
||||
Visit the [AstrBot Launcher](https://github.com/Raven95676/astrbot-launcher) repository and install the package for your OS from the latest release.
|
||||
|
||||
A quick deployment and multi-instance solution with environment isolation.
|
||||
|
||||
### Deploy on Replit
|
||||
|
||||
Replit deployment is maintained by the community and is suitable for online demos and lightweight trials.
|
||||
Community-contributed deployment method.
|
||||
|
||||
[](https://repl.it/github/AstrBotDevs/AstrBot)
|
||||
|
||||
### AUR
|
||||
|
||||
AUR deployment targets Arch Linux users who prefer installing AstrBot through the system package workflow.
|
||||
|
||||
Run the command below to install `astrbot-git`, then start AstrBot in your local environment.
|
||||
|
||||
```bash
|
||||
yay -S astrbot-git
|
||||
```
|
||||
|
||||
**More deployment methods**
|
||||
|
||||
If you need panel-based management or deeper customization, see [BT-Panel Deployment](https://astrbot.app/deploy/astrbot/btpanel.html) for BT Panel app-store setup, [1Panel Deployment](https://astrbot.app/deploy/astrbot/1panel.html) for 1Panel app-market deployment, [CasaOS Deployment](https://astrbot.app/deploy/astrbot/casaos.html) for NAS/home-server visual deployment, and [Manual Deployment](https://astrbot.app/deploy/astrbot/cli.html) for fully custom source-based installation with `uv`.
|
||||
**More deployment methods**: [BT-Panel Deployment](https://astrbot.app/deploy/astrbot/btpanel.html) | [1Panel Deployment](https://astrbot.app/deploy/astrbot/1panel.html) | [CasaOS Deployment](https://astrbot.app/deploy/astrbot/casaos.html) | [Manual Deployment](https://astrbot.app/deploy/astrbot/cli.html)
|
||||
|
||||
## Supported Messaging Platforms
|
||||
|
||||
@@ -201,7 +190,6 @@ Connect AstrBot to your favorite chat platform.
|
||||
<img alt="sponsors" src="https://sponsors.astrbot.app/?v=1">
|
||||
</p>
|
||||
|
||||
|
||||
## ❤️ Contributing
|
||||
|
||||
Issues and Pull Requests are always welcome! Feel free to submit your changes to this project :)
|
||||
@@ -220,20 +208,16 @@ pip install pre-commit
|
||||
pre-commit install
|
||||
```
|
||||
|
||||
|
||||
## 🌍 Community
|
||||
|
||||
### QQ Groups
|
||||
|
||||
- Group 9: 1076659624 (New)
|
||||
- Group 10: 1078079676 (New)
|
||||
- Group 1: 322154837
|
||||
- Group 3: 630166526
|
||||
- Group 5: 822130018
|
||||
- Group 6: 753075035
|
||||
- Group 7: 743746109
|
||||
- Group 8: 1030353265
|
||||
|
||||
- Developer Group: 975206796
|
||||
|
||||
### Discord Server
|
||||
|
||||
+16
-27
@@ -73,7 +73,7 @@ AstrBot est une plateforme de chatbot Agent tout-en-un open source qui s'intègr
|
||||
|
||||
### Déploiement en un clic
|
||||
|
||||
Pour les utilisateurs qui veulent découvrir AstrBot rapidement, qui sont familiers avec la ligne de commande et peuvent installer eux-mêmes l'environnement `uv`, nous recommandons la méthode de déploiement en un clic avec `uv` ⚡️ :
|
||||
Pour les utilisateurs qui souhaitent découvrir AstrBot rapidement, nous recommandons la méthode de déploiement en un clic avec `uv` ⚡️ :
|
||||
|
||||
```bash
|
||||
uv tool install astrbot
|
||||
@@ -83,58 +83,47 @@ astrbot
|
||||
|
||||
> [uv](https://docs.astral.sh/uv/) doit être installé.
|
||||
|
||||
> [!NOTE]
|
||||
> Pour les utilisateurs macOS : en raison des vérifications de sécurité de macOS, la première exécution de la commande `astrbot` peut prendre plus de temps (environ 10-20s).
|
||||
|
||||
Mettre à jour `astrbot` :
|
||||
|
||||
```bash
|
||||
uv tool upgrade astrbot
|
||||
```
|
||||
|
||||
### Déploiement Docker
|
||||
|
||||
Pour les utilisateurs familiers avec les conteneurs et qui souhaitent une méthode plus stable et adaptée à la production, nous recommandons de déployer AstrBot avec Docker / Docker Compose.
|
||||
Pour les utilisateurs qui veulent un déploiement plus stable et prêt pour la production, nous recommandons d'utiliser Docker / Docker Compose pour déployer AstrBot.
|
||||
|
||||
Veuillez consulter la documentation officielle [Déployer AstrBot avec Docker](https://astrbot.app/deploy/astrbot/docker.html#%E4%BD%BF%E7%94%A8-docker-%E9%83%A8%E7%BD%B2-astrbot).
|
||||
Veuillez consulter la documentation officielle : [Déployer AstrBot avec Docker](https://astrbot.app/deploy/astrbot/docker.html#%E4%BD%BF%E7%94%A8-docker-%E9%83%A8%E7%BD%B2-astrbot).
|
||||
|
||||
### Déployer sur RainYun
|
||||
|
||||
Pour les utilisateurs qui souhaitent déployer AstrBot en un clic sans gérer le serveur eux-mêmes, nous recommandons le service de déploiement cloud en un clic de RainYun ☁️ :
|
||||
Pour les utilisateurs qui souhaitent déployer AstrBot en un clic sans gérer le serveur, nous recommandons le service de déploiement cloud en un clic de RainYun ☁️ :
|
||||
|
||||
[](https://app.rainyun.com/apps/rca/store/5994?ref=NjU1ODg0)
|
||||
|
||||
### Déploiement de l'application de bureau
|
||||
### Application de bureau (Tauri)
|
||||
|
||||
Pour les utilisateurs qui veulent utiliser AstrBot sur desktop et passer principalement par ChatUI, nous recommandons AstrBot App.
|
||||
Pour les utilisateurs qui veulent déployer AstrBot sur desktop, utilisent principalement AstrBot ChatUI et utilisent rarement les plugins AstrBot, nous recommandons AstrBot App :
|
||||
|
||||
Accédez à [AstrBot-desktop](https://github.com/AstrBotDevs/AstrBot-desktop) pour télécharger et installer l'application ; cette méthode est conçue pour un usage desktop et n'est pas recommandée pour les scénarios serveur.
|
||||
Dépôt de l'application de bureau : [AstrBot-desktop](https://github.com/AstrBotDevs/AstrBot-desktop).
|
||||
|
||||
### Déploiement avec le lanceur
|
||||
Prend en charge plusieurs architectures système, installation directe, prête à l'emploi. Solution de déploiement bureau en un clic, particulièrement adaptée aux débutants. Non recommandée pour les serveurs.
|
||||
|
||||
Également sur desktop, pour les utilisateurs qui souhaitent un déploiement rapide avec isolation d'environnement et multi-instances, nous recommandons AstrBot Launcher.
|
||||
### Déploiement en un clic avec le lanceur (AstrBot Launcher)
|
||||
|
||||
Accédez à [AstrBot Launcher](https://github.com/Raven95676/astrbot-launcher) pour télécharger et installer.
|
||||
Pour les utilisateurs qui veulent une solution de déploiement rapide et multi-instances avec isolation d'environnement, nous recommandons d'utiliser AstrBot Launcher :
|
||||
|
||||
Accédez au dépôt [AstrBot Launcher](https://github.com/Raven95676/astrbot-launcher) et installez le package correspondant à votre système depuis la dernière release.
|
||||
|
||||
Une solution de déploiement rapide et multi-instances avec isolation d'environnement.
|
||||
|
||||
### Déployer sur Replit
|
||||
|
||||
Le déploiement sur Replit est maintenu par la communauté et convient aux démonstrations en ligne et aux essais légers.
|
||||
Méthode de déploiement contribuée par la communauté.
|
||||
|
||||
[](https://repl.it/github/AstrBotDevs/AstrBot)
|
||||
|
||||
### AUR
|
||||
|
||||
Le mode AUR s'adresse aux utilisateurs Arch Linux qui préfèrent installer AstrBot via le gestionnaire de paquets système.
|
||||
|
||||
Exécutez la commande ci-dessous pour installer `astrbot-git`, puis lancez AstrBot localement.
|
||||
|
||||
```bash
|
||||
yay -S astrbot-git
|
||||
```
|
||||
|
||||
**Autres méthodes de déploiement**
|
||||
|
||||
Si vous avez besoin d'une gestion par panneau ou d'une personnalisation plus poussée, consultez [Déploiement BT-Panel](https://astrbot.app/deploy/astrbot/btpanel.html) pour une installation via BT Panel, [Déploiement 1Panel](https://astrbot.app/deploy/astrbot/1panel.html) pour le marketplace 1Panel, [Déploiement CasaOS](https://astrbot.app/deploy/astrbot/casaos.html) pour un déploiement visuel sur NAS/serveur domestique, et [Déploiement manuel](https://astrbot.app/deploy/astrbot/cli.html) pour une installation complète depuis les sources avec `uv`.
|
||||
**Autres méthodes de déploiement** : [Déploiement BT-Panel](https://astrbot.app/deploy/astrbot/btpanel.html) | [Déploiement 1Panel](https://astrbot.app/deploy/astrbot/1panel.html) | [Déploiement CasaOS](https://astrbot.app/deploy/astrbot/casaos.html) | [Déploiement manuel](https://astrbot.app/deploy/astrbot/cli.html)
|
||||
|
||||
## Plateformes de messagerie prises en charge
|
||||
|
||||
|
||||
+15
-26
@@ -73,7 +73,7 @@ AstrBot は、主要なインスタントメッセージングアプリと統合
|
||||
|
||||
### ワンクリックデプロイ
|
||||
|
||||
AstrBot を素早く試したいユーザーで、コマンドラインに慣れており `uv` 環境を自分でインストールできる場合は、`uv` のワンクリックデプロイをおすすめします ⚡️:
|
||||
AstrBot を素早く試したいユーザーには、`uv` を使ったワンクリックデプロイをおすすめします ⚡️:
|
||||
|
||||
```bash
|
||||
uv tool install astrbot
|
||||
@@ -83,58 +83,47 @@ astrbot
|
||||
|
||||
> [uv](https://docs.astral.sh/uv/) のインストールが必要です。
|
||||
|
||||
> [!NOTE]
|
||||
> macOS ユーザーの場合:macOS のセキュリティチェックにより、`astrbot` コマンドの初回実行に時間がかかる場合があります(約 10〜20 秒)。
|
||||
|
||||
`astrbot` の更新:
|
||||
|
||||
```bash
|
||||
uv tool upgrade astrbot
|
||||
```
|
||||
|
||||
### Docker デプロイ
|
||||
|
||||
コンテナ運用に慣れており、より安定した本番向けのデプロイ方法を求めるユーザーには、Docker / Docker Compose での AstrBot デプロイをおすすめします。
|
||||
より安定した本番向けのデプロイを求めるユーザーには、Docker / Docker Compose で AstrBot をデプロイすることをおすすめします。
|
||||
|
||||
公式ドキュメント [Docker を使用した AstrBot のデプロイ](https://astrbot.app/deploy/astrbot/docker.html#%E4%BD%BF%E7%94%A8-docker-%E9%83%A8%E7%BD%B2-astrbot) をご参照ください。
|
||||
|
||||
### 雨云でのデプロイ
|
||||
|
||||
AstrBot をワンクリックでデプロイしたく、サーバーを自分で管理したくないユーザーには、雨云のワンクリッククラウドデプロイサービスをおすすめします ☁️:
|
||||
サーバー管理をせずに AstrBot をワンクリックでデプロイしたいユーザーには、雨云のワンクリッククラウドデプロイサービスをおすすめします ☁️:
|
||||
|
||||
[](https://app.rainyun.com/apps/rca/store/5994?ref=NjU1ODg0)
|
||||
|
||||
### デスクトップアプリのデプロイ
|
||||
### デスクトップクライアント(Tauri)
|
||||
|
||||
デスクトップで AstrBot を使い、主に ChatUI を入口として利用するユーザーには、AstrBot App をおすすめします。
|
||||
デスクトップで AstrBot を使いたいユーザーで、主に AstrBot ChatUI を利用し、AstrBot プラグインの利用頻度が低い場合は、AstrBot App の利用をおすすめします:
|
||||
|
||||
[AstrBot-desktop](https://github.com/AstrBotDevs/AstrBot-desktop) からダウンロードしてインストールしてください。この方式はデスクトップ向けであり、サーバー用途には推奨されません。
|
||||
デスクトップアプリのリポジトリ [AstrBot-desktop](https://github.com/AstrBotDevs/AstrBot-desktop)。
|
||||
|
||||
### ランチャーのデプロイ
|
||||
マルチシステムアーキテクチャに対応し、インストーラーですぐ利用可能。初心者にも使いやすいワンクリックのデスクトップデプロイ方式です。サーバー用途には推奨されません。
|
||||
|
||||
同じくデスクトップで、素早くデプロイしつつ環境を分離して多重起動したいユーザーには、AstrBot Launcher をおすすめします。
|
||||
### ランチャーによるワンクリックデプロイ(AstrBot Launcher)
|
||||
|
||||
[AstrBot Launcher](https://github.com/Raven95676/astrbot-launcher) からダウンロードしてインストールしてください。
|
||||
高速デプロイと環境分離されたマルチインスタンス運用を求めるユーザーには、AstrBot Launcher の利用をおすすめします:
|
||||
|
||||
[AstrBot Launcher](https://github.com/Raven95676/astrbot-launcher) リポジトリにアクセスし、最新リリースからお使いの OS 向けパッケージをインストールしてください。
|
||||
|
||||
高速デプロイと環境分離されたマルチインスタンス運用を実現できます。
|
||||
|
||||
### Replit でのデプロイ
|
||||
|
||||
Replit デプロイはコミュニティ提供の方式で、オンラインデモや軽量な試用に向いています。
|
||||
コミュニティ貢献によるデプロイ方法。
|
||||
|
||||
[](https://repl.it/github/AstrBotDevs/AstrBot)
|
||||
|
||||
### AUR
|
||||
|
||||
AUR 方式は Arch Linux ユーザー向けで、システムのパッケージ運用に合わせて AstrBot を導入したい場合に適しています。
|
||||
|
||||
次のコマンドで `astrbot-git` をインストールし、ローカル環境で AstrBot を起動してください。
|
||||
|
||||
```bash
|
||||
yay -S astrbot-git
|
||||
```
|
||||
|
||||
**その他のデプロイ方法**
|
||||
|
||||
パネル操作での導入やより高度なカスタマイズが必要な場合は、[宝塔パネルデプロイ](https://astrbot.app/deploy/astrbot/btpanel.html)(BT Panel 経由の導入)、[1Panel デプロイ](https://astrbot.app/deploy/astrbot/1panel.html)(1Panel アプリマーケット経由)、[CasaOS デプロイ](https://astrbot.app/deploy/astrbot/casaos.html)(NAS / ホームサーバー向け可視化導入)、[手動デプロイ](https://astrbot.app/deploy/astrbot/cli.html)(`uv` とソースベースのフルカスタム導入)を参照してください。
|
||||
**その他のデプロイ方法**:[宝塔パネルデプロイ](https://astrbot.app/deploy/astrbot/btpanel.html) | [1Panel デプロイ](https://astrbot.app/deploy/astrbot/1panel.html) | [CasaOS デプロイ](https://astrbot.app/deploy/astrbot/casaos.html) | [手動デプロイ](https://astrbot.app/deploy/astrbot/cli.html)
|
||||
|
||||
## サポートされているメッセージプラットフォーム
|
||||
|
||||
|
||||
+16
-27
@@ -73,7 +73,7 @@ AstrBot — это универсальная платформа Agent-чатб
|
||||
|
||||
### Развёртывание в один клик
|
||||
|
||||
Для пользователей, которые хотят быстро попробовать AstrBot, знакомы с командной строкой и могут самостоятельно установить окружение `uv`, мы рекомендуем использовать развёртывание в один клик через `uv` ⚡️:
|
||||
Для пользователей, которые хотят быстро попробовать AstrBot, мы рекомендуем использовать развёртывание в один клик через `uv` ⚡️:
|
||||
|
||||
```bash
|
||||
uv tool install astrbot
|
||||
@@ -83,58 +83,47 @@ astrbot
|
||||
|
||||
> Требуется установленный [uv](https://docs.astral.sh/uv/).
|
||||
|
||||
> [!NOTE]
|
||||
> Для пользователей macOS: из-за проверок безопасности macOS первый запуск команды `astrbot` может занять больше времени (около 10-20 секунд).
|
||||
|
||||
Обновить `astrbot`:
|
||||
|
||||
```bash
|
||||
uv tool upgrade astrbot
|
||||
```
|
||||
|
||||
### Развёртывание Docker
|
||||
|
||||
Для пользователей, знакомых с контейнерами и которым нужен более стабильный и подходящий для production способ, мы рекомендуем разворачивать AstrBot через Docker / Docker Compose.
|
||||
Для пользователей, которым нужен более стабильный и готовый к production вариант, мы рекомендуем развёртывать AstrBot через Docker / Docker Compose.
|
||||
|
||||
См. официальную документацию [Развёртывание AstrBot с Docker](https://astrbot.app/deploy/astrbot/docker.html#%E4%BD%BF%E7%94%A8-docker-%E9%83%A8%E7%BD%B2-astrbot).
|
||||
См. официальную документацию: [Развёртывание AstrBot с Docker](https://astrbot.app/deploy/astrbot/docker.html#%E4%BD%BF%E7%94%A8-docker-%E9%83%A8%E7%BD%B2-astrbot).
|
||||
|
||||
### Развёртывание на RainYun
|
||||
|
||||
Для пользователей, которые хотят развернуть AstrBot в один клик и не хотят самостоятельно управлять сервером, мы рекомендуем облачный сервис развёртывания в один клик от RainYun ☁️:
|
||||
Для пользователей, которые хотят развернуть AstrBot в один клик и не управлять сервером самостоятельно, мы рекомендуем облачный сервис развёртывания в один клик от RainYun ☁️:
|
||||
|
||||
[](https://app.rainyun.com/apps/rca/store/5994?ref=NjU1ODg0)
|
||||
|
||||
### Развёртывание десктопного приложения
|
||||
### Десктопное приложение (Tauri)
|
||||
|
||||
Для пользователей, которые хотят использовать AstrBot на десктопе и в основном работают через ChatUI, мы рекомендуем AstrBot App.
|
||||
Для пользователей, которые хотят использовать AstrBot на десктопе, в основном работают с AstrBot ChatUI и редко используют плагины AstrBot, мы рекомендуем AstrBot App:
|
||||
|
||||
Перейдите в [AstrBot-desktop](https://github.com/AstrBotDevs/AstrBot-desktop), скачайте и установите приложение; этот вариант предназначен для десктопа и не рекомендуется для серверных сценариев.
|
||||
Репозиторий десктопного приложения: [AstrBot-desktop](https://github.com/AstrBotDevs/AstrBot-desktop).
|
||||
|
||||
### Развёртывание через лаунчер
|
||||
Поддерживает разные архитектуры систем, устанавливается напрямую и работает сразу после установки. Удобное настольное развёртывание в один клик для новичков. Не рекомендуется для серверных сценариев.
|
||||
|
||||
Также на десктопе, для пользователей, которым нужен быстрый запуск и мультиинстанс с изоляцией окружений, мы рекомендуем AstrBot Launcher.
|
||||
### Установка в один клик через лаунчер (AstrBot Launcher)
|
||||
|
||||
Перейдите в [AstrBot Launcher](https://github.com/Raven95676/astrbot-launcher), чтобы скачать и установить.
|
||||
Для пользователей, которым нужно быстрое развёртывание и мультиинстанс с изоляцией окружений, мы рекомендуем использовать AstrBot Launcher:
|
||||
|
||||
Перейдите в репозиторий [AstrBot Launcher](https://github.com/Raven95676/astrbot-launcher), откройте Releases и установите пакет для вашей системы из последней версии.
|
||||
|
||||
Быстрое развёртывание и мультиинстанс-решение с изоляцией окружений.
|
||||
|
||||
### Развёртывание на Replit
|
||||
|
||||
Развёртывание через Replit поддерживается сообществом и подходит для онлайн-демо и лёгких тестовых запусков.
|
||||
Метод развёртывания от сообщества.
|
||||
|
||||
[](https://repl.it/github/AstrBotDevs/AstrBot)
|
||||
|
||||
### AUR
|
||||
|
||||
AUR-вариант предназначен для пользователей Arch Linux, которым удобна установка через системный менеджер пакетов.
|
||||
|
||||
Выполните команду ниже для установки `astrbot-git`, затем запустите AstrBot локально.
|
||||
|
||||
```bash
|
||||
yay -S astrbot-git
|
||||
```
|
||||
|
||||
**Другие способы развёртывания**
|
||||
|
||||
Если вам нужна панельная установка или более глубокая кастомизация, смотрите [Развёртывание BT-Panel](https://astrbot.app/deploy/astrbot/btpanel.html) (установка через BT Panel), [Развёртывание 1Panel](https://astrbot.app/deploy/astrbot/1panel.html) (развёртывание через маркетплейс 1Panel), [Развёртывание CasaOS](https://astrbot.app/deploy/astrbot/casaos.html) (визуальный вариант для NAS и домашних серверов) и [Ручное развёртывание](https://astrbot.app/deploy/astrbot/cli.html) (полностью настраиваемая установка из исходников через `uv`).
|
||||
**Другие способы развёртывания**: [Развёртывание BT-Panel](https://astrbot.app/deploy/astrbot/btpanel.html) | [Развёртывание 1Panel](https://astrbot.app/deploy/astrbot/1panel.html) | [Развёртывание CasaOS](https://astrbot.app/deploy/astrbot/casaos.html) | [Ручное развёртывание](https://astrbot.app/deploy/astrbot/cli.html)
|
||||
|
||||
## Поддерживаемые платформы обмена сообщениями
|
||||
|
||||
|
||||
+15
-30
@@ -73,7 +73,7 @@ AstrBot 是一個開源的一站式 Agent 聊天機器人平台,可接入主
|
||||
|
||||
### 一鍵部署
|
||||
|
||||
對於想快速體驗 AstrBot、且熟悉命令列並能自行安裝 `uv` 環境的使用者,我們推薦使用 `uv` 一鍵部署方式 ⚡️。
|
||||
對於想快速體驗 AstrBot 的使用者,我們推薦使用 `uv` 一鍵部署方式 ⚡️:
|
||||
|
||||
```bash
|
||||
uv tool install astrbot
|
||||
@@ -83,20 +83,11 @@ astrbot
|
||||
|
||||
> 需要安裝 [uv](https://docs.astral.sh/uv/)。
|
||||
|
||||
> [!NOTE]
|
||||
> 對於 macOS 使用者:由於 macOS 安全性檢查,首次執行 `astrbot` 指令可能需要較長時間(約 10-20 秒)。
|
||||
|
||||
更新 `astrbot`:
|
||||
|
||||
```bash
|
||||
uv tool upgrade astrbot
|
||||
```
|
||||
|
||||
### Docker 部署
|
||||
|
||||
對於熟悉容器、希望獲得更穩定且更適合正式環境部署方式的使用者,我們推薦使用 Docker / Docker Compose 部署 AstrBot。
|
||||
對於希望獲得更穩定、更適合正式環境部署方式的使用者,我們推薦使用 Docker / Docker Compose 部署 AstrBot。
|
||||
|
||||
請參考官方文件 [使用 Docker 部署 AstrBot](https://astrbot.app/deploy/astrbot/docker.html#%E4%BD%BF%E7%94%A8-docker-%E9%83%A8%E7%BD%B2-astrbot)。
|
||||
請參閱官方文件 [使用 Docker 部署 AstrBot](https://astrbot.app/deploy/astrbot/docker.html#%E4%BD%BF%E7%94%A8-docker-%E9%83%A8%E7%BD%B2-astrbot)。
|
||||
|
||||
### 在雨雲上部署
|
||||
|
||||
@@ -104,37 +95,35 @@ uv tool upgrade astrbot
|
||||
|
||||
[](https://app.rainyun.com/apps/rca/store/5994?ref=NjU1ODg0)
|
||||
|
||||
### 桌面客戶端部署
|
||||
### 桌面客戶端(Tauri)
|
||||
|
||||
對於希望在桌面端使用 AstrBot、並以 ChatUI 為主要入口的使用者,我們推薦使用 AstrBot App。
|
||||
對於希望在桌面部署 AstrBot、以 AstrBot ChatUI 為主要使用方式、較少使用 AstrBot 外掛的使用者,我們推薦使用 AstrBot App:
|
||||
|
||||
前往 [AstrBot-desktop](https://github.com/AstrBotDevs/AstrBot-desktop) 下載並安裝;此方式面向桌面使用,不建議伺服器場景。
|
||||
桌面應用倉庫 [AstrBot-desktop](https://github.com/AstrBotDevs/AstrBot-desktop)。
|
||||
|
||||
### 啟動器部署
|
||||
支援多系統架構,安裝包直接安裝,開箱即用,最適合新手和懶人的一鍵桌面部署方案,不推薦伺服器場景。
|
||||
|
||||
同樣在桌面端,對於希望快速部署並實現環境隔離多開的使用者,我們推薦使用 AstrBot Launcher。
|
||||
### 啟動器一鍵部署(AstrBot Launcher)
|
||||
|
||||
前往 [AstrBot Launcher](https://github.com/Raven95676/astrbot-launcher) 下載並安裝。
|
||||
對於希望快速部署並實現環境隔離多開的使用者,我們推薦使用 AstrBot Launcher:
|
||||
|
||||
進入 [AstrBot Launcher](https://github.com/Raven95676/astrbot-launcher) 倉庫,在 Releases 頁最新版本下找到對應的系統安裝包安裝即可。
|
||||
|
||||
一個快速部署和多開方案,實現環境隔離。
|
||||
|
||||
### 在 Replit 上部署
|
||||
|
||||
Replit 部署由社群維護,適合線上示範與輕量試用情境。
|
||||
社群貢獻的部署方式。
|
||||
|
||||
[](https://repl.it/github/AstrBotDevs/AstrBot)
|
||||
|
||||
### AUR
|
||||
|
||||
AUR 方式面向 Arch Linux 使用者,適合希望透過系統套件管理器安裝 AstrBot 的場景。
|
||||
|
||||
在終端執行下方命令安裝 `astrbot-git` 套件,安裝完成後即可啟動使用。
|
||||
|
||||
```bash
|
||||
yay -S astrbot-git
|
||||
```
|
||||
|
||||
**更多部署方式**
|
||||
|
||||
若你需要面板化或更高自訂程度的部署,可參考 [寶塔面板](https://astrbot.app/deploy/astrbot/btpanel.html)(BT Panel 應用商店安裝)、[1Panel](https://astrbot.app/deploy/astrbot/1panel.html)(1Panel 應用商店安裝)、[CasaOS](https://astrbot.app/deploy/astrbot/casaos.html)(NAS / 家用伺服器可視化部署)與 [手動部署](https://astrbot.app/deploy/astrbot/cli.html)(基於原始碼與 `uv` 的完整自訂安裝)。
|
||||
**更多部署方式**:[寶塔面板](https://astrbot.app/deploy/astrbot/btpanel.html) | [1Panel](https://astrbot.app/deploy/astrbot/1panel.html) | [CasaOS](https://astrbot.app/deploy/astrbot/casaos.html) | [手動部署](https://astrbot.app/deploy/astrbot/cli.html)
|
||||
|
||||
## 支援的訊息平台
|
||||
|
||||
@@ -217,14 +206,10 @@ pre-commit install
|
||||
|
||||
### QQ 群組
|
||||
|
||||
- 9 群: 1076659624 (新)
|
||||
- 10 群: 1078079676 (新)
|
||||
- 1 群:322154837
|
||||
- 3 群:630166526
|
||||
- 5 群:822130018
|
||||
- 6 群:753075035
|
||||
- 7 群:743746109
|
||||
- 8 群:1030353265
|
||||
- 開發者群:975206796
|
||||
|
||||
### Discord 群組
|
||||
|
||||
+15
-28
@@ -73,7 +73,7 @@ AstrBot 是一个开源的一站式 Agentic 个人和群聊助手,可在 QQ、
|
||||
|
||||
### 一键部署
|
||||
|
||||
对于想快速体验 AstrBot、且熟悉命令行并能够自行安装 `uv` 环境的用户,我们推荐使用 `uv` 一键部署方式 ⚡️。
|
||||
对于想快速体验 AstrBot 的用户,我们推荐使用 `uv` 一键部署方式 ⚡️:
|
||||
|
||||
```bash
|
||||
uv tool install astrbot
|
||||
@@ -83,20 +83,11 @@ astrbot
|
||||
|
||||
> 需要安装 [uv](https://docs.astral.sh/uv/)。
|
||||
|
||||
> [!NOTE]
|
||||
> 对于 macOS 用户:由于 macOS 安全检查,首次运行 `astrbot` 命令可能需要较长时间(约 10-20 秒)。
|
||||
|
||||
更新 `astrbot`:
|
||||
|
||||
```bash
|
||||
uv tool upgrade astrbot
|
||||
```
|
||||
|
||||
### Docker 部署
|
||||
|
||||
对于熟悉容器、希望获得更稳定且更适合生产环境部署方式的用户,我们推荐使用 Docker / Docker Compose 部署 AstrBot。
|
||||
对于希望获得更稳定、更适合生产环境部署方式的用户,我们推荐使用 Docker / Docker Compose 部署 AstrBot。
|
||||
|
||||
请参考官方文档 [使用 Docker 部署 AstrBot](https://astrbot.app/deploy/astrbot/docker.html#%E4%BD%BF%E7%94%A8-docker-%E9%83%A8%E7%BD%B2-astrbot)。
|
||||
请参阅官方文档 [使用 Docker 部署 AstrBot](https://astrbot.app/deploy/astrbot/docker.html#%E4%BD%BF%E7%94%A8-docker-%E9%83%A8%E7%BD%B2-astrbot) 。
|
||||
|
||||
### 在 雨云 上部署
|
||||
|
||||
@@ -104,37 +95,35 @@ uv tool upgrade astrbot
|
||||
|
||||
[](https://app.rainyun.com/apps/rca/store/5994?ref=NjU1ODg0)
|
||||
|
||||
### 桌面客户端部署
|
||||
### 桌面客户端(Tauri)
|
||||
|
||||
对于希望在桌面端使用 AstrBot、并以 ChatUI 为主要入口的用户,我们推荐使用 AstrBot App。
|
||||
对于希望在桌面部署 AstrBot、以 AstrBot ChatUI 为主要使用方式、较少使用 AstrBot 插件的用户,我们推荐使用 AstrBot App:
|
||||
|
||||
前往 [AstrBot-desktop](https://github.com/AstrBotDevs/AstrBot-desktop) 下载并安装;该方式面向桌面使用,不推荐服务器场景。
|
||||
桌面应用仓库 [AstrBot-desktop](https://github.com/AstrBotDevs/AstrBot-desktop)。
|
||||
|
||||
### 启动器部署
|
||||
支持多系统架构,安装包直接安装,开箱即用,最适合新手和懒人的一键桌面部署方案,不推荐服务器场景。
|
||||
|
||||
同样在桌面端,希望快速部署并实现环境隔离多开的用户,我们推荐使用 AstrBot Launcher。
|
||||
### 启动器一键部署(AstrBot Launcher)
|
||||
|
||||
前往 [AstrBot Launcher](https://github.com/Raven95676/astrbot-launcher) 下载并安装。
|
||||
对于希望快速部署并实现环境隔离多开的用户,我们推荐使用 AstrBot Launcher:
|
||||
|
||||
进入 [AstrBot Launcher](https://github.com/Raven95676/astrbot-launcher) 仓库,在 Releases 页最新版本下找到对应的系统安装包安装即可。
|
||||
|
||||
一个快速部署和多开方案,实现环境隔离。
|
||||
|
||||
### 在 Replit 上部署
|
||||
|
||||
Replit 部署由社区维护,适合在线演示和轻量试用场景。
|
||||
社区贡献的部署方式。
|
||||
|
||||
[](https://repl.it/github/AstrBotDevs/AstrBot)
|
||||
|
||||
### AUR
|
||||
|
||||
AUR 方式面向 Arch Linux 用户,适合希望通过系统包管理器安装 AstrBot 的场景。
|
||||
|
||||
在终端执行下方命令安装 `astrbot-git` 包,安装完成后即可启动使用。
|
||||
|
||||
```bash
|
||||
yay -S astrbot-git
|
||||
```
|
||||
|
||||
**更多部署方式**
|
||||
|
||||
若你需要面板化或更高自定义部署,可参考 [宝塔面板](https://astrbot.app/deploy/astrbot/btpanel.html)(BT Panel 应用商店安装)、[1Panel](https://astrbot.app/deploy/astrbot/1panel.html)(1Panel 应用商店安装)、[CasaOS](https://astrbot.app/deploy/astrbot/casaos.html)(NAS / 家庭服务器可视化部署)和 [手动部署](https://astrbot.app/deploy/astrbot/cli.html)(基于源码与 `uv` 的完整自定义安装)。
|
||||
**更多部署方式**:[宝塔面板](https://astrbot.app/deploy/astrbot/btpanel.html) | [1Panel](https://astrbot.app/deploy/astrbot/1panel.html) | [CasaOS](https://astrbot.app/deploy/astrbot/casaos.html) | [手动部署](https://astrbot.app/deploy/astrbot/cli.html)
|
||||
|
||||
## 支持的消息平台
|
||||
|
||||
@@ -218,8 +207,6 @@ pre-commit install
|
||||
|
||||
### QQ 群组
|
||||
|
||||
- 9 群: 1076659624 (新)
|
||||
- 10 群: 1078079676 (新)
|
||||
- 1 群:322154837
|
||||
- 3 群:630166526
|
||||
- 5 群:822130018
|
||||
|
||||
@@ -1 +1 @@
|
||||
__version__ = "4.19.5"
|
||||
__version__ = "4.18.3"
|
||||
|
||||
@@ -144,14 +144,10 @@ class MCPClient:
|
||||
|
||||
cfg = _prepare_config(mcp_server_config.copy())
|
||||
|
||||
def logging_callback(
|
||||
msg: str | mcp.types.LoggingMessageNotificationParams,
|
||||
) -> None:
|
||||
def logging_callback(msg: str) -> None:
|
||||
# Handle MCP service error logs
|
||||
if isinstance(msg, mcp.types.LoggingMessageNotificationParams):
|
||||
if msg.level in ("warning", "error", "critical", "alert", "emergency"):
|
||||
log_msg = f"[{msg.level.upper()}] {str(msg.data)}"
|
||||
self.server_errlogs.append(log_msg)
|
||||
print(f"MCP Server {name} Error: {msg}")
|
||||
self.server_errlogs.append(msg)
|
||||
|
||||
if "url" in cfg:
|
||||
success, error_msg = await _quick_test_mcp_connection(cfg)
|
||||
@@ -218,24 +214,15 @@ class MCPClient:
|
||||
**cfg,
|
||||
)
|
||||
|
||||
def callback(msg: str | mcp.types.LoggingMessageNotificationParams) -> None:
|
||||
def callback(msg: str) -> None:
|
||||
# Handle MCP service error logs
|
||||
if isinstance(msg, mcp.types.LoggingMessageNotificationParams):
|
||||
if msg.level in (
|
||||
"warning",
|
||||
"error",
|
||||
"critical",
|
||||
"alert",
|
||||
"emergency",
|
||||
):
|
||||
log_msg = f"[{msg.level.upper()}] {str(msg.data)}"
|
||||
self.server_errlogs.append(log_msg)
|
||||
self.server_errlogs.append(msg)
|
||||
|
||||
stdio_transport = await self.exit_stack.enter_async_context(
|
||||
mcp.stdio_client(
|
||||
server_params,
|
||||
errlog=LogPipe(
|
||||
level=logging.INFO,
|
||||
level=logging.ERROR,
|
||||
logger=logger,
|
||||
identifier=f"MCPServer-{name}",
|
||||
callback=callback,
|
||||
|
||||
@@ -302,7 +302,7 @@ class DashscopeAgentRunner(BaseAgentRunner[TContext]):
|
||||
|
||||
while True:
|
||||
try:
|
||||
item_type, item_data = await asyncio.get_running_loop().run_in_executor(
|
||||
item_type, item_data = await asyncio.get_event_loop().run_in_executor(
|
||||
None, response_queue.get, True, 1
|
||||
)
|
||||
except queue.Empty:
|
||||
@@ -388,7 +388,7 @@ class DashscopeAgentRunner(BaseAgentRunner[TContext]):
|
||||
|
||||
# 发起请求
|
||||
partial = functools.partial(Application.call, **payload)
|
||||
response = await asyncio.get_running_loop().run_in_executor(None, partial)
|
||||
response = await asyncio.get_event_loop().run_in_executor(None, partial)
|
||||
|
||||
async for resp in self._handle_streaming_response(response, session_id):
|
||||
yield resp
|
||||
|
||||
@@ -121,12 +121,11 @@ class BayContainerManager:
|
||||
async def wait_healthy(self, timeout: int = HEALTH_TIMEOUT_S) -> None:
|
||||
"""Block until Bay's ``/health`` endpoint returns 200."""
|
||||
url = f"http://127.0.0.1:{self._host_port}/health"
|
||||
loop = asyncio.get_running_loop()
|
||||
deadline = loop.time() + timeout
|
||||
deadline = asyncio.get_event_loop().time() + timeout
|
||||
last_error: str = ""
|
||||
|
||||
async with aiohttp.ClientSession() as session:
|
||||
while loop.time() < deadline:
|
||||
while asyncio.get_event_loop().time() < deadline:
|
||||
try:
|
||||
async with session.get(
|
||||
url, timeout=aiohttp.ClientTimeout(total=3)
|
||||
|
||||
@@ -5,7 +5,7 @@ from typing import Any, TypedDict
|
||||
|
||||
from astrbot.core.utils.astrbot_path import get_astrbot_data_path
|
||||
|
||||
VERSION = "4.19.5"
|
||||
VERSION = "4.18.3"
|
||||
DB_PATH = os.path.join(get_astrbot_data_path(), "data_v4.db")
|
||||
|
||||
WEBHOOK_SUPPORTED_PLATFORMS = [
|
||||
@@ -342,20 +342,14 @@ CONFIG_METADATA_2 = {
|
||||
"企业微信智能机器人": {
|
||||
"id": "wecom_ai_bot",
|
||||
"type": "wecom_ai_bot",
|
||||
"hint": "如果发现字段有异常,请重新创建",
|
||||
"enable": True,
|
||||
"wecom_ai_bot_connection_mode": "long_connection", # long_connection, webhook
|
||||
"wecom_ai_bot_name": "",
|
||||
"wecomaibot_ws_bot_id": "",
|
||||
"wecomaibot_ws_secret": "",
|
||||
"wecomaibot_token": "",
|
||||
"wecomaibot_encoding_aes_key": "",
|
||||
"wecomaibot_init_respond_text": "",
|
||||
"wecomaibot_friend_message_welcome_text": "",
|
||||
"wecom_ai_bot_name": "",
|
||||
"msg_push_webhook_url": "",
|
||||
"only_use_webhook_url_to_send": False,
|
||||
"wecomaibot_ws_url": "wss://openws.work.weixin.qq.com",
|
||||
"wecomaibot_heartbeat_interval": 30,
|
||||
"token": "",
|
||||
"encoding_aes_key": "",
|
||||
"unified_webhook_mode": True,
|
||||
"webhook_uuid": "",
|
||||
"callback_server_host": "0.0.0.0",
|
||||
@@ -738,13 +732,6 @@ CONFIG_METADATA_2 = {
|
||||
"type": "string",
|
||||
"hint": "请务必填写正确,否则无法使用一些指令。",
|
||||
},
|
||||
"wecom_ai_bot_connection_mode": {
|
||||
"description": "企业微信智能机器人连接模式",
|
||||
"type": "string",
|
||||
"options": ["webhook", "long_connection"],
|
||||
"labels": ["Webhook 回调", "长连接"],
|
||||
"hint": "Webhook 回调模式需要配置 Token/EncodingAESKey。长连接模式需要配置 BotID/Secret。",
|
||||
},
|
||||
"wecomaibot_init_respond_text": {
|
||||
"description": "企业微信智能机器人初始响应文本",
|
||||
"type": "string",
|
||||
@@ -755,22 +742,6 @@ CONFIG_METADATA_2 = {
|
||||
"type": "string",
|
||||
"hint": "当用户当天进入智能机器人单聊会话,回复欢迎语,留空则不回复。",
|
||||
},
|
||||
"wecomaibot_token": {
|
||||
"description": "企业微信智能机器人 Token",
|
||||
"type": "string",
|
||||
"hint": "用于 Webhook 回调模式的身份验证。",
|
||||
"condition": {
|
||||
"wecom_ai_bot_connection_mode": "webhook",
|
||||
},
|
||||
},
|
||||
"wecomaibot_encoding_aes_key": {
|
||||
"description": "企业微信智能机器人 EncodingAESKey",
|
||||
"type": "string",
|
||||
"hint": "用于 Webhook 回调模式的消息加密解密。",
|
||||
"condition": {
|
||||
"wecom_ai_bot_connection_mode": "webhook",
|
||||
},
|
||||
},
|
||||
"msg_push_webhook_url": {
|
||||
"description": "企业微信消息推送 Webhook URL",
|
||||
"type": "string",
|
||||
@@ -781,40 +752,6 @@ CONFIG_METADATA_2 = {
|
||||
"type": "bool",
|
||||
"hint": "启用后,企业微信智能机器人的所有回复都改为通过消息推送 Webhook 发送。消息推送 Webhook 支持更多的消息类型(如图片、文件等)。",
|
||||
},
|
||||
"wecomaibot_ws_bot_id": {
|
||||
"description": "长连接 BotID",
|
||||
"type": "string",
|
||||
"hint": "企业微信智能机器人长连接模式凭证 BotID。",
|
||||
"condition": {
|
||||
"wecom_ai_bot_connection_mode": "long_connection",
|
||||
},
|
||||
},
|
||||
"wecomaibot_ws_secret": {
|
||||
"description": "长连接 Secret",
|
||||
"type": "string",
|
||||
"hint": "企业微信智能机器人长连接模式凭证 Secret。",
|
||||
"condition": {
|
||||
"wecom_ai_bot_connection_mode": "long_connection",
|
||||
},
|
||||
},
|
||||
"wecomaibot_ws_url": {
|
||||
"description": "长连接 WebSocket 地址",
|
||||
"type": "string",
|
||||
"invisible": True,
|
||||
"hint": "默认值为 wss://openws.work.weixin.qq.com,一般无需修改。",
|
||||
"condition": {
|
||||
"wecom_ai_bot_connection_mode": "long_connection",
|
||||
},
|
||||
},
|
||||
"wecomaibot_heartbeat_interval": {
|
||||
"description": "长连接心跳间隔",
|
||||
"type": "int",
|
||||
"invisible": True,
|
||||
"hint": "长连接模式心跳间隔(秒),建议 30 秒。",
|
||||
"condition": {
|
||||
"wecom_ai_bot_connection_mode": "long_connection",
|
||||
},
|
||||
},
|
||||
"lark_bot_name": {
|
||||
"description": "飞书机器人的名字",
|
||||
"type": "string",
|
||||
@@ -859,7 +796,7 @@ CONFIG_METADATA_2 = {
|
||||
"unified_webhook_mode": {
|
||||
"description": "统一 Webhook 模式",
|
||||
"type": "bool",
|
||||
"hint": "Webhook 模式下使用 AstrBot 统一 Webhook 入口,无需单独开启端口。回调地址为 /api/platform/webhook/{webhook_uuid}。",
|
||||
"hint": "启用后,将使用 AstrBot 统一 Webhook 入口,无需单独开启端口。回调地址为 /api/platform/webhook/{webhook_uuid}。",
|
||||
},
|
||||
"webhook_uuid": {
|
||||
"invisible": True,
|
||||
@@ -1186,7 +1123,7 @@ CONFIG_METADATA_2 = {
|
||||
"enable": True,
|
||||
"key": [],
|
||||
"timeout": 120,
|
||||
"api_base": "https://openrouter.ai/api/v1",
|
||||
"api_base": "https://openrouter.ai/v1",
|
||||
"proxy": "",
|
||||
"custom_headers": {},
|
||||
},
|
||||
|
||||
@@ -539,36 +539,13 @@ class Reply(BaseMessageComponent):
|
||||
|
||||
|
||||
class Poke(BaseMessageComponent):
|
||||
type: ComponentType = ComponentType.Poke
|
||||
_type: str | int = "126"
|
||||
id: int | str | None = 0
|
||||
qq: int | str | None = 0 # deprecated: legacy field, kept for compatibility
|
||||
type: str = ComponentType.Poke
|
||||
id: int | None = 0
|
||||
qq: int | None = 0
|
||||
|
||||
def __init__(self, poke_type: str | int | None = None, **_) -> None:
|
||||
# Backward compatible with old signature: Poke(type="poke", ...)
|
||||
legacy_type = _.pop("type", None)
|
||||
if poke_type is None:
|
||||
poke_type = legacy_type
|
||||
if poke_type in (None, "", "poke", "Poke"):
|
||||
poke_type = "126"
|
||||
super().__init__(_type=str(poke_type), **_)
|
||||
|
||||
def target_id(self) -> str | None:
|
||||
"""Return normalized target id, compatible with old `qq` field."""
|
||||
for value in (self.id, self.qq):
|
||||
if value is None:
|
||||
continue
|
||||
text = str(value).strip()
|
||||
if text and text != "0":
|
||||
return text
|
||||
return None
|
||||
|
||||
def toDict(self):
|
||||
target_id = self.target_id()
|
||||
data = {"type": str(self._type or "126")}
|
||||
if target_id:
|
||||
data["id"] = target_id
|
||||
return {"type": "poke", "data": data}
|
||||
def __init__(self, type: str, **_) -> None:
|
||||
type = f"Poke:{type}"
|
||||
super().__init__(type=type, **_)
|
||||
|
||||
|
||||
class Forward(BaseMessageComponent):
|
||||
@@ -699,24 +676,21 @@ class File(BaseMessageComponent):
|
||||
|
||||
if self.url:
|
||||
try:
|
||||
# 检查是否有正在运行的 event loop
|
||||
asyncio.get_running_loop()
|
||||
logger.warning(
|
||||
"不可以在异步上下文中同步等待下载! "
|
||||
"这个警告通常发生于某些逻辑试图通过 <File>.file 获取文件消息段的文件内容。"
|
||||
"请使用 await get_file() 代替直接获取 <File>.file 字段",
|
||||
)
|
||||
return ""
|
||||
except RuntimeError:
|
||||
# 没有运行中的 event loop,可以同步执行
|
||||
try:
|
||||
# 使用 asyncio.run 安全地创建和关闭事件循环
|
||||
asyncio.run(self._download_file())
|
||||
except Exception:
|
||||
logger.exception("文件下载失败")
|
||||
loop = asyncio.get_event_loop()
|
||||
if loop.is_running():
|
||||
logger.warning(
|
||||
"不可以在异步上下文中同步等待下载! "
|
||||
"这个警告通常发生于某些逻辑试图通过 <File>.file 获取文件消息段的文件内容。"
|
||||
"请使用 await get_file() 代替直接获取 <File>.file 字段",
|
||||
)
|
||||
return ""
|
||||
# 等待下载完成
|
||||
loop.run_until_complete(self._download_file())
|
||||
|
||||
if self.file_ and os.path.exists(self.file_):
|
||||
return os.path.abspath(self.file_)
|
||||
except Exception as e:
|
||||
logger.error(f"文件下载失败: {e}")
|
||||
|
||||
return ""
|
||||
|
||||
|
||||
@@ -28,7 +28,7 @@ class RespondStage(Stage):
|
||||
Comp.At: lambda comp: bool(comp.qq) or bool(comp.name), # @
|
||||
Comp.Image: lambda comp: bool(comp.file), # 图片
|
||||
Comp.Reply: lambda comp: bool(comp.id) and comp.sender_id is not None, # 回复
|
||||
Comp.Poke: lambda comp: comp.target_id() is not None, # 戳一戳
|
||||
Comp.Poke: lambda comp: comp.id != 0 and comp.qq != 0, # 戳一戳
|
||||
Comp.Node: lambda comp: bool(comp.content), # 转发节点
|
||||
Comp.Nodes: lambda comp: bool(comp.nodes), # 多个转发节点
|
||||
Comp.File: lambda comp: bool(comp.file_ or comp.url),
|
||||
|
||||
@@ -5,7 +5,7 @@ import traceback
|
||||
from collections.abc import AsyncGenerator
|
||||
|
||||
from astrbot.core import file_token_service, html_renderer, logger
|
||||
from astrbot.core.message.components import At, Image, Node, Plain, Record, Reply
|
||||
from astrbot.core.message.components import At, File, Image, Node, Plain, Record, Reply
|
||||
from astrbot.core.message.message_event_result import ResultContentType
|
||||
from astrbot.core.pipeline.content_safety_check.stage import ContentSafetyCheckStage
|
||||
from astrbot.core.platform.astr_message_event import AstrMessageEvent
|
||||
@@ -383,11 +383,8 @@ class ResultDecorateStage(Stage):
|
||||
)
|
||||
result.chain = [node]
|
||||
|
||||
# at 回复 / 引用回复仅适用于纯文本或图文消息
|
||||
can_decorate = all(
|
||||
isinstance(item, (Plain, Image)) for item in result.chain
|
||||
)
|
||||
if can_decorate:
|
||||
has_plain = any(isinstance(item, Plain) for item in result.chain)
|
||||
if has_plain:
|
||||
# at 回复
|
||||
if (
|
||||
self.reply_with_mention
|
||||
@@ -402,4 +399,5 @@ class ResultDecorateStage(Stage):
|
||||
|
||||
# 引用回复
|
||||
if self.reply_with_quote:
|
||||
result.chain.insert(0, Reply(id=event.message_obj.message_id))
|
||||
if not any(isinstance(item, File) for item in result.chain):
|
||||
result.chain.insert(0, Reply(id=event.message_obj.message_id))
|
||||
|
||||
@@ -191,7 +191,7 @@ class AiocqhttpAdapter(Platform):
|
||||
|
||||
if "sub_type" in event:
|
||||
if event["sub_type"] == "poke" and "target_id" in event:
|
||||
abm.message.append(Poke(id=str(event["target_id"])))
|
||||
abm.message.append(Poke(qq=str(event["target_id"]), type="poke"))
|
||||
|
||||
return abm
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ from dingtalk_stream import AckMessage
|
||||
|
||||
from astrbot import logger
|
||||
from astrbot.api.event import MessageChain
|
||||
from astrbot.api.message_components import At, File, Image, Plain, Record, Video
|
||||
from astrbot.api.message_components import At, Image, Plain, Record, Video
|
||||
from astrbot.api.platform import (
|
||||
AstrBotMessage,
|
||||
MessageMember,
|
||||
@@ -178,110 +178,29 @@ class DingtalkPlatformAdapter(Platform):
|
||||
abm.session_id = abm.sender.user_id
|
||||
|
||||
message_type: str = cast(str, message.message_type)
|
||||
robot_code = cast(str, message.robot_code or "")
|
||||
raw_content = cast(dict, message.extensions.get("content") or {})
|
||||
if not isinstance(raw_content, dict):
|
||||
raw_content = {}
|
||||
match message_type:
|
||||
case "text":
|
||||
abm.message_str = message.text.content.strip()
|
||||
abm.message.append(Plain(abm.message_str))
|
||||
case "picture":
|
||||
if not robot_code:
|
||||
logger.error("钉钉图片消息解析失败: 回调中缺少 robotCode")
|
||||
await self._remember_sender_binding(message, abm)
|
||||
return abm
|
||||
image_content = cast(
|
||||
dingtalk_stream.ImageContent | None,
|
||||
message.image_content,
|
||||
)
|
||||
download_code = cast(
|
||||
str, (image_content.download_code if image_content else "") or ""
|
||||
)
|
||||
if not download_code:
|
||||
logger.warning("钉钉图片消息缺少 downloadCode,已跳过")
|
||||
else:
|
||||
f_path = await self.download_ding_file(
|
||||
download_code,
|
||||
robot_code,
|
||||
"jpg",
|
||||
)
|
||||
if f_path:
|
||||
abm.message.append(Image.fromFileSystem(f_path))
|
||||
else:
|
||||
logger.warning("钉钉图片消息下载失败,无法解析为图片")
|
||||
case "richText":
|
||||
rtc: dingtalk_stream.RichTextContent = cast(
|
||||
dingtalk_stream.RichTextContent, message.rich_text_content
|
||||
)
|
||||
contents: list[dict] = cast(list[dict], rtc.rich_text_list)
|
||||
plain_parts: list[str] = []
|
||||
for content in contents:
|
||||
plains = ""
|
||||
if "text" in content:
|
||||
plain_text = cast(str, content.get("text") or "")
|
||||
if plain_text:
|
||||
plain_parts.append(plain_text)
|
||||
abm.message.append(Plain(plain_text))
|
||||
plains += content["text"]
|
||||
abm.message.append(Plain(plains))
|
||||
elif "type" in content and content["type"] == "picture":
|
||||
download_code = cast(str, content.get("downloadCode") or "")
|
||||
if not download_code:
|
||||
logger.warning(
|
||||
"钉钉富文本图片消息缺少 downloadCode,已跳过"
|
||||
)
|
||||
continue
|
||||
if not robot_code:
|
||||
logger.error(
|
||||
"钉钉富文本图片消息解析失败: 回调中缺少 robotCode"
|
||||
)
|
||||
continue
|
||||
f_path = await self.download_ding_file(
|
||||
download_code,
|
||||
robot_code,
|
||||
content["downloadCode"],
|
||||
cast(str, message.robot_code),
|
||||
"jpg",
|
||||
)
|
||||
if f_path:
|
||||
abm.message.append(Image.fromFileSystem(f_path))
|
||||
abm.message_str = "".join(plain_parts).strip()
|
||||
case "audio" | "voice":
|
||||
download_code = cast(str, raw_content.get("downloadCode") or "")
|
||||
if not download_code:
|
||||
logger.warning("钉钉语音消息缺少 downloadCode,已跳过")
|
||||
elif not robot_code:
|
||||
logger.error("钉钉语音消息解析失败: 回调中缺少 robotCode")
|
||||
else:
|
||||
voice_ext = cast(str, raw_content.get("fileExtension") or "")
|
||||
if not voice_ext:
|
||||
voice_ext = "amr"
|
||||
voice_ext = voice_ext.lstrip(".")
|
||||
f_path = await self.download_ding_file(
|
||||
download_code,
|
||||
robot_code,
|
||||
voice_ext,
|
||||
)
|
||||
if f_path:
|
||||
abm.message.append(Record.fromFileSystem(f_path))
|
||||
case "file":
|
||||
download_code = cast(str, raw_content.get("downloadCode") or "")
|
||||
if not download_code:
|
||||
logger.warning("钉钉文件消息缺少 downloadCode,已跳过")
|
||||
elif not robot_code:
|
||||
logger.error("钉钉文件消息解析失败: 回调中缺少 robotCode")
|
||||
else:
|
||||
file_name = cast(str, raw_content.get("fileName") or "")
|
||||
file_ext = Path(file_name).suffix.lstrip(".") if file_name else ""
|
||||
if not file_ext:
|
||||
file_ext = cast(str, raw_content.get("fileExtension") or "")
|
||||
if not file_ext:
|
||||
file_ext = "file"
|
||||
f_path = await self.download_ding_file(
|
||||
download_code,
|
||||
robot_code,
|
||||
file_ext,
|
||||
)
|
||||
if f_path:
|
||||
if not file_name:
|
||||
file_name = Path(f_path).name
|
||||
abm.message.append(File(name=file_name, file=f_path))
|
||||
abm.message.append(Image.fromFileSystem(f_path))
|
||||
case "audio":
|
||||
pass
|
||||
|
||||
await self._remember_sender_binding(message, abm)
|
||||
return abm # 别忘了返回转换后的消息对象
|
||||
@@ -351,23 +270,13 @@ class DingtalkPlatformAdapter(Platform):
|
||||
)
|
||||
return ""
|
||||
resp_data = await resp.json()
|
||||
download_url = cast(
|
||||
str,
|
||||
(
|
||||
resp_data.get("downloadUrl")
|
||||
or resp_data.get("data", {}).get("downloadUrl")
|
||||
or ""
|
||||
),
|
||||
)
|
||||
if not download_url:
|
||||
logger.error(f"下载钉钉文件失败: 未找到 downloadUrl, 响应: {resp_data}")
|
||||
return ""
|
||||
download_url = resp_data["data"]["downloadUrl"]
|
||||
await download_file(download_url, str(f_path))
|
||||
return str(f_path)
|
||||
|
||||
async def get_access_token(self) -> str:
|
||||
try:
|
||||
access_token = await asyncio.get_running_loop().run_in_executor(
|
||||
access_token = await asyncio.get_event_loop().run_in_executor(
|
||||
None,
|
||||
self.client_.get_access_token,
|
||||
)
|
||||
@@ -632,28 +541,6 @@ class DingtalkPlatformAdapter(Platform):
|
||||
self._safe_remove_file(cover_path)
|
||||
if converted_video:
|
||||
self._safe_remove_file(video_path)
|
||||
elif isinstance(segment, File):
|
||||
try:
|
||||
file_path = await segment.get_file()
|
||||
if not file_path:
|
||||
logger.warning("钉钉文件发送失败: 无法解析文件路径")
|
||||
continue
|
||||
media_id = await self.upload_media(file_path, "file")
|
||||
if not media_id:
|
||||
continue
|
||||
file_name = segment.name or Path(file_path).name
|
||||
file_type = Path(file_name).suffix.lstrip(".")
|
||||
await send_message(
|
||||
msg_key="sampleFile",
|
||||
msg_param={
|
||||
"mediaId": media_id,
|
||||
"fileName": file_name,
|
||||
"fileType": file_type,
|
||||
},
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"钉钉文件发送失败: {e}")
|
||||
continue
|
||||
|
||||
async def send_message_chain_to_group(
|
||||
self,
|
||||
@@ -760,7 +647,7 @@ class DingtalkPlatformAdapter(Platform):
|
||||
return
|
||||
logger.error(f"钉钉机器人启动失败: {e}")
|
||||
|
||||
loop = asyncio.get_running_loop()
|
||||
loop = asyncio.get_event_loop()
|
||||
await loop.run_in_executor(None, start_client, loop)
|
||||
|
||||
async def terminate(self) -> None:
|
||||
|
||||
@@ -34,7 +34,7 @@ from .server import LarkWebhookServer
|
||||
|
||||
|
||||
@register_platform_adapter(
|
||||
"lark", "飞书机器人官方 API 适配器", support_streaming_message=True
|
||||
"lark", "飞书机器人官方 API 适配器", support_streaming_message=False
|
||||
)
|
||||
class LarkPlatformAdapter(Platform):
|
||||
def __init__(
|
||||
@@ -491,7 +491,7 @@ class LarkPlatformAdapter(Platform):
|
||||
name="lark",
|
||||
description="飞书机器人官方 API 适配器",
|
||||
id=cast(str, self.config.get("id")),
|
||||
support_streaming_message=True,
|
||||
support_streaming_message=False,
|
||||
)
|
||||
|
||||
async def convert_msg(self, event: lark.im.v1.P2ImMessageReceiveV1) -> None:
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import asyncio
|
||||
import base64
|
||||
import json
|
||||
import os
|
||||
@@ -6,14 +5,6 @@ import uuid
|
||||
from io import BytesIO
|
||||
|
||||
import lark_oapi as lark
|
||||
from lark_oapi.api.cardkit.v1 import (
|
||||
ContentCardElementRequest,
|
||||
ContentCardElementRequestBody,
|
||||
CreateCardRequest,
|
||||
CreateCardRequestBody,
|
||||
SettingsCardRequest,
|
||||
SettingsCardRequestBody,
|
||||
)
|
||||
from lark_oapi.api.im.v1 import (
|
||||
CreateFileRequest,
|
||||
CreateFileRequestBody,
|
||||
@@ -37,7 +28,6 @@ from astrbot.core.utils.media_utils import (
|
||||
convert_video_format,
|
||||
get_media_duration,
|
||||
)
|
||||
from astrbot.core.utils.metrics import Metric
|
||||
|
||||
|
||||
class LarkMessageEvent(AstrMessageEvent):
|
||||
@@ -565,257 +555,15 @@ class LarkMessageEvent(AstrMessageEvent):
|
||||
logger.error(f"发送飞书表情回应失败({response.code}): {response.msg}")
|
||||
return
|
||||
|
||||
async def _create_streaming_card(self) -> str | None:
|
||||
"""创建一个开启流式更新模式的卡片实体,返回 card_id。"""
|
||||
if self.bot.cardkit is None:
|
||||
logger.error("[Lark] API Client cardkit 模块未初始化")
|
||||
return None
|
||||
|
||||
card_json = {
|
||||
"schema": "2.0",
|
||||
"header": {
|
||||
"title": {"content": "", "tag": "plain_text"},
|
||||
},
|
||||
"config": {
|
||||
"streaming_mode": True,
|
||||
"summary": {"content": ""},
|
||||
"streaming_config": {
|
||||
"print_frequency_ms": {"default": 50},
|
||||
"print_step": {"default": 2},
|
||||
"print_strategy": "fast",
|
||||
},
|
||||
},
|
||||
"body": {
|
||||
"elements": [
|
||||
{
|
||||
"tag": "markdown",
|
||||
"content": "",
|
||||
"element_id": "markdown_1",
|
||||
}
|
||||
]
|
||||
},
|
||||
}
|
||||
|
||||
request = (
|
||||
CreateCardRequest.builder()
|
||||
.request_body(
|
||||
CreateCardRequestBody.builder()
|
||||
.type("card_json")
|
||||
.data(json.dumps(card_json, ensure_ascii=False))
|
||||
.build()
|
||||
)
|
||||
.build()
|
||||
)
|
||||
|
||||
try:
|
||||
response = await self.bot.cardkit.v1.card.acreate(request)
|
||||
except Exception as e:
|
||||
logger.error(f"[Lark] 创建流式卡片实体失败: {e}")
|
||||
return None
|
||||
|
||||
if not response.success():
|
||||
logger.error(
|
||||
f"[Lark] 创建流式卡片实体失败({response.code}): {response.msg}"
|
||||
)
|
||||
return None
|
||||
|
||||
if response.data is None or not response.data.card_id:
|
||||
logger.error("[Lark] 创建流式卡片实体成功但未返回 card_id")
|
||||
return None
|
||||
|
||||
card_id = response.data.card_id
|
||||
logger.debug(f"[Lark] 创建流式卡片实体成功: {card_id}")
|
||||
return card_id
|
||||
|
||||
async def _send_card_message(
|
||||
self,
|
||||
card_id: str,
|
||||
reply_message_id: str | None = None,
|
||||
receive_id: str | None = None,
|
||||
receive_id_type: str | None = None,
|
||||
) -> bool:
|
||||
"""将卡片实体作为 interactive 消息发送。"""
|
||||
content = json.dumps(
|
||||
{"type": "card", "data": {"card_id": card_id}},
|
||||
ensure_ascii=False,
|
||||
)
|
||||
return await self._send_im_message(
|
||||
self.bot,
|
||||
content=content,
|
||||
msg_type="interactive",
|
||||
reply_message_id=reply_message_id,
|
||||
receive_id=receive_id,
|
||||
receive_id_type=receive_id_type,
|
||||
)
|
||||
|
||||
async def _update_streaming_text(
|
||||
self,
|
||||
card_id: str,
|
||||
content: str,
|
||||
sequence: int,
|
||||
) -> bool:
|
||||
"""调用 CardKit 流式更新文本接口,向 markdown_1 组件推送全量文本。"""
|
||||
if self.bot.cardkit is None:
|
||||
logger.error("[Lark] API Client cardkit 模块未初始化")
|
||||
return False
|
||||
|
||||
request = (
|
||||
ContentCardElementRequest.builder()
|
||||
.card_id(card_id)
|
||||
.element_id("markdown_1")
|
||||
.request_body(
|
||||
ContentCardElementRequestBody.builder()
|
||||
.content(content)
|
||||
.sequence(sequence)
|
||||
.uuid(str(uuid.uuid4()))
|
||||
.build()
|
||||
)
|
||||
.build()
|
||||
)
|
||||
|
||||
try:
|
||||
response = await self.bot.cardkit.v1.card_element.acontent(request)
|
||||
except Exception as e:
|
||||
logger.debug(f"[Lark] 流式更新文本失败 (ignored): {e}")
|
||||
return False
|
||||
|
||||
if not response.success():
|
||||
logger.debug(f"[Lark] 流式更新文本失败({response.code}): {response.msg}")
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
async def _close_streaming_mode(
|
||||
self,
|
||||
card_id: str,
|
||||
sequence: int,
|
||||
) -> None:
|
||||
"""关闭卡片的流式更新模式,使其可正常转发、摘要恢复。"""
|
||||
if self.bot.cardkit is None:
|
||||
logger.error("[Lark] API Client cardkit 模块未初始化")
|
||||
return
|
||||
|
||||
settings_json = json.dumps(
|
||||
{"config": {"streaming_mode": False}},
|
||||
ensure_ascii=False,
|
||||
)
|
||||
|
||||
request = (
|
||||
SettingsCardRequest.builder()
|
||||
.card_id(card_id)
|
||||
.request_body(
|
||||
SettingsCardRequestBody.builder()
|
||||
.settings(settings_json)
|
||||
.sequence(sequence)
|
||||
.uuid(str(uuid.uuid4()))
|
||||
.build()
|
||||
)
|
||||
.build()
|
||||
)
|
||||
|
||||
try:
|
||||
response = await self.bot.cardkit.v1.card.asettings(request)
|
||||
except Exception as e:
|
||||
logger.error(f"[Lark] 关闭流式模式失败: {e}")
|
||||
return
|
||||
|
||||
if not response.success():
|
||||
logger.error(f"[Lark] 关闭流式模式失败({response.code}): {response.msg}")
|
||||
else:
|
||||
logger.debug(f"[Lark] 流式模式已关闭: {card_id}")
|
||||
|
||||
async def _fallback_send_streaming(self, generator, use_fallback: bool = False):
|
||||
"""回退到非流式发送:缓冲全部文本后一次性发送,并保留父类副作用。"""
|
||||
async def send_streaming(self, generator, use_fallback: bool = False):
|
||||
buffer = None
|
||||
async for chain in generator:
|
||||
if not buffer:
|
||||
buffer = chain
|
||||
else:
|
||||
buffer.chain.extend(chain.chain)
|
||||
|
||||
if buffer:
|
||||
buffer.squash_plain()
|
||||
await self.send(buffer)
|
||||
|
||||
await Metric.upload(msg_event_tick=1, adapter_name=self.platform_meta.name)
|
||||
self._has_send_oper = True
|
||||
|
||||
async def send_streaming(self, generator, use_fallback: bool = False):
|
||||
"""使用 CardKit 流式卡片实现打字机效果。
|
||||
|
||||
流程:创建卡片实体 → 发送消息 → 流式更新文本 → 关闭流式模式。
|
||||
使用解耦发送循环,LLM token 到达时只更新 buffer 并唤醒发送协程,
|
||||
发送频率由网络 RTT 自然限流。
|
||||
"""
|
||||
# Step 1: 创建流式卡片实体
|
||||
card_id = await self._create_streaming_card()
|
||||
if not card_id:
|
||||
logger.warning("[Lark] 无法创建流式卡片,回退到非流式发送")
|
||||
await self._fallback_send_streaming(generator, use_fallback)
|
||||
return
|
||||
|
||||
# Step 2: 发送卡片消息
|
||||
sent = await self._send_card_message(
|
||||
card_id,
|
||||
reply_message_id=self.message_obj.message_id,
|
||||
)
|
||||
if not sent:
|
||||
logger.error("[Lark] 发送流式卡片消息失败,回退到非流式发送")
|
||||
await self._fallback_send_streaming(generator, use_fallback)
|
||||
return
|
||||
|
||||
logger.info("[Lark] 流式输出: 使用 CardKit 流式卡片")
|
||||
|
||||
# Step 3: 解耦发送循环 (Event-driven, 参考 Telegram Draft 路径)
|
||||
sequence = 0
|
||||
delta = ""
|
||||
last_sent = ""
|
||||
done = False
|
||||
text_changed = asyncio.Event()
|
||||
|
||||
async def _sender_loop() -> None:
|
||||
"""信号驱动的文本发送循环,有新内容就发,RTT 自然限流。"""
|
||||
nonlocal sequence, last_sent
|
||||
while not done:
|
||||
await text_changed.wait()
|
||||
text_changed.clear()
|
||||
snapshot = delta
|
||||
if snapshot and snapshot != last_sent:
|
||||
sequence += 1
|
||||
ok = await self._update_streaming_text(card_id, snapshot, sequence)
|
||||
if ok:
|
||||
last_sent = snapshot
|
||||
if delta != snapshot:
|
||||
text_changed.set()
|
||||
|
||||
sender_task = asyncio.create_task(_sender_loop())
|
||||
|
||||
try:
|
||||
async for chain in generator:
|
||||
if not isinstance(chain, MessageChain):
|
||||
continue
|
||||
|
||||
if chain.type == "break":
|
||||
# 飞书卡片不支持分段,忽略 break
|
||||
continue
|
||||
|
||||
for comp in chain.chain:
|
||||
if isinstance(comp, Plain):
|
||||
delta += comp.text
|
||||
text_changed.set()
|
||||
finally:
|
||||
done = True
|
||||
text_changed.set()
|
||||
await sender_task
|
||||
|
||||
# Step 4: 必要时补发最终文本 + 关闭流式模式
|
||||
if delta and delta != last_sent:
|
||||
sequence += 1
|
||||
await self._update_streaming_text(card_id, delta, sequence)
|
||||
|
||||
sequence += 1
|
||||
await self._close_streaming_mode(card_id, sequence)
|
||||
|
||||
# Step 5: 内联父类 send_streaming 的副作用
|
||||
await Metric.upload(msg_event_tick=1, adapter_name=self.platform_meta.name)
|
||||
self._has_send_oper = True
|
||||
if not buffer:
|
||||
return None
|
||||
buffer.squash_plain()
|
||||
await self.send(buffer)
|
||||
return await super().send_streaming(generator, use_fallback)
|
||||
|
||||
@@ -80,7 +80,7 @@ class QQOfficialMessageEvent(AstrMessageEvent):
|
||||
|
||||
if isinstance(source, botpy.message.C2CMessage):
|
||||
# 真流式传输
|
||||
current_time = asyncio.get_running_loop().time()
|
||||
current_time = asyncio.get_event_loop().time()
|
||||
time_since_last_edit = current_time - last_edit_time
|
||||
|
||||
if time_since_last_edit >= throttle_interval:
|
||||
@@ -90,7 +90,7 @@ class QQOfficialMessageEvent(AstrMessageEvent):
|
||||
)
|
||||
stream_payload["index"] += 1
|
||||
stream_payload["id"] = ret["id"]
|
||||
last_edit_time = asyncio.get_running_loop().time()
|
||||
last_edit_time = asyncio.get_event_loop().time()
|
||||
|
||||
if isinstance(source, botpy.message.C2CMessage):
|
||||
# 结束流式对话,并且传输 buffer 中剩余的消息
|
||||
|
||||
@@ -55,7 +55,7 @@ class QQOfficialWebhook:
|
||||
max_async=1,
|
||||
connect=bot_connect,
|
||||
dispatch=self.client.ws_dispatch,
|
||||
loop=asyncio.get_running_loop(),
|
||||
loop=asyncio.get_event_loop(),
|
||||
api=self.api,
|
||||
)
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import asyncio
|
||||
import os
|
||||
import re
|
||||
from collections.abc import Callable
|
||||
from typing import Any, cast
|
||||
|
||||
import telegramify_markdown
|
||||
@@ -22,7 +21,6 @@ from astrbot.api.message_components import (
|
||||
Video,
|
||||
)
|
||||
from astrbot.api.platform import AstrBotMessage, MessageType, PlatformMetadata
|
||||
from astrbot.core.utils.metrics import Metric
|
||||
|
||||
|
||||
class TelegramPlatformEvent(AstrMessageEvent):
|
||||
@@ -36,20 +34,6 @@ class TelegramPlatformEvent(AstrMessageEvent):
|
||||
"word": re.compile(r"\s"),
|
||||
}
|
||||
|
||||
# sendMessageDraft 的 draft_id 类级递增计数器
|
||||
_TELEGRAM_DRAFT_ID_MAX = 2_147_483_647
|
||||
_next_draft_id: int = 0
|
||||
|
||||
@classmethod
|
||||
def _allocate_draft_id(cls) -> int:
|
||||
"""分配一个递增的 draft_id,溢出时归 1。"""
|
||||
cls._next_draft_id = (
|
||||
1
|
||||
if cls._next_draft_id >= cls._TELEGRAM_DRAFT_ID_MAX
|
||||
else cls._next_draft_id + 1
|
||||
)
|
||||
return cls._next_draft_id
|
||||
|
||||
# 消息类型到 chat action 的映射,用于优先级判断
|
||||
ACTION_BY_TYPE: dict[type, str] = {
|
||||
Record: ChatAction.UPLOAD_VOICE,
|
||||
@@ -355,118 +339,6 @@ class TelegramPlatformEvent(AstrMessageEvent):
|
||||
except Exception as e:
|
||||
logger.error(f"[Telegram] 添加反应失败: {e}")
|
||||
|
||||
async def _send_message_draft(
|
||||
self,
|
||||
chat_id: str,
|
||||
draft_id: int,
|
||||
text: str,
|
||||
message_thread_id: str | None = None,
|
||||
parse_mode: str | None = None,
|
||||
) -> None:
|
||||
"""通过 Bot.send_message_draft 发送草稿消息(流式推送部分消息)。
|
||||
|
||||
该 API 仅支持私聊。
|
||||
|
||||
Args:
|
||||
chat_id: 目标私聊的 chat_id
|
||||
draft_id: 草稿唯一标识,非零整数;相同 draft_id 的变更会以动画展示
|
||||
text: 消息文本,1-4096 字符
|
||||
message_thread_id: 可选,目标消息线程 ID
|
||||
parse_mode: 可选,消息文本的解析模式
|
||||
"""
|
||||
kwargs: dict[str, Any] = {}
|
||||
if message_thread_id:
|
||||
kwargs["message_thread_id"] = int(message_thread_id)
|
||||
if parse_mode:
|
||||
kwargs["parse_mode"] = parse_mode
|
||||
|
||||
try:
|
||||
logger.debug(
|
||||
f"[Telegram] sendMessageDraft: chat_id={chat_id}, draft_id={draft_id}, text_len={len(text)}"
|
||||
)
|
||||
await self.client.send_message_draft(
|
||||
chat_id=int(chat_id),
|
||||
draft_id=draft_id,
|
||||
text=text,
|
||||
**kwargs,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"[Telegram] sendMessageDraft 失败: {e!s}")
|
||||
|
||||
async def _process_chain_items(
|
||||
self,
|
||||
chain: MessageChain,
|
||||
payload: dict[str, Any],
|
||||
user_name: str,
|
||||
message_thread_id: str | None,
|
||||
on_text: Callable[[str], None],
|
||||
) -> None:
|
||||
"""处理 MessageChain 中的各类组件,文本通过 on_text 回调追加,媒体直接发送。"""
|
||||
for i in chain.chain:
|
||||
if isinstance(i, Plain):
|
||||
on_text(i.text)
|
||||
elif isinstance(i, Image):
|
||||
image_path = await i.convert_to_file_path()
|
||||
await self._send_media_with_action(
|
||||
self.client,
|
||||
ChatAction.UPLOAD_PHOTO,
|
||||
self.client.send_photo,
|
||||
user_name=user_name,
|
||||
photo=image_path,
|
||||
**cast(Any, payload),
|
||||
)
|
||||
elif isinstance(i, File):
|
||||
path = await i.get_file()
|
||||
name = i.name or os.path.basename(path)
|
||||
await self._send_media_with_action(
|
||||
self.client,
|
||||
ChatAction.UPLOAD_DOCUMENT,
|
||||
self.client.send_document,
|
||||
user_name=user_name,
|
||||
document=path,
|
||||
filename=name,
|
||||
**cast(Any, payload),
|
||||
)
|
||||
elif isinstance(i, Record):
|
||||
path = await i.convert_to_file_path()
|
||||
await self._send_voice_with_fallback(
|
||||
self.client,
|
||||
path,
|
||||
payload,
|
||||
caption=i.text or None,
|
||||
user_name=user_name,
|
||||
message_thread_id=message_thread_id,
|
||||
use_media_action=True,
|
||||
)
|
||||
elif isinstance(i, Video):
|
||||
path = await i.convert_to_file_path()
|
||||
await self._send_media_with_action(
|
||||
self.client,
|
||||
ChatAction.UPLOAD_VIDEO,
|
||||
self.client.send_video,
|
||||
user_name=user_name,
|
||||
video=path,
|
||||
**cast(Any, payload),
|
||||
)
|
||||
else:
|
||||
logger.warning(f"不支持的消息类型: {type(i)}")
|
||||
|
||||
async def _send_final_segment(self, delta: str, payload: dict[str, Any]) -> None:
|
||||
"""将累积文本作为 MarkdownV2 真实消息发送,失败时回退到纯文本。"""
|
||||
try:
|
||||
markdown_text = telegramify_markdown.markdownify(
|
||||
delta,
|
||||
normalize_whitespace=False,
|
||||
)
|
||||
await self.client.send_message(
|
||||
text=markdown_text,
|
||||
parse_mode="MarkdownV2",
|
||||
**cast(Any, payload),
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"Markdown转换失败,使用普通文本: {e!s}")
|
||||
await self.client.send_message(text=delta, **cast(Any, payload))
|
||||
|
||||
async def send_streaming(self, generator, use_fallback: bool = False):
|
||||
message_thread_id = None
|
||||
|
||||
@@ -484,138 +356,6 @@ class TelegramPlatformEvent(AstrMessageEvent):
|
||||
if message_thread_id:
|
||||
payload["message_thread_id"] = message_thread_id
|
||||
|
||||
# sendMessageDraft 仅支持私聊(显式检查 FRIEND_MESSAGE)
|
||||
is_private = self.get_message_type() == MessageType.FRIEND_MESSAGE
|
||||
|
||||
if is_private:
|
||||
logger.info("[Telegram] 流式输出: 使用 sendMessageDraft (私聊)")
|
||||
await self._send_streaming_draft(
|
||||
user_name, message_thread_id, payload, generator
|
||||
)
|
||||
else:
|
||||
logger.info("[Telegram] 流式输出: 使用 edit_message_text fallback (群聊)")
|
||||
await self._send_streaming_edit(
|
||||
user_name, message_thread_id, payload, generator
|
||||
)
|
||||
|
||||
# 内联父类 send_streaming 的副作用(避免传入已消费的 generator)
|
||||
asyncio.create_task(
|
||||
Metric.upload(msg_event_tick=1, adapter_name=self.platform_meta.name),
|
||||
)
|
||||
self._has_send_oper = True
|
||||
|
||||
async def _send_streaming_draft(
|
||||
self,
|
||||
user_name: str,
|
||||
message_thread_id: str | None,
|
||||
payload: dict[str, Any],
|
||||
generator,
|
||||
) -> None:
|
||||
"""使用 sendMessageDraft API 进行流式推送(私聊专用)。
|
||||
|
||||
流式过程中使用 sendMessageDraft 推送草稿动画,
|
||||
流式结束后发送一条真实消息保留最终内容(draft 是临时的,会消失)。
|
||||
使用信号驱动的发送循环:每次有新 token 到达时唤醒发送,
|
||||
发送频率由网络 RTT 自然限制(最多一个请求 in-flight)。
|
||||
"""
|
||||
draft_id = self._allocate_draft_id()
|
||||
delta = ""
|
||||
last_sent_text = ""
|
||||
done = False # 信号:生成器已结束
|
||||
text_changed = asyncio.Event() # 有新 token 到达时触发
|
||||
|
||||
async def _draft_sender_loop() -> None:
|
||||
"""信号驱动的草稿发送循环,有新内容就发,RTT 自然限流。"""
|
||||
nonlocal last_sent_text
|
||||
while not done:
|
||||
await text_changed.wait()
|
||||
text_changed.clear()
|
||||
# 发送最新的缓冲区内容(MarkdownV2 渲染,与真实消息一致)
|
||||
if delta and delta != last_sent_text:
|
||||
draft_text = delta[: self.MAX_MESSAGE_LENGTH]
|
||||
if draft_text != last_sent_text:
|
||||
try:
|
||||
md = telegramify_markdown.markdownify(
|
||||
draft_text,
|
||||
normalize_whitespace=False,
|
||||
)
|
||||
await self._send_message_draft(
|
||||
user_name,
|
||||
draft_id,
|
||||
md,
|
||||
message_thread_id,
|
||||
parse_mode="MarkdownV2",
|
||||
)
|
||||
last_sent_text = draft_text
|
||||
except Exception:
|
||||
# markdownify 对未闭合语法可能失败,回退纯文本
|
||||
try:
|
||||
await self._send_message_draft(
|
||||
user_name,
|
||||
draft_id,
|
||||
draft_text,
|
||||
message_thread_id,
|
||||
)
|
||||
last_sent_text = draft_text
|
||||
except Exception as e2:
|
||||
logger.debug(
|
||||
f"[Telegram] sendMessageDraft failed (ignored): {e2!s}"
|
||||
)
|
||||
|
||||
sender_task = asyncio.create_task(_draft_sender_loop())
|
||||
|
||||
def _append_text(t: str) -> None:
|
||||
nonlocal delta
|
||||
delta += t
|
||||
text_changed.set() # 唤醒发送循环
|
||||
|
||||
try:
|
||||
async for chain in generator:
|
||||
if not isinstance(chain, MessageChain):
|
||||
continue
|
||||
|
||||
if chain.type == "break":
|
||||
# 分割符:发送真实消息保留内容,重置缓冲区
|
||||
if delta:
|
||||
# 用 emoji 清空 draft 显示,避免 draft 和真实消息同时可见
|
||||
await self._send_message_draft(
|
||||
user_name,
|
||||
draft_id,
|
||||
"\u23f3",
|
||||
message_thread_id,
|
||||
)
|
||||
await self._send_final_segment(delta, payload)
|
||||
delta = ""
|
||||
last_sent_text = ""
|
||||
draft_id = self._allocate_draft_id()
|
||||
continue
|
||||
|
||||
await self._process_chain_items(
|
||||
chain, payload, user_name, message_thread_id, _append_text
|
||||
)
|
||||
finally:
|
||||
done = True
|
||||
text_changed.set() # 唤醒循环使其退出
|
||||
await sender_task
|
||||
|
||||
# 流式结束:用 emoji 清空 draft,然后发真实消息持久化
|
||||
if delta:
|
||||
await self._send_message_draft(
|
||||
user_name,
|
||||
draft_id,
|
||||
"\u23f3",
|
||||
message_thread_id,
|
||||
)
|
||||
await self._send_final_segment(delta, payload)
|
||||
|
||||
async def _send_streaming_edit(
|
||||
self,
|
||||
user_name: str,
|
||||
message_thread_id: str | None,
|
||||
payload: dict[str, Any],
|
||||
generator,
|
||||
) -> None:
|
||||
"""使用 send_message + edit_message_text 进行流式推送(群聊 fallback)。"""
|
||||
delta = ""
|
||||
current_content = ""
|
||||
message_id = None
|
||||
@@ -626,69 +366,123 @@ class TelegramPlatformEvent(AstrMessageEvent):
|
||||
|
||||
# 发送初始 typing 状态
|
||||
await self._ensure_typing(user_name, message_thread_id)
|
||||
last_chat_action_time = asyncio.get_running_loop().time()
|
||||
|
||||
def _append_text(t: str) -> None:
|
||||
nonlocal delta
|
||||
delta += t
|
||||
last_chat_action_time = asyncio.get_event_loop().time()
|
||||
|
||||
async for chain in generator:
|
||||
if not isinstance(chain, MessageChain):
|
||||
continue
|
||||
if isinstance(chain, MessageChain):
|
||||
if chain.type == "break":
|
||||
# 分割符
|
||||
if message_id:
|
||||
try:
|
||||
await self.client.edit_message_text(
|
||||
text=delta,
|
||||
chat_id=payload["chat_id"],
|
||||
message_id=message_id,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"编辑消息失败(streaming-break): {e!s}")
|
||||
message_id = None # 重置消息 ID
|
||||
delta = "" # 重置 delta
|
||||
continue
|
||||
|
||||
if chain.type == "break":
|
||||
# 分割符
|
||||
if message_id:
|
||||
try:
|
||||
await self.client.edit_message_text(
|
||||
text=delta,
|
||||
chat_id=payload["chat_id"],
|
||||
message_id=message_id,
|
||||
# 处理消息链中的每个组件
|
||||
for i in chain.chain:
|
||||
if isinstance(i, Plain):
|
||||
delta += i.text
|
||||
elif isinstance(i, Image):
|
||||
image_path = await i.convert_to_file_path()
|
||||
await self._send_media_with_action(
|
||||
self.client,
|
||||
ChatAction.UPLOAD_PHOTO,
|
||||
self.client.send_photo,
|
||||
user_name=user_name,
|
||||
photo=image_path,
|
||||
**cast(Any, payload),
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"编辑消息失败(streaming-break): {e!s}")
|
||||
message_id = None
|
||||
delta = ""
|
||||
continue
|
||||
continue
|
||||
elif isinstance(i, File):
|
||||
path = await i.get_file()
|
||||
name = i.name or os.path.basename(path)
|
||||
await self._send_media_with_action(
|
||||
self.client,
|
||||
ChatAction.UPLOAD_DOCUMENT,
|
||||
self.client.send_document,
|
||||
user_name=user_name,
|
||||
document=path,
|
||||
filename=name,
|
||||
**cast(Any, payload),
|
||||
)
|
||||
continue
|
||||
elif isinstance(i, Record):
|
||||
path = await i.convert_to_file_path()
|
||||
await self._send_voice_with_fallback(
|
||||
self.client,
|
||||
path,
|
||||
payload,
|
||||
caption=i.text or delta or None,
|
||||
user_name=user_name,
|
||||
message_thread_id=message_thread_id,
|
||||
use_media_action=True,
|
||||
)
|
||||
continue
|
||||
elif isinstance(i, Video):
|
||||
path = await i.convert_to_file_path()
|
||||
await self._send_media_with_action(
|
||||
self.client,
|
||||
ChatAction.UPLOAD_VIDEO,
|
||||
self.client.send_video,
|
||||
user_name=user_name,
|
||||
video=path,
|
||||
**cast(Any, payload),
|
||||
)
|
||||
continue
|
||||
else:
|
||||
logger.warning(f"不支持的消息类型: {type(i)}")
|
||||
continue
|
||||
|
||||
await self._process_chain_items(
|
||||
chain, payload, user_name, message_thread_id, _append_text
|
||||
)
|
||||
# Plain
|
||||
if message_id and len(delta) <= self.MAX_MESSAGE_LENGTH:
|
||||
current_time = asyncio.get_event_loop().time()
|
||||
time_since_last_edit = current_time - last_edit_time
|
||||
|
||||
# 编辑或发送消息
|
||||
if message_id and len(delta) <= self.MAX_MESSAGE_LENGTH:
|
||||
current_time = asyncio.get_running_loop().time()
|
||||
time_since_last_edit = current_time - last_edit_time
|
||||
|
||||
if time_since_last_edit >= throttle_interval:
|
||||
current_time = asyncio.get_running_loop().time()
|
||||
# 如果距离上次编辑的时间 >= 设定的间隔,等待一段时间
|
||||
if time_since_last_edit >= throttle_interval:
|
||||
# 发送 typing 状态(带节流)
|
||||
current_time = asyncio.get_event_loop().time()
|
||||
if current_time - last_chat_action_time >= chat_action_interval:
|
||||
await self._ensure_typing(user_name, message_thread_id)
|
||||
last_chat_action_time = current_time
|
||||
# 编辑消息
|
||||
try:
|
||||
await self.client.edit_message_text(
|
||||
text=delta,
|
||||
chat_id=payload["chat_id"],
|
||||
message_id=message_id,
|
||||
)
|
||||
current_content = delta
|
||||
except Exception as e:
|
||||
logger.warning(f"编辑消息失败(streaming): {e!s}")
|
||||
last_edit_time = (
|
||||
asyncio.get_event_loop().time()
|
||||
) # 更新上次编辑的时间
|
||||
else:
|
||||
# delta 长度一般不会大于 4096,因此这里直接发送
|
||||
# 发送 typing 状态(带节流)
|
||||
current_time = asyncio.get_event_loop().time()
|
||||
if current_time - last_chat_action_time >= chat_action_interval:
|
||||
await self._ensure_typing(user_name, message_thread_id)
|
||||
last_chat_action_time = current_time
|
||||
try:
|
||||
await self.client.edit_message_text(
|
||||
text=delta,
|
||||
chat_id=payload["chat_id"],
|
||||
message_id=message_id,
|
||||
msg = await self.client.send_message(
|
||||
text=delta, **cast(Any, payload)
|
||||
)
|
||||
current_content = delta
|
||||
except Exception as e:
|
||||
logger.warning(f"编辑消息失败(streaming): {e!s}")
|
||||
last_edit_time = asyncio.get_running_loop().time()
|
||||
else:
|
||||
current_time = asyncio.get_running_loop().time()
|
||||
if current_time - last_chat_action_time >= chat_action_interval:
|
||||
await self._ensure_typing(user_name, message_thread_id)
|
||||
last_chat_action_time = current_time
|
||||
try:
|
||||
msg = await self.client.send_message(
|
||||
text=delta, **cast(Any, payload)
|
||||
)
|
||||
current_content = delta
|
||||
except Exception as e:
|
||||
logger.warning(f"发送消息失败(streaming): {e!s}")
|
||||
message_id = msg.message_id
|
||||
last_edit_time = asyncio.get_running_loop().time()
|
||||
logger.warning(f"发送消息失败(streaming): {e!s}")
|
||||
message_id = msg.message_id
|
||||
last_edit_time = (
|
||||
asyncio.get_event_loop().time()
|
||||
) # 记录初始消息发送时间
|
||||
|
||||
try:
|
||||
if delta and current_content != delta:
|
||||
@@ -712,3 +506,5 @@ class TelegramPlatformEvent(AstrMessageEvent):
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"编辑消息失败(streaming): {e!s}")
|
||||
|
||||
return await super().send_streaming(generator, use_fallback)
|
||||
|
||||
@@ -200,7 +200,7 @@ class WecomPlatformAdapter(Platform):
|
||||
return msg_list[-1]
|
||||
return None
|
||||
|
||||
msg_new = await asyncio.get_running_loop().run_in_executor(
|
||||
msg_new = await asyncio.get_event_loop().run_in_executor(
|
||||
None,
|
||||
get_latest_msg_item,
|
||||
)
|
||||
@@ -261,7 +261,7 @@ class WecomPlatformAdapter(Platform):
|
||||
|
||||
@override
|
||||
async def run(self) -> None:
|
||||
loop = asyncio.get_running_loop()
|
||||
loop = asyncio.get_event_loop()
|
||||
if self.kf_name:
|
||||
try:
|
||||
acc_list = (
|
||||
@@ -339,7 +339,7 @@ class WecomPlatformAdapter(Platform):
|
||||
abm.session_id = abm.sender.user_id
|
||||
abm.raw_message = msg
|
||||
elif isinstance(msg, VoiceMessage):
|
||||
resp: Response = await asyncio.get_running_loop().run_in_executor(
|
||||
resp: Response = await asyncio.get_event_loop().run_in_executor(
|
||||
None,
|
||||
self.client.media.download,
|
||||
msg.media_id,
|
||||
@@ -395,7 +395,7 @@ class WecomPlatformAdapter(Platform):
|
||||
abm.message_str = text
|
||||
elif msgtype == "image":
|
||||
media_id = msg.get("image", {}).get("media_id", "")
|
||||
resp: Response = await asyncio.get_running_loop().run_in_executor(
|
||||
resp: Response = await asyncio.get_event_loop().run_in_executor(
|
||||
None,
|
||||
self.client.media.download,
|
||||
media_id,
|
||||
@@ -407,7 +407,7 @@ class WecomPlatformAdapter(Platform):
|
||||
abm.message = [Image(file=path, url=path)]
|
||||
elif msgtype == "voice":
|
||||
media_id = msg.get("voice", {}).get("media_id", "")
|
||||
resp: Response = await asyncio.get_running_loop().run_in_executor(
|
||||
resp: Response = await asyncio.get_event_loop().run_in_executor(
|
||||
None,
|
||||
self.client.media.download,
|
||||
media_id,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
"""企业微信智能机器人平台适配器
|
||||
基于企业微信智能机器人 API 的消息平台适配器,支持 HTTP 回调与长连接
|
||||
基于企业微信智能机器人 API 的消息平台适配器,支持 HTTP 回调
|
||||
参考webchat_adapter.py的队列机制,实现异步消息处理和流式响应
|
||||
"""
|
||||
|
||||
@@ -31,7 +31,6 @@ from .wecomai_api import (
|
||||
WecomAIBotStreamMessageBuilder,
|
||||
)
|
||||
from .wecomai_event import WecomAIBotMessageEvent
|
||||
from .wecomai_long_connection import WecomAIBotLongConnectionClient
|
||||
from .wecomai_queue_mgr import WecomAIQueueMgr
|
||||
from .wecomai_server import WecomAIBotServer
|
||||
from .wecomai_utils import (
|
||||
@@ -79,13 +78,8 @@ class WecomAIBotAdapter(Platform):
|
||||
self.settings = platform_settings
|
||||
|
||||
# 初始化配置参数
|
||||
self.connection_mode = self.config.get(
|
||||
"wecom_ai_bot_connection_mode", "webhook"
|
||||
)
|
||||
self.token = self.config.get("token", self.config.get("wecomaibot_token", ""))
|
||||
self.encoding_aes_key = self.config.get(
|
||||
"encoding_aes_key", self.config.get("wecomaibot_encoding_aes_key", "")
|
||||
)
|
||||
self.token = self.config["token"]
|
||||
self.encoding_aes_key = self.config["encoding_aes_key"]
|
||||
self.port = int(self.config["port"])
|
||||
self.host = self.config.get("callback_server_host", "0.0.0.0")
|
||||
self.bot_name = self.config.get("wecom_ai_bot_name", "")
|
||||
@@ -102,52 +96,25 @@ class WecomAIBotAdapter(Platform):
|
||||
self.only_use_webhook_url_to_send = bool(
|
||||
self.config.get("only_use_webhook_url_to_send", False),
|
||||
)
|
||||
self.long_connection_bot_id = self.config.get(
|
||||
"wecomaibot_ws_bot_id", self.config.get("long_connection_bot_id", "")
|
||||
)
|
||||
self.long_connection_secret = self.config.get(
|
||||
"wecomaibot_ws_secret", self.config.get("long_connection_secret", "")
|
||||
)
|
||||
self.long_connection_ws_url = self.config.get(
|
||||
"wecomaibot_ws_url",
|
||||
"wss://openws.work.weixin.qq.com",
|
||||
)
|
||||
self.long_connection_heartbeat_interval = int(
|
||||
self.config.get("wecomaibot_heartbeat_interval", 30),
|
||||
)
|
||||
|
||||
# 平台元数据
|
||||
self.metadata = PlatformMetadata(
|
||||
name="wecom_ai_bot",
|
||||
description="企业微信智能机器人适配器,支持 HTTP 回调和长连接模式",
|
||||
description="企业微信智能机器人适配器,支持 HTTP 回调接收消息",
|
||||
id=self.config.get("id", "wecom_ai_bot"),
|
||||
support_proactive_message=bool(self.msg_push_webhook_url),
|
||||
)
|
||||
|
||||
self.api_client: WecomAIBotAPIClient | None = None
|
||||
self.server: WecomAIBotServer | None = None
|
||||
self.long_connection_client: WecomAIBotLongConnectionClient | None = None
|
||||
# 初始化 API 客户端
|
||||
self.api_client = WecomAIBotAPIClient(self.token, self.encoding_aes_key)
|
||||
|
||||
if self.connection_mode == "long_connection":
|
||||
if not self.long_connection_bot_id or not self.long_connection_secret:
|
||||
logger.warning(
|
||||
"企业微信智能机器人长连接模式缺少 BotID 或 Secret,连接可能失败"
|
||||
)
|
||||
self.long_connection_client = WecomAIBotLongConnectionClient(
|
||||
bot_id=self.long_connection_bot_id,
|
||||
secret=self.long_connection_secret,
|
||||
ws_url=self.long_connection_ws_url,
|
||||
heartbeat_interval=self.long_connection_heartbeat_interval,
|
||||
message_handler=self._process_long_connection_payload,
|
||||
)
|
||||
else:
|
||||
self.api_client = WecomAIBotAPIClient(self.token, self.encoding_aes_key)
|
||||
self.server = WecomAIBotServer(
|
||||
host=self.host,
|
||||
port=self.port,
|
||||
api_client=self.api_client,
|
||||
message_handler=self._process_message,
|
||||
)
|
||||
# 初始化 HTTP 服务器
|
||||
self.server = WecomAIBotServer(
|
||||
host=self.host,
|
||||
port=self.port,
|
||||
api_client=self.api_client,
|
||||
message_handler=self._process_message,
|
||||
)
|
||||
|
||||
# 事件循环和关闭信号
|
||||
self.shutdown_event = asyncio.Event()
|
||||
@@ -194,9 +161,6 @@ class WecomAIBotAdapter(Platform):
|
||||
加密后的响应消息,无需响应时返回 None
|
||||
|
||||
"""
|
||||
if not self.api_client:
|
||||
logger.error("Webhook 消息处理失败: API 客户端未初始化")
|
||||
return None
|
||||
msgtype = message_data.get("msgtype")
|
||||
if not msgtype:
|
||||
logger.warning(f"消息类型未知,忽略: {message_data}")
|
||||
@@ -356,89 +320,6 @@ class WecomAIBotAdapter(Platform):
|
||||
logger.error("处理欢迎消息时发生异常: %s", e)
|
||||
return None
|
||||
|
||||
async def _process_long_connection_payload(
|
||||
self,
|
||||
payload: dict[str, Any],
|
||||
) -> None:
|
||||
"""处理长连接回调消息。"""
|
||||
cmd = payload.get("cmd")
|
||||
headers = payload.get("headers") or {}
|
||||
body = payload.get("body") or {}
|
||||
req_id = headers.get("req_id")
|
||||
if not isinstance(body, dict):
|
||||
return
|
||||
|
||||
if cmd == "aibot_msg_callback":
|
||||
session_id = self._extract_session_id(body)
|
||||
stream_id = f"{session_id}_{generate_random_string(10)}"
|
||||
await self._enqueue_message(
|
||||
body, {"req_id": req_id or ""}, stream_id, session_id
|
||||
)
|
||||
self.queue_mgr.set_pending_response(
|
||||
stream_id,
|
||||
{
|
||||
"req_id": req_id or "",
|
||||
"connection_mode": "long_connection",
|
||||
},
|
||||
)
|
||||
|
||||
if self.initial_respond_text and req_id:
|
||||
await self._send_long_connection_respond_msg(
|
||||
req_id=req_id,
|
||||
body={
|
||||
"msgtype": "stream",
|
||||
"stream": {
|
||||
"id": stream_id,
|
||||
"finish": False,
|
||||
"content": self.initial_respond_text,
|
||||
},
|
||||
},
|
||||
)
|
||||
return
|
||||
|
||||
if cmd == "aibot_event_callback":
|
||||
event = body.get("event") or {}
|
||||
event_type = event.get("eventtype")
|
||||
if (
|
||||
event_type == "enter_chat"
|
||||
and self.friend_message_welcome_text
|
||||
and req_id
|
||||
):
|
||||
await self._send_long_connection_respond_welcome(req_id)
|
||||
elif event_type == "disconnected_event":
|
||||
logger.warning(
|
||||
"[WecomAI][LongConn] 收到 disconnected_event,旧连接将被关闭"
|
||||
)
|
||||
|
||||
async def _send_long_connection_respond_welcome(self, req_id: str) -> bool:
|
||||
client = self.long_connection_client
|
||||
if not client:
|
||||
return False
|
||||
return await client.send_command(
|
||||
cmd="aibot_respond_welcome_msg",
|
||||
req_id=req_id,
|
||||
body={
|
||||
"msgtype": "text",
|
||||
"text": {
|
||||
"content": self.friend_message_welcome_text,
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
async def _send_long_connection_respond_msg(
|
||||
self,
|
||||
req_id: str,
|
||||
body: dict[str, Any],
|
||||
) -> bool:
|
||||
client = self.long_connection_client
|
||||
if not client:
|
||||
return False
|
||||
return await client.send_command(
|
||||
cmd="aibot_respond_msg",
|
||||
req_id=req_id,
|
||||
body=body,
|
||||
)
|
||||
|
||||
def _extract_session_id(self, message_data: dict[str, Any]) -> str:
|
||||
"""从消息数据中提取会话ID"""
|
||||
user_id = message_data.get("from", {}).get("userid", "default_user")
|
||||
@@ -474,16 +355,15 @@ class WecomAIBotAdapter(Platform):
|
||||
content = ""
|
||||
image_base64 = []
|
||||
|
||||
_img_url_to_process: list[tuple[str, str | None]] = []
|
||||
_img_url_to_process = []
|
||||
msg_items = []
|
||||
|
||||
if msgtype == WecomAIBotConstants.MSG_TYPE_TEXT:
|
||||
content = WecomAIBotMessageParser.parse_text_message(message_data)
|
||||
elif msgtype == WecomAIBotConstants.MSG_TYPE_IMAGE:
|
||||
image_payload = message_data.get("image", {})
|
||||
image_url = image_payload.get("url", "")
|
||||
if image_url:
|
||||
_img_url_to_process.append((image_url, image_payload.get("aeskey")))
|
||||
_img_url_to_process.append(
|
||||
WecomAIBotMessageParser.parse_image_message(message_data),
|
||||
)
|
||||
elif msgtype == WecomAIBotConstants.MSG_TYPE_MIXED:
|
||||
# 提取混合消息中的文本内容
|
||||
msg_items = WecomAIBotMessageParser.parse_mixed_message(message_data)
|
||||
@@ -494,12 +374,9 @@ class WecomAIBotAdapter(Platform):
|
||||
if text_content:
|
||||
text_parts.append(text_content)
|
||||
elif item.get("msgtype") == WecomAIBotConstants.MSG_TYPE_IMAGE:
|
||||
image_payload = item.get("image", {})
|
||||
image_url = image_payload.get("url", "")
|
||||
image_url = item.get("image", {}).get("url", "")
|
||||
if image_url:
|
||||
_img_url_to_process.append(
|
||||
(image_url, image_payload.get("aeskey"))
|
||||
)
|
||||
_img_url_to_process.append(image_url)
|
||||
content = " ".join(text_parts) if text_parts else ""
|
||||
else:
|
||||
content = f"[{msgtype}消息]"
|
||||
@@ -507,8 +384,8 @@ class WecomAIBotAdapter(Platform):
|
||||
# 并行处理图片下载和解密
|
||||
if _img_url_to_process:
|
||||
tasks = [
|
||||
process_encrypted_image(url, aes_key or self.encoding_aes_key)
|
||||
for url, aes_key in _img_url_to_process
|
||||
process_encrypted_image(url, self.encoding_aes_key)
|
||||
for url in _img_url_to_process
|
||||
]
|
||||
results = await asyncio.gather(*tasks)
|
||||
for success, result in results:
|
||||
@@ -582,43 +459,26 @@ class WecomAIBotAdapter(Platform):
|
||||
"""运行适配器,同时启动HTTP服务器和队列监听器"""
|
||||
|
||||
async def run_both() -> None:
|
||||
if self.connection_mode == "long_connection":
|
||||
if not self.long_connection_client:
|
||||
raise RuntimeError("长连接客户端未初始化")
|
||||
# 如果启用统一 webhook 模式,则不启动独立服务器
|
||||
webhook_uuid = self.config.get("webhook_uuid")
|
||||
if self.unified_webhook_mode and webhook_uuid:
|
||||
log_webhook_info(f"{self.meta().id}(企业微信智能机器人)", webhook_uuid)
|
||||
# 只运行队列监听器
|
||||
await self.queue_listener.run()
|
||||
else:
|
||||
logger.info(
|
||||
"启动企业微信智能机器人长连接模式: %s", self.long_connection_ws_url
|
||||
"启动企业微信智能机器人适配器,监听 %s:%d", self.host, self.port
|
||||
)
|
||||
# 同时运行HTTP服务器和队列监听器
|
||||
await asyncio.gather(
|
||||
self.long_connection_client.start(),
|
||||
self.server.start_server(),
|
||||
self.queue_listener.run(),
|
||||
)
|
||||
else:
|
||||
# 如果启用统一 webhook 模式,则不启动独立服务器
|
||||
webhook_uuid = self.config.get("webhook_uuid")
|
||||
if self.unified_webhook_mode and webhook_uuid:
|
||||
log_webhook_info(
|
||||
f"{self.meta().id}(企业微信智能机器人)", webhook_uuid
|
||||
)
|
||||
# 只运行队列监听器
|
||||
await self.queue_listener.run()
|
||||
else:
|
||||
if not self.server:
|
||||
raise RuntimeError("Webhook 服务器未初始化")
|
||||
logger.info(
|
||||
"启动企业微信智能机器人适配器,监听 %s:%d", self.host, self.port
|
||||
)
|
||||
# 同时运行HTTP服务器和队列监听器
|
||||
await asyncio.gather(
|
||||
self.server.start_server(),
|
||||
self.queue_listener.run(),
|
||||
)
|
||||
|
||||
return run_both()
|
||||
|
||||
async def webhook_callback(self, request: Any) -> Any:
|
||||
"""统一 Webhook 回调入口"""
|
||||
if self.connection_mode == "long_connection" or not self.server:
|
||||
return "long_connection mode does not accept webhook callbacks", 400
|
||||
# 根据请求方法分发到不同的处理函数
|
||||
if request.method == "GET":
|
||||
return await self.server.handle_verify(request)
|
||||
@@ -629,10 +489,7 @@ class WecomAIBotAdapter(Platform):
|
||||
"""终止适配器"""
|
||||
logger.info("企业微信智能机器人适配器正在关闭...")
|
||||
self.shutdown_event.set()
|
||||
if self.long_connection_client:
|
||||
await self.long_connection_client.shutdown()
|
||||
if self.server:
|
||||
await self.server.shutdown()
|
||||
await self.server.shutdown()
|
||||
|
||||
def meta(self) -> PlatformMetadata:
|
||||
"""获取平台元数据"""
|
||||
@@ -650,22 +507,17 @@ class WecomAIBotAdapter(Platform):
|
||||
queue_mgr=self.queue_mgr,
|
||||
webhook_client=self.webhook_client,
|
||||
only_use_webhook_url_to_send=self.only_use_webhook_url_to_send,
|
||||
long_connection_sender=self._send_long_connection_respond_msg,
|
||||
)
|
||||
message_event.is_at_or_wake_command = (
|
||||
True # 企业微信智能机器人默认消息都是 at 或唤醒命令
|
||||
)
|
||||
message_event.is_wake = True # 企业微信智能机器人消息默认当做唤醒命令处理
|
||||
|
||||
self.commit_event(message_event)
|
||||
|
||||
except Exception as e:
|
||||
logger.error("处理消息时发生异常: %s", e)
|
||||
|
||||
def get_client(self) -> WecomAIBotAPIClient | None:
|
||||
def get_client(self) -> WecomAIBotAPIClient:
|
||||
"""获取 API 客户端"""
|
||||
return self.api_client
|
||||
|
||||
def get_server(self) -> WecomAIBotServer | None:
|
||||
def get_server(self) -> WecomAIBotServer:
|
||||
"""获取 HTTP 服务器实例"""
|
||||
return self.server
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
"""企业微信智能机器人事件处理模块,处理消息事件的发送和接收"""
|
||||
|
||||
from collections.abc import Awaitable, Callable
|
||||
|
||||
from astrbot.api import logger
|
||||
from astrbot.api.event import AstrMessageEvent, MessageChain
|
||||
from astrbot.api.message_components import At, Image, Plain
|
||||
@@ -20,11 +18,10 @@ class WecomAIBotMessageEvent(AstrMessageEvent):
|
||||
message_obj,
|
||||
platform_meta,
|
||||
session_id: str,
|
||||
api_client: WecomAIBotAPIClient | None,
|
||||
api_client: WecomAIBotAPIClient,
|
||||
queue_mgr: WecomAIQueueMgr,
|
||||
webhook_client: WecomAIBotWebhookClient | None = None,
|
||||
only_use_webhook_url_to_send: bool = False,
|
||||
long_connection_sender: (Callable[[str, dict], Awaitable[bool]] | None) = None,
|
||||
) -> None:
|
||||
"""初始化消息事件
|
||||
|
||||
@@ -41,7 +38,6 @@ class WecomAIBotMessageEvent(AstrMessageEvent):
|
||||
self.queue_mgr = queue_mgr
|
||||
self.webhook_client = webhook_client
|
||||
self.only_use_webhook_url_to_send = only_use_webhook_url_to_send
|
||||
self.long_connection_sender = long_connection_sender
|
||||
|
||||
async def _mark_stream_complete(self, stream_id: str) -> None:
|
||||
back_queue = self.queue_mgr.get_or_create_back_queue(stream_id)
|
||||
@@ -121,18 +117,6 @@ class WecomAIBotMessageEvent(AstrMessageEvent):
|
||||
|
||||
return data
|
||||
|
||||
@staticmethod
|
||||
def _extract_plain_text_from_chain(message_chain: MessageChain | None) -> str:
|
||||
if not message_chain:
|
||||
return ""
|
||||
plain_parts: list[str] = []
|
||||
for comp in message_chain.chain:
|
||||
if isinstance(comp, At):
|
||||
plain_parts.append(f"@{comp.name} ")
|
||||
elif isinstance(comp, Plain):
|
||||
plain_parts.append(comp.text)
|
||||
return "".join(plain_parts).strip()
|
||||
|
||||
async def send(self, message: MessageChain | None) -> None:
|
||||
"""发送消息"""
|
||||
raw = self.message_obj.raw_message
|
||||
@@ -140,44 +124,6 @@ class WecomAIBotMessageEvent(AstrMessageEvent):
|
||||
"wecom_ai_bot platform event raw_message should be a dict"
|
||||
)
|
||||
stream_id = raw.get("stream_id", self.session_id)
|
||||
pending_response = self.queue_mgr.get_pending_response(stream_id) or {}
|
||||
connection_mode = pending_response.get("callback_params", {}).get(
|
||||
"connection_mode"
|
||||
)
|
||||
req_id = pending_response.get("callback_params", {}).get("req_id")
|
||||
|
||||
if (
|
||||
connection_mode == "long_connection"
|
||||
and self.long_connection_sender
|
||||
and isinstance(req_id, str)
|
||||
and req_id
|
||||
):
|
||||
if self.only_use_webhook_url_to_send and self.webhook_client and message:
|
||||
await self.webhook_client.send_message_chain(message)
|
||||
await super().send(MessageChain([]))
|
||||
return
|
||||
|
||||
if self.webhook_client and message:
|
||||
await self.webhook_client.send_message_chain(
|
||||
message,
|
||||
unsupported_only=True,
|
||||
)
|
||||
|
||||
content = self._extract_plain_text_from_chain(message)
|
||||
await self.long_connection_sender(
|
||||
req_id,
|
||||
{
|
||||
"msgtype": "stream",
|
||||
"stream": {
|
||||
"id": stream_id,
|
||||
"finish": True,
|
||||
"content": content,
|
||||
},
|
||||
},
|
||||
)
|
||||
await super().send(MessageChain([]))
|
||||
return
|
||||
|
||||
if self.only_use_webhook_url_to_send and self.webhook_client and message:
|
||||
await self.webhook_client.send_message_chain(message)
|
||||
await self._mark_stream_complete(stream_id)
|
||||
@@ -206,77 +152,8 @@ class WecomAIBotMessageEvent(AstrMessageEvent):
|
||||
"wecom_ai_bot platform event raw_message should be a dict"
|
||||
)
|
||||
stream_id = raw.get("stream_id", self.session_id)
|
||||
pending_response = self.queue_mgr.get_pending_response(stream_id) or {}
|
||||
connection_mode = pending_response.get("callback_params", {}).get(
|
||||
"connection_mode"
|
||||
)
|
||||
req_id = pending_response.get("callback_params", {}).get("req_id")
|
||||
back_queue = self.queue_mgr.get_or_create_back_queue(stream_id)
|
||||
|
||||
if (
|
||||
connection_mode == "long_connection"
|
||||
and self.long_connection_sender
|
||||
and isinstance(req_id, str)
|
||||
and req_id
|
||||
):
|
||||
if self.only_use_webhook_url_to_send and self.webhook_client:
|
||||
merged_chain = MessageChain([])
|
||||
async for chain in generator:
|
||||
merged_chain.chain.extend(chain.chain)
|
||||
merged_chain.squash_plain()
|
||||
await self.webhook_client.send_message_chain(merged_chain)
|
||||
await self.long_connection_sender(
|
||||
req_id,
|
||||
{
|
||||
"msgtype": "stream",
|
||||
"stream": {
|
||||
"id": stream_id,
|
||||
"finish": True,
|
||||
"content": "",
|
||||
},
|
||||
},
|
||||
)
|
||||
await super().send_streaming(generator, use_fallback)
|
||||
return
|
||||
|
||||
increment_plain = ""
|
||||
async for chain in generator:
|
||||
if self.webhook_client:
|
||||
await self.webhook_client.send_message_chain(
|
||||
chain,
|
||||
unsupported_only=True,
|
||||
)
|
||||
|
||||
chain.squash_plain()
|
||||
chunk_text = self._extract_plain_text_from_chain(chain)
|
||||
if chunk_text:
|
||||
increment_plain += chunk_text
|
||||
await self.long_connection_sender(
|
||||
req_id,
|
||||
{
|
||||
"msgtype": "stream",
|
||||
"stream": {
|
||||
"id": stream_id,
|
||||
"finish": False,
|
||||
"content": increment_plain,
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
await self.long_connection_sender(
|
||||
req_id,
|
||||
{
|
||||
"msgtype": "stream",
|
||||
"stream": {
|
||||
"id": stream_id,
|
||||
"finish": True,
|
||||
"content": increment_plain,
|
||||
},
|
||||
},
|
||||
)
|
||||
await super().send_streaming(generator, use_fallback)
|
||||
return
|
||||
|
||||
if self.only_use_webhook_url_to_send and self.webhook_client:
|
||||
merged_chain = MessageChain([])
|
||||
async for chain in generator:
|
||||
|
||||
@@ -1,236 +0,0 @@
|
||||
"""企业微信智能机器人长连接客户端。"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import uuid
|
||||
from collections.abc import Awaitable, Callable
|
||||
from typing import Any
|
||||
|
||||
import aiohttp
|
||||
|
||||
from astrbot.api import logger
|
||||
|
||||
|
||||
class WecomAIBotLongConnectionClient:
|
||||
"""企业微信智能机器人 WebSocket 长连接客户端。"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
bot_id: str,
|
||||
secret: str,
|
||||
ws_url: str,
|
||||
heartbeat_interval: int,
|
||||
message_handler: Callable[[dict[str, Any]], Awaitable[None]],
|
||||
) -> None:
|
||||
self.bot_id = bot_id
|
||||
self.secret = secret
|
||||
self.ws_url = ws_url
|
||||
self.heartbeat_interval = max(5, int(heartbeat_interval))
|
||||
self.message_handler = message_handler
|
||||
|
||||
self._session: aiohttp.ClientSession | None = None
|
||||
self._ws: aiohttp.ClientWebSocketResponse | None = None
|
||||
self._shutdown_event = asyncio.Event()
|
||||
self._send_lock = asyncio.Lock()
|
||||
self._command_lock = asyncio.Lock()
|
||||
self._response_waiters: dict[str, asyncio.Future[dict[str, Any]]] = {}
|
||||
|
||||
@staticmethod
|
||||
def gen_req_id() -> str:
|
||||
return uuid.uuid4().hex
|
||||
|
||||
async def start(self) -> None:
|
||||
"""启动长连接并自动重连。"""
|
||||
reconnect_delay = 1
|
||||
while not self._shutdown_event.is_set():
|
||||
try:
|
||||
await self._run_once()
|
||||
reconnect_delay = 1
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error("[WecomAI][LongConn] 长连接异常: %s", e)
|
||||
if self._shutdown_event.is_set():
|
||||
break
|
||||
await asyncio.sleep(reconnect_delay)
|
||||
reconnect_delay = min(reconnect_delay * 2, 30)
|
||||
|
||||
async def _run_once(self) -> None:
|
||||
timeout = aiohttp.ClientTimeout(total=None, sock_connect=15, sock_read=None)
|
||||
async with aiohttp.ClientSession(timeout=timeout) as session:
|
||||
self._session = session
|
||||
logger.info("[WecomAI][LongConn] 正在连接: %s", self.ws_url)
|
||||
async with session.ws_connect(
|
||||
self.ws_url, heartbeat=None, autoping=True
|
||||
) as ws:
|
||||
self._ws = ws
|
||||
await self._subscribe()
|
||||
logger.info("[WecomAI][LongConn] 订阅成功,已建立长连接")
|
||||
|
||||
heartbeat_task = asyncio.create_task(self._heartbeat_loop())
|
||||
try:
|
||||
while not self._shutdown_event.is_set():
|
||||
message = await ws.receive()
|
||||
if message.type == aiohttp.WSMsgType.TEXT:
|
||||
await self._handle_text_message(message.data)
|
||||
elif message.type in {
|
||||
aiohttp.WSMsgType.CLOSED,
|
||||
aiohttp.WSMsgType.CLOSE,
|
||||
aiohttp.WSMsgType.ERROR,
|
||||
}:
|
||||
break
|
||||
finally:
|
||||
heartbeat_task.cancel()
|
||||
try:
|
||||
await heartbeat_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
self._ws = None
|
||||
|
||||
async def _subscribe(self) -> None:
|
||||
"""发送 aibot_subscribe,并等待响应。"""
|
||||
req_id = self.gen_req_id()
|
||||
payload = {
|
||||
"cmd": "aibot_subscribe",
|
||||
"headers": {"req_id": req_id},
|
||||
"body": {"bot_id": self.bot_id, "secret": self.secret},
|
||||
}
|
||||
await self._send_json(payload)
|
||||
|
||||
if not self._ws:
|
||||
raise RuntimeError("WebSocket 未建立")
|
||||
|
||||
reply = await self._ws.receive(timeout=10)
|
||||
if reply.type != aiohttp.WSMsgType.TEXT:
|
||||
raise RuntimeError(f"订阅失败: 非文本响应 {reply.type}")
|
||||
|
||||
data = json.loads(reply.data)
|
||||
if data.get("errcode") != 0:
|
||||
raise RuntimeError(
|
||||
f"订阅失败 errcode={data.get('errcode')} errmsg={data.get('errmsg')}"
|
||||
)
|
||||
|
||||
async def _heartbeat_loop(self) -> None:
|
||||
while not self._shutdown_event.is_set():
|
||||
await asyncio.sleep(self.heartbeat_interval)
|
||||
if self._shutdown_event.is_set():
|
||||
break
|
||||
try:
|
||||
await self.send_command("ping", self.gen_req_id(), None)
|
||||
except Exception as e:
|
||||
logger.warning("[WecomAI][LongConn] 发送心跳失败: %s", e)
|
||||
return
|
||||
|
||||
async def _handle_text_message(self, text: str) -> None:
|
||||
try:
|
||||
payload = json.loads(text)
|
||||
except json.JSONDecodeError:
|
||||
logger.warning("[WecomAI][LongConn] 收到非 JSON 消息: %s", text)
|
||||
return
|
||||
|
||||
headers = payload.get("headers") or {}
|
||||
req_id = headers.get("req_id")
|
||||
if isinstance(req_id, str):
|
||||
waiter = self._response_waiters.get(req_id)
|
||||
if waiter and not waiter.done():
|
||||
waiter.set_result(payload)
|
||||
return
|
||||
|
||||
cmd = payload.get("cmd")
|
||||
if cmd in {"aibot_msg_callback", "aibot_event_callback"}:
|
||||
await self.message_handler(payload)
|
||||
return
|
||||
|
||||
if payload.get("errcode") not in (None, 0):
|
||||
logger.warning(
|
||||
"[WecomAI][LongConn] 服务端返回错误: errcode=%s errmsg=%s",
|
||||
payload.get("errcode"),
|
||||
payload.get("errmsg"),
|
||||
)
|
||||
|
||||
async def send_command(
|
||||
self,
|
||||
cmd: str,
|
||||
req_id: str,
|
||||
body: dict[str, Any] | None,
|
||||
) -> bool:
|
||||
"""发送长连接命令。"""
|
||||
headers = {"req_id": req_id}
|
||||
payload: dict[str, Any] = {"cmd": cmd, "headers": headers}
|
||||
if body is not None:
|
||||
payload["body"] = body
|
||||
|
||||
async with self._command_lock:
|
||||
max_retries = 3
|
||||
for attempt in range(max_retries + 1):
|
||||
response = await self._send_and_wait_response(req_id, payload)
|
||||
if not response:
|
||||
if attempt < max_retries:
|
||||
await asyncio.sleep(min(0.2 * (2**attempt), 2.0))
|
||||
continue
|
||||
return False
|
||||
|
||||
errcode = response.get("errcode")
|
||||
if errcode in (0, None):
|
||||
return True
|
||||
|
||||
if errcode == 6000 and attempt < max_retries:
|
||||
backoff = min(0.2 * (2**attempt), 2.0)
|
||||
logger.warning(
|
||||
"[WecomAI][LongConn] 命令冲突(errcode=6000),将重试。cmd=%s req_id=%s attempt=%d",
|
||||
cmd,
|
||||
req_id,
|
||||
attempt + 1,
|
||||
)
|
||||
await asyncio.sleep(backoff)
|
||||
continue
|
||||
|
||||
logger.warning(
|
||||
"[WecomAI][LongConn] 命令失败: cmd=%s req_id=%s errcode=%s errmsg=%s",
|
||||
cmd,
|
||||
req_id,
|
||||
errcode,
|
||||
response.get("errmsg"),
|
||||
)
|
||||
return False
|
||||
|
||||
return False
|
||||
|
||||
async def _send_and_wait_response(
|
||||
self,
|
||||
req_id: str,
|
||||
payload: dict[str, Any],
|
||||
timeout: float = 10.0,
|
||||
) -> dict[str, Any] | None:
|
||||
loop = asyncio.get_running_loop()
|
||||
waiter: asyncio.Future[dict[str, Any]] = loop.create_future()
|
||||
self._response_waiters[req_id] = waiter
|
||||
try:
|
||||
await self._send_json(payload)
|
||||
return await asyncio.wait_for(waiter, timeout=timeout)
|
||||
except TimeoutError:
|
||||
logger.warning(
|
||||
"[WecomAI][LongConn] 等待命令响应超时: cmd=%s req_id=%s",
|
||||
payload.get("cmd"),
|
||||
req_id,
|
||||
)
|
||||
return None
|
||||
finally:
|
||||
self._response_waiters.pop(req_id, None)
|
||||
|
||||
async def _send_json(self, payload: dict[str, Any]) -> None:
|
||||
ws = self._ws
|
||||
if ws is None or ws.closed:
|
||||
raise RuntimeError("长连接尚未建立")
|
||||
async with self._send_lock:
|
||||
await ws.send_json(payload)
|
||||
|
||||
async def shutdown(self) -> None:
|
||||
self._shutdown_event.set()
|
||||
ws = self._ws
|
||||
if ws is not None and not ws.closed:
|
||||
await ws.close()
|
||||
|
||||
session = self._session
|
||||
if session is not None and not session.closed:
|
||||
await session.close()
|
||||
@@ -4,7 +4,6 @@
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import time
|
||||
from collections.abc import Awaitable, Callable
|
||||
from typing import Any
|
||||
|
||||
@@ -83,7 +82,7 @@ class WecomAIQueueMgr:
|
||||
del self.pending_responses[session_id]
|
||||
logger.debug(f"[WecomAI] 移除待处理响应: {session_id}")
|
||||
if mark_finished:
|
||||
self.completed_streams[session_id] = time.monotonic()
|
||||
self.completed_streams[session_id] = asyncio.get_event_loop().time()
|
||||
logger.debug(f"[WecomAI] 标记流已结束: {session_id}")
|
||||
|
||||
def remove_queue(self, session_id: str):
|
||||
@@ -136,7 +135,7 @@ class WecomAIQueueMgr:
|
||||
"""
|
||||
self.pending_responses[session_id] = {
|
||||
"callback_params": callback_params,
|
||||
"timestamp": time.monotonic(),
|
||||
"timestamp": asyncio.get_event_loop().time(),
|
||||
}
|
||||
logger.debug(f"[WecomAI] 设置待处理响应: {session_id}")
|
||||
|
||||
@@ -161,7 +160,7 @@ class WecomAIQueueMgr:
|
||||
finished_at = self.completed_streams.get(session_id)
|
||||
if finished_at is None:
|
||||
return False
|
||||
if time.monotonic() - finished_at > max_age_seconds:
|
||||
if asyncio.get_event_loop().time() - finished_at > max_age_seconds:
|
||||
self.completed_streams.pop(session_id, None)
|
||||
return False
|
||||
return True
|
||||
@@ -173,7 +172,7 @@ class WecomAIQueueMgr:
|
||||
max_age_seconds: 最大存活时间(秒)
|
||||
|
||||
"""
|
||||
current_time = time.monotonic()
|
||||
current_time = asyncio.get_event_loop().time()
|
||||
expired_sessions = []
|
||||
|
||||
for session_id, response_data in self.pending_responses.items():
|
||||
|
||||
@@ -369,7 +369,7 @@ class WeixinOfficialAccountPlatformAdapter(Platform):
|
||||
if future:
|
||||
logger.debug(f"duplicate message id checked: {msg.id}")
|
||||
else:
|
||||
future = asyncio.get_running_loop().create_future()
|
||||
future = asyncio.get_event_loop().create_future()
|
||||
self.wexin_event_workers[msg_id] = future
|
||||
await self.convert_message(msg, future)
|
||||
# I love shield so much!
|
||||
@@ -461,7 +461,7 @@ class WeixinOfficialAccountPlatformAdapter(Platform):
|
||||
elif msg.type == "voice":
|
||||
assert isinstance(msg, VoiceMessage)
|
||||
|
||||
resp: Response = await asyncio.get_running_loop().run_in_executor(
|
||||
resp: Response = await asyncio.get_event_loop().run_in_executor(
|
||||
None,
|
||||
self.client.media.download,
|
||||
msg.media_id,
|
||||
|
||||
@@ -21,8 +21,8 @@ from astrbot.core.utils.astrbot_path import get_astrbot_data_path
|
||||
|
||||
DEFAULT_MCP_CONFIG = {"mcpServers": {}}
|
||||
|
||||
DEFAULT_MCP_INIT_TIMEOUT_SECONDS = 180.0
|
||||
DEFAULT_ENABLE_MCP_TIMEOUT_SECONDS = 180.0
|
||||
DEFAULT_MCP_INIT_TIMEOUT_SECONDS = 20.0
|
||||
DEFAULT_ENABLE_MCP_TIMEOUT_SECONDS = 30.0
|
||||
MCP_INIT_TIMEOUT_ENV = "ASTRBOT_MCP_INIT_TIMEOUT"
|
||||
ENABLE_MCP_TIMEOUT_ENV = "ASTRBOT_MCP_ENABLE_TIMEOUT"
|
||||
MAX_MCP_TIMEOUT_SECONDS = 300.0
|
||||
@@ -417,11 +417,9 @@ class FunctionToolManager:
|
||||
for (name, cfg, _), result in zip(active_configs, results, strict=False):
|
||||
if isinstance(result, Exception):
|
||||
if isinstance(result, MCPInitTimeoutError):
|
||||
logger.error(
|
||||
f"Connected to MCP server {name} timeout ({timeout_display} seconds)"
|
||||
)
|
||||
logger.error(f"MCP 服务 {name} 初始化超时({timeout_display}秒)")
|
||||
else:
|
||||
logger.error(f"Failed to initialize MCP server {name}: {result}")
|
||||
logger.error(f"MCP 服务 {name} 初始化失败: {result}")
|
||||
self._log_safe_mcp_debug_config(cfg)
|
||||
failed_services.append(name)
|
||||
async with self._runtime_lock:
|
||||
@@ -432,18 +430,16 @@ class FunctionToolManager:
|
||||
|
||||
if failed_services:
|
||||
logger.warning(
|
||||
f"The following MCP services failed to initialize: {', '.join(failed_services)}. "
|
||||
f"Please check the mcp_server.json file and server availability."
|
||||
f"以下 MCP 服务初始化失败: {', '.join(failed_services)}。"
|
||||
f"请检查配置文件 mcp_server.json 和服务器可用性。"
|
||||
)
|
||||
|
||||
summary = MCPInitSummary(
|
||||
total=len(active_configs), success=success_count, failed=failed_services
|
||||
)
|
||||
logger.info(
|
||||
f"MCP services initialization completed: {summary.success}/{summary.total} successful, {len(summary.failed)} failed."
|
||||
)
|
||||
logger.info(f"MCP 服务初始化完成: {summary.success}/{summary.total} 成功")
|
||||
if summary.total > 0 and summary.success == 0:
|
||||
msg = "All MCP services failed to initialize, please check the mcp_server.json and server availability."
|
||||
msg = "全部 MCP 服务初始化失败,请检查 mcp_server.json 配置和服务器可用性。"
|
||||
if raise_on_all_failed:
|
||||
raise MCPAllServicesFailedError(msg)
|
||||
logger.error(msg)
|
||||
@@ -465,7 +461,7 @@ class FunctionToolManager:
|
||||
async with self._runtime_lock:
|
||||
if name in self._mcp_server_runtime or name in self._mcp_starting:
|
||||
logger.warning(
|
||||
f"Connected to MCP server {name}, ignoring this startup request (timeout={timeout:g})."
|
||||
f"MCP 服务 {name} 已在运行,忽略本次启用请求(timeout={timeout:g})。"
|
||||
)
|
||||
self._log_safe_mcp_debug_config(cfg)
|
||||
return
|
||||
@@ -482,10 +478,10 @@ class FunctionToolManager:
|
||||
)
|
||||
except asyncio.TimeoutError as exc:
|
||||
raise MCPInitTimeoutError(
|
||||
f"Connected to MCP server {name} timeout ({timeout:g} seconds)"
|
||||
f"MCP 服务 {name} 初始化超时({timeout:g} 秒)"
|
||||
) from exc
|
||||
except Exception:
|
||||
logger.error(f"Failed to initialize MCP client {name}", exc_info=True)
|
||||
logger.error(f"初始化 MCP 客户端 {name} 失败", exc_info=True)
|
||||
raise
|
||||
finally:
|
||||
if mcp_client is None:
|
||||
@@ -495,9 +491,9 @@ class FunctionToolManager:
|
||||
async def lifecycle() -> None:
|
||||
try:
|
||||
await shutdown_event.wait()
|
||||
logger.info(f"Received shutdown signal for MCP client {name}")
|
||||
logger.info(f"收到 MCP 客户端 {name} 终止信号")
|
||||
except asyncio.CancelledError:
|
||||
logger.debug(f"MCP client {name} task was cancelled")
|
||||
logger.debug(f"MCP 客户端 {name} 任务被取消")
|
||||
raise
|
||||
finally:
|
||||
await self._terminate_mcp_client(name)
|
||||
@@ -549,7 +545,7 @@ class FunctionToolManager:
|
||||
if strict:
|
||||
raise MCPShutdownTimeoutError(pending_names, timeout)
|
||||
logger.warning(
|
||||
"MCP server shutdown timeout (%s seconds), the following servers were not fully closed: %s",
|
||||
"MCP 服务关闭超时(%s 秒),以下服务未完全关闭:%s",
|
||||
f"{timeout:g}",
|
||||
", ".join(pending_names),
|
||||
)
|
||||
@@ -572,9 +568,7 @@ class FunctionToolManager:
|
||||
try:
|
||||
await mcp_client.cleanup()
|
||||
except Exception as cleanup_exc: # noqa: BLE001 - only log here
|
||||
logger.error(
|
||||
f"Failed to cleanup MCP client resources {name}: {cleanup_exc}"
|
||||
)
|
||||
logger.error(f"清理 MCP 客户端资源 {name} 失败: {cleanup_exc}")
|
||||
|
||||
async def _init_mcp_client(self, name: str, config: dict) -> MCPClient:
|
||||
"""初始化单个MCP客户端"""
|
||||
@@ -608,7 +602,7 @@ class FunctionToolManager:
|
||||
)
|
||||
self.func_list.append(func_tool)
|
||||
|
||||
logger.info(f"Connected to MCP server {name}, Tools: {tool_names}")
|
||||
logger.info(f"已连接 MCP 服务 {name}, Tools: {tool_names}")
|
||||
return mcp_client
|
||||
|
||||
async def _terminate_mcp_client(self, name: str) -> None:
|
||||
@@ -628,7 +622,7 @@ class FunctionToolManager:
|
||||
async with self._runtime_lock:
|
||||
self._mcp_server_runtime.pop(name, None)
|
||||
self._mcp_starting.discard(name)
|
||||
logger.info(f"Disconnected from MCP server {name}")
|
||||
logger.info(f"已关闭 MCP 服务 {name}")
|
||||
return
|
||||
|
||||
# Runtime missing but stale tools may still exist after failed flows.
|
||||
|
||||
@@ -79,7 +79,6 @@ class ProviderManager:
|
||||
self._provider_change_hooks: list[
|
||||
Callable[[str, ProviderType, str | None], None]
|
||||
] = []
|
||||
self._mcp_init_task: asyncio.Task | None = None
|
||||
|
||||
def set_provider_change_callback(
|
||||
self,
|
||||
@@ -331,16 +330,24 @@ class ProviderManager:
|
||||
if not self.curr_tts_provider_inst and self.tts_provider_insts:
|
||||
self.curr_tts_provider_inst = self.tts_provider_insts[0]
|
||||
|
||||
async def _init_mcp_clients_bg() -> None:
|
||||
try:
|
||||
await self.llm_tools.init_mcp_clients()
|
||||
except Exception:
|
||||
logger.error("MCP init background task failed", exc_info=True)
|
||||
|
||||
if self._mcp_init_task is None or self._mcp_init_task.done():
|
||||
self._mcp_init_task = asyncio.create_task(
|
||||
_init_mcp_clients_bg(),
|
||||
name="provider-manager:mcp-init",
|
||||
# 初始化 MCP Client 连接(等待完成以确保工具可用)
|
||||
strict_mcp_init = os.getenv("ASTRBOT_MCP_INIT_STRICT", "").strip().lower() in {
|
||||
"1",
|
||||
"true",
|
||||
"yes",
|
||||
"on",
|
||||
}
|
||||
mcp_init_summary = await self.llm_tools.init_mcp_clients(
|
||||
raise_on_all_failed=strict_mcp_init
|
||||
)
|
||||
if (
|
||||
mcp_init_summary.total > 0
|
||||
and mcp_init_summary.success == 0
|
||||
and not strict_mcp_init
|
||||
):
|
||||
logger.warning(
|
||||
"MCP 服务全部初始化失败,系统将继续启动(可设置 "
|
||||
"ASTRBOT_MCP_INIT_STRICT=1 以在此场景下中止启动)。"
|
||||
)
|
||||
|
||||
def dynamic_import_provider(self, type: str) -> None:
|
||||
@@ -810,13 +817,6 @@ class ProviderManager:
|
||||
await self.load_provider(new_config)
|
||||
|
||||
async def terminate(self) -> None:
|
||||
if self._mcp_init_task and not self._mcp_init_task.done():
|
||||
self._mcp_init_task.cancel()
|
||||
try:
|
||||
await self._mcp_init_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
for provider_inst in self.provider_insts:
|
||||
if hasattr(provider_inst, "terminate"):
|
||||
await provider_inst.terminate() # type: ignore
|
||||
|
||||
@@ -281,24 +281,7 @@ class TTSProvider(AbstractProvider):
|
||||
accumulated_text += text_part
|
||||
|
||||
async def test(self) -> None:
|
||||
audio_path = await self.get_audio("hi")
|
||||
|
||||
# 检查生成的音频文件是否有效
|
||||
if not os.path.exists(audio_path):
|
||||
raise Exception("TTS test failed: audio file was not created")
|
||||
|
||||
file_size = os.path.getsize(audio_path)
|
||||
if file_size == 0:
|
||||
raise Exception(
|
||||
"TTS test failed: generated audio file is empty (0 bytes). "
|
||||
"Please check your TTS provider configuration, especially required parameters like group_id for MiniMax."
|
||||
)
|
||||
|
||||
# 清理测试文件
|
||||
try:
|
||||
os.remove(audio_path)
|
||||
except Exception:
|
||||
pass
|
||||
await self.get_audio("hi")
|
||||
|
||||
|
||||
class EmbeddingProvider(AbstractProvider):
|
||||
|
||||
@@ -276,24 +276,9 @@ class ProviderAnthropic(Provider):
|
||||
llm_response.id = completion.id
|
||||
llm_response.usage = self._extract_usage(completion.usage)
|
||||
|
||||
# Handle cases where completion only contains ThinkingBlock (e.g., MiniMax max_tokens)
|
||||
# When stop_reason='max_tokens', the model may return only thinking content
|
||||
# This is valid and should not raise an exception
|
||||
# TODO(Soulter): 处理 end_turn 情况
|
||||
if not llm_response.completion_text and not llm_response.tools_call_args:
|
||||
# Guard clause: raise early if no valid content at all
|
||||
if not llm_response.reasoning_content:
|
||||
raise ValueError(
|
||||
f"Anthropic API returned unparsable completion: "
|
||||
f"no text, tool_use, or thinking content found. "
|
||||
f"Completion: {completion}"
|
||||
)
|
||||
|
||||
# We have reasoning content (ThinkingBlock) - this is valid
|
||||
stop_reason = getattr(completion, "stop_reason", "unknown")
|
||||
logger.debug(
|
||||
f"Completion contains only ThinkingBlock (stop_reason={stop_reason})"
|
||||
)
|
||||
llm_response.completion_text = "" # Ensure empty string, not None
|
||||
raise Exception(f"Anthropic API 返回的 completion 无法解析:{completion}。")
|
||||
|
||||
return llm_response
|
||||
|
||||
|
||||
@@ -20,7 +20,6 @@ from ..register import register_provider_adapter
|
||||
|
||||
TEMP_DIR = Path(get_astrbot_temp_path()) / "azure_tts"
|
||||
TEMP_DIR.mkdir(parents=True, exist_ok=True)
|
||||
AZURE_TTS_SUBSCRIPTION_KEY_PATTERN = r"^(?:[a-zA-Z0-9]{32}|[a-zA-Z0-9]{84})$"
|
||||
|
||||
|
||||
class OTTSProvider:
|
||||
@@ -117,7 +116,7 @@ class AzureNativeProvider(TTSProvider):
|
||||
"azure_tts_subscription_key",
|
||||
"",
|
||||
).strip()
|
||||
if not re.fullmatch(AZURE_TTS_SUBSCRIPTION_KEY_PATTERN, self.subscription_key):
|
||||
if not re.fullmatch(r"^[a-zA-Z0-9]{32}$", self.subscription_key):
|
||||
raise ValueError("无效的Azure订阅密钥")
|
||||
self.region = provider_config.get("azure_tts_region", "eastus").strip()
|
||||
self.endpoint = (
|
||||
@@ -236,9 +235,9 @@ class AzureTTSProvider(TTSProvider):
|
||||
raise ValueError(error_msg) from e
|
||||
except KeyError as e:
|
||||
raise ValueError(f"配置错误: 缺少必要参数 {e}") from e
|
||||
if re.fullmatch(AZURE_TTS_SUBSCRIPTION_KEY_PATTERN, key_value):
|
||||
if re.fullmatch(r"^[a-zA-Z0-9]{32}$", key_value):
|
||||
return AzureNativeProvider(config, self.provider_settings)
|
||||
raise ValueError("订阅密钥格式无效,应为32位或84位字母数字或other[...]格式")
|
||||
raise ValueError("订阅密钥格式无效,应为32位字母数字或other[...]格式")
|
||||
|
||||
async def get_audio(self, text: str) -> str:
|
||||
if isinstance(self.provider, OTTSProvider):
|
||||
|
||||
@@ -87,7 +87,7 @@ class ProviderDashscopeTTSAPI(TTSProvider):
|
||||
model: str,
|
||||
text: str,
|
||||
) -> tuple[bytes | None, str]:
|
||||
loop = asyncio.get_running_loop()
|
||||
loop = asyncio.get_event_loop()
|
||||
response = await loop.run_in_executor(None, self._call_qwen_tts, model, text)
|
||||
audio_bytes = await self._extract_audio_from_response(response)
|
||||
if not audio_bytes:
|
||||
@@ -143,7 +143,7 @@ class ProviderDashscopeTTSAPI(TTSProvider):
|
||||
voice=self.voice,
|
||||
format=AudioFormat.WAV_24000HZ_MONO_16BIT,
|
||||
)
|
||||
loop = asyncio.get_running_loop()
|
||||
loop = asyncio.get_event_loop()
|
||||
audio_bytes = await loop.run_in_executor(
|
||||
None,
|
||||
synthesizer.call,
|
||||
|
||||
@@ -59,7 +59,7 @@ class GenieTTSProvider(TTSProvider):
|
||||
filename = f"genie_tts_{uuid.uuid4()}.wav"
|
||||
path = os.path.join(temp_dir, filename)
|
||||
|
||||
loop = asyncio.get_running_loop()
|
||||
loop = asyncio.get_event_loop()
|
||||
|
||||
def _generate(save_path: str) -> None:
|
||||
assert genie is not None
|
||||
@@ -85,7 +85,7 @@ class GenieTTSProvider(TTSProvider):
|
||||
text_queue: asyncio.Queue[str | None],
|
||||
audio_queue: "asyncio.Queue[bytes | tuple[str, bytes] | None]",
|
||||
) -> None:
|
||||
loop = asyncio.get_running_loop()
|
||||
loop = asyncio.get_event_loop()
|
||||
|
||||
while True:
|
||||
text = await text_queue.get()
|
||||
|
||||
@@ -154,14 +154,6 @@ class ProviderMiniMaxTTSAPI(TTSProvider):
|
||||
audio_stream = self._call_tts_stream(text)
|
||||
audio = await self._audio_play(audio_stream)
|
||||
|
||||
# 检查音频数据是否为空
|
||||
if not audio or len(audio) == 0:
|
||||
raise Exception(
|
||||
"MiniMax TTS API returned empty audio data. "
|
||||
"Please verify your configuration, especially the 'group_id' parameter. "
|
||||
"You can find your group_id in Account Management -> Basic Information on the MiniMax platform."
|
||||
)
|
||||
|
||||
# 结果保存至文件
|
||||
with open(path, "wb") as file:
|
||||
file.write(audio)
|
||||
@@ -169,4 +161,4 @@ class ProviderMiniMaxTTSAPI(TTSProvider):
|
||||
return path
|
||||
|
||||
except aiohttp.ClientError as e:
|
||||
raise Exception(f"MiniMax TTS API request failed: {e!s}")
|
||||
raise e
|
||||
|
||||
@@ -43,7 +43,7 @@ class ProviderSenseVoiceSTTSelfHost(STTProvider):
|
||||
logger.info("下载或者加载 SenseVoice 模型中,这可能需要一些时间 ...")
|
||||
|
||||
# 将模型加载放到线程池中执行
|
||||
self.model = await asyncio.get_running_loop().run_in_executor(
|
||||
self.model = await asyncio.get_event_loop().run_in_executor(
|
||||
None,
|
||||
lambda: SenseVoiceSmall(self.model_name, quantize=True, batch_size=16),
|
||||
)
|
||||
@@ -88,7 +88,7 @@ class ProviderSenseVoiceSTTSelfHost(STTProvider):
|
||||
audio_url = output_path
|
||||
|
||||
# 使用 run_in_executor 来调用模型进行识别
|
||||
loop = asyncio.get_running_loop()
|
||||
loop = asyncio.get_event_loop()
|
||||
res = await loop.run_in_executor(
|
||||
None, # 使用默认的线程池
|
||||
lambda: cast(SenseVoiceSmall, self.model)(
|
||||
|
||||
@@ -31,7 +31,7 @@ class ProviderOpenAIWhisperSelfHost(STTProvider):
|
||||
self.model = None
|
||||
|
||||
async def initialize(self) -> None:
|
||||
loop = asyncio.get_running_loop()
|
||||
loop = asyncio.get_event_loop()
|
||||
logger.info("下载或者加载 Whisper 模型中,这可能需要一些时间 ...")
|
||||
self.model = await loop.run_in_executor(
|
||||
None,
|
||||
@@ -50,7 +50,7 @@ class ProviderOpenAIWhisperSelfHost(STTProvider):
|
||||
return False
|
||||
|
||||
async def get_text(self, audio_url: str) -> str:
|
||||
loop = asyncio.get_running_loop()
|
||||
loop = asyncio.get_event_loop()
|
||||
|
||||
is_tencent = False
|
||||
|
||||
|
||||
@@ -26,13 +26,6 @@ _SANDBOX_SKILLS_CACHE_VERSION = 1
|
||||
_SKILL_NAME_RE = re.compile(r"^[A-Za-z0-9._-]+$")
|
||||
|
||||
|
||||
def _is_ignored_zip_entry(name: str) -> bool:
|
||||
parts = PurePosixPath(name).parts
|
||||
if not parts:
|
||||
return True
|
||||
return parts[0] == "__MACOSX"
|
||||
|
||||
|
||||
@dataclass
|
||||
class SkillInfo:
|
||||
name: str
|
||||
@@ -408,11 +401,7 @@ class SkillManager:
|
||||
raise ValueError("Uploaded file is not a valid zip archive.")
|
||||
|
||||
with zipfile.ZipFile(zip_path) as zf:
|
||||
names = [
|
||||
name
|
||||
for name in (entry.replace("\\", "/") for entry in zf.namelist())
|
||||
if name and not _is_ignored_zip_entry(name)
|
||||
]
|
||||
names = [name.replace("\\", "/") for name in zf.namelist()]
|
||||
file_names = [name for name in names if name and not name.endswith("/")]
|
||||
if not file_names:
|
||||
raise ValueError("Zip archive is empty.")
|
||||
@@ -447,11 +436,7 @@ class SkillManager:
|
||||
raise ValueError("SKILL.md not found in the skill folder.")
|
||||
|
||||
with tempfile.TemporaryDirectory(dir=get_astrbot_temp_path()) as tmp_dir:
|
||||
for member in zf.infolist():
|
||||
member_name = member.filename.replace("\\", "/")
|
||||
if not member_name or _is_ignored_zip_entry(member_name):
|
||||
continue
|
||||
zf.extract(member, tmp_dir)
|
||||
zf.extractall(tmp_dir)
|
||||
src_dir = Path(tmp_dir) / skill_name
|
||||
if not src_dir.exists():
|
||||
raise ValueError("Skill folder not found after extraction.")
|
||||
|
||||
@@ -15,4 +15,4 @@ class RegexFilter(HandlerFilter):
|
||||
self.regex = re.compile(regex)
|
||||
|
||||
def filter(self, event: AstrMessageEvent, cfg: AstrBotConfig) -> bool:
|
||||
return bool(self.regex.search(event.get_message_str().strip()))
|
||||
return bool(self.regex.match(event.get_message_str().strip()))
|
||||
|
||||
@@ -1374,23 +1374,10 @@ class PluginManager:
|
||||
return
|
||||
|
||||
if "__del__" in star_metadata.star_cls_type.__dict__:
|
||||
loop = asyncio.get_running_loop()
|
||||
future = loop.run_in_executor(
|
||||
asyncio.get_event_loop().run_in_executor(
|
||||
None,
|
||||
star_metadata.star_cls.__del__,
|
||||
)
|
||||
|
||||
def _log_del_exception(fut: asyncio.Future) -> None:
|
||||
if fut.cancelled():
|
||||
return
|
||||
if (exc := fut.exception()) is not None:
|
||||
logger.error(
|
||||
"插件 %s 在 __del__ 中抛出了异常:%r",
|
||||
star_metadata.name,
|
||||
exc,
|
||||
)
|
||||
|
||||
future.add_done_callback(_log_del_exception)
|
||||
elif "terminate" in star_metadata.star_cls_type.__dict__:
|
||||
await star_metadata.star_cls.terminate()
|
||||
|
||||
|
||||
@@ -7,4 +7,4 @@ def is_frozen_runtime() -> bool:
|
||||
|
||||
|
||||
def is_packaged_desktop_runtime() -> bool:
|
||||
return os.environ.get("ASTRBOT_DESKTOP_CLIENT") == "1"
|
||||
return is_frozen_runtime() and os.environ.get("ASTRBOT_DESKTOP_CLIENT") == "1"
|
||||
|
||||
@@ -1,13 +1,9 @@
|
||||
import asyncio
|
||||
import threading
|
||||
import weakref
|
||||
from collections import defaultdict
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
|
||||
class _PerLoopSessionLockManager:
|
||||
"""Per-event-loop session lock manager; keeps original simple semantics."""
|
||||
|
||||
class SessionLockManager:
|
||||
def __init__(self) -> None:
|
||||
self._locks: dict[str, asyncio.Lock] = defaultdict(asyncio.Lock)
|
||||
self._lock_count: dict[str, int] = defaultdict(int)
|
||||
@@ -30,26 +26,4 @@ class _PerLoopSessionLockManager:
|
||||
self._lock_count.pop(session_id, None)
|
||||
|
||||
|
||||
class SessionLockManager:
|
||||
"""Thread-safe session lock manager with per-event-loop isolation."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._state_guard = threading.Lock()
|
||||
self._loop_managers: weakref.WeakKeyDictionary[
|
||||
asyncio.AbstractEventLoop, _PerLoopSessionLockManager
|
||||
] = weakref.WeakKeyDictionary()
|
||||
|
||||
def _get_loop_manager(self) -> _PerLoopSessionLockManager:
|
||||
"""Get the lock manager for the current event loop."""
|
||||
loop = asyncio.get_running_loop()
|
||||
with self._state_guard:
|
||||
return self._loop_managers.setdefault(loop, _PerLoopSessionLockManager())
|
||||
|
||||
@asynccontextmanager
|
||||
async def acquire_lock(self, session_id: str):
|
||||
manager = self._get_loop_manager()
|
||||
async with manager.acquire_lock(session_id):
|
||||
yield
|
||||
|
||||
|
||||
session_lock_manager = SessionLockManager()
|
||||
|
||||
@@ -610,7 +610,6 @@ class ConfigRoute(Route):
|
||||
|
||||
try:
|
||||
conf_id = self.acm.create_conf(name=name, config=config)
|
||||
await self.core_lifecycle.reload_pipeline_scheduler(conf_id)
|
||||
return Response().ok(message="创建成功", data={"conf_id": conf_id}).__dict__
|
||||
except ValueError as e:
|
||||
return Response().error(str(e)).__dict__
|
||||
@@ -650,7 +649,6 @@ class ConfigRoute(Route):
|
||||
try:
|
||||
success = self.acm.delete_conf(conf_id)
|
||||
if success:
|
||||
self.core_lifecycle.pipeline_scheduler_mapping.pop(conf_id, None)
|
||||
return Response().ok(message="删除成功").__dict__
|
||||
return Response().error("删除失败").__dict__
|
||||
except ValueError as e:
|
||||
|
||||
@@ -5,8 +5,7 @@ import os
|
||||
import ssl
|
||||
import traceback
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from datetime import datetime
|
||||
|
||||
import aiohttp
|
||||
import certifi
|
||||
@@ -353,34 +352,6 @@ class PluginRoute(Route):
|
||||
logger.warning(f"获取插件 Logo 失败: {e}")
|
||||
return None
|
||||
|
||||
def _resolve_plugin_dir(self, plugin) -> Path | None:
|
||||
if not plugin.root_dir_name:
|
||||
return None
|
||||
|
||||
base_dir = Path(
|
||||
self.plugin_manager.reserved_plugin_path
|
||||
if plugin.reserved
|
||||
else self.plugin_manager.plugin_store_path
|
||||
)
|
||||
plugin_dir = base_dir / plugin.root_dir_name
|
||||
if not plugin_dir.is_dir():
|
||||
return None
|
||||
return plugin_dir
|
||||
|
||||
def _get_plugin_installed_at(self, plugin) -> str | None:
|
||||
plugin_dir = self._resolve_plugin_dir(plugin)
|
||||
if plugin_dir is None:
|
||||
return None
|
||||
|
||||
try:
|
||||
return datetime.fromtimestamp(
|
||||
plugin_dir.stat().st_mtime,
|
||||
timezone.utc,
|
||||
).isoformat()
|
||||
except OSError as exc:
|
||||
logger.warning(f"获取插件安装时间失败 {plugin.name}: {exc!s}")
|
||||
return None
|
||||
|
||||
async def get_plugins(self):
|
||||
_plugin_resp = []
|
||||
plugin_name = request.args.get("name")
|
||||
@@ -406,7 +377,6 @@ class PluginRoute(Route):
|
||||
"logo": f"/api/file/{logo_url}" if logo_url else None,
|
||||
"support_platforms": plugin.support_platforms,
|
||||
"astrbot_version": plugin.astrbot_version,
|
||||
"installed_at": self._get_plugin_installed_at(plugin),
|
||||
}
|
||||
# 检查是否为全空的幽灵插件
|
||||
if not any(
|
||||
|
||||
@@ -2,7 +2,6 @@ import os
|
||||
import re
|
||||
import shutil
|
||||
import traceback
|
||||
import uuid
|
||||
from collections.abc import Awaitable, Callable
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
@@ -51,7 +50,6 @@ class SkillsRoute(Route):
|
||||
self.routes = {
|
||||
"/skills": ("GET", self.get_skills),
|
||||
"/skills/upload": ("POST", self.upload_skill),
|
||||
"/skills/batch-upload": ("POST", self.batch_upload_skills),
|
||||
"/skills/download": ("GET", self.download_skill),
|
||||
"/skills/update": ("POST", self.update_skill),
|
||||
"/skills/delete": ("POST", self.delete_skill),
|
||||
@@ -190,114 +188,6 @@ class SkillsRoute(Route):
|
||||
except Exception:
|
||||
logger.warning(f"Failed to remove temp skill file: {temp_path}")
|
||||
|
||||
async def batch_upload_skills(self):
|
||||
"""批量上传多个 skill ZIP 文件"""
|
||||
if DEMO_MODE:
|
||||
return (
|
||||
Response()
|
||||
.error("You are not permitted to do this operation in demo mode")
|
||||
.__dict__
|
||||
)
|
||||
|
||||
try:
|
||||
files = await request.files
|
||||
file_list = files.getlist("files")
|
||||
|
||||
if not file_list:
|
||||
return Response().error("No files provided").__dict__
|
||||
|
||||
succeeded = []
|
||||
failed = []
|
||||
skill_mgr = SkillManager()
|
||||
temp_dir = get_astrbot_temp_path()
|
||||
os.makedirs(temp_dir, exist_ok=True)
|
||||
|
||||
for file in file_list:
|
||||
filename = os.path.basename(file.filename or "unknown.zip")
|
||||
temp_path = None
|
||||
|
||||
try:
|
||||
if not filename.lower().endswith(".zip"):
|
||||
failed.append(
|
||||
{
|
||||
"filename": filename,
|
||||
"error": "Only .zip files are supported",
|
||||
}
|
||||
)
|
||||
continue
|
||||
|
||||
temp_path = os.path.join(
|
||||
temp_dir, f"batch_{uuid.uuid4().hex}_{filename}"
|
||||
)
|
||||
await file.save(temp_path)
|
||||
|
||||
skill_name = skill_mgr.install_skill_from_zip(
|
||||
temp_path, overwrite=True
|
||||
)
|
||||
succeeded.append({"filename": filename, "name": skill_name})
|
||||
|
||||
except Exception as e:
|
||||
failed.append({"filename": filename, "error": str(e)})
|
||||
finally:
|
||||
if temp_path and os.path.exists(temp_path):
|
||||
try:
|
||||
os.remove(temp_path)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if succeeded:
|
||||
try:
|
||||
await sync_skills_to_active_sandboxes()
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"Failed to sync uploaded skills to active sandboxes."
|
||||
)
|
||||
|
||||
total = len(file_list)
|
||||
success_count = len(succeeded)
|
||||
|
||||
if success_count == total:
|
||||
message = f"All {total} skill(s) uploaded successfully."
|
||||
return (
|
||||
Response()
|
||||
.ok(
|
||||
{
|
||||
"total": total,
|
||||
"succeeded": succeeded,
|
||||
"failed": failed,
|
||||
},
|
||||
message,
|
||||
)
|
||||
.__dict__
|
||||
)
|
||||
if success_count == 0:
|
||||
message = f"Upload failed for all {total} file(s)."
|
||||
resp = Response().error(message)
|
||||
resp.data = {
|
||||
"total": total,
|
||||
"succeeded": succeeded,
|
||||
"failed": failed,
|
||||
}
|
||||
return resp.__dict__
|
||||
|
||||
message = f"Partial success: {success_count}/{total} skill(s) uploaded."
|
||||
return (
|
||||
Response()
|
||||
.ok(
|
||||
{
|
||||
"total": total,
|
||||
"succeeded": succeeded,
|
||||
"failed": failed,
|
||||
},
|
||||
message,
|
||||
)
|
||||
.__dict__
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(traceback.format_exc())
|
||||
return Response().error(str(e)).__dict__
|
||||
|
||||
async def download_skill(self):
|
||||
try:
|
||||
name = str(request.args.get("name") or "").strip()
|
||||
|
||||
@@ -12,32 +12,6 @@ from .route import Response, Route, RouteContext
|
||||
DEFAULT_MCP_CONFIG = {"mcpServers": {}}
|
||||
|
||||
|
||||
class EmptyMcpServersError(ValueError):
|
||||
"""Raised when mcpServers is empty."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
def _extract_mcp_server_config(mcp_servers_value: object) -> dict:
|
||||
"""Extract server configuration from user-submitted mcpServers field.
|
||||
|
||||
Raises:
|
||||
ValueError: Invalid configuration
|
||||
"""
|
||||
if not isinstance(mcp_servers_value, dict):
|
||||
raise ValueError("mcpServers must be a JSON object")
|
||||
if not mcp_servers_value:
|
||||
raise EmptyMcpServersError("mcpServers configuration cannot be empty")
|
||||
key_0 = next(iter(mcp_servers_value))
|
||||
extracted = mcp_servers_value[key_0]
|
||||
if not isinstance(extracted, dict):
|
||||
raise ValueError(
|
||||
"Invalid mcpServers format. Ensure each key in mcpServers is a server name, "
|
||||
"and each value is an object containing fields like command/url."
|
||||
)
|
||||
return extracted
|
||||
|
||||
|
||||
class ToolsRoute(Route):
|
||||
def __init__(
|
||||
self,
|
||||
@@ -59,37 +33,13 @@ class ToolsRoute(Route):
|
||||
self.register_routes()
|
||||
self.tool_mgr = self.core_lifecycle.provider_manager.llm_tools
|
||||
|
||||
def _rollback_mcp_server(self, name: str) -> bool:
|
||||
try:
|
||||
rollback_config = self.tool_mgr.load_mcp_config()
|
||||
if name in rollback_config["mcpServers"]:
|
||||
rollback_config["mcpServers"].pop(name)
|
||||
return self.tool_mgr.save_mcp_config(rollback_config)
|
||||
return True
|
||||
except Exception:
|
||||
logger.error(traceback.format_exc())
|
||||
return False
|
||||
|
||||
async def get_mcp_servers(self):
|
||||
try:
|
||||
config = self.tool_mgr.load_mcp_config()
|
||||
servers = []
|
||||
mcp_servers = config.get("mcpServers", {})
|
||||
|
||||
if not isinstance(mcp_servers, dict):
|
||||
logger.warning(
|
||||
f"Invalid MCP server config type: {type(mcp_servers).__name__}. Expected object/dict; skipped all MCP servers."
|
||||
)
|
||||
mcp_servers = {}
|
||||
|
||||
# 获取所有服务器并添加它们的工具列表
|
||||
for name, server_config in mcp_servers.items():
|
||||
if not isinstance(server_config, dict):
|
||||
logger.warning(
|
||||
f"Invalid config for MCP server '{name}' (type: {type(server_config).__name__}); skipped."
|
||||
)
|
||||
continue
|
||||
|
||||
for name, server_config in config["mcpServers"].items():
|
||||
server_info = {
|
||||
"name": name,
|
||||
"active": server_config.get("active", True),
|
||||
@@ -115,7 +65,7 @@ class ToolsRoute(Route):
|
||||
return Response().ok(servers).__dict__
|
||||
except Exception as e:
|
||||
logger.error(traceback.format_exc())
|
||||
return Response().error(f"Failed to get MCP server list: {e!s}").__dict__
|
||||
return Response().error(f"获取 MCP 服务器列表失败: {e!s}").__dict__
|
||||
|
||||
async def add_mcp_server(self):
|
||||
try:
|
||||
@@ -125,7 +75,7 @@ class ToolsRoute(Route):
|
||||
|
||||
# 检查必填字段
|
||||
if not name:
|
||||
return Response().error("Server name cannot be empty").__dict__
|
||||
return Response().error("服务器名称不能为空").__dict__
|
||||
|
||||
# 移除特殊字段并检查配置是否有效
|
||||
has_valid_config = False
|
||||
@@ -135,33 +85,21 @@ class ToolsRoute(Route):
|
||||
for key, value in server_data.items():
|
||||
if key not in ["name", "active", "tools", "errlogs"]: # 排除特殊字段
|
||||
if key == "mcpServers":
|
||||
try:
|
||||
server_config = _extract_mcp_server_config(
|
||||
server_data["mcpServers"]
|
||||
)
|
||||
except ValueError as e:
|
||||
return Response().error(f"{e!s}").__dict__
|
||||
key_0 = list(server_data["mcpServers"].keys())[
|
||||
0
|
||||
] # 不考虑为空的情况
|
||||
server_config = server_data["mcpServers"][key_0]
|
||||
else:
|
||||
server_config[key] = value
|
||||
has_valid_config = True
|
||||
|
||||
if not has_valid_config:
|
||||
return (
|
||||
Response()
|
||||
.error("A valid server configuration is required")
|
||||
.__dict__
|
||||
)
|
||||
return Response().error("必须提供有效的服务器配置").__dict__
|
||||
|
||||
config = self.tool_mgr.load_mcp_config()
|
||||
|
||||
if name in config["mcpServers"]:
|
||||
return Response().error(f"Server {name} already exists").__dict__
|
||||
|
||||
try:
|
||||
await self.tool_mgr.test_mcp_server_connection(server_config)
|
||||
except Exception as e:
|
||||
logger.error(traceback.format_exc())
|
||||
return Response().error(f"MCP connection test failed: {e!s}").__dict__
|
||||
return Response().error(f"服务器 {name} 已存在").__dict__
|
||||
|
||||
config["mcpServers"][name] = server_config
|
||||
|
||||
@@ -173,27 +111,17 @@ class ToolsRoute(Route):
|
||||
timeout=30,
|
||||
)
|
||||
except TimeoutError:
|
||||
rollback_ok = self._rollback_mcp_server(name)
|
||||
err_msg = f"Timed out while enabling MCP server {name}."
|
||||
if not rollback_ok:
|
||||
err_msg += " Configuration rollback failed. Please check the config manually."
|
||||
return Response().error(err_msg).__dict__
|
||||
return Response().error(f"启用 MCP 服务器 {name} 超时。").__dict__
|
||||
except Exception as e:
|
||||
logger.error(traceback.format_exc())
|
||||
rollback_ok = self._rollback_mcp_server(name)
|
||||
err_msg = f"Failed to enable MCP server {name}: {e!s}"
|
||||
if not rollback_ok:
|
||||
err_msg += " Configuration rollback failed. Please check the config manually."
|
||||
return Response().error(err_msg).__dict__
|
||||
return (
|
||||
Response()
|
||||
.ok(None, f"Successfully added MCP server {name}")
|
||||
.__dict__
|
||||
)
|
||||
return Response().error("Failed to save configuration").__dict__
|
||||
return (
|
||||
Response().error(f"启用 MCP 服务器 {name} 失败: {e!s}").__dict__
|
||||
)
|
||||
return Response().ok(None, f"成功添加 MCP 服务器 {name}").__dict__
|
||||
return Response().error("保存配置失败").__dict__
|
||||
except Exception as e:
|
||||
logger.error(traceback.format_exc())
|
||||
return Response().error(f"Failed to add MCP server: {e!s}").__dict__
|
||||
return Response().error(f"添加 MCP 服务器失败: {e!s}").__dict__
|
||||
|
||||
async def update_mcp_server(self):
|
||||
try:
|
||||
@@ -203,25 +131,23 @@ class ToolsRoute(Route):
|
||||
old_name = server_data.get("oldName") or name
|
||||
|
||||
if not name:
|
||||
return Response().error("Server name cannot be empty").__dict__
|
||||
return Response().error("服务器名称不能为空").__dict__
|
||||
|
||||
config = self.tool_mgr.load_mcp_config()
|
||||
|
||||
if old_name not in config["mcpServers"]:
|
||||
return Response().error(f"Server {old_name} does not exist").__dict__
|
||||
return Response().error(f"服务器 {old_name} 不存在").__dict__
|
||||
|
||||
is_rename = name != old_name
|
||||
|
||||
if name in config["mcpServers"] and is_rename:
|
||||
return Response().error(f"Server {name} already exists").__dict__
|
||||
return Response().error(f"服务器 {name} 已存在").__dict__
|
||||
|
||||
# 获取活动状态
|
||||
old_config = config["mcpServers"][old_name]
|
||||
if isinstance(old_config, dict):
|
||||
old_active = old_config.get("active", True)
|
||||
else:
|
||||
old_active = True
|
||||
active = server_data.get("active", old_active)
|
||||
active = server_data.get(
|
||||
"active",
|
||||
config["mcpServers"][old_name].get("active", True),
|
||||
)
|
||||
|
||||
# 创建新的配置对象
|
||||
server_config = {"active": active}
|
||||
@@ -239,19 +165,17 @@ class ToolsRoute(Route):
|
||||
"oldName",
|
||||
]: # 排除特殊字段
|
||||
if key == "mcpServers":
|
||||
try:
|
||||
server_config = _extract_mcp_server_config(
|
||||
server_data["mcpServers"]
|
||||
)
|
||||
except ValueError as e:
|
||||
return Response().error(f"{e!s}").__dict__
|
||||
key_0 = list(server_data["mcpServers"].keys())[
|
||||
0
|
||||
] # 不考虑为空的情况
|
||||
server_config = server_data["mcpServers"][key_0]
|
||||
else:
|
||||
server_config[key] = value
|
||||
only_update_active = False
|
||||
|
||||
# 如果只更新活动状态,保留原始配置
|
||||
if only_update_active and isinstance(old_config, dict):
|
||||
for key, value in old_config.items():
|
||||
if only_update_active:
|
||||
for key, value in config["mcpServers"][old_name].items():
|
||||
if key != "active": # 除了active之外的所有字段都保留
|
||||
server_config[key] = value
|
||||
|
||||
@@ -276,7 +200,7 @@ class ToolsRoute(Route):
|
||||
return (
|
||||
Response()
|
||||
.error(
|
||||
f"Timed out while disabling MCP server {old_name} before enabling: {e!s}"
|
||||
f"启用前停用 MCP 服务器时 {old_name} 超时: {e!s}"
|
||||
)
|
||||
.__dict__
|
||||
)
|
||||
@@ -285,7 +209,7 @@ class ToolsRoute(Route):
|
||||
return (
|
||||
Response()
|
||||
.error(
|
||||
f"Failed to disable MCP server {old_name} before enabling: {e!s}"
|
||||
f"启用前停用 MCP 服务器时 {old_name} 失败: {e!s}"
|
||||
)
|
||||
.__dict__
|
||||
)
|
||||
@@ -297,15 +221,13 @@ class ToolsRoute(Route):
|
||||
)
|
||||
except TimeoutError:
|
||||
return (
|
||||
Response()
|
||||
.error(f"Timed out while enabling MCP server {name}.")
|
||||
.__dict__
|
||||
Response().error(f"启用 MCP 服务器 {name} 超时。").__dict__
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(traceback.format_exc())
|
||||
return (
|
||||
Response()
|
||||
.error(f"Failed to enable MCP server {name}: {e!s}")
|
||||
.error(f"启用 MCP 服务器 {name} 失败: {e!s}")
|
||||
.__dict__
|
||||
)
|
||||
# 如果要停用服务器
|
||||
@@ -315,26 +237,22 @@ class ToolsRoute(Route):
|
||||
except TimeoutError:
|
||||
return (
|
||||
Response()
|
||||
.error(f"Timed out while disabling MCP server {old_name}.")
|
||||
.error(f"停用 MCP 服务器 {old_name} 超时。")
|
||||
.__dict__
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(traceback.format_exc())
|
||||
return (
|
||||
Response()
|
||||
.error(f"Failed to disable MCP server {old_name}: {e!s}")
|
||||
.error(f"停用 MCP 服务器 {old_name} 失败: {e!s}")
|
||||
.__dict__
|
||||
)
|
||||
|
||||
return (
|
||||
Response()
|
||||
.ok(None, f"Successfully updated MCP server {name}")
|
||||
.__dict__
|
||||
)
|
||||
return Response().error("Failed to save configuration").__dict__
|
||||
return Response().ok(None, f"成功更新 MCP 服务器 {name}").__dict__
|
||||
return Response().error("保存配置失败").__dict__
|
||||
except Exception as e:
|
||||
logger.error(traceback.format_exc())
|
||||
return Response().error(f"Failed to update MCP server: {e!s}").__dict__
|
||||
return Response().error(f"更新 MCP 服务器失败: {e!s}").__dict__
|
||||
|
||||
async def delete_mcp_server(self):
|
||||
try:
|
||||
@@ -342,12 +260,12 @@ class ToolsRoute(Route):
|
||||
name = server_data.get("name", "")
|
||||
|
||||
if not name:
|
||||
return Response().error("Server name cannot be empty").__dict__
|
||||
return Response().error("服务器名称不能为空").__dict__
|
||||
|
||||
config = self.tool_mgr.load_mcp_config()
|
||||
|
||||
if name not in config["mcpServers"]:
|
||||
return Response().error(f"Server {name} does not exist").__dict__
|
||||
return Response().error(f"服务器 {name} 不存在").__dict__
|
||||
|
||||
del config["mcpServers"][name]
|
||||
|
||||
@@ -357,76 +275,51 @@ class ToolsRoute(Route):
|
||||
await self.tool_mgr.disable_mcp_server(name, timeout=10)
|
||||
except TimeoutError:
|
||||
return (
|
||||
Response()
|
||||
.error(f"Timed out while disabling MCP server {name}.")
|
||||
.__dict__
|
||||
Response().error(f"停用 MCP 服务器 {name} 超时。").__dict__
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(traceback.format_exc())
|
||||
return (
|
||||
Response()
|
||||
.error(f"Failed to disable MCP server {name}: {e!s}")
|
||||
.error(f"停用 MCP 服务器 {name} 失败: {e!s}")
|
||||
.__dict__
|
||||
)
|
||||
return (
|
||||
Response()
|
||||
.ok(None, f"Successfully deleted MCP server {name}")
|
||||
.__dict__
|
||||
)
|
||||
return Response().error("Failed to save configuration").__dict__
|
||||
return Response().ok(None, f"成功删除 MCP 服务器 {name}").__dict__
|
||||
return Response().error("保存配置失败").__dict__
|
||||
except Exception as e:
|
||||
logger.error(traceback.format_exc())
|
||||
return Response().error(f"Failed to delete MCP server: {e!s}").__dict__
|
||||
return Response().error(f"删除 MCP 服务器失败: {e!s}").__dict__
|
||||
|
||||
async def test_mcp_connection(self):
|
||||
"""Test MCP server connection."""
|
||||
"""测试 MCP 服务器连接"""
|
||||
try:
|
||||
server_data = await request.json
|
||||
config = server_data.get("mcp_server_config", None)
|
||||
|
||||
if not isinstance(config, dict) or not config:
|
||||
return Response().error("Invalid MCP server configuration").__dict__
|
||||
return Response().error("无效的 MCP 服务器配置").__dict__
|
||||
|
||||
if "mcpServers" in config:
|
||||
mcp_servers = config["mcpServers"]
|
||||
if isinstance(mcp_servers, dict) and len(mcp_servers) > 1:
|
||||
return (
|
||||
Response()
|
||||
.error(
|
||||
"Only one MCP server configuration can be tested at a time"
|
||||
)
|
||||
.__dict__
|
||||
)
|
||||
try:
|
||||
config = _extract_mcp_server_config(mcp_servers)
|
||||
except EmptyMcpServersError:
|
||||
return (
|
||||
Response()
|
||||
.error("MCP server configuration cannot be empty")
|
||||
.__dict__
|
||||
)
|
||||
except ValueError as e:
|
||||
return Response().error(f"{e!s}").__dict__
|
||||
keys = list(config["mcpServers"].keys())
|
||||
if not keys:
|
||||
return Response().error("MCP 服务器配置不能为空").__dict__
|
||||
if len(keys) > 1:
|
||||
return Response().error("一次只能配置一个 MCP 服务器配置").__dict__
|
||||
config = config["mcpServers"][keys[0]]
|
||||
elif not config:
|
||||
return (
|
||||
Response()
|
||||
.error("MCP server configuration cannot be empty")
|
||||
.__dict__
|
||||
)
|
||||
return Response().error("MCP 服务器配置不能为空").__dict__
|
||||
|
||||
tools_name = await self.tool_mgr.test_mcp_server_connection(config)
|
||||
return (
|
||||
Response()
|
||||
.ok(data=tools_name, message="🎉 MCP server is available!")
|
||||
.__dict__
|
||||
Response().ok(data=tools_name, message="🎉 MCP 服务器可用!").__dict__
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(traceback.format_exc())
|
||||
return Response().error(f"Failed to test MCP connection: {e!s}").__dict__
|
||||
return Response().error(f"测试 MCP 连接失败: {e!s}").__dict__
|
||||
|
||||
async def get_tool_list(self):
|
||||
"""Get all registered tools."""
|
||||
"""获取所有注册的工具列表"""
|
||||
try:
|
||||
tools = self.tool_mgr.func_list
|
||||
tools_dict = []
|
||||
@@ -456,44 +349,36 @@ class ToolsRoute(Route):
|
||||
return Response().ok(data=tools_dict).__dict__
|
||||
except Exception as e:
|
||||
logger.error(traceback.format_exc())
|
||||
return Response().error(f"Failed to get tool list: {e!s}").__dict__
|
||||
return Response().error(f"获取工具列表失败: {e!s}").__dict__
|
||||
|
||||
async def toggle_tool(self):
|
||||
"""Activate or deactivate a specified tool."""
|
||||
"""启用或停用指定的工具"""
|
||||
try:
|
||||
data = await request.json
|
||||
tool_name = data.get("name")
|
||||
action = data.get("activate") # True or False
|
||||
|
||||
if not tool_name or action is None:
|
||||
return (
|
||||
Response()
|
||||
.error("Missing required parameters: name or activate")
|
||||
.__dict__
|
||||
)
|
||||
return Response().error("缺少必要参数: name 或 action").__dict__
|
||||
|
||||
if action:
|
||||
try:
|
||||
ok = self.tool_mgr.activate_llm_tool(tool_name, star_map=star_map)
|
||||
except ValueError as e:
|
||||
return Response().error(f"Failed to activate tool: {e!s}").__dict__
|
||||
return Response().error(f"启用工具失败: {e!s}").__dict__
|
||||
else:
|
||||
ok = self.tool_mgr.deactivate_llm_tool(tool_name)
|
||||
|
||||
if ok:
|
||||
return Response().ok(None, "Operation successful.").__dict__
|
||||
return (
|
||||
Response()
|
||||
.error(f"Tool {tool_name} does not exist or the operation failed.")
|
||||
.__dict__
|
||||
)
|
||||
return Response().ok(None, "操作成功。").__dict__
|
||||
return Response().error(f"工具 {tool_name} 不存在或操作失败。").__dict__
|
||||
|
||||
except Exception as e:
|
||||
logger.error(traceback.format_exc())
|
||||
return Response().error(f"Failed to operate tool: {e!s}").__dict__
|
||||
return Response().error(f"操作工具失败: {e!s}").__dict__
|
||||
|
||||
async def sync_provider(self):
|
||||
"""Sync MCP provider configuration."""
|
||||
"""同步 MCP 提供者配置"""
|
||||
try:
|
||||
data = await request.json
|
||||
provider_name = data.get("name") # modelscope, or others
|
||||
@@ -502,11 +387,9 @@ class ToolsRoute(Route):
|
||||
access_token = data.get("access_token", "")
|
||||
await self.tool_mgr.sync_modelscope_mcp_servers(access_token)
|
||||
case _:
|
||||
return (
|
||||
Response().error(f"Unknown provider: {provider_name}").__dict__
|
||||
)
|
||||
return Response().error(f"未知: {provider_name}").__dict__
|
||||
|
||||
return Response().ok(message="Sync completed").__dict__
|
||||
return Response().ok(message="同步成功").__dict__
|
||||
except Exception as e:
|
||||
logger.error(traceback.format_exc())
|
||||
return Response().error(f"Sync failed: {e!s}").__dict__
|
||||
return Response().error(f"同步失败: {e!s}").__dict__
|
||||
|
||||
@@ -1,70 +0,0 @@
|
||||
## What's Changed
|
||||
|
||||
### 新增
|
||||
|
||||
- 集成 KOOK 平台适配器 ([#5658](https://github.com/AstrBotDevs/AstrBot/pull/5658))。
|
||||
- 新增 Discord pre-react Emoji 支持 ([#5609](https://github.com/AstrBotDevs/AstrBot/pull/5609))。
|
||||
- 新增 Telegram 支持 `sendMessageDraft` 流式实时输出 API ([#5726](https://github.com/AstrBotDevs/AstrBot/issues/5726))
|
||||
- 支持在 Agent 运行时进行消息跟进能力,跟进的消息实时注入给 Agent ([#5484](https://github.com/AstrBotDevs/AstrBot/pull/5484))。
|
||||
- 集成 DeerFlow Agent Runner 并优化流式处理 ([#5581](https://github.com/AstrBotDevs/AstrBot/pull/5581))。
|
||||
- 新增 shell, ipython tool 中包含操作系统信息,提高 windows 下 tool call 成功率 ([#5677](https://github.com/AstrBotDevs/AstrBot/pull/5677))。
|
||||
- Sandbox 支持 Shipyard-neo - 支持 Skills 自迭代 ([#5028](https://github.com/AstrBotDevs/AstrBot/pull/5028))。
|
||||
- 新增 ChatUI WebSocket 传输模式选择,OpenAPI Chat API 支持 WebSocket 连接 ([#5410](https://github.com/AstrBotDevs/AstrBot/pull/5410))。
|
||||
- 支持 Persona 自定义报错回复消息与兜底逻辑 ([#5547](https://github.com/AstrBotDevs/AstrBot/pull/5547))。
|
||||
- 将 WebUI 静态文件打包至 wheel,并将 astrbot CLI 日志替换为英文 ([#5665](https://github.com/AstrBotDevs/AstrBot/pull/5665))。
|
||||
- 增强聊天界面与移动端响应式体验 ([#5635](https://github.com/AstrBotDevs/AstrBot/pull/5635))。
|
||||
- 优化插件失败处理逻辑与扩展列表交互体验 ([#5535](https://github.com/AstrBotDevs/AstrBot/pull/5535))。
|
||||
|
||||
### 修复
|
||||
|
||||
- 修复 MCP 初始化超时参数关键字不匹配的问题 ([#5743](https://github.com/AstrBotDevs/AstrBot/pull/5743))。
|
||||
- 修复 MCP 工具竞态条件导致"completion 无法解析"错误 ([#5534](https://github.com/AstrBotDevs/AstrBot/pull/5534))。
|
||||
- 修复 LINE 适配器中非 HTTPS URL 直接透传的问题 ([#5697](https://github.com/AstrBotDevs/AstrBot/pull/5697))。
|
||||
- 修复 WebUI 侧边栏自定义状态不稳定的问题 ([#5670](https://github.com/AstrBotDevs/AstrBot/pull/5670))。
|
||||
- 修复 KOOK 适配器收到消息和心跳响应时输出多余调试日志的问题。
|
||||
- 修复 `DEMO_MODE` 环境变量未正确解析为布尔值的问题 ([#5676](https://github.com/AstrBotDevs/AstrBot/pull/5676))。
|
||||
- 修复子 Agent 无法正确接收本地图片(参考图)路径的问题 ([#5579](https://github.com/AstrBotDevs/AstrBot/pull/5579))。
|
||||
- 修复 `/model` 命令切换至不同 Provider 模型时产生误导性行为的问题 ([#5578](https://github.com/AstrBotDevs/AstrBot/pull/5578))。
|
||||
- 修复对话记录中 UTC 时区偏移未处理导致时间戳异常的问题 ([#5580](https://github.com/AstrBotDevs/AstrBot/pull/5580))。
|
||||
- 修复备份导入时重复平台统计数据导致异常的问题 ([#5594](https://github.com/AstrBotDevs/AstrBot/pull/5594))。
|
||||
- 修复 `max_agent_step` 配置未应用到子 Agent 的问题 ([#5608](https://github.com/AstrBotDevs/AstrBot/pull/5608))。
|
||||
- 修复插件列表排序和搜索过滤逻辑 ([#5559](https://github.com/AstrBotDevs/AstrBot/pull/5559))。
|
||||
- 修复 `uv sync` 时未要求 Node.js 环境的问题。
|
||||
|
||||
---
|
||||
|
||||
## What's Changed (EN)
|
||||
|
||||
### New Features
|
||||
|
||||
- Integrated KOOK platform adapter ([#5658](https://github.com/AstrBotDevs/AstrBot/pull/5658)).
|
||||
- Integrated DeerFlow Agent Runner with optimized streaming support ([#5581](https://github.com/AstrBotDevs/AstrBot/pull/5581)).
|
||||
- feat(telegram): supports sendMessageDraft API ([#5726](https://github.com/AstrBotDevs/AstrBot/issues/5726))
|
||||
- Integrated Neo skill self-iteration capability with full lifecycle management (candidate, release, deletion) via Shipyard Neo sandbox ([#5028](https://github.com/AstrBotDevs/AstrBot/pull/5028)).
|
||||
- Added Discord pre-ack emoji support ([#5609](https://github.com/AstrBotDevs/AstrBot/pull/5609)).
|
||||
- Added WebSocket transport mode selection for the chat interface ([#5410](https://github.com/AstrBotDevs/AstrBot/pull/5410)).
|
||||
- Added OS information to tool descriptions with unit test coverage ([#5677](https://github.com/AstrBotDevs/AstrBot/pull/5677)).
|
||||
- Added follow-up message handling in `ToolLoopAgentRunner` ([#5484](https://github.com/AstrBotDevs/AstrBot/pull/5484)).
|
||||
- Added support for persona custom error reply messages with fallback logic ([#5547](https://github.com/AstrBotDevs/AstrBot/pull/5547)).
|
||||
- Bundled WebUI static files into the wheel package and replaced astrbot CLI logs with English ([#5665](https://github.com/AstrBotDevs/AstrBot/pull/5665)).
|
||||
- Optimized async IO performance and added benchmark coverage ([#5737](https://github.com/AstrBotDevs/AstrBot/pull/5737)).
|
||||
- Refactored API key creation and added unit tests for open API routes.
|
||||
- Improved error messaging for AI execution failures in agent runners.
|
||||
- Enhanced chat interface and mobile responsiveness ([#5635](https://github.com/AstrBotDevs/AstrBot/pull/5635)).
|
||||
- Improved plugin failure handling and extension list UX ([#5535](https://github.com/AstrBotDevs/AstrBot/pull/5535)).
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Fixed MCP initialization timeout keyword mismatch ([#5743](https://github.com/AstrBotDevs/AstrBot/pull/5743)).
|
||||
- Fixed MCP tools race condition causing `completion 无法解析` error ([#5534](https://github.com/AstrBotDevs/AstrBot/pull/5534)).
|
||||
- Fixed LINE adapter allowing non-HTTPS URLs to pass through directly ([#5697](https://github.com/AstrBotDevs/AstrBot/pull/5697)).
|
||||
- Fixed unstable sidebar customization state in WebUI ([#5670](https://github.com/AstrBotDevs/AstrBot/pull/5670)).
|
||||
- Fixed excessive debug logging in KOOK adapter for received messages and heartbeat responses.
|
||||
- Fixed `DEMO_MODE` environment variable not being parsed correctly as a boolean ([#5676](https://github.com/AstrBotDevs/AstrBot/pull/5676)).
|
||||
- Fixed sub-agent failing to correctly receive local image (reference image) paths ([#5579](https://github.com/AstrBotDevs/AstrBot/pull/5579)).
|
||||
- Fixed misleading behavior of the `/model` command when switching to a model from a different provider ([#5578](https://github.com/AstrBotDevs/AstrBot/pull/5578)).
|
||||
- Fixed unhandled UTC timezone offset causing incorrect timestamps in conversation records ([#5580](https://github.com/AstrBotDevs/AstrBot/pull/5580)).
|
||||
- Fixed backup import failure due to duplicate platform stats entries ([#5594](https://github.com/AstrBotDevs/AstrBot/pull/5594)).
|
||||
- Fixed `max_agent_step` config not being applied to sub-agents ([#5608](https://github.com/AstrBotDevs/AstrBot/pull/5608)).
|
||||
- Fixed plugin list sorting and search filtering logic ([#5559](https://github.com/AstrBotDevs/AstrBot/pull/5559)).
|
||||
- Fixed missing Node.js environment requirement during `uv sync`.
|
||||
@@ -1,40 +0,0 @@
|
||||
## What's Changed
|
||||
|
||||
### 新增
|
||||
|
||||
- 新增技能 ZIP 批量上传能力 ([#5804](https://github.com/AstrBotDevs/AstrBot/pull/5804))。
|
||||
|
||||
### 修复
|
||||
|
||||
- 修复 MCP Server 配置异常时可能导致崩溃的问题 ([#5666](https://github.com/AstrBotDevs/AstrBot/pull/5666), [#5673](https://github.com/AstrBotDevs/AstrBot/pull/5673))。
|
||||
- 修复钉钉适配器文本消息被忽略、无法主动发送文件的问题 ([#5921](https://github.com/AstrBotDevs/AstrBot/pull/5921))。
|
||||
- 修复钉钉适配器无法接收图片与文件的问题 ([#5920](https://github.com/AstrBotDevs/AstrBot/pull/5920))。
|
||||
- fix(provider): handle MiniMax ThinkingBlock when max_tokens reached ([#5913](https://github.com/AstrBotDevs/AstrBot/pull/5913))。
|
||||
- 修复 OpenRouter `api_base` 配置错误的问题 ([#5911](https://github.com/AstrBotDevs/AstrBot/pull/5911))。
|
||||
- 修复插件市场中按展示名搜索已安装插件不生效的问题 ([#5806](https://github.com/AstrBotDevs/AstrBot/pull/5806), [#5811](https://github.com/AstrBotDevs/AstrBot/pull/5811))。
|
||||
- 修复仅图片响应未应用 `reply_with_quote` 与 `reply_with_mention` 的问题 ([#5219](https://github.com/AstrBotDevs/AstrBot/pull/5219))。
|
||||
- 修复 `RegexFilter` 使用 `re.match` 导致匹配范围不正确的问题 ([#5368](https://github.com/AstrBotDevs/AstrBot/pull/5368))。
|
||||
- 修复桌面运行环境检测依赖 frozen Python 的问题 ([#5859](https://github.com/AstrBotDevs/AstrBot/pull/5859))。
|
||||
- 修复通过“创建新配置”创建平台机器人后找不到 pipeline scheduler 的问题 ([#5776](https://github.com/AstrBotDevs/AstrBot/pull/5776))。
|
||||
|
||||
---
|
||||
|
||||
## What's Changed (EN)
|
||||
|
||||
### New Features
|
||||
|
||||
- Added batch upload support for multiple skill ZIP files ([#5804](https://github.com/AstrBotDevs/AstrBot/pull/5804)).
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Fixed potential crash on malformed MCP server config ([#5666](https://github.com/AstrBotDevs/AstrBot/pull/5666), [#5673](https://github.com/AstrBotDevs/AstrBot/pull/5673)).
|
||||
- Fixed DingTalk adapter issue where text messages were ignored and files could not be sent proactively ([#5921](https://github.com/AstrBotDevs/AstrBot/pull/5921)).
|
||||
- Fixed DingTalk adapter issue where image and file messages could not be received ([#5920](https://github.com/AstrBotDevs/AstrBot/pull/5920)).
|
||||
- Fixed incorrect OpenRouter `api_base` configuration ([#5911](https://github.com/AstrBotDevs/AstrBot/pull/5911)).
|
||||
- Fixed searching installed plugins by display name in extensions ([#5806](https://github.com/AstrBotDevs/AstrBot/pull/5806), [#5811](https://github.com/AstrBotDevs/AstrBot/pull/5811)).
|
||||
- Fixed image-only responses not applying `reply_with_quote` and `reply_with_mention` ([#5219](https://github.com/AstrBotDevs/AstrBot/pull/5219)).
|
||||
- Fixed `RegexFilter` using `re.match` instead of `re.search` for expected matching behavior ([#5368](https://github.com/AstrBotDevs/AstrBot/pull/5368)).
|
||||
- Fixed desktop runtime detection requiring frozen Python ([#5859](https://github.com/AstrBotDevs/AstrBot/pull/5859)).
|
||||
- Fixed missing pipeline scheduler after creating a platform bot via "create new config" ([#5776](https://github.com/AstrBotDevs/AstrBot/pull/5776)).
|
||||
- fix(provider): handle MiniMax ThinkingBlock when max_tokens reached ([#5913](https://github.com/AstrBotDevs/AstrBot/pull/5913))
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
## What's Changed
|
||||
|
||||
### 新增
|
||||
|
||||
- 企业微信智能机器人支持长连接模式。[#5930](https://github.com/AstrBotDevs/AstrBot/pull/5930)
|
||||
|
||||
### New
|
||||
|
||||
- Wecom AI Bot supports long-connection mode(Websockets). [#5930](https://github.com/AstrBotDevs/AstrBot/pull/5930)
|
||||
@@ -1,43 +0,0 @@
|
||||
## What's Changed
|
||||
|
||||
### 新增
|
||||
|
||||
- Lark 适配器支持 CardKit 流式输出(飞书)([#5777](https://github.com/AstrBotDevs/AstrBot/pull/5777))。
|
||||
- WebUI 已安装插件列表新增筛选与排序功能 ([#5923](https://github.com/AstrBotDevs/AstrBot/pull/5923))。
|
||||
|
||||
### 优化
|
||||
- 启动时后台加载 MCP Server,不阻塞加载流程 ([#5993](https://github.com/AstrBotDevs/AstrBot/pull/5993))。
|
||||
|
||||
### 修复
|
||||
|
||||
- 部分情况下 MCP 页报错 500 导致查看不了 MCP 服务器 ([#5993](https://github.com/AstrBotDevs/AstrBot/pull/5993))。
|
||||
- 修复 TTS Provider 测试:增加文件大小校验,并补充 MiniMax 空音频检测 ([#5999](https://github.com/AstrBotDevs/AstrBot/pull/5999))。
|
||||
- 修复前端切换到 Chat 后又回到 Welcome 时,页面切换配置未正确持久化的问题 ([#5792](https://github.com/AstrBotDevs/AstrBot/pull/5792))。
|
||||
- 修复 Azure TTS 不支持 84 位订阅密钥的问题 ([#5813](https://github.com/AstrBotDevs/AstrBot/pull/5813))。
|
||||
|
||||
### 文档
|
||||
|
||||
- 文档仓库迁移:将 `AstrBotDevs/AstrBot-docs` 内容迁移至 `AstrBotDevs/AstrBot` ([#5960](https://github.com/AstrBotDevs/AstrBot/pull/5960))。
|
||||
|
||||
---
|
||||
|
||||
## What's Changed (EN)
|
||||
|
||||
### New Features
|
||||
|
||||
- Added CardKit streaming output support for the Lark/Feishu adapter ([#5777](https://github.com/AstrBotDevs/AstrBot/pull/5777)).
|
||||
- Added filtering and sorting for installed plugins in the WebUI ([#5923](https://github.com/AstrBotDevs/AstrBot/pull/5923)).
|
||||
|
||||
### Impprovement
|
||||
- MCP Server now loads in the background during startup without blocking the loading process ([#5993](https://github.com/AstrBotDevs/AstrBot/pull/5993)).
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Added file size validation in TTS provider tests and MiniMax empty-audio detection ([#5999](https://github.com/AstrBotDevs/AstrBot/pull/5999)).
|
||||
- Fixed frontend state persistence when switching from Chat back to Welcome ([#5792](https://github.com/AstrBotDevs/AstrBot/pull/5792)).
|
||||
- Fixed Azure TTS support for 84-character subscription keys ([#5813](https://github.com/AstrBotDevs/AstrBot/pull/5813)).
|
||||
- Reverted the MCP stdio missing-command error wording change after the previous fix ([#5992](https://github.com/AstrBotDevs/AstrBot/pull/5992)).
|
||||
|
||||
### Documentation
|
||||
|
||||
- Migrated documentation content from `AstrBotDevs/AstrBot-docs` into `AstrBotDevs/AstrBot` ([#5960](https://github.com/AstrBotDevs/AstrBot/pull/5960)).
|
||||
@@ -300,10 +300,6 @@ export default {
|
||||
this.loadingGettingServers = true;
|
||||
axios.get('/api/tools/mcp/servers')
|
||||
.then(response => {
|
||||
if (response.data.status === 'error') {
|
||||
this.showError(response.data.message || this.tm('messages.getServersError', { error: 'Unknown error' }));
|
||||
return;
|
||||
}
|
||||
this.mcpServers = response.data.data || [];
|
||||
this.mcpServers.forEach(server => {
|
||||
if (!this.mcpServerUpdateLoaders[server.name]) {
|
||||
@@ -376,10 +372,6 @@ export default {
|
||||
axios.post(endpoint, serverData)
|
||||
.then(response => {
|
||||
this.loading = false;
|
||||
if (response.data.status === 'error') {
|
||||
this.showError(response.data.message || this.tm('messages.saveError', { error: 'Unknown error' }));
|
||||
return;
|
||||
}
|
||||
this.showMcpServerDialog = false;
|
||||
this.addServerDialogMessage = '';
|
||||
this.getServers();
|
||||
|
||||
@@ -1,97 +0,0 @@
|
||||
<script setup>
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
items: {
|
||||
type: Array,
|
||||
required: true,
|
||||
},
|
||||
label: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
order: {
|
||||
type: String,
|
||||
default: "desc",
|
||||
},
|
||||
ascendingLabel: {
|
||||
type: String,
|
||||
default: "Ascending",
|
||||
},
|
||||
descendingLabel: {
|
||||
type: String,
|
||||
default: "Descending",
|
||||
},
|
||||
showOrder: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(["update:modelValue", "update:order"]);
|
||||
|
||||
const updateSortBy = (value) => {
|
||||
emit("update:modelValue", value);
|
||||
};
|
||||
|
||||
const toggleOrder = () => {
|
||||
emit("update:order", props.order === "desc" ? "asc" : "desc");
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="plugin-sort-control">
|
||||
<v-select
|
||||
:model-value="modelValue"
|
||||
:items="items"
|
||||
density="compact"
|
||||
variant="outlined"
|
||||
hide-details
|
||||
:label="label"
|
||||
class="plugin-sort-control__select"
|
||||
@update:model-value="updateSortBy"
|
||||
>
|
||||
<template #prepend-inner>
|
||||
<v-icon size="small">mdi-sort</v-icon>
|
||||
</template>
|
||||
</v-select>
|
||||
|
||||
<v-btn
|
||||
v-if="showOrder"
|
||||
icon
|
||||
variant="text"
|
||||
density="compact"
|
||||
@click="toggleOrder"
|
||||
>
|
||||
<v-icon>{{
|
||||
order === "desc" ? "mdi-arrow-down-thin" : "mdi-arrow-up-thin"
|
||||
}}</v-icon>
|
||||
<v-tooltip activator="parent" location="top">
|
||||
{{ order === "desc" ? descendingLabel : ascendingLabel }}
|
||||
</v-tooltip>
|
||||
</v-btn>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.plugin-sort-control {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.plugin-sort-control__select {
|
||||
min-width: 180px;
|
||||
max-width: 220px;
|
||||
}
|
||||
|
||||
.plugin-sort-control__select :deep(.v-field__input),
|
||||
.plugin-sort-control__select :deep(.v-field-label),
|
||||
.plugin-sort-control__select :deep(.v-select__selection-text),
|
||||
.plugin-sort-control__select :deep(.v-field__prepend-inner) {
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
</style>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -550,10 +550,6 @@
|
||||
"description": "WeCom AI Bot Name",
|
||||
"hint": "Must be correct; otherwise some commands won't work."
|
||||
},
|
||||
"wecom_ai_bot_connection_mode": {
|
||||
"description": "WeCom AI Bot Connection Mode",
|
||||
"hint": "Webhook mode requires Token/EncodingAESKey; long_connection mode requires BotID/Secret."
|
||||
},
|
||||
"wecomaibot_friend_message_welcome_text": {
|
||||
"description": "WeCom AI Bot DM Welcome Message",
|
||||
"hint": "When a user enters a DM session on that day, reply with a welcome message. Leave empty to disable."
|
||||
@@ -562,30 +558,6 @@
|
||||
"description": "WeCom AI Bot Initial Response Text",
|
||||
"hint": "First reply when the bot receives a message. Leave empty to disable."
|
||||
},
|
||||
"wecomaibot_token": {
|
||||
"description": "WeCom AI Bot Token",
|
||||
"hint": "Used for authentication in webhook callback mode."
|
||||
},
|
||||
"wecomaibot_encoding_aes_key": {
|
||||
"description": "WeCom AI Bot EncodingAESKey",
|
||||
"hint": "Used for message encryption/decryption in webhook callback mode."
|
||||
},
|
||||
"wecomaibot_ws_bot_id": {
|
||||
"description": "Long Connection BotID",
|
||||
"hint": "BotID credential for WeCom AI Bot long connection mode."
|
||||
},
|
||||
"wecomaibot_ws_secret": {
|
||||
"description": "Long Connection Secret",
|
||||
"hint": "Secret credential for WeCom AI Bot long connection mode."
|
||||
},
|
||||
"wecomaibot_ws_url": {
|
||||
"description": "Long Connection WebSocket URL",
|
||||
"hint": "Default is wss://openws.work.weixin.qq.com and usually does not need changes."
|
||||
},
|
||||
"wecomaibot_heartbeat_interval": {
|
||||
"description": "Long Connection Heartbeat Interval",
|
||||
"hint": "Heartbeat interval (seconds) in long connection mode. 30 seconds is recommended."
|
||||
},
|
||||
"wpp_active_message_poll": {
|
||||
"description": "Enable Proactive Message Polling",
|
||||
"hint": "Only enable if WeChat messages are not syncing to AstrBot on time. Disabled by default."
|
||||
@@ -1521,4 +1493,4 @@
|
||||
"helpMiddle": "or",
|
||||
"helpSuffix": "."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,9 +23,6 @@
|
||||
"placeholder": "Search extensions...",
|
||||
"marketPlaceholder": "Search market extensions..."
|
||||
},
|
||||
"filters": {
|
||||
"all": "All"
|
||||
},
|
||||
"views": {
|
||||
"card": "Card View",
|
||||
"list": "List View"
|
||||
@@ -125,14 +122,10 @@
|
||||
"sourceSafetyWarning": "Even with the default source, plugin stability and security cannot be fully guaranteed. Please verify carefully before use."
|
||||
},
|
||||
"sort": {
|
||||
"by": "Sort by",
|
||||
"default": "Default",
|
||||
"installTime": "Last Modified",
|
||||
"name": "Name",
|
||||
"stars": "Stars",
|
||||
"author": "Author",
|
||||
"updated": "Last Updated",
|
||||
"updateStatus": "Update Status",
|
||||
"ascending": "Ascending",
|
||||
"descending": "Descending"
|
||||
},
|
||||
@@ -231,43 +224,10 @@
|
||||
"empty": "No Skills found",
|
||||
"emptyHint": "Upload a Skills zip to get started",
|
||||
"uploadDialogTitle": "Upload Skills",
|
||||
"uploadHint": "Upload multiple zip skill packages or drag them in. The system validates the structure automatically and shows a result for each file.",
|
||||
"structureRequirement": "The most common failure is an invalid archive structure. Each zip must contain exactly one top-level folder such as `skillname/`, and that folder must include `SKILL.md`.",
|
||||
"abilityMultiple": "Upload multiple zip files at once",
|
||||
"abilityValidate": "Validate `SKILL.md` automatically",
|
||||
"abilitySkip": "Automatically skip duplicate files.",
|
||||
"uploadHint": "Upload a zip file that contains skill_name/ and a SKILL.md inside.",
|
||||
"selectFile": "Select file",
|
||||
"selectFiles": "Select files (multiple allowed)",
|
||||
"dropzoneTitle": "Drag multiple zip files here",
|
||||
"dropzoneAction": "or click to pick multiple files from a folder",
|
||||
"dropzoneHint": "Batch upload is supported and the structure will be validated automatically",
|
||||
"fileListTitle": "Files in queue",
|
||||
"fileListEmpty": "Selected files will appear here with validation feedback and upload status",
|
||||
"uploading": "Uploading...",
|
||||
"batchResultTitle": "Batch Upload Results",
|
||||
"batchResultSummary": "{success} of {total} files uploaded successfully",
|
||||
"batchSuccessList": "Successfully uploaded",
|
||||
"batchFailedList": "Failed to upload",
|
||||
"confirm": "OK",
|
||||
"confirmUpload": "Start Upload",
|
||||
"confirmUpload": "Upload",
|
||||
"cancel": "Cancel",
|
||||
"statusWaiting": "Waiting",
|
||||
"statusUploading": "Uploading",
|
||||
"statusSuccess": "Uploaded",
|
||||
"statusError": "Failed",
|
||||
"statusSkipped": "Skipped",
|
||||
"summaryTotal": "{count} file(s)",
|
||||
"summaryReady": "Pending {count}",
|
||||
"summarySuccess": "Success {count}",
|
||||
"summaryFailed": "Failed {count}",
|
||||
"summarySkipped": "Skipped {count}",
|
||||
"validationReady": "Ready to upload. The archive structure will be checked during upload.",
|
||||
"validationZipOnly": "Only zip skill packages are supported",
|
||||
"validationDuplicate": "A file with the same name is already in the queue and has been skipped",
|
||||
"validationUploading": "Validating and uploading...",
|
||||
"validationUploadFailed": "Upload failed. Please try again.",
|
||||
"validationUploadedAs": "Installed as {name}",
|
||||
"validationNoResult": "No validation result was returned. Check the platform logs.",
|
||||
"noDescription": "No description",
|
||||
"path": "Path",
|
||||
"uploadSuccess": "Upload succeeded",
|
||||
|
||||
@@ -543,7 +543,7 @@
|
||||
},
|
||||
"unified_webhook_mode": {
|
||||
"description": "统一 Webhook 模式",
|
||||
"hint": "Webhook 模式下使用 AstrBot 统一 Webhook 入口,无需单独开启端口。回调地址为 /api/platform/webhook/{webhook_uuid}。"
|
||||
"hint": "启用后,将使用 AstrBot 统一 Webhook 入口,无需单独开启端口。回调地址为 /api/platform/webhook/{webhook_uuid}。"
|
||||
},
|
||||
"webhook_uuid": {
|
||||
"description": "Webhook UUID",
|
||||
@@ -553,41 +553,13 @@
|
||||
"description": "企业微信智能机器人的名字",
|
||||
"hint": "请务必填写正确,否则无法使用一些指令。"
|
||||
},
|
||||
"wecom_ai_bot_connection_mode": {
|
||||
"description": "企业微信智能机器人连接模式",
|
||||
"hint": "Webhook 回调模式需要配置 Token/EncodingAESKey;长连接模式需要配置 BotID/Secret。"
|
||||
},
|
||||
"wecomaibot_friend_message_welcome_text": {
|
||||
"description": "企业微信智能机器人私聊欢迎语",
|
||||
"hint": "可选。当用户当天进入智能机器人单聊会话,回复欢迎语,如 “💭 思考中...”。留空则不回复。"
|
||||
"hint": "当用户当天进入智能机器人单聊会话,回复欢迎语,如 “💭 思考中...”。留空则不回复。"
|
||||
},
|
||||
"wecomaibot_init_respond_text": {
|
||||
"description": "企业微信智能机器人初始响应文本",
|
||||
"hint": "可选。当机器人收到消息时,首先回复的文本内容。留空则不设置。"
|
||||
},
|
||||
"wecomaibot_token": {
|
||||
"description": "企业微信智能机器人 Token",
|
||||
"hint": "用于 Webhook 回调模式的身份验证。"
|
||||
},
|
||||
"wecomaibot_encoding_aes_key": {
|
||||
"description": "企业微信智能机器人 EncodingAESKey",
|
||||
"hint": "用于 Webhook 回调模式的消息加密解密。"
|
||||
},
|
||||
"wecomaibot_ws_bot_id": {
|
||||
"description": "长连接 BotID",
|
||||
"hint": "企业微信智能机器人长连接模式凭证 BotID。"
|
||||
},
|
||||
"wecomaibot_ws_secret": {
|
||||
"description": "长连接 Secret",
|
||||
"hint": "企业微信智能机器人长连接模式凭证 Secret。"
|
||||
},
|
||||
"wecomaibot_ws_url": {
|
||||
"description": "长连接 WebSocket 地址",
|
||||
"hint": "默认值为 wss://openws.work.weixin.qq.com,一般无需修改。"
|
||||
},
|
||||
"wecomaibot_heartbeat_interval": {
|
||||
"description": "长连接心跳间隔",
|
||||
"hint": "长连接模式心跳间隔(秒),建议 30 秒。"
|
||||
"hint": "当机器人收到消息时,首先回复的文本内容。留空则不设置。"
|
||||
},
|
||||
"wpp_active_message_poll": {
|
||||
"description": "是否启用主动消息轮询",
|
||||
@@ -610,11 +582,11 @@
|
||||
},
|
||||
"msg_push_webhook_url": {
|
||||
"description": "企业微信消息推送 Webhook URL",
|
||||
"hint": "可选。用于主动消息推送,请在企微群->消息推送得到 URL。建议设置此项以带来更好的消息发送体验。"
|
||||
"hint": "用于主动消息推送,请在企微群->消息推送得到 URL。强烈建议设置此项以带来更好的消息发送体验。"
|
||||
},
|
||||
"only_use_webhook_url_to_send": {
|
||||
"description": "仅使用 Webhook 发送消息",
|
||||
"hint": "可选。启用后,企业微信智能机器人的所有回复都改为通过消息推送 Webhook 发送。消息推送 Webhook 支持更多的消息类型(如图片、文件等)。如果不需要打字机效果,强烈建议使用此选项。"
|
||||
"hint": "启用后,企业微信智能机器人的所有回复都改为通过消息推送 Webhook 发送。消息推送 Webhook 支持更多的消息类型(如图片、文件等)。如果不需要打字机效果,强烈建议使用此选项。"
|
||||
},
|
||||
"kook_bot_token": {
|
||||
"description": "机器人 Token",
|
||||
@@ -1524,4 +1496,4 @@
|
||||
"helpMiddle": "或",
|
||||
"helpSuffix": "。"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,9 +23,6 @@
|
||||
"placeholder": "搜索插件...",
|
||||
"marketPlaceholder": "搜索市场插件..."
|
||||
},
|
||||
"filters": {
|
||||
"all": "全部"
|
||||
},
|
||||
"views": {
|
||||
"card": "卡片视图",
|
||||
"list": "列表视图"
|
||||
@@ -125,14 +122,10 @@
|
||||
"sourceSafetyWarning": "即使是默认插件源,我们也不能完全保证插件的稳定性和安全性,使用前请谨慎核查。"
|
||||
},
|
||||
"sort": {
|
||||
"by": "排序方式",
|
||||
"default": "默认排序",
|
||||
"installTime": "最后修改时间",
|
||||
"name": "名称",
|
||||
"stars": "Star数",
|
||||
"author": "作者名",
|
||||
"updated": "更新时间",
|
||||
"updateStatus": "更新状态",
|
||||
"ascending": "升序",
|
||||
"descending": "降序"
|
||||
},
|
||||
@@ -231,43 +224,10 @@
|
||||
"empty": "暂无 Skills",
|
||||
"emptyHint": "请上传 Skills 压缩包",
|
||||
"uploadDialogTitle": "上传 Skills",
|
||||
"uploadHint": "支持批量上传 zip 技能包,也支持拖拽批量上传 zip 技能包。系统会自动校验目录结构,并给出逐个文件的结果。",
|
||||
"structureRequirement": "常见失败原因是压缩包结构不正确。每个 zip 必须只包含一个顶层目录,例如 `skillname/`,且该目录下必须存在 `SKILL.md`。",
|
||||
"abilityMultiple": "支持一次上传多个zip文件",
|
||||
"abilityValidate": "自动校验 `SKILL.md`",
|
||||
"abilitySkip": "自动跳过重复文件",
|
||||
"uploadHint": "请上传 zip 压缩包,解压后为 skill_name/ 目录,且包含 SKILL.md",
|
||||
"selectFile": "选择文件",
|
||||
"selectFiles": "选择文件(可多选)",
|
||||
"dropzoneTitle": "拖拽多个 zip 文件到这里",
|
||||
"dropzoneAction": "或者点击之后在文件夹中选择多个文件",
|
||||
"dropzoneHint": "支持批量上传,系统会自动校验目录结构",
|
||||
"fileListTitle": "待处理文件",
|
||||
"fileListEmpty": "选择文件后会在这里显示校验结果与上传状态",
|
||||
"uploading": "正在上传...",
|
||||
"batchResultTitle": "批量上传结果",
|
||||
"batchResultSummary": "共 {total} 个文件,成功 {success} 个",
|
||||
"batchSuccessList": "上传成功",
|
||||
"batchFailedList": "上传失败",
|
||||
"confirm": "确定",
|
||||
"confirmUpload": "开始上传",
|
||||
"confirmUpload": "上传",
|
||||
"cancel": "取消",
|
||||
"statusWaiting": "待上传",
|
||||
"statusUploading": "上传中",
|
||||
"statusSuccess": "已上传",
|
||||
"statusError": "校验失败",
|
||||
"statusSkipped": "已跳过",
|
||||
"summaryTotal": "共 {count} 个文件",
|
||||
"summaryReady": "待处理 {count}",
|
||||
"summarySuccess": "成功 {count}",
|
||||
"summaryFailed": "失败 {count}",
|
||||
"summarySkipped": "跳过 {count}",
|
||||
"validationReady": "等待上传,上传时会自动校验目录结构",
|
||||
"validationZipOnly": "仅支持 zip 技能包",
|
||||
"validationDuplicate": "同名文件已在列表中,已跳过",
|
||||
"validationUploading": "正在校验并上传...",
|
||||
"validationUploadFailed": "上传失败,请重试",
|
||||
"validationUploadedAs": "已安装为 {name}",
|
||||
"validationNoResult": "未收到校验结果,请检查平台日志",
|
||||
"noDescription": "无描述",
|
||||
"path": "路径",
|
||||
"uploadSuccess": "上传成功",
|
||||
|
||||
@@ -27,7 +27,6 @@ const customizer = useCustomizerStore();
|
||||
const theme = useTheme();
|
||||
const { t } = useI18n();
|
||||
const route = useRoute();
|
||||
const LAST_BOT_ROUTE_KEY = 'astrbot:last_bot_route';
|
||||
let dialog = ref(false);
|
||||
let accountWarning = ref(false)
|
||||
let updateStatusDialog = ref(false);
|
||||
@@ -403,29 +402,12 @@ const viewMode = computed({
|
||||
});
|
||||
|
||||
// 监听 viewMode 变化,切换到 bot 模式时跳转到首页
|
||||
// 保存 bot 模式的最後路由
|
||||
// 監聽 route 變化,保存最後一次 bot 路由
|
||||
watch(() => route.fullPath, (newPath) => {
|
||||
if (customizer.viewMode === 'bot' && typeof window !== 'undefined') {
|
||||
try {
|
||||
localStorage.setItem(LAST_BOT_ROUTE_KEY, newPath);
|
||||
} catch (e) {
|
||||
console.error('Failed to save last bot route to localStorage:', e);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// 監聽 viewMode 切換
|
||||
watch(() => customizer.viewMode, (newMode, oldMode) => {
|
||||
if (newMode === 'bot' && oldMode === 'chat' && typeof window !== 'undefined') {
|
||||
// 從 chat 切換回 bot,跳轉到最後一次的 bot 路由
|
||||
let lastBotRoute = '/';
|
||||
try {
|
||||
lastBotRoute = localStorage.getItem(LAST_BOT_ROUTE_KEY) || '/';
|
||||
} catch (e) {
|
||||
console.error('Failed to read last bot route from localStorage:', e);
|
||||
if (newMode === 'bot' && oldMode === 'chat') {
|
||||
// 从 chat 模式切换到 bot 模式时,跳转到首页
|
||||
if (route.path !== '/') {
|
||||
router.push('/');
|
||||
}
|
||||
router.push(lastBotRoute);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -1,102 +0,0 @@
|
||||
import { pinyin } from "pinyin-pro";
|
||||
|
||||
const HAN_IDEOGRAPH_RE = /\p{Unified_Ideograph}/u;
|
||||
|
||||
export const normalizeStr = (s) => (s ?? "").toString().toLowerCase().trim();
|
||||
|
||||
const normalizeLooseFromNormalized = (normalized) =>
|
||||
normalized.replace(/[\s_-]+/g, "").replace(/[()()【】\[\]{}·•]+/g, "");
|
||||
|
||||
export const normalizeLoose = (s) =>
|
||||
normalizeLooseFromNormalized(normalizeStr(s));
|
||||
|
||||
const memoizeStringFn = (fn) => {
|
||||
const cache = new Map();
|
||||
|
||||
return (raw) => {
|
||||
const key = (raw ?? "").toString();
|
||||
if (cache.has(key)) {
|
||||
return cache.get(key);
|
||||
}
|
||||
|
||||
const value = fn(key);
|
||||
cache.set(key, value);
|
||||
return value;
|
||||
};
|
||||
};
|
||||
|
||||
const getNormalizedText = memoizeStringFn(normalizeStr);
|
||||
|
||||
const getLooseText = memoizeStringFn((text) =>
|
||||
normalizeLooseFromNormalized(getNormalizedText(text)),
|
||||
);
|
||||
|
||||
export const toPinyinText = memoizeStringFn((text) =>
|
||||
pinyin(text, { toneType: "none" })
|
||||
.toLowerCase()
|
||||
.replace(/\s+/g, ""),
|
||||
);
|
||||
|
||||
export const toInitials = memoizeStringFn((text) =>
|
||||
pinyin(text, { pattern: "first", toneType: "none" })
|
||||
.toLowerCase()
|
||||
.replace(/\s+/g, ""),
|
||||
);
|
||||
|
||||
export const buildSearchQuery = (raw) => {
|
||||
const norm = getNormalizedText(raw);
|
||||
if (!norm) return null;
|
||||
return {
|
||||
norm,
|
||||
loose: getLooseText(raw),
|
||||
};
|
||||
};
|
||||
|
||||
export const matchesText = (value, query) => {
|
||||
if (value == null || !query?.norm) return false;
|
||||
const text = String(value);
|
||||
|
||||
const normalizedValue = getNormalizedText(text);
|
||||
const looseValue = query.loose ? getLooseText(text) : null;
|
||||
|
||||
if (normalizedValue.includes(query.norm)) return true;
|
||||
if (query.loose && looseValue?.includes(query.loose)) return true;
|
||||
|
||||
if (!HAN_IDEOGRAPH_RE.test(text)) return false;
|
||||
|
||||
const pinyinValue = toPinyinText(text);
|
||||
if (pinyinValue.includes(query.norm)) return true;
|
||||
|
||||
const initialsValue = toInitials(text);
|
||||
if (initialsValue.includes(query.norm)) return true;
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
export const getPluginSearchFields = (plugin) => {
|
||||
const supportPlatforms = Array.isArray(plugin?.support_platforms)
|
||||
? plugin.support_platforms.join(" ")
|
||||
: "";
|
||||
const tags = Array.isArray(plugin?.tags) ? plugin.tags.join(" ") : "";
|
||||
|
||||
return [
|
||||
plugin?.name,
|
||||
plugin?.trimmedName,
|
||||
plugin?.display_name,
|
||||
plugin?.desc,
|
||||
plugin?.author,
|
||||
plugin?.repo,
|
||||
plugin?.version,
|
||||
plugin?.astrbot_version,
|
||||
supportPlatforms,
|
||||
tags,
|
||||
];
|
||||
};
|
||||
|
||||
export const matchesPluginSearch = (plugin, query) => {
|
||||
if (!query) return true;
|
||||
|
||||
return getPluginSearchFields(plugin).some((candidate) =>
|
||||
matchesText(candidate, query),
|
||||
);
|
||||
};
|
||||
@@ -84,6 +84,7 @@ const {
|
||||
normalizeStr,
|
||||
toPinyinText,
|
||||
toInitials,
|
||||
marketCustomFilter,
|
||||
plugin_handler_info_headers,
|
||||
pluginHeaders,
|
||||
filteredExtensions,
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
<script setup>
|
||||
import PluginSortControl from "@/components/extension/PluginSortControl.vue";
|
||||
import ExtensionCard from "@/components/shared/ExtensionCard.vue";
|
||||
import StyledMenu from "@/components/shared/StyledMenu.vue";
|
||||
import defaultPluginIcon from "@/assets/images/plugin_icon.png";
|
||||
@@ -49,9 +48,6 @@ const {
|
||||
getInitialListViewMode,
|
||||
isListView,
|
||||
pluginSearch,
|
||||
installedStatusFilter,
|
||||
installedSortBy,
|
||||
installedSortOrder,
|
||||
loading_,
|
||||
currentPage,
|
||||
dangerConfirmDialog,
|
||||
@@ -85,9 +81,8 @@ const {
|
||||
normalizeStr,
|
||||
toPinyinText,
|
||||
toInitials,
|
||||
marketCustomFilter,
|
||||
plugin_handler_info_headers,
|
||||
installedSortItems,
|
||||
installedSortUsesOrder,
|
||||
pluginHeaders,
|
||||
filteredExtensions,
|
||||
filteredPlugins,
|
||||
@@ -191,64 +186,30 @@ const {
|
||||
</div>
|
||||
|
||||
<v-row class="mb-4">
|
||||
<v-col cols="12">
|
||||
<div class="installed-toolbar">
|
||||
<div class="installed-toolbar__actions">
|
||||
<v-btn variant="tonal" @click="toggleShowReserved">
|
||||
<v-icon>{{
|
||||
showReserved ? "mdi-eye-off" : "mdi-eye"
|
||||
}}</v-icon>
|
||||
{{
|
||||
showReserved
|
||||
? tm("buttons.hideSystemPlugins")
|
||||
: tm("buttons.showSystemPlugins")
|
||||
}}
|
||||
</v-btn>
|
||||
<v-col cols="12" class="d-flex align-center flex-wrap ga-2">
|
||||
<v-btn variant="tonal" @click="toggleShowReserved">
|
||||
<v-icon>{{
|
||||
showReserved ? "mdi-eye-off" : "mdi-eye"
|
||||
}}</v-icon>
|
||||
{{
|
||||
showReserved
|
||||
? tm("buttons.hideSystemPlugins")
|
||||
: tm("buttons.showSystemPlugins")
|
||||
}}
|
||||
</v-btn>
|
||||
|
||||
<v-btn
|
||||
color="warning"
|
||||
variant="tonal"
|
||||
:disabled="updatableExtensions.length === 0"
|
||||
:loading="updatingAll"
|
||||
@click="showUpdateAllConfirm"
|
||||
>
|
||||
<v-icon>mdi-update</v-icon>
|
||||
{{ tm("buttons.updateAll") }}
|
||||
</v-btn>
|
||||
</div>
|
||||
<v-btn
|
||||
class="ml-2"
|
||||
color="warning"
|
||||
variant="tonal"
|
||||
:disabled="updatableExtensions.length === 0"
|
||||
:loading="updatingAll"
|
||||
@click="showUpdateAllConfirm"
|
||||
>
|
||||
<v-icon>mdi-update</v-icon>
|
||||
{{ tm("buttons.updateAll") }}
|
||||
</v-btn>
|
||||
|
||||
<div class="installed-toolbar__controls">
|
||||
<v-btn-toggle
|
||||
v-model="installedStatusFilter"
|
||||
mandatory
|
||||
divided
|
||||
density="compact"
|
||||
color="primary"
|
||||
class="installed-status-toggle"
|
||||
>
|
||||
<v-btn value="all" prepend-icon="mdi-filter-variant">
|
||||
{{ tm("filters.all") }}
|
||||
</v-btn>
|
||||
<v-btn value="enabled" prepend-icon="mdi-play-circle-outline">
|
||||
{{ tm("status.enabled") }}
|
||||
</v-btn>
|
||||
<v-btn value="disabled" prepend-icon="mdi-pause-circle-outline">
|
||||
{{ tm("status.disabled") }}
|
||||
</v-btn>
|
||||
</v-btn-toggle>
|
||||
|
||||
<PluginSortControl
|
||||
v-model="installedSortBy"
|
||||
:items="installedSortItems"
|
||||
:label="tm('sort.by')"
|
||||
:order="installedSortOrder"
|
||||
:ascending-label="tm('sort.ascending')"
|
||||
:descending-label="tm('sort.descending')"
|
||||
:show-order="installedSortUsesOrder"
|
||||
@update:order="installedSortOrder = $event"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
@@ -694,32 +655,6 @@ const {
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.installed-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.installed-toolbar__actions,
|
||||
.installed-toolbar__controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.installed-toolbar__controls {
|
||||
margin-left: auto;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.installed-status-toggle :deep(.v-btn) {
|
||||
min-height: 34px;
|
||||
text-transform: none;
|
||||
}
|
||||
|
||||
.view-mode-toggle :deep(.v-btn) {
|
||||
min-width: 30px;
|
||||
height: 28px;
|
||||
@@ -750,14 +685,6 @@ const {
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 960px) {
|
||||
.installed-toolbar__controls {
|
||||
margin-left: 0;
|
||||
width: 100%;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
}
|
||||
|
||||
.fab-button {
|
||||
transition: all 0.3s cubic-bezier(0.25, 0.8, 0.25, 1);
|
||||
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
<script setup>
|
||||
import MarketPluginCard from "@/components/extension/MarketPluginCard.vue";
|
||||
import PluginSortControl from "@/components/extension/PluginSortControl.vue";
|
||||
import defaultPluginIcon from "@/assets/images/plugin_icon.png";
|
||||
import { computed } from "vue";
|
||||
|
||||
@@ -83,6 +82,7 @@ const {
|
||||
normalizeStr,
|
||||
toPinyinText,
|
||||
toInitials,
|
||||
marketCustomFilter,
|
||||
plugin_handler_info_headers,
|
||||
pluginHeaders,
|
||||
filteredExtensions,
|
||||
@@ -158,13 +158,6 @@ const currentSourceName = computed(() => {
|
||||
const matched = customSources.value.find((s) => s.url === selectedSource.value);
|
||||
return matched?.name || tm("market.defaultSource");
|
||||
});
|
||||
|
||||
const marketSortItems = computed(() => [
|
||||
{ title: tm("sort.default"), value: "default" },
|
||||
{ title: tm("sort.stars"), value: "stars" },
|
||||
{ title: tm("sort.author"), value: "author" },
|
||||
{ title: tm("sort.updated"), value: "updated" },
|
||||
]);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -335,16 +328,44 @@ const marketSortItems = computed(() => [
|
||||
class="d-flex align-center"
|
||||
style="gap: 8px; flex-wrap: wrap"
|
||||
>
|
||||
<PluginSortControl
|
||||
<v-select
|
||||
v-model="sortBy"
|
||||
:items="marketSortItems"
|
||||
:label="tm('sort.by')"
|
||||
:order="sortOrder"
|
||||
:ascending-label="tm('sort.ascending')"
|
||||
:descending-label="tm('sort.descending')"
|
||||
:show-order="sortBy !== 'default'"
|
||||
@update:order="sortOrder = $event"
|
||||
/>
|
||||
:items="[
|
||||
{ title: tm('sort.default'), value: 'default' },
|
||||
{ title: tm('sort.stars'), value: 'stars' },
|
||||
{ title: tm('sort.author'), value: 'author' },
|
||||
{ title: tm('sort.updated'), value: 'updated' },
|
||||
]"
|
||||
density="compact"
|
||||
variant="outlined"
|
||||
hide-details
|
||||
style="max-width: 150px"
|
||||
>
|
||||
<template v-slot:prepend-inner>
|
||||
<v-icon size="small">mdi-sort</v-icon>
|
||||
</template>
|
||||
</v-select>
|
||||
|
||||
<v-btn
|
||||
icon
|
||||
v-if="sortBy !== 'default'"
|
||||
@click="sortOrder = sortOrder === 'desc' ? 'asc' : 'desc'"
|
||||
variant="text"
|
||||
density="compact"
|
||||
>
|
||||
<v-icon>{{
|
||||
sortOrder === "desc"
|
||||
? "mdi-sort-descending"
|
||||
: "mdi-sort-ascending"
|
||||
}}</v-icon>
|
||||
<v-tooltip activator="parent" location="top">
|
||||
{{
|
||||
sortOrder === "desc"
|
||||
? tm("sort.descending")
|
||||
: tm("sort.ascending")
|
||||
}}
|
||||
</v-tooltip>
|
||||
</v-btn>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,15 +1,9 @@
|
||||
import axios from "axios";
|
||||
import { pinyin } from "pinyin-pro";
|
||||
import { useCommonStore } from "@/stores/common";
|
||||
import { useI18n, useModuleI18n } from "@/i18n/composables";
|
||||
import { getPlatformDisplayName } from "@/utils/platformUtils";
|
||||
import { resolveErrorMessage } from "@/utils/errorUtils";
|
||||
import {
|
||||
buildSearchQuery,
|
||||
matchesPluginSearch,
|
||||
normalizeStr,
|
||||
toInitials,
|
||||
toPinyinText,
|
||||
} from "@/utils/pluginSearch";
|
||||
import { ref, computed, onMounted, onUnmounted, reactive, watch } from "vue";
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
import { useDisplay } from "vuetify";
|
||||
@@ -186,9 +180,6 @@ export const useExtensionPage = () => {
|
||||
};
|
||||
const isListView = ref(getInitialListViewMode());
|
||||
const pluginSearch = ref("");
|
||||
const installedStatusFilter = ref("all");
|
||||
const installedSortBy = ref("default");
|
||||
const installedSortOrder = ref("desc");
|
||||
const loading_ = ref(false);
|
||||
|
||||
// 分页相关
|
||||
@@ -249,6 +240,37 @@ export const useExtensionPage = () => {
|
||||
});
|
||||
|
||||
// 插件市场拼音搜索
|
||||
const normalizeStr = (s) => (s ?? "").toString().toLowerCase().trim();
|
||||
const toPinyinText = (s) =>
|
||||
pinyin(s ?? "", { toneType: "none" })
|
||||
.toLowerCase()
|
||||
.replace(/\s+/g, "");
|
||||
const toInitials = (s) =>
|
||||
pinyin(s ?? "", { pattern: "first", toneType: "none" })
|
||||
.toLowerCase()
|
||||
.replace(/\s+/g, "");
|
||||
const marketCustomFilter = (value, query, item) => {
|
||||
const q = normalizeStr(query);
|
||||
if (!q) return true;
|
||||
|
||||
const candidates = new Set();
|
||||
if (value != null) candidates.add(String(value));
|
||||
if (item?.name) candidates.add(String(item.name));
|
||||
if (item?.trimmedName) candidates.add(String(item.trimmedName));
|
||||
if (item?.display_name) candidates.add(String(item.display_name));
|
||||
if (item?.desc) candidates.add(String(item.desc));
|
||||
if (item?.author) candidates.add(String(item.author));
|
||||
|
||||
for (const v of candidates) {
|
||||
const nv = normalizeStr(v);
|
||||
if (nv.includes(q)) return true;
|
||||
const pv = toPinyinText(v);
|
||||
if (pv.includes(q)) return true;
|
||||
const iv = toInitials(v);
|
||||
if (iv.includes(q)) return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const plugin_handler_info_headers = computed(() => [
|
||||
{ title: tm("table.headers.eventType"), key: "event_type_h" },
|
||||
@@ -256,18 +278,6 @@ export const useExtensionPage = () => {
|
||||
{ title: tm("table.headers.specificType"), key: "type" },
|
||||
{ title: tm("table.headers.trigger"), key: "cmd" },
|
||||
]);
|
||||
|
||||
const installedSortItems = computed(() => [
|
||||
{ title: tm("sort.default"), value: "default" },
|
||||
{ title: tm("sort.installTime"), value: "install_time" },
|
||||
{ title: tm("sort.name"), value: "name" },
|
||||
{ title: tm("sort.author"), value: "author" },
|
||||
{ title: tm("sort.updateStatus"), value: "update_status" },
|
||||
]);
|
||||
|
||||
const installedSortUsesOrder = computed(
|
||||
() => installedSortBy.value !== "default",
|
||||
);
|
||||
|
||||
// 插件表格的表头定义
|
||||
const showAuthorColumn = computed(() => width.value >= 1280);
|
||||
@@ -276,19 +286,16 @@ export const useExtensionPage = () => {
|
||||
{
|
||||
title: tm("table.headers.name"),
|
||||
key: "name",
|
||||
sortable: false,
|
||||
width: showAuthorColumn.value ? "24%" : "26%",
|
||||
},
|
||||
{
|
||||
title: tm("table.headers.description"),
|
||||
key: "desc",
|
||||
sortable: false,
|
||||
width: showAuthorColumn.value ? "32%" : "36%",
|
||||
},
|
||||
{
|
||||
title: tm("table.headers.version"),
|
||||
key: "version",
|
||||
sortable: false,
|
||||
width: showAuthorColumn.value ? "12%" : "14%",
|
||||
},
|
||||
];
|
||||
@@ -297,7 +304,6 @@ export const useExtensionPage = () => {
|
||||
headers.push({
|
||||
title: tm("table.headers.author"),
|
||||
key: "author",
|
||||
sortable: false,
|
||||
width: "10%",
|
||||
});
|
||||
}
|
||||
@@ -320,132 +326,68 @@ export const useExtensionPage = () => {
|
||||
}
|
||||
return data;
|
||||
});
|
||||
|
||||
const compareInstalledPluginNames = (left, right) =>
|
||||
normalizeStr(left?.name ?? "").localeCompare(
|
||||
normalizeStr(right?.name ?? ""),
|
||||
undefined,
|
||||
{
|
||||
sensitivity: "base",
|
||||
},
|
||||
);
|
||||
|
||||
const compareInstalledPluginAuthors = (left, right) =>
|
||||
normalizeStr(left?.author ?? "").localeCompare(
|
||||
normalizeStr(right?.author ?? ""),
|
||||
undefined,
|
||||
{ sensitivity: "base" },
|
||||
);
|
||||
|
||||
const getInstalledAtTimestamp = (plugin) => {
|
||||
const parsed = Date.parse(plugin?.installed_at ?? "");
|
||||
return Number.isFinite(parsed) ? parsed : null;
|
||||
};
|
||||
|
||||
const sortInstalledPlugins = (plugins) => {
|
||||
|
||||
const sortPluginsByName = (plugins) => {
|
||||
return plugins
|
||||
.map((plugin, index) => ({
|
||||
plugin,
|
||||
index,
|
||||
installedAtTimestamp: getInstalledAtTimestamp(plugin),
|
||||
}))
|
||||
.sort((left, right) => {
|
||||
const fallbackNameCompare = compareInstalledPluginNames(
|
||||
left.plugin,
|
||||
right.plugin,
|
||||
);
|
||||
const fallbackResult =
|
||||
fallbackNameCompare !== 0 ? fallbackNameCompare : left.index - right.index;
|
||||
|
||||
if (installedSortBy.value === "install_time") {
|
||||
const leftTimestamp = left.installedAtTimestamp;
|
||||
const rightTimestamp = right.installedAtTimestamp;
|
||||
|
||||
if (leftTimestamp == null && rightTimestamp == null) {
|
||||
return fallbackResult;
|
||||
}
|
||||
if (leftTimestamp == null) {
|
||||
return 1;
|
||||
}
|
||||
if (rightTimestamp == null) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
const timeDiff =
|
||||
installedSortOrder.value === "desc"
|
||||
? rightTimestamp - leftTimestamp
|
||||
: leftTimestamp - rightTimestamp;
|
||||
return timeDiff !== 0 ? timeDiff : fallbackResult;
|
||||
.map((plugin, index) => ({ plugin, index }))
|
||||
.sort((a, b) => {
|
||||
const nameA = String(a.plugin?.name ?? "");
|
||||
const nameB = String(b.plugin?.name ?? "");
|
||||
const nameCompare = nameA.localeCompare(nameB, undefined, {
|
||||
sensitivity: "base",
|
||||
});
|
||||
if (nameCompare !== 0) {
|
||||
return nameCompare;
|
||||
}
|
||||
|
||||
if (installedSortBy.value === "name") {
|
||||
const nameCompare = compareInstalledPluginNames(left.plugin, right.plugin);
|
||||
if (nameCompare !== 0) {
|
||||
return installedSortOrder.value === "desc"
|
||||
? -nameCompare
|
||||
: nameCompare;
|
||||
}
|
||||
return left.index - right.index;
|
||||
}
|
||||
|
||||
if (installedSortBy.value === "author") {
|
||||
const authorCompare = compareInstalledPluginAuthors(
|
||||
left.plugin,
|
||||
right.plugin,
|
||||
);
|
||||
if (authorCompare !== 0) {
|
||||
return installedSortOrder.value === "desc"
|
||||
? -authorCompare
|
||||
: authorCompare;
|
||||
}
|
||||
return fallbackResult;
|
||||
}
|
||||
|
||||
if (installedSortBy.value === "update_status") {
|
||||
const leftHasUpdate = left.plugin?.has_update ? 1 : 0;
|
||||
const rightHasUpdate = right.plugin?.has_update ? 1 : 0;
|
||||
const updateDiff =
|
||||
installedSortOrder.value === "desc"
|
||||
? rightHasUpdate - leftHasUpdate
|
||||
: leftHasUpdate - rightHasUpdate;
|
||||
return updateDiff !== 0 ? updateDiff : fallbackResult;
|
||||
}
|
||||
|
||||
return fallbackResult;
|
||||
return a.index - b.index;
|
||||
})
|
||||
.map((item) => item.plugin);
|
||||
};
|
||||
|
||||
// 通过搜索过滤插件
|
||||
const filteredPlugins = computed(() => {
|
||||
const plugins = filteredExtensions.value.filter((plugin) => {
|
||||
if (installedStatusFilter.value === "enabled") {
|
||||
return !!plugin.activated;
|
||||
}
|
||||
if (installedStatusFilter.value === "disabled") {
|
||||
return !plugin.activated;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
const plugins = filteredExtensions.value;
|
||||
let filtered = plugins;
|
||||
|
||||
const query = buildSearchQuery(pluginSearch.value);
|
||||
const filtered = query
|
||||
? plugins.filter((plugin) => matchesPluginSearch(plugin, query))
|
||||
: plugins;
|
||||
if (pluginSearch.value) {
|
||||
const search = pluginSearch.value.toLowerCase();
|
||||
filtered = plugins.filter((plugin) => {
|
||||
const pluginName = (plugin.name ?? "").toLowerCase();
|
||||
const pluginDesc = (plugin.desc ?? "").toLowerCase();
|
||||
const pluginAuthor = (plugin.author ?? "").toLowerCase();
|
||||
const supportPlatforms = Array.isArray(plugin.support_platforms)
|
||||
? plugin.support_platforms.join(" ").toLowerCase()
|
||||
: "";
|
||||
const astrbotVersion = (plugin.astrbot_version ?? "").toLowerCase();
|
||||
|
||||
return sortInstalledPlugins(filtered);
|
||||
return (
|
||||
pluginName.includes(search) ||
|
||||
pluginDesc.includes(search) ||
|
||||
pluginAuthor.includes(search) ||
|
||||
supportPlatforms.includes(search) ||
|
||||
astrbotVersion.includes(search)
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
return sortPluginsByName([...filtered]);
|
||||
});
|
||||
|
||||
// 过滤后的插件市场数据(带搜索)
|
||||
const filteredMarketPlugins = computed(() => {
|
||||
const query = buildSearchQuery(debouncedMarketSearch.value);
|
||||
if (!query) {
|
||||
if (!debouncedMarketSearch.value) {
|
||||
return pluginMarketData.value;
|
||||
}
|
||||
|
||||
return pluginMarketData.value.filter((plugin) =>
|
||||
matchesPluginSearch(plugin, query),
|
||||
);
|
||||
|
||||
const search = debouncedMarketSearch.value.toLowerCase();
|
||||
return pluginMarketData.value.filter((plugin) => {
|
||||
// 使用自定义过滤器
|
||||
return (
|
||||
marketCustomFilter(plugin.name, search, plugin) ||
|
||||
marketCustomFilter(plugin.desc, search, plugin) ||
|
||||
marketCustomFilter(plugin.author, search, plugin)
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// 所有插件列表,推荐插件排在前面
|
||||
@@ -1587,9 +1529,6 @@ export const useExtensionPage = () => {
|
||||
getInitialListViewMode,
|
||||
isListView,
|
||||
pluginSearch,
|
||||
installedStatusFilter,
|
||||
installedSortBy,
|
||||
installedSortOrder,
|
||||
loading_,
|
||||
currentPage,
|
||||
dangerConfirmDialog,
|
||||
@@ -1624,9 +1563,8 @@ export const useExtensionPage = () => {
|
||||
normalizeStr,
|
||||
toPinyinText,
|
||||
toInitials,
|
||||
marketCustomFilter,
|
||||
plugin_handler_info_headers,
|
||||
installedSortItems,
|
||||
installedSortUsesOrder,
|
||||
pluginHeaders,
|
||||
filteredExtensions,
|
||||
filteredPlugins,
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
__pycache__/
|
||||
venv/
|
||||
.DS_Store
|
||||
node_modules/
|
||||
.vitepress/cache
|
||||
*dist
|
||||
@@ -1,530 +0,0 @@
|
||||
import { defineConfig } from "vitepress";
|
||||
import { head } from "./config/head";
|
||||
|
||||
// https://vitepress.dev/reference/site-config
|
||||
export default defineConfig({
|
||||
title: "AstrBot",
|
||||
description: "AstrBot",
|
||||
head: head,
|
||||
|
||||
rewrites: {
|
||||
'zh/:rest*': ':rest*'
|
||||
},
|
||||
|
||||
sitemap: {
|
||||
hostname: "https://docs.astrbot.app",
|
||||
},
|
||||
|
||||
lastUpdated: true,
|
||||
ignoreDeadLinks: true,
|
||||
|
||||
locales: {
|
||||
root: {
|
||||
label: "简体中文",
|
||||
lang: "zh-Hans",
|
||||
themeConfig: {
|
||||
nav: [
|
||||
{ text: "主页", link: "https://astrbot.app" },
|
||||
{ text: "博客", link: "https://blog.astrbot.app" },
|
||||
{ text: "路线图", link: "https://astrbot.featurebase.app/roadmap" },
|
||||
{ text: "HTTP API", link: "https://docs.astrbot.app/scalar.html" },
|
||||
],
|
||||
sidebar: [
|
||||
{
|
||||
text: "简介",
|
||||
items: [
|
||||
{ text: "关于 AstrBot", link: "/what-is-astrbot" },
|
||||
{ text: "社区", link: "/community" },
|
||||
{ text: "常见问题", link: "/faq" },
|
||||
],
|
||||
},
|
||||
{
|
||||
text: "部署",
|
||||
base: "/deploy",
|
||||
collapsed: false,
|
||||
items: [
|
||||
{ text: "包管理器部署", link: "/astrbot/package" },
|
||||
{ text: "雨云一键云部署", link: "/astrbot/rainyun" },
|
||||
{ text: "桌面客户端部署", link: "/astrbot/desktop" },
|
||||
{ text: "启动器一键部署", link: "/astrbot/launcher" },
|
||||
{ text: "Docker 部署", link: "/astrbot/docker" },
|
||||
{ text: "Kubernetes 部署", link: "/astrbot/kubernetes" },
|
||||
{ text: "宝塔面板部署", link: "/astrbot/btpanel" },
|
||||
{ text: "1Panel 部署", link: "/astrbot/1panel" },
|
||||
{ text: "手动部署", link: "/astrbot/cli" },
|
||||
{
|
||||
text: "其他部署方式",
|
||||
link: "/astrbot/other-deployments",
|
||||
collapsed: true,
|
||||
items: [
|
||||
{ text: "CasaOS 部署", link: "/astrbot/casaos" },
|
||||
{ text: "优云智算 GPU 部署", link: "/astrbot/compshare" },
|
||||
{ text: "社区提供的部署方式", link: "/astrbot/community-deployment" },
|
||||
],
|
||||
},
|
||||
{
|
||||
text: "支持我们",
|
||||
link: "/when-deployed",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
text: "接入消息平台",
|
||||
base: "/platform",
|
||||
items: [
|
||||
{
|
||||
text: "快速接入指南",
|
||||
link: "/start",
|
||||
},
|
||||
{
|
||||
text: "QQ 官方机器人",
|
||||
link: "/qqofficial",
|
||||
collapsed: true,
|
||||
items: [
|
||||
{ text: "Websockets 方式(推荐)", link: "/qqofficial/websockets" },
|
||||
{ text: "Webhook 方式", link: "/qqofficial/webhook" },
|
||||
],
|
||||
},
|
||||
{
|
||||
text: "OneBot v11",
|
||||
base: "/platform/aiocqhttp",
|
||||
collapsed: true,
|
||||
items: [
|
||||
{ text: "NapCat", link: "/napcat" },
|
||||
{ text: "Lagrange", link: "/lagrange" },
|
||||
{ text: "其他端", link: "/others" },
|
||||
],
|
||||
},
|
||||
{ text: "企微应用", link: "/wecom" },
|
||||
{ text: "企微智能机器人", link: "/wecom_ai_bot" },
|
||||
{ text: "微信公众号", link: "/weixin-official-account" },
|
||||
{ text: "飞书", link: "/lark" },
|
||||
{ text: "钉钉", link: "/dingtalk" },
|
||||
{ text: "Telegram", link: "/telegram" },
|
||||
{ text: "LINE", link: "/line" },
|
||||
{ text: "Slack", link: "/slack" },
|
||||
{ text: "Misskey", link: "/misskey" },
|
||||
{ text: "Discord", link: "/discord" },
|
||||
{ text: "KOOK", link: "/kook" },
|
||||
{
|
||||
text: "Satori",
|
||||
base: "/platform/satori",
|
||||
collapsed: true,
|
||||
items: [
|
||||
{ text: "使用 LLOneBot", link: "/llonebot" },
|
||||
{ text: "使用 server-satori", link: "/server-satori" },
|
||||
],
|
||||
},
|
||||
{
|
||||
text: "社区提供",
|
||||
collapsed: false,
|
||||
items: [
|
||||
{ text: "Matrix", link: "/matrix" },
|
||||
{ text: "VoceChat", link: "/vocechat" },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
text: "接入 AI",
|
||||
base: "/providers",
|
||||
items: [
|
||||
{
|
||||
text: "✨ 接入模型服务",
|
||||
link: "/start",
|
||||
collapsed: true,
|
||||
items: [
|
||||
{ text: "NewAPI", link: "/newapi" },
|
||||
{ text: "AIHubMix", link: "/aihubmix" },
|
||||
{ text: "PPIO 派欧云", link: "/ppio" },
|
||||
{ text: "硅基流动", link: "/siliconflow" },
|
||||
{ text: "小马算力", link: "/tokenpony" },
|
||||
{ text: "302.AI", link: "/302ai" },
|
||||
{ text: "Ollama", link: "/provider-ollama" },
|
||||
{ text: "LMStudio", link: "/provider-lmstudio" },
|
||||
]
|
||||
},
|
||||
{
|
||||
text: "⚙️ Agent 执行器",
|
||||
link: "/agent-runners",
|
||||
collapsed: false,
|
||||
items: [
|
||||
{ text: "内置 Agent 执行器", link: "/agent-runners/astrbot-agent-runner" },
|
||||
{ text: "Dify", link: "/agent-runners/dify" },
|
||||
{ text: "扣子 Coze", link: "/agent-runners/coze" },
|
||||
{ text: "阿里云百炼应用", link: "/agent-runners/dashscope" },
|
||||
{ text: "DeerFlow", link: "/agent-runners/deerflow" },
|
||||
]
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
text: "使用",
|
||||
base: "/use",
|
||||
items: [
|
||||
{ text: "WebUI", link: "/webui" },
|
||||
{ text: "插件", link: "/plugin" },
|
||||
{ text: "内置指令", link: "/command" },
|
||||
{ text: "工具使用 Tools", link: "/function-calling" },
|
||||
{ text: "技能 Skills", link: "/skills" },
|
||||
{ text: "SubAgent 编排", link: "/subagent" },
|
||||
{ text: "主动型 Agent 能力", link: "/proactive-agent" },
|
||||
{ text: "MCP", link: "/mcp" },
|
||||
{ text: "网页搜索", link: "/websearch" },
|
||||
{ text: "知识库", link: "/knowledge-base" },
|
||||
{ text: "自定义规则", link: "/custom-rules" },
|
||||
{ text: "Agent 执行器", link: "/agent-runner" },
|
||||
{ text: "统一 Webhook 模式", link: "/unified-webhook" },
|
||||
{ text: "自动上下文压缩", link: "/context-compress" },
|
||||
{ text: "Agent 沙箱环境", link: "/astrbot-agent-sandbox" },
|
||||
],
|
||||
},
|
||||
{
|
||||
text: "开发",
|
||||
base: "/dev",
|
||||
collapsed: true,
|
||||
items: [
|
||||
{
|
||||
text: "插件开发",
|
||||
base: "/dev/star",
|
||||
collapsed: true,
|
||||
items: [
|
||||
{ text: "🌠 从这里开始", link: "/plugin-new" },
|
||||
{ text: "最小实例", link: "/guides/simple" },
|
||||
{ text: "接收消息事件", link: "/guides/listen-message-event" },
|
||||
{ text: "发送消息", link: "/guides/send-message" },
|
||||
{ text: "插件配置", link: "/guides/plugin-config" },
|
||||
{ text: "调用 AI", link: "/guides/ai" },
|
||||
{ text: "存储", link: "/guides/storage" },
|
||||
{ text: "文转图", link: "/guides/html-to-pic" },
|
||||
{ text: "会话控制器", link: "/guides/session-control" },
|
||||
{ text: "杂项", link: "/guides/other" },
|
||||
{ text: "发布插件", link: "/plugin-publish" },
|
||||
{ text: "插件指南(旧)", link: "/plugin" },
|
||||
],
|
||||
},
|
||||
{
|
||||
text: "接入平台适配器",
|
||||
link: "/plugin-platform-adapter",
|
||||
},
|
||||
{
|
||||
text: "AstrBot HTTP API",
|
||||
link: "/openapi",
|
||||
},
|
||||
{
|
||||
text: "AstrBot 配置文件",
|
||||
link: "/astrbot-config",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
text: "其他",
|
||||
base: "/others",
|
||||
collapsed: true,
|
||||
items: [
|
||||
{ text: "自部署文转图", link: "/self-host-t2i" },
|
||||
{ text: "插件下载不了?试试自建 GitHub 加速服务", link: "/github-proxy" },
|
||||
],
|
||||
},
|
||||
{
|
||||
text: "开源之夏",
|
||||
base: "/ospp",
|
||||
collapsed: true,
|
||||
items: [{ text: "OSPP 2025", link: "/2025" }],
|
||||
},
|
||||
],
|
||||
outline: {
|
||||
level: 'deep',
|
||||
label: '目录',
|
||||
},
|
||||
darkModeSwitchLabel: '切换日光/暗黑模式',
|
||||
sidebarMenuLabel: '文章',
|
||||
returnToTopLabel: '返回顶部',
|
||||
docFooter: {
|
||||
prev: '上一篇',
|
||||
next: '下一篇'
|
||||
},
|
||||
editLink: {
|
||||
pattern: 'https://github.com/AstrBotdevs/AstrBot-docs/edit/v4/:path',
|
||||
text: '发现文档有问题?在 GitHub 上编辑此页',
|
||||
},
|
||||
logo: '/logo_prod.png',
|
||||
socialLinks: [
|
||||
{ icon: "github", link: "https://github.com/AstrBotDevs/AstrBot" },
|
||||
],
|
||||
footer: {
|
||||
message: 'Deployed on ' +
|
||||
'<a href="https://www.rainyun.com/NjY3OTQ5_" class="deployment-link" style="display: inline-flex; align-items: center;">' +
|
||||
'<img src="https://www.rainyun.com/img/logo.d193755d.png" width="50" alt="Rainyun Logo">' +
|
||||
'</a>',
|
||||
}
|
||||
}
|
||||
},
|
||||
en: {
|
||||
label: "English",
|
||||
lang: "en-US",
|
||||
themeConfig: {
|
||||
nav: [
|
||||
{ text: "Home", link: "https://astrbot.app" },
|
||||
{ text: "Blog", link: "https://blog.astrbot.app" },
|
||||
{ text: "Roadmap", link: "https://astrbot.featurebase.app/roadmap" },
|
||||
{ text: "HTTP API", link: "https://docs.astrbot.app/scalar.html" },
|
||||
],
|
||||
sidebar: [
|
||||
{
|
||||
text: "Introduction",
|
||||
items: [
|
||||
{ text: "What is AstrBot", link: "/en/what-is-astrbot" },
|
||||
{ text: "Community", link: "/en/community" },
|
||||
{ text: "FAQ", link: "/en/faq" },
|
||||
],
|
||||
},
|
||||
{
|
||||
text: "Deployment",
|
||||
base: "/en/deploy",
|
||||
collapsed: false,
|
||||
items: [
|
||||
{ text: "Package Manager", link: "/astrbot/package" },
|
||||
{ text: "One-click Launcher", link: "/astrbot/launcher" },
|
||||
{ text: "Docker", link: "/astrbot/docker" },
|
||||
{ text: "Kubernetes", link: "/astrbot/kubernetes" },
|
||||
{ text: "BT Panel", link: "/astrbot/btpanel" },
|
||||
{ text: "1Panel", link: "/astrbot/1panel" },
|
||||
{ text: "Manual", link: "/astrbot/cli" },
|
||||
{
|
||||
text: "Other Deployments",
|
||||
link: "/astrbot/other-deployments",
|
||||
collapsed: true,
|
||||
items: [
|
||||
{ text: "CasaOS", link: "/astrbot/casaos" },
|
||||
{ text: "Compshare GPU", link: "/astrbot/compshare" },
|
||||
{ text: "Community-provided Deployment", link: "/astrbot/community-deployment" },
|
||||
],
|
||||
},
|
||||
{
|
||||
text: "Support Us",
|
||||
link: "/when-deployed",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
text: "Messaging Platforms",
|
||||
base: "/en/platform",
|
||||
collapsed: false,
|
||||
items: [
|
||||
{
|
||||
text: "Quick Start",
|
||||
link: "/start",
|
||||
},
|
||||
{
|
||||
text: "QQ Official Bot",
|
||||
link: "/qqofficial",
|
||||
collapsed: true,
|
||||
items: [
|
||||
{ text: "Websockets", link: "/qqofficial/websockets" },
|
||||
{ text: "Webhook", link: "/qqofficial/webhook" },
|
||||
],
|
||||
},
|
||||
{
|
||||
text: "OneBot v11",
|
||||
base: "/en/platform/aiocqhttp",
|
||||
collapsed: true,
|
||||
items: [
|
||||
{ text: "NapCat", link: "/napcat" },
|
||||
{ text: "Lagrange", link: "/lagrange" },
|
||||
{ text: "Other Clients", link: "/others" },
|
||||
],
|
||||
},
|
||||
{ text: "WeCom Application", link: "/wecom" },
|
||||
{ text: "WeCom AI Bot", link: "/wecom_ai_bot" },
|
||||
{ text: "WeChat Official Account", link: "/weixin-official-account" },
|
||||
{ text: "Lark", link: "/lark" },
|
||||
{ text: "DingTalk", link: "/dingtalk" },
|
||||
{ text: "Telegram", link: "/telegram" },
|
||||
{ text: "LINE", link: "/line" },
|
||||
{ text: "Slack", link: "/slack" },
|
||||
{ text: "Misskey", link: "/misskey" },
|
||||
{ text: "Discord", link: "/discord" },
|
||||
{
|
||||
text: "Satori",
|
||||
base: "/en/platform/satori",
|
||||
collapsed: true,
|
||||
items: [
|
||||
{ text: "Using LLOneBot", link: "/llonebot" },
|
||||
{ text: "Using server-satori", link: "/server-satori" },
|
||||
],
|
||||
},
|
||||
{
|
||||
text: "Community-provided",
|
||||
collapsed: false,
|
||||
items: [
|
||||
{ text: "Matrix", link: "/matrix" },
|
||||
{ text: "KOOK", link: "/kook" },
|
||||
{ text: "VoceChat", link: "/vocechat" },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
text: "AI Integration",
|
||||
base: "/en/providers",
|
||||
collapsed: false,
|
||||
items: [
|
||||
{
|
||||
text: "✨ Model Providers",
|
||||
link: "/start",
|
||||
collapsed: true,
|
||||
items: [
|
||||
{ text: "NewAPI", link: "/newapi" },
|
||||
{ text: "AIHubMix", link: "/aihubmix" },
|
||||
{ text: "PPIO Cloud", link: "/ppio" },
|
||||
{ text: "SiliconFlow", link: "/siliconflow" },
|
||||
{ text: "TokenPony", link: "/tokenpony" },
|
||||
{ text: "302.AI", link: "/302ai" },
|
||||
{ text: "Ollama", link: "/provider-ollama" },
|
||||
{ text: "LMStudio", link: "/provider-lmstudio" },
|
||||
],
|
||||
},
|
||||
{
|
||||
text: "⚙️ Agent Runners",
|
||||
link: "/agent-runners",
|
||||
collapsed: false,
|
||||
items: [
|
||||
{ text: "Built-in Agent Runner", link: "/agent-runners/astrbot-agent-runner" },
|
||||
{ text: "Dify", link: "/agent-runners/dify" },
|
||||
{ text: "Coze", link: "/agent-runners/coze" },
|
||||
{ text: "Alibaba Bailian", link: "/agent-runners/dashscope" },
|
||||
{ text: "DeerFlow", link: "/agent-runners/deerflow" },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
text: "Usage",
|
||||
base: "/en/use",
|
||||
collapsed: true,
|
||||
items: [
|
||||
{ text: "WebUI", link: "/webui" },
|
||||
{ text: "Plugins", link: "/plugin" },
|
||||
{ text: "Built-in Commands", link: "/command" },
|
||||
{ text: "Tool Use", link: "/function-calling" },
|
||||
{ text: "Anthropic Skills", link: "/skills" },
|
||||
{ text: "SubAgent Orchestration", link: "/subagent" },
|
||||
{ text: "Proactive Tasks", link: "/proactive-agent" },
|
||||
{ text: "MCP", link: "/mcp" },
|
||||
{ text: "Web Search", link: "/websearch" },
|
||||
{ text: "Knowledge Base", link: "/knowledge-base" },
|
||||
{ text: "Custom Rules", link: "/custom-rules" },
|
||||
{ text: "Agent Runner", link: "/agent-runner" },
|
||||
{ text: "Unified Webhook Mode", link: "/unified-webhook" },
|
||||
{ text: "Auto Context Compression", link: "/context-compress" },
|
||||
{ text: "Agent Sandbox", link: "/astrbot-agent-sandbox" },
|
||||
],
|
||||
},
|
||||
{
|
||||
text: "Development",
|
||||
base: "/en/dev",
|
||||
collapsed: true,
|
||||
items: [
|
||||
{
|
||||
text: "Plugin Development",
|
||||
base: "/en/dev/star",
|
||||
collapsed: true,
|
||||
items: [
|
||||
{ text: "🌠 Getting Started", link: "/plugin-new" },
|
||||
{ text: "Minimal Example", link: "/guides/simple" },
|
||||
{ text: "Listen to Message Events", link: "/guides/listen-message-event" },
|
||||
{ text: "Send Messages", link: "/guides/send-message" },
|
||||
{ text: "Plugin Configuration", link: "/guides/plugin-config" },
|
||||
{ text: "AI", link: "/guides/ai" },
|
||||
{ text: "Storage", link: "/guides/storage" },
|
||||
{ text: "HTML to Image", link: "/guides/html-to-pic" },
|
||||
{ text: "Session Control", link: "/guides/session-control" },
|
||||
{ text: "Publish Plugin", link: "/plugin-publish" },
|
||||
],
|
||||
},
|
||||
{
|
||||
text: "Platform Adapter Integration",
|
||||
link: "/plugin-platform-adapter",
|
||||
},
|
||||
{
|
||||
text: "AstrBot HTTP API",
|
||||
link: "/openapi",
|
||||
},
|
||||
{
|
||||
text: "AstrBot Configuration File",
|
||||
link: "/astrbot-config",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
text: "Others",
|
||||
base: "/en/others",
|
||||
collapsed: true,
|
||||
items: [
|
||||
{ text: "Self-hosted HTML to Image", link: "/self-host-t2i" },
|
||||
],
|
||||
},
|
||||
{
|
||||
text: "Open Source Summer",
|
||||
base: "/en/ospp",
|
||||
collapsed: true,
|
||||
items: [{ text: "OSPP 2025", link: "/2025" }],
|
||||
},
|
||||
],
|
||||
outline: {
|
||||
level: 'deep',
|
||||
label: 'On this page',
|
||||
},
|
||||
darkModeSwitchLabel: 'Toggle dark mode',
|
||||
sidebarMenuLabel: 'Menu',
|
||||
returnToTopLabel: 'Return to top',
|
||||
docFooter: {
|
||||
prev: 'Previous',
|
||||
next: 'Next'
|
||||
},
|
||||
editLink: {
|
||||
pattern: 'https://github.com/AstrBotdevs/AstrBot-docs/edit/v4/:path',
|
||||
text: 'Edit this page on GitHub',
|
||||
},
|
||||
logo: '/logo_prod.png',
|
||||
socialLinks: [
|
||||
{ icon: "github", link: "https://github.com/AstrBotDevs/AstrBot" },
|
||||
],
|
||||
footer: {
|
||||
message: 'Deployed on ' +
|
||||
'<a href="https://www.rainyun.com/NjY3OTQ5_" class="deployment-link" style="display: inline-flex; align-items: center;">' +
|
||||
'<img src="https://www.rainyun.com/img/logo.d193755d.png" width="50" alt="Rainyun Logo">' +
|
||||
'</a>',
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
themeConfig: {
|
||||
search: {
|
||||
provider: "local",
|
||||
options: {
|
||||
locales: {
|
||||
root: {
|
||||
translations: {
|
||||
button: {
|
||||
buttonText: "搜索文档",
|
||||
buttonAriaLabel: "搜索文档",
|
||||
},
|
||||
modal: {
|
||||
noResultsText: "无法找到相关结果",
|
||||
resetButtonTitle: "清除查询条件",
|
||||
footer: {
|
||||
selectText: "选择",
|
||||
navigateText: "切换",
|
||||
closeText: "关闭",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
});
|
||||
@@ -1,47 +0,0 @@
|
||||
import type { HeadConfig } from "vitepress";
|
||||
|
||||
export const head: HeadConfig[] = [
|
||||
// --- Google Fonts ---
|
||||
["link", { rel: "preconnect", href: "https://fonts.googleapis.cn", crossorigin: "" }],
|
||||
["link", { rel: "dns-prefetch", href: "https://fonts.googleapis.cn" }],
|
||||
["link", { rel: "preconnect", href: "https://fonts.gstatic.cn", crossorigin: "" }],
|
||||
["link", { rel: "dns-prefetch", href: "https://fonts.gstatic.cn" }],
|
||||
["link", { rel: "stylesheet", href: "https://fonts.googleapis.com/css2?family=Outfit:wght@100..900&display=swap" }],
|
||||
|
||||
// --- 基础和SEO元数据 ---
|
||||
["link", { rel: "icon", href: "/logo.png" }],
|
||||
["meta", { name: "description", content: "AstrBot" }],
|
||||
[
|
||||
"meta",
|
||||
{ name: "viewport", content: "width=device-width, initial-scale=1.0" },
|
||||
],
|
||||
|
||||
/* // --- Open Graph (OG) 协议元数据 (用于社交媒体分享) ---
|
||||
["meta", { property: "og:type", content: "website" }],
|
||||
["meta", { property: "og:locale", content: "zh_CN" }],
|
||||
["meta", { property: "og:title", content: "AstrBot" }],
|
||||
["meta", { property: "og:description", content: "AstrBot" }],
|
||||
["meta", { property: "og:url", content: "https://docs.astrbot.app" }],
|
||||
["meta", { property: "og:site_name", content: "AstrBot" }],
|
||||
[
|
||||
"meta",
|
||||
{
|
||||
property: "og:image",
|
||||
content: "/",
|
||||
},
|
||||
],
|
||||
[
|
||||
"meta",
|
||||
{ property: "og:image:alt", content: "AstrBot" },
|
||||
],
|
||||
["meta", { property: "og:image:width", content: "1200" }],
|
||||
["meta", { property: "og:image:height", content: "630" }],
|
||||
["meta", { property: "og:image:type", content: "image/png" }],
|
||||
|
||||
// --- Twitter Card 元数据 ---
|
||||
["meta", { name: "twitter:card", content: "summary_large_image" }],
|
||||
["meta", { name: "twitter:site", content: "@AstrBot" }],*/
|
||||
|
||||
// --- Umami Analytics ---
|
||||
["script", { defer: "", src: "https://cloud.umami.is/script.js", "data-website-id": "9c3f777e-9f4a-4b79-a5c3-ff94f5dca8f9" }],
|
||||
];
|
||||
@@ -1,194 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from "vue"
|
||||
|
||||
const props = defineProps({
|
||||
shareText: {
|
||||
type: String,
|
||||
default: "分享链接",
|
||||
},
|
||||
copiedText: {
|
||||
type: String,
|
||||
default: "已复制!",
|
||||
},
|
||||
includeQuery: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
includeHash: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
copiedTimeout: {
|
||||
type: Number,
|
||||
default: 2000,
|
||||
},
|
||||
})
|
||||
|
||||
defineOptions({ name: "ArticleShare" })
|
||||
|
||||
const copied = ref(false)
|
||||
const isClient =
|
||||
typeof window !== "undefined" && typeof document !== "undefined"
|
||||
|
||||
const shareLink = computed(() => {
|
||||
if (!isClient) return ""
|
||||
|
||||
const { origin, pathname, search, hash } = window.location
|
||||
const finalSearch = props.includeQuery ? search : ""
|
||||
const finalHash = props.includeHash ? hash : ""
|
||||
return `${origin}${pathname}${finalSearch}${finalHash}`
|
||||
})
|
||||
|
||||
async function copyToClipboard() {
|
||||
if (copied.value || !isClient) return
|
||||
|
||||
try {
|
||||
if (navigator.clipboard) {
|
||||
await navigator.clipboard.writeText(shareLink.value)
|
||||
} else {
|
||||
const input = document.createElement("input")
|
||||
input.setAttribute("readonly", "readonly")
|
||||
input.setAttribute("value", shareLink.value)
|
||||
document.body.appendChild(input)
|
||||
input.select()
|
||||
document.execCommand("copy")
|
||||
document.body.removeChild(input)
|
||||
}
|
||||
|
||||
copied.value = true
|
||||
setTimeout(() => {
|
||||
copied.value = false
|
||||
}, props.copiedTimeout)
|
||||
} catch (error) {
|
||||
console.error("复制链接失败:", error)
|
||||
}
|
||||
}
|
||||
|
||||
const shareIconSvg = `
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M4 12v8a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-8"></path>
|
||||
<polyline points="16 6 12 2 8 6"></polyline>
|
||||
<line x1="12" y1="2" x2="12" y2="15"></line>
|
||||
</svg>
|
||||
`
|
||||
|
||||
const copiedIconSvg = `
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M20 6 9 17l-5-5"></path>
|
||||
</svg>
|
||||
`
|
||||
|
||||
// onMounted(() => {
|
||||
// const script = document.createElement('script')
|
||||
// script.src = 'https://cdn.wwads.cn/js/makemoney.js'
|
||||
// script.async = true
|
||||
// document.head.appendChild(script)
|
||||
// })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div style="display: flex; justify-content: center; align-items: center; flex-direction: column;">
|
||||
<div class="article-share">
|
||||
<button :class="['article-share__button', { copied: copied }]"
|
||||
:aria-label="copied ? props.copiedText : props.shareText" aria-live="polite" @click="copyToClipboard">
|
||||
<div v-if="!copied" class="content-wrapper">
|
||||
<span class="icon" v-html="shareIconSvg"></span>
|
||||
{{ props.shareText }}
|
||||
</div>
|
||||
|
||||
<div v-else class="content-wrapper">
|
||||
<span class="icon" v-html="copiedIconSvg"></span>
|
||||
{{ props.copiedText }}
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
<!-- <div class="wwads-cn wwads-vertical sponsors" data-id="380" style="max-width:180px"></div> -->
|
||||
</div>
|
||||
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.article-share {
|
||||
padding: 14px 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.article-share__button {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
font-weight: 500;
|
||||
font-size: 14px;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
transition: all 0.4s var(--ease-out-cubic, cubic-bezier(0.33, 1, 0.68, 1));
|
||||
cursor: pointer;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 14px;
|
||||
padding: 7px 14px;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
color: var(--vp-c-text-1, #333);
|
||||
background-color: var(--vp-c-bg-alt, #f6f6f7);
|
||||
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.02);
|
||||
will-change: transform, box-shadow;
|
||||
}
|
||||
|
||||
.article-share__button::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: -100%;
|
||||
z-index: -1;
|
||||
transition: left 0.6s ease;
|
||||
background-color: var(--vp-c-brand-soft, #ddf4ff);
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.article-share__button:hover {
|
||||
transform: translateY(-1px);
|
||||
border-color: var(--vp-c-brand-soft, #ddf4ff);
|
||||
background-color: var(--vp-c-brand-soft, #ddf4ff);
|
||||
}
|
||||
|
||||
.article-share__button:active {
|
||||
transform: scale(0.9);
|
||||
}
|
||||
|
||||
.article-share__button.copied {
|
||||
color: var(--vp-c-brand-1, #007acc);
|
||||
/* 增加了备用颜色 */
|
||||
background-color: var(--vp-c-brand-soft, #ddf4ff);
|
||||
}
|
||||
|
||||
.article-share__button.copied::before {
|
||||
left: 0;
|
||||
background-color: var(--vp-c-brand-soft, #ddf4ff);
|
||||
}
|
||||
|
||||
.content-wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
margin-right: 6px;
|
||||
}
|
||||
|
||||
.sponsors {
|
||||
max-width: 100%;
|
||||
margin: 0 !important;
|
||||
background-color: transparent !important;
|
||||
}
|
||||
|
||||
.sponsors .wwads-text {
|
||||
color: var(--vp-c-text-1) !important;
|
||||
transition-property: color;
|
||||
transition-duration: 500ms;
|
||||
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
</style>
|
||||
@@ -1,7 +0,0 @@
|
||||
<template>
|
||||
<div style="display: flex; justify-content: center; align-items: center; margin-top: 16px; gap: 12px;">
|
||||
<span style="font-size: 13px; color: #666; font-style: italic;;">Deployed on</span>
|
||||
<a href="https://www.rainyun.com/NjY3OTQ1_"><img src="https://www.rainyun.com/img/logo.d193755d.png" width="50" alt="Rainyun Logo"></a>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,131 +0,0 @@
|
||||
<script setup>
|
||||
import { useRoute } from 'vitepress'
|
||||
import { computed, provide, useSlots, watch } from 'vue'
|
||||
import VPBackdrop from 'vitepress/dist/client/theme-default/components/VPBackdrop.vue'
|
||||
import VPContent from 'vitepress/dist/client/theme-default/components/VPContent.vue'
|
||||
import VPFooter from 'vitepress/dist/client/theme-default/components/VPFooter.vue'
|
||||
import VPLocalNav from 'vitepress/dist/client/theme-default/components/VPLocalNav.vue'
|
||||
import VPNav from 'vitepress/dist/client/theme-default/components/VPNav.vue'
|
||||
import VPSidebar from 'vitepress/dist/client/theme-default/components/VPSidebar.vue'
|
||||
import VPSkipLink from 'vitepress/dist/client/theme-default/components/VPSkipLink.vue'
|
||||
import { useData } from 'vitepress/dist/client/theme-default/composables/data'
|
||||
import { useCloseSidebarOnEscape, useSidebar } from 'vitepress/dist/client/theme-default/composables/sidebar'
|
||||
import SectionTabs from './SectionTabs.vue'
|
||||
|
||||
const {
|
||||
isOpen: isSidebarOpen,
|
||||
open: openSidebar,
|
||||
close: closeSidebar
|
||||
} = useSidebar()
|
||||
|
||||
const route = useRoute()
|
||||
watch(() => route.path, closeSidebar)
|
||||
|
||||
useCloseSidebarOnEscape(isSidebarOpen, closeSidebar)
|
||||
|
||||
const { frontmatter } = useData()
|
||||
|
||||
const sidebarScopeClass = computed(() => {
|
||||
const path = route.path
|
||||
const normalizedPath = path
|
||||
.replace(/\.html$/, '')
|
||||
.replace(/\/$/, '') || '/'
|
||||
|
||||
if (
|
||||
normalizedPath === '/what-is-astrbot' || normalizedPath === '/community' || normalizedPath === '/faq'
|
||||
|| path.startsWith('/deploy/') || path.startsWith('/others/') || path.startsWith('/ospp/')
|
||||
|| normalizedPath === '/en/what-is-astrbot' || normalizedPath === '/en/community' || normalizedPath === '/en/faq'
|
||||
|| path.startsWith('/en/deploy/') || path.startsWith('/en/others/') || path.startsWith('/en/ospp/')
|
||||
)
|
||||
return 'sidebar-scope-intro-deploy'
|
||||
|
||||
if (path.startsWith('/platform/') || path.startsWith('/en/platform/'))
|
||||
return 'sidebar-scope-platform'
|
||||
|
||||
if (path.startsWith('/providers/') || path.startsWith('/en/providers/'))
|
||||
return 'sidebar-scope-providers'
|
||||
|
||||
if (path.startsWith('/use/') || path.startsWith('/en/use/'))
|
||||
return 'sidebar-scope-use'
|
||||
|
||||
if (path.startsWith('/dev/') || path.startsWith('/en/dev/'))
|
||||
return 'sidebar-scope-dev'
|
||||
|
||||
return ''
|
||||
})
|
||||
|
||||
const slots = useSlots()
|
||||
const heroImageSlotExists = computed(() => !!slots['home-hero-image'])
|
||||
|
||||
provide('hero-image-slot-exists', heroImageSlotExists)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
v-if="frontmatter.layout !== false"
|
||||
class="Layout"
|
||||
:class="[frontmatter.pageClass, sidebarScopeClass]"
|
||||
>
|
||||
<slot name="layout-top" />
|
||||
<VPSkipLink />
|
||||
<VPBackdrop class="backdrop" :show="isSidebarOpen" @click="closeSidebar" />
|
||||
<VPNav>
|
||||
<template #nav-bar-title-before><slot name="nav-bar-title-before" /></template>
|
||||
<template #nav-bar-title-after><slot name="nav-bar-title-after" /></template>
|
||||
<template #nav-bar-content-before><slot name="nav-bar-content-before" /></template>
|
||||
<template #nav-bar-content-after><slot name="nav-bar-content-after" /></template>
|
||||
<template #nav-screen-content-before><slot name="nav-screen-content-before" /></template>
|
||||
<template #nav-screen-content-after><slot name="nav-screen-content-after" /></template>
|
||||
</VPNav>
|
||||
|
||||
<SectionTabs />
|
||||
|
||||
<VPLocalNav :open="isSidebarOpen" @open-menu="openSidebar" />
|
||||
|
||||
<VPSidebar :open="isSidebarOpen">
|
||||
<template #sidebar-nav-before><slot name="sidebar-nav-before" /></template>
|
||||
<template #sidebar-nav-after><slot name="sidebar-nav-after" /></template>
|
||||
</VPSidebar>
|
||||
|
||||
<VPContent>
|
||||
<template #page-top><slot name="page-top" /></template>
|
||||
<template #page-bottom><slot name="page-bottom" /></template>
|
||||
|
||||
<template #not-found><slot name="not-found" /></template>
|
||||
<template #home-hero-before><slot name="home-hero-before" /></template>
|
||||
<template #home-hero-info-before><slot name="home-hero-info-before" /></template>
|
||||
<template #home-hero-info><slot name="home-hero-info" /></template>
|
||||
<template #home-hero-info-after><slot name="home-hero-info-after" /></template>
|
||||
<template #home-hero-actions-after><slot name="home-hero-actions-after" /></template>
|
||||
<template #home-hero-image><slot name="home-hero-image" /></template>
|
||||
<template #home-hero-after><slot name="home-hero-after" /></template>
|
||||
<template #home-features-before><slot name="home-features-before" /></template>
|
||||
<template #home-features-after><slot name="home-features-after" /></template>
|
||||
|
||||
<template #doc-footer-before><slot name="doc-footer-before" /></template>
|
||||
<template #doc-before><slot name="doc-before" /></template>
|
||||
<template #doc-after><slot name="doc-after" /></template>
|
||||
<template #doc-top><slot name="doc-top" /></template>
|
||||
<template #doc-bottom><slot name="doc-bottom" /></template>
|
||||
|
||||
<template #aside-top><slot name="aside-top" /></template>
|
||||
<template #aside-bottom><slot name="aside-bottom" /></template>
|
||||
<template #aside-outline-before><slot name="aside-outline-before" /></template>
|
||||
<template #aside-outline-after><slot name="aside-outline-after" /></template>
|
||||
<template #aside-ads-before><slot name="aside-ads-before" /></template>
|
||||
<template #aside-ads-after><slot name="aside-ads-after" /></template>
|
||||
</VPContent>
|
||||
|
||||
<VPFooter />
|
||||
<slot name="layout-bottom" />
|
||||
</div>
|
||||
<Content v-else />
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.Layout {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 100vh;
|
||||
}
|
||||
</style>
|
||||
@@ -1,73 +0,0 @@
|
||||
<script setup>
|
||||
import { useRouter } from 'vitepress'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
const goHome = () => {
|
||||
router.go('/')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="NotFound">
|
||||
<img src="/404-seio.png" alt="404 Not Found" class="not-found-image" />
|
||||
<h1 class="not-found-title">😢 你来到了未知的领域,页面不存在!</h1>
|
||||
<p class="not-found-desc">请点击左上角 Logo 返回首页,或点击下方按钮。</p>
|
||||
<button @click="goHome" class="not-found-button">返回首页</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.NotFound {
|
||||
padding: 4rem 2rem;
|
||||
text-align: center;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 60vh;
|
||||
}
|
||||
|
||||
.not-found-image {
|
||||
max-width: 400px;
|
||||
width: 100%;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.not-found-title {
|
||||
font-size: 1.5rem;
|
||||
margin-bottom: 1rem;
|
||||
color: var(--vp-c-text-1);
|
||||
}
|
||||
|
||||
.not-found-desc {
|
||||
font-size: 1rem;
|
||||
margin-bottom: 2rem;
|
||||
color: var(--vp-c-text-2);
|
||||
}
|
||||
|
||||
.not-found-button {
|
||||
padding: 0.75rem 1.5rem;
|
||||
font-size: 1rem;
|
||||
color: #fff;
|
||||
background-color: var(--vp-c-brand-1);
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.not-found-button:hover {
|
||||
background-color: var(--vp-c-brand-2);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.not-found-image {
|
||||
max-width: 300px;
|
||||
}
|
||||
|
||||
.not-found-title {
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,121 +0,0 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import { useData, useRoute } from 'vitepress'
|
||||
|
||||
const route = useRoute()
|
||||
const { frontmatter } = useData()
|
||||
|
||||
const isEnglish = computed(() => route.path.startsWith('/en/'))
|
||||
|
||||
const zhTabs = [
|
||||
{
|
||||
text: '简介和部署',
|
||||
link: '/what-is-astrbot',
|
||||
matchers: ['/what-is-astrbot', '/community', '/faq', '/deploy/', '/others/', '/ospp/']
|
||||
},
|
||||
{ text: '接入消息平台', link: '/platform/start', matchers: ['/platform/'] },
|
||||
{ text: '接入 AI', link: '/providers/start', matchers: ['/providers/'] },
|
||||
{ text: '使用', link: '/use/webui', matchers: ['/use/'] },
|
||||
{ text: '开发', link: '/dev/star/plugin-new', matchers: ['/dev/'] }
|
||||
]
|
||||
|
||||
const enTabs = [
|
||||
{
|
||||
text: 'Intro & Deploy',
|
||||
link: '/en/what-is-astrbot',
|
||||
matchers: ['/en/what-is-astrbot', '/en/community', '/en/faq', '/en/deploy/', '/en/others/', '/en/ospp/']
|
||||
},
|
||||
{ text: 'Messaging Platforms', link: '/en/platform/start', matchers: ['/en/platform/'] },
|
||||
{ text: 'AI Integration', link: '/en/providers/start', matchers: ['/en/providers/'] },
|
||||
{ text: 'Usage', link: '/en/use/webui', matchers: ['/en/use/'] },
|
||||
{ text: 'Development', link: '/en/dev/star/plugin-new', matchers: ['/en/dev/'] }
|
||||
]
|
||||
|
||||
const tabs = computed(() => (isEnglish.value ? enTabs : zhTabs))
|
||||
|
||||
const isHome = computed(() => route.path === '/' || route.path === '/en/')
|
||||
|
||||
const shouldShow = computed(() => frontmatter.value.layout !== false && frontmatter.value.layout !== 'home' && !isHome.value)
|
||||
|
||||
function isActive(tab) {
|
||||
return tab.matchers.some(prefix => route.path.startsWith(prefix))
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<template v-if="shouldShow">
|
||||
<div class="VPSectionTabsPlaceholder" aria-hidden="true" />
|
||||
<div class="VPSectionTabs">
|
||||
<div class="container">
|
||||
<a
|
||||
v-for="tab in tabs"
|
||||
:key="tab.link"
|
||||
class="tab"
|
||||
:class="{ active: isActive(tab) }"
|
||||
:href="tab.link"
|
||||
>
|
||||
{{ tab.text }}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.VPSectionTabs {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.VPSectionTabsPlaceholder {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media (min-width: 1280px) {
|
||||
.VPSectionTabsPlaceholder {
|
||||
display: block;
|
||||
height: var(--vp-section-tabs-height, 44px);
|
||||
}
|
||||
|
||||
.VPSectionTabs {
|
||||
display: block;
|
||||
position: fixed;
|
||||
left: 0;
|
||||
right: 0;
|
||||
top: calc(var(--vp-layout-top-height, 0px) + var(--vp-nav-height));
|
||||
z-index: 26;
|
||||
border-bottom: 1px solid var(--vp-c-gutter);
|
||||
background-color: var(--vp-nav-bg-color);
|
||||
}
|
||||
|
||||
.container {
|
||||
margin: 0 auto;
|
||||
max-width: var(--vp-layout-max-width);
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: 10px;
|
||||
box-sizing: border-box;
|
||||
height: var(--vp-section-tabs-height, 44px);
|
||||
padding: 0 32px 8px;
|
||||
}
|
||||
|
||||
.tab {
|
||||
border-radius: 999px;
|
||||
padding: 6px 12px;
|
||||
font-size: 13px;
|
||||
line-height: 20px;
|
||||
color: var(--vp-c-text-2);
|
||||
white-space: nowrap;
|
||||
transition: color 0.2s ease, background-color 0.2s ease;
|
||||
}
|
||||
|
||||
.tab:hover {
|
||||
color: var(--vp-c-text-1);
|
||||
background-color: var(--vp-c-default-soft);
|
||||
}
|
||||
|
||||
.tab.active {
|
||||
color: var(--vp-c-brand-1);
|
||||
background-color: var(--vp-c-brand-soft);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,21 +0,0 @@
|
||||
// https://vitepress.dev/guide/custom-theme
|
||||
import { h } from 'vue'
|
||||
import DefaultTheme from 'vitepress/theme'
|
||||
import './styles/style.css'
|
||||
import './styles/custom-block.css'
|
||||
import './styles/font.css'
|
||||
import Layout from './components/Layout.vue'
|
||||
import ArticleShare from './components/ArticleShare.vue'
|
||||
import NotFound from './components/NotFound.vue'
|
||||
|
||||
/** @type {import('vitepress').Theme} */
|
||||
export default {
|
||||
extends: DefaultTheme,
|
||||
Layout() {
|
||||
return h(Layout, null, {
|
||||
// https://vitepress.dev/guide/extending-default-theme#layout-slots
|
||||
'aside-outline-after': () => h(ArticleShare),
|
||||
'not-found': () => h(NotFound)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,185 +0,0 @@
|
||||
/* .vitepress/theme/style/custom-block.css */
|
||||
/* 深浅色卡 */
|
||||
:root {
|
||||
--custom-block-info-left: #cccccc;
|
||||
--custom-block-info-bg: #fafafa;
|
||||
|
||||
--custom-block-tip-left: #009400;
|
||||
--custom-block-tip-bg: #b6dcc7;
|
||||
|
||||
--custom-block-warning-left: #e6a700;
|
||||
--custom-block-warning-bg: #ffe69d;
|
||||
|
||||
--custom-block-danger-left: #e13238;
|
||||
--custom-block-danger-bg: #ffebec;
|
||||
|
||||
--custom-block-note-left: #4cb3d4;
|
||||
--custom-block-note-bg: #d6eff7;
|
||||
|
||||
--custom-block-important-left: #a371f7;
|
||||
--custom-block-important-bg: #f4eefe;
|
||||
|
||||
--custom-block-caution-left: #e0575b;
|
||||
--custom-block-caution-bg: #fde4e8;
|
||||
}
|
||||
|
||||
.dark {
|
||||
--custom-block-info-left: #cccccc;
|
||||
--custom-block-info-bg: #474748;
|
||||
|
||||
--custom-block-tip-left: #009400;
|
||||
--custom-block-tip-bg: #003100;
|
||||
|
||||
--custom-block-warning-left: #e6a700;
|
||||
--custom-block-warning-bg: #4d3800;
|
||||
|
||||
--custom-block-danger-left: #e13238;
|
||||
--custom-block-danger-bg: #4b1113;
|
||||
|
||||
--custom-block-note-left: #4cb3d4;
|
||||
--custom-block-note-bg: #193c47;
|
||||
|
||||
--custom-block-important-left: #a371f7;
|
||||
--custom-block-important-bg: #230555;
|
||||
|
||||
--custom-block-caution-left: #e0575b;
|
||||
--custom-block-caution-bg: #391c22;
|
||||
}
|
||||
|
||||
|
||||
/* 标题字体大小 */
|
||||
.custom-block-title {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
/* info容器:背景色、左侧 */
|
||||
.custom-block.info {
|
||||
background-color: var(--custom-block-info-bg);
|
||||
}
|
||||
|
||||
/* info容器:svg图 */
|
||||
.custom-block.info [class*="custom-block-title"]::before {
|
||||
content: '';
|
||||
background-image: url("data:image/svg+xml;utf8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath d='M12 22C6.477 22 2 17.523 2 12S6.477 2 12 2s10 4.477 10 10-4.477 10-10 10zm-1-11v6h2v-6h-2zm0-4v2h2V7h-2z' fill='%23ccc'/%3E%3C/svg%3E");
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
display: inline-block;
|
||||
vertical-align: middle;
|
||||
position: relative;
|
||||
margin-right: 4px;
|
||||
left: -5px;
|
||||
top: -1px;
|
||||
}
|
||||
|
||||
/* 提示容器:边框色、背景色、左侧 */
|
||||
.custom-block.tip {
|
||||
background-color: var(--custom-block-tip-bg);
|
||||
}
|
||||
|
||||
/* 提示容器:svg图 */
|
||||
.custom-block.tip [class*="custom-block-title"]::before {
|
||||
content: '';
|
||||
background-image: url("data:image/svg+xml;utf8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath fill='%23009400' d='M7.941 18c-.297-1.273-1.637-2.314-2.187-3a8 8 0 1 1 12.49.002c-.55.685-1.888 1.726-2.185 2.998H7.94zM16 20v1a2 2 0 0 1-2 2h-4a2 2 0 0 1-2-2v-1h8zm-3-9.995V6l-4.5 6.005H11v4l4.5-6H13z'/%3E%3C/svg%3E");
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
display: inline-block;
|
||||
vertical-align: middle;
|
||||
position: relative;
|
||||
margin-right: 4px;
|
||||
left: -5px;
|
||||
top: -2px;
|
||||
}
|
||||
|
||||
/* 警告容器:背景色、左侧 */
|
||||
.custom-block.warning {
|
||||
background-color: var(--custom-block-warning-bg);
|
||||
}
|
||||
|
||||
/* 警告容器:svg图 */
|
||||
.custom-block.warning [class*="custom-block-title"]::before {
|
||||
content: '';
|
||||
background-image: url("data:image/svg+xml;utf8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 1024 1024'%3E%3Cpath d='M576.286 752.57v-95.425q0-7.031-4.771-11.802t-11.3-4.772h-96.43q-6.528 0-11.3 4.772t-4.77 11.802v95.424q0 7.031 4.77 11.803t11.3 4.77h96.43q6.528 0 11.3-4.77t4.77-11.803zm-1.005-187.836 9.04-230.524q0-6.027-5.022-9.543-6.529-5.524-12.053-5.524H456.754q-5.524 0-12.053 5.524-5.022 3.516-5.022 10.547l8.538 229.52q0 5.023 5.022 8.287t12.053 3.265h92.913q7.032 0 11.803-3.265t5.273-8.287zM568.25 95.65l385.714 707.142q17.578 31.641-1.004 63.282-8.538 14.564-23.354 23.102t-31.892 8.538H126.286q-17.076 0-31.892-8.538T71.04 866.074q-18.582-31.641-1.004-63.282L455.75 95.65q8.538-15.57 23.605-24.61T512 62t32.645 9.04 23.605 24.61z' fill='%23e6a700'/%3E%3C/svg%3E");
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
display: inline-block;
|
||||
vertical-align: middle;
|
||||
position: relative;
|
||||
margin-right: 4px;
|
||||
left: -5px;
|
||||
}
|
||||
|
||||
/* 危险容器:背景色、左侧 */
|
||||
.custom-block.danger {
|
||||
background-color: var(--custom-block-danger-bg);
|
||||
}
|
||||
|
||||
/* 危险容器:svg图 */
|
||||
.custom-block.danger [class*="custom-block-title"]::before {
|
||||
content: '';
|
||||
background-image: url("data:image/svg+xml;utf8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath d='M12 2c5.523 0 10 4.477 10 10v3.764a2 2 0 0 1-1.106 1.789L18 19v1a3 3 0 0 1-2.824 2.995L14.95 23a2.5 2.5 0 0 0 .044-.33L15 22.5V22a2 2 0 0 0-1.85-1.995L13 20h-2a2 2 0 0 0-1.995 1.85L9 22v.5c0 .171.017.339.05.5H9a3 3 0 0 1-3-3v-1l-2.894-1.447A2 2 0 0 1 2 15.763V12C2 6.477 6.477 2 12 2zm-4 9a2 2 0 1 0 0 4 2 2 0 0 0 0-4zm8 0a2 2 0 1 0 0 4 2 2 0 0 0 0-4z' fill='%23e13238'/%3E%3C/svg%3E");
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
display: inline-block;
|
||||
vertical-align: middle;
|
||||
position: relative;
|
||||
margin-right: 4px;
|
||||
left: -5px;
|
||||
top: -1px;
|
||||
}
|
||||
|
||||
/* 提醒容器:背景色、左侧 */
|
||||
.custom-block.note {
|
||||
background-color: var(--custom-block-note-bg);
|
||||
}
|
||||
|
||||
/* 提醒容器:svg图 */
|
||||
.custom-block.note [class*="custom-block-title"]::before {
|
||||
content: '';
|
||||
background-image: url("data:image/svg+xml;utf8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath d='M12 22C6.477 22 2 17.523 2 12S6.477 2 12 2s10 4.477 10 10-4.477 10-10 10zm-1-11v6h2v-6h-2zm0-4v2h2V7h-2z' fill='%234cb3d4'/%3E%3C/svg%3E");
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
display: inline-block;
|
||||
vertical-align: middle;
|
||||
position: relative;
|
||||
margin-right: 4px;
|
||||
left: -5px;
|
||||
top: -1px;
|
||||
}
|
||||
|
||||
/* 重要容器:背景色、左侧 */
|
||||
.custom-block.important {
|
||||
background-color: var(--custom-block-important-bg);
|
||||
}
|
||||
|
||||
/* 重要容器:svg图 */
|
||||
.custom-block.important [class*="custom-block-title"]::before {
|
||||
content: '';
|
||||
background-image: url("data:image/svg+xml;utf8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 1024 1024'%3E%3Cpath d='M512 981.333a84.992 84.992 0 0 1-84.907-84.906h169.814A84.992 84.992 0 0 1 512 981.333zm384-128H128v-42.666l85.333-85.334v-256A298.325 298.325 0 0 1 448 177.92V128a64 64 0 0 1 128 0v49.92a298.325 298.325 0 0 1 234.667 291.413v256L896 810.667v42.666zm-426.667-256v85.334h85.334v-85.334h-85.334zm0-256V512h85.334V341.333h-85.334z' fill='%23a371f7'/%3E%3C/svg%3E");
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
display: inline-block;
|
||||
vertical-align: middle;
|
||||
position: relative;
|
||||
margin-right: 4px;
|
||||
left: -5px;
|
||||
top: -1px;
|
||||
}
|
||||
|
||||
/* 注意容器:背景色、左侧 */
|
||||
.custom-block.caution {
|
||||
background-color: var(--custom-block-caution-bg);
|
||||
}
|
||||
|
||||
/* 注意容器:svg图 */
|
||||
.custom-block.caution [class*="custom-block-title"]::before {
|
||||
content: '';
|
||||
background-image: url("data:image/svg+xml;utf8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath d='M12 2c5.523 0 10 4.477 10 10v3.764a2 2 0 0 1-1.106 1.789L18 19v1a3 3 0 0 1-2.824 2.995L14.95 23a2.5 2.5 0 0 0 .044-.33L15 22.5V22a2 2 0 0 0-1.85-1.995L13 20h-2a2 2 0 0 0-1.995 1.85L9 22v.5c0 .171.017.339.05.5H9a3 3 0 0 1-3-3v-1l-2.894-1.447A2 2 0 0 1 2 15.763V12C2 6.477 6.477 2 12 2zm-4 9a2 2 0 1 0 0 4 2 2 0 0 0 0-4zm8 0a2 2 0 1 0 0 4 2 2 0 0 0 0-4z' fill='%23e13238'/%3E%3C/svg%3E");
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
display: inline-block;
|
||||
vertical-align: middle;
|
||||
position: relative;
|
||||
margin-right: 4px;
|
||||
left: -5px;
|
||||
top: -1px;
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
/* Keep only the top-left navbar title in Outfit; use VitePress defaults elsewhere. */
|
||||
.VPNavBarTitle .title,
|
||||
.VPNavBarTitle .title .text {
|
||||
font-family: "Outfit", sans-serif !important;
|
||||
}
|
||||
@@ -1,358 +0,0 @@
|
||||
/**
|
||||
* Customize default theme styling by overriding CSS variables:
|
||||
* https://github.com/vuejs/vitepress/blob/main/src/client/theme-default/styles/vars.css
|
||||
*/
|
||||
|
||||
/**
|
||||
* Colors
|
||||
*
|
||||
* Each colors have exact same color scale system with 3 levels of solid
|
||||
* colors with different brightness, and 1 soft color.
|
||||
*
|
||||
* - `XXX-1`: The most solid color used mainly for colored text. It must
|
||||
* satisfy the contrast ratio against when used on top of `XXX-soft`.
|
||||
*
|
||||
* - `XXX-2`: The color used mainly for hover state of the button.
|
||||
*
|
||||
* - `XXX-3`: The color for solid background, such as bg color of the button.
|
||||
* It must satisfy the contrast ratio with pure white (#ffffff) text on
|
||||
* top of it.
|
||||
*
|
||||
* - `XXX-soft`: The color used for subtle background such as custom container
|
||||
* or badges. It must satisfy the contrast ratio when putting `XXX-1` colors
|
||||
* on top of it.
|
||||
*
|
||||
* The soft color must be semi transparent alpha channel. This is crucial
|
||||
* because it allows adding multiple "soft" colors on top of each other
|
||||
* to create a accent, such as when having inline code block inside
|
||||
* custom containers.
|
||||
*
|
||||
* - `default`: The color used purely for subtle indication without any
|
||||
* special meanings attached to it such as bg color for menu hover state.
|
||||
*
|
||||
* - `brand`: Used for primary brand colors, such as link text, button with
|
||||
* brand theme, etc.
|
||||
*
|
||||
* - `tip`: Used to indicate useful information. The default theme uses the
|
||||
* brand color for this by default.
|
||||
*
|
||||
* - `warning`: Used to indicate warning to the users. Used in custom
|
||||
* container, badges, etc.
|
||||
*
|
||||
* - `danger`: Used to show error, or dangerous message to the users. Used
|
||||
* in custom container, badges, etc.
|
||||
* -------------------------------------------------------------------------- */
|
||||
|
||||
:root {
|
||||
--vp-c-default-1: var(--vp-c-gray-1);
|
||||
--vp-c-default-2: var(--vp-c-gray-2);
|
||||
--vp-c-default-3: var(--vp-c-gray-3);
|
||||
--vp-c-default-soft: var(--vp-c-gray-soft);
|
||||
|
||||
--vp-c-brand-1: var(--vp-c-indigo-1);
|
||||
--vp-c-brand-2: var(--vp-c-indigo-2);
|
||||
--vp-c-brand-3: var(--vp-c-indigo-3);
|
||||
--vp-c-brand-soft: var(--vp-c-indigo-soft);
|
||||
|
||||
--vp-c-tip-1: var(--vp-c-brand-1);
|
||||
--vp-c-tip-2: var(--vp-c-brand-2);
|
||||
--vp-c-tip-3: var(--vp-c-brand-3);
|
||||
--vp-c-tip-soft: var(--vp-c-brand-soft);
|
||||
|
||||
--vp-c-warning-1: var(--vp-c-yellow-1);
|
||||
--vp-c-warning-2: var(--vp-c-yellow-2);
|
||||
--vp-c-warning-3: var(--vp-c-yellow-3);
|
||||
--vp-c-warning-soft: var(--vp-c-yellow-soft);
|
||||
|
||||
--vp-c-danger-1: var(--vp-c-red-1);
|
||||
--vp-c-danger-2: var(--vp-c-red-2);
|
||||
--vp-c-danger-3: var(--vp-c-red-3);
|
||||
--vp-c-danger-soft: var(--vp-c-red-soft);
|
||||
}
|
||||
|
||||
/**
|
||||
* Component: Button
|
||||
* -------------------------------------------------------------------------- */
|
||||
|
||||
:root {
|
||||
--vp-button-brand-border: transparent;
|
||||
--vp-button-brand-text: var(--vp-c-white);
|
||||
--vp-button-brand-bg: var(--vp-c-brand-3);
|
||||
--vp-button-brand-hover-border: transparent;
|
||||
--vp-button-brand-hover-text: var(--vp-c-white);
|
||||
--vp-button-brand-hover-bg: var(--vp-c-brand-2);
|
||||
--vp-button-brand-active-border: transparent;
|
||||
--vp-button-brand-active-text: var(--vp-c-white);
|
||||
--vp-button-brand-active-bg: var(--vp-c-brand-1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Component: Home
|
||||
* -------------------------------------------------------------------------- */
|
||||
|
||||
:root {
|
||||
--vp-home-hero-name-color: transparent;
|
||||
--vp-home-hero-name-background: -webkit-linear-gradient(
|
||||
120deg,
|
||||
#bd34fe 30%,
|
||||
#41d1ff
|
||||
);
|
||||
|
||||
--vp-home-hero-image-background-image: linear-gradient(
|
||||
-45deg,
|
||||
#bd34fe 50%,
|
||||
#47caff 50%
|
||||
);
|
||||
--vp-home-hero-image-filter: blur(44px);
|
||||
}
|
||||
|
||||
@media (min-width: 640px) {
|
||||
:root {
|
||||
--vp-home-hero-image-filter: blur(56px);
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 960px) {
|
||||
:root {
|
||||
--vp-home-hero-image-filter: blur(68px);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Component: Custom Block
|
||||
* -------------------------------------------------------------------------- */
|
||||
|
||||
:root {
|
||||
--vp-custom-block-tip-border: transparent;
|
||||
--vp-custom-block-tip-text: var(--vp-c-text-1);
|
||||
--vp-custom-block-tip-bg: var(--vp-c-brand-soft);
|
||||
--vp-custom-block-tip-code-bg: var(--vp-c-brand-soft);
|
||||
}
|
||||
|
||||
/**
|
||||
* Component: Sidebar
|
||||
* -------------------------------------------------------------------------- */
|
||||
|
||||
:root {
|
||||
--vp-sidebar-bg-color: transparent;
|
||||
--vp-section-tabs-height: 44px;
|
||||
}
|
||||
|
||||
@media (max-width: 959px) {
|
||||
:root {
|
||||
--vp-sidebar-bg-color: var(--vp-c-bg-alt);
|
||||
}
|
||||
|
||||
.VPSidebar {
|
||||
background-color: var(--vp-c-bg-alt) !important;
|
||||
}
|
||||
}
|
||||
|
||||
.VPSidebarItem.is-link > .item > .link {
|
||||
margin: 2px 0;
|
||||
border-radius: 8px;
|
||||
padding: 0 10px;
|
||||
transition: none;
|
||||
}
|
||||
|
||||
.VPSidebarItem,
|
||||
.VPSidebarItem > .item,
|
||||
.VPSidebarItem > .item > .link {
|
||||
border-bottom: none !important;
|
||||
}
|
||||
|
||||
.VPSidebar .group + .group {
|
||||
border-top: none !important;
|
||||
}
|
||||
|
||||
.VPSidebar {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--vp-c-divider) transparent;
|
||||
}
|
||||
|
||||
.VPSidebar::-webkit-scrollbar {
|
||||
width: 10px;
|
||||
}
|
||||
|
||||
.VPSidebar::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.VPSidebar::-webkit-scrollbar-thumb {
|
||||
border: 2px solid transparent;
|
||||
border-radius: 999px;
|
||||
background-clip: padding-box;
|
||||
background-color: var(--vp-c-divider);
|
||||
}
|
||||
|
||||
.VPSidebar::-webkit-scrollbar-thumb:hover {
|
||||
background-color: var(--vp-c-text-3);
|
||||
}
|
||||
|
||||
.VPSidebarItem.is-link > .item > .link:hover {
|
||||
background-color: var(--vp-c-default-soft);
|
||||
}
|
||||
|
||||
.VPSidebarItem.is-link.is-active > .item > .link {
|
||||
background-color: var(--vp-c-brand-soft);
|
||||
}
|
||||
|
||||
/**
|
||||
* Component: Algolia
|
||||
* -------------------------------------------------------------------------- */
|
||||
|
||||
.DocSearch {
|
||||
--docsearch-primary-color: var(--vp-c-brand-1) !important;
|
||||
}
|
||||
|
||||
/**
|
||||
* Component: Nav
|
||||
* -------------------------------------------------------------------------- */
|
||||
|
||||
.VPNavBarTitle .logo {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
}
|
||||
|
||||
.VPNavBarTitle .title > span {
|
||||
font-size: 26px;
|
||||
color: var(--vp-c-text-1);
|
||||
}
|
||||
|
||||
@media (min-width: 960px) {
|
||||
.VPNavBar.has-sidebar .wrapper {
|
||||
padding: 0 32px !important;
|
||||
background-color: var(--vp-nav-bg-color) !important;
|
||||
}
|
||||
|
||||
.VPNavBar.has-sidebar .container {
|
||||
max-width: calc(var(--vp-layout-max-width) - 64px) !important;
|
||||
justify-content: flex-start !important;
|
||||
gap: 24px !important;
|
||||
background-color: var(--vp-nav-bg-color) !important;
|
||||
}
|
||||
|
||||
.VPNavBar.has-sidebar .container > .title {
|
||||
position: relative !important;
|
||||
z-index: 3 !important;
|
||||
padding: 0 !important;
|
||||
width: auto !important;
|
||||
max-width: none !important;
|
||||
background-color: var(--vp-nav-bg-color) !important;
|
||||
}
|
||||
|
||||
.VPNavBar.has-sidebar .content {
|
||||
padding-left: 0 !important;
|
||||
padding-right: 0 !important;
|
||||
}
|
||||
|
||||
.VPNavBar.has-sidebar .content-body {
|
||||
justify-content: flex-start !important;
|
||||
}
|
||||
|
||||
.VPNavBar.has-sidebar .menu {
|
||||
margin-right: auto !important;
|
||||
}
|
||||
|
||||
.VPNavBar.has-sidebar .divider {
|
||||
padding-left: 0 !important;
|
||||
}
|
||||
|
||||
.VPNavBar.has-sidebar .VPNavBarTitle .title {
|
||||
border-bottom: none !important;
|
||||
background-color: var(--vp-nav-bg-color);
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 1440px) {
|
||||
.VPNavBar.has-sidebar .container > .title {
|
||||
padding-left: 0 !important;
|
||||
width: auto !important;
|
||||
}
|
||||
|
||||
.VPNavBar.has-sidebar .content {
|
||||
padding-right: 0 !important;
|
||||
padding-left: 0 !important;
|
||||
}
|
||||
|
||||
.VPNavBar.has-sidebar .divider {
|
||||
padding-left: 0 !important;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Component: Local Nav
|
||||
* -------------------------------------------------------------------------- */
|
||||
|
||||
@media (min-width: 960px) {
|
||||
.VPLocalNav.has-sidebar {
|
||||
border-bottom: none !important;
|
||||
}
|
||||
|
||||
.VPLocalNav.has-sidebar::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: var(--vp-sidebar-width);
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
height: 1px;
|
||||
background-color: var(--vp-c-gutter);
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 1440px) {
|
||||
.VPLocalNav.has-sidebar::after {
|
||||
left: calc((100vw - var(--vp-layout-max-width)) / 2 + var(--vp-sidebar-width));
|
||||
}
|
||||
}
|
||||
|
||||
.VPDocAsideOutline.has-outline .content {
|
||||
border-left: none !important;
|
||||
}
|
||||
|
||||
@media (min-width: 1280px) {
|
||||
.VPNavBar.has-sidebar .divider {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.VPSidebar {
|
||||
padding-top: calc(var(--vp-nav-height) + var(--vp-section-tabs-height)) !important;
|
||||
}
|
||||
|
||||
.Layout.sidebar-scope-intro-deploy .VPSidebar .group,
|
||||
.Layout.sidebar-scope-platform .VPSidebar .group,
|
||||
.Layout.sidebar-scope-providers .VPSidebar .group,
|
||||
.Layout.sidebar-scope-use .VPSidebar .group,
|
||||
.Layout.sidebar-scope-dev .VPSidebar .group {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.Layout.sidebar-scope-intro-deploy .VPSidebar .group:nth-of-type(1),
|
||||
.Layout.sidebar-scope-intro-deploy .VPSidebar .group:nth-of-type(2),
|
||||
.Layout.sidebar-scope-intro-deploy .VPSidebar .group:nth-of-type(7),
|
||||
.Layout.sidebar-scope-intro-deploy .VPSidebar .group:nth-of-type(8),
|
||||
.Layout.sidebar-scope-platform .VPSidebar .group:nth-of-type(3),
|
||||
.Layout.sidebar-scope-providers .VPSidebar .group:nth-of-type(4),
|
||||
.Layout.sidebar-scope-use .VPSidebar .group:nth-of-type(5),
|
||||
.Layout.sidebar-scope-dev .VPSidebar .group:nth-of-type(6) {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
.VPHomeHero:not(.has-image) .container {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.VPHomeHero:not(.has-image) .heading {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.VPHomeHero:not(.has-image) .name,
|
||||
.VPHomeHero:not(.has-image) .text,
|
||||
.VPHomeHero:not(.has-image) .tagline {
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.VPHomeHero:not(.has-image) .actions {
|
||||
justify-content: center;
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
|
||||
# AstrBot
|
||||
_✨ 易上手的多平台 LLM 聊天机器人及开发框架(的官方文档) ✨_
|
||||
|
||||
[查看文档](https://docs.astrbot.app/) | [问题提交](https://github.com/AstrBotDevs/AstrBot/issues)
|
||||
|
||||
[AstrBot](https://github.com/AstrBotDevs/AstrBot) 是一个松耦合、异步、支持多消息平台部署、具有易用的插件系统和完善的大语言模型(LLM)接入功能的聊天机器人及开发框架。
|
||||
|
||||

|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
# Community
|
||||
|
||||
## Community Channels
|
||||
|
||||
This documentation may not cover all features comprehensively. If you have any questions or suggestions regarding AstrBot or this documentation, please feel free to reach out to us through the community channels below.
|
||||
|
||||
### Discord
|
||||
|
||||
<https://discord.gg/PxgzhmxJ>
|
||||
|
||||
### GitHub
|
||||
|
||||
Welcome to submit Issues or Pull Requests:
|
||||
|
||||
- [AstrBotDevs/AstrBot](https://github.com/AstrBotDevs/AstrBot)
|
||||
|
||||
- [AstrBotDevs/AstrBot-Docs](https://github.com/AstrBotDevs/AstrBot-docs)
|
||||
|
||||
### Tencent QQ Groups
|
||||
|
||||
> - All groups are available to join. If you find that the group size is below the limit, please feel free to join.
|
||||
|
||||
- Group 1: 322154837 (2000-member group)
|
||||
- Group 3: 630166526 (2000-member group)
|
||||
- Group 4: 1077826412 (1000-member group)
|
||||
- Group 5: 822130018 (2000-member group)
|
||||
- Group 6: 753075035 (2000-member group)
|
||||
- Group 7: 743746109 (500-member group)
|
||||
- Group 8: 1030353265 (500-member group)
|
||||
- **AstrBot Core Development Group: 975206796** (AstrBot development members are usually active here. Welcome to anyone interested in programming/AI technology~)
|
||||
|
||||
## Become an AstrBot Organization Member
|
||||
|
||||
We welcome you to join us!
|
||||
@@ -1,12 +0,0 @@
|
||||
|
||||
# 配置自定义的模型参数
|
||||
|
||||
请手动修改位于 `data/cmd_config.json` 下的配置文件。
|
||||
|
||||
找到 `provider`,并找到你想要修改的提供商的模型配置:
|
||||
|
||||

|
||||
|
||||
然后在 `model_config` 中添加新的参数即可。
|
||||
|
||||
具体的参数请参看对应的提供商的文档。
|
||||
@@ -1,27 +0,0 @@
|
||||
# Deploy AstrBot on 1Panel
|
||||
|
||||
[1Panel](https://1panel.cn/) is an open-source next-generation Linux server operation and management panel.
|
||||
|
||||
AstrBot has been published to the [1Panel App Store](https://apps.fit2cloud.com/1panel) by the 1Panel team, allowing users to quickly deploy and use it directly through 1Panel.
|
||||
|
||||
## Install 1Panel
|
||||
|
||||
If you haven't installed 1Panel yet, please refer to the [1Panel official website](https://1panel.cn/) for one-click installation.
|
||||
|
||||
> International users can refer to the [1Panel official site](https://github.com/1Panel-dev/1Panel) for tutorials.
|
||||
|
||||
## Install AstrBot
|
||||
|
||||
Open the 1Panel panel, go to the 1Panel App Store, and search for `AstrBot`, as shown below.
|
||||
|
||||

|
||||
|
||||
Click `Install` and wait for the installation to complete.
|
||||
|
||||
After successful installation, open the corresponding AstrBot port (default is 6185) in the 1Panel System-Firewall page.
|
||||
|
||||
If you are using cloud servers from providers like AWS, Alibaba Cloud, Tencent Cloud, etc., make sure their security groups also allow port 6185.
|
||||
|
||||
## Access AstrBot
|
||||
|
||||
Visit `http://IP:6185` to access the AstrBot dashboard.
|
||||
@@ -1,48 +0,0 @@
|
||||
# Deploy AstrBot on BT Panel
|
||||
|
||||
[BT Panel](https://www.bt.cn/new/index.html) is a secure, efficient, and production-ready Linux/Windows server operation panel.
|
||||
|
||||
AstrBot has been published to BT Panel's Docker App Store, supporting one-click installation.
|
||||
|
||||
## Install BT Panel
|
||||
|
||||
If you haven't installed BT Panel yet, please refer to [Install BT Products](https://www.bt.cn/new/download.html) for one-click installation.
|
||||
|
||||
## Set Acceleration URL (For Users in Mainland China)
|
||||
|
||||
After entering the BT Panel page, click `Docker` on the left sidebar, click Settings, and modify the `Acceleration URL`.
|
||||
|
||||

|
||||
|
||||
## Install AstrBot
|
||||
|
||||
Go to Docker's App Store and search for `AstrBot`, as shown below.
|
||||
|
||||

|
||||
|
||||
Click Install and wait for the installation to complete.
|
||||
|
||||
After successful installation, click `Security` on the left sidebar and open the corresponding AstrBot port (default is 6185).
|
||||
|
||||
If you are using cloud servers from providers like AWS, Alibaba Cloud, Tencent Cloud, etc., make sure their security groups also allow the corresponding port.
|
||||
|
||||
## Access AstrBot
|
||||
|
||||
Visit `http://IP:6185` to access the AstrBot dashboard.
|
||||
|
||||
> [!TIP]
|
||||
> By default, the above method only opens port 6185. If you need to deploy messaging platforms, you need to additionally open the corresponding ports. Click `Container` in the top bar, find the AstrBot container, click `Manage`, click `Edit Container`, and add the corresponding ports.
|
||||
>
|
||||
> 
|
||||
>
|
||||
> For specific messaging platform port mappings, refer to the table below:
|
||||
>
|
||||
>| Port | Description | Type
|
||||
>| -------- | ------- | ------- |
|
||||
>| 6185 | AstrBot WebUI `default` port | Required |
|
||||
>| 6195 | WeCom `default` port | Optional |
|
||||
>| 6199 | QQ Personal Account(aiocqhttp) `default` port | Optional |
|
||||
>| 6196 | QQ Official API(Webhook) `default` port | Optional |
|
||||
>
|
||||
> Platforms not listed do not require additional port opening.
|
||||
|
||||
@@ -1,39 +0,0 @@
|
||||
# Deploy AstrBot on CasaOS
|
||||
|
||||
## Install CasaOS
|
||||
|
||||
```bash
|
||||
curl -fsSL https://get.casaos.io | sudo bash
|
||||
```
|
||||
|
||||
## Add CasaOS-AppStore-Play App Store Source
|
||||
|
||||

|
||||
|
||||
Click `More Apps`, then enter:
|
||||
|
||||
```txt
|
||||
https://play.cuse.eu.org/Cp0204-AppStore-Play.zip
|
||||
```
|
||||
|
||||
And add it, wait for the addition to complete.
|
||||
|
||||
If your network environment is in mainland China, please search for and add `dkTurbo` first, otherwise you may not be able to pull the AstrBot image.
|
||||
|
||||

|
||||
|
||||
Enter `Astrbot` to find AstrBot.
|
||||
|
||||

|
||||
|
||||
Click the icon (not the install button), then hover over the `Install` button and click Custom Install.
|
||||
|
||||

|
||||
|
||||
In the Network section, select `host`.
|
||||
|
||||

|
||||
|
||||
Then click `Install` to start the installation.
|
||||
|
||||
After installation is complete, the AstrBot APP will appear on the main interface. Click it to open the dashboard.
|
||||
@@ -1,92 +0,0 @@
|
||||
# Deploy AstrBot from Source Code
|
||||
|
||||
> [!WARNING]
|
||||
> You are deploying this project directly from source code. This tutorial requires you to have some technical background.
|
||||
>
|
||||
> This tutorial assumes Python is already installed on your device with version `>=3.10`
|
||||
|
||||
|
||||
## Download/Clone Repository
|
||||
|
||||
If you have `git` installed on your computer, you can download the source code with the following command:
|
||||
|
||||
```bash
|
||||
git clone https://github.com/AstrBotDevs/AstrBot.git
|
||||
# The above code will pull the latest commit of the source code, if you need to pull the latest stable release version of the source code, you can use the following command:
|
||||
# git clone --depth=1 --branch $(git ls-remote --tags --sort='-v:refname' https://github.com/AstrBotDevs/AstrBot.git | head -n1 | awk -F/ '{print $3}') https://github.com/AstrBotDevs/AstrBot.git
|
||||
cd AstrBot
|
||||
```
|
||||
|
||||
If you don't have `git` installed, please download and install it first.
|
||||
|
||||
Alternatively, download the source code directly from GitHub and extract it:
|
||||
|
||||

|
||||
|
||||
## Install Dependencies and Run
|
||||
|
||||
::: details 【🥳Recommended】Use `uv` to Manage Dependencies
|
||||
|
||||
> If `uv` is not installed, please refer to [Installing uv](https://docs.astral.sh/uv/getting-started/installation/) for installation.
|
||||
|
||||
2. Execute in terminal (in the AstrBot directory)
|
||||
```bash
|
||||
uv sync
|
||||
uv run main.py
|
||||
```
|
||||
|
||||
If you have installed some plugins, it is recommended to add the `--no-sync` parameter for subsequent startups to avoid reinstalling plugin dependencies. We are working on solving this issue, so stay tuned.
|
||||
|
||||
```bash
|
||||
uv run --no-sync main.py
|
||||
```
|
||||
:::
|
||||
|
||||
::: details Install Dependencies with Python Built-in venv
|
||||
|
||||
In the AstrBot source code directory, run the following command in the terminal:
|
||||
|
||||
> If on Windows and you downloaded and extracted the source code directly, please open the extracted folder and enter in the address bar:
|
||||
> 
|
||||
|
||||
```bash
|
||||
python3 -m venv ./venv
|
||||
```
|
||||
|
||||
> It might be `python` instead of `python3`
|
||||
|
||||
The above steps will create and activate a virtual environment (to avoid disrupting your local Python environment).
|
||||
|
||||
Next, install the dependencies with the following command, which may take some time:
|
||||
|
||||
Execute on Mac/Linux/WSL:
|
||||
|
||||
```bash
|
||||
source venv/bin/activate
|
||||
python -m pip install -r requirements.txt -i https://mirrors.tuna.tsinghua.edu.cn/pypi/web/simple
|
||||
python main.py
|
||||
```
|
||||
|
||||
Execute on Windows:
|
||||
|
||||
```bash
|
||||
venv\Scripts\activate
|
||||
python -m pip install -r requirements.txt -i https://mirrors.tuna.tsinghua.edu.cn/pypi/web/simple
|
||||
python main.py
|
||||
```
|
||||
:::
|
||||
|
||||
|
||||
## 🎉 All Done!
|
||||
|
||||
If everything goes well, you will see logs printed by AstrBot.
|
||||
|
||||
If there are no errors, you will see a log message similar to `🌈 Dashboard started, accessible at` with several links. Open one of the links to access the AstrBot dashboard. The link is `http://localhost:6185`.
|
||||
|
||||
> [!TIP]
|
||||
> If you are deploying AstrBot on a server, you need to replace `localhost` with your server's IP address.
|
||||
>
|
||||
> The default username and password are `astrbot` and `astrbot`.
|
||||
|
||||
|
||||
Next, you need to deploy any messaging platform to use AstrBot on that platform.
|
||||
@@ -1,52 +0,0 @@
|
||||
# Community-Provided Deployment Methods
|
||||
|
||||
> [!WARNING]
|
||||
> AstrBot official does not guarantee the security and stability of these deployment methods.
|
||||
|
||||
## Linux One-Click Deployment Script
|
||||
|
||||
Use `curl` to download the script and execute it using `bash`:
|
||||
|
||||
```bash
|
||||
bash <(curl -sSL https://raw.githubusercontent.com/zhende1113/Antlia/refs/heads/main/Script/AstrBot/Antlia.sh)
|
||||
```
|
||||
|
||||
If your system does not have `curl`, you can use `wget`:
|
||||
|
||||
```bash
|
||||
wget -qO- https://raw.githubusercontent.com/zhende1113/Antlia/refs/heads/main/Script/AstrBot/Antlia.sh | bash
|
||||
```
|
||||
|
||||
Repository Address: [zhende1113/Antlia](https://github.com/zhende1113/Antlia/)
|
||||
|
||||
## Linux One-Click Deployment Script (Based on Docker)
|
||||
|
||||
Supports AstrBot / NapCat.
|
||||
|
||||
> [!TIP]
|
||||
> Use `sudo` for elevated permissions if you have insufficient privileges.
|
||||
|
||||
### Using `curl`
|
||||
|
||||
```bash
|
||||
curl -sSL https://raw.githubusercontent.com/railgun19457/AstrbotScript/main/AstrbotScript.sh -o AstrbotScript.sh
|
||||
chmod +x AstrbotScript.sh
|
||||
sudo ./AstrbotScript.sh
|
||||
```
|
||||
|
||||
### Using `wget`
|
||||
|
||||
```bash
|
||||
wget -qO AstrbotScript.sh https://raw.githubusercontent.com/railgun19457/AstrbotScript/main/AstrbotScript.sh
|
||||
chmod +x AstrbotScript.sh
|
||||
sudo ./AstrbotScript.sh
|
||||
```
|
||||
|
||||
> [!note]
|
||||
> `sudo ./AstrbotScript.sh --no-color (Optional: disable color output)`
|
||||
|
||||
__Repository Address: [railgun19457/AstrbotScript](https://github.com/railgun19457/AstrbotScript)__
|
||||
|
||||
## AstrBot Android Deployment
|
||||
|
||||
Refer to [zz6zz666/AstrBot-Android-App](https://github.com/zz6zz666/AstrBot-Android-App)
|
||||
@@ -1,92 +0,0 @@
|
||||
# Deploy via Compshare
|
||||
|
||||
Compshare is UCloud's GPU compute rental and LLM API platform, offering compute resources for AI, deep learning, and scientific workloads.
|
||||
|
||||
AstrBot provides an Ollama + AstrBot one-click self-deployment image on Compshare, and also supports Compshare model APIs.
|
||||
|
||||
## Use the Ollama + AstrBot One-Click Image
|
||||
|
||||
> Default image spec: RTX 3090 24GB + Intel 16-core + 64GB RAM + 200GB system disk. Billing is pay-as-you-go, so please monitor your balance.
|
||||
|
||||
1. Register a Compshare account via [this link](https://passport.compshare.cn/register?referral_code=FV7DcGowN4hB5UuXKgpE74).
|
||||
2. Open the [AstrBot image page](https://www.compshare.cn/images/0oX7xoGrzfre) and create an instance.
|
||||
3. After deployment, open `JupyterLab` from the [console](https://console.compshare.cn/light-gpu/console/resources).
|
||||
4. In JupyterLab, create a new terminal and run:
|
||||
|
||||
```bash
|
||||
cd
|
||||
./astrbot_booter.sh
|
||||
```
|
||||
|
||||
If startup succeeds, you should see output similar to:
|
||||
|
||||
```txt
|
||||
(py312) root@f8396035c96d:/workspace# cd
|
||||
./astrbot_booter.sh
|
||||
Starting AstrBot...
|
||||
Starting ollama...
|
||||
Both services started in the background.
|
||||
```
|
||||
|
||||
After startup, open `http://<instance-public-ip>:6185` in your browser to access the AstrBot dashboard.
|
||||
You can find the public IP in Console -> Basic Network (Public).
|
||||
|
||||
> It may take around 30 seconds before the page becomes reachable.
|
||||
|
||||

|
||||
|
||||
Login with username `astrbot` and password `astrbot`.
|
||||
|
||||
After logging in, you can reset your password and continue setup.
|
||||
|
||||
The instance imports `Ollama-DeepSeek-R1-32B` by default.
|
||||
|
||||
## Use Other Models
|
||||
|
||||
### Pull Models with Ollama
|
||||
|
||||
The image includes Ollama. You can pull any model and host it locally on the instance.
|
||||
|
||||
1. Choose a model from [Ollama Search](https://ollama.com/search).
|
||||
2. Connect to the instance terminal via SSH (from Compshare Console -> Instance List -> Console Command and Password).
|
||||
3. Run `ollama pull <model-name>` and wait for completion.
|
||||
4. In AstrBot Dashboard -> Providers, edit `ollama_deepseek-r1`, update the model name, and save.
|
||||
|
||||

|
||||
|
||||
### Use Compshare Model API
|
||||
|
||||
AstrBot supports direct access to model APIs provided by Compshare.
|
||||
|
||||
1. Find the model you want at [Compshare Model Center](https://console.compshare.cn/light-gpu/model-center).
|
||||
2. In AstrBot Dashboard -> Providers, click `+ Add Provider`, then choose Compshare.
|
||||
If Compshare is not listed, choose OpenAI-compatible access and set API Base URL to `https://api.modelverse.cn/v1`.
|
||||
Enter the model name in model configuration and save.
|
||||
|
||||
### Test
|
||||
|
||||
In AstrBot Dashboard, click `Chat` and run `/provider` to view and switch your active provider.
|
||||
|
||||
Then send a normal message to test whether the model works.
|
||||
|
||||

|
||||
|
||||
## Connect to Messaging Platforms
|
||||
|
||||
You can follow the latest platform integration guides in the [AstrBot Documentation](https://docs.astrbot.app/en/what-is-astrbot.html).
|
||||
Open the docs and check the left sidebar under Messaging Platforms.
|
||||
|
||||
- Lark: [Connect to Lark](https://docs.astrbot.app/en/platform/lark.html)
|
||||
- LINE: [Connect to LINE](https://docs.astrbot.app/en/platform/line.html)
|
||||
- DingTalk: [Connect to DingTalk](https://docs.astrbot.app/en/platform/dingtalk.html)
|
||||
- WeCom: [Connect to WeCom](https://docs.astrbot.app/en/platform/wecom.html)
|
||||
- WeChat Official Account: [Connect to WeChat Official Account](https://docs.astrbot.app/en/platform/weixin-official-account.html)
|
||||
- QQ Official Bot: [Connect to QQ Official API](https://docs.astrbot.app/en/platform/qqofficial/webhook.html)
|
||||
- KOOK: [Connect to KOOK](https://docs.astrbot.app/en/platform/kook.html)
|
||||
- Slack: [Connect to Slack](https://docs.astrbot.app/en/platform/slack.html)
|
||||
- Discord: [Connect to Discord](https://docs.astrbot.app/en/platform/discord.html)
|
||||
- More methods: [AstrBot Documentation](https://docs.astrbot.app/en/what-is-astrbot.html)
|
||||
|
||||
## More Features
|
||||
|
||||
For more capabilities, see the [AstrBot Documentation](https://docs.astrbot.app/en/what-is-astrbot.html).
|
||||
@@ -1,91 +0,0 @@
|
||||
# Deploy AstrBot with Docker
|
||||
|
||||
> [!WARNING]
|
||||
> Docker provides a convenient way to deploy AstrBot on Windows, Mac, and Linux.
|
||||
>
|
||||
> This tutorial assumes you have Docker installed in your environment. If not, please refer to the [Docker official documentation](https://docs.docker.com/get-docker/) for installation.
|
||||
|
||||
## Deploy with Docker Compose
|
||||
|
||||
::: details Deploy AstrBot Only (General Method)
|
||||
|
||||
First, clone the AstrBot repository to your local machine:
|
||||
|
||||
```bash
|
||||
git clone https://github.com/AstrBotDevs/AstrBot
|
||||
cd AstrBot
|
||||
```
|
||||
|
||||
Then, run Compose:
|
||||
|
||||
```bash
|
||||
sudo docker compose up -d
|
||||
```
|
||||
|
||||
> [!TIP]
|
||||
> If your network environment is in mainland China, the above command will not pull properly. You may need to modify the compose.yml file and replace `image: soulter/astrbot:latest` with `image: m.daocloud.io/docker.io/soulter/astrbot:latest`.
|
||||
:::
|
||||
|
||||
::: details Deploy with Agent Sandbox Environment
|
||||
|
||||
Supports native Python code execution, Shell code execution, and other features.
|
||||
|
||||
Deployment method:
|
||||
|
||||
```bash
|
||||
git clone https://github.com/AstrBotDevs/AstrBot
|
||||
cd AstrBot
|
||||
# Modify the environment variable configuration in the compose-with-shipyard.yml file, such as Shipyard's access token, etc.
|
||||
docker compose -f compose-with-shipyard.yml up -d
|
||||
docker pull soulter/shipyard-ship:latest
|
||||
```
|
||||
|
||||
For configuration and usage details, see the [Agent Sandbox Environment](/en/use/astrbot-agent-sandbox.md) documentation.
|
||||
:::
|
||||
|
||||
|
||||
## Deploy with Docker
|
||||
|
||||
```bash
|
||||
mkdir astrbot
|
||||
cd astrbot
|
||||
sudo docker run -itd -p 6185:6185 -p 6199:6199 -v $PWD/data:/AstrBot/data -v /etc/localtime:/etc/localtime:ro -v /etc/timezone:/etc/timezone:ro --name astrbot soulter/astrbot:latest
|
||||
```
|
||||
|
||||
> [!TIP]
|
||||
> If your network environment is in mainland China, the above command will not pull properly. Please use the following command to pull the image:
|
||||
>
|
||||
> ```bash
|
||||
> sudo docker run -itd -p 6185:6185 -p 6199:6199 -v $PWD/data:/AstrBot/data -v /etc/localtime:/etc/localtime:ro -v /etc/timezone:/etc/timezone:ro --name astrbot m.daocloud.io/docker.io/soulter/astrbot:latest
|
||||
> ```
|
||||
>
|
||||
> (Thanks to DaoCloud ❤️)
|
||||
|
||||
> No need to add sudo on Windows, same below
|
||||
> Sync Host Time on Windows (requires WSL2)
|
||||
|
||||
```
|
||||
-v \\wsl.localhost\(your-wsl-os)\etc\timezone:/etc/timezone:ro
|
||||
-v \\wsl.localhost\(your-wsl-os)\etc\localtime:/etc/localtime:ro
|
||||
```
|
||||
|
||||
View AstrBot logs with the following command:
|
||||
|
||||
```bash
|
||||
sudo docker logs -f astrbot
|
||||
```
|
||||
|
||||
## 🎉 All Done
|
||||
|
||||
If everything goes well, you will see logs printed by AstrBot.
|
||||
|
||||
If there are no errors, you will see a log message similar to `🌈 Dashboard started, accessible at` with several links. Open one of the links to access the AstrBot dashboard.
|
||||
|
||||
> [!TIP]
|
||||
> Since Docker isolates the network environment, you cannot use `localhost` to access the dashboard.
|
||||
>
|
||||
> The default username and password are `astrbot` and `astrbot`.
|
||||
>
|
||||
> If deployed on a cloud server, you need to open ports `6180-6200` and `11451` in the cloud provider's console.
|
||||
|
||||
Next, you need to deploy any messaging platform to use AstrBot on that platform.
|
||||
@@ -1,197 +0,0 @@
|
||||
# Deploy AstrBot with Kubernetes
|
||||
|
||||
> [!WARNING]
|
||||
> You can deploy AstrBot in a high-availability setup using Kubernetes (K8s), allowing it to automatically recover from failures.
|
||||
>
|
||||
> Due to the current use of an SQLite database, this deployment does not support horizontal scaling with multiple replicas. Additionally, if using the Sidecar mode, pay special attention to the persistence of NapCat's login state.
|
||||
>
|
||||
> The following tutorial assumes that you have `kubectl` installed and configured, and that you can connect to your K8s cluster.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
Before you begin, make sure your Kubernetes cluster meets the following conditions:
|
||||
|
||||
1. **Default StorageClass**: Used to dynamically create `PersistentVolumeClaim` (PVC). You can check this with `kubectl get sc`. If you don't have one, you need to manually create a `PersistentVolume` (PV) or install a corresponding storage plugin (e.g., `nfs-client-provisioner`).
|
||||
2. **Network Access**: Ensure that your cluster nodes can pull images from `docker.io` or your specified image repository.
|
||||
|
||||
## Deployment Methods
|
||||
|
||||
We offer two deployment options:
|
||||
|
||||
* **Integrated Deployment (Sidecar Mode)**: Deploy AstrBot and NapCat in the same Pod. Recommended for personal QQ accounts.
|
||||
* **Standalone Deployment**: Deploy only AstrBot. Suitable for other platforms or if you want to manage NapCat independently.
|
||||
|
||||
---
|
||||
|
||||
### Method 1: Deploy with NapCatQQ (Sidecar)
|
||||
|
||||
This method is located in the `k8s/astrbot_with_napcat` directory.
|
||||
|
||||
#### 1. Deploy
|
||||
|
||||
```bash
|
||||
# 1. Create namespace
|
||||
kubectl apply -f k8s/astrbot_with_napcat/00-namespace.yaml
|
||||
|
||||
# 2. Create Persistent Volume Claim
|
||||
# Note: astrbot-data-shared-pvc requires ReadWriteMany (RWX) access mode.
|
||||
# If your cluster does not support RWX, you need to configure shared storage such as NFS and modify the storageClassName in 01-pvc.yaml.
|
||||
kubectl apply -f k8s/astrbot_with_napcat/01-pvc.yaml
|
||||
|
||||
# 3. Deploy the application
|
||||
kubectl apply -f k8s/astrbot_with_napcat/02-deployment.yaml
|
||||
```
|
||||
|
||||
#### 2. Expose Service (Choose one)
|
||||
|
||||
* **Option A: NodePort**
|
||||
|
||||
```bash
|
||||
kubectl apply -f k8s/astrbot_with_napcat/03-service-nodeport.yaml
|
||||
```
|
||||
|
||||
The service will be exposed via the node IP and a port automatically assigned by Kubernetes. You can find the port with the following command:
|
||||
|
||||
```bash
|
||||
kubectl get svc -n astrbot-ns
|
||||
```
|
||||
|
||||
In the output, find the `PORT(S)` column for `astrbot-webui-svc` and `napcat-web-svc`. The format is `<internal-port>:<NodePort>/TCP`. For example, if you see `8080:30185/TCP`, the access address is `http://<NodeIP>:30185`.
|
||||
|
||||
* **Option B: LoadBalancer**
|
||||
|
||||
If your cluster supports `LoadBalancer` type services (usually provided in K8s services from cloud providers), you can use this method.
|
||||
|
||||
```bash
|
||||
kubectl apply -f k8s/astrbot_with_napcat/04-service-loadbalancer.yaml
|
||||
```
|
||||
|
||||
After execution, check the assigned external IP (EXTERNAL-IP) with `kubectl get svc -n astrbot-ns`.
|
||||
|
||||
#### 3. Configure Connection
|
||||
|
||||
Since AstrBot and NapCat are in the same Pod, they can communicate directly via `localhost`.
|
||||
|
||||
1. **Add a message platform in AstrBot:**
|
||||
* Go to the AstrBot WebUI, select `Settings` -> `Message Platform` -> `Add`.
|
||||
* **Select Message Platform Category**: `aiocqhttp`
|
||||
* **Bot Name**: `napcat` (or custom)
|
||||
* **Reverse Websocket Host**: `0.0.0.0`
|
||||
* **Reverse Websocket Port**: `6199`
|
||||
* Save the configuration.
|
||||
|
||||
|
||||
2. **Configure Websocket Client in NapCat:**
|
||||
* Go to the NapCat WebUI, select `Settings` -> `Reverse WS` -> `Add`.
|
||||
* **Enable**: On
|
||||
* **URL**: `ws://localhost:6199/ws`
|
||||
* **Message Format**: `Array`
|
||||
* Save the configuration.
|
||||
|
||||
|
||||
---
|
||||
|
||||
### Method 2: Deploy AstrBot Only (General Purpose)
|
||||
|
||||
This method is located in the `k8s/astrbot` directory.
|
||||
|
||||
#### 1. Deploy
|
||||
|
||||
```bash
|
||||
# 1. Create namespace
|
||||
kubectl apply -f k8s/astrbot/00-namespace.yaml
|
||||
|
||||
# 2. Create Persistent Volume Claim
|
||||
kubectl apply -f k8s/astrbot/01-pvc.yaml
|
||||
|
||||
# 3. Deploy the application
|
||||
kubectl apply -f k8s/astrbot/02-deployment.yaml
|
||||
```
|
||||
|
||||
#### 2. Expose Service (Choose one)
|
||||
|
||||
* **Option A: NodePort**
|
||||
|
||||
```bash
|
||||
kubectl apply -f k8s/astrbot/03-service-nodeport.yaml
|
||||
```
|
||||
|
||||
The service will be exposed via the node IP and a port automatically assigned by Kubernetes. You can find the port with the following command:
|
||||
|
||||
```bash
|
||||
kubectl get svc -n astrbot-standalone-ns
|
||||
```
|
||||
|
||||
In the output, find the `PORT(S)` column for `astrbot-webui-svc`. The format is `<internal-port>:<NodePort>/TCP`. For example, if you see `8080:30185/TCP`, the access address is `http://<NodeIP>:30185`.
|
||||
|
||||
* **Option B: LoadBalancer**
|
||||
|
||||
```bash
|
||||
kubectl apply -f k8s/astrbot/04-service-loadbalancer.yaml
|
||||
```
|
||||
|
||||
After execution, check the assigned external IP (EXTERNAL-IP) with `kubectl get svc -n astrbot-standalone-ns`.
|
||||
|
||||
---
|
||||
|
||||
## Advanced Configuration
|
||||
|
||||
### Image Mirror (for users in mainland China)
|
||||
|
||||
If you have difficulty pulling the `soulter/astrbot:latest` or `mlikiowa/napcat-docker:latest` images, you can manually edit the corresponding `02-deployment.yaml` file and replace the `image` field with a domestic mirror address, for example:
|
||||
|
||||
```yaml
|
||||
# Example:
|
||||
# image: soulter/astrbot:latest
|
||||
# Replace with:
|
||||
image: m.daocloud.io/docker.io/soulter/astrbot:latest
|
||||
```
|
||||
|
||||
### Enable Docker Sandbox Code Executor
|
||||
|
||||
If you need to use the sandbox code executor, you need to mount the Docker socket file into the Pod.
|
||||
|
||||
Edit the `02-deployment.yaml` file and add `volumes` and `volumeMounts` under `spec.template.spec`:
|
||||
|
||||
1. **Add the following to the `volumeMounts` list of the `astrbot` container:**
|
||||
|
||||
```yaml
|
||||
- name: docker-sock
|
||||
mountPath: /var/run/docker.sock
|
||||
```
|
||||
|
||||
2. **Add the following to the `spec.template.spec.volumes` list:**
|
||||
|
||||
```yaml
|
||||
- name: docker-sock
|
||||
hostPath:
|
||||
path: /var/run/docker.sock
|
||||
type: Socket
|
||||
```
|
||||
|
||||
> [!WARNING]
|
||||
> Mounting the Docker socket into a Pod poses a security risk. Please ensure you understand the implications.
|
||||
|
||||
## View Logs
|
||||
|
||||
* **Sidecar Deployment Mode:**
|
||||
|
||||
```bash
|
||||
# View AstrBot logs
|
||||
kubectl logs -f -n astrbot-ns deployment/astrbot-stack -c astrbot
|
||||
|
||||
# View NapCat logs
|
||||
kubectl logs -f -n astrbot-ns deployment/astrbot-stack -c napcat
|
||||
```
|
||||
|
||||
* **Standalone Deployment Mode:**
|
||||
|
||||
```bash
|
||||
kubectl logs -f -n astrbot-standalone-ns deployment/astrbot-standalone
|
||||
```
|
||||
|
||||
## 🎉 All Done!
|
||||
|
||||
After deploying and exposing the service, you can access the AstrBot admin panel through the corresponding IP and port.
|
||||
|
||||
> The default username and password are `astrbot` and `astrbot`.
|
||||
@@ -1,98 +0,0 @@
|
||||
# Deploy AstrBot with AstrBot Launcher
|
||||
|
||||
## Recommended Method 1: AstrBot One-Click Launcher
|
||||
|
||||
AstrBot One-Click Launcher supports Windows, macOS, and Linux.
|
||||
|
||||
0. Open [AstrBotDevs/astrbot-launcher](https://github.com/AstrBotDevs/astrbot-launcher)
|
||||
1. **Optional but recommended**: give this project a [**Star ⭐**](https://github.com/AstrBotDevs/astrbot-launcher). Your support helps maintainers keep improving it.
|
||||
2. Find **Releases** on the right, open the latest release, then download the installer for your system from **Assets**.
|
||||
|
||||
For example:
|
||||
|
||||
- Windows x86 users: `AstrBot.Launcher_0.2.1_x64-setup.exe`
|
||||
- Windows on Arm users: `AstrBot.Launcher_0.2.1_arm64-setup.exe`
|
||||
- macOS Apple Silicon users: `AstrBot.Launcher_0.2.1_aarch64.dmg`
|
||||
|
||||
For macOS users, if you see "damaged and can't be opened", it is caused by macOS security restrictions on unsigned apps. Fix it with:
|
||||
|
||||
1. Open Terminal.
|
||||
2. Run:
|
||||
`xattr -dr com.apple.quarantine /Applications/AstrBot\ Launcher.app`
|
||||
3. Reopen AstrBot Launcher.
|
||||
|
||||
## Method 2: Legacy Windows Installer
|
||||
|
||||
We still recommend the One-Click Launcher above because it is simpler, more automated, and better for most users.
|
||||
|
||||
The legacy installer is a `PowerShell` script, very small (<20KB). It requires `PowerShell` (usually built in on `Windows 10` and newer).
|
||||
|
||||
> [!WARNING]
|
||||
> `Python 3.10` or later must be installed, and environment variables must be configured.
|
||||
|
||||
> [!TIP]
|
||||
> If deployment fails, try Docker deployment or manual deployment instead.
|
||||
|
||||
## Download the Legacy Installer
|
||||
|
||||
Open <https://github.com/AstrBotDevs/AstrBotLauncher/releases/latest>
|
||||
|
||||
Download `Source code (zip)` and extract it.
|
||||
|
||||
## Run the Legacy Installer
|
||||
|
||||
> The video may be outdated. Follow the steps here.
|
||||
|
||||
After extraction, open the folder.
|
||||
|
||||
Type `PowerShell` in the address bar and press Enter:
|
||||
|
||||

|
||||
|
||||
Drag `launcher_astrbot_en.bat` into the PowerShell window and press Enter.
|
||||
|
||||
> [!WARNING]
|
||||
> - The script is safe. If you see `Windows protected your PC`, click `More info` and then `Run anyway`.
|
||||
> - By default, it uses `python`. If you want to specify another interpreter path/command, edit `launcher_astrbot_en.bat`, find `set PYTHON_CMD=python`, and replace `python` with your own command/path.
|
||||
|
||||
If Python is not detected, the script exits with a prompt.
|
||||
|
||||
The script checks whether an `AstrBot` folder exists. If not, it downloads the latest AstrBot source from [GitHub](https://github.com/AstrBotDevs/AstrBot/releases/latest), installs dependencies, and runs it automatically.
|
||||
|
||||
## Done
|
||||
|
||||
If everything works, you will see AstrBot logs.
|
||||
|
||||
Without errors, you should see a log like `🌈 Management panel started, accessible at` with several URLs. Open one URL to access AstrBot WebUI.
|
||||
|
||||
> [!TIP]
|
||||
> Default username and password: `astrbot` / `astrbot`.
|
||||
>
|
||||
> If WebUI returns 404:
|
||||
> Download `dist.zip` from [release](https://github.com/AstrBotDevs/AstrBot/releases), extract it into `AstrBot/data`, then restart the computer if needed.
|
||||
|
||||
Then deploy at least one messaging platform adapter to start using AstrBot in IM apps.
|
||||
|
||||
## Error: Python is not installed
|
||||
|
||||
If you still get this error after installing Python and restarting, your PATH is likely incorrect.
|
||||
|
||||
**Method 1**
|
||||
|
||||
Search for Python in Windows and open its file location:
|
||||
|
||||

|
||||
|
||||
Right-click the shortcut below and open file location:
|
||||
|
||||

|
||||
|
||||
Copy the file path:
|
||||
|
||||

|
||||
|
||||
Edit `launcher_astrbot_en.bat` in Notepad, find `set PYTHON_CMD=python`, and replace `python` with your interpreter command/path. Keep quotes if your path contains spaces.
|
||||
|
||||
**Method 2**
|
||||
|
||||
Reinstall Python, check `Add Python to PATH` during installation, then restart your computer.
|
||||
@@ -1,5 +0,0 @@
|
||||
# Other Deployments
|
||||
|
||||
- [CasaOS Deployment](./casaos.md)
|
||||
- [Compshare GPU Deployment](./compshare.md)
|
||||
- [Community Deployments](./community-deployment.md)
|
||||
@@ -1,17 +0,0 @@
|
||||
# Package Manager Deployment (uv)
|
||||
|
||||
Use `uv` to install and run AstrBot quickly.
|
||||
|
||||
## Before You Start
|
||||
|
||||
If `uv` is not installed, install it first by following the official guide:
|
||||
<https://docs.astral.sh/uv/>
|
||||
|
||||
`uv` supports Linux, Windows, and macOS.
|
||||
|
||||
## Install and Start
|
||||
|
||||
```bash
|
||||
uv tool install astrbot
|
||||
astrbot
|
||||
```
|
||||
@@ -1,41 +0,0 @@
|
||||
# Installation via System Package Manager
|
||||
|
||||
> [!WARNING]
|
||||
> Currently, only the AUR version is provided.
|
||||
> If you are a Windows/macOS user, it is recommended to install via `uv`.
|
||||
> If you are a Linux user, it is highly recommended to install via a package manager.
|
||||
|
||||
# Preparation
|
||||
|
||||
## What is AUR?
|
||||
AUR (Arch User Repository) allows users to install software from community-maintained software repositories. AUR packages are typically maintained by community members rather than official maintainers.
|
||||
Common AUR helpers include `yay` and `paru`.
|
||||
The following tutorial uses `paru` as an example; `yay` works similarly, just replace `paru` with `yay`.
|
||||
|
||||
# Installation Process
|
||||
|
||||
## AUR
|
||||
```bash
|
||||
paru -S astrbot-git
|
||||
# Note:
|
||||
# The review step will begin; press 'q' to exit review and continue installation.
|
||||
# After installation, the data directory is fixed at: ~/.local/share/astrbot
|
||||
```
|
||||
|
||||
# Starting
|
||||
>[!TIP]
|
||||
> You can directly use `astrbot init` (for the first run) to initialize.
|
||||
> Use `astrbot run` to run the bot.
|
||||
> However, it is highly recommended to use `systemctl` for starting, as it provides features like automatic restart and log rotation.
|
||||
|
||||
```bash
|
||||
systemctl --user start astrbot.service
|
||||
```
|
||||
|
||||
# Auto-start on Boot
|
||||
```bash
|
||||
# For security reasons, it is designed to run as a user.
|
||||
systemctl --user enable astrbot.service
|
||||
# If you need to start it immediately, add --now
|
||||
# systemctl --user enable --now astrbot.service
|
||||
```
|
||||
@@ -1,16 +0,0 @@
|
||||
# Preface
|
||||
|
||||
After successful deployment... of course, don't forget to give [AstrBot](https://github.com/AstrBotDevs/AstrBot) a Star!
|
||||
|
||||
AstrBot Main Repository: [](https://wakatime.com/badge/user/915e5316-99c6-4563-a483-ef186cf000c9/project/018e705a-a1a7-409a-a849-3013485e6c8e)
|
||||
|
||||
AstrBot Dashboard: [](https://wakatime.com/badge/user/915e5316-99c6-4563-a483-ef186cf000c9/project/018c440f-c177-45f8-8224-292cdf5926f3)
|
||||
|
||||
AstrBot Documentation: [](https://wakatime.com/badge/user/915e5316-99c6-4563-a483-ef186cf000c9/project/018c9619-e195-4b94-bd7b-2ca61679145b)
|
||||
|
||||
❤️ Contributions to this project are warmly welcomed, including Issues and Pull Requests.
|
||||
|
||||
## Next...
|
||||
|
||||
If you're reading this, it means you have successfully deployed the messaging platform and sent/received your first command. Next, you can configure large language models or add plugins. Please refer to the `Configuration - Integrating LLM Services` section.
|
||||
|
||||
@@ -1,557 +0,0 @@
|
||||
---
|
||||
outline: deep
|
||||
---
|
||||
|
||||
# AstrBot Configuration File
|
||||
|
||||
## data/cmd_config.json
|
||||
|
||||
AstrBot's configuration file is a JSON format file. AstrBot reads this file at startup and initializes based on the settings within. Its path is `data/cmd_config.json`.
|
||||
|
||||
> Since AstrBot v4.0.0, we introduced the concept of [multiple configuration files](https://blog.astrbot.app/posts/what-is-changed-in-4.0.0/#%E5%A4%9A%E9%85%8D%E7%BD%AE%E6%96%87%E4%BB%B6). `data/cmd_config.json` serves as the default configuration `default`. Other configuration files you create in the WebUI are stored in the `data/config/` directory, starting with `abconf_`.
|
||||
|
||||
The default AstrBot configuration is as follows:
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"config_version": 2,
|
||||
"platform_settings": {
|
||||
"unique_session": False,
|
||||
"rate_limit": {
|
||||
"time": 60,
|
||||
"count": 30,
|
||||
"strategy": "stall", # stall, discard
|
||||
},
|
||||
"reply_prefix": "",
|
||||
"forward_threshold": 1500,
|
||||
"enable_id_white_list": True,
|
||||
"id_whitelist": [],
|
||||
"id_whitelist_log": True,
|
||||
"wl_ignore_admin_on_group": True,
|
||||
"wl_ignore_admin_on_friend": True,
|
||||
"reply_with_mention": False,
|
||||
"reply_with_quote": False,
|
||||
"path_mapping": [],
|
||||
"segmented_reply": {
|
||||
"enable": False,
|
||||
"only_llm_result": True,
|
||||
"interval_method": "random",
|
||||
"interval": "1.5,3.5",
|
||||
"log_base": 2.6,
|
||||
"words_count_threshold": 150,
|
||||
"regex": ".*?[。?!~…]+|.+$",
|
||||
"content_cleanup_rule": "",
|
||||
},
|
||||
"no_permission_reply": True,
|
||||
"empty_mention_waiting": True,
|
||||
"empty_mention_waiting_need_reply": True,
|
||||
"friend_message_needs_wake_prefix": False,
|
||||
"ignore_bot_self_message": False,
|
||||
"ignore_at_all": False,
|
||||
},
|
||||
"provider": [],
|
||||
"provider_settings": {
|
||||
"enable": True,
|
||||
"default_provider_id": "",
|
||||
"default_image_caption_provider_id": "",
|
||||
"image_caption_prompt": "Please describe the image using Chinese.",
|
||||
"provider_pool": ["*"], # "*" means use all available providers
|
||||
"wake_prefix": "",
|
||||
"web_search": False,
|
||||
"websearch_provider": "default",
|
||||
"websearch_tavily_key": [],
|
||||
"web_search_link": False,
|
||||
"display_reasoning_text": False,
|
||||
"identifier": False,
|
||||
"group_name_display": False,
|
||||
"datetime_system_prompt": True,
|
||||
"default_personality": "default",
|
||||
"persona_pool": ["*"],
|
||||
"prompt_prefix": "{{prompt}}",
|
||||
"max_context_length": -1,
|
||||
"dequeue_context_length": 1,
|
||||
"streaming_response": False,
|
||||
"show_tool_use_status": False,
|
||||
"streaming_segmented": False,
|
||||
"max_agent_step": 30,
|
||||
"tool_call_timeout": 60,
|
||||
},
|
||||
"provider_stt_settings": {
|
||||
"enable": False,
|
||||
"provider_id": "",
|
||||
},
|
||||
"provider_tts_settings": {
|
||||
"enable": False,
|
||||
"provider_id": "",
|
||||
"dual_output": False,
|
||||
"use_file_service": False,
|
||||
},
|
||||
"provider_ltm_settings": {
|
||||
"group_icl_enable": False,
|
||||
"group_message_max_cnt": 300,
|
||||
"image_caption": False,
|
||||
"active_reply": {
|
||||
"enable": False,
|
||||
"method": "possibility_reply",
|
||||
"possibility_reply": 0.1,
|
||||
"whitelist": [],
|
||||
},
|
||||
},
|
||||
"content_safety": {
|
||||
"also_use_in_response": False,
|
||||
"internal_keywords": {"enable": True, "extra_keywords": []},
|
||||
"baidu_aip": {"enable": False, "app_id": "", "api_key": "", "secret_key": ""},
|
||||
},
|
||||
"admins_id": ["astrbot"],
|
||||
"t2i": False,
|
||||
"t2i_word_threshold": 150,
|
||||
"t2i_strategy": "remote",
|
||||
"t2i_endpoint": "",
|
||||
"t2i_use_file_service": False,
|
||||
"t2i_active_template": "base",
|
||||
"http_proxy": "",
|
||||
"no_proxy": ["localhost", "127.0.0.1", "::1"],
|
||||
"dashboard": {
|
||||
"enable": True,
|
||||
"username": "astrbot",
|
||||
"password": "77b90590a8945a7d36c963981a307dc9",
|
||||
"jwt_secret": "",
|
||||
"host": "0.0.0.0",
|
||||
"port": 6185,
|
||||
},
|
||||
"platform": [],
|
||||
"platform_specific": {
|
||||
# Platform-specific settings: categorized by platform, then by feature group
|
||||
"lark": {
|
||||
"pre_ack_emoji": {"enable": False, "emojis": ["Typing"]},
|
||||
},
|
||||
"telegram": {
|
||||
"pre_ack_emoji": {"enable": False, "emojis": ["✍️"]},
|
||||
},
|
||||
},
|
||||
"wake_prefix": ["/"],
|
||||
"log_level": "INFO",
|
||||
"trace_enable": False,
|
||||
"pip_install_arg": "",
|
||||
"pypi_index_url": "https://mirrors.aliyun.com/pypi/simple/",
|
||||
"persona": [], # deprecated
|
||||
"timezone": "Asia/Shanghai",
|
||||
"callback_api_base": "",
|
||||
"default_kb_collection": "", # Default knowledge base name
|
||||
"plugin_set": ["*"], # "*" means use all available plugins, empty list means none
|
||||
}
|
||||
```
|
||||
|
||||
## Field Details
|
||||
|
||||
### `config_version`
|
||||
|
||||
Configuration version, do not modify.
|
||||
|
||||
### `platform_settings`
|
||||
|
||||
General settings for message platform adapters.
|
||||
|
||||
#### `platform_settings.unique_session`
|
||||
|
||||
Whether to enable session isolation. Default is `false`. When enabled, each person's conversation context in groups or channels is independent.
|
||||
|
||||
#### `platform_settings.rate_limit`
|
||||
|
||||
Strategy when message rate exceeds limits. `time` is the window, `count` is the number of messages, and `strategy` is the limit strategy. `stall` means wait, `discard` means drop.
|
||||
|
||||
#### `platform_settings.reply_prefix`
|
||||
|
||||
Fixed prefix string when replying to messages. Default is empty.
|
||||
|
||||
#### `platform_settings.forward_threshold`
|
||||
|
||||
> Currently only applicable to the QQ platform adapter.
|
||||
|
||||
Message forwarding threshold. When the reply content exceeds a certain number of characters, the bot will fold the message into a QQ group "forwarded message" to prevent spamming.
|
||||
|
||||
#### `platform_settings.enable_id_white_list`
|
||||
|
||||
Whether to enable the ID whitelist. Default is `true`. When enabled, only messages from IDs in the whitelist will be processed.
|
||||
|
||||
#### `platform_settings.id_whitelist`
|
||||
|
||||
ID whitelist. If filled, only message events from the specified IDs will be processed. Empty means the whitelist filter is not enabled. You can use the `/sid` command to get the session ID on a platform.
|
||||
|
||||
Session IDs can also be found in AstrBot logs; when a message fails the whitelist, an INFO level log is output, e.g., `aiocqhttp:GroupMessage:547540978`.
|
||||
|
||||
#### `platform_settings.id_whitelist_log`
|
||||
|
||||
Whether to print logs for messages that fail the ID whitelist. Default is `true`.
|
||||
|
||||
#### `platform_settings.wl_ignore_admin_on_group` & `platform_settings.wl_ignore_admin_on_friend`
|
||||
|
||||
- `wl_ignore_admin_on_group`: Whether group messages from admins bypass the ID whitelist. Default is `true`.
|
||||
|
||||
- `wl_ignore_admin_on_friend`: Whether private messages from admins bypass the ID whitelist. Default is `true`.
|
||||
|
||||
#### `platform_settings.reply_with_mention`
|
||||
|
||||
Whether to @ mention the user when replying. Default is `false`.
|
||||
|
||||
#### `platform_settings.reply_with_quote`
|
||||
|
||||
Whether to quote the user's message when replying. Default is `false`.
|
||||
|
||||
#### `platform_settings.path_mapping`
|
||||
|
||||
*This configuration item has been deprecated since v4.0.0.*
|
||||
|
||||
List of path mappings. Used to replace file paths in messages. Each mapping item contains `from` and `to` fields, indicating that `from` in the message path is replaced with `to`.
|
||||
|
||||
#### `platform_settings.segmented_reply`
|
||||
|
||||
Segmented reply settings.
|
||||
|
||||
- `enable`: Whether to enable segmented replies. Default is `false`.
|
||||
- `only_llm_result`: Whether to only segment replies generated by the LLM. Default is `true`.
|
||||
- `interval_method`: Method for segmentation intervals. Options are `random` and `log`. Default is `random`.
|
||||
- `interval`: Interval time for segmentation. For `random`, fill in two comma-separated numbers representing min and max intervals (seconds). For `log`, fill in one number representing the log base. Default is `"1.5,3.5"`.
|
||||
- `log_base`: Log base, only applicable when `interval_method` is `log`. Default is `2.6`.
|
||||
- `words_count_threshold`: Character limit for segmented replies. Only messages shorter than this value will be segmented; longer messages will be sent directly (unsegmented). Default is `150`.
|
||||
- `regex`: Used to split a message. By default, it splits based on punctuation like periods and question marks. `re.findall(r'<regex>', text)`. Default is `".*?[。?!~…]+|.+$"`.
|
||||
- `content_cleanup_rule`: Removes specified content from segments. Supports regex. For example, `[。?!]` will remove all periods, question marks, and exclamation points. `re.sub(r'<regex>', '', text)`.
|
||||
|
||||
#### `platform_settings.no_permission_reply`
|
||||
|
||||
Whether to reply with a "no permission" prompt when a user lacks authority. Default is `true`.
|
||||
|
||||
#### `platform_settings.empty_mention_waiting`
|
||||
|
||||
Whether to enable the empty @ waiting mechanism. Default is `true`. When enabled, if a user sends a message containing only an @ mention of the bot, the bot waits for the user to send the next message within 60 seconds and merges the two for processing. This is particularly useful on platforms that don't support sending @ and voice/images simultaneously.
|
||||
|
||||
#### `platform_settings.empty_mention_waiting_need_reply`
|
||||
|
||||
In the above item (`empty_mention_waiting`), if waiting is triggered, enabling this will make the bot immediately generate an LLM reply. Otherwise, it just waits without replying. Default is `true`.
|
||||
|
||||
#### `platform_settings.friend_message_needs_wake_prefix`
|
||||
|
||||
Whether private messages on platforms require a wake prefix. Default is `false`. When enabled, users must use a wake prefix to trigger a bot response in private chats.
|
||||
|
||||
#### `platform_settings.ignore_bot_self_message`
|
||||
|
||||
Whether to ignore messages sent by the bot itself. Default is `false`. When enabled, the bot won't process its own messages, preventing infinite loops on some platforms.
|
||||
|
||||
#### `platform_settings.ignore_at_all`
|
||||
|
||||
Whether to ignore @all messages. Default is `false`. When enabled, the bot won't respond to messages containing @all.
|
||||
|
||||
### `provider`
|
||||
|
||||
> This item only takes effect in `data/cmd_config.json`; AstrBot does not read this from configuration files in the `data/config/` directory.
|
||||
|
||||
List of configured model service provider settings.
|
||||
|
||||
### `provider_settings`
|
||||
|
||||
General settings for LLM providers.
|
||||
|
||||
#### `provider_settings.enable`
|
||||
|
||||
Whether to enable LLM chat. Default is `true`.
|
||||
|
||||
#### `provider_settings.default_provider_id`
|
||||
|
||||
Default conversation model provider ID. Must be a provider ID already configured in the `provider` list. If empty, the first provider in the list is used.
|
||||
|
||||
#### `provider_settings.default_image_caption_provider_id`
|
||||
|
||||
Default image captioning model provider ID. Must be a provider ID already configured in the `provider` list. If empty, image captioning is disabled.
|
||||
|
||||
This means when a user sends an image, AstrBot uses this provider to generate a text description, which is then used as part of the conversation context. This is useful when the conversation model doesn't support multimodal input.
|
||||
|
||||
#### `provider_settings.image_caption_prompt`
|
||||
|
||||
Prompt template for image captioning. Default is `"Please describe the image using Chinese."`.
|
||||
|
||||
#### `provider_settings.provider_pool`
|
||||
|
||||
*This configuration item is not yet in actual use.*
|
||||
|
||||
#### `provider_settings.wake_prefix`
|
||||
|
||||
Extra trigger condition for LLM chat. For example, if `chat` is filled, messages must start with `/chat` to trigger LLM chat, where `/` is the bot's wake prefix. This is a measure to prevent abuse.
|
||||
|
||||
#### `provider_settings.web_search`
|
||||
|
||||
Whether to enable AstrBot's built-in web search capability. Default is `false`. When enabled, the LLM may automatically search the web and answer based on the content.
|
||||
|
||||
#### `provider_settings.websearch_provider`
|
||||
|
||||
Web search provider type. Default is `default`. Currently supports `default` and `tavily`.
|
||||
|
||||
- `default`: Works best when Google is accessible. If Google fails, it tries Bing and Sogou in order.
|
||||
|
||||
- `tavily`: Uses the Tavily search engine.
|
||||
|
||||
#### `provider_settings.websearch_tavily_key`
|
||||
|
||||
API Key list for the Tavily search engine. Required when using `tavily` as the web search provider.
|
||||
|
||||
#### `provider_settings.web_search_link`
|
||||
|
||||
Whether to prompt the model to include links to search results in the reply. Default is `false`.
|
||||
|
||||
#### `provider_settings.display_reasoning_text`
|
||||
|
||||
Whether to display the model's reasoning process in the reply. Default is `false`.
|
||||
|
||||
#### `provider_settings.identifier`
|
||||
|
||||
Whether to prepend the group member's name to the prompt so the model better understands the group chat state. Default is `false`. Enabling this slightly increases token usage.
|
||||
|
||||
#### `provider_settings.group_name_display`
|
||||
|
||||
Whether to let the model know the name of the group it's in. Default is `false`. This currently only takes effect in the QQ platform adapter.
|
||||
|
||||
#### `provider_settings.datetime_system_prompt`
|
||||
|
||||
Whether to include the current machine date and time in the system prompt. Default is `true`.
|
||||
|
||||
#### `provider_settings.default_personality`
|
||||
|
||||
ID of the default personality to use. Configure personalities in the WebUI.
|
||||
|
||||
#### `provider_settings.persona_pool`
|
||||
|
||||
*This configuration item is not yet in actual use.*
|
||||
|
||||
#### `provider_settings.prompt_prefix`
|
||||
|
||||
User prompt. You can use `{{prompt}}` as a placeholder for user input. If no placeholder is provided, it's prepended to the user input.
|
||||
|
||||
#### `provider_settings.max_context_length`
|
||||
|
||||
When the conversation context exceeds this number, the oldest parts are discarded. One round of chat counts as 1. -1 means no limit.
|
||||
|
||||
#### `provider_settings.dequeue_context_length`
|
||||
|
||||
The number of conversation rounds to discard each time the `max_context_length` limit is triggered.
|
||||
|
||||
#### `provider_settings.streaming_response`
|
||||
|
||||
Whether to enable streaming responses. Default is `false`. When enabled, the model's reply is sent to the user in real-time with a typewriter effect. This only takes effect on WebChat, Telegram, and Lark platforms.
|
||||
|
||||
#### `provider_settings.show_tool_use_status`
|
||||
|
||||
Whether to show tool usage status. Default is `false`. When enabled, the model displays the tool name and input parameters when using a tool.
|
||||
|
||||
#### `provider_settings.streaming_segmented`
|
||||
|
||||
Whether platforms that don't support streaming responses should fall back to segmented replies. Default is `false`. This means if streaming is enabled but the platform doesn't support it, segmented multiple replies are used instead.
|
||||
|
||||
#### `provider_settings.max_agent_step`
|
||||
|
||||
Limit on the maximum number of Agent steps. Default is `30`. Each tool call by the model counts as one step.
|
||||
|
||||
#### `provider_settings.tool_call_timeout`
|
||||
|
||||
Added in `v4.3.5`
|
||||
|
||||
Maximum timeout for tool calls (seconds), default is `60` seconds.
|
||||
|
||||
#### `provider_stt_settings`
|
||||
|
||||
General settings for Speech-to-Text (STT) providers.
|
||||
|
||||
#### `provider_stt_settings.enable`
|
||||
|
||||
Whether to enable STT services. Default is `false`.
|
||||
|
||||
#### `provider_stt_settings.provider_id`
|
||||
|
||||
STT provider ID. Must be an STT provider ID already configured in the `provider` list.
|
||||
|
||||
#### `provider_tts_settings`
|
||||
|
||||
General settings for Text-to-Speech (TTS) providers.
|
||||
|
||||
#### `provider_tts_settings.enable`
|
||||
|
||||
Whether to enable TTS services. Default is `false`.
|
||||
|
||||
#### `provider_tts_settings.provider_id`
|
||||
|
||||
TTS provider ID. Must be a TTS provider ID already configured in the `provider` list.
|
||||
|
||||
#### `provider_tts_settings.dual_output`
|
||||
|
||||
Whether to enable dual output. Default is `false`. When enabled, the bot sends both text and voice messages.
|
||||
|
||||
#### `provider_tts_settings.use_file_service`
|
||||
|
||||
Whether to enable the file service. Default is `false`. When enabled, the bot provides the output voice file as an external HTTP link to the message platform. This depends on the `callback_api_base` configuration.
|
||||
|
||||
#### `provider_ltm_settings`
|
||||
|
||||
General settings for group chat context awareness providers.
|
||||
|
||||
#### `provider_ltm_settings.group_icl_enable`
|
||||
|
||||
Whether to enable group chat context awareness. Default is `false`. When enabled, the bot records group chat conversations to better understand context.
|
||||
|
||||
The context content is placed in the conversation's system prompt.
|
||||
|
||||
#### `provider_ltm_settings.group_message_max_cnt`
|
||||
|
||||
Maximum number of group chat messages to record. Default is `100`. Messages exceeding this count are discarded.
|
||||
|
||||
#### `provider_ltm_settings.image_caption`
|
||||
|
||||
Whether to record images in group chats and automatically generate text descriptions using an image captioning model. Default is `false`. This depends on the `provider_settings.default_image_caption_provider_id` configuration. Use with caution as it can significantly increase API calls and token usage.
|
||||
|
||||
#### `provider_ltm_settings.active_reply`
|
||||
|
||||
- `enable`: Whether to enable active replies. Default is `false`.
|
||||
- `method`: Method for active replies. Option is `possibility_reply`.
|
||||
- `possibility_reply`: Probability of an active reply. Default is `0.1`. Only applicable when `method` is `possibility_reply`.
|
||||
- `whitelist`: ID whitelist for active replies. Only IDs in this list will trigger active replies. Empty means no whitelist filter. You can use the `/sid` command to get the session ID on a platform.
|
||||
|
||||
### `content_safety`
|
||||
|
||||
Content safety settings.
|
||||
|
||||
#### `content_safety.also_use_in_response`
|
||||
|
||||
Whether to also perform content safety checks on LLM replies. Default is `false`. When enabled, bot-generated replies also undergo safety checks to prevent inappropriate content.
|
||||
|
||||
#### `content_safety.internal_keywords`
|
||||
|
||||
Internal keyword detection settings.
|
||||
|
||||
- `enable`: Whether to enable internal keyword detection. Default is `true`.
|
||||
- `extra_keywords`: List of extra keywords, supports regex. Default is empty.
|
||||
|
||||
#### `content_safety.baidu_aip`
|
||||
|
||||
Baidu AI content moderation settings.
|
||||
|
||||
- `enable`: Whether to enable Baidu AI content moderation. Default is `false`.
|
||||
- `app_id`: App ID for Baidu AI content moderation.
|
||||
- `api_key`: API Key for Baidu AI content moderation.
|
||||
- `secret_key`: Secret Key for Baidu AI content moderation.
|
||||
|
||||
> [!TIP]
|
||||
> To enable Baidu AI content moderation, please `pip install baidu-aip` first.
|
||||
|
||||
### `admins_id`
|
||||
|
||||
List of administrator IDs. Additionally, you can use `/op` and `/deop` commands to add or remove admins.
|
||||
|
||||
### `t2i`
|
||||
|
||||
Whether to enable Text-to-Image (T2I) functionality. Default is `false`. When enabled, if a user's message exceeds a certain character count, the bot renders the message as an image to improve readability and prevent spamming. Supports Markdown rendering.
|
||||
|
||||
### `t2i_word_threshold`
|
||||
|
||||
Character threshold for T2I. Default is `150`. When a message exceeds this count, the bot renders it as an image.
|
||||
|
||||
### `t2i_strategy`
|
||||
|
||||
Rendering strategy for T2I. Options are `local` and `remote`. Default is `remote`.
|
||||
|
||||
- `local`: Uses AstrBot's local T2I service for rendering. Lower quality but doesn't depend on external services.
|
||||
- `remote`: Uses a remote T2I service for rendering. Uses the official AstrBot service by default, which offers better quality.
|
||||
|
||||
### `t2i_endpoint`
|
||||
|
||||
AstrBot API address. Used for rendering Markdown images. Effective when `t2i_strategy` is `remote`. Default is empty, meaning the official AstrBot service is used.
|
||||
|
||||
### `t2i_use_file_service`
|
||||
|
||||
Whether to enable the file service. Default is `false`. When enabled, the bot provides the rendered image as an external HTTP link to the message platform. This depends on the `callback_api_base` configuration.
|
||||
|
||||
### `http_proxy`
|
||||
|
||||
HTTP proxy. E.g., `http://localhost:7890`.
|
||||
|
||||
### `no_proxy`
|
||||
|
||||
List of addresses that bypass the proxy. E.g., `["localhost", "127.0.0.1"]`.
|
||||
|
||||
### `dashboard`
|
||||
|
||||
AstrBot WebUI configuration.
|
||||
|
||||
Please do not change the `password` value arbitrarily. It is an `md5` encoded password. Change the password in the control panel.
|
||||
|
||||
- `enable`: Whether to enable the AstrBot WebUI. Default is `true`.
|
||||
- `username`: Username for the AstrBot WebUI. Default is `astrbot`.
|
||||
- `password`: Password for the AstrBot WebUI. Default is the `md5` encoded value of `astrbot`. Do not modify directly unless you know what you are doing.
|
||||
- `jwt_secret`: JWT secret key. AstrBot generates this randomly at initialization. Do not modify unless you know what you are doing.
|
||||
- `host`: Address the AstrBot WebUI listens on. Default is `0.0.0.0`.
|
||||
- `port`: Port the AstrBot WebUI listens on. Default is `6185`.
|
||||
|
||||
### `platform`
|
||||
|
||||
> This item only takes effect in `data/cmd_config.json`; AstrBot does not read this from configuration files in the `data/config/` directory.
|
||||
|
||||
List of configured AstrBot message platform adapter settings.
|
||||
|
||||
### `platform_specific`
|
||||
|
||||
Platform-specific settings. Categorized by platform, then by feature group.
|
||||
|
||||
#### `platform_specific.<platform>.pre_ack_emoji`
|
||||
|
||||
When enabled, AstrBot sends a pre-reply emoji before requesting the LLM to inform the user that the request is being processed. This currently only takes effect in the Lark and Telegram platform adapters.
|
||||
|
||||
##### lark
|
||||
|
||||
- `enable`: Whether to enable pre-reply emojis for Lark messages. Default is `false`.
|
||||
- `emojis`: List of pre-reply emojis. Default is `["Typing"]`. Refer to [Emoji Documentation](https://open.feishu.cn/document/server-docs/im-v1/message-reaction/emojis-introduce) for emoji names.
|
||||
|
||||
##### telegram
|
||||
|
||||
- `enable`: Whether to enable pre-reply emojis for Telegram messages. Default is `false`.
|
||||
- `emojis`: List of pre-reply emojis. Default is `["✍️"]`. Telegram only supports a fixed set of reactions; refer to [reactions.txt](https://gist.github.com/Soulter/3f22c8e5f9c7e152e967e8bc28c97fc9).
|
||||
|
||||
### `wake_prefix`
|
||||
|
||||
Wake prefix. Default is `/`. When a message starts with `/`, AstrBot is awakened.
|
||||
|
||||
> [!TIP]
|
||||
> If the awakened session is not in the ID whitelist, AstrBot will not respond.
|
||||
|
||||
### `log_level`
|
||||
|
||||
Log level. Default is `INFO`. Can be set to `DEBUG`, `INFO`, `WARNING`, `ERROR`, `CRITICAL`.
|
||||
|
||||
### `trace_enable`
|
||||
|
||||
Whether to enable trace recording. Default is `false`. When enabled, AstrBot records execution traces, which can be viewed on the Trace page of the admin panel.
|
||||
|
||||
### `pip_install_arg`
|
||||
|
||||
Arguments for `pip install`. E.g., `-i https://mirrors.tuna.tsinghua.edu.cn/pypi/web/simple`.
|
||||
|
||||
### `pypi_index_url`
|
||||
|
||||
PyPI index URL. Default is `https://mirrors.aliyun.com/pypi/simple/`.
|
||||
|
||||
### `persona`
|
||||
|
||||
*This configuration item has been deprecated since v4.0.0. Please use the WebUI to configure personalities.*
|
||||
|
||||
List of configured personalities. Each personality contains `id`, `name`, `description`, and `system_prompt` fields.
|
||||
|
||||
### `timezone`
|
||||
|
||||
Timezone setting. Please fill in an IANA timezone name, such as Asia/Shanghai. If empty, the system default timezone is used. See all timezones at: [IANA Time Zone Database](https://data.iana.org/time-zones/tzdb-2021a/zone1970.tab).
|
||||
|
||||
### `callback_api_base`
|
||||
|
||||
Base address for the AstrBot API. Used for file services, plugin callbacks, etc. E.g., `http://example.com:6185`. Default is empty, meaning file services and plugin callbacks are disabled.
|
||||
|
||||
### `default_kb_collection`
|
||||
|
||||
Default knowledge base name. Used for RAG. If empty, no knowledge base is used.
|
||||
|
||||
### `plugin_set`
|
||||
|
||||
List of enabled plugins. `*` means all available plugins are enabled. Default is `["*"]`.
|
||||
@@ -1,51 +0,0 @@
|
||||
---
|
||||
outline: deep
|
||||
---
|
||||
|
||||
# AstrBot HTTP API
|
||||
|
||||
Starting from v4.18.0, AstrBot provides API Key based HTTP APIs for programmatic access.
|
||||
|
||||
## Quick Start
|
||||
|
||||
1. Create an API key in WebUI - Settings.
|
||||
2. Include the API key in request headers:
|
||||
|
||||
```http
|
||||
Authorization: Bearer abk_xxx
|
||||
```
|
||||
|
||||
Also supported:
|
||||
|
||||
```http
|
||||
X-API-Key: abk_xxx
|
||||
```
|
||||
|
||||
3. For chat endpoints, `username` is required:
|
||||
|
||||
- `POST /api/v1/chat`: request body must include `username`
|
||||
- `GET /api/v1/chat/sessions`: query params must include `username`
|
||||
|
||||
## Common Endpoints
|
||||
|
||||
- `POST /api/v1/chat`: send chat message (SSE stream, server generates UUID when `session_id` is omitted)
|
||||
- `GET /api/v1/chat/sessions`: list sessions for a specific `username` with pagination
|
||||
- `GET /api/v1/configs`: list available config files
|
||||
- `POST /api/v1/file`: upload attachment
|
||||
- `POST /api/v1/im/message`: proactive message via UMO
|
||||
- `GET /api/v1/im/bots`: list bot/platform IDs
|
||||
|
||||
## Example
|
||||
|
||||
```bash
|
||||
curl -N 'http://localhost:6185/api/v1/chat' \
|
||||
-H 'Authorization: Bearer abk_xxx' \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"message":"Hello","username":"alice"}'
|
||||
```
|
||||
|
||||
## Full API Reference
|
||||
|
||||
Use the interactive docs:
|
||||
|
||||
- https://docs.astrbot.app/scalar.html
|
||||
@@ -1,185 +0,0 @@
|
||||
---
|
||||
outline: deep
|
||||
---
|
||||
|
||||
# 开发一个平台适配器
|
||||
|
||||
AstrBot 支持以插件的形式接入平台适配器,你可以自行接入 AstrBot 没有的平台。如飞书、钉钉甚至是哔哩哔哩私信、Minecraft。
|
||||
|
||||
我们以一个平台 `FakePlatform` 为例展开讲解。
|
||||
|
||||
首先,在插件目录下新增 `fake_platform_adapter.py` 和 `fake_platform_event.py` 文件。前者主要是平台适配器的实现,后者是平台事件的定义。
|
||||
|
||||
## 平台适配器
|
||||
|
||||
假设 FakePlatform 的客户端 SDK 是这样:
|
||||
|
||||
```py
|
||||
import asyncio
|
||||
|
||||
class FakeClient():
|
||||
'''模拟一个消息平台,这里 5 秒钟下发一个消息'''
|
||||
def __init__(self, token: str, username: str):
|
||||
self.token = token
|
||||
self.username = username
|
||||
# ...
|
||||
|
||||
async def start_polling(self):
|
||||
while True:
|
||||
await asyncio.sleep(5)
|
||||
await getattr(self, 'on_message_received')({
|
||||
'bot_id': '123',
|
||||
'content': '新消息',
|
||||
'username': 'zhangsan',
|
||||
'userid': '123',
|
||||
'message_id': 'asdhoashd',
|
||||
'group_id': 'group123',
|
||||
})
|
||||
|
||||
async def send_text(self, to: str, message: str):
|
||||
print('发了消息:', to, message)
|
||||
|
||||
async def send_image(self, to: str, image_path: str):
|
||||
print('发了消息:', to, image_path)
|
||||
```
|
||||
|
||||
我们创建 `fake_platform_adapter.py`:
|
||||
|
||||
```py
|
||||
import asyncio
|
||||
|
||||
from astrbot.api.platform import Platform, AstrBotMessage, MessageMember, PlatformMetadata, MessageType
|
||||
from astrbot.api.event import MessageChain
|
||||
from astrbot.api.message_components import Plain, Image, Record # 消息链中的组件,可以根据需要导入
|
||||
from astrbot.core.platform.astr_message_event import MessageSesion
|
||||
from astrbot.api.platform import register_platform_adapter
|
||||
from astrbot import logger
|
||||
from .client import FakeClient
|
||||
from .fake_platform_event import FakePlatformEvent
|
||||
|
||||
# 注册平台适配器。第一个参数为平台名,第二个为描述。第三个为默认配置。
|
||||
@register_platform_adapter("fake", "fake 适配器", default_config_tmpl={
|
||||
"token": "your_token",
|
||||
"username": "bot_username"
|
||||
})
|
||||
class FakePlatformAdapter(Platform):
|
||||
|
||||
def __init__(self, platform_config: dict, platform_settings: dict, event_queue: asyncio.Queue) -> None:
|
||||
super().__init__(event_queue)
|
||||
self.config = platform_config # 上面的默认配置,用户填写后会传到这里
|
||||
self.settings = platform_settings # platform_settings 平台设置。
|
||||
|
||||
async def send_by_session(self, session: MessageSesion, message_chain: MessageChain):
|
||||
# 必须实现
|
||||
await super().send_by_session(session, message_chain)
|
||||
|
||||
def meta(self) -> PlatformMetadata:
|
||||
# 必须实现,直接像下面一样返回即可。
|
||||
return PlatformMetadata(
|
||||
"fake",
|
||||
"fake 适配器",
|
||||
)
|
||||
|
||||
async def run(self):
|
||||
# 必须实现,这里是主要逻辑。
|
||||
|
||||
# FakeClient 是我们自己定义的,这里只是示例。这个是其回调函数
|
||||
async def on_received(data):
|
||||
logger.info(data)
|
||||
abm = await self.convert_message(data=data) # 转换成 AstrBotMessage
|
||||
await self.handle_msg(abm)
|
||||
|
||||
# 初始化 FakeClient
|
||||
self.client = FakeClient(self.config['token'], self.config['username'])
|
||||
self.client.on_message_received = on_received
|
||||
await self.client.start_polling() # 持续监听消息,这是个堵塞方法。
|
||||
|
||||
async def convert_message(self, data: dict) -> AstrBotMessage:
|
||||
# 将平台消息转换成 AstrBotMessage
|
||||
# 这里就体现了适配程度,不同平台的消息结构不一样,这里需要根据实际情况进行转换。
|
||||
abm = AstrBotMessage()
|
||||
abm.type = MessageType.GROUP_MESSAGE # 还有 friend_message,对应私聊。具体平台具体分析。重要!
|
||||
abm.group_id = data['group_id'] # 如果是私聊,这里可以不填
|
||||
abm.message_str = data['content'] # 纯文本消息。重要!
|
||||
abm.sender = MessageMember(user_id=data['userid'], nickname=data['username']) # 发送者。重要!
|
||||
abm.message = [Plain(text=data['content'])] # 消息链。如果有其他类型的消息,直接 append 即可。重要!
|
||||
abm.raw_message = data # 原始消息。
|
||||
abm.self_id = data['bot_id']
|
||||
abm.session_id = data['userid'] # 会话 ID。重要!
|
||||
abm.message_id = data['message_id'] # 消息 ID。
|
||||
|
||||
return abm
|
||||
|
||||
async def handle_msg(self, message: AstrBotMessage):
|
||||
# 处理消息
|
||||
message_event = FakePlatformEvent(
|
||||
message_str=message.message_str,
|
||||
message_obj=message,
|
||||
platform_meta=self.meta(),
|
||||
session_id=message.session_id,
|
||||
client=self.client
|
||||
)
|
||||
self.commit_event(message_event) # 提交事件到事件队列。不要忘记!
|
||||
```
|
||||
|
||||
|
||||
`fake_platform_event.py`:
|
||||
|
||||
```py
|
||||
from astrbot.api.event import AstrMessageEvent, MessageChain
|
||||
from astrbot.api.platform import AstrBotMessage, PlatformMetadata
|
||||
from astrbot.api.message_components import Plain, Image
|
||||
from .client import FakeClient
|
||||
from astrbot.core.utils.io import download_image_by_url
|
||||
|
||||
class FakePlatformEvent(AstrMessageEvent):
|
||||
def __init__(self, message_str: str, message_obj: AstrBotMessage, platform_meta: PlatformMetadata, session_id: str, client: FakeClient):
|
||||
super().__init__(message_str, message_obj, platform_meta, session_id)
|
||||
self.client = client
|
||||
|
||||
async def send(self, message: MessageChain):
|
||||
for i in message.chain: # 遍历消息链
|
||||
if isinstance(i, Plain): # 如果是文字类型的
|
||||
await self.client.send_text(to=self.get_sender_id(), message=i.text)
|
||||
elif isinstance(i, Image): # 如果是图片类型的
|
||||
img_url = i.file
|
||||
img_path = ""
|
||||
# 下面的三个条件可以直接参考一下。
|
||||
if img_url.startswith("file:///"):
|
||||
img_path = img_url[8:]
|
||||
elif i.file and i.file.startswith("http"):
|
||||
img_path = await download_image_by_url(i.file)
|
||||
else:
|
||||
img_path = img_url
|
||||
|
||||
# 请善于 Debug!
|
||||
|
||||
await self.client.send_image(to=self.get_sender_id(), image_path=img_path)
|
||||
|
||||
await super().send(message) # 需要最后加上这一段,执行父类的 send 方法。
|
||||
```
|
||||
|
||||
最后,main.py 只需这样,在初始化的时候导入 fake_platform_adapter 模块。装饰器会自动注册。
|
||||
|
||||
```py
|
||||
from astrbot.api.star import Context, Star
|
||||
|
||||
class MyPlugin(Star):
|
||||
def __init__(self, context: Context):
|
||||
from .fake_platform_adapter import FakePlatformAdapter # noqa
|
||||
```
|
||||
|
||||
搞好后,运行 AstrBot:
|
||||
|
||||

|
||||
|
||||
这里出现了我们创建的 fake。
|
||||
|
||||

|
||||
|
||||
启动后,可以看到正常工作:
|
||||
|
||||

|
||||
|
||||
|
||||
有任何疑问欢迎加群询问~
|
||||
@@ -1 +0,0 @@
|
||||
This page has moved to [AstrBot Plugin Development Guide](/en/dev/star/plugin-new).
|
||||
@@ -1,489 +0,0 @@
|
||||
|
||||
# AI
|
||||
|
||||
AstrBot provides built-in support for multiple Large Language Model (LLM) providers and offers a unified interface, making it convenient for plugin developers to access various LLM services.
|
||||
|
||||
You can use the LLM / Agent interfaces provided by AstrBot to implement your own intelligent agents.
|
||||
|
||||
Starting from version `v4.5.7`, we've made significant improvements to the way LLM providers are invoked. We recommend using the new approach, which is more concise and supports additional features. The legacy invocation method remains documented in the previous Chinese-only guide.
|
||||
|
||||
## Getting the Chat Model ID for the Current Session
|
||||
|
||||
> [!TIP]
|
||||
> Added in v4.5.7
|
||||
|
||||
```py
|
||||
umo = event.unified_msg_origin
|
||||
provider_id = await self.context.get_current_chat_provider_id(umo=umo)
|
||||
```
|
||||
|
||||
## Invoking Large Language Models
|
||||
|
||||
> [!TIP]
|
||||
> Added in v4.5.7
|
||||
|
||||
|
||||
```py
|
||||
llm_resp = await self.context.llm_generate(
|
||||
chat_provider_id=provider_id, # Chat model ID
|
||||
prompt="Hello, world!",
|
||||
)
|
||||
# print(llm_resp.completion_text) # Get the returned text
|
||||
```
|
||||
|
||||
## Defining Tools
|
||||
|
||||
Tools enable large language models to invoke external capabilities.
|
||||
|
||||
```py
|
||||
from pydantic import Field
|
||||
from pydantic.dataclasses import dataclass
|
||||
|
||||
from astrbot.core.agent.run_context import ContextWrapper
|
||||
from astrbot.core.agent.tool import FunctionTool, ToolExecResult
|
||||
from astrbot.core.astr_agent_context import AstrAgentContext
|
||||
|
||||
|
||||
@dataclass
|
||||
class BilibiliTool(FunctionTool[AstrAgentContext]):
|
||||
name: str = "bilibili_videos" # Tool name
|
||||
description: str = "A tool to fetch Bilibili videos." # Tool description
|
||||
parameters: dict = Field(
|
||||
default_factory=lambda: {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"keywords": {
|
||||
"type": "string",
|
||||
"description": "Keywords to search for Bilibili videos.",
|
||||
},
|
||||
},
|
||||
"required": ["keywords"],
|
||||
}
|
||||
)
|
||||
|
||||
async def call(
|
||||
self, context: ContextWrapper[AstrAgentContext], **kwargs
|
||||
) -> ToolExecResult:
|
||||
return "1. Video Title: How to Use AstrBot\nVideo Link: xxxxxx"
|
||||
```
|
||||
|
||||
## Invoking Agents
|
||||
|
||||
> [!TIP]
|
||||
> Added in v4.5.7
|
||||
|
||||
|
||||
An Agent can be defined as a combination of system_prompt + tools + llm, enabling more sophisticated intelligent behavior.
|
||||
|
||||
After defining the Tool above, you can invoke an Agent as follows:
|
||||
|
||||
```py
|
||||
llm_resp = await self.context.tool_loop_agent(
|
||||
event=event,
|
||||
chat_provider_id=prov_id,
|
||||
prompt="Search for videos related to AstrBot on Bilibili.",
|
||||
tools=ToolSet([BilibiliTool()]),
|
||||
max_steps=30, # Maximum agent execution steps
|
||||
tool_call_timeout=60, # Tool invocation timeout
|
||||
)
|
||||
# print(llm_resp.completion_text) # Get the returned text
|
||||
```
|
||||
|
||||
`tool_loop_agent()` method automatically handles the loop of tool invocations and LLM requests until the model stops calling tools or the maximum number of steps is reached.
|
||||
|
||||
## Multi-Agent
|
||||
|
||||
> [!TIP]
|
||||
> Added in v4.5.7
|
||||
|
||||
|
||||
Multi-Agent systems decompose complex applications into multiple specialized agents that collaborate to solve problems. Unlike relying on a single agent to handle every step, multi-agent architectures allow smaller, more focused agents to be composed into coordinated workflows. We implement multi-agent systems using the `agent-as-tool` pattern.
|
||||
|
||||
In the example below, we define a Main Agent responsible for delegating tasks to different Sub-Agents based on user queries. Each Sub-Agent focuses on specific tasks, such as retrieving weather information.
|
||||
|
||||

|
||||
|
||||
Define Tools:
|
||||
|
||||
```py
|
||||
@dataclass
|
||||
class AssignAgentTool(FunctionTool[AstrAgentContext]):
|
||||
"""Main agent uses this tool to decide which sub-agent to delegate a task to."""
|
||||
|
||||
name: str = "assign_agent"
|
||||
description: str = "Assign an agent to a task based on the given query"
|
||||
parameters: dict = field(
|
||||
default_factory=lambda: {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"query": {
|
||||
"type": "string",
|
||||
"description": "The query to call the sub-agent with.",
|
||||
},
|
||||
},
|
||||
"required": ["query"],
|
||||
}
|
||||
)
|
||||
|
||||
async def call(
|
||||
self, context: ContextWrapper[AstrAgentContext], **kwargs
|
||||
) -> str | CallToolResult:
|
||||
# Here you would implement the actual agent assignment logic.
|
||||
# For demonstration purposes, we'll return a dummy response.
|
||||
return "Based on the query, you should assign agent 1."
|
||||
|
||||
|
||||
@dataclass
|
||||
class WeatherTool(FunctionTool[AstrAgentContext]):
|
||||
"""In this example, sub agent 1 uses this tool to get weather information."""
|
||||
|
||||
name: str = "weather"
|
||||
description: str = "Get weather information for a location"
|
||||
parameters: dict = field(
|
||||
default_factory=lambda: {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"city": {
|
||||
"type": "string",
|
||||
"description": "The city to get weather information for.",
|
||||
},
|
||||
},
|
||||
"required": ["city"],
|
||||
}
|
||||
)
|
||||
|
||||
async def call(
|
||||
self, context: ContextWrapper[AstrAgentContext], **kwargs
|
||||
) -> str | CallToolResult:
|
||||
city = kwargs["city"]
|
||||
# Here you would implement the actual weather fetching logic.
|
||||
# For demonstration purposes, we'll return a dummy response.
|
||||
return f"The current weather in {city} is sunny with a temperature of 25°C."
|
||||
|
||||
|
||||
@dataclass
|
||||
class SubAgent1(FunctionTool[AstrAgentContext]):
|
||||
"""Define a sub-agent as a function tool."""
|
||||
|
||||
name: str = "subagent1_name"
|
||||
description: str = "subagent1_description"
|
||||
parameters: dict = field(
|
||||
default_factory=lambda: {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"query": {
|
||||
"type": "string",
|
||||
"description": "The query to call the sub-agent with.",
|
||||
},
|
||||
},
|
||||
"required": ["query"],
|
||||
}
|
||||
)
|
||||
|
||||
async def call(
|
||||
self, context: ContextWrapper[AstrAgentContext], **kwargs
|
||||
) -> str | CallToolResult:
|
||||
ctx = context.context.context
|
||||
event = context.context.event
|
||||
logger.info(f"the llm context messages: {context.messages}")
|
||||
llm_resp = await ctx.tool_loop_agent(
|
||||
event=event,
|
||||
chat_provider_id=await ctx.get_current_chat_provider_id(
|
||||
event.unified_msg_origin
|
||||
),
|
||||
prompt=kwargs["query"],
|
||||
tools=ToolSet([WeatherTool()]),
|
||||
max_steps=30,
|
||||
)
|
||||
return llm_resp.completion_text
|
||||
|
||||
|
||||
@dataclass
|
||||
class SubAgent2(FunctionTool[AstrAgentContext]):
|
||||
"""Define a sub-agent as a function tool."""
|
||||
|
||||
name: str = "subagent2_name"
|
||||
description: str = "subagent2_description"
|
||||
parameters: dict = field(
|
||||
default_factory=lambda: {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"query": {
|
||||
"type": "string",
|
||||
"description": "The query to call the sub-agent with.",
|
||||
},
|
||||
},
|
||||
"required": ["query"],
|
||||
}
|
||||
)
|
||||
|
||||
async def call(
|
||||
self, context: ContextWrapper[AstrAgentContext], **kwargs
|
||||
) -> str | CallToolResult:
|
||||
return "I am useless :(, you shouldn't call me :("
|
||||
```
|
||||
|
||||
Then, similarly, invoke the Agent using the `tool_loop_agent()` method:
|
||||
|
||||
```py
|
||||
@filter.command("test")
|
||||
async def test(self, event: AstrMessageEvent):
|
||||
umo = event.unified_msg_origin
|
||||
prov_id = await self.context.get_current_chat_provider_id(umo)
|
||||
llm_resp = await self.context.tool_loop_agent(
|
||||
event=event,
|
||||
chat_provider_id=prov_id,
|
||||
prompt="Test calling sub-agent for Beijing's weather information.",
|
||||
system_prompt=(
|
||||
"You are the main agent. Your task is to delegate tasks to sub-agents based on user queries."
|
||||
"Before delegating, use the 'assign_agent' tool to determine which sub-agent is best suited for the task."
|
||||
),
|
||||
tools=ToolSet([SubAgent1(), SubAgent2(), AssignAgentTool()]),
|
||||
max_steps=30,
|
||||
)
|
||||
yield event.plain_result(llm_resp.completion_text)
|
||||
```
|
||||
|
||||
## Conversation Manager
|
||||
|
||||
### Getting the Current LLM Conversation History for a Session
|
||||
|
||||
```py
|
||||
from astrbot.core.conversation_mgr import Conversation
|
||||
|
||||
uid = event.unified_msg_origin
|
||||
conv_mgr = self.context.conversation_manager
|
||||
curr_cid = await conv_mgr.get_curr_conversation_id(uid)
|
||||
conversation = await conv_mgr.get_conversation(uid, curr_cid) # Conversation
|
||||
```
|
||||
|
||||
::: details Conversation 类型定义
|
||||
|
||||
```py
|
||||
@dataclass
|
||||
class Conversation:
|
||||
"""The conversation entity representing a chat session."""
|
||||
|
||||
platform_id: str
|
||||
"""The platform ID in AstrBot"""
|
||||
user_id: str
|
||||
"""The user ID associated with the conversation."""
|
||||
cid: str
|
||||
"""The conversation ID, in UUID format."""
|
||||
history: str = ""
|
||||
"""The conversation history as a string."""
|
||||
title: str | None = ""
|
||||
"""The title of the conversation. For now, it's only used in WebChat."""
|
||||
persona_id: str | None = ""
|
||||
"""The persona ID associated with the conversation."""
|
||||
created_at: int = 0
|
||||
"""The timestamp when the conversation was created."""
|
||||
updated_at: int = 0
|
||||
"""The timestamp when the conversation was last updated."""
|
||||
```
|
||||
|
||||
:::
|
||||
|
||||
### Main Methods
|
||||
|
||||
#### `new_conversation`
|
||||
|
||||
- **Usage**
|
||||
Create a new conversation in the current session and automatically switch to it.
|
||||
- **Arguments**
|
||||
- `unified_msg_origin: str` – In the format `platform_name:message_type:session_id`
|
||||
- `platform_id: str | None` – Platform identifier, defaults to parsing from `unified_msg_origin`
|
||||
- `content: list[dict] | None` – Initial message history
|
||||
- `title: str | None` – Conversation title
|
||||
- `persona_id: str | None` – Associated persona ID
|
||||
- **Returns**
|
||||
`str` – Newly generated UUID conversation ID
|
||||
|
||||
#### `switch_conversation`
|
||||
|
||||
- **Usage**
|
||||
Switch the session to a specified conversation.
|
||||
- **Arguments**
|
||||
- `unified_msg_origin: str`
|
||||
- `conversation_id: str`
|
||||
- **Returns**
|
||||
`None`
|
||||
|
||||
#### `delete_conversation`
|
||||
|
||||
- **Usage**
|
||||
Delete a conversation from the session; if `conversation_id` is `None`, deletes the current conversation.
|
||||
- **Arguments**
|
||||
- `unified_msg_origin: str`
|
||||
- `conversation_id: str | None`
|
||||
- **Returns**
|
||||
`None`
|
||||
|
||||
#### `get_curr_conversation_id`
|
||||
|
||||
- **Usage**
|
||||
Get the conversation ID currently in use by the session.
|
||||
- **Arguments**
|
||||
- `unified_msg_origin: str`
|
||||
- **Returns**
|
||||
`str | None` – Current conversation ID, returns `None` if it doesn't exist
|
||||
|
||||
#### `get_conversation`
|
||||
|
||||
- **Usage**
|
||||
Get the complete object for a specified conversation; automatically creates it if it doesn't exist and `create_if_not_exists=True`.
|
||||
- **Arguments**
|
||||
- `unified_msg_origin: str`
|
||||
- `conversation_id: str`
|
||||
- `create_if_not_exists: bool = False`
|
||||
- **Returns**
|
||||
`Conversation | None`
|
||||
|
||||
#### `get_conversations`
|
||||
|
||||
- **Usage**
|
||||
Retrieve the complete list of conversations for a user or platform.
|
||||
- **Arguments**
|
||||
- `unified_msg_origin: str | None` – When `None`, does not filter by user
|
||||
- `platform_id: str | None`
|
||||
- **Returns**
|
||||
`List[Conversation]`
|
||||
|
||||
#### `update_conversation`
|
||||
|
||||
- **Usage**
|
||||
Update the title, history, or persona_id of a conversation.
|
||||
- **Arguments**
|
||||
- `unified_msg_origin: str`
|
||||
- `conversation_id: str | None` – Uses the current conversation when `None`
|
||||
- `history: list[dict] | None`
|
||||
- `title: str | None`
|
||||
- `persona_id: str | None`
|
||||
- **Returns**
|
||||
`None`
|
||||
|
||||
## Persona Manager
|
||||
|
||||
`PersonaManager` is responsible for unified loading, caching, and providing CRUD interfaces for all Personas, while maintaining compatibility with the legacy persona format (v3) from before AstrBot 4.x.
|
||||
During initialization, it automatically reads all personas from the database and generates v3-compatible data for seamless use with legacy code.
|
||||
|
||||
```py
|
||||
persona_mgr = self.context.persona_manager
|
||||
```
|
||||
|
||||
### Main Methods
|
||||
|
||||
#### `get_persona`
|
||||
|
||||
- **Usage**
|
||||
Get persona data by persona ID.
|
||||
- **Arguments**
|
||||
- `persona_id: str` – Persona ID
|
||||
- **Returns**
|
||||
`Persona` – Persona data, returns None if it doesn't exist
|
||||
- **Raises**
|
||||
`ValueError` – Raised when it doesn't exist
|
||||
|
||||
#### `get_all_personas`
|
||||
|
||||
- **Usage**
|
||||
Retrieve all personas from the database at once.
|
||||
- **Returns**
|
||||
`list[Persona]` – Persona list, may be empty
|
||||
|
||||
#### `create_persona`
|
||||
|
||||
- **Usage**
|
||||
Create a new persona and immediately write it to the database; automatically refreshes the local cache upon success.
|
||||
- **Arguments**
|
||||
- `persona_id: str` – New persona ID (unique)
|
||||
- `system_prompt: str` – System prompt
|
||||
- `begin_dialogs: list[str]` – Optional, opening dialogs (even number of entries, alternating user/assistant)
|
||||
- `tools: list[str]` – Optional, list of allowed tools; `None`=all tools, `[]`=disable all
|
||||
- **Returns**
|
||||
`Persona` – Newly created persona object
|
||||
- **Raises**
|
||||
`ValueError` – If `persona_id` already exists
|
||||
|
||||
#### `update_persona`
|
||||
|
||||
- **Usage**
|
||||
Update any fields of an existing persona and synchronize to database and cache.
|
||||
- **Arguments**
|
||||
- `persona_id: str` – Persona ID to update
|
||||
- `system_prompt: str` – Optional, new system prompt
|
||||
- `begin_dialogs: list[str]` – Optional, new opening dialogs
|
||||
- `tools: list[str]` – Optional, new tool list; semantics same as `create_persona`
|
||||
- **Returns**
|
||||
`Persona` – Updated persona object
|
||||
- **Raises**
|
||||
`ValueError` – If `persona_id` doesn't exist
|
||||
|
||||
#### `delete_persona`
|
||||
|
||||
- **Usage**
|
||||
Delete the specified persona and clean up both database and cache.
|
||||
- **Arguments**
|
||||
- `persona_id: str` – Persona ID to delete
|
||||
- **Raises**
|
||||
`ValueError` – If `persona_id` doesn't exist
|
||||
|
||||
#### `get_default_persona_v3`
|
||||
|
||||
- **Usage**
|
||||
Get the default persona (v3 format) to use based on the current session configuration.
|
||||
Falls back to `DEFAULT_PERSONALITY` if configuration doesn't specify one or the specified persona doesn't exist.
|
||||
- **Arguments**
|
||||
- `umo: str | MessageSession | None` – Session identifier, used to read user-level configuration
|
||||
- **Returns**
|
||||
`Personality` – Default persona object in v3 format
|
||||
|
||||
::: details Persona / Personality 类型定义
|
||||
|
||||
```py
|
||||
|
||||
class Persona(SQLModel, table=True):
|
||||
"""Persona is a set of instructions for LLMs to follow.
|
||||
|
||||
It can be used to customize the behavior of LLMs.
|
||||
"""
|
||||
|
||||
__tablename__ = "personas"
|
||||
|
||||
id: int = Field(primary_key=True, sa_column_kwargs={"autoincrement": True})
|
||||
persona_id: str = Field(max_length=255, nullable=False)
|
||||
system_prompt: str = Field(sa_type=Text, nullable=False)
|
||||
begin_dialogs: Optional[list] = Field(default=None, sa_type=JSON)
|
||||
"""a list of strings, each representing a dialog to start with"""
|
||||
tools: Optional[list] = Field(default=None, sa_type=JSON)
|
||||
"""None means use ALL tools for default, empty list means no tools, otherwise a list of tool names."""
|
||||
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
|
||||
updated_at: datetime = Field(
|
||||
default_factory=lambda: datetime.now(timezone.utc),
|
||||
sa_column_kwargs={"onupdate": datetime.now(timezone.utc)},
|
||||
)
|
||||
|
||||
__table_args__ = (
|
||||
UniqueConstraint(
|
||||
"persona_id",
|
||||
name="uix_persona_id",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class Personality(TypedDict):
|
||||
"""LLM Persona class.
|
||||
|
||||
Starting from v4.0.0 and later, it's recommended to use the Persona class above. Additionally, the mood_imitation_dialogs field has been deprecated.
|
||||
"""
|
||||
|
||||
prompt: str
|
||||
name: str
|
||||
begin_dialogs: list[str]
|
||||
mood_imitation_dialogs: list[str]
|
||||
"""Mood imitation dialog preset. Deprecated since v4.0.0 and later."""
|
||||
tools: list[str] | None
|
||||
"""Tool list. None means use all tools, empty list means don't use any tools"""
|
||||
```
|
||||
|
||||
:::
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user