Compare commits
44 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8faaa4b2be | |||
| 7f5bd942b3 | |||
| e254caf82d | |||
| 7efcd242d6 | |||
| 5d811d3949 | |||
| 8e6aaee10c | |||
| 6da59cfb07 | |||
| 10ceacfbb1 | |||
| 66f5ccd902 | |||
| 3379587223 | |||
| e25a1a42cf | |||
| 0c771e4a77 | |||
| ec21cb13d3 | |||
| 1d26b96d90 | |||
| be017c87f4 | |||
| 23fffa95c8 | |||
| 5b303e2e6d | |||
| fc33b3eb68 | |||
| 795aec9578 | |||
| 7d31140c14 | |||
| 654112ca86 | |||
| 5dd30f9a45 | |||
| a53a1ca49b | |||
| 3fd6c4c8a6 | |||
| 5808784f07 | |||
| 537849c1e7 | |||
| 7f3c0fdeb2 | |||
| 8e431e2076 | |||
| 89c11fd683 | |||
| 7cfe2aca99 | |||
| 3a938d2a13 | |||
| 812834bc9f | |||
| 51ff4f6e46 | |||
| 7ac169c5e8 | |||
| 61648ebe3e | |||
| 0610f0db0a | |||
| 8c935981bb | |||
| 3f3b4e4924 | |||
| af581e7f21 | |||
| 9e371ee10b | |||
| 7cf77adbc8 | |||
| 31673ee521 | |||
| ff22030dde | |||
| 101580fd77 |
@@ -0,0 +1,43 @@
|
||||
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@v3
|
||||
uses: docker/setup-qemu-action@v4
|
||||
|
||||
- name: Set Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
uses: docker/setup-buildx-action@v4
|
||||
|
||||
- name: Log in to DockerHub
|
||||
uses: docker/login-action@v3
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_HUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_HUB_PASSWORD }}
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
if: env.HAS_GHCR_TOKEN == 'true'
|
||||
uses: docker/login-action@v3
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ env.GHCR_OWNER }}
|
||||
@@ -98,7 +98,7 @@ jobs:
|
||||
echo "EOF" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Build and Push Nightly Image
|
||||
uses: docker/build-push-action@v6
|
||||
uses: docker/build-push-action@v7
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/amd64,linux/arm64
|
||||
@@ -163,27 +163,27 @@ jobs:
|
||||
cp -r dashboard/dist data/
|
||||
|
||||
- name: Set QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
uses: docker/setup-qemu-action@v4
|
||||
|
||||
- name: Set Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
uses: docker/setup-buildx-action@v4
|
||||
|
||||
- name: Log in to DockerHub
|
||||
uses: docker/login-action@v3
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_HUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_HUB_PASSWORD }}
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
if: env.HAS_GHCR_TOKEN == 'true'
|
||||
uses: docker/login-action@v3
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ env.GHCR_OWNER }}
|
||||
password: ${{ secrets.GHCR_GITHUB_TOKEN }}
|
||||
|
||||
- name: Build and Push Release Image
|
||||
uses: docker/build-push-action@v6
|
||||
uses: docker/build-push-action@v7
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/amd64,linux/arm64
|
||||
|
||||
@@ -184,7 +184,8 @@ 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
|
||||
@@ -192,6 +193,36 @@ 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:
|
||||
@@ -203,6 +234,8 @@ 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
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
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
|
||||
@@ -61,3 +61,5 @@ GenieData/
|
||||
.codex/
|
||||
.opencode/
|
||||
.kilocode/
|
||||
.worktrees/
|
||||
docs/plans/
|
||||
|
||||
@@ -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, we recommend using the one-click deployment method with `uv` ⚡️:
|
||||
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 ⚡️:
|
||||
|
||||
```bash
|
||||
uv tool install astrbot
|
||||
@@ -83,47 +83,58 @@ 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 who want a more stable and production-ready deployment, we recommend using Docker / Docker Compose to deploy AstrBot.
|
||||
For users familiar with containers and looking for a more stable, production-ready deployment method, we recommend deploying AstrBot with Docker / Docker Compose.
|
||||
|
||||
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 to deploy AstrBot with one-click and don't want to manage the server, we recommend using RainYun's one-click cloud deployment service ☁️:
|
||||
For users who want one-click deployment and do not want to manage servers themselves, we recommend RainYun's one-click cloud deployment service ☁️:
|
||||
|
||||
[](https://app.rainyun.com/apps/rca/store/5994?ref=NjU1ODg0)
|
||||
|
||||
### Desktop Application (Tauri)
|
||||
### Desktop Application Deployment
|
||||
|
||||
For users who want to deploy AstrBot on their desktop, primarily using AstrBot ChatUI, rarely use AstrBot plugins, we recommend using the AstrBot App:
|
||||
For users who want to use AstrBot on desktop and mainly use ChatUI, we recommend AstrBot App.
|
||||
|
||||
Desktop repository: [AstrBot-desktop](https://github.com/AstrBotDevs/AstrBot-desktop).
|
||||
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.
|
||||
|
||||
Supports multiple system architectures, direct package installation, and out-of-the-box usage. A convenient one-click desktop deployment option for beginners.
|
||||
### Launcher Deployment
|
||||
|
||||
### One-Click Launcher Deployment (AstrBot Launcher)
|
||||
For desktop users who also want fast deployment and isolated multi-instance usage, we recommend AstrBot Launcher.
|
||||
|
||||
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.
|
||||
Visit [AstrBot Launcher](https://github.com/Raven95676/astrbot-launcher) to download and install.
|
||||
|
||||
### Deploy on Replit
|
||||
|
||||
Community-contributed deployment method.
|
||||
Replit deployment is maintained by the community and is suitable for online demos and lightweight trials.
|
||||
|
||||
[](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**: [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)
|
||||
**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`.
|
||||
|
||||
## Supported Messaging Platforms
|
||||
|
||||
@@ -184,6 +195,13 @@ Connect AstrBot to your favorite chat platform.
|
||||
| Minimax TTS | Text-to-Speech Services |
|
||||
| Volcano Engine TTS | Text-to-Speech Services |
|
||||
|
||||
## ❤️ Sponsors
|
||||
|
||||
<p align="center">
|
||||
<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 :)
|
||||
@@ -202,17 +220,22 @@ 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
|
||||
|
||||
- Developer Group(Chit-chat): 975206796
|
||||
- Developer Group(Formal): 1039761811
|
||||
|
||||
### Discord Server
|
||||
|
||||
|
||||
+28
-16
@@ -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 souhaitent découvrir AstrBot rapidement, nous recommandons la méthode de déploiement en un clic avec `uv` ⚡️ :
|
||||
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` ⚡️ :
|
||||
|
||||
```bash
|
||||
uv tool install astrbot
|
||||
@@ -83,47 +83,58 @@ 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 qui veulent un déploiement plus stable et prêt pour la production, nous recommandons d'utiliser Docker / Docker Compose pour déployer AstrBot.
|
||||
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.
|
||||
|
||||
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, 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 eux-mêmes, nous recommandons le service de déploiement cloud en un clic de RainYun ☁️ :
|
||||
|
||||
[](https://app.rainyun.com/apps/rca/store/5994?ref=NjU1ODg0)
|
||||
|
||||
### Application de bureau (Tauri)
|
||||
### Déploiement de l'application de bureau
|
||||
|
||||
Pour les utilisateurs qui veulent déployer AstrBot sur desktop, utilisent principalement AstrBot ChatUI et utilisent rarement les plugins AstrBot, nous recommandons AstrBot App :
|
||||
Pour les utilisateurs qui veulent utiliser AstrBot sur desktop et passer principalement par ChatUI, nous recommandons AstrBot App.
|
||||
|
||||
Dépôt de l'application de bureau : [AstrBot-desktop](https://github.com/AstrBotDevs/AstrBot-desktop).
|
||||
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.
|
||||
|
||||
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.
|
||||
### Déploiement avec le lanceur
|
||||
|
||||
### Déploiement en un clic avec le lanceur (AstrBot Launcher)
|
||||
Également sur desktop, pour les utilisateurs qui souhaitent un déploiement rapide avec isolation d'environnement et multi-instances, nous recommandons AstrBot Launcher.
|
||||
|
||||
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.
|
||||
Accédez à [AstrBot Launcher](https://github.com/Raven95676/astrbot-launcher) pour télécharger et installer.
|
||||
|
||||
### Déployer sur Replit
|
||||
|
||||
Méthode de déploiement contribuée par la communauté.
|
||||
Le déploiement sur Replit est maintenu par la communauté et convient aux démonstrations en ligne et aux essais légers.
|
||||
|
||||
[](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** : [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)
|
||||
**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`.
|
||||
|
||||
## Plateformes de messagerie prises en charge
|
||||
|
||||
@@ -211,6 +222,7 @@ pre-commit install
|
||||
- Groupe 5 : 822130018
|
||||
- Groupe 6 : 753075035
|
||||
- Groupe développeurs : 975206796
|
||||
- Groupe développeurs (officiel) : 1039761811
|
||||
|
||||
### Serveur Discord
|
||||
|
||||
|
||||
+27
-15
@@ -73,7 +73,7 @@ AstrBot は、主要なインスタントメッセージングアプリと統合
|
||||
|
||||
### ワンクリックデプロイ
|
||||
|
||||
AstrBot を素早く試したいユーザーには、`uv` を使ったワンクリックデプロイをおすすめします ⚡️:
|
||||
AstrBot を素早く試したいユーザーで、コマンドラインに慣れており `uv` 環境を自分でインストールできる場合は、`uv` のワンクリックデプロイをおすすめします ⚡️:
|
||||
|
||||
```bash
|
||||
uv tool install astrbot
|
||||
@@ -83,47 +83,58 @@ 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 を使いたいユーザーで、主に AstrBot ChatUI を利用し、AstrBot プラグインの利用頻度が低い場合は、AstrBot App の利用をおすすめします:
|
||||
デスクトップで AstrBot を使い、主に ChatUI を入口として利用するユーザーには、AstrBot App をおすすめします。
|
||||
|
||||
デスクトップアプリのリポジトリ [AstrBot-desktop](https://github.com/AstrBotDevs/AstrBot-desktop)。
|
||||
[AstrBot-desktop](https://github.com/AstrBotDevs/AstrBot-desktop) からダウンロードしてインストールしてください。この方式はデスクトップ向けであり、サーバー用途には推奨されません。
|
||||
|
||||
マルチシステムアーキテクチャに対応し、インストーラーですぐ利用可能。初心者にも使いやすいワンクリックのデスクトップデプロイ方式です。サーバー用途には推奨されません。
|
||||
### ランチャーのデプロイ
|
||||
|
||||
### ランチャーによるワンクリックデプロイ(AstrBot Launcher)
|
||||
同じくデスクトップで、素早くデプロイしつつ環境を分離して多重起動したいユーザーには、AstrBot Launcher をおすすめします。
|
||||
|
||||
高速デプロイと環境分離されたマルチインスタンス運用を求めるユーザーには、AstrBot Launcher の利用をおすすめします:
|
||||
|
||||
[AstrBot Launcher](https://github.com/Raven95676/astrbot-launcher) リポジトリにアクセスし、最新リリースからお使いの OS 向けパッケージをインストールしてください。
|
||||
|
||||
高速デプロイと環境分離されたマルチインスタンス運用を実現できます。
|
||||
[AstrBot Launcher](https://github.com/Raven95676/astrbot-launcher) からダウンロードしてインストールしてください。
|
||||
|
||||
### 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) | [1Panel デプロイ](https://astrbot.app/deploy/astrbot/1panel.html) | [CasaOS デプロイ](https://astrbot.app/deploy/astrbot/casaos.html) | [手動デプロイ](https://astrbot.app/deploy/astrbot/cli.html)
|
||||
**その他のデプロイ方法**
|
||||
|
||||
パネル操作での導入やより高度なカスタマイズが必要な場合は、[宝塔パネルデプロイ](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` とソースベースのフルカスタム導入)を参照してください。
|
||||
|
||||
## サポートされているメッセージプラットフォーム
|
||||
|
||||
@@ -212,6 +223,7 @@ pre-commit install
|
||||
- 5群: 822130018
|
||||
- 6群: 753075035
|
||||
- 開発者群: 975206796
|
||||
- 開発者群(正式): 1039761811
|
||||
|
||||
### Discord サーバー
|
||||
|
||||
|
||||
+28
-16
@@ -73,7 +73,7 @@ AstrBot — это универсальная платформа Agent-чатб
|
||||
|
||||
### Развёртывание в один клик
|
||||
|
||||
Для пользователей, которые хотят быстро попробовать AstrBot, мы рекомендуем использовать развёртывание в один клик через `uv` ⚡️:
|
||||
Для пользователей, которые хотят быстро попробовать AstrBot, знакомы с командной строкой и могут самостоятельно установить окружение `uv`, мы рекомендуем использовать развёртывание в один клик через `uv` ⚡️:
|
||||
|
||||
```bash
|
||||
uv tool install astrbot
|
||||
@@ -83,47 +83,58 @@ 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 на десктопе, в основном работают с AstrBot ChatUI и редко используют плагины AstrBot, мы рекомендуем AstrBot App:
|
||||
Для пользователей, которые хотят использовать AstrBot на десктопе и в основном работают через ChatUI, мы рекомендуем AstrBot App.
|
||||
|
||||
Репозиторий десктопного приложения: [AstrBot-desktop](https://github.com/AstrBotDevs/AstrBot-desktop).
|
||||
Перейдите в [AstrBot-desktop](https://github.com/AstrBotDevs/AstrBot-desktop), скачайте и установите приложение; этот вариант предназначен для десктопа и не рекомендуется для серверных сценариев.
|
||||
|
||||
Поддерживает разные архитектуры систем, устанавливается напрямую и работает сразу после установки. Удобное настольное развёртывание в один клик для новичков. Не рекомендуется для серверных сценариев.
|
||||
### Развёртывание через лаунчер
|
||||
|
||||
### Установка в один клик через лаунчер (AstrBot Launcher)
|
||||
Также на десктопе, для пользователей, которым нужен быстрый запуск и мультиинстанс с изоляцией окружений, мы рекомендуем AstrBot Launcher.
|
||||
|
||||
Для пользователей, которым нужно быстрое развёртывание и мультиинстанс с изоляцией окружений, мы рекомендуем использовать AstrBot Launcher:
|
||||
|
||||
Перейдите в репозиторий [AstrBot Launcher](https://github.com/Raven95676/astrbot-launcher), откройте Releases и установите пакет для вашей системы из последней версии.
|
||||
|
||||
Быстрое развёртывание и мультиинстанс-решение с изоляцией окружений.
|
||||
Перейдите в [AstrBot Launcher](https://github.com/Raven95676/astrbot-launcher), чтобы скачать и установить.
|
||||
|
||||
### Развёртывание на 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) | [Развёртывание 1Panel](https://astrbot.app/deploy/astrbot/1panel.html) | [Развёртывание CasaOS](https://astrbot.app/deploy/astrbot/casaos.html) | [Ручное развёртывание](https://astrbot.app/deploy/astrbot/cli.html)
|
||||
**Другие способы развёртывания**
|
||||
|
||||
Если вам нужна панельная установка или более глубокая кастомизация, смотрите [Развёртывание 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`).
|
||||
|
||||
## Поддерживаемые платформы обмена сообщениями
|
||||
|
||||
@@ -211,6 +222,7 @@ pre-commit install
|
||||
- Группа 5: 822130018
|
||||
- Группа 6: 753075035
|
||||
- Группа разработчиков: 975206796
|
||||
- Группа разработчиков (официальная): 1039761811
|
||||
|
||||
### Сервер Discord
|
||||
|
||||
|
||||
+32
-16
@@ -73,7 +73,7 @@ AstrBot 是一個開源的一站式 Agent 聊天機器人平台,可接入主
|
||||
|
||||
### 一鍵部署
|
||||
|
||||
對於想快速體驗 AstrBot 的使用者,我們推薦使用 `uv` 一鍵部署方式 ⚡️:
|
||||
對於想快速體驗 AstrBot、且熟悉命令列並能自行安裝 `uv` 環境的使用者,我們推薦使用 `uv` 一鍵部署方式 ⚡️。
|
||||
|
||||
```bash
|
||||
uv tool install astrbot
|
||||
@@ -83,11 +83,20 @@ 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)。
|
||||
|
||||
### 在雨雲上部署
|
||||
|
||||
@@ -95,35 +104,37 @@ astrbot
|
||||
|
||||
[](https://app.rainyun.com/apps/rca/store/5994?ref=NjU1ODg0)
|
||||
|
||||
### 桌面客戶端(Tauri)
|
||||
### 桌面客戶端部署
|
||||
|
||||
對於希望在桌面部署 AstrBot、以 AstrBot ChatUI 為主要使用方式、較少使用 AstrBot 外掛的使用者,我們推薦使用 AstrBot App:
|
||||
對於希望在桌面端使用 AstrBot、並以 ChatUI 為主要入口的使用者,我們推薦使用 AstrBot App。
|
||||
|
||||
桌面應用倉庫 [AstrBot-desktop](https://github.com/AstrBotDevs/AstrBot-desktop)。
|
||||
前往 [AstrBot-desktop](https://github.com/AstrBotDevs/AstrBot-desktop) 下載並安裝;此方式面向桌面使用,不建議伺服器場景。
|
||||
|
||||
支援多系統架構,安裝包直接安裝,開箱即用,最適合新手和懶人的一鍵桌面部署方案,不推薦伺服器場景。
|
||||
### 啟動器部署
|
||||
|
||||
### 啟動器一鍵部署(AstrBot Launcher)
|
||||
同樣在桌面端,對於希望快速部署並實現環境隔離多開的使用者,我們推薦使用 AstrBot Launcher。
|
||||
|
||||
對於希望快速部署並實現環境隔離多開的使用者,我們推薦使用 AstrBot Launcher:
|
||||
|
||||
進入 [AstrBot Launcher](https://github.com/Raven95676/astrbot-launcher) 倉庫,在 Releases 頁最新版本下找到對應的系統安裝包安裝即可。
|
||||
|
||||
一個快速部署和多開方案,實現環境隔離。
|
||||
前往 [AstrBot Launcher](https://github.com/Raven95676/astrbot-launcher) 下載並安裝。
|
||||
|
||||
### 在 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) | [1Panel](https://astrbot.app/deploy/astrbot/1panel.html) | [CasaOS](https://astrbot.app/deploy/astrbot/casaos.html) | [手動部署](https://astrbot.app/deploy/astrbot/cli.html)
|
||||
**更多部署方式**
|
||||
|
||||
若你需要面板化或更高自訂程度的部署,可參考 [寶塔面板](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` 的完整自訂安裝)。
|
||||
|
||||
## 支援的訊息平台
|
||||
|
||||
@@ -206,11 +217,16 @@ pre-commit install
|
||||
|
||||
### QQ 群組
|
||||
|
||||
- 9 群: 1076659624 (新)
|
||||
- 10 群: 1078079676 (新)
|
||||
- 1 群:322154837
|
||||
- 3 群:630166526
|
||||
- 5 群:822130018
|
||||
- 6 群:753075035
|
||||
- 開發者群:975206796
|
||||
- 7 群:743746109
|
||||
- 8 群:1030353265
|
||||
- 開發者群(闲聊吹水):975206796
|
||||
- 開發者群(正式):1039761811
|
||||
|
||||
### Discord 群組
|
||||
|
||||
|
||||
+30
-16
@@ -73,7 +73,7 @@ AstrBot 是一个开源的一站式 Agentic 个人和群聊助手,可在 QQ、
|
||||
|
||||
### 一键部署
|
||||
|
||||
对于想快速体验 AstrBot 的用户,我们推荐使用 `uv` 一键部署方式 ⚡️:
|
||||
对于想快速体验 AstrBot、且熟悉命令行并能够自行安装 `uv` 环境的用户,我们推荐使用 `uv` 一键部署方式 ⚡️。
|
||||
|
||||
```bash
|
||||
uv tool install astrbot
|
||||
@@ -83,11 +83,20 @@ 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)。
|
||||
|
||||
### 在 雨云 上部署
|
||||
|
||||
@@ -95,35 +104,37 @@ astrbot
|
||||
|
||||
[](https://app.rainyun.com/apps/rca/store/5994?ref=NjU1ODg0)
|
||||
|
||||
### 桌面客户端(Tauri)
|
||||
### 桌面客户端部署
|
||||
|
||||
对于希望在桌面部署 AstrBot、以 AstrBot ChatUI 为主要使用方式、较少使用 AstrBot 插件的用户,我们推荐使用 AstrBot App:
|
||||
对于希望在桌面端使用 AstrBot、并以 ChatUI 为主要入口的用户,我们推荐使用 AstrBot App。
|
||||
|
||||
桌面应用仓库 [AstrBot-desktop](https://github.com/AstrBotDevs/AstrBot-desktop)。
|
||||
前往 [AstrBot-desktop](https://github.com/AstrBotDevs/AstrBot-desktop) 下载并安装;该方式面向桌面使用,不推荐服务器场景。
|
||||
|
||||
支持多系统架构,安装包直接安装,开箱即用,最适合新手和懒人的一键桌面部署方案,不推荐服务器场景。
|
||||
### 启动器部署
|
||||
|
||||
### 启动器一键部署(AstrBot Launcher)
|
||||
同样在桌面端,希望快速部署并实现环境隔离多开的用户,我们推荐使用 AstrBot Launcher。
|
||||
|
||||
对于希望快速部署并实现环境隔离多开的用户,我们推荐使用 AstrBot Launcher:
|
||||
|
||||
进入 [AstrBot Launcher](https://github.com/Raven95676/astrbot-launcher) 仓库,在 Releases 页最新版本下找到对应的系统安装包安装即可。
|
||||
|
||||
一个快速部署和多开方案,实现环境隔离。
|
||||
前往 [AstrBot Launcher](https://github.com/Raven95676/astrbot-launcher) 下载并安装。
|
||||
|
||||
### 在 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) | [1Panel](https://astrbot.app/deploy/astrbot/1panel.html) | [CasaOS](https://astrbot.app/deploy/astrbot/casaos.html) | [手动部署](https://astrbot.app/deploy/astrbot/cli.html)
|
||||
**更多部署方式**
|
||||
|
||||
若你需要面板化或更高自定义部署,可参考 [宝塔面板](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` 的完整自定义安装)。
|
||||
|
||||
## 支持的消息平台
|
||||
|
||||
@@ -207,13 +218,16 @@ pre-commit install
|
||||
|
||||
### QQ 群组
|
||||
|
||||
- 9 群: 1076659624 (新)
|
||||
- 10 群: 1078079676 (新)
|
||||
- 1 群:322154837
|
||||
- 3 群:630166526
|
||||
- 5 群:822130018
|
||||
- 6 群:753075035
|
||||
- 7 群:743746109
|
||||
- 8 群:1030353265
|
||||
- 开发者群:975206796
|
||||
- 开发者群(偏闲聊吹水):975206796
|
||||
- 开发者群(正式):1039761811
|
||||
|
||||
### Discord 频道
|
||||
|
||||
|
||||
@@ -1 +1 @@
|
||||
__version__ = "4.18.3"
|
||||
__version__ = "4.19.5"
|
||||
|
||||
@@ -4,7 +4,21 @@ from astrbot.core.config import AstrBotConfig
|
||||
from astrbot.core.config.default import DB_PATH
|
||||
from astrbot.core.db.sqlite import SQLiteDatabase
|
||||
from astrbot.core.file_token_service import FileTokenService
|
||||
from astrbot.core.utils.pip_installer import PipInstaller
|
||||
from astrbot.core.utils.pip_installer import (
|
||||
DependencyConflictError as DependencyConflictError,
|
||||
)
|
||||
from astrbot.core.utils.pip_installer import (
|
||||
PipInstaller,
|
||||
)
|
||||
from astrbot.core.utils.requirements_utils import (
|
||||
RequirementsPrecheckFailed as RequirementsPrecheckFailed,
|
||||
)
|
||||
from astrbot.core.utils.requirements_utils import (
|
||||
find_missing_requirements as find_missing_requirements,
|
||||
)
|
||||
from astrbot.core.utils.requirements_utils import (
|
||||
find_missing_requirements_or_raise as find_missing_requirements_or_raise,
|
||||
)
|
||||
from astrbot.core.utils.shared_preferences import SharedPreferences
|
||||
from astrbot.core.utils.t2i.renderer import HtmlRenderer
|
||||
|
||||
|
||||
@@ -144,10 +144,14 @@ class MCPClient:
|
||||
|
||||
cfg = _prepare_config(mcp_server_config.copy())
|
||||
|
||||
def logging_callback(msg: str) -> None:
|
||||
def logging_callback(
|
||||
msg: str | mcp.types.LoggingMessageNotificationParams,
|
||||
) -> None:
|
||||
# Handle MCP service error logs
|
||||
print(f"MCP Server {name} Error: {msg}")
|
||||
self.server_errlogs.append(msg)
|
||||
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)
|
||||
|
||||
if "url" in cfg:
|
||||
success, error_msg = await _quick_test_mcp_connection(cfg)
|
||||
@@ -214,15 +218,24 @@ class MCPClient:
|
||||
**cfg,
|
||||
)
|
||||
|
||||
def callback(msg: str) -> None:
|
||||
def callback(msg: str | mcp.types.LoggingMessageNotificationParams) -> None:
|
||||
# Handle MCP service error logs
|
||||
self.server_errlogs.append(msg)
|
||||
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)
|
||||
|
||||
stdio_transport = await self.exit_stack.enter_async_context(
|
||||
mcp.stdio_client(
|
||||
server_params,
|
||||
errlog=LogPipe(
|
||||
level=logging.ERROR,
|
||||
level=logging.INFO,
|
||||
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_event_loop().run_in_executor(
|
||||
item_type, item_data = await asyncio.get_running_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_event_loop().run_in_executor(None, partial)
|
||||
response = await asyncio.get_running_loop().run_in_executor(None, partial)
|
||||
|
||||
async for resp in self._handle_streaming_response(response, session_id):
|
||||
yield resp
|
||||
|
||||
@@ -204,7 +204,7 @@ class SendMessageToUserTool(FunctionTool[AstrAgentContext]):
|
||||
"type": "string",
|
||||
"description": (
|
||||
"Component type. One of: "
|
||||
"plain, image, record, file, mention_user"
|
||||
"plain, image, record, video, file, mention_user. Record is voice message."
|
||||
),
|
||||
},
|
||||
"text": {
|
||||
@@ -320,6 +320,19 @@ class SendMessageToUserTool(FunctionTool[AstrAgentContext]):
|
||||
components.append(Comp.Record.fromURL(url=url))
|
||||
else:
|
||||
return f"error: messages[{idx}] must include path or url for record component."
|
||||
elif msg_type == "video":
|
||||
path = msg.get("path")
|
||||
url = msg.get("url")
|
||||
if path:
|
||||
(
|
||||
local_path,
|
||||
file_from_sandbox,
|
||||
) = await self._resolve_path_from_sandbox(context, path)
|
||||
components.append(Comp.Video.fromFileSystem(path=local_path))
|
||||
elif url:
|
||||
components.append(Comp.Video.fromURL(url=url))
|
||||
else:
|
||||
return f"error: messages[{idx}] must include path or url for video component."
|
||||
elif msg_type == "file":
|
||||
path = msg.get("path")
|
||||
url = msg.get("url")
|
||||
|
||||
@@ -121,11 +121,12 @@ 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"
|
||||
deadline = asyncio.get_event_loop().time() + timeout
|
||||
loop = asyncio.get_running_loop()
|
||||
deadline = loop.time() + timeout
|
||||
last_error: str = ""
|
||||
|
||||
async with aiohttp.ClientSession() as session:
|
||||
while asyncio.get_event_loop().time() < deadline:
|
||||
while loop.time() < deadline:
|
||||
try:
|
||||
async with session.get(
|
||||
url, timeout=aiohttp.ClientTimeout(total=3)
|
||||
|
||||
@@ -422,6 +422,12 @@ async def get_booter(
|
||||
) -> ComputerBooter:
|
||||
config = context.get_config(umo=session_id)
|
||||
|
||||
runtime = config.get("provider_settings", {}).get("computer_use_runtime", "local")
|
||||
if runtime == "local":
|
||||
return get_local_booter()
|
||||
elif runtime == "none":
|
||||
raise RuntimeError("Sandbox runtime is disabled by configuration.")
|
||||
|
||||
sandbox_cfg = config.get("provider_settings", {}).get("sandbox", {})
|
||||
booter_type = sandbox_cfg.get("booter", "shipyard_neo")
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ from typing import Any, TypedDict
|
||||
|
||||
from astrbot.core.utils.astrbot_path import get_astrbot_data_path
|
||||
|
||||
VERSION = "4.18.3"
|
||||
VERSION = "4.19.5"
|
||||
DB_PATH = os.path.join(get_astrbot_data_path(), "data_v4.db")
|
||||
|
||||
WEBHOOK_SUPPORTED_PLATFORMS = [
|
||||
@@ -219,6 +219,9 @@ DEFAULT_CONFIG = {
|
||||
"telegram": {
|
||||
"pre_ack_emoji": {"enable": False, "emojis": ["✍️"]},
|
||||
},
|
||||
"discord": {
|
||||
"pre_ack_emoji": {"enable": False, "emojis": ["🤔"]},
|
||||
},
|
||||
},
|
||||
"wake_prefix": ["/"],
|
||||
"log_level": "INFO",
|
||||
@@ -342,14 +345,20 @@ 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,
|
||||
"token": "",
|
||||
"encoding_aes_key": "",
|
||||
"wecomaibot_ws_url": "wss://openws.work.weixin.qq.com",
|
||||
"wecomaibot_heartbeat_interval": 30,
|
||||
"unified_webhook_mode": True,
|
||||
"webhook_uuid": "",
|
||||
"callback_server_host": "0.0.0.0",
|
||||
@@ -732,6 +741,13 @@ 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",
|
||||
@@ -742,6 +758,22 @@ 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",
|
||||
@@ -752,6 +784,40 @@ 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",
|
||||
@@ -796,7 +862,7 @@ CONFIG_METADATA_2 = {
|
||||
"unified_webhook_mode": {
|
||||
"description": "统一 Webhook 模式",
|
||||
"type": "bool",
|
||||
"hint": "启用后,将使用 AstrBot 统一 Webhook 入口,无需单独开启端口。回调地址为 /api/platform/webhook/{webhook_uuid}。",
|
||||
"hint": "Webhook 模式下使用 AstrBot 统一 Webhook 入口,无需单独开启端口。回调地址为 /api/platform/webhook/{webhook_uuid}。",
|
||||
},
|
||||
"webhook_uuid": {
|
||||
"invisible": True,
|
||||
@@ -1123,7 +1189,7 @@ CONFIG_METADATA_2 = {
|
||||
"enable": True,
|
||||
"key": [],
|
||||
"timeout": 120,
|
||||
"api_base": "https://openrouter.ai/v1",
|
||||
"api_base": "https://openrouter.ai/api/v1",
|
||||
"proxy": "",
|
||||
"custom_headers": {},
|
||||
},
|
||||
|
||||
@@ -539,13 +539,36 @@ class Reply(BaseMessageComponent):
|
||||
|
||||
|
||||
class Poke(BaseMessageComponent):
|
||||
type: str = ComponentType.Poke
|
||||
id: int | None = 0
|
||||
qq: int | None = 0
|
||||
type: ComponentType = ComponentType.Poke
|
||||
_type: str | int = "126"
|
||||
id: int | str | None = 0
|
||||
qq: int | str | None = 0 # deprecated: legacy field, kept for compatibility
|
||||
|
||||
def __init__(self, type: str, **_) -> None:
|
||||
type = f"Poke:{type}"
|
||||
super().__init__(type=type, **_)
|
||||
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}
|
||||
|
||||
|
||||
class Forward(BaseMessageComponent):
|
||||
@@ -676,21 +699,24 @@ class File(BaseMessageComponent):
|
||||
|
||||
if self.url:
|
||||
try:
|
||||
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())
|
||||
# 检查是否有正在运行的 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("文件下载失败")
|
||||
|
||||
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.id != 0 and comp.qq != 0, # 戳一戳
|
||||
Comp.Poke: lambda comp: comp.target_id() is not None, # 戳一戳
|
||||
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, File, Image, Node, Plain, Record, Reply
|
||||
from astrbot.core.message.components import At, 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,8 +383,11 @@ class ResultDecorateStage(Stage):
|
||||
)
|
||||
result.chain = [node]
|
||||
|
||||
has_plain = any(isinstance(item, Plain) for item in result.chain)
|
||||
if has_plain:
|
||||
# at 回复 / 引用回复仅适用于纯文本或图文消息
|
||||
can_decorate = all(
|
||||
isinstance(item, (Plain, Image)) for item in result.chain
|
||||
)
|
||||
if can_decorate:
|
||||
# at 回复
|
||||
if (
|
||||
self.reply_with_mention
|
||||
@@ -399,5 +402,4 @@ class ResultDecorateStage(Stage):
|
||||
|
||||
# 引用回复
|
||||
if self.reply_with_quote:
|
||||
if not any(isinstance(item, File) for item in result.chain):
|
||||
result.chain.insert(0, Reply(id=event.message_obj.message_id))
|
||||
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(qq=str(event["target_id"]), type="poke"))
|
||||
abm.message.append(Poke(id=str(event["target_id"])))
|
||||
|
||||
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, Image, Plain, Record, Video
|
||||
from astrbot.api.message_components import At, File, Image, Plain, Record, Video
|
||||
from astrbot.api.platform import (
|
||||
AstrBotMessage,
|
||||
MessageMember,
|
||||
@@ -178,29 +178,110 @@ 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:
|
||||
plains += content["text"]
|
||||
abm.message.append(Plain(plains))
|
||||
plain_text = cast(str, content.get("text") or "")
|
||||
if plain_text:
|
||||
plain_parts.append(plain_text)
|
||||
abm.message.append(Plain(plain_text))
|
||||
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(
|
||||
content["downloadCode"],
|
||||
cast(str, message.robot_code),
|
||||
download_code,
|
||||
robot_code,
|
||||
"jpg",
|
||||
)
|
||||
abm.message.append(Image.fromFileSystem(f_path))
|
||||
case "audio":
|
||||
pass
|
||||
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))
|
||||
|
||||
await self._remember_sender_binding(message, abm)
|
||||
return abm # 别忘了返回转换后的消息对象
|
||||
@@ -270,13 +351,23 @@ class DingtalkPlatformAdapter(Platform):
|
||||
)
|
||||
return ""
|
||||
resp_data = await resp.json()
|
||||
download_url = resp_data["data"]["downloadUrl"]
|
||||
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 ""
|
||||
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_event_loop().run_in_executor(
|
||||
access_token = await asyncio.get_running_loop().run_in_executor(
|
||||
None,
|
||||
self.client_.get_access_token,
|
||||
)
|
||||
@@ -541,6 +632,28 @@ 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,
|
||||
@@ -647,7 +760,7 @@ class DingtalkPlatformAdapter(Platform):
|
||||
return
|
||||
logger.error(f"钉钉机器人启动失败: {e}")
|
||||
|
||||
loop = asyncio.get_event_loop()
|
||||
loop = asyncio.get_running_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=False
|
||||
"lark", "飞书机器人官方 API 适配器", support_streaming_message=True
|
||||
)
|
||||
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=False,
|
||||
support_streaming_message=True,
|
||||
)
|
||||
|
||||
async def convert_msg(self, event: lark.im.v1.P2ImMessageReceiveV1) -> None:
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import asyncio
|
||||
import base64
|
||||
import json
|
||||
import os
|
||||
@@ -5,6 +6,14 @@ 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,
|
||||
@@ -28,6 +37,7 @@ from astrbot.core.utils.media_utils import (
|
||||
convert_video_format,
|
||||
get_media_duration,
|
||||
)
|
||||
from astrbot.core.utils.metrics import Metric
|
||||
|
||||
|
||||
class LarkMessageEvent(AstrMessageEvent):
|
||||
@@ -555,15 +565,257 @@ class LarkMessageEvent(AstrMessageEvent):
|
||||
logger.error(f"发送飞书表情回应失败({response.code}): {response.msg}")
|
||||
return
|
||||
|
||||
async def send_streaming(self, generator, use_fallback: bool = False):
|
||||
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):
|
||||
"""回退到非流式发送:缓冲全部文本后一次性发送,并保留父类副作用。"""
|
||||
buffer = None
|
||||
async for chain in generator:
|
||||
if not buffer:
|
||||
buffer = chain
|
||||
else:
|
||||
buffer.chain.extend(chain.chain)
|
||||
if not buffer:
|
||||
return None
|
||||
buffer.squash_plain()
|
||||
await self.send(buffer)
|
||||
return await super().send_streaming(generator, use_fallback)
|
||||
|
||||
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
|
||||
|
||||
@@ -80,7 +80,7 @@ class QQOfficialMessageEvent(AstrMessageEvent):
|
||||
|
||||
if isinstance(source, botpy.message.C2CMessage):
|
||||
# 真流式传输
|
||||
current_time = asyncio.get_event_loop().time()
|
||||
current_time = asyncio.get_running_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_event_loop().time()
|
||||
last_edit_time = asyncio.get_running_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_event_loop(),
|
||||
loop=asyncio.get_running_loop(),
|
||||
api=self.api,
|
||||
)
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import asyncio
|
||||
import os
|
||||
import re
|
||||
from collections.abc import Callable
|
||||
from typing import Any, cast
|
||||
|
||||
import telegramify_markdown
|
||||
@@ -21,6 +22,7 @@ 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):
|
||||
@@ -34,6 +36,20 @@ 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,
|
||||
@@ -339,6 +355,118 @@ 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
|
||||
|
||||
@@ -356,6 +484,138 @@ 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
|
||||
@@ -366,123 +626,69 @@ class TelegramPlatformEvent(AstrMessageEvent):
|
||||
|
||||
# 发送初始 typing 状态
|
||||
await self._ensure_typing(user_name, message_thread_id)
|
||||
last_chat_action_time = asyncio.get_event_loop().time()
|
||||
last_chat_action_time = asyncio.get_running_loop().time()
|
||||
|
||||
def _append_text(t: str) -> None:
|
||||
nonlocal delta
|
||||
delta += t
|
||||
|
||||
async for chain in generator:
|
||||
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 not isinstance(chain, MessageChain):
|
||||
continue
|
||||
|
||||
# 处理消息链中的每个组件
|
||||
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),
|
||||
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,
|
||||
)
|
||||
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
|
||||
except Exception as e:
|
||||
logger.warning(f"编辑消息失败(streaming-break): {e!s}")
|
||||
message_id = None
|
||||
delta = ""
|
||||
continue
|
||||
|
||||
# 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
|
||||
await self._process_chain_items(
|
||||
chain, payload, user_name, message_thread_id, _append_text
|
||||
)
|
||||
|
||||
# 如果距离上次编辑的时间 >= 设定的间隔,等待一段时间
|
||||
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 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 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)
|
||||
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}")
|
||||
message_id = msg.message_id
|
||||
last_edit_time = (
|
||||
asyncio.get_event_loop().time()
|
||||
) # 记录初始消息发送时间
|
||||
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()
|
||||
|
||||
try:
|
||||
if delta and current_content != delta:
|
||||
@@ -506,5 +712,3 @@ 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_event_loop().run_in_executor(
|
||||
msg_new = await asyncio.get_running_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_event_loop()
|
||||
loop = asyncio.get_running_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_event_loop().run_in_executor(
|
||||
resp: Response = await asyncio.get_running_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_event_loop().run_in_executor(
|
||||
resp: Response = await asyncio.get_running_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_event_loop().run_in_executor(
|
||||
resp: Response = await asyncio.get_running_loop().run_in_executor(
|
||||
None,
|
||||
self.client.media.download,
|
||||
media_id,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
"""企业微信智能机器人平台适配器
|
||||
基于企业微信智能机器人 API 的消息平台适配器,支持 HTTP 回调
|
||||
基于企业微信智能机器人 API 的消息平台适配器,支持 HTTP 回调与长连接
|
||||
参考webchat_adapter.py的队列机制,实现异步消息处理和流式响应
|
||||
"""
|
||||
|
||||
@@ -31,6 +31,7 @@ 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 (
|
||||
@@ -78,8 +79,13 @@ class WecomAIBotAdapter(Platform):
|
||||
self.settings = platform_settings
|
||||
|
||||
# 初始化配置参数
|
||||
self.token = self.config["token"]
|
||||
self.encoding_aes_key = self.config["encoding_aes_key"]
|
||||
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.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", "")
|
||||
@@ -96,25 +102,52 @@ 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),
|
||||
)
|
||||
|
||||
# 初始化 API 客户端
|
||||
self.api_client = WecomAIBotAPIClient(self.token, self.encoding_aes_key)
|
||||
self.api_client: WecomAIBotAPIClient | None = None
|
||||
self.server: WecomAIBotServer | None = None
|
||||
self.long_connection_client: WecomAIBotLongConnectionClient | None = None
|
||||
|
||||
# 初始化 HTTP 服务器
|
||||
self.server = WecomAIBotServer(
|
||||
host=self.host,
|
||||
port=self.port,
|
||||
api_client=self.api_client,
|
||||
message_handler=self._process_message,
|
||||
)
|
||||
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,
|
||||
)
|
||||
|
||||
# 事件循环和关闭信号
|
||||
self.shutdown_event = asyncio.Event()
|
||||
@@ -161,6 +194,9 @@ 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}")
|
||||
@@ -320,6 +356,89 @@ 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")
|
||||
@@ -355,15 +474,16 @@ class WecomAIBotAdapter(Platform):
|
||||
content = ""
|
||||
image_base64 = []
|
||||
|
||||
_img_url_to_process = []
|
||||
_img_url_to_process: list[tuple[str, str | None]] = []
|
||||
msg_items = []
|
||||
|
||||
if msgtype == WecomAIBotConstants.MSG_TYPE_TEXT:
|
||||
content = WecomAIBotMessageParser.parse_text_message(message_data)
|
||||
elif msgtype == WecomAIBotConstants.MSG_TYPE_IMAGE:
|
||||
_img_url_to_process.append(
|
||||
WecomAIBotMessageParser.parse_image_message(message_data),
|
||||
)
|
||||
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")))
|
||||
elif msgtype == WecomAIBotConstants.MSG_TYPE_MIXED:
|
||||
# 提取混合消息中的文本内容
|
||||
msg_items = WecomAIBotMessageParser.parse_mixed_message(message_data)
|
||||
@@ -374,9 +494,12 @@ class WecomAIBotAdapter(Platform):
|
||||
if text_content:
|
||||
text_parts.append(text_content)
|
||||
elif item.get("msgtype") == WecomAIBotConstants.MSG_TYPE_IMAGE:
|
||||
image_url = item.get("image", {}).get("url", "")
|
||||
image_payload = item.get("image", {})
|
||||
image_url = image_payload.get("url", "")
|
||||
if image_url:
|
||||
_img_url_to_process.append(image_url)
|
||||
_img_url_to_process.append(
|
||||
(image_url, image_payload.get("aeskey"))
|
||||
)
|
||||
content = " ".join(text_parts) if text_parts else ""
|
||||
else:
|
||||
content = f"[{msgtype}消息]"
|
||||
@@ -384,8 +507,8 @@ class WecomAIBotAdapter(Platform):
|
||||
# 并行处理图片下载和解密
|
||||
if _img_url_to_process:
|
||||
tasks = [
|
||||
process_encrypted_image(url, self.encoding_aes_key)
|
||||
for url in _img_url_to_process
|
||||
process_encrypted_image(url, aes_key or self.encoding_aes_key)
|
||||
for url, aes_key in _img_url_to_process
|
||||
]
|
||||
results = await asyncio.gather(*tasks)
|
||||
for success, result in results:
|
||||
@@ -459,26 +582,43 @@ class WecomAIBotAdapter(Platform):
|
||||
"""运行适配器,同时启动HTTP服务器和队列监听器"""
|
||||
|
||||
async def run_both() -> None:
|
||||
# 如果启用统一 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 self.connection_mode == "long_connection":
|
||||
if not self.long_connection_client:
|
||||
raise RuntimeError("长连接客户端未初始化")
|
||||
logger.info(
|
||||
"启动企业微信智能机器人适配器,监听 %s:%d", self.host, self.port
|
||||
"启动企业微信智能机器人长连接模式: %s", self.long_connection_ws_url
|
||||
)
|
||||
# 同时运行HTTP服务器和队列监听器
|
||||
await asyncio.gather(
|
||||
self.server.start_server(),
|
||||
self.long_connection_client.start(),
|
||||
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)
|
||||
@@ -489,7 +629,10 @@ class WecomAIBotAdapter(Platform):
|
||||
"""终止适配器"""
|
||||
logger.info("企业微信智能机器人适配器正在关闭...")
|
||||
self.shutdown_event.set()
|
||||
await self.server.shutdown()
|
||||
if self.long_connection_client:
|
||||
await self.long_connection_client.shutdown()
|
||||
if self.server:
|
||||
await self.server.shutdown()
|
||||
|
||||
def meta(self) -> PlatformMetadata:
|
||||
"""获取平台元数据"""
|
||||
@@ -507,17 +650,22 @@ 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:
|
||||
def get_client(self) -> WecomAIBotAPIClient | None:
|
||||
"""获取 API 客户端"""
|
||||
return self.api_client
|
||||
|
||||
def get_server(self) -> WecomAIBotServer:
|
||||
def get_server(self) -> WecomAIBotServer | None:
|
||||
"""获取 HTTP 服务器实例"""
|
||||
return self.server
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
"""企业微信智能机器人事件处理模块,处理消息事件的发送和接收"""
|
||||
|
||||
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
|
||||
@@ -18,10 +20,11 @@ class WecomAIBotMessageEvent(AstrMessageEvent):
|
||||
message_obj,
|
||||
platform_meta,
|
||||
session_id: str,
|
||||
api_client: WecomAIBotAPIClient,
|
||||
api_client: WecomAIBotAPIClient | None,
|
||||
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:
|
||||
"""初始化消息事件
|
||||
|
||||
@@ -38,6 +41,7 @@ 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)
|
||||
@@ -117,6 +121,18 @@ 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
|
||||
@@ -124,6 +140,44 @@ 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)
|
||||
@@ -152,8 +206,77 @@ 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:
|
||||
|
||||
@@ -0,0 +1,236 @@
|
||||
"""企业微信智能机器人长连接客户端。"""
|
||||
|
||||
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,6 +4,7 @@
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import time
|
||||
from collections.abc import Awaitable, Callable
|
||||
from typing import Any
|
||||
|
||||
@@ -82,7 +83,7 @@ class WecomAIQueueMgr:
|
||||
del self.pending_responses[session_id]
|
||||
logger.debug(f"[WecomAI] 移除待处理响应: {session_id}")
|
||||
if mark_finished:
|
||||
self.completed_streams[session_id] = asyncio.get_event_loop().time()
|
||||
self.completed_streams[session_id] = time.monotonic()
|
||||
logger.debug(f"[WecomAI] 标记流已结束: {session_id}")
|
||||
|
||||
def remove_queue(self, session_id: str):
|
||||
@@ -135,7 +136,7 @@ class WecomAIQueueMgr:
|
||||
"""
|
||||
self.pending_responses[session_id] = {
|
||||
"callback_params": callback_params,
|
||||
"timestamp": asyncio.get_event_loop().time(),
|
||||
"timestamp": time.monotonic(),
|
||||
}
|
||||
logger.debug(f"[WecomAI] 设置待处理响应: {session_id}")
|
||||
|
||||
@@ -160,7 +161,7 @@ class WecomAIQueueMgr:
|
||||
finished_at = self.completed_streams.get(session_id)
|
||||
if finished_at is None:
|
||||
return False
|
||||
if asyncio.get_event_loop().time() - finished_at > max_age_seconds:
|
||||
if time.monotonic() - finished_at > max_age_seconds:
|
||||
self.completed_streams.pop(session_id, None)
|
||||
return False
|
||||
return True
|
||||
@@ -172,7 +173,7 @@ class WecomAIQueueMgr:
|
||||
max_age_seconds: 最大存活时间(秒)
|
||||
|
||||
"""
|
||||
current_time = asyncio.get_event_loop().time()
|
||||
current_time = time.monotonic()
|
||||
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_event_loop().create_future()
|
||||
future = asyncio.get_running_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_event_loop().run_in_executor(
|
||||
resp: Response = await asyncio.get_running_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 = 20.0
|
||||
DEFAULT_ENABLE_MCP_TIMEOUT_SECONDS = 30.0
|
||||
DEFAULT_MCP_INIT_TIMEOUT_SECONDS = 180.0
|
||||
DEFAULT_ENABLE_MCP_TIMEOUT_SECONDS = 180.0
|
||||
MCP_INIT_TIMEOUT_ENV = "ASTRBOT_MCP_INIT_TIMEOUT"
|
||||
ENABLE_MCP_TIMEOUT_ENV = "ASTRBOT_MCP_ENABLE_TIMEOUT"
|
||||
MAX_MCP_TIMEOUT_SECONDS = 300.0
|
||||
@@ -417,9 +417,11 @@ class FunctionToolManager:
|
||||
for (name, cfg, _), result in zip(active_configs, results, strict=False):
|
||||
if isinstance(result, Exception):
|
||||
if isinstance(result, MCPInitTimeoutError):
|
||||
logger.error(f"MCP 服务 {name} 初始化超时({timeout_display}秒)")
|
||||
logger.error(
|
||||
f"Connected to MCP server {name} timeout ({timeout_display} seconds)"
|
||||
)
|
||||
else:
|
||||
logger.error(f"MCP 服务 {name} 初始化失败: {result}")
|
||||
logger.error(f"Failed to initialize MCP server {name}: {result}")
|
||||
self._log_safe_mcp_debug_config(cfg)
|
||||
failed_services.append(name)
|
||||
async with self._runtime_lock:
|
||||
@@ -430,16 +432,18 @@ class FunctionToolManager:
|
||||
|
||||
if failed_services:
|
||||
logger.warning(
|
||||
f"以下 MCP 服务初始化失败: {', '.join(failed_services)}。"
|
||||
f"请检查配置文件 mcp_server.json 和服务器可用性。"
|
||||
f"The following MCP services failed to initialize: {', '.join(failed_services)}. "
|
||||
f"Please check the mcp_server.json file and server availability."
|
||||
)
|
||||
|
||||
summary = MCPInitSummary(
|
||||
total=len(active_configs), success=success_count, failed=failed_services
|
||||
)
|
||||
logger.info(f"MCP 服务初始化完成: {summary.success}/{summary.total} 成功")
|
||||
logger.info(
|
||||
f"MCP services initialization completed: {summary.success}/{summary.total} successful, {len(summary.failed)} failed."
|
||||
)
|
||||
if summary.total > 0 and summary.success == 0:
|
||||
msg = "全部 MCP 服务初始化失败,请检查 mcp_server.json 配置和服务器可用性。"
|
||||
msg = "All MCP services failed to initialize, please check the mcp_server.json and server availability."
|
||||
if raise_on_all_failed:
|
||||
raise MCPAllServicesFailedError(msg)
|
||||
logger.error(msg)
|
||||
@@ -461,7 +465,7 @@ class FunctionToolManager:
|
||||
async with self._runtime_lock:
|
||||
if name in self._mcp_server_runtime or name in self._mcp_starting:
|
||||
logger.warning(
|
||||
f"MCP 服务 {name} 已在运行,忽略本次启用请求(timeout={timeout:g})。"
|
||||
f"Connected to MCP server {name}, ignoring this startup request (timeout={timeout:g})."
|
||||
)
|
||||
self._log_safe_mcp_debug_config(cfg)
|
||||
return
|
||||
@@ -478,10 +482,10 @@ class FunctionToolManager:
|
||||
)
|
||||
except asyncio.TimeoutError as exc:
|
||||
raise MCPInitTimeoutError(
|
||||
f"MCP 服务 {name} 初始化超时({timeout:g} 秒)"
|
||||
f"Connected to MCP server {name} timeout ({timeout:g} seconds)"
|
||||
) from exc
|
||||
except Exception:
|
||||
logger.error(f"初始化 MCP 客户端 {name} 失败", exc_info=True)
|
||||
logger.error(f"Failed to initialize MCP client {name}", exc_info=True)
|
||||
raise
|
||||
finally:
|
||||
if mcp_client is None:
|
||||
@@ -491,9 +495,9 @@ class FunctionToolManager:
|
||||
async def lifecycle() -> None:
|
||||
try:
|
||||
await shutdown_event.wait()
|
||||
logger.info(f"收到 MCP 客户端 {name} 终止信号")
|
||||
logger.info(f"Received shutdown signal for MCP client {name}")
|
||||
except asyncio.CancelledError:
|
||||
logger.debug(f"MCP 客户端 {name} 任务被取消")
|
||||
logger.debug(f"MCP client {name} task was cancelled")
|
||||
raise
|
||||
finally:
|
||||
await self._terminate_mcp_client(name)
|
||||
@@ -545,7 +549,7 @@ class FunctionToolManager:
|
||||
if strict:
|
||||
raise MCPShutdownTimeoutError(pending_names, timeout)
|
||||
logger.warning(
|
||||
"MCP 服务关闭超时(%s 秒),以下服务未完全关闭:%s",
|
||||
"MCP server shutdown timeout (%s seconds), the following servers were not fully closed: %s",
|
||||
f"{timeout:g}",
|
||||
", ".join(pending_names),
|
||||
)
|
||||
@@ -568,7 +572,9 @@ class FunctionToolManager:
|
||||
try:
|
||||
await mcp_client.cleanup()
|
||||
except Exception as cleanup_exc: # noqa: BLE001 - only log here
|
||||
logger.error(f"清理 MCP 客户端资源 {name} 失败: {cleanup_exc}")
|
||||
logger.error(
|
||||
f"Failed to cleanup MCP client resources {name}: {cleanup_exc}"
|
||||
)
|
||||
|
||||
async def _init_mcp_client(self, name: str, config: dict) -> MCPClient:
|
||||
"""初始化单个MCP客户端"""
|
||||
@@ -602,7 +608,7 @@ class FunctionToolManager:
|
||||
)
|
||||
self.func_list.append(func_tool)
|
||||
|
||||
logger.info(f"已连接 MCP 服务 {name}, Tools: {tool_names}")
|
||||
logger.info(f"Connected to MCP server {name}, Tools: {tool_names}")
|
||||
return mcp_client
|
||||
|
||||
async def _terminate_mcp_client(self, name: str) -> None:
|
||||
@@ -622,7 +628,7 @@ class FunctionToolManager:
|
||||
async with self._runtime_lock:
|
||||
self._mcp_server_runtime.pop(name, None)
|
||||
self._mcp_starting.discard(name)
|
||||
logger.info(f"已关闭 MCP 服务 {name}")
|
||||
logger.info(f"Disconnected from MCP server {name}")
|
||||
return
|
||||
|
||||
# Runtime missing but stale tools may still exist after failed flows.
|
||||
|
||||
@@ -79,6 +79,7 @@ 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,
|
||||
@@ -330,24 +331,16 @@ class ProviderManager:
|
||||
if not self.curr_tts_provider_inst and self.tts_provider_insts:
|
||||
self.curr_tts_provider_inst = self.tts_provider_insts[0]
|
||||
|
||||
# 初始化 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 以在此场景下中止启动)。"
|
||||
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",
|
||||
)
|
||||
|
||||
def dynamic_import_provider(self, type: str) -> None:
|
||||
@@ -817,6 +810,13 @@ 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,7 +281,24 @@ class TTSProvider(AbstractProvider):
|
||||
accumulated_text += text_part
|
||||
|
||||
async def test(self) -> None:
|
||||
await self.get_audio("hi")
|
||||
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
|
||||
|
||||
|
||||
class EmbeddingProvider(AbstractProvider):
|
||||
|
||||
@@ -276,9 +276,24 @@ class ProviderAnthropic(Provider):
|
||||
llm_response.id = completion.id
|
||||
llm_response.usage = self._extract_usage(completion.usage)
|
||||
|
||||
# TODO(Soulter): 处理 end_turn 情况
|
||||
# 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
|
||||
if not llm_response.completion_text and not llm_response.tools_call_args:
|
||||
raise Exception(f"Anthropic API 返回的 completion 无法解析:{completion}。")
|
||||
# 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
|
||||
|
||||
return llm_response
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@ 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:
|
||||
@@ -116,7 +117,7 @@ class AzureNativeProvider(TTSProvider):
|
||||
"azure_tts_subscription_key",
|
||||
"",
|
||||
).strip()
|
||||
if not re.fullmatch(r"^[a-zA-Z0-9]{32}$", self.subscription_key):
|
||||
if not re.fullmatch(AZURE_TTS_SUBSCRIPTION_KEY_PATTERN, self.subscription_key):
|
||||
raise ValueError("无效的Azure订阅密钥")
|
||||
self.region = provider_config.get("azure_tts_region", "eastus").strip()
|
||||
self.endpoint = (
|
||||
@@ -235,9 +236,9 @@ class AzureTTSProvider(TTSProvider):
|
||||
raise ValueError(error_msg) from e
|
||||
except KeyError as e:
|
||||
raise ValueError(f"配置错误: 缺少必要参数 {e}") from e
|
||||
if re.fullmatch(r"^[a-zA-Z0-9]{32}$", key_value):
|
||||
if re.fullmatch(AZURE_TTS_SUBSCRIPTION_KEY_PATTERN, key_value):
|
||||
return AzureNativeProvider(config, self.provider_settings)
|
||||
raise ValueError("订阅密钥格式无效,应为32位字母数字或other[...]格式")
|
||||
raise ValueError("订阅密钥格式无效,应为32位或84位字母数字或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_event_loop()
|
||||
loop = asyncio.get_running_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_event_loop()
|
||||
loop = asyncio.get_running_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_event_loop()
|
||||
loop = asyncio.get_running_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_event_loop()
|
||||
loop = asyncio.get_running_loop()
|
||||
|
||||
while True:
|
||||
text = await text_queue.get()
|
||||
|
||||
@@ -154,6 +154,14 @@ 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)
|
||||
@@ -161,4 +169,4 @@ class ProviderMiniMaxTTSAPI(TTSProvider):
|
||||
return path
|
||||
|
||||
except aiohttp.ClientError as e:
|
||||
raise e
|
||||
raise Exception(f"MiniMax TTS API request failed: {e!s}")
|
||||
|
||||
@@ -43,7 +43,7 @@ class ProviderSenseVoiceSTTSelfHost(STTProvider):
|
||||
logger.info("下载或者加载 SenseVoice 模型中,这可能需要一些时间 ...")
|
||||
|
||||
# 将模型加载放到线程池中执行
|
||||
self.model = await asyncio.get_event_loop().run_in_executor(
|
||||
self.model = await asyncio.get_running_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_event_loop()
|
||||
loop = asyncio.get_running_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_event_loop()
|
||||
loop = asyncio.get_running_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_event_loop()
|
||||
loop = asyncio.get_running_loop()
|
||||
|
||||
is_tencent = False
|
||||
|
||||
|
||||
@@ -26,6 +26,13 @@ _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
|
||||
@@ -401,7 +408,11 @@ class SkillManager:
|
||||
raise ValueError("Uploaded file is not a valid zip archive.")
|
||||
|
||||
with zipfile.ZipFile(zip_path) as zf:
|
||||
names = [name.replace("\\", "/") for name in zf.namelist()]
|
||||
names = [
|
||||
name
|
||||
for name in (entry.replace("\\", "/") for entry in zf.namelist())
|
||||
if name and not _is_ignored_zip_entry(name)
|
||||
]
|
||||
file_names = [name for name in names if name and not name.endswith("/")]
|
||||
if not file_names:
|
||||
raise ValueError("Zip archive is empty.")
|
||||
@@ -436,7 +447,11 @@ class SkillManager:
|
||||
raise ValueError("SKILL.md not found in the skill folder.")
|
||||
|
||||
with tempfile.TemporaryDirectory(dir=get_astrbot_temp_path()) as tmp_dir:
|
||||
zf.extractall(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)
|
||||
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.match(event.get_message_str().strip()))
|
||||
return bool(self.regex.search(event.get_message_str().strip()))
|
||||
|
||||
@@ -14,7 +14,12 @@ import yaml
|
||||
from packaging.specifiers import InvalidSpecifier, SpecifierSet
|
||||
from packaging.version import InvalidVersion, Version
|
||||
|
||||
from astrbot.core import logger, pip_installer, sp
|
||||
from astrbot.core import (
|
||||
DependencyConflictError,
|
||||
logger,
|
||||
pip_installer,
|
||||
sp,
|
||||
)
|
||||
from astrbot.core.agent.handoff import FunctionTool, HandoffTool
|
||||
from astrbot.core.config.astrbot_config import AstrBotConfig
|
||||
from astrbot.core.config.default import VERSION
|
||||
@@ -27,6 +32,10 @@ from astrbot.core.utils.astrbot_path import (
|
||||
)
|
||||
from astrbot.core.utils.io import remove_dir
|
||||
from astrbot.core.utils.metrics import Metric
|
||||
from astrbot.core.utils.requirements_utils import (
|
||||
RequirementsPrecheckFailed,
|
||||
find_missing_requirements_or_raise,
|
||||
)
|
||||
|
||||
from . import StarMetadata
|
||||
from .command_management import sync_command_configs
|
||||
@@ -48,6 +57,49 @@ class PluginVersionIncompatibleError(Exception):
|
||||
"""Raised when plugin astrbot_version is incompatible with current AstrBot."""
|
||||
|
||||
|
||||
class PluginDependencyInstallError(Exception):
|
||||
"""Raised when plugin dependency installation fails."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
plugin_label: str,
|
||||
requirements_path: str,
|
||||
error: Exception,
|
||||
) -> None:
|
||||
message = f"插件 {plugin_label} 依赖安装失败: {error!s}"
|
||||
super().__init__(message)
|
||||
self.plugin_label = plugin_label
|
||||
self.requirements_path = requirements_path
|
||||
self.error = error
|
||||
|
||||
|
||||
async def _install_requirements_with_precheck(
|
||||
*,
|
||||
plugin_label: str,
|
||||
requirements_path: str,
|
||||
) -> None:
|
||||
try:
|
||||
missing = find_missing_requirements_or_raise(requirements_path)
|
||||
except RequirementsPrecheckFailed:
|
||||
logger.info(
|
||||
f"正在安装插件 {plugin_label} 的依赖库(预检查失败,回退到完整安装): "
|
||||
f"{requirements_path}"
|
||||
)
|
||||
await pip_installer.install(requirements_path=requirements_path)
|
||||
return
|
||||
|
||||
if not missing:
|
||||
logger.info(f"插件 {plugin_label} 的依赖已满足,跳过安装。")
|
||||
return
|
||||
|
||||
logger.info(
|
||||
f"检测到插件 {plugin_label} 缺失依赖,正在按 requirements.txt 安装: "
|
||||
f"{requirements_path} -> {sorted(missing)}"
|
||||
)
|
||||
await pip_installer.install(requirements_path=requirements_path)
|
||||
|
||||
|
||||
class PluginManager:
|
||||
def __init__(self, context: Context, config: AstrBotConfig) -> None:
|
||||
from .star_tools import StarTools
|
||||
@@ -198,15 +250,37 @@ class PluginManager:
|
||||
to_update.append(p.root_dir_name)
|
||||
for p in to_update:
|
||||
plugin_path = os.path.join(plugin_dir, p)
|
||||
if os.path.exists(os.path.join(plugin_path, "requirements.txt")):
|
||||
pth = os.path.join(plugin_path, "requirements.txt")
|
||||
logger.info(f"正在安装插件 {p} 所需的依赖库: {pth}")
|
||||
try:
|
||||
await pip_installer.install(requirements_path=pth)
|
||||
except Exception as e:
|
||||
logger.error(f"更新插件 {p} 的依赖失败。Code: {e!s}")
|
||||
await self._ensure_plugin_requirements(plugin_path, p)
|
||||
return True
|
||||
|
||||
async def _ensure_plugin_requirements(
|
||||
self,
|
||||
plugin_dir_path: str,
|
||||
plugin_label: str,
|
||||
) -> None:
|
||||
requirements_path = os.path.join(plugin_dir_path, "requirements.txt")
|
||||
if not os.path.exists(requirements_path):
|
||||
return
|
||||
|
||||
try:
|
||||
await _install_requirements_with_precheck(
|
||||
plugin_label=plugin_label,
|
||||
requirements_path=requirements_path,
|
||||
)
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except DependencyConflictError as e:
|
||||
logger.error(f"插件 {plugin_label} 依赖冲突: {e!s}")
|
||||
raise
|
||||
except Exception as e:
|
||||
dependency_error = PluginDependencyInstallError(
|
||||
plugin_label=plugin_label,
|
||||
requirements_path=requirements_path,
|
||||
error=e,
|
||||
)
|
||||
logger.exception(str(dependency_error))
|
||||
raise dependency_error from e
|
||||
|
||||
async def _import_plugin_with_dependency_recovery(
|
||||
self,
|
||||
path: str,
|
||||
@@ -422,7 +496,7 @@ class PluginManager:
|
||||
root_dir_name: str,
|
||||
plugin_dir_path: str,
|
||||
reserved: bool,
|
||||
error: Exception | str,
|
||||
error: BaseException | str,
|
||||
error_trace: str,
|
||||
) -> dict:
|
||||
record: dict = {
|
||||
@@ -495,6 +569,9 @@ class PluginManager:
|
||||
|
||||
self._cleanup_plugin_state(dir_name)
|
||||
|
||||
plugin_path = os.path.join(self.plugin_store_path, dir_name)
|
||||
await self._ensure_plugin_requirements(plugin_path, dir_name)
|
||||
|
||||
success, error = await self.load(specified_dir_name=dir_name)
|
||||
if success:
|
||||
self.failed_plugin_dict.pop(dir_name, None)
|
||||
@@ -1078,6 +1155,10 @@ class PluginManager:
|
||||
|
||||
# reload the plugin
|
||||
dir_name = os.path.basename(plugin_path)
|
||||
await self._ensure_plugin_requirements(
|
||||
plugin_path,
|
||||
dir_name,
|
||||
)
|
||||
success, error_message = await self.load(
|
||||
specified_dir_name=dir_name,
|
||||
ignore_version_check=ignore_version_check,
|
||||
@@ -1317,6 +1398,12 @@ class PluginManager:
|
||||
raise Exception("该插件是 AstrBot 保留插件,无法更新。")
|
||||
|
||||
await self.updator.update(plugin, proxy=proxy)
|
||||
if plugin.root_dir_name:
|
||||
plugin_dir_path = os.path.join(self.plugin_store_path, plugin.root_dir_name)
|
||||
await self._ensure_plugin_requirements(
|
||||
plugin_dir_path,
|
||||
plugin_name,
|
||||
)
|
||||
await self.reload(plugin_name)
|
||||
|
||||
async def turn_off_plugin(self, plugin_name: str) -> None:
|
||||
@@ -1374,10 +1461,23 @@ class PluginManager:
|
||||
return
|
||||
|
||||
if "__del__" in star_metadata.star_cls_type.__dict__:
|
||||
asyncio.get_event_loop().run_in_executor(
|
||||
loop = asyncio.get_running_loop()
|
||||
future = 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()
|
||||
|
||||
@@ -1475,6 +1575,7 @@ class PluginManager:
|
||||
os.remove(zip_file_path)
|
||||
except BaseException as e:
|
||||
logger.warning(f"删除插件压缩包失败: {e!s}")
|
||||
await self._ensure_plugin_requirements(desti_dir, dir_name)
|
||||
# await self.reload()
|
||||
success, error_message = await self.load(
|
||||
specified_dir_name=dir_name,
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
import contextlib
|
||||
import functools
|
||||
import importlib.metadata as importlib_metadata
|
||||
import logging
|
||||
import os
|
||||
from collections.abc import Iterator
|
||||
|
||||
from packaging.requirements import Requirement
|
||||
|
||||
from astrbot.core.utils.requirements_utils import (
|
||||
canonicalize_distribution_name,
|
||||
collect_installed_distribution_versions,
|
||||
get_requirement_check_paths,
|
||||
)
|
||||
|
||||
logger = logging.getLogger("astrbot")
|
||||
|
||||
|
||||
def _resolve_core_dist_name(core_dist_name: str | None) -> str | None:
|
||||
if core_dist_name:
|
||||
try:
|
||||
importlib_metadata.distribution(core_dist_name)
|
||||
return core_dist_name
|
||||
except importlib_metadata.PackageNotFoundError:
|
||||
return None
|
||||
|
||||
try:
|
||||
importlib_metadata.distribution("AstrBot")
|
||||
return "AstrBot"
|
||||
except importlib_metadata.PackageNotFoundError:
|
||||
pass
|
||||
|
||||
if not __package__:
|
||||
return None
|
||||
|
||||
top_pkg = __package__.split(".")[0]
|
||||
for dist in importlib_metadata.distributions():
|
||||
try:
|
||||
top_level = dist.read_text("top_level.txt") or ""
|
||||
except Exception:
|
||||
continue
|
||||
if top_pkg in top_level.splitlines():
|
||||
if "Name" in dist.metadata:
|
||||
return dist.metadata["Name"]
|
||||
|
||||
return None
|
||||
|
||||
|
||||
@functools.cache
|
||||
def _get_core_constraints(core_dist_name: str | None) -> tuple[str, ...]:
|
||||
try:
|
||||
resolved_core_dist_name = _resolve_core_dist_name(core_dist_name)
|
||||
except Exception as exc:
|
||||
logger.warning("解析核心分发名称失败: %s", exc)
|
||||
return ()
|
||||
|
||||
if not resolved_core_dist_name:
|
||||
return ()
|
||||
|
||||
try:
|
||||
dist = importlib_metadata.distribution(resolved_core_dist_name)
|
||||
except importlib_metadata.PackageNotFoundError:
|
||||
return ()
|
||||
except Exception as exc:
|
||||
logger.warning("读取核心分发元数据失败 (%s): %s", resolved_core_dist_name, exc)
|
||||
return ()
|
||||
|
||||
if not dist or not dist.requires:
|
||||
return ()
|
||||
|
||||
installed = collect_installed_distribution_versions(get_requirement_check_paths())
|
||||
if not installed:
|
||||
return ()
|
||||
|
||||
constraints: list[str] = []
|
||||
for req_str in dist.requires:
|
||||
try:
|
||||
req = Requirement(req_str)
|
||||
if req.marker and not req.marker.evaluate():
|
||||
continue
|
||||
name = canonicalize_distribution_name(req.name)
|
||||
if name in installed:
|
||||
constraints.append(f"{name}=={installed[name]}")
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
return tuple(constraints)
|
||||
|
||||
|
||||
class CoreConstraintsProvider:
|
||||
def __init__(self, core_dist_name: str | None) -> None:
|
||||
self._core_dist_name = core_dist_name
|
||||
|
||||
@contextlib.contextmanager
|
||||
def constraints_file(self) -> Iterator[str | None]:
|
||||
constraints = _get_core_constraints(self._core_dist_name)
|
||||
if not constraints:
|
||||
yield None
|
||||
return
|
||||
|
||||
path: str | None = None
|
||||
try:
|
||||
import tempfile
|
||||
|
||||
with tempfile.NamedTemporaryFile(
|
||||
mode="w", suffix="_constraints.txt", delete=False, encoding="utf-8"
|
||||
) as f:
|
||||
f.write("\n".join(constraints))
|
||||
path = f.name
|
||||
logger.info("已启用核心依赖版本保护 (%d 个约束)", len(constraints))
|
||||
except Exception as exc:
|
||||
logger.warning("创建临时约束文件失败: %s", exc)
|
||||
yield None
|
||||
return
|
||||
|
||||
try:
|
||||
yield path
|
||||
finally:
|
||||
if path and os.path.exists(path):
|
||||
with contextlib.suppress(Exception):
|
||||
os.remove(path)
|
||||
@@ -7,21 +7,71 @@ import io
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import shlex
|
||||
import sys
|
||||
import threading
|
||||
from collections import deque
|
||||
from dataclasses import dataclass
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from astrbot.core.utils.astrbot_path import get_astrbot_site_packages_path
|
||||
from astrbot.core.utils.core_constraints import CoreConstraintsProvider
|
||||
from astrbot.core.utils.requirements_utils import (
|
||||
canonicalize_distribution_name as _canonicalize_distribution_name,
|
||||
)
|
||||
from astrbot.core.utils.requirements_utils import (
|
||||
extract_requirement_name,
|
||||
extract_requirement_names,
|
||||
parse_package_install_input,
|
||||
)
|
||||
from astrbot.core.utils.runtime_env import is_packaged_desktop_runtime
|
||||
|
||||
logger = logging.getLogger("astrbot")
|
||||
|
||||
_DISTLIB_FINDER_PATCH_ATTEMPTED = False
|
||||
_SITE_PACKAGES_IMPORT_LOCK = threading.RLock()
|
||||
_PIP_FAILURE_PATTERNS = {
|
||||
"error_prefix": re.compile(r"^\s*error:", re.IGNORECASE),
|
||||
"user_requested": re.compile(r"\bthe user requested\b", re.IGNORECASE),
|
||||
"resolution_impossible": re.compile(r"\bresolutionimpossible\b", re.IGNORECASE),
|
||||
"cannot_install": re.compile(r"\bcannot install\b", re.IGNORECASE),
|
||||
"conflict": re.compile(r"\bconflict(?:ing|s)?\b", re.IGNORECASE),
|
||||
"constraint": re.compile(r"\(constraint\)", re.IGNORECASE),
|
||||
"dependency_detail": re.compile(r"\bdepends on\b", re.IGNORECASE),
|
||||
}
|
||||
_SENSITIVE_PIP_VALUE_KEYS = frozenset(
|
||||
{"password", "passwd", "pass", "api_token", "token", "auth_token"}
|
||||
)
|
||||
_MAX_PIP_OUTPUT_LINES = 200
|
||||
|
||||
|
||||
def _canonicalize_distribution_name(name: str) -> str:
|
||||
return re.sub(r"[-_.]+", "-", name).strip("-").lower()
|
||||
class DependencyConflictError(Exception):
|
||||
"""Raised when pip encounters a dependency conflict."""
|
||||
|
||||
def __init__(
|
||||
self, message: str, errors: list[str], *, is_core_conflict: bool
|
||||
) -> None:
|
||||
super().__init__(message)
|
||||
self.errors = errors
|
||||
self.is_core_conflict = is_core_conflict
|
||||
|
||||
|
||||
class PipInstallError(Exception):
|
||||
"""Raised when pip install fails without a classified dependency conflict."""
|
||||
|
||||
def __init__(self, message: str, *, code: int) -> None:
|
||||
super().__init__(message)
|
||||
self.code = code
|
||||
|
||||
|
||||
@dataclass
|
||||
class PipConflictContext:
|
||||
relevant_lines: list[str]
|
||||
requested_lines: list[str]
|
||||
dependency_detail_lines: list[str]
|
||||
constraint_lines: list[str]
|
||||
has_strong_conflict_signal: bool
|
||||
has_contextual_conflict_signal: bool
|
||||
|
||||
|
||||
def _get_pip_main():
|
||||
@@ -41,11 +91,12 @@ def _get_pip_main():
|
||||
return pip_main
|
||||
|
||||
|
||||
def _run_pip_main_with_output(pip_main, args: list[str]) -> tuple[int, str]:
|
||||
stream = io.StringIO()
|
||||
with contextlib.redirect_stdout(stream), contextlib.redirect_stderr(stream):
|
||||
result_code = pip_main(args)
|
||||
return result_code, stream.getvalue()
|
||||
def _prepend_sys_path(path: str) -> None:
|
||||
normalized_target = os.path.realpath(path)
|
||||
sys.path[:] = [
|
||||
item for item in sys.path if os.path.realpath(item) != normalized_target
|
||||
]
|
||||
sys.path.insert(0, normalized_target)
|
||||
|
||||
|
||||
def _cleanup_added_root_handlers(original_handlers: list[logging.Handler]) -> None:
|
||||
@@ -59,76 +110,258 @@ def _cleanup_added_root_handlers(original_handlers: list[logging.Handler]) -> No
|
||||
handler.close()
|
||||
|
||||
|
||||
def _prepend_sys_path(path: str) -> None:
|
||||
normalized_target = os.path.realpath(path)
|
||||
sys.path[:] = [
|
||||
item for item in sys.path if os.path.realpath(item) != normalized_target
|
||||
]
|
||||
sys.path.insert(0, normalized_target)
|
||||
def _get_trusted_host_for_index_url(index_url: str) -> str | None:
|
||||
parsed = urlparse(index_url if "://" in index_url else f"//{index_url}")
|
||||
host = parsed.hostname
|
||||
if host == "mirrors.aliyun.com":
|
||||
return host
|
||||
return None
|
||||
|
||||
|
||||
def _module_exists_in_site_packages(module_name: str, site_packages_path: str) -> bool:
|
||||
base_path = os.path.join(site_packages_path, *module_name.split("."))
|
||||
package_init = os.path.join(base_path, "__init__.py")
|
||||
module_file = f"{base_path}.py"
|
||||
return os.path.isfile(package_init) or os.path.isfile(module_file)
|
||||
def _normalize_sensitive_pip_key(raw_key: str) -> str:
|
||||
return raw_key.lstrip("-").replace("-", "_").lower()
|
||||
|
||||
|
||||
def _is_module_loaded_from_site_packages(
|
||||
module_name: str,
|
||||
site_packages_path: str,
|
||||
) -> bool:
|
||||
module = sys.modules.get(module_name)
|
||||
if module is None:
|
||||
try:
|
||||
module = importlib.import_module(module_name)
|
||||
except Exception:
|
||||
return False
|
||||
def _is_sensitive_pip_value_key(raw_key: str) -> bool:
|
||||
return _normalize_sensitive_pip_key(raw_key) in _SENSITIVE_PIP_VALUE_KEYS
|
||||
|
||||
module_file = getattr(module, "__file__", None)
|
||||
if not module_file:
|
||||
return False
|
||||
|
||||
module_path = os.path.realpath(module_file)
|
||||
site_packages_real = os.path.realpath(site_packages_path)
|
||||
try:
|
||||
return (
|
||||
os.path.commonpath([module_path, site_packages_real]) == site_packages_real
|
||||
def _redact_url_credentials(raw_value: str) -> str:
|
||||
"""Redact URL credentials and known inline secret values for safe logging."""
|
||||
parsed = urlparse(raw_value)
|
||||
if parsed.netloc and "@" in parsed.netloc:
|
||||
hostname = parsed.hostname or ""
|
||||
port = f":{parsed.port}" if parsed.port else ""
|
||||
return parsed._replace(netloc=f"<redacted>@{hostname}{port}").geturl()
|
||||
|
||||
if raw_value.startswith("--"):
|
||||
option, separator, _ = raw_value.partition("=")
|
||||
if separator and _is_sensitive_pip_value_key(option):
|
||||
return f"{option}=****"
|
||||
return raw_value
|
||||
|
||||
key, separator, _ = raw_value.partition("=")
|
||||
if separator and _is_sensitive_pip_value_key(key):
|
||||
return f"{key}=****"
|
||||
|
||||
return raw_value
|
||||
|
||||
|
||||
def _redact_pip_args_for_logging(args: list[str]) -> list[str]:
|
||||
redacted_args: list[str] = []
|
||||
redact_next_value = False
|
||||
|
||||
for arg in args:
|
||||
if redact_next_value:
|
||||
redacted_args.append("****")
|
||||
redact_next_value = False
|
||||
continue
|
||||
|
||||
if arg.startswith("--") and "=" in arg:
|
||||
option, value = arg.split("=", 1)
|
||||
if _is_sensitive_pip_value_key(option):
|
||||
redacted_args.append(f"{option}=****")
|
||||
else:
|
||||
redacted_args.append(f"{option}={_redact_url_credentials(value)}")
|
||||
continue
|
||||
|
||||
if arg.startswith("-i") and arg != "-i":
|
||||
redacted_args.append(f"-i{_redact_url_credentials(arg[2:])}")
|
||||
continue
|
||||
|
||||
if _is_sensitive_pip_value_key(arg):
|
||||
redacted_args.append(arg)
|
||||
redact_next_value = True
|
||||
continue
|
||||
|
||||
redacted_args.append(_redact_url_credentials(arg))
|
||||
|
||||
return redacted_args
|
||||
|
||||
|
||||
def _package_specs_override_index(package_specs: list[str]) -> bool:
|
||||
for index, spec in enumerate(package_specs):
|
||||
if spec == "--no-index":
|
||||
return True
|
||||
if spec in {"-i", "--index-url"}:
|
||||
if index + 1 < len(package_specs):
|
||||
return True
|
||||
continue
|
||||
if spec.startswith("--index-url="):
|
||||
return True
|
||||
if spec.startswith("-i") and spec != "-i":
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
class _StreamingLogWriter(io.TextIOBase):
|
||||
def __init__(self, log_func, *, max_lines: int | None = None) -> None:
|
||||
self._log_func = log_func
|
||||
self._lines = deque(maxlen=max_lines or _MAX_PIP_OUTPUT_LINES)
|
||||
self._buffer = ""
|
||||
|
||||
def write(self, text: str) -> int:
|
||||
if not text:
|
||||
return 0
|
||||
|
||||
self._buffer += text.replace("\r\n", "\n").replace("\r", "\n")
|
||||
while "\n" in self._buffer:
|
||||
raw_line, self._buffer = self._buffer.split("\n", 1)
|
||||
line = raw_line.rstrip("\r\n")
|
||||
self._log_func(line)
|
||||
self._lines.append(line)
|
||||
return len(text)
|
||||
|
||||
def flush(self) -> None:
|
||||
line = self._buffer.rstrip("\r\n")
|
||||
if line:
|
||||
self._log_func(line)
|
||||
self._lines.append(line)
|
||||
self._buffer = ""
|
||||
|
||||
@property
|
||||
def lines(self) -> list[str]:
|
||||
return list(self._lines)
|
||||
|
||||
|
||||
def _run_pip_main_streaming(pip_main, args: list[str]) -> tuple[int, list[str]]:
|
||||
stream = _StreamingLogWriter(logger.info, max_lines=_MAX_PIP_OUTPUT_LINES)
|
||||
with (
|
||||
contextlib.redirect_stdout(stream),
|
||||
contextlib.redirect_stderr(stream),
|
||||
):
|
||||
result_code = pip_main(args)
|
||||
stream.flush()
|
||||
return result_code, stream.lines
|
||||
|
||||
|
||||
def _matches_pip_failure_pattern(line: str, *pattern_names: str) -> bool:
|
||||
names = pattern_names or tuple(_PIP_FAILURE_PATTERNS)
|
||||
return any(_PIP_FAILURE_PATTERNS[name].search(line) for name in names)
|
||||
|
||||
|
||||
def _normalize_conflict_detail_line(line: str) -> str:
|
||||
stripped = line.strip()
|
||||
if _matches_pip_failure_pattern(stripped, "user_requested"):
|
||||
return re.sub(
|
||||
r"^\s*The user requested\s+",
|
||||
"",
|
||||
stripped,
|
||||
flags=re.IGNORECASE,
|
||||
)
|
||||
except ValueError:
|
||||
return False
|
||||
return stripped
|
||||
|
||||
|
||||
def _extract_requirement_name(raw_requirement: str) -> str | None:
|
||||
line = raw_requirement.split("#", 1)[0].strip()
|
||||
if not line:
|
||||
return None
|
||||
if line.startswith(("-r", "--requirement", "-c", "--constraint")):
|
||||
return None
|
||||
if line.startswith("-"):
|
||||
def _build_pip_conflict_context(output_lines: list[str]) -> PipConflictContext | None:
|
||||
matched_indices = [
|
||||
index
|
||||
for index, line in enumerate(output_lines)
|
||||
if _matches_pip_failure_pattern(line)
|
||||
]
|
||||
if matched_indices:
|
||||
relevant_index_set: set[int] = set()
|
||||
for index in matched_indices:
|
||||
start = max(0, index - 1)
|
||||
end = min(len(output_lines), index + 2)
|
||||
relevant_index_set.update(range(start, end))
|
||||
relevant_output_lines = [
|
||||
line
|
||||
for index, line in enumerate(output_lines)
|
||||
if index in relevant_index_set
|
||||
]
|
||||
else:
|
||||
relevant_output_lines = output_lines[-5:]
|
||||
|
||||
if not relevant_output_lines:
|
||||
return None
|
||||
|
||||
egg_match = re.search(r"#egg=([A-Za-z0-9_.-]+)", raw_requirement)
|
||||
if egg_match:
|
||||
return _canonicalize_distribution_name(egg_match.group(1))
|
||||
dependency_detail_lines = [
|
||||
line.strip()
|
||||
for line in relevant_output_lines
|
||||
if _matches_pip_failure_pattern(line, "dependency_detail")
|
||||
]
|
||||
requested_lines = [
|
||||
line.strip()
|
||||
for line in relevant_output_lines
|
||||
if _matches_pip_failure_pattern(line, "user_requested")
|
||||
and not _matches_pip_failure_pattern(line, "constraint")
|
||||
]
|
||||
if not requested_lines:
|
||||
requested_lines = [
|
||||
line
|
||||
for line in dependency_detail_lines
|
||||
if not _matches_pip_failure_pattern(line, "constraint")
|
||||
]
|
||||
constraint_lines = [
|
||||
line.strip()
|
||||
for line in relevant_output_lines
|
||||
if _matches_pip_failure_pattern(line, "constraint")
|
||||
]
|
||||
|
||||
candidate = re.split(r"[<>=!~;\s\[]", line, maxsplit=1)[0].strip()
|
||||
if not candidate:
|
||||
has_strong_conflict_signal = any(
|
||||
_matches_pip_failure_pattern(
|
||||
line,
|
||||
"resolution_impossible",
|
||||
"cannot_install",
|
||||
)
|
||||
for line in relevant_output_lines
|
||||
)
|
||||
|
||||
has_contextual_conflict_signal = any(
|
||||
_matches_pip_failure_pattern(line, "conflict") for line in relevant_output_lines
|
||||
) and bool(dependency_detail_lines or requested_lines or constraint_lines)
|
||||
|
||||
return PipConflictContext(
|
||||
relevant_lines=relevant_output_lines,
|
||||
requested_lines=requested_lines,
|
||||
dependency_detail_lines=dependency_detail_lines,
|
||||
constraint_lines=constraint_lines,
|
||||
has_strong_conflict_signal=has_strong_conflict_signal,
|
||||
has_contextual_conflict_signal=has_contextual_conflict_signal,
|
||||
)
|
||||
|
||||
|
||||
def _classify_pip_failure(output_lines: list[str]) -> DependencyConflictError | None:
|
||||
context = _build_pip_conflict_context(output_lines)
|
||||
if context is None:
|
||||
return None
|
||||
return _canonicalize_distribution_name(candidate)
|
||||
|
||||
if (
|
||||
not context.has_strong_conflict_signal
|
||||
and not context.has_contextual_conflict_signal
|
||||
and not (context.requested_lines and context.constraint_lines)
|
||||
):
|
||||
return None
|
||||
|
||||
def _extract_requirement_names(requirements_path: str) -> set[str]:
|
||||
names: set[str] = set()
|
||||
try:
|
||||
with open(requirements_path, encoding="utf-8") as requirements_file:
|
||||
for line in requirements_file:
|
||||
requirement_name = _extract_requirement_name(line)
|
||||
if requirement_name:
|
||||
names.add(requirement_name)
|
||||
except Exception as exc:
|
||||
logger.warning("读取依赖文件失败,跳过冲突检测: %s", exc)
|
||||
return names
|
||||
is_core_conflict = bool(context.constraint_lines)
|
||||
|
||||
detail = ""
|
||||
if context.constraint_lines and context.requested_lines:
|
||||
detail = (
|
||||
" 冲突详情: "
|
||||
f"{_normalize_conflict_detail_line(context.requested_lines[0])} vs "
|
||||
f"{_normalize_conflict_detail_line(context.constraint_lines[0])}。"
|
||||
)
|
||||
elif len(context.dependency_detail_lines) >= 2:
|
||||
detail = (
|
||||
" 冲突详情: "
|
||||
f"{_normalize_conflict_detail_line(context.dependency_detail_lines[0])} vs "
|
||||
f"{_normalize_conflict_detail_line(context.dependency_detail_lines[1])}。"
|
||||
)
|
||||
|
||||
if is_core_conflict:
|
||||
message = (
|
||||
f"检测到核心依赖版本保护冲突。{detail}插件要求的依赖版本与 AstrBot 核心不兼容,"
|
||||
"为了系统稳定,已阻止该降级行为。请联系插件作者或调整 requirements.txt。"
|
||||
)
|
||||
else:
|
||||
message = f"检测到依赖冲突。{detail}"
|
||||
|
||||
return DependencyConflictError(
|
||||
message,
|
||||
context.relevant_lines,
|
||||
is_core_conflict=is_core_conflict,
|
||||
)
|
||||
|
||||
|
||||
def _extract_top_level_modules(
|
||||
@@ -155,7 +388,11 @@ def _collect_candidate_modules(
|
||||
by_name: dict[str, list[importlib_metadata.Distribution]] = {}
|
||||
try:
|
||||
for distribution in importlib_metadata.distributions(path=[site_packages_path]):
|
||||
distribution_name = distribution.metadata.get("Name")
|
||||
distribution_name = (
|
||||
distribution.metadata["Name"]
|
||||
if "Name" in distribution.metadata
|
||||
else None
|
||||
)
|
||||
if not distribution_name:
|
||||
continue
|
||||
canonical_name = _canonicalize_distribution_name(distribution_name)
|
||||
@@ -173,7 +410,7 @@ def _collect_candidate_modules(
|
||||
|
||||
for distribution in by_name.get(requirement_name, []):
|
||||
for dependency_line in distribution.requires or []:
|
||||
dependency_name = _extract_requirement_name(dependency_line)
|
||||
dependency_name = extract_requirement_name(dependency_line)
|
||||
if not dependency_name:
|
||||
continue
|
||||
if dependency_name in expanded_requirement_names:
|
||||
@@ -230,6 +467,38 @@ def _ensure_preferred_modules(
|
||||
raise RuntimeError(conflict_message)
|
||||
|
||||
|
||||
def _module_exists_in_site_packages(module_name: str, site_packages_path: str) -> bool:
|
||||
base_path = os.path.join(site_packages_path, *module_name.split("."))
|
||||
package_init = os.path.join(base_path, "__init__.py")
|
||||
module_file = f"{base_path}.py"
|
||||
return os.path.isfile(package_init) or os.path.isfile(module_file)
|
||||
|
||||
|
||||
def _is_module_loaded_from_site_packages(
|
||||
module_name: str,
|
||||
site_packages_path: str,
|
||||
) -> bool:
|
||||
module = sys.modules.get(module_name)
|
||||
if module is None:
|
||||
try:
|
||||
module = importlib.import_module(module_name)
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
module_file = getattr(module, "__file__", None)
|
||||
if not module_file:
|
||||
return False
|
||||
|
||||
module_path = os.path.realpath(module_file)
|
||||
site_packages_real = os.path.realpath(site_packages_path)
|
||||
try:
|
||||
return (
|
||||
os.path.commonpath([module_path, site_packages_real]) == site_packages_real
|
||||
)
|
||||
except ValueError:
|
||||
return False
|
||||
|
||||
|
||||
def _prefer_module_from_site_packages(
|
||||
module_name: str, site_packages_path: str
|
||||
) -> bool:
|
||||
@@ -531,9 +800,63 @@ def _patch_distlib_finder_for_frozen_runtime() -> None:
|
||||
|
||||
|
||||
class PipInstaller:
|
||||
def __init__(self, pip_install_arg: str, pypi_index_url: str | None = None) -> None:
|
||||
def __init__(
|
||||
self,
|
||||
pip_install_arg: str,
|
||||
pypi_index_url: str | None = None,
|
||||
core_dist_name: str | None = "AstrBot",
|
||||
) -> None:
|
||||
self.pip_install_arg = pip_install_arg
|
||||
self.pypi_index_url = pypi_index_url
|
||||
self.core_dist_name = core_dist_name
|
||||
self._core_constraints = CoreConstraintsProvider(core_dist_name)
|
||||
|
||||
def _build_pip_args(
|
||||
self,
|
||||
package_name: str | None,
|
||||
requirements_path: str | None,
|
||||
mirror: str | None,
|
||||
) -> tuple[list[str], set[str]]:
|
||||
args: list[str] = []
|
||||
requested_requirements: set[str] = set()
|
||||
normalized_requirements_path = (
|
||||
requirements_path.strip() if requirements_path else ""
|
||||
)
|
||||
|
||||
if package_name and normalized_requirements_path:
|
||||
raise ValueError(
|
||||
"package_name and requirements_path cannot be used together"
|
||||
)
|
||||
|
||||
if package_name:
|
||||
parsed_package = parse_package_install_input(package_name)
|
||||
if parsed_package.specs:
|
||||
args = ["install", *parsed_package.specs]
|
||||
requested_requirements = set(parsed_package.requirement_names)
|
||||
elif normalized_requirements_path:
|
||||
args = ["install", "-r", normalized_requirements_path]
|
||||
requested_requirements = extract_requirement_names(
|
||||
normalized_requirements_path
|
||||
)
|
||||
|
||||
if not args:
|
||||
return [], requested_requirements
|
||||
|
||||
pip_install_args = (
|
||||
shlex.split(self.pip_install_arg) if self.pip_install_arg else []
|
||||
)
|
||||
|
||||
if not _package_specs_override_index([*args[1:], *pip_install_args]):
|
||||
index_url = mirror or self.pypi_index_url or "https://pypi.org/simple"
|
||||
trusted_host = _get_trusted_host_for_index_url(index_url)
|
||||
if trusted_host:
|
||||
args.extend(["--trusted-host", trusted_host])
|
||||
args.extend(["-i", index_url])
|
||||
|
||||
if pip_install_args:
|
||||
args.extend(pip_install_args)
|
||||
|
||||
return args, requested_requirements
|
||||
|
||||
async def install(
|
||||
self,
|
||||
@@ -541,36 +864,37 @@ class PipInstaller:
|
||||
requirements_path: str | None = None,
|
||||
mirror: str | None = None,
|
||||
) -> None:
|
||||
args = ["install"]
|
||||
requested_requirements: set[str] = set()
|
||||
if package_name:
|
||||
args.append(package_name)
|
||||
requirement_name = _extract_requirement_name(package_name)
|
||||
if requirement_name:
|
||||
requested_requirements.add(requirement_name)
|
||||
elif requirements_path:
|
||||
args.extend(["-r", requirements_path])
|
||||
requested_requirements = _extract_requirement_names(requirements_path)
|
||||
|
||||
index_url = mirror or self.pypi_index_url or "https://pypi.org/simple"
|
||||
args.extend(["--trusted-host", "mirrors.aliyun.com", "-i", index_url])
|
||||
args, requested_requirements = self._build_pip_args(
|
||||
package_name, requirements_path, mirror
|
||||
)
|
||||
if not args:
|
||||
logger.info("Pip 包管理器跳过安装:未提供有效的包名或 requirements 文件。")
|
||||
return
|
||||
|
||||
target_site_packages = None
|
||||
if is_packaged_desktop_runtime():
|
||||
target_site_packages = get_astrbot_site_packages_path()
|
||||
os.makedirs(target_site_packages, exist_ok=True)
|
||||
_prepend_sys_path(target_site_packages)
|
||||
args.extend(["--target", target_site_packages])
|
||||
args.extend(["--upgrade", "--force-reinstall"])
|
||||
args.extend(
|
||||
[
|
||||
"--target",
|
||||
target_site_packages,
|
||||
"--upgrade",
|
||||
"--upgrade-strategy",
|
||||
"only-if-needed",
|
||||
]
|
||||
)
|
||||
|
||||
if self.pip_install_arg:
|
||||
args.extend(self.pip_install_arg.split())
|
||||
with self._core_constraints.constraints_file() as constraints_file_path:
|
||||
if constraints_file_path:
|
||||
args.extend(["-c", constraints_file_path])
|
||||
|
||||
logger.info(f"Pip 包管理器: pip {' '.join(args)}")
|
||||
result_code = await self._run_pip_in_process(args)
|
||||
|
||||
if result_code != 0:
|
||||
raise Exception(f"安装失败,错误码:{result_code}")
|
||||
logger.info(
|
||||
"Pip 包管理器 argv: %s",
|
||||
["pip", *_redact_pip_args_for_logging(args)],
|
||||
)
|
||||
await self._run_pip_with_classification(args)
|
||||
|
||||
if target_site_packages:
|
||||
_prepend_sys_path(target_site_packages)
|
||||
@@ -589,7 +913,7 @@ class PipInstaller:
|
||||
if not os.path.isdir(target_site_packages):
|
||||
return
|
||||
|
||||
requested_requirements = _extract_requirement_names(requirements_path)
|
||||
requested_requirements = extract_requirement_names(requirements_path)
|
||||
if not requested_requirements:
|
||||
return
|
||||
|
||||
@@ -605,13 +929,21 @@ class PipInstaller:
|
||||
_patch_distlib_finder_for_frozen_runtime()
|
||||
|
||||
original_handlers = list(logging.getLogger().handlers)
|
||||
result_code, output = await asyncio.to_thread(
|
||||
_run_pip_main_with_output, pip_main, args
|
||||
)
|
||||
for line in output.splitlines():
|
||||
line = line.strip()
|
||||
if line:
|
||||
logger.info(line)
|
||||
try:
|
||||
result_code, output_lines = await asyncio.to_thread(
|
||||
_run_pip_main_streaming, pip_main, args
|
||||
)
|
||||
finally:
|
||||
_cleanup_added_root_handlers(original_handlers)
|
||||
|
||||
if result_code != 0:
|
||||
conflict = _classify_pip_failure(output_lines)
|
||||
if conflict:
|
||||
raise conflict
|
||||
|
||||
_cleanup_added_root_handlers(original_handlers)
|
||||
return result_code
|
||||
|
||||
async def _run_pip_with_classification(self, args: list[str]) -> None:
|
||||
result_code = await self._run_pip_in_process(args)
|
||||
if result_code != 0:
|
||||
raise PipInstallError(f"安装失败,错误码:{result_code}", code=result_code)
|
||||
|
||||
@@ -0,0 +1,408 @@
|
||||
import importlib.metadata as importlib_metadata
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import shlex
|
||||
import sys
|
||||
from collections.abc import Iterable, Iterator
|
||||
from dataclasses import dataclass
|
||||
|
||||
from packaging.requirements import InvalidRequirement, Requirement
|
||||
from packaging.specifiers import SpecifierSet
|
||||
from packaging.version import InvalidVersion, Version
|
||||
|
||||
from astrbot.core.utils.astrbot_path import get_astrbot_site_packages_path
|
||||
from astrbot.core.utils.runtime_env import is_packaged_desktop_runtime
|
||||
|
||||
logger = logging.getLogger("astrbot")
|
||||
|
||||
|
||||
class RequirementsPrecheckFailed(Exception):
|
||||
"""Raised when the pre-check of requirements fails."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ParsedPackageInput:
|
||||
specs: tuple[str, ...]
|
||||
requirement_names: frozenset[str]
|
||||
|
||||
|
||||
def canonicalize_distribution_name(name: str) -> str:
|
||||
return re.sub(r"[-_.]+", "-", name).strip("-").lower()
|
||||
|
||||
|
||||
def strip_inline_requirement_comment(raw_input: str) -> str:
|
||||
if raw_input.lstrip().startswith("#"):
|
||||
return ""
|
||||
return re.split(r"[ \t]+#", raw_input, maxsplit=1)[0].strip()
|
||||
|
||||
|
||||
def _specifier_contains_version(specifier: SpecifierSet, version: str) -> bool:
|
||||
try:
|
||||
parsed_version = Version(version)
|
||||
except InvalidVersion:
|
||||
return False
|
||||
return specifier.contains(parsed_version, prereleases=True)
|
||||
|
||||
|
||||
def _looks_like_local_path_reference(token: str) -> bool:
|
||||
candidate = token.strip()
|
||||
if not candidate:
|
||||
return False
|
||||
return candidate in {".", ".."} or candidate.startswith(
|
||||
("./", "../", "/", "~/", ".\\", "..\\", "\\")
|
||||
)
|
||||
|
||||
|
||||
def looks_like_direct_reference(token: str) -> bool:
|
||||
candidate = token.strip()
|
||||
if not candidate:
|
||||
return False
|
||||
return (
|
||||
_looks_like_local_path_reference(candidate)
|
||||
or candidate.startswith("git+")
|
||||
or "://" in candidate
|
||||
)
|
||||
|
||||
|
||||
def extract_requirement_name(raw_requirement: str) -> str | None:
|
||||
line = raw_requirement.split("#", 1)[0].strip()
|
||||
if not line:
|
||||
return None
|
||||
if line.startswith(("-r", "--requirement", "-c", "--constraint")):
|
||||
return None
|
||||
|
||||
egg_match = re.search(r"#egg=([A-Za-z0-9_.-]+)", raw_requirement)
|
||||
if egg_match:
|
||||
return canonicalize_distribution_name(egg_match.group(1))
|
||||
|
||||
if line.startswith("-"):
|
||||
return None
|
||||
|
||||
candidate = re.split(r"[<>=!~;\s\[]", line, maxsplit=1)[0].strip()
|
||||
if not candidate:
|
||||
return None
|
||||
return canonicalize_distribution_name(candidate)
|
||||
|
||||
|
||||
def _parse_editable_or_direct_name(target: str) -> str | None:
|
||||
name = extract_requirement_name(target)
|
||||
if not name:
|
||||
return None
|
||||
if "#egg=" in target or not looks_like_direct_reference(target):
|
||||
return name
|
||||
return None
|
||||
|
||||
|
||||
def _parse_requirement_name_and_spec(
|
||||
line: str,
|
||||
) -> tuple[str | None, SpecifierSet | None]:
|
||||
if line.startswith(("-c", "--constraint")):
|
||||
return None, None
|
||||
|
||||
try:
|
||||
req = Requirement(line)
|
||||
except InvalidRequirement:
|
||||
tokens = shlex.split(line)
|
||||
if not tokens:
|
||||
return None, None
|
||||
|
||||
editable_target: str | None = None
|
||||
if tokens[0] in {"-e", "--editable"} and len(tokens) > 1:
|
||||
editable_target = tokens[1]
|
||||
elif tokens[0].startswith("--editable="):
|
||||
editable_target = tokens[0].split("=", 1)[1]
|
||||
|
||||
if editable_target:
|
||||
name = _parse_editable_or_direct_name(editable_target)
|
||||
return (name, None) if name else (None, None)
|
||||
|
||||
name = _parse_editable_or_direct_name(line)
|
||||
return (name, None) if name else (None, None)
|
||||
|
||||
if req.marker and not req.marker.evaluate():
|
||||
return None, None
|
||||
|
||||
return canonicalize_distribution_name(req.name), (req.specifier or None)
|
||||
|
||||
|
||||
def _parse_requirement_line(
|
||||
line: str,
|
||||
) -> tuple[str, SpecifierSet | None] | None:
|
||||
name, specifier = _parse_requirement_name_and_spec(line)
|
||||
return (name, specifier) if name else None
|
||||
|
||||
|
||||
def _extract_requirement_names_from_package_tokens(tokens: list[str]) -> frozenset[str]:
|
||||
requirement_names: set[str] = set()
|
||||
skip_next_for: str | None = None
|
||||
|
||||
for token in tokens:
|
||||
if skip_next_for:
|
||||
if skip_next_for == "editable":
|
||||
name = _parse_editable_or_direct_name(token)
|
||||
if name:
|
||||
requirement_names.add(name)
|
||||
skip_next_for = None
|
||||
continue
|
||||
|
||||
if token in {"-e", "--editable"}:
|
||||
skip_next_for = "editable"
|
||||
continue
|
||||
|
||||
if token in {
|
||||
"-i",
|
||||
"--index-url",
|
||||
"--extra-index-url",
|
||||
"-f",
|
||||
"--find-links",
|
||||
"--trusted-host",
|
||||
"-r",
|
||||
"--requirement",
|
||||
"-c",
|
||||
"--constraint",
|
||||
}:
|
||||
skip_next_for = "option-value"
|
||||
continue
|
||||
|
||||
if token.startswith(("--editable=",)):
|
||||
editable_target = token.split("=", 1)[1]
|
||||
name = _parse_editable_or_direct_name(editable_target)
|
||||
if name:
|
||||
requirement_names.add(name)
|
||||
continue
|
||||
|
||||
if token.startswith(
|
||||
(
|
||||
"--index-url=",
|
||||
"--extra-index-url=",
|
||||
"--find-links=",
|
||||
"--trusted-host=",
|
||||
"--requirement=",
|
||||
"--constraint=",
|
||||
)
|
||||
):
|
||||
continue
|
||||
|
||||
if (
|
||||
(token.startswith("-i") and token != "-i")
|
||||
or (token.startswith("-f") and token != "-f")
|
||||
or token == "--no-index"
|
||||
):
|
||||
continue
|
||||
|
||||
if token.startswith("-"):
|
||||
continue
|
||||
|
||||
name, _ = _parse_requirement_name_and_spec(token)
|
||||
if name:
|
||||
requirement_names.add(name)
|
||||
|
||||
return frozenset(requirement_names)
|
||||
|
||||
|
||||
def parse_package_install_input(raw_input: str) -> ParsedPackageInput:
|
||||
specs: list[str] = []
|
||||
requirement_names: set[str] = set()
|
||||
normalized = raw_input.strip()
|
||||
if not normalized:
|
||||
return ParsedPackageInput(specs=(), requirement_names=frozenset())
|
||||
|
||||
for raw_line in normalized.splitlines():
|
||||
line = strip_inline_requirement_comment(raw_line)
|
||||
if not line:
|
||||
continue
|
||||
|
||||
try:
|
||||
Requirement(line)
|
||||
except InvalidRequirement:
|
||||
tokens = shlex.split(line)
|
||||
if not tokens:
|
||||
continue
|
||||
specs.extend(tokens)
|
||||
requirement_names.update(
|
||||
_extract_requirement_names_from_package_tokens(tokens)
|
||||
)
|
||||
continue
|
||||
|
||||
specs.append(line)
|
||||
name, _ = _parse_requirement_name_and_spec(line)
|
||||
if name:
|
||||
requirement_names.add(name)
|
||||
|
||||
return ParsedPackageInput(
|
||||
specs=tuple(specs),
|
||||
requirement_names=frozenset(requirement_names),
|
||||
)
|
||||
|
||||
|
||||
def _iter_requirement_lines(
|
||||
requirements_path: str,
|
||||
_visited: set[str] | None = None,
|
||||
) -> Iterator[str]:
|
||||
visited = _visited or set()
|
||||
resolved_path = os.path.realpath(requirements_path)
|
||||
if resolved_path in visited:
|
||||
logger.warning(
|
||||
"检测到循环依赖的 requirements 包含: %s,将跳过该文件", resolved_path
|
||||
)
|
||||
return
|
||||
visited.add(resolved_path)
|
||||
|
||||
with open(resolved_path, encoding="utf-8") as f:
|
||||
for raw_line in f:
|
||||
line = strip_inline_requirement_comment(raw_line)
|
||||
if not line:
|
||||
continue
|
||||
|
||||
tokens = shlex.split(line)
|
||||
if not tokens:
|
||||
continue
|
||||
|
||||
nested: str | None = None
|
||||
if tokens[0] in {"-r", "--requirement"} and len(tokens) > 1:
|
||||
nested = tokens[1]
|
||||
elif tokens[0].startswith("--requirement="):
|
||||
nested = tokens[0].split("=", 1)[1]
|
||||
|
||||
if nested:
|
||||
if not os.path.isabs(nested):
|
||||
nested = os.path.join(os.path.dirname(resolved_path), nested)
|
||||
yield from _iter_requirement_lines(nested, _visited=visited)
|
||||
continue
|
||||
|
||||
yield line
|
||||
|
||||
|
||||
def iter_requirements(
|
||||
requirements_path: str | None = None,
|
||||
lines: Iterable[str] | None = None,
|
||||
) -> Iterator[tuple[str, SpecifierSet | None]]:
|
||||
if lines is None:
|
||||
if requirements_path is None:
|
||||
raise ValueError("Either requirements_path or lines must be provided")
|
||||
lines = _iter_requirement_lines(requirements_path)
|
||||
|
||||
for line in lines:
|
||||
parsed = _parse_requirement_line(line)
|
||||
if parsed is not None:
|
||||
yield parsed
|
||||
|
||||
|
||||
def extract_requirement_names(requirements_path: str) -> set[str]:
|
||||
try:
|
||||
return {
|
||||
name for name, _ in iter_requirements(requirements_path=requirements_path)
|
||||
}
|
||||
except Exception as exc:
|
||||
logger.warning("读取依赖文件失败,跳过冲突检测: %s", exc)
|
||||
return set()
|
||||
|
||||
|
||||
def get_requirement_check_paths() -> list[str]:
|
||||
paths = list(sys.path)
|
||||
if is_packaged_desktop_runtime():
|
||||
target_site_packages = get_astrbot_site_packages_path()
|
||||
if os.path.isdir(target_site_packages):
|
||||
paths.insert(0, target_site_packages)
|
||||
return paths
|
||||
|
||||
|
||||
def _canonical_distribution_identity(distribution) -> tuple[str | None, str | None]:
|
||||
distribution_name = (
|
||||
distribution.metadata["Name"] if "Name" in distribution.metadata else None
|
||||
)
|
||||
if not distribution_name:
|
||||
return None, None
|
||||
return canonicalize_distribution_name(distribution_name), distribution.version
|
||||
|
||||
|
||||
def collect_installed_distribution_versions(paths: list[str]) -> dict[str, str] | None:
|
||||
installed: dict[str, str] = {}
|
||||
try:
|
||||
for distribution in importlib_metadata.distributions(path=paths):
|
||||
distribution_name, version = _canonical_distribution_identity(distribution)
|
||||
if not distribution_name or not version:
|
||||
continue
|
||||
installed.setdefault(distribution_name, version)
|
||||
except Exception as exc:
|
||||
logger.warning("读取已安装依赖失败,跳过缺失依赖预检查: %s", exc)
|
||||
return None
|
||||
return installed
|
||||
|
||||
|
||||
def _load_requirement_lines_for_precheck(
|
||||
requirements_path: str,
|
||||
) -> tuple[bool, list[str] | None]:
|
||||
try:
|
||||
requirement_lines = list(_iter_requirement_lines(requirements_path))
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"预检查缺失依赖失败,将回退到完整安装: %s (%s)",
|
||||
requirements_path,
|
||||
exc,
|
||||
)
|
||||
return False, None
|
||||
|
||||
fallback_line = next(
|
||||
(
|
||||
line
|
||||
for line in requirement_lines
|
||||
if (
|
||||
(
|
||||
line.startswith(("-e ", "--editable ", "--editable="))
|
||||
and "#egg=" not in line
|
||||
)
|
||||
or (
|
||||
_parse_requirement_line(line) is None
|
||||
and looks_like_direct_reference(line)
|
||||
)
|
||||
)
|
||||
),
|
||||
None,
|
||||
)
|
||||
if fallback_line is not None:
|
||||
logger.warning(
|
||||
"预检查缺失依赖失败,将回退到完整安装: unresolved direct reference in %s: %s",
|
||||
requirements_path,
|
||||
fallback_line,
|
||||
)
|
||||
return False, None
|
||||
|
||||
return True, requirement_lines
|
||||
|
||||
|
||||
def find_missing_requirements(requirements_path: str) -> set[str] | None:
|
||||
can_precheck, requirement_lines = _load_requirement_lines_for_precheck(
|
||||
requirements_path
|
||||
)
|
||||
if not can_precheck or requirement_lines is None:
|
||||
return None
|
||||
|
||||
required = list(iter_requirements(lines=requirement_lines))
|
||||
if not required:
|
||||
return set()
|
||||
|
||||
installed = collect_installed_distribution_versions(get_requirement_check_paths())
|
||||
if installed is None:
|
||||
return None
|
||||
|
||||
missing: set[str] = set()
|
||||
for name, specifier in required:
|
||||
installed_version = installed.get(name)
|
||||
if not installed_version:
|
||||
missing.add(name)
|
||||
continue
|
||||
if specifier and not _specifier_contains_version(specifier, installed_version):
|
||||
missing.add(name)
|
||||
|
||||
return missing
|
||||
|
||||
|
||||
def find_missing_requirements_or_raise(requirements_path: str) -> set[str]:
|
||||
missing = find_missing_requirements(requirements_path)
|
||||
if missing is None:
|
||||
raise RequirementsPrecheckFailed(f"预检查失败: {requirements_path}")
|
||||
return missing
|
||||
@@ -7,4 +7,4 @@ def is_frozen_runtime() -> bool:
|
||||
|
||||
|
||||
def is_packaged_desktop_runtime() -> bool:
|
||||
return is_frozen_runtime() and os.environ.get("ASTRBOT_DESKTOP_CLIENT") == "1"
|
||||
return os.environ.get("ASTRBOT_DESKTOP_CLIENT") == "1"
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
import asyncio
|
||||
import threading
|
||||
import weakref
|
||||
from collections import defaultdict
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
|
||||
class SessionLockManager:
|
||||
class _PerLoopSessionLockManager:
|
||||
"""Per-event-loop session lock manager; keeps original simple semantics."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._locks: dict[str, asyncio.Lock] = defaultdict(asyncio.Lock)
|
||||
self._lock_count: dict[str, int] = defaultdict(int)
|
||||
@@ -26,4 +30,26 @@ class SessionLockManager:
|
||||
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,6 +610,7 @@ 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__
|
||||
@@ -649,6 +650,7 @@ 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,7 +5,8 @@ import os
|
||||
import ssl
|
||||
import traceback
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
import aiohttp
|
||||
import certifi
|
||||
@@ -352,6 +353,34 @@ 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")
|
||||
@@ -377,6 +406,7 @@ 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,6 +2,7 @@ import os
|
||||
import re
|
||||
import shutil
|
||||
import traceback
|
||||
import uuid
|
||||
from collections.abc import Awaitable, Callable
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
@@ -50,6 +51,7 @@ 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),
|
||||
@@ -188,6 +190,114 @@ 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,6 +12,32 @@ 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,
|
||||
@@ -33,13 +59,37 @@ 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 config["mcpServers"].items():
|
||||
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
|
||||
|
||||
server_info = {
|
||||
"name": name,
|
||||
"active": server_config.get("active", True),
|
||||
@@ -65,7 +115,7 @@ class ToolsRoute(Route):
|
||||
return Response().ok(servers).__dict__
|
||||
except Exception as e:
|
||||
logger.error(traceback.format_exc())
|
||||
return Response().error(f"获取 MCP 服务器列表失败: {e!s}").__dict__
|
||||
return Response().error(f"Failed to get MCP server list: {e!s}").__dict__
|
||||
|
||||
async def add_mcp_server(self):
|
||||
try:
|
||||
@@ -75,7 +125,7 @@ class ToolsRoute(Route):
|
||||
|
||||
# 检查必填字段
|
||||
if not name:
|
||||
return Response().error("服务器名称不能为空").__dict__
|
||||
return Response().error("Server name cannot be empty").__dict__
|
||||
|
||||
# 移除特殊字段并检查配置是否有效
|
||||
has_valid_config = False
|
||||
@@ -85,21 +135,33 @@ class ToolsRoute(Route):
|
||||
for key, value in server_data.items():
|
||||
if key not in ["name", "active", "tools", "errlogs"]: # 排除特殊字段
|
||||
if key == "mcpServers":
|
||||
key_0 = list(server_data["mcpServers"].keys())[
|
||||
0
|
||||
] # 不考虑为空的情况
|
||||
server_config = server_data["mcpServers"][key_0]
|
||||
try:
|
||||
server_config = _extract_mcp_server_config(
|
||||
server_data["mcpServers"]
|
||||
)
|
||||
except ValueError as e:
|
||||
return Response().error(f"{e!s}").__dict__
|
||||
else:
|
||||
server_config[key] = value
|
||||
has_valid_config = True
|
||||
|
||||
if not has_valid_config:
|
||||
return Response().error("必须提供有效的服务器配置").__dict__
|
||||
return (
|
||||
Response()
|
||||
.error("A valid server configuration is required")
|
||||
.__dict__
|
||||
)
|
||||
|
||||
config = self.tool_mgr.load_mcp_config()
|
||||
|
||||
if name in config["mcpServers"]:
|
||||
return Response().error(f"服务器 {name} 已存在").__dict__
|
||||
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__
|
||||
|
||||
config["mcpServers"][name] = server_config
|
||||
|
||||
@@ -111,17 +173,27 @@ class ToolsRoute(Route):
|
||||
timeout=30,
|
||||
)
|
||||
except TimeoutError:
|
||||
return Response().error(f"启用 MCP 服务器 {name} 超时。").__dict__
|
||||
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__
|
||||
except Exception as e:
|
||||
logger.error(traceback.format_exc())
|
||||
return (
|
||||
Response().error(f"启用 MCP 服务器 {name} 失败: {e!s}").__dict__
|
||||
)
|
||||
return Response().ok(None, f"成功添加 MCP 服务器 {name}").__dict__
|
||||
return Response().error("保存配置失败").__dict__
|
||||
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__
|
||||
except Exception as e:
|
||||
logger.error(traceback.format_exc())
|
||||
return Response().error(f"添加 MCP 服务器失败: {e!s}").__dict__
|
||||
return Response().error(f"Failed to add MCP server: {e!s}").__dict__
|
||||
|
||||
async def update_mcp_server(self):
|
||||
try:
|
||||
@@ -131,23 +203,25 @@ class ToolsRoute(Route):
|
||||
old_name = server_data.get("oldName") or name
|
||||
|
||||
if not name:
|
||||
return Response().error("服务器名称不能为空").__dict__
|
||||
return Response().error("Server name cannot be empty").__dict__
|
||||
|
||||
config = self.tool_mgr.load_mcp_config()
|
||||
|
||||
if old_name not in config["mcpServers"]:
|
||||
return Response().error(f"服务器 {old_name} 不存在").__dict__
|
||||
return Response().error(f"Server {old_name} does not exist").__dict__
|
||||
|
||||
is_rename = name != old_name
|
||||
|
||||
if name in config["mcpServers"] and is_rename:
|
||||
return Response().error(f"服务器 {name} 已存在").__dict__
|
||||
return Response().error(f"Server {name} already exists").__dict__
|
||||
|
||||
# 获取活动状态
|
||||
active = server_data.get(
|
||||
"active",
|
||||
config["mcpServers"][old_name].get("active", True),
|
||||
)
|
||||
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)
|
||||
|
||||
# 创建新的配置对象
|
||||
server_config = {"active": active}
|
||||
@@ -165,17 +239,19 @@ class ToolsRoute(Route):
|
||||
"oldName",
|
||||
]: # 排除特殊字段
|
||||
if key == "mcpServers":
|
||||
key_0 = list(server_data["mcpServers"].keys())[
|
||||
0
|
||||
] # 不考虑为空的情况
|
||||
server_config = server_data["mcpServers"][key_0]
|
||||
try:
|
||||
server_config = _extract_mcp_server_config(
|
||||
server_data["mcpServers"]
|
||||
)
|
||||
except ValueError as e:
|
||||
return Response().error(f"{e!s}").__dict__
|
||||
else:
|
||||
server_config[key] = value
|
||||
only_update_active = False
|
||||
|
||||
# 如果只更新活动状态,保留原始配置
|
||||
if only_update_active:
|
||||
for key, value in config["mcpServers"][old_name].items():
|
||||
if only_update_active and isinstance(old_config, dict):
|
||||
for key, value in old_config.items():
|
||||
if key != "active": # 除了active之外的所有字段都保留
|
||||
server_config[key] = value
|
||||
|
||||
@@ -200,7 +276,7 @@ class ToolsRoute(Route):
|
||||
return (
|
||||
Response()
|
||||
.error(
|
||||
f"启用前停用 MCP 服务器时 {old_name} 超时: {e!s}"
|
||||
f"Timed out while disabling MCP server {old_name} before enabling: {e!s}"
|
||||
)
|
||||
.__dict__
|
||||
)
|
||||
@@ -209,7 +285,7 @@ class ToolsRoute(Route):
|
||||
return (
|
||||
Response()
|
||||
.error(
|
||||
f"启用前停用 MCP 服务器时 {old_name} 失败: {e!s}"
|
||||
f"Failed to disable MCP server {old_name} before enabling: {e!s}"
|
||||
)
|
||||
.__dict__
|
||||
)
|
||||
@@ -221,13 +297,15 @@ class ToolsRoute(Route):
|
||||
)
|
||||
except TimeoutError:
|
||||
return (
|
||||
Response().error(f"启用 MCP 服务器 {name} 超时。").__dict__
|
||||
Response()
|
||||
.error(f"Timed out while enabling MCP server {name}.")
|
||||
.__dict__
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(traceback.format_exc())
|
||||
return (
|
||||
Response()
|
||||
.error(f"启用 MCP 服务器 {name} 失败: {e!s}")
|
||||
.error(f"Failed to enable MCP server {name}: {e!s}")
|
||||
.__dict__
|
||||
)
|
||||
# 如果要停用服务器
|
||||
@@ -237,22 +315,26 @@ class ToolsRoute(Route):
|
||||
except TimeoutError:
|
||||
return (
|
||||
Response()
|
||||
.error(f"停用 MCP 服务器 {old_name} 超时。")
|
||||
.error(f"Timed out while disabling MCP server {old_name}.")
|
||||
.__dict__
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(traceback.format_exc())
|
||||
return (
|
||||
Response()
|
||||
.error(f"停用 MCP 服务器 {old_name} 失败: {e!s}")
|
||||
.error(f"Failed to disable MCP server {old_name}: {e!s}")
|
||||
.__dict__
|
||||
)
|
||||
|
||||
return Response().ok(None, f"成功更新 MCP 服务器 {name}").__dict__
|
||||
return Response().error("保存配置失败").__dict__
|
||||
return (
|
||||
Response()
|
||||
.ok(None, f"Successfully updated MCP server {name}")
|
||||
.__dict__
|
||||
)
|
||||
return Response().error("Failed to save configuration").__dict__
|
||||
except Exception as e:
|
||||
logger.error(traceback.format_exc())
|
||||
return Response().error(f"更新 MCP 服务器失败: {e!s}").__dict__
|
||||
return Response().error(f"Failed to update MCP server: {e!s}").__dict__
|
||||
|
||||
async def delete_mcp_server(self):
|
||||
try:
|
||||
@@ -260,12 +342,12 @@ class ToolsRoute(Route):
|
||||
name = server_data.get("name", "")
|
||||
|
||||
if not name:
|
||||
return Response().error("服务器名称不能为空").__dict__
|
||||
return Response().error("Server name cannot be empty").__dict__
|
||||
|
||||
config = self.tool_mgr.load_mcp_config()
|
||||
|
||||
if name not in config["mcpServers"]:
|
||||
return Response().error(f"服务器 {name} 不存在").__dict__
|
||||
return Response().error(f"Server {name} does not exist").__dict__
|
||||
|
||||
del config["mcpServers"][name]
|
||||
|
||||
@@ -275,51 +357,76 @@ class ToolsRoute(Route):
|
||||
await self.tool_mgr.disable_mcp_server(name, timeout=10)
|
||||
except TimeoutError:
|
||||
return (
|
||||
Response().error(f"停用 MCP 服务器 {name} 超时。").__dict__
|
||||
Response()
|
||||
.error(f"Timed out while disabling MCP server {name}.")
|
||||
.__dict__
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(traceback.format_exc())
|
||||
return (
|
||||
Response()
|
||||
.error(f"停用 MCP 服务器 {name} 失败: {e!s}")
|
||||
.error(f"Failed to disable MCP server {name}: {e!s}")
|
||||
.__dict__
|
||||
)
|
||||
return Response().ok(None, f"成功删除 MCP 服务器 {name}").__dict__
|
||||
return Response().error("保存配置失败").__dict__
|
||||
return (
|
||||
Response()
|
||||
.ok(None, f"Successfully deleted MCP server {name}")
|
||||
.__dict__
|
||||
)
|
||||
return Response().error("Failed to save configuration").__dict__
|
||||
except Exception as e:
|
||||
logger.error(traceback.format_exc())
|
||||
return Response().error(f"删除 MCP 服务器失败: {e!s}").__dict__
|
||||
return Response().error(f"Failed to delete MCP server: {e!s}").__dict__
|
||||
|
||||
async def test_mcp_connection(self):
|
||||
"""测试 MCP 服务器连接"""
|
||||
"""Test MCP server connection."""
|
||||
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("无效的 MCP 服务器配置").__dict__
|
||||
return Response().error("Invalid MCP server configuration").__dict__
|
||||
|
||||
if "mcpServers" in config:
|
||||
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]]
|
||||
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__
|
||||
elif not config:
|
||||
return Response().error("MCP 服务器配置不能为空").__dict__
|
||||
return (
|
||||
Response()
|
||||
.error("MCP server configuration cannot be empty")
|
||||
.__dict__
|
||||
)
|
||||
|
||||
tools_name = await self.tool_mgr.test_mcp_server_connection(config)
|
||||
return (
|
||||
Response().ok(data=tools_name, message="🎉 MCP 服务器可用!").__dict__
|
||||
Response()
|
||||
.ok(data=tools_name, message="🎉 MCP server is available!")
|
||||
.__dict__
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(traceback.format_exc())
|
||||
return Response().error(f"测试 MCP 连接失败: {e!s}").__dict__
|
||||
return Response().error(f"Failed to test MCP connection: {e!s}").__dict__
|
||||
|
||||
async def get_tool_list(self):
|
||||
"""获取所有注册的工具列表"""
|
||||
"""Get all registered tools."""
|
||||
try:
|
||||
tools = self.tool_mgr.func_list
|
||||
tools_dict = []
|
||||
@@ -349,36 +456,44 @@ class ToolsRoute(Route):
|
||||
return Response().ok(data=tools_dict).__dict__
|
||||
except Exception as e:
|
||||
logger.error(traceback.format_exc())
|
||||
return Response().error(f"获取工具列表失败: {e!s}").__dict__
|
||||
return Response().error(f"Failed to get tool list: {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("缺少必要参数: name 或 action").__dict__
|
||||
return (
|
||||
Response()
|
||||
.error("Missing required parameters: name or activate")
|
||||
.__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"启用工具失败: {e!s}").__dict__
|
||||
return Response().error(f"Failed to activate tool: {e!s}").__dict__
|
||||
else:
|
||||
ok = self.tool_mgr.deactivate_llm_tool(tool_name)
|
||||
|
||||
if ok:
|
||||
return Response().ok(None, "操作成功。").__dict__
|
||||
return Response().error(f"工具 {tool_name} 不存在或操作失败。").__dict__
|
||||
return Response().ok(None, "Operation successful.").__dict__
|
||||
return (
|
||||
Response()
|
||||
.error(f"Tool {tool_name} does not exist or the operation failed.")
|
||||
.__dict__
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(traceback.format_exc())
|
||||
return Response().error(f"操作工具失败: {e!s}").__dict__
|
||||
return Response().error(f"Failed to operate tool: {e!s}").__dict__
|
||||
|
||||
async def sync_provider(self):
|
||||
"""同步 MCP 提供者配置"""
|
||||
"""Sync MCP provider configuration."""
|
||||
try:
|
||||
data = await request.json
|
||||
provider_name = data.get("name") # modelscope, or others
|
||||
@@ -387,9 +502,11 @@ class ToolsRoute(Route):
|
||||
access_token = data.get("access_token", "")
|
||||
await self.tool_mgr.sync_modelscope_mcp_servers(access_token)
|
||||
case _:
|
||||
return Response().error(f"未知: {provider_name}").__dict__
|
||||
return (
|
||||
Response().error(f"Unknown provider: {provider_name}").__dict__
|
||||
)
|
||||
|
||||
return Response().ok(message="同步成功").__dict__
|
||||
return Response().ok(message="Sync completed").__dict__
|
||||
except Exception as e:
|
||||
logger.error(traceback.format_exc())
|
||||
return Response().error(f"同步失败: {e!s}").__dict__
|
||||
return Response().error(f"Sync failed: {e!s}").__dict__
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
## 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`.
|
||||
@@ -0,0 +1,40 @@
|
||||
## 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))
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
## 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)
|
||||
@@ -0,0 +1,43 @@
|
||||
## 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,6 +300,10 @@ 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]) {
|
||||
@@ -372,6 +376,10 @@ 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();
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
<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
@@ -1,6 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { useModuleI18n } from '@/i18n/composables';
|
||||
import { normalizeTextInput } from '@/utils/inputValue';
|
||||
|
||||
const { tm } = useModuleI18n('features/command');
|
||||
|
||||
@@ -52,6 +53,7 @@ const statusItems = [
|
||||
{ title: tm('filters.disabled'), value: 'disabled' },
|
||||
{ title: tm('filters.conflict'), value: 'conflict' }
|
||||
];
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -108,10 +110,11 @@ const statusItems = [
|
||||
<div style="min-width: 200px; max-width: 350px; flex: 1; border: 1px solid #B9B9B9; border-radius: 16px;">
|
||||
<v-text-field
|
||||
:model-value="searchQuery"
|
||||
@update:model-value="emit('update:searchQuery', $event)"
|
||||
@update:model-value="emit('update:searchQuery', normalizeTextInput($event))"
|
||||
density="compact"
|
||||
:label="tm('search.placeholder')"
|
||||
prepend-inner-icon="mdi-magnify"
|
||||
clearable
|
||||
variant="solo-filled"
|
||||
flat
|
||||
hide-details
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
*/
|
||||
import { ref, computed, type Ref } from 'vue';
|
||||
import type { CommandItem, FilterState } from '../types';
|
||||
import { normalizeTextInput } from '@/utils/inputValue';
|
||||
|
||||
export function useCommandFilters(commands: Ref<CommandItem[]>) {
|
||||
// 过滤状态
|
||||
@@ -95,7 +96,7 @@ export function useCommandFilters(commands: Ref<CommandItem[]>) {
|
||||
* 过滤后的指令列表(支持层级结构)
|
||||
*/
|
||||
const filteredCommands = computed(() => {
|
||||
const query = searchQuery.value.toLowerCase();
|
||||
const query = normalizeTextInput(searchQuery.value).toLowerCase();
|
||||
const conflictCmds: CommandItem[] = [];
|
||||
const normalCmds: CommandItem[] = [];
|
||||
|
||||
@@ -184,4 +185,3 @@ export function useCommandFilters(commands: Ref<CommandItem[]>) {
|
||||
isGroupExpanded
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
import { computed, onActivated, onMounted, ref, watch} from 'vue';
|
||||
import axios from 'axios';
|
||||
import { useModuleI18n } from '@/i18n/composables';
|
||||
import { normalizeTextInput } from '@/utils/inputValue';
|
||||
|
||||
// Composables
|
||||
import { useComponentData } from './composables/useComponentData';
|
||||
@@ -83,7 +84,7 @@ const {
|
||||
} = useCommandActions(toast, () => fetchCommands(tm('messages.loadFailed')));
|
||||
|
||||
const filteredTools = computed(() => {
|
||||
const query = toolSearch.value.trim().toLowerCase();
|
||||
const query = normalizeTextInput(toolSearch.value).trim().toLowerCase();
|
||||
if (!query) return tools.value;
|
||||
return tools.value.filter(tool =>
|
||||
tool.name?.toLowerCase().includes(query) ||
|
||||
@@ -253,7 +254,8 @@ watch(viewMode, async (mode) => {
|
||||
<div class="d-flex flex-wrap align-center ga-3 mb-4">
|
||||
<div style="min-width: 240px; max-width: 380px; flex: 1;">
|
||||
<v-text-field
|
||||
v-model="toolSearch"
|
||||
:model-value="toolSearch"
|
||||
@update:model-value="toolSearch = normalizeTextInput($event)"
|
||||
prepend-inner-icon="mdi-magnify"
|
||||
:label="tmTool('functionTools.search')"
|
||||
variant="outlined"
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
v-model="modelSearchProxy"
|
||||
density="compact"
|
||||
prepend-inner-icon="mdi-magnify"
|
||||
clearable
|
||||
hide-details
|
||||
variant="solo-filled"
|
||||
flat
|
||||
@@ -161,6 +162,7 @@
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import { normalizeTextInput } from '@/utils/inputValue'
|
||||
|
||||
const props = defineProps({
|
||||
entries: {
|
||||
@@ -222,7 +224,7 @@ const emit = defineEmits([
|
||||
|
||||
const modelSearchProxy = computed({
|
||||
get: () => props.modelSearch,
|
||||
set: (val) => emit('update:modelSearch', val)
|
||||
set: (val) => emit('update:modelSearch', normalizeTextInput(val))
|
||||
})
|
||||
|
||||
const isProviderTesting = (providerId) => props.testingProviders.includes(providerId)
|
||||
|
||||
@@ -2,6 +2,7 @@ import { ref, computed, onMounted, nextTick, watch } from 'vue'
|
||||
import axios from 'axios'
|
||||
import { getProviderIcon } from '@/utils/providerUtils'
|
||||
import { askForConfirmation as askForConfirmationDialog, useConfirmDialog } from '@/utils/confirmDialog'
|
||||
import { normalizeTextInput } from '@/utils/inputValue'
|
||||
|
||||
export interface UseProviderSourcesOptions {
|
||||
defaultTab?: string
|
||||
@@ -157,7 +158,7 @@ export function useProviderSources(options: UseProviderSourcesOptions) {
|
||||
})
|
||||
|
||||
const filteredMergedModelEntries = computed(() => {
|
||||
const term = modelSearch.value.trim().toLowerCase()
|
||||
const term = normalizeTextInput(modelSearch.value).trim().toLowerCase()
|
||||
if (!term) return mergedModelEntries.value
|
||||
|
||||
return mergedModelEntries.value.filter((entry: any) => {
|
||||
|
||||
@@ -550,6 +550,10 @@
|
||||
"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."
|
||||
@@ -558,6 +562,30 @@
|
||||
"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."
|
||||
@@ -845,7 +873,8 @@
|
||||
]
|
||||
},
|
||||
"regex": {
|
||||
"description": "Segmentation Regular Expression"
|
||||
"description": "Segmentation Regular Expression",
|
||||
"hint": "Used to identify split points with a regular expression. Prefer patterns that match separators."
|
||||
},
|
||||
"split_words": {
|
||||
"description": "Split Word List",
|
||||
@@ -1493,4 +1522,4 @@
|
||||
"helpMiddle": "or",
|
||||
"helpSuffix": "."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -23,6 +23,9 @@
|
||||
"placeholder": "Search extensions...",
|
||||
"marketPlaceholder": "Search market extensions..."
|
||||
},
|
||||
"filters": {
|
||||
"all": "All"
|
||||
},
|
||||
"views": {
|
||||
"card": "Card View",
|
||||
"list": "List View"
|
||||
@@ -122,10 +125,14 @@
|
||||
"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"
|
||||
},
|
||||
@@ -224,10 +231,43 @@
|
||||
"empty": "No Skills found",
|
||||
"emptyHint": "Upload a Skills zip to get started",
|
||||
"uploadDialogTitle": "Upload Skills",
|
||||
"uploadHint": "Upload a zip file that contains skill_name/ and a SKILL.md inside.",
|
||||
"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.",
|
||||
"selectFile": "Select file",
|
||||
"confirmUpload": "Upload",
|
||||
"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",
|
||||
"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": "启用后,将使用 AstrBot 统一 Webhook 入口,无需单独开启端口。回调地址为 /api/platform/webhook/{webhook_uuid}。"
|
||||
"hint": "Webhook 模式下使用 AstrBot 统一 Webhook 入口,无需单独开启端口。回调地址为 /api/platform/webhook/{webhook_uuid}。"
|
||||
},
|
||||
"webhook_uuid": {
|
||||
"description": "Webhook UUID",
|
||||
@@ -553,13 +553,41 @@
|
||||
"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": "当机器人收到消息时,首先回复的文本内容。留空则不设置。"
|
||||
"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 秒。"
|
||||
},
|
||||
"wpp_active_message_poll": {
|
||||
"description": "是否启用主动消息轮询",
|
||||
@@ -582,11 +610,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",
|
||||
@@ -848,7 +876,8 @@
|
||||
]
|
||||
},
|
||||
"regex": {
|
||||
"description": "分段正则表达式"
|
||||
"description": "分段正则表达式",
|
||||
"hint": "用于按正则规则识别分段点。建议使用能匹配分隔符的表达式。"
|
||||
},
|
||||
"split_words": {
|
||||
"description": "分段词列表",
|
||||
@@ -1496,4 +1525,4 @@
|
||||
"helpMiddle": "或",
|
||||
"helpSuffix": "。"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -23,6 +23,9 @@
|
||||
"placeholder": "搜索插件...",
|
||||
"marketPlaceholder": "搜索市场插件..."
|
||||
},
|
||||
"filters": {
|
||||
"all": "全部"
|
||||
},
|
||||
"views": {
|
||||
"card": "卡片视图",
|
||||
"list": "列表视图"
|
||||
@@ -122,10 +125,14 @@
|
||||
"sourceSafetyWarning": "即使是默认插件源,我们也不能完全保证插件的稳定性和安全性,使用前请谨慎核查。"
|
||||
},
|
||||
"sort": {
|
||||
"by": "排序方式",
|
||||
"default": "默认排序",
|
||||
"installTime": "最后修改时间",
|
||||
"name": "名称",
|
||||
"stars": "Star数",
|
||||
"author": "作者名",
|
||||
"updated": "更新时间",
|
||||
"updateStatus": "更新状态",
|
||||
"ascending": "升序",
|
||||
"descending": "降序"
|
||||
},
|
||||
@@ -224,10 +231,43 @@
|
||||
"empty": "暂无 Skills",
|
||||
"emptyHint": "请上传 Skills 压缩包",
|
||||
"uploadDialogTitle": "上传 Skills",
|
||||
"uploadHint": "请上传 zip 压缩包,解压后为 skill_name/ 目录,且包含 SKILL.md",
|
||||
"uploadHint": "支持批量上传 zip 技能包,也支持拖拽批量上传 zip 技能包。系统会自动校验目录结构,并给出逐个文件的结果。",
|
||||
"structureRequirement": "常见失败原因是压缩包结构不正确。每个 zip 必须只包含一个顶层目录,例如 `skillname/`,且该目录下必须存在 `SKILL.md`。",
|
||||
"abilityMultiple": "支持一次上传多个zip文件",
|
||||
"abilityValidate": "自动校验 `SKILL.md`",
|
||||
"abilitySkip": "自动跳过重复文件",
|
||||
"selectFile": "选择文件",
|
||||
"confirmUpload": "上传",
|
||||
"selectFiles": "选择文件(可多选)",
|
||||
"dropzoneTitle": "拖拽多个 zip 文件到这里",
|
||||
"dropzoneAction": "或者点击之后在文件夹中选择多个文件",
|
||||
"dropzoneHint": "支持批量上传,系统会自动校验目录结构",
|
||||
"fileListTitle": "待处理文件",
|
||||
"fileListEmpty": "选择文件后会在这里显示校验结果与上传状态",
|
||||
"uploading": "正在上传...",
|
||||
"batchResultTitle": "批量上传结果",
|
||||
"batchResultSummary": "共 {total} 个文件,成功 {success} 个",
|
||||
"batchSuccessList": "上传成功",
|
||||
"batchFailedList": "上传失败",
|
||||
"confirm": "确定",
|
||||
"confirmUpload": "开始上传",
|
||||
"cancel": "取消",
|
||||
"statusWaiting": "待上传",
|
||||
"statusUploading": "上传中",
|
||||
"statusSuccess": "已上传",
|
||||
"statusError": "校验失败",
|
||||
"statusSkipped": "已跳过",
|
||||
"summaryTotal": "共 {count} 个文件",
|
||||
"summaryReady": "待处理 {count}",
|
||||
"summarySuccess": "成功 {count}",
|
||||
"summaryFailed": "失败 {count}",
|
||||
"summarySkipped": "跳过 {count}",
|
||||
"validationReady": "等待上传,上传时会自动校验目录结构",
|
||||
"validationZipOnly": "仅支持 zip 技能包",
|
||||
"validationDuplicate": "同名文件已在列表中,已跳过",
|
||||
"validationUploading": "正在校验并上传...",
|
||||
"validationUploadFailed": "上传失败,请重试",
|
||||
"validationUploadedAs": "已安装为 {name}",
|
||||
"validationNoResult": "未收到校验结果,请检查平台日志",
|
||||
"noDescription": "无描述",
|
||||
"path": "路径",
|
||||
"uploadSuccess": "上传成功",
|
||||
|
||||
@@ -27,6 +27,7 @@ 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);
|
||||
@@ -402,15 +403,32 @@ const viewMode = computed({
|
||||
});
|
||||
|
||||
// 监听 viewMode 变化,切换到 bot 模式时跳转到首页
|
||||
watch(() => customizer.viewMode, (newMode, oldMode) => {
|
||||
if (newMode === 'bot' && oldMode === 'chat') {
|
||||
// 从 chat 模式切换到 bot 模式时,跳转到首页
|
||||
if (route.path !== '/') {
|
||||
router.push('/');
|
||||
// 保存 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);
|
||||
}
|
||||
router.push(lastBotRoute);
|
||||
}
|
||||
});
|
||||
|
||||
// Merry Christmas! 🎄
|
||||
const isChristmas = computed(() => {
|
||||
const today = new Date();
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
export const normalizeTextInput = (value: unknown): string =>
|
||||
typeof value === 'string' ? value : '';
|
||||
@@ -0,0 +1,102 @@
|
||||
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),
|
||||
);
|
||||
};
|
||||
@@ -13,9 +13,11 @@
|
||||
</v-select>
|
||||
<v-text-field
|
||||
class="config-search-input"
|
||||
v-model="configSearchKeyword"
|
||||
:model-value="configSearchKeyword"
|
||||
@update:model-value="onConfigSearchInput"
|
||||
prepend-inner-icon="mdi-magnify"
|
||||
:label="tm('search.placeholder')"
|
||||
clearable
|
||||
hide-details
|
||||
density="compact"
|
||||
rounded="md"
|
||||
@@ -211,6 +213,7 @@ import {
|
||||
useConfirmDialog
|
||||
} from '@/utils/confirmDialog';
|
||||
import UnsavedChangesConfirmDialog from '@/components/config/UnsavedChangesConfirmDialog.vue';
|
||||
import { normalizeTextInput } from '@/utils/inputValue';
|
||||
|
||||
export default {
|
||||
name: 'ConfigPage',
|
||||
@@ -419,6 +422,9 @@ export default {
|
||||
|
||||
},
|
||||
methods: {
|
||||
onConfigSearchInput(value) {
|
||||
this.configSearchKeyword = normalizeTextInput(value);
|
||||
},
|
||||
extractConfigTypeFromHash(hash) {
|
||||
const rawHash = String(hash || '');
|
||||
const lastHashIndex = rawHash.lastIndexOf('#');
|
||||
|
||||
@@ -84,7 +84,6 @@ const {
|
||||
normalizeStr,
|
||||
toPinyinText,
|
||||
toInitials,
|
||||
marketCustomFilter,
|
||||
plugin_handler_info_headers,
|
||||
pluginHeaders,
|
||||
filteredExtensions,
|
||||
|
||||
@@ -353,10 +353,11 @@
|
||||
<v-window-item value="search">
|
||||
<div class="search-container pa-4">
|
||||
<v-form @submit.prevent="searchKnowledgeBase" class="d-flex align-center">
|
||||
<v-text-field v-model="searchQuery" :label="tm('search.queryLabel')"
|
||||
<v-text-field :model-value="searchQuery"
|
||||
@update:model-value="onSearchQueryInput" :label="tm('search.queryLabel')"
|
||||
append-icon="mdi-magnify" variant="outlined" class="flex-grow-1 me-2"
|
||||
@click:append="searchKnowledgeBase" @keyup.enter="searchKnowledgeBase"
|
||||
:placeholder="tm('search.queryPlaceholder')" hide-details></v-text-field>
|
||||
:placeholder="tm('search.queryPlaceholder')" hide-details clearable></v-text-field>
|
||||
|
||||
<v-select v-model="topK" :items="[3, 5, 10, 20]"
|
||||
:label="tm('search.resultCountLabel')" variant="outlined"
|
||||
@@ -434,6 +435,7 @@
|
||||
import axios from 'axios';
|
||||
import ConsoleDisplayer from '@/components/shared/ConsoleDisplayer.vue';
|
||||
import { useModuleI18n } from '@/i18n/composables';
|
||||
import { normalizeTextInput } from '@/utils/inputValue';
|
||||
|
||||
export default {
|
||||
name: 'KnowledgeBase',
|
||||
@@ -580,6 +582,9 @@ export default {
|
||||
this.getProviderList();
|
||||
},
|
||||
methods: {
|
||||
onSearchQueryInput(value) {
|
||||
this.searchQuery = normalizeTextInput(value);
|
||||
},
|
||||
getSelectedGitHubProxy() {
|
||||
if (typeof window === "undefined" || !window.localStorage) return "";
|
||||
return localStorage.getItem("githubProxyRadioValue") === "1"
|
||||
@@ -903,7 +908,8 @@ export default {
|
||||
},
|
||||
|
||||
searchKnowledgeBase() {
|
||||
if (!this.searchQuery.trim()) {
|
||||
const query = normalizeTextInput(this.searchQuery).trim();
|
||||
if (!query) {
|
||||
this.showSnackbar(this.tm('messages.pleaseEnterSearchContent'), 'warning');
|
||||
return;
|
||||
}
|
||||
@@ -914,7 +920,7 @@ export default {
|
||||
axios.get(`/api/plug/alkaid/kb/collection/search`, {
|
||||
params: {
|
||||
collection_name: this.currentKB.collection_name,
|
||||
query: this.searchQuery,
|
||||
query,
|
||||
top_k: this.topK
|
||||
}
|
||||
})
|
||||
|
||||
@@ -37,10 +37,12 @@
|
||||
<h3>{{ tm('search.title') }}</h3>
|
||||
<v-card variant="outlined" class="mt-2 pa-3">
|
||||
<div>
|
||||
<v-text-field v-model="searchMemoryUserId" :label="tm('search.userIdLabel')" variant="outlined" density="compact" hide-details
|
||||
class="mb-2"></v-text-field>
|
||||
<v-text-field v-model="searchQuery" :label="tm('search.queryLabel')" variant="outlined" density="compact" hide-details
|
||||
@keyup.enter="searchMemory" class="mb-2"></v-text-field>
|
||||
<v-text-field :model-value="searchMemoryUserId"
|
||||
@update:model-value="onSearchMemoryUserIdInput" :label="tm('search.userIdLabel')" variant="outlined" density="compact" hide-details
|
||||
class="mb-2" clearable></v-text-field>
|
||||
<v-text-field :model-value="searchQuery"
|
||||
@update:model-value="onSearchQueryInput" :label="tm('search.queryLabel')" variant="outlined" density="compact" hide-details
|
||||
@keyup.enter="searchMemory" class="mb-2" clearable></v-text-field>
|
||||
<v-btn color="info" @click="searchMemory" :loading="isSearching" variant="tonal">
|
||||
<v-icon start>mdi-text-search</v-icon>
|
||||
{{ tm('search.searchButton') }}
|
||||
@@ -254,6 +256,7 @@
|
||||
import axios from 'axios';
|
||||
// import * as d3 from "d3"; // npm install d3
|
||||
import { useModuleI18n } from '@/i18n/composables';
|
||||
import { normalizeTextInput } from '@/utils/inputValue';
|
||||
|
||||
export default {
|
||||
name: 'LongTermMemory',
|
||||
@@ -336,9 +339,16 @@ export default {
|
||||
this.searchResults = [];
|
||||
},
|
||||
methods: {
|
||||
onSearchMemoryUserIdInput(value) {
|
||||
this.searchMemoryUserId = normalizeTextInput(value);
|
||||
},
|
||||
onSearchQueryInput(value) {
|
||||
this.searchQuery = normalizeTextInput(value);
|
||||
},
|
||||
// 添加搜索记忆方法
|
||||
searchMemory() {
|
||||
if (!this.searchQuery.trim()) {
|
||||
const query = normalizeTextInput(this.searchQuery).trim();
|
||||
if (!query) {
|
||||
this.$toast.warning(this.tm('messages.searchQueryRequired'));
|
||||
return;
|
||||
}
|
||||
@@ -349,12 +359,13 @@ export default {
|
||||
|
||||
// 构建查询参数
|
||||
const params = {
|
||||
query: this.searchQuery
|
||||
query
|
||||
};
|
||||
|
||||
// 如果有选择用户ID,也加入查询参数
|
||||
if (this.searchMemoryUserId) {
|
||||
params.user_id = this.searchMemoryUserId;
|
||||
const normalizedUserId = normalizeTextInput(this.searchMemoryUserId).trim();
|
||||
if (normalizedUserId) {
|
||||
params.user_id = normalizedUserId;
|
||||
}
|
||||
|
||||
axios.get('/api/plug/alkaid/ltm/graph/search', { params })
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
<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";
|
||||
import { normalizeTextInput } from "@/utils/inputValue";
|
||||
|
||||
const props = defineProps({
|
||||
state: {
|
||||
@@ -48,6 +50,9 @@ const {
|
||||
getInitialListViewMode,
|
||||
isListView,
|
||||
pluginSearch,
|
||||
installedStatusFilter,
|
||||
installedSortBy,
|
||||
installedSortOrder,
|
||||
loading_,
|
||||
currentPage,
|
||||
dangerConfirmDialog,
|
||||
@@ -81,8 +86,9 @@ const {
|
||||
normalizeStr,
|
||||
toPinyinText,
|
||||
toInitials,
|
||||
marketCustomFilter,
|
||||
plugin_handler_info_headers,
|
||||
installedSortItems,
|
||||
installedSortUsesOrder,
|
||||
pluginHeaders,
|
||||
filteredExtensions,
|
||||
filteredPlugins,
|
||||
@@ -159,10 +165,12 @@ const {
|
||||
|
||||
<div class="d-flex align-center flex-wrap ml-auto" style="gap: 8px">
|
||||
<v-text-field
|
||||
v-model="pluginSearch"
|
||||
:model-value="pluginSearch"
|
||||
@update:model-value="pluginSearch = normalizeTextInput($event)"
|
||||
density="compact"
|
||||
:label="tm('search.placeholder')"
|
||||
prepend-inner-icon="mdi-magnify"
|
||||
clearable
|
||||
variant="solo-filled"
|
||||
flat
|
||||
hide-details
|
||||
@@ -186,30 +194,64 @@ const {
|
||||
</div>
|
||||
|
||||
<v-row class="mb-4">
|
||||
<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-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-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>
|
||||
<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>
|
||||
|
||||
<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>
|
||||
|
||||
@@ -655,6 +697,32 @@ 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;
|
||||
@@ -685,6 +753,14 @@ 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,7 +1,9 @@
|
||||
<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";
|
||||
import { normalizeTextInput } from "@/utils/inputValue";
|
||||
|
||||
const props = defineProps({
|
||||
state: {
|
||||
@@ -82,7 +84,6 @@ const {
|
||||
normalizeStr,
|
||||
toPinyinText,
|
||||
toInitials,
|
||||
marketCustomFilter,
|
||||
plugin_handler_info_headers,
|
||||
pluginHeaders,
|
||||
filteredExtensions,
|
||||
@@ -158,6 +159,13 @@ 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>
|
||||
@@ -205,11 +213,13 @@ const currentSourceName = computed(() => {
|
||||
</div>
|
||||
|
||||
<v-text-field
|
||||
v-model="marketSearch"
|
||||
:model-value="marketSearch"
|
||||
@update:model-value="marketSearch = normalizeTextInput($event)"
|
||||
class="ml-auto"
|
||||
density="compact"
|
||||
:label="tm('search.marketPlaceholder')"
|
||||
prepend-inner-icon="mdi-magnify"
|
||||
clearable
|
||||
variant="solo-filled"
|
||||
flat
|
||||
hide-details
|
||||
@@ -328,44 +338,16 @@ const currentSourceName = computed(() => {
|
||||
class="d-flex align-center"
|
||||
style="gap: 8px; flex-wrap: wrap"
|
||||
>
|
||||
<v-select
|
||||
<PluginSortControl
|
||||
v-model="sortBy"
|
||||
: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>
|
||||
: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"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
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";
|
||||
@@ -180,6 +186,9 @@ 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);
|
||||
|
||||
// 分页相关
|
||||
@@ -240,37 +249,6 @@ 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" },
|
||||
@@ -278,6 +256,18 @@ 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);
|
||||
@@ -286,16 +276,19 @@ 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%",
|
||||
},
|
||||
];
|
||||
@@ -304,6 +297,7 @@ export const useExtensionPage = () => {
|
||||
headers.push({
|
||||
title: tm("table.headers.author"),
|
||||
key: "author",
|
||||
sortable: false,
|
||||
width: "10%",
|
||||
});
|
||||
}
|
||||
@@ -326,68 +320,132 @@ export const useExtensionPage = () => {
|
||||
}
|
||||
return data;
|
||||
});
|
||||
|
||||
const sortPluginsByName = (plugins) => {
|
||||
|
||||
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) => {
|
||||
return plugins
|
||||
.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;
|
||||
.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;
|
||||
}
|
||||
return a.index - b.index;
|
||||
|
||||
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;
|
||||
})
|
||||
.map((item) => item.plugin);
|
||||
};
|
||||
|
||||
// 通过搜索过滤插件
|
||||
const filteredPlugins = computed(() => {
|
||||
const plugins = filteredExtensions.value;
|
||||
let filtered = plugins;
|
||||
const plugins = filteredExtensions.value.filter((plugin) => {
|
||||
if (installedStatusFilter.value === "enabled") {
|
||||
return !!plugin.activated;
|
||||
}
|
||||
if (installedStatusFilter.value === "disabled") {
|
||||
return !plugin.activated;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
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();
|
||||
const query = buildSearchQuery(pluginSearch.value);
|
||||
const filtered = query
|
||||
? plugins.filter((plugin) => matchesPluginSearch(plugin, query))
|
||||
: plugins;
|
||||
|
||||
return (
|
||||
pluginName.includes(search) ||
|
||||
pluginDesc.includes(search) ||
|
||||
pluginAuthor.includes(search) ||
|
||||
supportPlatforms.includes(search) ||
|
||||
astrbotVersion.includes(search)
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
return sortPluginsByName([...filtered]);
|
||||
return sortInstalledPlugins(filtered);
|
||||
});
|
||||
|
||||
// 过滤后的插件市场数据(带搜索)
|
||||
const filteredMarketPlugins = computed(() => {
|
||||
if (!debouncedMarketSearch.value) {
|
||||
const query = buildSearchQuery(debouncedMarketSearch.value);
|
||||
if (!query) {
|
||||
return pluginMarketData.value;
|
||||
}
|
||||
|
||||
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)
|
||||
);
|
||||
});
|
||||
|
||||
return pluginMarketData.value.filter((plugin) =>
|
||||
matchesPluginSearch(plugin, query),
|
||||
);
|
||||
});
|
||||
|
||||
// 所有插件列表,推荐插件排在前面
|
||||
@@ -1529,6 +1587,9 @@ export const useExtensionPage = () => {
|
||||
getInitialListViewMode,
|
||||
isListView,
|
||||
pluginSearch,
|
||||
installedStatusFilter,
|
||||
installedSortBy,
|
||||
installedSortOrder,
|
||||
loading_,
|
||||
currentPage,
|
||||
dangerConfirmDialog,
|
||||
@@ -1563,8 +1624,9 @@ export const useExtensionPage = () => {
|
||||
normalizeStr,
|
||||
toPinyinText,
|
||||
toInitials,
|
||||
marketCustomFilter,
|
||||
plugin_handler_info_headers,
|
||||
installedSortItems,
|
||||
installedSortUsesOrder,
|
||||
pluginHeaders,
|
||||
filteredExtensions,
|
||||
filteredPlugins,
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
__pycache__/
|
||||
venv/
|
||||
.DS_Store
|
||||
node_modules/
|
||||
.vitepress/cache
|
||||
*dist
|
||||
@@ -0,0 +1,530 @@
|
||||
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/edit/master/docs/: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/edit/master/docs/: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: "关闭",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,47 @@
|
||||
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" }],
|
||||
];
|
||||
@@ -0,0 +1,194 @@
|
||||
<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>
|
||||
@@ -0,0 +1,7 @@
|
||||
<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>
|
||||
@@ -0,0 +1,131 @@
|
||||
<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>
|
||||
@@ -0,0 +1,73 @@
|
||||
<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>
|
||||
@@ -0,0 +1,121 @@
|
||||
<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>
|
||||
@@ -0,0 +1,21 @@
|
||||
// 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)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
/* .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;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
/* Keep only the top-left navbar title in Outfit; use VitePress defaults elsewhere. */
|
||||
.VPNavBarTitle .title,
|
||||
.VPNavBarTitle .title .text {
|
||||
font-family: "Outfit", sans-serif !important;
|
||||
}
|
||||
@@ -0,0 +1,358 @@
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
|
||||
# AstrBot
|
||||
_✨ 易上手的多平台 LLM 聊天机器人及开发框架(的官方文档) ✨_
|
||||
|
||||
[查看文档](https://docs.astrbot.app/) | [问题提交](https://github.com/AstrBotDevs/AstrBot/issues)
|
||||
|
||||
[AstrBot](https://github.com/AstrBotDevs/AstrBot) 是一个松耦合、异步、支持多消息平台部署、具有易用的插件系统和完善的大语言模型(LLM)接入功能的聊天机器人及开发框架。
|
||||
|
||||

|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
# 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)
|
||||
|
||||
### 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!
|
||||
@@ -0,0 +1,12 @@
|
||||
|
||||
# 配置自定义的模型参数
|
||||
|
||||
请手动修改位于 `data/cmd_config.json` 下的配置文件。
|
||||
|
||||
找到 `provider`,并找到你想要修改的提供商的模型配置:
|
||||
|
||||

|
||||
|
||||
然后在 `model_config` 中添加新的参数即可。
|
||||
|
||||
具体的参数请参看对应的提供商的文档。
|
||||
@@ -0,0 +1,27 @@
|
||||
# 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.
|
||||
@@ -0,0 +1,48 @@
|
||||
# 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.
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user