Compare commits
32 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| bb1b6a3814 | |||
| 28bfb3b8b2 | |||
| 351895ae66 | |||
| c1009adf52 | |||
| ecaec41208 | |||
| 997b51102b | |||
| c5bd074c28 | |||
| 4c09ed3c09 | |||
| a56e43d17e | |||
| e357d9de74 | |||
| 94736ff199 | |||
| aff92a48bf | |||
| d0998a9dfb | |||
| 3678688433 | |||
| 0c03177840 | |||
| 20ff719c00 | |||
| 8a8ec492d7 | |||
| 02c1443dd1 | |||
| 79301f192c | |||
| 4b2c854c42 | |||
| d02ee7be8b | |||
| dbeadb6833 | |||
| 478cc32de1 | |||
| 7b302445c2 | |||
| ae839ef6d8 | |||
| 144a53f4b3 | |||
| fa1d1e6034 | |||
| a404436f2c | |||
| bcb12a0717 | |||
| 5d0fc8ac7a | |||
| a4d37e2c20 | |||
| c599fb75ed |
@@ -17,7 +17,6 @@ ENV/
|
||||
.conda/
|
||||
dashboard/
|
||||
data/
|
||||
changelogs/
|
||||
tests/
|
||||
.ruff_cache/
|
||||
.astrbot
|
||||
|
||||
@@ -43,7 +43,7 @@ AstrBot 是一个开源的一站式 Agentic 个人和群聊助手,可在 QQ、
|
||||
2. ✨ AI 大模型对话,多模态,Agent,MCP,Skills,知识库,人格设定,自动压缩对话。
|
||||
3. 🤖 支持接入 Dify、阿里云百炼、Coze 等智能体平台。
|
||||
4. 🌐 多平台,支持 QQ、企业微信、飞书、钉钉、微信公众号、Telegram、Slack 以及[更多](#支持的消息平台)。
|
||||
5. 📦 插件扩展,已有近 800 个插件可一键安装。
|
||||
5. 📦 插件扩展,已有 1000+ 个插件可一键安装。
|
||||
6. 🛡️ [Agent Sandbox](https://docs.astrbot.app/use/astrbot-agent-sandbox.html) 隔离化环境,安全地执行任何代码、调用 Shell、会话级资源复用。
|
||||
7. 💻 WebUI 支持。
|
||||
8. 🌈 Web ChatUI 支持,ChatUI 内置代理沙盒、网页搜索等。
|
||||
@@ -56,7 +56,7 @@ AstrBot 是一个开源的一站式 Agentic 个人和群聊助手,可在 QQ、
|
||||
<th>💙 角色扮演 & 情感陪伴</th>
|
||||
<th>✨ 主动式 Agent</th>
|
||||
<th>🚀 通用 Agentic 能力</th>
|
||||
<th>🧩 900+ 社区插件</th>
|
||||
<th>🧩 1000+ 社区插件</th>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><p align="center"><img width="984" height="1746" alt="99b587c5d35eea09d84f33e6cf6cfd4f" src="https://github.com/user-attachments/assets/89196061-3290-458d-b51f-afa178049f84" /></p></td>
|
||||
@@ -81,9 +81,15 @@ uv tool install astrbot
|
||||
astrbot
|
||||
```
|
||||
|
||||
#### 桌面应用部署(Tauri)
|
||||
|
||||
桌面应用仓库 [AstrBot-desktop](https://github.com/AstrBotDevs/AstrBot-desktop)。
|
||||
|
||||
支持多系统架构,安装包直接安装,开箱即用,最适合新手和懒人的一键桌面部署方案,不推荐服务器场景。
|
||||
|
||||
#### 启动器一键部署(AstrBot Launcher)
|
||||
|
||||
进入 [AstrBot Launcher](https://github.com/Raven95676/astrbot-launcher) 仓库,在 Releases 页最新版本下找到对应的系统安装包安装即可。
|
||||
快速部署和多开方案,实现环境隔离,进入 [AstrBot Launcher](https://github.com/Raven95676/astrbot-launcher) 仓库,在 Releases 页最新版本下找到对应的系统安装包安装即可。
|
||||
|
||||
#### 宝塔面板部署
|
||||
|
||||
@@ -146,10 +152,6 @@ yay -S astrbot-git
|
||||
paru -S astrbot-git
|
||||
```
|
||||
|
||||
#### 桌面端(Tauri)
|
||||
|
||||
桌面端已迁移为独立仓库(Tauri):[https://github.com/AstrBotDevs/AstrBot-desktop](https://github.com/AstrBotDevs/AstrBot-desktop)。
|
||||
|
||||
## 支持的消息平台
|
||||
|
||||
**官方维护**
|
||||
@@ -262,7 +264,7 @@ pre-commit install
|
||||
特别感谢所有 Contributors 和插件开发者对 AstrBot 的贡献 ❤️
|
||||
|
||||
<a href="https://github.com/AstrBotDevs/AstrBot/graphs/contributors">
|
||||
<img src="https://contrib.rocks/image?repo=AstrBotDevs/AstrBot" />
|
||||
<img src="https://contrib.rocks/image?repo=AstrBotDevs/AstrBot&max=200&columns=14" />
|
||||
</a>
|
||||
|
||||
此外,本项目的诞生离不开以下开源项目的帮助:
|
||||
|
||||
+14
-18
@@ -37,7 +37,7 @@
|
||||
|
||||
AstrBot is an open-source all-in-one Agent chatbot platform that integrates with mainstream instant messaging apps. It provides reliable and scalable conversational AI infrastructure for individuals, developers, and teams. Whether you're building a personal AI companion, intelligent customer service, automation assistant, or enterprise knowledge base, AstrBot enables you to quickly build production-ready AI applications within your IM platform workflows.
|
||||
|
||||

|
||||

|
||||
|
||||
## Key Features
|
||||
|
||||
@@ -45,7 +45,7 @@ AstrBot is an open-source all-in-one Agent chatbot platform that integrates with
|
||||
2. ✨ AI LLM Conversations, Multimodal, Agent, MCP, Skills, Knowledge Base, Persona Settings, Auto Context Compression.
|
||||
3. 🤖 Supports integration with Dify, Alibaba Cloud Bailian, Coze, and other agent platforms.
|
||||
4. 🌐 Multi-Platform: QQ, WeChat Work, Feishu, DingTalk, WeChat Official Accounts, Telegram, Slack, and [more](#supported-messaging-platforms).
|
||||
5. 📦 Plugin Extensions with nearly 800 plugins available for one-click installation.
|
||||
5. 📦 Plugin Extensions with 1000+ plugins available for one-click installation.
|
||||
6. 🛡️ [Agent Sandbox](https://docs.astrbot.app/use/astrbot-agent-sandbox.html) for isolated, safe execution of code, shell calls, and session-level resource reuse.
|
||||
7. 💻 WebUI Support.
|
||||
8. 🌈 Web ChatUI Support with built-in agent sandbox and web search.
|
||||
@@ -58,7 +58,7 @@ AstrBot is an open-source all-in-one Agent chatbot platform that integrates with
|
||||
<th>💙 Role-playing & Emotional Companionship</th>
|
||||
<th>✨ Proactive Agent</th>
|
||||
<th>🚀 General Agentic Capabilities</th>
|
||||
<th>🧩 900+ Community Plugins</th>
|
||||
<th>🧩 1000+ Community Plugins</th>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><p align="center"><img width="984" height="1746" alt="99b587c5d35eea09d84f33e6cf6cfd4f" src="https://github.com/user-attachments/assets/89196061-3290-458d-b51f-afa178049f84" /></p></td>
|
||||
@@ -93,6 +93,16 @@ yay -S astrbot-git
|
||||
paru -S astrbot-git
|
||||
```
|
||||
|
||||
#### Desktop Application (Tauri)
|
||||
|
||||
Desktop repository: [AstrBot-desktop](https://github.com/AstrBotDevs/AstrBot-desktop).
|
||||
|
||||
Supports multiple system architectures, direct installation, out-of-the-box experience. Ideal for beginners.
|
||||
|
||||
#### AstrBot Launcher
|
||||
|
||||
Quick deployment and multi-instance solution. Visit the [AstrBot Launcher](https://github.com/Raven95676/astrbot-launcher) repository and find the latest release for your system.
|
||||
|
||||
#### BT-Panel Deployment
|
||||
|
||||
AstrBot has partnered with BT-Panel and is now available in their marketplace.
|
||||
@@ -144,20 +154,6 @@ uv run main.py
|
||||
|
||||
Or refer to the official documentation: [Deploy AstrBot from Source](https://astrbot.app/deploy/astrbot/cli.html).
|
||||
|
||||
#### System Package Manager Installation
|
||||
|
||||
##### Arch Linux
|
||||
|
||||
```bash
|
||||
yay -S astrbot-git
|
||||
# or use paru
|
||||
paru -S astrbot-git
|
||||
```
|
||||
|
||||
#### Desktop (Tauri)
|
||||
|
||||
Desktop packaging has moved to a standalone Tauri repository: [https://github.com/AstrBotDevs/AstrBot-desktop](https://github.com/AstrBotDevs/AstrBot-desktop).
|
||||
|
||||
## Supported Messaging Platforms
|
||||
|
||||
**Officially Maintained**
|
||||
@@ -268,7 +264,7 @@ pre-commit install
|
||||
Special thanks to all Contributors and plugin developers for their contributions to AstrBot ❤️
|
||||
|
||||
<a href="https://github.com/AstrBotDevs/AstrBot/graphs/contributors">
|
||||
<img src="https://contrib.rocks/image?repo=AstrBotDevs/AstrBot" />
|
||||
<img src="https://contrib.rocks/image?repo=AstrBotDevs/AstrBot&max=200&columns=14" />
|
||||
</a>
|
||||
|
||||
Additionally, the birth of this project would not have been possible without the help of the following open-source projects:
|
||||
|
||||
+15
-15
@@ -21,9 +21,9 @@
|
||||
<img src="https://img.shields.io/github/v/release/AstrBotDevs/AstrBot?color=76bad9" href="https://github.com/AstrBotDevs/AstrBot/releases/latest">
|
||||
<img src="https://img.shields.io/badge/python-3.10+-blue.svg" alt="python">
|
||||
<img src="https://deepwiki.com/badge.svg" href="https://deepwiki.com/AstrBotDevs/AstrBot">
|
||||
<a href="https://zread.ai/AstrBotDevs/AstrBot" target="_blank"><img src="https://img.shields.io/badge/Ask_Zread-_.svg?style=flat&color=00b0aa&labelColor=000000&logo=data%3Aimage%2Fsvg%2Bxml%3Bbase64%2CPHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHZpZXdCb3g9IjAgMCAxNiAxNiIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPHBhdGggZD0iTTQuOTYxNTYgMS42MDAxSDIuMjQxNTZDMS44ODgxIDEuNjAwMSAxLjYwMTU2IDEuODg2NjQgMS42MDE1NiAyLjI0MDFWNC45NjAxQzEuNjAxNTYgNS4zMTM1NiAxLjg4ODEgNS42MDAxIDIuMjQxNTYgNS42MDAxSDQuOTYxNTZDNS4zMTUwMiA1LjYwMDEgNS42MDE1NiA1LjMxMzU2IDUuNjAxNTYgNC45NjAxVjIuMjQwMUM1LjYwMTU2IDEuODg2NjQgNS4zMTUwMiAxLjYwMDEgNC45NjE1NiAxLjYwMDFaIiBmaWxsPSIjZmZmIi8%2BCjxwYXRoIGQ9Ik00Ljk2MTU2IDEwLjM5OTlIMi4yNDE1NkMxLjg4ODEgMTAuMzk5OSAxLjYwMTU2IDEwLjY4NjQgMS42MDE1NiAxMS4wMzk5VjEzLjc1OTlDMS42MDE1NiAxNC4xMTM0IDEuODg4MSAxNC4zOTk5IDIuMjQxNTYgMTQuMzk5OUg0Ljk2MTU2QzUuMzE1MDIgMTQuMzk5OSA1LjYwMTU2IDE0LjExMzQgNS42MDE1NiAxMy43NTk5VjExLjAzOTlDNS42MDE1NiAxMC42ODY0IDUuMzE1MDIgMTAuMzk5OSA0Ljk2MTU2IDEwLjM5OTlaIiBmaWxsPSIjZmZmIi8%2BCjxwYXRoIGQ9Ik0xMy43NTg0IDEuNjAwMUgxMS4wMzg0QzEwLjY4NSAxLjYwMDEgMTAuMzk4NCAxLjg4NjY0IDEwLjM5ODQgMi4yNDAxVjQuOTYwMUMxMC4zOTg0IDUuMzEzNTYgMTAuNjg1IDUuNjAwMSAxMS4wMzg0IDUuNjAwMUgxMy43NTg0QzE0LjExMTkgNS42MDAxIDE0LjM5ODQgNS4zMTM1NiAxNC4zOTg0IDQuOTYwMVYyLjI0MDFDMTQuMzk4NCAxLjg4NjY0IDE0LjExMTkgMS42MDAxIDEzLjc1ODQgMS42MDAxWiIgZmlsbD0iI2ZmZiIvPgo8cGF0aCBkPSJNNCAxMkwxMiA0TDQgMTJaIiBmaWxsPSIjZmZmIi8%2BCjxwYXRoIGQ9Ik00IDEyTDEyIDQiIHN0cm9rZT0iI2ZmZiIgc3Ryb2tlLXdpZHRoPSIxLjUiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIvPgo8L3N2Zz4K&logoColor=ffffff" alt="zread"/></a>
|
||||
<a href="https://zread.ai/AstrBotDevs/AstrBot" target="_blank"><img src="https://img.shields.io/badge/Ask_Zread-_.svg?style=flat&color=00b0aa&labelColor=000000&logo=data%3Aimage%2Fsvg%2Bxml%3Bbase64%2CPHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHZpZXdCb3g9IjAgMCAxNiAxNiIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPHBhdGggZD0iTTQuOTYxNTYgMS42MDAxSDIuMjQxNTZDMS44ODgxIDEuNjAwMSAxLjYwMTU2IDEuODg2NjQgMS42MDE1NiAyLjI0MDFWNC45NjAxQzEuNjAxNTYgNS4zMTM1NiAxLjg4ODEgNS42MDAxIDIuMjQxNTYgNS42MDAxSDQuOTYxNTZDNS4zMTUwMiA1LjYwMDEgNS42MDE1NiA1LjMxMzU2IDUuNjAxNTYgNC45NjAxVjIuMjQwMUM1LjYwMTU2IDEuODg2NjQgNS4zMTUwMiAxLjYwMDEgNC45NjE1NiAxLjYwMDFZIiBmaWxsPSIjZmZmIi8%2BCjxwYXRoIGQ9Ik00Ljk2MTU2IDEwLjM5OTlIMi4yNDE1NkMxLjg4ODEgMTAuMzk5OSAxLjYwMTU2IDEwLjY4NjQgMS42MDE1NiAxMS4wMzk5VjEzLjc1OTlDMS42MDE1NiAxNC4xMTM0IDEuODg4MSAxNC4zOTk5IDIuMjQxNTYgMTQuMzk5OUg0Ljk2MTU2QzUuMzE1MDIgMTQuMzk5OSA1LjYwMTU2IDE0LjExMzQgNS42MDE1NiAxMy43NTk5VjExLjAzOTlDNS42MDE1NiAxMC42ODY0IDUuMzE1MDIgMTAuMzk5OSA0Ljk2MTU2IDEwLjM5OTlaIiBmaWxsPSIjZmZmIi8%2BCjxwYXRoIGQ9Ik0xMy43NTg0IDEuNjAwMUgxMS4wMzg0QzEwLjY4NSAxLjYwMDEgMTAuMzk4NCAxLjg4NjY0IDEwLjM5ODQgMi4yNDAxVjQuOTYwMUMxMC4zOTg0IDUuMzEzNTYgMTAuNjg1IDUuNjAwMSAxMS4wMzg0IDUuNjAwMUgxMy43NTg0QzE0LjExMTkgNS42MDAxIDE0LjM5ODQgNS4zMTM1NiAxNC4zOTg0IDQuOTYwMVYyLjI0MDFDMTQuMzk4NCAxLjg4NjY0IDE0LjExMTkgMS42MDAxIDEzLjc1ODQgMS42MDAxWiIgZmlsbD0iI2ZmZiIvPgo8cGF0aCBkPSJNNCAxMkwxMiA0TDQgMTJaIiBmaWxsPSIjZmZmIi8%2BCjxwYXRoIGQ9Ik00IDEyTDEyIDQiIHN0cm9rZT0iI2ZmZiIgc3Ryb2tlLXdpZHRoPSIxLjUiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIvPgo8L3N2Zz4K&logoColor=ffffff" alt="zread"/></a>
|
||||
<a href="https://hub.docker.com/r/soulter/astrbot"><img alt="Docker pull" src="https://img.shields.io/docker/pulls/soulter/astrbot.svg?color=76bad9"/></a>
|
||||
<img src="https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fapi.soulter.top%2Fastrbot%2Fplugin-num&query=%24.result&suffix=%E4%B8%AA&label=%E6%8F%92%E4%BB%B6%E5%B8%82%E5%9C%BA&cacheSeconds=3600">
|
||||
<img src="https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fapi.soulter.top%2Fastrbot%2Fplugin-num&query=%24.result&suffix=%20&label=Marketplace&cacheSeconds=3600">
|
||||
<img src="https://gitcode.com/Soulter/AstrBot/star/badge.svg" href="https://gitcode.com/Soulter/AstrBot">
|
||||
</div>
|
||||
|
||||
@@ -37,7 +37,7 @@
|
||||
|
||||
AstrBot est une plateforme de chatbot Agent tout-en-un open source qui s'intègre aux principales applications de messagerie instantanée. Elle fournit une infrastructure d'IA conversationnelle fiable et évolutive pour les particuliers, les développeurs et les équipes. Que vous construisiez un compagnon IA personnel, un service client intelligent, un assistant d'automatisation ou une base de connaissances d'entreprise, AstrBot vous permet de créer rapidement des applications d'IA prêtes pour la production dans les flux de travail de votre plateforme de messagerie.
|
||||
|
||||
<img width="1776" height="1080" alt="image" src="https://github.com/user-attachments/assets/00782c4c-4437-4d97-aabc-605e3738da5c" />
|
||||

|
||||
|
||||
## Fonctionnalités principales
|
||||
|
||||
@@ -45,7 +45,7 @@ AstrBot est une plateforme de chatbot Agent tout-en-un open source qui s'intègr
|
||||
2. ✨ Dialogue avec de grands modèles d'IA, multimodal, Agent, MCP, Skills, Base de connaissances, Paramétrage de personnalité, compression automatique des dialogues.
|
||||
3. 🤖 Prise en charge de l'accès aux plateformes d'Agents telles que Dify, Alibaba Cloud Bailian, Coze, etc.
|
||||
4. 🌐 Multiplateforme : supporte QQ, WeChat Enterprise, Feishu, DingTalk, Comptes officiels WeChat, Telegram, Slack et [plus encore](#plateformes-de-messagerie-prises-en-charge).
|
||||
5. 📦 Extension par plugins, avec près de 800 plugins déjà disponibles pour une installation en un clic.
|
||||
5. 📦 Extension par plugins, avec plus de 1000 plugins déjà disponibles pour une installation en un clic.
|
||||
6. 🛡️ Environnement isolé [Agent Sandbox](https://docs.astrbot.app/use/astrbot-agent-sandbox.html) : exécution sécurisée de code, appels Shell et réutilisation des ressources au niveau de la session.
|
||||
7. 💻 Support WebUI.
|
||||
8. 🌈 Support Web ChatUI, avec sandbox d'agent intégrée, recherche web, etc.
|
||||
@@ -58,7 +58,7 @@ AstrBot est une plateforme de chatbot Agent tout-en-un open source qui s'intègr
|
||||
<th>💙 Jeux de rôle & Accompagnement émotionnel</th>
|
||||
<th>✨ Agent proactif</th>
|
||||
<th>🚀 Capacités agentiques générales</th>
|
||||
<th>🧩 900+ Plugins de communauté</th>
|
||||
<th>🧩 1000+ Plugins de communauté</th>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><p align="center"><img width="984" height="1746" alt="99b587c5d35eea09d84f33e6cf6cfd4f" src="https://github.com/user-attachments/assets/89196061-3290-458d-b51f-afa178049f84" /></p></td>
|
||||
@@ -83,15 +83,15 @@ uv tool install astrbot
|
||||
astrbot
|
||||
```
|
||||
|
||||
#### Installation via le gestionnaire de paquets du système
|
||||
#### Application de bureau (Tauri)
|
||||
|
||||
##### Arch Linux
|
||||
Dépôt de l'application de bureau : [AstrBot-desktop](https://github.com/AstrBotDevs/AstrBot-desktop).
|
||||
|
||||
```bash
|
||||
yay -S astrbot-git
|
||||
# ou utiliser paru
|
||||
paru -S astrbot-git
|
||||
```
|
||||
Prend en charge plusieurs architectures système, installation directe, prête à l'emploi. La solution de déploiement de bureau en un clic la plus adaptée aux débutants. Non recommandée pour les serveurs.
|
||||
|
||||
#### Déploiement en un clic avec le lanceur (AstrBot Launcher)
|
||||
|
||||
Déploiement rapide et solution multi-instances, isolation de l'environnement. Accédez au dépôt [AstrBot Launcher](https://github.com/Raven95676/astrbot-launcher), trouvez le package d'installation correspondant à votre système sous la dernière version sur la page Releases.
|
||||
|
||||
#### Déploiement BT-Panel
|
||||
|
||||
@@ -144,13 +144,13 @@ uv run main.py
|
||||
|
||||
Ou consultez la documentation officielle : [Déployer AstrBot depuis les sources](https://astrbot.app/deploy/astrbot/cli.html).
|
||||
|
||||
#### Установка через системный пакетный менеджер
|
||||
#### Installation via le gestionnaire de paquets du système
|
||||
|
||||
##### Arch Linux
|
||||
|
||||
```bash
|
||||
yay -S astrbot-git
|
||||
# или используйте paru
|
||||
# ou utiliser paru
|
||||
paru -S astrbot-git
|
||||
```
|
||||
|
||||
@@ -262,7 +262,7 @@ pre-commit install
|
||||
Un grand merci à tous les contributeurs et développeurs de plugins pour leurs contributions à AstrBot ❤️
|
||||
|
||||
<a href="https://github.com/AstrBotDevs/AstrBot/graphs/contributors">
|
||||
<img src="https://contrib.rocks/image?repo=AstrBotDevs/AstrBot" />
|
||||
<img src="https://contrib.rocks/image?repo=AstrBotDevs/AstrBot&max=200&columns=14" />
|
||||
</a>
|
||||
|
||||
De plus, la naissance de ce projet n'aurait pas été possible sans l'aide des projets open source suivants :
|
||||
|
||||
+15
-15
@@ -21,9 +21,9 @@
|
||||
<img src="https://img.shields.io/github/v/release/AstrBotDevs/AstrBot?color=76bad9" href="https://github.com/AstrBotDevs/AstrBot/releases/latest">
|
||||
<img src="https://img.shields.io/badge/python-3.10+-blue.svg" alt="python">
|
||||
<img src="https://deepwiki.com/badge.svg" href="https://deepwiki.com/AstrBotDevs/AstrBot">
|
||||
<a href="https://zread.ai/AstrBotDevs/AstrBot" target="_blank"><img src="https://img.shields.io/badge/Ask_Zread-_.svg?style=flat&color=00b0aa&labelColor=000000&logo=data%3Aimage%2Fsvg%2Bxml%3Bbase64%2CPHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHZpZXdCb3g9IjAgMCAxNiAxNiIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPHBhdGggZD0iTTQuOTYxNTYgMS42MDAxSDIuMjQxNTZDMS44ODgxIDEuNjAwMSAxLjYwMTU2IDEuODg2NjQgMS42MDE1NiAyLjI0MDFWNC45NjAxQzEuNjAxNTYgNS4zMTM1NiAxLjg4ODEgNS42MDAxIDIuMjQxNTYgNS42MDAxSDQuOTYxNTZDNS4zMTUwMiA1LjYwMDEgNS42MDE1NiA1LjMxMzU2IDUuNjAxNTYgNC45NjAxVjIuMjQwMUM1LjYwMTU2IDEuODg2NjQgNS4zMTUwMiAxLjYwMDEgNC45NjE1NiAxLjYwMDFaIiBmaWxsPSIjZmZmIi8%2BCjxwYXRoIGQ9Ik00Ljk2MTU2IDEwLjM5OTlIMi4yNDE1NkMxLjg4ODEgMTAuMzk5OSAxLjYwMTU2IDEwLjY4NjQgMS42MDE1NiAxMS4wMzk5VjEzLjc1OTlDMS42MDE1NiAxNC4xMTM0IDEuODg4MSAxNC4zOTk5IDIuMjQxNTYgMTQuMzk5OUg0Ljk2MTU2QzUuMzE1MDIgMTQuMzk5OSA1LjYwMTU2IDE0LjExMzQgNS42MDE1NiAxMy43NTk5VjExLjAzOTlDNS42MDE1NiAxMC42ODY0IDUuMzE1MDIgMTAuMzk5OSA0Ljk2MTU2IDEwLjM5OTlaIiBmaWxsPSIjZmZmIi8%2BCjxwYXRoIGQ9Ik0xMy43NTg0IDEuNjAwMUgxMS4wMzg0QzEwLjY4NSAxLjYwMDEgMTAuMzk4NCAxLjg4NjY0IDEwLjM5ODQgMi4yNDAxVjQuOTYwMUMxMC4zOTg0IDUuMzEzNTYgMTAuNjg1IDUuNjAwMSAxMS4wMzg0IDUuNjAwMUgxMy43NTg0QzE0LjExMTkgNS42MDAxIDE0LjM5ODQgNS4zMTM1NiAxNC4zOTg0IDQuOTYwMVYyLjI0MDFDMTQuMzk4NCAxLjg4NjY0IDE0LjExMTkgMS42MDAxIDEzLjc1ODQgMS42MDAxWiIgZmlsbD0iI2ZmZiIvPgo8cGF0aCBkPSJNNCAxMkwxMiA0TDQgMTJaIiBmaWxsPSIjZmZmIi8%2BCjxwYXRoIGQ9Ik00IDEyTDEyIDQiIHN0cm9rZT0iI2ZmZiIgc3Ryb2tlLXdpZHRoPSIxLjUiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIvPgo8L3N2Zz4K&logoColor=ffffff" alt="zread"/></a>
|
||||
<a href="https://zread.ai/AstrBotDevs/AstrBot" target="_blank"><img src="https://img.shields.io/badge/Ask_Zread-_.svg?style=flat&color=00b0aa&labelColor=000000&logo=data%3Aimage%2Fsvg%2Bxml%3Bbase64%2CPHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHZpZXdCb3g9IjAgMCAxNiAxNiIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPHBhdGggZD0iTTQuOTYxNTYgMS42MDAxSDIuMjQxNTZDMS44ODgxIDEuNjAwMSAxLjYwMTU2IDEuODg2NjQgMS42MDE1NiAyLjI0MDFWNC45NjAxQzEuNjAxNTYgNS4zMTM1NiAxLjg4ODEgNS42MDAxIDIuMjQxNTYgNS42MDAxSDQuOTYxNTZDNS4zMTUwMiA1LjYwMDEgNS42MDE1NiA1LjMxMzU2IDUuNjAxNTYgNC45NjAxVjIuMjQwMUM1LjYwMTU2IDEuODg2NjQgNS4zMTUwMiAxLjYwMDEgNC45NjE1NiAxLjYwMDFZIiBmaWxsPSIjZmZmIi8%2BCjxwYXRoIGQ9Ik00Ljk2MTU2IDEwLjM5OTlIMi4yNDE1NkMxLjg4ODEgMTAuMzk5OSAxLjYwMTU2IDEwLjY4NjQgMS42MDE1NiAxMS4wMzk5VjEzLjc1OTlDMS42MDE1NiAxNC4xMTM0IDEuODg4MSAxNC4zOTk5IDIuMjQxNTYgMTQuMzk5OUg0LjYxNTZDNS4zMTUwMiAxNC4zOTk5IDUuNjAxNTYgMTQuMTEzNCA1LjYwMTU2IDEzLjc1OTlWMTEuMDM5OUM1LjYwMTU2IDEwLjY4NjQgNS4zMTUwMiAxMC4zOTk5IDQuOTYxNTYgMTAuMzk5OVoiIGZpbGw9IiNmZmYiLz4KPHBhdGggZD0iTTEzLjc1ODQgMS42MDAxSDExLjAzODRDMTAuNjg1IDEuNjAwMSAxMC4zOTg0IDEuODg2NjQgMTAuMzk4NCAyLjI0MDFWNC45NjAxQzEwLjM5ODQgNS4zMTM1NiAxMC42ODUgNS42MDAxIDExLjAzODQgNS42MDAxSDEzLjc1ODRDMTQuMTExOSA1LjYwMDEgMTQuMzk4NCA1LjMxMzU2IDE0LjM5ODQgNC45NjAxVjIuMjQwMUMxNC4zOTg0IDEuODg2NjQgMTQuMTExOSAxLjYwMDEgMTMuNzU4NCAxLjYwMDFZIiBmaWxsPSIjZmZmIi8%2BCjxwYXRoIGQ9Ik00IDEyTDEyIDRMNCAxMlpFIiBmaWxsPSIjZmZmIi8%2BCjxwYXRoIGQ9Ik00IDEyTDEyIDQiIHN0cm9rZT0iI2ZmZiIgc3Ryb2tlLXdpZHRoPSIxLjUiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIvPgo8L3N2Zz4K&logoColor=ffffff" alt="zread"/></a>
|
||||
<a href="https://hub.docker.com/r/soulter/astrbot"><img alt="Docker pull" src="https://img.shields.io/docker/pulls/soulter/astrbot.svg?color=76bad9"/></a>
|
||||
<img src="https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fapi.soulter.top%2Fastrbot%2Fplugin-num&query=%24.result&suffix=%E4%B8%AA&label=%E6%8F%92%E4%BB%B6%E5%B8%82%E5%9C%BA&cacheSeconds=3600">
|
||||
<img src="https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fapi.soulter.top%2Fastrbot%2Fplugin-num&query=%24.result&suffix=%20&label=%E3%83%97%E3%83%A9%E3%82%B0%E3%82%A4%E3%83%B3%E3%83%9E%E3%83%BC%E3%82%B1%E3%83%83%E3%83%88&cacheSeconds=3600">
|
||||
<img src="https://gitcode.com/Soulter/AstrBot/star/badge.svg" href="https://gitcode.com/Soulter/AstrBot">
|
||||
</div>
|
||||
|
||||
@@ -37,7 +37,7 @@
|
||||
|
||||
AstrBot は、主要なインスタントメッセージングアプリと統合できるオープンソースのオールインワン Agent チャットボットプラットフォームです。個人、開発者、チームに信頼性が高くスケーラブルな会話型 AI インフラストラクチャを提供します。パーソナル AI コンパニオン、インテリジェントカスタマーサービス、オートメーションアシスタント、エンタープライズナレッジベースなど、AstrBot を使用すると、IM プラットフォームのワークフロー内で本番環境対応の AI アプリケーションを迅速に構築できます。
|
||||
|
||||
<img width="1776" height="1080" alt="image" src="https://github.com/user-attachments/assets/00782c4c-4437-4d97-aabc-605e3738da5c" />
|
||||

|
||||
|
||||
## 主な機能
|
||||
|
||||
@@ -45,7 +45,7 @@ AstrBot は、主要なインスタントメッセージングアプリと統合
|
||||
2. ✨ AI大規模言語モデル対話、マルチモーダル、Agent、MCP、Skills、ナレッジベース、ペルソナ設定、対話の自動圧縮。
|
||||
3. 🤖 Dify、Alibaba Cloud Bailian(百煉)、Coze などのAgentプラットフォームへの接続をサポート。
|
||||
4. 🌐 マルチプラットフォーム:QQ、企業微信(WeCom)、飛書(Lark)、釘釘(DingTalk)、WeChat公式アカウント、Telegram、Slack、[その他](#サポートされているメッセージプラットフォーム)に対応。
|
||||
5. 📦 プラグイン拡張:800近い既存プラグインをワンクリックでインストール可能。
|
||||
5. 📦 プラグイン拡張:1000を超える既存プラグインをワンクリックでインストール可能。
|
||||
6. 🛡️ 隔離環境[Agent Sandbox](https://docs.astrbot.app/use/astrbot-agent-sandbox.html):コードの安全な実行、Shell呼び出し、セッションレベルのリソース再利用。
|
||||
7. 💻 WebUI 対応。
|
||||
8. 🌈 Web ChatUI 対応:ChatUI内にAgent Sandboxやウェブ検索などを内蔵。
|
||||
@@ -58,7 +58,7 @@ AstrBot は、主要なインスタントメッセージングアプリと統合
|
||||
<th>💙 ロールプレイ & 感情的な対話</th>
|
||||
<th>✨ プロアクティブ・エージェント (Proactive Agent)</th>
|
||||
<th>🚀 汎用 エージェント的能力</th>
|
||||
<th>🧩 900+ コミュニティプラグイン</th>
|
||||
<th>🧩 1000+ コミュニティプラグイン</th>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><p align="center"><img width="984" height="1746" alt="99b587c5d35eea09d84f33e6cf6cfd4f" src="https://github.com/user-attachments/assets/89196061-3290-458d-b51f-afa178049f84" /></p></td>
|
||||
@@ -83,15 +83,15 @@ uv tool install astrbot
|
||||
astrbot
|
||||
```
|
||||
|
||||
#### システムパッケージマネージャーでのインストール
|
||||
#### デスクトップアプリのデプロイ(Tauri)
|
||||
|
||||
##### Arch Linux
|
||||
デスクトップアプリのリポジトリ [AstrBot-desktop](https://github.com/AstrBotDevs/AstrBot-desktop)。
|
||||
|
||||
```bash
|
||||
yay -S astrbot-git
|
||||
# または paru を使用
|
||||
paru -S astrbot-git
|
||||
```
|
||||
マルチシステムアーキテクチャをサポートし、インストールしてすぐに使用可能。初心者や手軽さを求める人に最適なワンクリックデスクトップデプロイソリューションです。サーバー環境での使用は推奨されません。
|
||||
|
||||
#### ランチャーによるワンクリックデプロイ(AstrBot Launcher)
|
||||
|
||||
迅速なデプロイとマルチインスタンス対応、環境の隔離が可能。[AstrBot Launcher](https://github.com/Raven95676/astrbot-launcher) リポジトリにアクセスし、Releases ページから最新バージョンのシステム対応パッケージをダウンロードしてインストールしてください。
|
||||
|
||||
#### 宝塔パネルデプロイ
|
||||
|
||||
@@ -144,13 +144,13 @@ uv run main.py
|
||||
|
||||
または、公式ドキュメント [ソースコードから AstrBot をデプロイ](https://astrbot.app/deploy/astrbot/cli.html) をご参照ください。
|
||||
|
||||
#### Установка через системный пакетный менеджер
|
||||
#### システムパッケージマネージャーでのインストール
|
||||
|
||||
##### Arch Linux
|
||||
|
||||
```bash
|
||||
yay -S astrbot-git
|
||||
# или используйте paru
|
||||
# または paru を使用
|
||||
paru -S astrbot-git
|
||||
```
|
||||
|
||||
@@ -263,7 +263,7 @@ pre-commit install
|
||||
AstrBot への貢献をしていただいたすべてのコントリビューターとプラグイン開発者に特別な感謝を ❤️
|
||||
|
||||
<a href="https://github.com/AstrBotDevs/AstrBot/graphs/contributors">
|
||||
<img src="https://contrib.rocks/image?repo=AstrBotDevs/AstrBot" />
|
||||
<img src="https://contrib.rocks/image?repo=AstrBotDevs/AstrBot&max=200&columns=14" />
|
||||
</a>
|
||||
|
||||
また、このプロジェクトの誕生は以下のオープンソースプロジェクトの助けなしには実現できませんでした:
|
||||
|
||||
+18
-8
@@ -21,9 +21,9 @@
|
||||
<img src="https://img.shields.io/github/v/release/AstrBotDevs/AstrBot?color=76bad9" href="https://github.com/AstrBotDevs/AstrBot/releases/latest">
|
||||
<img src="https://img.shields.io/badge/python-3.10+-blue.svg" alt="python">
|
||||
<img src="https://deepwiki.com/badge.svg" href="https://deepwiki.com/AstrBotDevs/AstrBot">
|
||||
<a href="https://zread.ai/AstrBotDevs/AstrBot" target="_blank"><img src="https://img.shields.io/badge/Ask_Zread-_.svg?style=flat&color=00b0aa&labelColor=000000&logo=data%3Aimage%2Fsvg%2Bxml%3Bbase64%2CPHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHZpZXdCb3g9IjAgMCAxNiAxNiIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPHBhdGggZD0iTTQuOTYxNTYgMS42MDAxSDIuMjQxNTZDMS44ODgxIDEuNjAwMSAxLjYwMTU2IDEuODg2NjQgMS42MDE1NiAyLjI0MDFWNC45NjAxQzEuNjAxNTYgNS4zMTM1NiAxLjg4ODEgNS42MDAxIDIuMjQxNTYgNS42MDAxSDQuOTYxNTZDNS4zMTUwMiA1LjYwMDEgNS42MDE1NiA1LjMxMzU2IDUuNjAxNTYgNC45NjAxVjIuMjQwMUM1LjYwMTU2IDEuODg2NjQgNS4zMTUwMiAxLjYwMDEgNC45NjE1NiAxLjYwMDFaIiBmaWxsPSIjZmZmIi8%2BCjxwYXRoIGQ9Ik00Ljk2MTU2IDEwLjM5OTlIMi4yNDE1NkMxLjg4ODEgMTAuMzk5OSAxLjYwMTU2IDEwLjY4NjQgMS42MDE1NiAxMS4wMzk5VjEzLjc1OTlDMS42MDE1NiAxNC4xMTM0IDEuODg4MSAxNC4zOTk5IDIuMjQxNTYgMTQuMzk5OUg0Ljk2MTU2QzUuMzE1MDIgMTQuMzk5OSA1LjYwMTU2IDE0LjExMzQgNS42MDE1NiAxMy43NTk5VjExLjAzOTlDNS42MDE1NiAxMC42ODY0IDUuMzE1MDIgMTAuMzk5OSA0Ljk2MTU2IDEwLjM5OTlaIiBmaWxsPSIjZmZmIi8%2BCjxwYXRoIGQ9Ik0xMy43NTg0IDEuNjAwMUgxMS4wMzg0QzEwLjY4NSAxLjYwMDEgMTAuMzk4NCAxLjg4NjY0IDEwLjM5ODQgMi4yNDAxVjQuOTYwMUMxMC4zOTg0IDUuMzEzNTYgMTAuNjg1IDUuNjAwMSAxMS4wMzg0IDUuNjAwMUgxMy43NTg0QzE0LjExMTkgNS42MDAxIDE0LjM5ODQgNS4zMTM1NiAxNC4zOTg0IDQuOTYwMVYyLjI0MDFDMTQuMzk4NCAxLjg4NjY0IDE0LjExMTkgMS42MDAxIDEzLjc1ODQgMS42MDAxWiIgZmlsbD0iI2ZmZiIvPgo8cGF0aCBkPSJNNCAxMkwxMiA0TDQgMTJaIiBmaWxsPSIjZmZmIi8%2BCjxwYXRoIGQ9Ik00IDEyTDEyIDQiIHN0cm9rZT0iI2ZmZiIgc3Ryb2tlLXdpZHRoPSIxLjUiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIvPgo8L3N2Zz4K&logoColor=ffffff" alt="zread"/></a>
|
||||
<a href="https://zread.ai/AstrBotDevs/AstrBot" target="_blank"><img src="https://img.shields.io/badge/Ask_Zread-_.svg?style=flat&color=00b0aa&labelColor=000000&logo=data%3Aimage%2Fsvg%2Bxml%3Bbase64%2CPHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHZpZXdCb3g9IjAgMCAxNiAxNiIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPHBhdGggZD0iTTQuOTYxNTYgMS42MDAxSDIuMjQxNTZDMS44ODgxIDEuNjAwMSAxLjYwMTU2IDEuODg2NjQgMS42MDE1NiAyLjI0MDFWNC45NjAxQzEuNjAxNTYgNS4zMTM1NiAxLjg4ODEgNS42MDAxIDIuMjQxNTYgNS42MDAxSDQuOTYxNTZDNS4zMTUwMiA1LjYwMDEgNS42MDE1NiA1LjMxMzU2IDUuNjAxNTYgNC45NjAxVjIuMjQwMUM1LjYwMTU2IDEuODg2NjQgNS4zMTUwMiAxLjYwMDEgNC45NjE1NiAxLjYwMDFZIiBmaWxsPSIjZmZmIi8%2BCjxwYXRoIGQ9Ik00Ljk2MTU2IDEwLjM5OTlIMi4yNDE1NkMxLjg4ODEgMTAuMzk5OSAxLjYwMTU2IDEwLjY4NjQgMS42MDE1NiAxMS4wMzk5VjEzLjc1OTlDMS42MDE1NiAxNC4xMTM0IDEuODg4MSAxNC4zOTk5IDIuMjQxNTYgMTQuMzk5OUg0Ljk2MTU2QzUuMzE1MDIgMTQuMzk5OSA1LjYwMTU2IDE0LjExMzQgNS42MDE1NiAxMy43NTk5VjExLjAzOTlDNS42MDE1NiAxMC42ODY0IDUuMzE1MDIgMTAuMzk5OSA0Ljk2MTU2IDEwLjM5OTlaIiBmaWxsPSIjZmZmIi8%2BCjxwYXRoIGQ9Ik0xMy43NTg0IDEuNjAwMUgxMS4wMzg0QzEwLjY4NSAxLjYwMDEgMTAuMzk4NCAxLjg4NjY0IDEwLjM5ODQgMi4yNDAxVjQuOTYwMUMxMC4zOTg0IDUuMzEzNTYgMTAuNjg1IDUuNjAwMSAxMS4wMzg0IDUuNjAwMUgxMy43NTg0QzE0LjExMTkgNS42MDAxIDE0LjM5ODQgNS4zMTM1NiAxNC4zOTg0IDQuOTYwMVYyLjI0MDFDMTQuMzk4NCAxLjg4NjY0IDE0LjExMTkgMS42MDAxIDEzLjczODQgMS42MDAxWiIgZmlsbD0iI2ZmZiIvPgo8cGF0aCBkPSJNNCAxMkwxMiA0TDQgMTJaIiBmaWxsPSIjZmZmIi8%2BCjxwYXRoIGQ9Ik00IDEyTDEyIDQiIHN0cm9rZT0iI2ZmZiIgc3Ryb2tlLXdpZHRoPSIxLjUiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIvPgo8L3N2Zz4K&logoColor=ffffff" alt="zread"/></a>
|
||||
<a href="https://hub.docker.com/r/soulter/astrbot"><img alt="Docker pull" src="https://img.shields.io/docker/pulls/soulter/astrbot.svg?color=76bad9"/></a>
|
||||
<img src="https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fapi.soulter.top%2Fastrbot%2Fplugin-num&query=%24.result&suffix=%E4%B8%AA&label=%E6%8F%92%E4%BB%B6%E5%B8%82%E5%9C%BA&cacheSeconds=3600">
|
||||
<img src="https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fapi.soulter.top%2Fastrbot%2Fplugin-num&query=%24.result&suffix=%20&label=%D0%9C%D0%B0%D1%80%D0%BA%D0%B5%D1%82%D0%BF%D0%BB%D0%B5%D0%B9%D1%81&cacheSeconds=3600">
|
||||
<img src="https://gitcode.com/Soulter/AstrBot/star/badge.svg" href="https://gitcode.com/Soulter/AstrBot">
|
||||
</div>
|
||||
|
||||
@@ -37,7 +37,7 @@
|
||||
|
||||
AstrBot — это универсальная платформа Agent-чатботов с открытым исходным кодом, которая интегрируется с основными приложениями для обмена мгновенными сообщениями. Она предоставляет надёжную и масштабируемую инфраструктуру разговорного ИИ для частных лиц, разработчиков и команд. Будь то персональный ИИ-компаньон, интеллектуальная служба поддержки, автоматизированный помощник или корпоративная база знаний — AstrBot позволяет быстро создавать готовые к использованию ИИ-приложения в рабочих процессах вашей платформы обмена сообщениями.
|
||||
|
||||
<img width="1776" height="1080" alt="image" src="https://github.com/user-attachments/assets/00782c4c-4437-4d97-aabc-605e3738da5c" />
|
||||

|
||||
|
||||
## Основные возможности
|
||||
|
||||
@@ -45,7 +45,7 @@ AstrBot — это универсальная платформа Agent-чатб
|
||||
2. ✨ Диалоги с ИИ-моделями, мультимодальность, Agent, MCP, Skills, База знаний, Настройка личности, автоматическое сжатие диалогов.
|
||||
3. 🤖 Поддержка интеграции с платформами Agents, такими как Dify, Alibaba Cloud Bailian, Coze и др.
|
||||
4. 🌐 Мультиплатформенность: поддержка QQ, WeChat для предприятий, Feishu, DingTalk, публичных аккаунтов WeChat, Telegram, Slack и [других](#Поддерживаемые-платформы-обмена-сообщениями).
|
||||
5. 📦 Расширение плагинами: доступно почти 800 плагинов для установки в один клик.
|
||||
5. 📦 Расширение плагинами: доступно более 1000 плагинов для установки в один клик.
|
||||
6. 🛡️ Изолированная среда[Agent Sandbox](https://docs.astrbot.app/use/astrbot-agent-sandbox.html): безопасное выполнение любого кода, вызов Shell, повторное использование ресурсов на уровне сессии.
|
||||
7. 💻 Поддержка WebUI.
|
||||
8. 🌈 Поддержка Web ChatUI: встроенная песочница агента, веб-поиск и др.
|
||||
@@ -56,9 +56,9 @@ AstrBot — это универсальная платформа Agent-чатб
|
||||
<table align="center">
|
||||
<tr align="center">
|
||||
<th>💙 Ролевые игры & Эмоциональная поддержка</th>
|
||||
<th>✨ Проактивный Агент(Agent)</th>
|
||||
<th>🚀 Универсальные Агентные возможности</th>
|
||||
<th>🧩 Универсальные Агентные (Agentic) возможности</th>
|
||||
<th>✨ Проактивный Агент (Agent)</th>
|
||||
<th>🚀 Универсальные возможности Агента</th>
|
||||
<th>🧩 1000+ плагинов сообщества</th>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><p align="center"><img width="984" height="1746" alt="99b587c5d35eea09d84f33e6cf6cfd4f" src="https://github.com/user-attachments/assets/89196061-3290-458d-b51f-afa178049f84" /></p></td>
|
||||
@@ -83,6 +83,16 @@ uv tool install astrbot
|
||||
astrbot
|
||||
```
|
||||
|
||||
#### Десктопное приложение (Tauri)
|
||||
|
||||
Репозиторий десктопного приложения: [AstrBot-desktop](https://github.com/AstrBotDevs/AstrBot-desktop).
|
||||
|
||||
Поддерживает различные системные архитектуры, устанавливается напрямую, "из коробки", лучшее настольное решение в один клик для новичков и тех, кто ценит простоту. Не рекомендуется для серверных сценариев.
|
||||
|
||||
#### Установка в один клик через лаунчер (AstrBot Launcher)
|
||||
|
||||
Быстрое развёртывание и поддержка нескольких экземпляров, изоляция среды. Перейдите в репозиторий [AstrBot Launcher](https://github.com/Raven95676/astrbot-launcher), найдите последнюю версию на странице Releases и установите соответствующий пакет для вашей системы.
|
||||
|
||||
#### Развёртывание BT-Panel
|
||||
|
||||
AstrBot в партнёрстве с BT-Panel теперь доступен на их маркетплейсе.
|
||||
@@ -253,7 +263,7 @@ pre-commit install
|
||||
Особая благодарность всем контрибьюторам и разработчикам плагинов за их вклад в AstrBot ❤️
|
||||
|
||||
<a href="https://github.com/AstrBotDevs/AstrBot/graphs/contributors">
|
||||
<img src="https://contrib.rocks/image?repo=AstrBotDevs/AstrBot" />
|
||||
<img src="https://contrib.rocks/image?repo=AstrBotDevs/AstrBot&max=200&columns=14" />
|
||||
</a>
|
||||
|
||||
Кроме того, рождение этого проекта было бы невозможно без помощи следующих проектов с открытым исходным кодом:
|
||||
|
||||
+14
-4
@@ -37,7 +37,7 @@
|
||||
|
||||
AstrBot 是一個開源的一站式 Agent 聊天機器人平台,可接入主流即時通訊軟體,為個人、開發者和團隊打造可靠、可擴展的對話式智慧基礎設施。無論是個人 AI 夥伴、智慧客服、自動化助手,還是企業知識庫,AstrBot 都能在您的即時通訊軟體平台的工作流程中快速構建生產可用的 AI 應用程式。
|
||||
|
||||
<img width="1776" height="1080" alt="image" src="https://github.com/user-attachments/assets/00782c4c-4437-4d97-aabc-605e3738da5c" />
|
||||

|
||||
|
||||
## 主要功能
|
||||
|
||||
@@ -45,7 +45,7 @@ AstrBot 是一個開源的一站式 Agent 聊天機器人平台,可接入主
|
||||
2. ✨ AI 大模型對話,多模態,Agent,MCP,Skills,知識庫,人格設定,自動壓縮對話。
|
||||
3. 🤖 支援接入 Dify、阿里雲百煉、Coze 等智慧體 (Agent) 平台。
|
||||
4. 🌐 多平台,支援 QQ、企業微信、飛書、釘釘、微信公眾號、Telegram、Slack 以及[更多](#支援的訊息平台)。
|
||||
5. 📦 插件擴展,已有近 800 個插件可一鍵安裝。
|
||||
5. 📦 插件擴展,已有 1000+ 個插件可一鍵安裝。
|
||||
6. 🛡️ [Agent Sandbox](https://docs.astrbot.app/use/astrbot-agent-sandbox.html) 隔離化環境,安全地執行任何代碼、調用 Shell、會話級資源複用。
|
||||
7. 💻 WebUI 支援。
|
||||
8. 🌈 Web ChatUI 支援,ChatUI 內置代理沙盒 (Agent Sandbox)、網頁搜尋等。
|
||||
@@ -58,7 +58,7 @@ AstrBot 是一個開源的一站式 Agent 聊天機器人平台,可接入主
|
||||
<th>💙 角色扮演 & 情感陪伴</th>
|
||||
<th>✨ 主動式 Agent</th>
|
||||
<th>🚀 通用 Agentic 能力</th>
|
||||
<th>🧩 900+ 社區外掛程式</th>
|
||||
<th>🧩 1000+ 社區外掛程式</th>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><p align="center"><img width="984" height="1746" alt="99b587c5d35eea09d84f33e6cf6cfd4f" src="https://github.com/user-attachments/assets/89196061-3290-458d-b51f-afa178049f84" /></p></td>
|
||||
@@ -83,6 +83,16 @@ uv tool install astrbot
|
||||
astrbot
|
||||
```
|
||||
|
||||
#### 桌面應用部署(Tauri)
|
||||
|
||||
桌面應用倉庫 [AstrBot-desktop](https://github.com/AstrBotDevs/AstrBot-desktop)。
|
||||
|
||||
支援多系統架構,安裝包直接安裝,開箱即用,最適合新手和懶人的一鍵桌面部署方案,不推薦伺服器場景。
|
||||
|
||||
#### 啟動器一鍵部署(AstrBot Launcher)
|
||||
|
||||
快速部署和多開方案,實現環境隔離,進入 [AstrBot Launcher](https://github.com/Raven95676/astrbot-launcher) 倉庫,在 Releases 頁最新版本下找到對應的系統安裝包安裝即可。
|
||||
|
||||
#### 寶塔面板部署
|
||||
|
||||
AstrBot 與寶塔面板合作,已上架至寶塔面板。
|
||||
@@ -253,7 +263,7 @@ pre-commit install
|
||||
特別感謝所有 Contributors 和外掛開發者對 AstrBot 的貢獻 ❤️
|
||||
|
||||
<a href="https://github.com/AstrBotDevs/AstrBot/graphs/contributors">
|
||||
<img src="https://contrib.rocks/image?repo=AstrBotDevs/AstrBot" />
|
||||
<img src="https://contrib.rocks/image?repo=AstrBotDevs/AstrBot&max=200&columns=14" />
|
||||
</a>
|
||||
|
||||
此外,本專案的誕生離不開以下開源專案的幫助:
|
||||
|
||||
@@ -25,6 +25,8 @@ from astrbot.core.star.register import (
|
||||
)
|
||||
from astrbot.core.star.register import register_on_platform_loaded as on_platform_loaded
|
||||
from astrbot.core.star.register import register_on_plugin_error as on_plugin_error
|
||||
from astrbot.core.star.register import register_on_plugin_loaded as on_plugin_loaded
|
||||
from astrbot.core.star.register import register_on_plugin_unloaded as on_plugin_unloaded
|
||||
from astrbot.core.star.register import register_on_using_llm_tool as on_using_llm_tool
|
||||
from astrbot.core.star.register import (
|
||||
register_on_waiting_llm_request as on_waiting_llm_request,
|
||||
@@ -54,6 +56,8 @@ __all__ = [
|
||||
"on_llm_request",
|
||||
"on_llm_response",
|
||||
"on_plugin_error",
|
||||
"on_plugin_loaded",
|
||||
"on_plugin_unloaded",
|
||||
"on_platform_loaded",
|
||||
"on_waiting_llm_request",
|
||||
"permission_type",
|
||||
|
||||
@@ -102,6 +102,30 @@ class ConversationCommands:
|
||||
|
||||
message.set_result(MessageEventResult().message(ret))
|
||||
|
||||
async def stop(self, message: AstrMessageEvent) -> None:
|
||||
"""停止当前会话正在运行的 Agent"""
|
||||
cfg = self.context.get_config(umo=message.unified_msg_origin)
|
||||
agent_runner_type = cfg["provider_settings"]["agent_runner_type"]
|
||||
umo = message.unified_msg_origin
|
||||
|
||||
if agent_runner_type in THIRD_PARTY_AGENT_RUNNER_KEY:
|
||||
stopped_count = active_event_registry.stop_all(umo, exclude=message)
|
||||
else:
|
||||
stopped_count = active_event_registry.request_agent_stop_all(
|
||||
umo,
|
||||
exclude=message,
|
||||
)
|
||||
|
||||
if stopped_count > 0:
|
||||
message.set_result(
|
||||
MessageEventResult().message(
|
||||
f"已请求停止 {stopped_count} 个运行中的任务。"
|
||||
)
|
||||
)
|
||||
return
|
||||
|
||||
message.set_result(MessageEventResult().message("当前会话没有运行中的任务。"))
|
||||
|
||||
async def his(self, message: AstrMessageEvent, page: int = 1) -> None:
|
||||
"""查看对话记录"""
|
||||
if not self.context.get_using_provider(message.unified_msg_origin):
|
||||
|
||||
@@ -132,6 +132,11 @@ class Main(star.Star):
|
||||
"""重置 LLM 会话"""
|
||||
await self.conversation_c.reset(message)
|
||||
|
||||
@filter.command("stop")
|
||||
async def stop(self, message: AstrMessageEvent) -> None:
|
||||
"""停止当前会话中正在运行的 Agent"""
|
||||
await self.conversation_c.stop(message)
|
||||
|
||||
@filter.permission_type(filter.PermissionType.ADMIN)
|
||||
@filter.command("model")
|
||||
async def model_ls(
|
||||
|
||||
@@ -70,7 +70,7 @@ class Main(star.Star):
|
||||
header = HEADERS
|
||||
header.update({"User-Agent": random.choice(USER_AGENTS)})
|
||||
async with aiohttp.ClientSession(trust_env=True) as session:
|
||||
async with session.get(url, headers=header, timeout=6) as response:
|
||||
async with session.get(url, headers=header) as response:
|
||||
html = await response.text(encoding="utf-8")
|
||||
doc = Document(html)
|
||||
ret = doc.summary(html_partial=True)
|
||||
@@ -151,7 +151,6 @@ class Main(star.Star):
|
||||
url,
|
||||
json=payload,
|
||||
headers=header,
|
||||
timeout=6,
|
||||
) as response:
|
||||
if response.status != 200:
|
||||
reason = await response.text()
|
||||
@@ -183,7 +182,6 @@ class Main(star.Star):
|
||||
url,
|
||||
json=payload,
|
||||
headers=header,
|
||||
timeout=6,
|
||||
) as response:
|
||||
if response.status != 200:
|
||||
reason = await response.text()
|
||||
@@ -265,7 +263,7 @@ class Main(star.Star):
|
||||
"transport": "sse",
|
||||
"url": f"http://appbuilder.baidu.com/v2/ai_search/mcp/sse?api_key={key}",
|
||||
"headers": {},
|
||||
"timeout": 30,
|
||||
"timeout": 600,
|
||||
},
|
||||
)
|
||||
self.baidu_initialized = True
|
||||
|
||||
@@ -1 +1 @@
|
||||
__version__ = "4.17.6"
|
||||
__version__ = "4.18.1"
|
||||
|
||||
@@ -44,6 +44,19 @@ class HandoffTool(FunctionTool, Generic[TContext]):
|
||||
"type": "string",
|
||||
"description": "The input to be handed off to another agent. This should be a clear and concise request or task.",
|
||||
},
|
||||
"image_urls": {
|
||||
"type": "array",
|
||||
"items": {"type": "string"},
|
||||
"description": "Optional: An array of image sources (public HTTP URLs or local file paths) used as references in multimodal tasks such as video generation.",
|
||||
},
|
||||
"background_task": {
|
||||
"type": "boolean",
|
||||
"description": (
|
||||
"Defaults to false. "
|
||||
"Set to true if the task may take noticeable time, involves external tools, or the user does not need to wait. "
|
||||
"Use false only for quick, immediate tasks."
|
||||
),
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -17,6 +17,8 @@ class ContextWrapper(Generic[TContext]):
|
||||
messages: list[Message] = Field(default_factory=list)
|
||||
"""This field stores the llm message context for the agent run, agent runners will maintain this field automatically."""
|
||||
tool_call_timeout: int = 60 # Default tool call timeout in seconds
|
||||
tool_call_approval: dict[str, Any] = Field(default_factory=dict)
|
||||
"""Tool call approval runtime configuration."""
|
||||
|
||||
|
||||
NoContext = ContextWrapper[None]
|
||||
|
||||
@@ -37,6 +37,10 @@ from ..hooks import BaseAgentRunHooks
|
||||
from ..message import AssistantMessageSegment, Message, ToolCallMessageSegment
|
||||
from ..response import AgentResponseData, AgentStats
|
||||
from ..run_context import ContextWrapper, TContext
|
||||
from ..tool_call_approval import (
|
||||
ToolCallApprovalContext,
|
||||
request_tool_call_approval,
|
||||
)
|
||||
from ..tool_executor import BaseFunctionToolExecutor
|
||||
from .base import AgentResponse, AgentState, BaseAgentRunner
|
||||
|
||||
@@ -137,6 +141,8 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
|
||||
self.tool_executor = tool_executor
|
||||
self.agent_hooks = agent_hooks
|
||||
self.run_context = run_context
|
||||
self._stop_requested = False
|
||||
self._aborted = False
|
||||
|
||||
# These two are used for tool schema mode handling
|
||||
# We now have two modes:
|
||||
@@ -328,6 +334,14 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
|
||||
),
|
||||
),
|
||||
)
|
||||
if self._stop_requested:
|
||||
llm_resp_result = LLMResponse(
|
||||
role="assistant",
|
||||
completion_text="[SYSTEM: User actively interrupted the response generation. Partial output before interruption is preserved.]",
|
||||
reasoning_content=llm_response.reasoning_content,
|
||||
reasoning_signature=llm_response.reasoning_signature,
|
||||
)
|
||||
break
|
||||
continue
|
||||
llm_resp_result = llm_response
|
||||
|
||||
@@ -339,6 +353,48 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
|
||||
break # got final response
|
||||
|
||||
if not llm_resp_result:
|
||||
if self._stop_requested:
|
||||
llm_resp_result = LLMResponse(role="assistant", completion_text="")
|
||||
else:
|
||||
return
|
||||
|
||||
if self._stop_requested:
|
||||
logger.info("Agent execution was requested to stop by user.")
|
||||
llm_resp = llm_resp_result
|
||||
if llm_resp.role != "assistant":
|
||||
llm_resp = LLMResponse(
|
||||
role="assistant",
|
||||
completion_text="[SYSTEM: User actively interrupted the response generation. Partial output before interruption is preserved.]",
|
||||
)
|
||||
self.final_llm_resp = llm_resp
|
||||
self._aborted = True
|
||||
self._transition_state(AgentState.DONE)
|
||||
self.stats.end_time = time.time()
|
||||
|
||||
parts = []
|
||||
if llm_resp.reasoning_content or llm_resp.reasoning_signature:
|
||||
parts.append(
|
||||
ThinkPart(
|
||||
think=llm_resp.reasoning_content,
|
||||
encrypted=llm_resp.reasoning_signature,
|
||||
)
|
||||
)
|
||||
if llm_resp.completion_text:
|
||||
parts.append(TextPart(text=llm_resp.completion_text))
|
||||
if parts:
|
||||
self.run_context.messages.append(
|
||||
Message(role="assistant", content=parts)
|
||||
)
|
||||
|
||||
try:
|
||||
await self.agent_hooks.on_agent_done(self.run_context, llm_resp)
|
||||
except Exception as e:
|
||||
logger.error(f"Error in on_agent_done hook: {e}", exc_info=True)
|
||||
|
||||
yield AgentResponse(
|
||||
type="aborted",
|
||||
data=AgentResponseData(chain=MessageChain(type="aborted")),
|
||||
)
|
||||
return
|
||||
|
||||
# 处理 LLM 响应
|
||||
@@ -607,6 +663,41 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
|
||||
# 如果没有 handler(如 MCP 工具),使用所有参数
|
||||
valid_params = func_tool_args
|
||||
|
||||
approval_cfg = self.run_context.tool_call_approval
|
||||
if approval_cfg.get("enable", False):
|
||||
event = getattr(self.run_context.context, "event", None)
|
||||
if event is None:
|
||||
tool_call_result_blocks.append(
|
||||
ToolCallMessageSegment(
|
||||
role="tool",
|
||||
tool_call_id=func_tool_id,
|
||||
content=(
|
||||
f"error: tool call approval is enabled, but event context is unavailable for `{func_tool_name}`."
|
||||
),
|
||||
),
|
||||
)
|
||||
continue
|
||||
approval_result = await request_tool_call_approval(
|
||||
config=approval_cfg,
|
||||
ctx=ToolCallApprovalContext(
|
||||
event=event,
|
||||
tool_name=func_tool_name,
|
||||
tool_args=valid_params,
|
||||
tool_call_id=func_tool_id,
|
||||
),
|
||||
)
|
||||
if not approval_result.approved:
|
||||
tool_call_result_blocks.append(
|
||||
ToolCallMessageSegment(
|
||||
role="tool",
|
||||
tool_call_id=func_tool_id,
|
||||
content=approval_result.to_tool_result_text(
|
||||
func_tool_name
|
||||
),
|
||||
),
|
||||
)
|
||||
continue
|
||||
|
||||
try:
|
||||
await self.agent_hooks.on_tool_start(
|
||||
self.run_context,
|
||||
@@ -848,5 +939,11 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
|
||||
"""检查 Agent 是否已完成工作"""
|
||||
return self._state in (AgentState.DONE, AgentState.ERROR)
|
||||
|
||||
def request_stop(self) -> None:
|
||||
self._stop_requested = True
|
||||
|
||||
def was_aborted(self) -> bool:
|
||||
return self._aborted
|
||||
|
||||
def get_final_llm_resp(self) -> LLMResponse | None:
|
||||
return self.final_llm_resp
|
||||
|
||||
@@ -0,0 +1,248 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import secrets
|
||||
import string
|
||||
import typing as T
|
||||
from abc import ABC, abstractmethod
|
||||
from dataclasses import dataclass
|
||||
|
||||
from astrbot import logger
|
||||
from astrbot.core.message.message_event_result import MessageChain
|
||||
from astrbot.core.platform.astr_message_event import AstrMessageEvent
|
||||
from astrbot.core.utils.session_waiter import (
|
||||
FILTERS,
|
||||
DefaultSessionFilter,
|
||||
SessionController,
|
||||
SessionWaiter,
|
||||
)
|
||||
|
||||
ApprovalReason = T.Literal[
|
||||
"approved",
|
||||
"rejected",
|
||||
"timeout",
|
||||
"unsupported_strategy",
|
||||
"error",
|
||||
]
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class ToolCallApprovalContext:
|
||||
event: AstrMessageEvent
|
||||
tool_name: str
|
||||
tool_args: dict[str, T.Any]
|
||||
tool_call_id: str
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class ToolCallApprovalResult:
|
||||
approved: bool
|
||||
reason: ApprovalReason
|
||||
detail: str = ""
|
||||
|
||||
def to_tool_result_text(self, tool_name: str) -> str:
|
||||
if self.approved:
|
||||
return f"tool call approval passed: {tool_name}"
|
||||
if self.reason == "timeout":
|
||||
return (
|
||||
f"error: tool call approval timed out for `{tool_name}`. "
|
||||
"The tool call was cancelled."
|
||||
)
|
||||
if self.reason == "unsupported_strategy":
|
||||
return (
|
||||
f"error: tool call approval strategy is unsupported for `{tool_name}`. "
|
||||
"The tool call was cancelled."
|
||||
)
|
||||
if self.reason == "error":
|
||||
return (
|
||||
f"error: tool call approval failed for `{tool_name}` ({self.detail}). "
|
||||
"The tool call was cancelled."
|
||||
)
|
||||
return (
|
||||
f"error: user rejected tool call approval for `{tool_name}`. "
|
||||
"The tool call was cancelled."
|
||||
)
|
||||
|
||||
|
||||
class BaseToolCallApprovalStrategy(ABC):
|
||||
@property
|
||||
@abstractmethod
|
||||
def name(self) -> str: ...
|
||||
|
||||
@abstractmethod
|
||||
async def request(
|
||||
self,
|
||||
ctx: ToolCallApprovalContext,
|
||||
config: dict[str, T.Any],
|
||||
) -> ToolCallApprovalResult: ...
|
||||
|
||||
|
||||
class DynamicCodeApprovalStrategy(BaseToolCallApprovalStrategy):
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return "dynamic_code"
|
||||
|
||||
async def request(
|
||||
self,
|
||||
ctx: ToolCallApprovalContext,
|
||||
config: dict[str, T.Any],
|
||||
) -> ToolCallApprovalResult:
|
||||
timeout_seconds = _safe_int(config.get("timeout", 60), default=60, minimum=1)
|
||||
dynamic_cfg = config.get("dynamic_code", {})
|
||||
if not isinstance(dynamic_cfg, dict):
|
||||
dynamic_cfg = {}
|
||||
code_length = _safe_int(dynamic_cfg.get("code_length", 6), default=6, minimum=4)
|
||||
case_sensitive = bool(dynamic_cfg.get("case_sensitive", False))
|
||||
|
||||
code = "".join(secrets.choice(string.digits) for _ in range(code_length))
|
||||
|
||||
await ctx.event.send(
|
||||
MessageChain().message(
|
||||
"Tool call needs your approval before execution.\n"
|
||||
f"Tool: `{ctx.tool_name}`\n"
|
||||
f"Approval code: `{code}`\n"
|
||||
"Please send this code to continue. "
|
||||
"Any other message will cancel this tool call."
|
||||
)
|
||||
)
|
||||
|
||||
try:
|
||||
result = await _wait_for_code_input(
|
||||
event=ctx.event,
|
||||
expected_code=code,
|
||||
timeout=timeout_seconds,
|
||||
case_sensitive=case_sensitive,
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.error(
|
||||
"Tool call approval failed unexpectedly for %s: %s",
|
||||
ctx.tool_name,
|
||||
exc,
|
||||
exc_info=True,
|
||||
)
|
||||
return ToolCallApprovalResult(
|
||||
approved=False,
|
||||
reason="error",
|
||||
detail=str(exc),
|
||||
)
|
||||
|
||||
if not result.approved:
|
||||
if result.reason == "timeout":
|
||||
await ctx.event.send(
|
||||
MessageChain().message(
|
||||
f"Tool call `{ctx.tool_name}` approval timed out. This call was cancelled."
|
||||
)
|
||||
)
|
||||
else:
|
||||
await ctx.event.send(
|
||||
MessageChain().message(
|
||||
f"Tool call `{ctx.tool_name}` was cancelled."
|
||||
)
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
_STRATEGY_REGISTRY: dict[str, BaseToolCallApprovalStrategy] = {}
|
||||
|
||||
|
||||
def register_tool_call_approval_strategy(
|
||||
strategy: BaseToolCallApprovalStrategy,
|
||||
) -> None:
|
||||
_STRATEGY_REGISTRY[strategy.name] = strategy
|
||||
|
||||
|
||||
def _register_builtin_strategies() -> None:
|
||||
register_tool_call_approval_strategy(DynamicCodeApprovalStrategy())
|
||||
|
||||
|
||||
_register_builtin_strategies()
|
||||
|
||||
|
||||
async def request_tool_call_approval(
|
||||
*,
|
||||
config: dict[str, T.Any] | None,
|
||||
ctx: ToolCallApprovalContext,
|
||||
) -> ToolCallApprovalResult:
|
||||
if not config or not bool(config.get("enable", False)):
|
||||
return ToolCallApprovalResult(approved=True, reason="approved")
|
||||
|
||||
strategy_name = (
|
||||
str(config.get("strategy", "dynamic_code")).strip() or "dynamic_code"
|
||||
)
|
||||
strategy = _STRATEGY_REGISTRY.get(strategy_name)
|
||||
if not strategy:
|
||||
logger.warning("Unsupported tool call approval strategy: %s", strategy_name)
|
||||
return ToolCallApprovalResult(
|
||||
approved=False,
|
||||
reason="unsupported_strategy",
|
||||
detail=strategy_name,
|
||||
)
|
||||
return await strategy.request(ctx, config)
|
||||
|
||||
|
||||
async def _wait_for_code_input(
|
||||
*,
|
||||
event: AstrMessageEvent,
|
||||
expected_code: str,
|
||||
timeout: int,
|
||||
case_sensitive: bool,
|
||||
) -> ToolCallApprovalResult:
|
||||
session_filter = DefaultSessionFilter()
|
||||
FILTERS.append(session_filter)
|
||||
waiter = SessionWaiter(
|
||||
session_filter=session_filter,
|
||||
session_id=event.unified_msg_origin,
|
||||
record_history_chains=False,
|
||||
)
|
||||
|
||||
async def _handler(
|
||||
controller: SessionController, incoming: AstrMessageEvent
|
||||
) -> None:
|
||||
raw_input = (incoming.message_str or "").strip()
|
||||
if _is_code_match(
|
||||
expected=expected_code,
|
||||
actual=raw_input,
|
||||
case_sensitive=case_sensitive,
|
||||
):
|
||||
if not controller.future.done():
|
||||
controller.future.set_result(
|
||||
ToolCallApprovalResult(approved=True, reason="approved"),
|
||||
)
|
||||
else:
|
||||
if not controller.future.done():
|
||||
controller.future.set_result(
|
||||
ToolCallApprovalResult(
|
||||
approved=False,
|
||||
reason="rejected",
|
||||
detail=raw_input,
|
||||
)
|
||||
)
|
||||
controller.stop()
|
||||
|
||||
try:
|
||||
result = await waiter.register_wait(handler=_handler, timeout=timeout)
|
||||
except TimeoutError:
|
||||
return ToolCallApprovalResult(approved=False, reason="timeout")
|
||||
|
||||
if isinstance(result, ToolCallApprovalResult):
|
||||
return result
|
||||
return ToolCallApprovalResult(
|
||||
approved=False,
|
||||
reason="error",
|
||||
detail=f"Invalid approval result type: {type(result).__name__}",
|
||||
)
|
||||
|
||||
|
||||
def _is_code_match(*, expected: str, actual: str, case_sensitive: bool) -> bool:
|
||||
if case_sensitive:
|
||||
return actual == expected
|
||||
return actual.casefold() == expected.casefold()
|
||||
|
||||
|
||||
def _safe_int(value: T.Any, *, default: int, minimum: int) -> int:
|
||||
try:
|
||||
parsed = int(value)
|
||||
if parsed < minimum:
|
||||
return minimum
|
||||
return parsed
|
||||
except Exception: # noqa: BLE001
|
||||
return default
|
||||
@@ -20,6 +20,10 @@ from astrbot.core.provider.provider import TTSProvider
|
||||
AgentRunner = ToolLoopAgentRunner[AstrAgentContext]
|
||||
|
||||
|
||||
def _should_stop_agent(astr_event) -> bool:
|
||||
return astr_event.is_stopped() or bool(astr_event.get_extra("agent_stop_requested"))
|
||||
|
||||
|
||||
async def run_agent(
|
||||
agent_runner: AgentRunner,
|
||||
max_step: int = 30,
|
||||
@@ -48,10 +52,28 @@ async def run_agent(
|
||||
)
|
||||
)
|
||||
|
||||
stop_watcher = asyncio.create_task(
|
||||
_watch_agent_stop_signal(agent_runner, astr_event),
|
||||
)
|
||||
try:
|
||||
async for resp in agent_runner.step():
|
||||
if astr_event.is_stopped():
|
||||
if _should_stop_agent(astr_event):
|
||||
agent_runner.request_stop()
|
||||
|
||||
if resp.type == "aborted":
|
||||
if not stop_watcher.done():
|
||||
stop_watcher.cancel()
|
||||
try:
|
||||
await stop_watcher
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
astr_event.set_extra("agent_user_aborted", True)
|
||||
astr_event.set_extra("agent_stop_requested", False)
|
||||
return
|
||||
|
||||
if _should_stop_agent(astr_event):
|
||||
continue
|
||||
|
||||
if resp.type == "tool_call_result":
|
||||
msg_chain = resp.data["chain"]
|
||||
|
||||
@@ -120,6 +142,12 @@ async def run_agent(
|
||||
# display the reasoning content only when configured
|
||||
continue
|
||||
yield resp.data["chain"] # MessageChain
|
||||
if not stop_watcher.done():
|
||||
stop_watcher.cancel()
|
||||
try:
|
||||
await stop_watcher
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
if agent_runner.done():
|
||||
# send agent stats to webchat
|
||||
if astr_event.get_platform_name() == "webchat":
|
||||
@@ -133,6 +161,12 @@ async def run_agent(
|
||||
break
|
||||
|
||||
except Exception as e:
|
||||
if "stop_watcher" in locals() and not stop_watcher.done():
|
||||
stop_watcher.cancel()
|
||||
try:
|
||||
await stop_watcher
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
logger.error(traceback.format_exc())
|
||||
|
||||
err_msg = f"\n\nAstrBot 请求失败。\n错误类型: {type(e).__name__}\n错误信息: {e!s}\n\n请在平台日志查看和分享错误详情。\n"
|
||||
@@ -155,6 +189,14 @@ async def run_agent(
|
||||
return
|
||||
|
||||
|
||||
async def _watch_agent_stop_signal(agent_runner: AgentRunner, astr_event) -> None:
|
||||
while not agent_runner.done():
|
||||
if _should_stop_agent(astr_event):
|
||||
agent_runner.request_stop()
|
||||
return
|
||||
await asyncio.sleep(0.5)
|
||||
|
||||
|
||||
async def run_live_agent(
|
||||
agent_runner: AgentRunner,
|
||||
tts_provider: TTSProvider | None = None,
|
||||
|
||||
@@ -45,6 +45,13 @@ class FunctionToolExecutor(BaseFunctionToolExecutor[AstrAgentContext]):
|
||||
|
||||
"""
|
||||
if isinstance(tool, HandoffTool):
|
||||
is_bg = tool_args.pop("background_task", False)
|
||||
if is_bg:
|
||||
async for r in cls._execute_handoff_background(
|
||||
tool, run_context, **tool_args
|
||||
):
|
||||
yield r
|
||||
return
|
||||
async for r in cls._execute_handoff(tool, run_context, **tool_args):
|
||||
yield r
|
||||
return
|
||||
@@ -92,6 +99,7 @@ class FunctionToolExecutor(BaseFunctionToolExecutor[AstrAgentContext]):
|
||||
**tool_args,
|
||||
):
|
||||
input_ = tool_args.get("input")
|
||||
image_urls = tool_args.get("image_urls")
|
||||
|
||||
# make toolset for the agent
|
||||
tools = tool.agent.tools
|
||||
@@ -136,16 +144,98 @@ class FunctionToolExecutor(BaseFunctionToolExecutor[AstrAgentContext]):
|
||||
event=event,
|
||||
chat_provider_id=prov_id,
|
||||
prompt=input_,
|
||||
image_urls=image_urls,
|
||||
system_prompt=tool.agent.instructions,
|
||||
tools=toolset,
|
||||
contexts=contexts,
|
||||
max_steps=30,
|
||||
run_hooks=tool.agent.run_hooks,
|
||||
stream=ctx.get_config().get("provider_settings", {}).get("stream", False),
|
||||
)
|
||||
yield mcp.types.CallToolResult(
|
||||
content=[mcp.types.TextContent(type="text", text=llm_resp.completion_text)]
|
||||
)
|
||||
|
||||
@classmethod
|
||||
async def _execute_handoff_background(
|
||||
cls,
|
||||
tool: HandoffTool,
|
||||
run_context: ContextWrapper[AstrAgentContext],
|
||||
**tool_args,
|
||||
):
|
||||
"""Execute a handoff as a background task.
|
||||
|
||||
Immediately yields a success response with a task_id, then runs
|
||||
the subagent asynchronously. When the subagent finishes, a
|
||||
``CronMessageEvent`` is created so the main LLM can inform the
|
||||
user of the result – the same pattern used by
|
||||
``_execute_background`` for regular background tasks.
|
||||
"""
|
||||
task_id = uuid.uuid4().hex
|
||||
|
||||
async def _run_handoff_in_background() -> None:
|
||||
try:
|
||||
await cls._do_handoff_background(
|
||||
tool=tool,
|
||||
run_context=run_context,
|
||||
task_id=task_id,
|
||||
**tool_args,
|
||||
)
|
||||
except Exception as e: # noqa: BLE001
|
||||
logger.error(
|
||||
f"Background handoff {task_id} ({tool.name}) failed: {e!s}",
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
asyncio.create_task(_run_handoff_in_background())
|
||||
|
||||
text_content = mcp.types.TextContent(
|
||||
type="text",
|
||||
text=(
|
||||
f"Background task dedicated to subagent '{tool.agent.name}' submitted. task_id={task_id}. "
|
||||
f"The subagent '{tool.agent.name}' is working on the task on hehalf you. "
|
||||
f"You will be notified when it finishes."
|
||||
),
|
||||
)
|
||||
yield mcp.types.CallToolResult(content=[text_content])
|
||||
|
||||
@classmethod
|
||||
async def _do_handoff_background(
|
||||
cls,
|
||||
tool: HandoffTool,
|
||||
run_context: ContextWrapper[AstrAgentContext],
|
||||
task_id: str,
|
||||
**tool_args,
|
||||
) -> None:
|
||||
"""Run the subagent handoff and, on completion, wake the main agent."""
|
||||
result_text = ""
|
||||
try:
|
||||
async for r in cls._execute_handoff(tool, run_context, **tool_args):
|
||||
if isinstance(r, mcp.types.CallToolResult):
|
||||
for content in r.content:
|
||||
if isinstance(content, mcp.types.TextContent):
|
||||
result_text += content.text + "\n"
|
||||
except Exception as e:
|
||||
result_text = (
|
||||
f"error: Background task execution failed, internal error: {e!s}"
|
||||
)
|
||||
|
||||
event = run_context.context.event
|
||||
|
||||
await cls._wake_main_agent_for_background_result(
|
||||
run_context=run_context,
|
||||
task_id=task_id,
|
||||
tool_name=tool.name,
|
||||
result_text=result_text,
|
||||
tool_args=tool_args,
|
||||
note=(
|
||||
event.get_extra("background_note")
|
||||
or f"Background task for subagent '{tool.agent.name}' finished."
|
||||
),
|
||||
summary_name=f"Dedicated to subagent `{tool.agent.name}`",
|
||||
extra_result_fields={"subagent_name": tool.agent.name},
|
||||
)
|
||||
|
||||
@classmethod
|
||||
async def _execute_background(
|
||||
cls,
|
||||
@@ -154,12 +244,6 @@ class FunctionToolExecutor(BaseFunctionToolExecutor[AstrAgentContext]):
|
||||
task_id: str,
|
||||
**tool_args,
|
||||
) -> None:
|
||||
from astrbot.core.astr_main_agent import (
|
||||
MainAgentBuildConfig,
|
||||
_get_session_conv,
|
||||
build_main_agent,
|
||||
)
|
||||
|
||||
# run the tool
|
||||
result_text = ""
|
||||
try:
|
||||
@@ -178,20 +262,52 @@ class FunctionToolExecutor(BaseFunctionToolExecutor[AstrAgentContext]):
|
||||
)
|
||||
|
||||
event = run_context.context.event
|
||||
ctx = run_context.context.context
|
||||
|
||||
note = (
|
||||
await cls._wake_main_agent_for_background_result(
|
||||
run_context=run_context,
|
||||
task_id=task_id,
|
||||
tool_name=tool.name,
|
||||
result_text=result_text,
|
||||
tool_args=tool_args,
|
||||
note=(
|
||||
event.get_extra("background_note")
|
||||
or f"Background task {tool.name} finished."
|
||||
),
|
||||
summary_name=tool.name,
|
||||
)
|
||||
extras = {
|
||||
"background_task_result": {
|
||||
|
||||
@classmethod
|
||||
async def _wake_main_agent_for_background_result(
|
||||
cls,
|
||||
run_context: ContextWrapper[AstrAgentContext],
|
||||
*,
|
||||
task_id: str,
|
||||
tool_name: str,
|
||||
result_text: str,
|
||||
tool_args: dict[str, T.Any],
|
||||
note: str,
|
||||
summary_name: str,
|
||||
extra_result_fields: dict[str, T.Any] | None = None,
|
||||
) -> None:
|
||||
from astrbot.core.astr_main_agent import (
|
||||
MainAgentBuildConfig,
|
||||
_get_session_conv,
|
||||
build_main_agent,
|
||||
)
|
||||
|
||||
event = run_context.context.event
|
||||
ctx = run_context.context.context
|
||||
|
||||
task_result = {
|
||||
"task_id": task_id,
|
||||
"tool_name": tool.name,
|
||||
"tool_name": tool_name,
|
||||
"result": result_text or "",
|
||||
"tool_args": tool_args,
|
||||
}
|
||||
}
|
||||
if extra_result_fields:
|
||||
task_result.update(extra_result_fields)
|
||||
extras = {"background_task_result": task_result}
|
||||
|
||||
session = MessageSession.from_str(event.unified_msg_origin)
|
||||
cron_event = CronMessageEvent(
|
||||
context=ctx,
|
||||
@@ -201,7 +317,12 @@ class FunctionToolExecutor(BaseFunctionToolExecutor[AstrAgentContext]):
|
||||
message_type=session.message_type,
|
||||
)
|
||||
cron_event.role = event.role
|
||||
config = MainAgentBuildConfig(tool_call_timeout=3600)
|
||||
config = MainAgentBuildConfig(
|
||||
tool_call_timeout=3600,
|
||||
streaming_response=ctx.get_config()
|
||||
.get("provider_settings", {})
|
||||
.get("stream", False),
|
||||
)
|
||||
|
||||
req = ProviderRequest()
|
||||
conv = await _get_session_conv(event=cron_event, plugin_context=ctx)
|
||||
@@ -222,8 +343,11 @@ class FunctionToolExecutor(BaseFunctionToolExecutor[AstrAgentContext]):
|
||||
)
|
||||
req.prompt = (
|
||||
"Proceed according to your system instructions. "
|
||||
"Output using same language as previous conversation."
|
||||
" After completing your task, summarize and output your actions and results."
|
||||
"Output using same language as previous conversation. "
|
||||
"If you need to deliver the result to the user immediately, "
|
||||
"you MUST use `send_message_to_user` tool to send the message directly to the user, "
|
||||
"otherwise the user will not see the result. "
|
||||
"After completing your task, summarize and output your actions and results. "
|
||||
)
|
||||
if not req.func_tool:
|
||||
req.func_tool = ToolSet()
|
||||
@@ -233,7 +357,7 @@ class FunctionToolExecutor(BaseFunctionToolExecutor[AstrAgentContext]):
|
||||
event=cron_event, plugin_context=ctx, config=config, req=req
|
||||
)
|
||||
if not result:
|
||||
logger.error("Failed to build main agent for background task job.")
|
||||
logger.error(f"Failed to build main agent for background task {tool_name}.")
|
||||
return
|
||||
|
||||
runner = result.agent_runner
|
||||
@@ -243,7 +367,7 @@ class FunctionToolExecutor(BaseFunctionToolExecutor[AstrAgentContext]):
|
||||
llm_resp = runner.get_final_llm_resp()
|
||||
task_meta = extras.get("background_task_result", {})
|
||||
summary_note = (
|
||||
f"[BackgroundTask] {task_meta.get('tool_name', tool.name)} "
|
||||
f"[BackgroundTask] {summary_name} "
|
||||
f"(task_id={task_meta.get('task_id', task_id)}) finished. "
|
||||
f"Result: {task_meta.get('result') or result_text or 'no content'}"
|
||||
)
|
||||
|
||||
@@ -121,6 +121,8 @@ class MainAgentBuildConfig:
|
||||
timezone: str | None = None
|
||||
max_quoted_fallback_images: int = 20
|
||||
"""Maximum number of images injected from quoted-message fallback extraction."""
|
||||
tool_call_approval: dict = field(default_factory=dict)
|
||||
"""Tool call approval configuration."""
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
@@ -1118,6 +1120,7 @@ async def build_main_agent(
|
||||
run_context=AgentContextWrapper(
|
||||
context=astr_agent_ctx,
|
||||
tool_call_timeout=config.tool_call_timeout,
|
||||
tool_call_approval=config.tool_call_approval,
|
||||
),
|
||||
tool_executor=FunctionToolExecutor(),
|
||||
agent_hooks=MAIN_AGENT_HOOKS,
|
||||
|
||||
@@ -52,6 +52,9 @@ class AstrBotConfig(dict):
|
||||
|
||||
with open(config_path, encoding="utf-8-sig") as f:
|
||||
conf_str = f.read()
|
||||
# Handle UTF-8 BOM if present
|
||||
if conf_str.startswith("\ufeff"):
|
||||
conf_str = conf_str[1:]
|
||||
conf = json.loads(conf_str)
|
||||
|
||||
# 检查配置完整性,并插入
|
||||
|
||||
@@ -5,7 +5,7 @@ from typing import Any, TypedDict
|
||||
|
||||
from astrbot.core.utils.astrbot_path import get_astrbot_data_path
|
||||
|
||||
VERSION = "4.17.6"
|
||||
VERSION = "4.18.1"
|
||||
DB_PATH = os.path.join(get_astrbot_data_path(), "data_v4.db")
|
||||
|
||||
WEBHOOK_SUPPORTED_PLATFORMS = [
|
||||
@@ -117,6 +117,15 @@ DEFAULT_CONFIG = {
|
||||
"max_agent_step": 30,
|
||||
"tool_call_timeout": 60,
|
||||
"tool_schema_mode": "full",
|
||||
"tool_call_approval": {
|
||||
"enable": False,
|
||||
"strategy": "dynamic_code",
|
||||
"timeout": 60,
|
||||
"dynamic_code": {
|
||||
"code_length": 6,
|
||||
"case_sensitive": False,
|
||||
},
|
||||
},
|
||||
"llm_safety_mode": True,
|
||||
"safety_mode_strategy": "system_prompt", # TODO: llm judge
|
||||
"file_extract": {
|
||||
@@ -979,7 +988,7 @@ CONFIG_METADATA_2 = {
|
||||
"api_base": "https://api.anthropic.com/v1",
|
||||
"timeout": 120,
|
||||
"proxy": "",
|
||||
"anth_thinking_config": {"budget": 0},
|
||||
"anth_thinking_config": {"type": "", "budget": 0, "effort": ""},
|
||||
},
|
||||
"Moonshot": {
|
||||
"id": "moonshot",
|
||||
@@ -1042,6 +1051,18 @@ CONFIG_METADATA_2 = {
|
||||
"proxy": "",
|
||||
"custom_headers": {},
|
||||
},
|
||||
"OpenRouter": {
|
||||
"id": "openrouter",
|
||||
"provider": "openrouter",
|
||||
"type": "openrouter_chat_completion",
|
||||
"provider_type": "chat_completion",
|
||||
"enable": True,
|
||||
"key": [],
|
||||
"timeout": 120,
|
||||
"api_base": "https://openrouter.ai/v1",
|
||||
"proxy": "",
|
||||
"custom_headers": {},
|
||||
},
|
||||
"NVIDIA": {
|
||||
"id": "nvidia",
|
||||
"provider": "nvidia",
|
||||
@@ -1952,13 +1973,25 @@ CONFIG_METADATA_2 = {
|
||||
},
|
||||
},
|
||||
"anth_thinking_config": {
|
||||
"description": "Thinking Config",
|
||||
"description": "思考配置",
|
||||
"type": "object",
|
||||
"items": {
|
||||
"type": {
|
||||
"description": "思考类型",
|
||||
"type": "string",
|
||||
"options": ["", "adaptive"],
|
||||
"hint": "Opus 4.6+ / Sonnet 4.6+ 推荐设为 'adaptive'。留空则使用手动 budget 模式。参见: https://platform.claude.com/docs/en/build-with-claude/adaptive-thinking",
|
||||
},
|
||||
"budget": {
|
||||
"description": "Thinking Budget",
|
||||
"description": "思考预算",
|
||||
"type": "int",
|
||||
"hint": "Anthropic thinking.budget_tokens param. Must >= 1024. See: https://platform.claude.com/docs/en/build-with-claude/extended-thinking",
|
||||
"hint": "手动 budget_tokens,需 >= 1024。仅在 type 为空时生效。Opus 4.6 / Sonnet 4.6 上已弃用。参见: https://platform.claude.com/docs/en/build-with-claude/extended-thinking",
|
||||
},
|
||||
"effort": {
|
||||
"description": "思考深度",
|
||||
"type": "string",
|
||||
"options": ["", "low", "medium", "high", "max"],
|
||||
"hint": "type 为 'adaptive' 时控制思考深度。默认 'high'。'max' 仅限 Opus 4.6。参见: https://platform.claude.com/docs/en/build-with-claude/effort",
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -2306,6 +2339,31 @@ CONFIG_METADATA_2 = {
|
||||
"tool_schema_mode": {
|
||||
"type": "string",
|
||||
},
|
||||
"tool_call_approval": {
|
||||
"type": "object",
|
||||
"items": {
|
||||
"enable": {
|
||||
"type": "bool",
|
||||
},
|
||||
"strategy": {
|
||||
"type": "string",
|
||||
},
|
||||
"timeout": {
|
||||
"type": "int",
|
||||
},
|
||||
"dynamic_code": {
|
||||
"type": "object",
|
||||
"items": {
|
||||
"code_length": {
|
||||
"type": "int",
|
||||
},
|
||||
"case_sensitive": {
|
||||
"type": "bool",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"file_extract": {
|
||||
"type": "object",
|
||||
"items": {
|
||||
@@ -3042,6 +3100,50 @@ CONFIG_METADATA_3 = {
|
||||
"provider_settings.agent_runner_type": "local",
|
||||
},
|
||||
},
|
||||
"provider_settings.tool_call_approval.enable": {
|
||||
"description": "启用工具调用确认",
|
||||
"type": "bool",
|
||||
"hint": "开启后,工具调用需要用户确认后才会执行。",
|
||||
"condition": {
|
||||
"provider_settings.agent_runner_type": "local",
|
||||
},
|
||||
},
|
||||
"provider_settings.tool_call_approval.strategy": {
|
||||
"description": "工具调用确认策略",
|
||||
"type": "string",
|
||||
"options": ["dynamic_code"],
|
||||
"labels": ["Dynamic Code(动态码)"],
|
||||
"condition": {
|
||||
"provider_settings.agent_runner_type": "local",
|
||||
"provider_settings.tool_call_approval.enable": True,
|
||||
},
|
||||
},
|
||||
"provider_settings.tool_call_approval.timeout": {
|
||||
"description": "工具调用确认超时(秒)",
|
||||
"type": "int",
|
||||
"condition": {
|
||||
"provider_settings.agent_runner_type": "local",
|
||||
"provider_settings.tool_call_approval.enable": True,
|
||||
},
|
||||
},
|
||||
"provider_settings.tool_call_approval.dynamic_code.code_length": {
|
||||
"description": "动态确认码长度",
|
||||
"type": "int",
|
||||
"condition": {
|
||||
"provider_settings.agent_runner_type": "local",
|
||||
"provider_settings.tool_call_approval.enable": True,
|
||||
"provider_settings.tool_call_approval.strategy": "dynamic_code",
|
||||
},
|
||||
},
|
||||
"provider_settings.tool_call_approval.dynamic_code.case_sensitive": {
|
||||
"description": "动态确认码区分大小写",
|
||||
"type": "bool",
|
||||
"condition": {
|
||||
"provider_settings.agent_runner_type": "local",
|
||||
"provider_settings.tool_call_approval.enable": True,
|
||||
"provider_settings.tool_call_approval.strategy": "dynamic_code",
|
||||
},
|
||||
},
|
||||
"provider_settings.wake_prefix": {
|
||||
"description": "LLM 聊天额外唤醒前缀 ",
|
||||
"type": "string",
|
||||
|
||||
@@ -8,6 +8,7 @@ from deprecated import deprecated
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
||||
|
||||
from astrbot.core.db.po import (
|
||||
ApiKey,
|
||||
Attachment,
|
||||
ChatUIProject,
|
||||
CommandConfig,
|
||||
@@ -248,6 +249,55 @@ class BaseDatabase(abc.ABC):
|
||||
"""
|
||||
...
|
||||
|
||||
@abc.abstractmethod
|
||||
async def create_api_key(
|
||||
self,
|
||||
name: str,
|
||||
key_hash: str,
|
||||
key_prefix: str,
|
||||
scopes: list[str] | None,
|
||||
created_by: str,
|
||||
expires_at: datetime.datetime | None = None,
|
||||
) -> ApiKey:
|
||||
"""Create a new API key record."""
|
||||
...
|
||||
|
||||
@abc.abstractmethod
|
||||
async def list_api_keys(self) -> list[ApiKey]:
|
||||
"""List all API keys."""
|
||||
...
|
||||
|
||||
@abc.abstractmethod
|
||||
async def get_api_key_by_id(self, key_id: str) -> ApiKey | None:
|
||||
"""Get an API key by key_id."""
|
||||
...
|
||||
|
||||
@abc.abstractmethod
|
||||
async def get_active_api_key_by_hash(self, key_hash: str) -> ApiKey | None:
|
||||
"""Get an active API key by hash (not revoked, not expired)."""
|
||||
...
|
||||
|
||||
@abc.abstractmethod
|
||||
async def touch_api_key(self, key_id: str) -> None:
|
||||
"""Update last_used_at of an API key."""
|
||||
...
|
||||
|
||||
@abc.abstractmethod
|
||||
async def revoke_api_key(self, key_id: str) -> bool:
|
||||
"""Revoke an API key.
|
||||
|
||||
Returns True when the key exists and is updated.
|
||||
"""
|
||||
...
|
||||
|
||||
@abc.abstractmethod
|
||||
async def delete_api_key(self, key_id: str) -> bool:
|
||||
"""Delete an API key.
|
||||
|
||||
Returns True when the key exists and is deleted.
|
||||
"""
|
||||
...
|
||||
|
||||
@abc.abstractmethod
|
||||
async def insert_persona(
|
||||
self,
|
||||
@@ -608,6 +658,22 @@ class BaseDatabase(abc.ABC):
|
||||
"""
|
||||
...
|
||||
|
||||
@abc.abstractmethod
|
||||
async def get_platform_sessions_by_creator_paginated(
|
||||
self,
|
||||
creator: str,
|
||||
platform_id: str | None = None,
|
||||
page: int = 1,
|
||||
page_size: int = 20,
|
||||
exclude_project_sessions: bool = False,
|
||||
) -> tuple[list[dict], int]:
|
||||
"""Get paginated platform sessions and total count for a creator.
|
||||
|
||||
Returns:
|
||||
tuple[list[dict], int]: (sessions_with_project_info, total_count)
|
||||
"""
|
||||
...
|
||||
|
||||
@abc.abstractmethod
|
||||
async def update_platform_session(
|
||||
self,
|
||||
|
||||
@@ -288,6 +288,43 @@ class Attachment(TimestampMixin, SQLModel, table=True):
|
||||
)
|
||||
|
||||
|
||||
class ApiKey(TimestampMixin, SQLModel, table=True):
|
||||
"""API keys used by external developers to access Open APIs."""
|
||||
|
||||
__tablename__: str = "api_keys"
|
||||
|
||||
inner_id: int | None = Field(
|
||||
primary_key=True,
|
||||
sa_column_kwargs={"autoincrement": True},
|
||||
default=None,
|
||||
)
|
||||
key_id: str = Field(
|
||||
max_length=36,
|
||||
nullable=False,
|
||||
unique=True,
|
||||
default_factory=lambda: str(uuid.uuid4()),
|
||||
)
|
||||
name: str = Field(max_length=255, nullable=False)
|
||||
key_hash: str = Field(max_length=128, nullable=False, unique=True)
|
||||
key_prefix: str = Field(max_length=24, nullable=False)
|
||||
scopes: list | None = Field(default=None, sa_type=JSON)
|
||||
created_by: str = Field(max_length=255, nullable=False)
|
||||
last_used_at: datetime | None = Field(default=None)
|
||||
expires_at: datetime | None = Field(default=None)
|
||||
revoked_at: datetime | None = Field(default=None)
|
||||
|
||||
__table_args__ = (
|
||||
UniqueConstraint(
|
||||
"key_id",
|
||||
name="uix_api_key_id",
|
||||
),
|
||||
UniqueConstraint(
|
||||
"key_hash",
|
||||
name="uix_api_key_hash",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class ChatUIProject(TimestampMixin, SQLModel, table=True):
|
||||
"""This class represents projects for organizing ChatUI conversations.
|
||||
|
||||
|
||||
+153
-14
@@ -10,6 +10,7 @@ from sqlmodel import col, delete, desc, func, or_, select, text, update
|
||||
|
||||
from astrbot.core.db import BaseDatabase
|
||||
from astrbot.core.db.po import (
|
||||
ApiKey,
|
||||
Attachment,
|
||||
ChatUIProject,
|
||||
CommandConfig,
|
||||
@@ -573,6 +574,100 @@ class SQLiteDatabase(BaseDatabase):
|
||||
result = T.cast(CursorResult, await session.execute(query))
|
||||
return result.rowcount
|
||||
|
||||
async def create_api_key(
|
||||
self,
|
||||
name: str,
|
||||
key_hash: str,
|
||||
key_prefix: str,
|
||||
scopes: list[str] | None,
|
||||
created_by: str,
|
||||
expires_at: datetime | None = None,
|
||||
) -> ApiKey:
|
||||
"""Create a new API key record."""
|
||||
async with self.get_db() as session:
|
||||
session: AsyncSession
|
||||
async with session.begin():
|
||||
api_key = ApiKey(
|
||||
name=name,
|
||||
key_hash=key_hash,
|
||||
key_prefix=key_prefix,
|
||||
scopes=scopes,
|
||||
created_by=created_by,
|
||||
expires_at=expires_at,
|
||||
)
|
||||
session.add(api_key)
|
||||
await session.flush()
|
||||
await session.refresh(api_key)
|
||||
return api_key
|
||||
|
||||
async def list_api_keys(self) -> list[ApiKey]:
|
||||
"""List all API keys."""
|
||||
async with self.get_db() as session:
|
||||
session: AsyncSession
|
||||
result = await session.execute(
|
||||
select(ApiKey).order_by(desc(ApiKey.created_at))
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
|
||||
async def get_api_key_by_id(self, key_id: str) -> ApiKey | None:
|
||||
"""Get an API key by key_id."""
|
||||
async with self.get_db() as session:
|
||||
session: AsyncSession
|
||||
result = await session.execute(
|
||||
select(ApiKey).where(ApiKey.key_id == key_id)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
async def get_active_api_key_by_hash(self, key_hash: str) -> ApiKey | None:
|
||||
"""Get an active API key by hash (not revoked, not expired)."""
|
||||
async with self.get_db() as session:
|
||||
session: AsyncSession
|
||||
now = datetime.now(timezone.utc)
|
||||
query = select(ApiKey).where(
|
||||
ApiKey.key_hash == key_hash,
|
||||
col(ApiKey.revoked_at).is_(None),
|
||||
or_(col(ApiKey.expires_at).is_(None), ApiKey.expires_at > now),
|
||||
)
|
||||
result = await session.execute(query)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
async def touch_api_key(self, key_id: str) -> None:
|
||||
"""Update last_used_at of an API key."""
|
||||
async with self.get_db() as session:
|
||||
session: AsyncSession
|
||||
async with session.begin():
|
||||
await session.execute(
|
||||
update(ApiKey)
|
||||
.where(ApiKey.key_id == key_id)
|
||||
.values(last_used_at=datetime.now(timezone.utc)),
|
||||
)
|
||||
|
||||
async def revoke_api_key(self, key_id: str) -> bool:
|
||||
"""Revoke an API key."""
|
||||
async with self.get_db() as session:
|
||||
session: AsyncSession
|
||||
async with session.begin():
|
||||
query = (
|
||||
update(ApiKey)
|
||||
.where(ApiKey.key_id == key_id)
|
||||
.values(revoked_at=datetime.now(timezone.utc))
|
||||
)
|
||||
result = T.cast(CursorResult, await session.execute(query))
|
||||
return result.rowcount > 0
|
||||
|
||||
async def delete_api_key(self, key_id: str) -> bool:
|
||||
"""Delete an API key."""
|
||||
async with self.get_db() as session:
|
||||
session: AsyncSession
|
||||
async with session.begin():
|
||||
result = T.cast(
|
||||
CursorResult,
|
||||
await session.execute(
|
||||
delete(ApiKey).where(ApiKey.key_id == key_id)
|
||||
),
|
||||
)
|
||||
return result.rowcount > 0
|
||||
|
||||
async def insert_persona(
|
||||
self,
|
||||
persona_id,
|
||||
@@ -1317,11 +1412,24 @@ class SQLiteDatabase(BaseDatabase):
|
||||
|
||||
Returns a list of dicts containing session info and project info (if session belongs to a project).
|
||||
"""
|
||||
async with self.get_db() as session:
|
||||
session: AsyncSession
|
||||
offset = (page - 1) * page_size
|
||||
(
|
||||
sessions_with_projects,
|
||||
_,
|
||||
) = await self.get_platform_sessions_by_creator_paginated(
|
||||
creator=creator,
|
||||
platform_id=platform_id,
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
exclude_project_sessions=False,
|
||||
)
|
||||
return sessions_with_projects
|
||||
|
||||
# LEFT JOIN with SessionProjectRelation and ChatUIProject to get project info
|
||||
@staticmethod
|
||||
def _build_platform_sessions_query(
|
||||
creator: str,
|
||||
platform_id: str | None = None,
|
||||
exclude_project_sessions: bool = False,
|
||||
):
|
||||
query = (
|
||||
select(
|
||||
PlatformSession,
|
||||
@@ -1336,25 +1444,22 @@ class SQLiteDatabase(BaseDatabase):
|
||||
)
|
||||
.outerjoin(
|
||||
ChatUIProject,
|
||||
col(SessionProjectRelation.project_id)
|
||||
== col(ChatUIProject.project_id),
|
||||
col(SessionProjectRelation.project_id) == col(ChatUIProject.project_id),
|
||||
)
|
||||
.where(col(PlatformSession.creator) == creator)
|
||||
)
|
||||
|
||||
if platform_id:
|
||||
query = query.where(PlatformSession.platform_id == platform_id)
|
||||
if exclude_project_sessions:
|
||||
query = query.where(col(ChatUIProject.project_id).is_(None))
|
||||
|
||||
query = (
|
||||
query.order_by(desc(PlatformSession.updated_at))
|
||||
.offset(offset)
|
||||
.limit(page_size)
|
||||
)
|
||||
result = await session.execute(query)
|
||||
return query
|
||||
|
||||
# Convert to list of dicts with session and project info
|
||||
@staticmethod
|
||||
def _rows_to_session_dicts(rows: list[tuple]) -> list[dict]:
|
||||
sessions_with_projects = []
|
||||
for row in result.all():
|
||||
for row in rows:
|
||||
platform_session = row[0]
|
||||
project_id = row[1]
|
||||
project_title = row[2]
|
||||
@@ -1370,6 +1475,40 @@ class SQLiteDatabase(BaseDatabase):
|
||||
|
||||
return sessions_with_projects
|
||||
|
||||
async def get_platform_sessions_by_creator_paginated(
|
||||
self,
|
||||
creator: str,
|
||||
platform_id: str | None = None,
|
||||
page: int = 1,
|
||||
page_size: int = 20,
|
||||
exclude_project_sessions: bool = False,
|
||||
) -> tuple[list[dict], int]:
|
||||
"""Get paginated Platform sessions for a creator with total count."""
|
||||
async with self.get_db() as session:
|
||||
session: AsyncSession
|
||||
offset = (page - 1) * page_size
|
||||
|
||||
base_query = self._build_platform_sessions_query(
|
||||
creator=creator,
|
||||
platform_id=platform_id,
|
||||
exclude_project_sessions=exclude_project_sessions,
|
||||
)
|
||||
|
||||
total_result = await session.execute(
|
||||
select(func.count()).select_from(base_query.subquery())
|
||||
)
|
||||
total = int(total_result.scalar_one() or 0)
|
||||
|
||||
result_query = (
|
||||
base_query.order_by(desc(PlatformSession.updated_at))
|
||||
.offset(offset)
|
||||
.limit(page_size)
|
||||
)
|
||||
result = await session.execute(result_query)
|
||||
|
||||
sessions_with_projects = self._rows_to_session_dicts(result.all())
|
||||
return sessions_with_projects, total
|
||||
|
||||
async def update_platform_session(
|
||||
self,
|
||||
session_id: str,
|
||||
|
||||
@@ -44,6 +44,7 @@ class InternalAgentSubStage(Stage):
|
||||
]
|
||||
self.max_step: int = settings.get("max_agent_step", 30)
|
||||
self.tool_call_timeout: int = settings.get("tool_call_timeout", 60)
|
||||
self.tool_call_approval: dict = settings.get("tool_call_approval", {})
|
||||
self.tool_schema_mode: str = settings.get("tool_schema_mode", "full")
|
||||
if self.tool_schema_mode not in ("skills_like", "full"):
|
||||
logger.warning(
|
||||
@@ -124,6 +125,7 @@ class InternalAgentSubStage(Stage):
|
||||
subagent_orchestrator=conf.get("subagent_orchestrator", {}),
|
||||
timezone=self.ctx.plugin_manager.context.get_config().get("timezone"),
|
||||
max_quoted_fallback_images=settings.get("max_quoted_fallback_images", 20),
|
||||
tool_call_approval=self.tool_call_approval,
|
||||
)
|
||||
|
||||
async def process(
|
||||
@@ -247,13 +249,16 @@ class InternalAgentSubStage(Stage):
|
||||
yield
|
||||
|
||||
# 保存历史记录
|
||||
if not event.is_stopped() and agent_runner.done():
|
||||
if agent_runner.done() and (
|
||||
not event.is_stopped() or agent_runner.was_aborted()
|
||||
):
|
||||
await self._save_to_history(
|
||||
event,
|
||||
req,
|
||||
agent_runner.get_final_llm_resp(),
|
||||
agent_runner.run_context.messages,
|
||||
agent_runner.stats,
|
||||
user_aborted=agent_runner.was_aborted(),
|
||||
)
|
||||
|
||||
elif streaming_response and not stream_to_general:
|
||||
@@ -308,13 +313,14 @@ class InternalAgentSubStage(Stage):
|
||||
)
|
||||
|
||||
# 检查事件是否被停止,如果被停止则不保存历史记录
|
||||
if not event.is_stopped():
|
||||
if not event.is_stopped() or agent_runner.was_aborted():
|
||||
await self._save_to_history(
|
||||
event,
|
||||
req,
|
||||
final_resp,
|
||||
agent_runner.run_context.messages,
|
||||
agent_runner.stats,
|
||||
user_aborted=agent_runner.was_aborted(),
|
||||
)
|
||||
|
||||
asyncio.create_task(
|
||||
@@ -340,16 +346,29 @@ class InternalAgentSubStage(Stage):
|
||||
llm_response: LLMResponse | None,
|
||||
all_messages: list[Message],
|
||||
runner_stats: AgentStats | None,
|
||||
user_aborted: bool = False,
|
||||
) -> None:
|
||||
if (
|
||||
not req
|
||||
or not req.conversation
|
||||
or not llm_response
|
||||
or llm_response.role != "assistant"
|
||||
):
|
||||
if not req or not req.conversation:
|
||||
return
|
||||
|
||||
if not llm_response.completion_text and not req.tool_calls_result:
|
||||
if not llm_response and not user_aborted:
|
||||
return
|
||||
|
||||
if llm_response and llm_response.role != "assistant":
|
||||
if not user_aborted:
|
||||
return
|
||||
llm_response = LLMResponse(
|
||||
role="assistant",
|
||||
completion_text=llm_response.completion_text or "",
|
||||
)
|
||||
elif llm_response is None:
|
||||
llm_response = LLMResponse(role="assistant", completion_text="")
|
||||
|
||||
if (
|
||||
not llm_response.completion_text
|
||||
and not req.tool_calls_result
|
||||
and not user_aborted
|
||||
):
|
||||
logger.debug("LLM 响应为空,不保存记录。")
|
||||
return
|
||||
|
||||
@@ -363,6 +382,14 @@ class InternalAgentSubStage(Stage):
|
||||
continue
|
||||
message_to_save.append(message.model_dump())
|
||||
|
||||
# if user_aborted:
|
||||
# message_to_save.append(
|
||||
# Message(
|
||||
# role="assistant",
|
||||
# content="[User aborted this request. Partial output before abort was preserved.]",
|
||||
# ).model_dump()
|
||||
# )
|
||||
|
||||
token_usage = None
|
||||
if runner_stats:
|
||||
# token_usage = runner_stats.token_usage.total
|
||||
|
||||
@@ -25,6 +25,26 @@ from astrbot.core.utils.io import download_image_by_url, file_to_base64
|
||||
from astrbot.core.utils.tencent_record_helper import wav_to_tencent_silk
|
||||
|
||||
|
||||
def _patch_qq_botpy_formdata() -> None:
|
||||
"""Patch qq-botpy for aiohttp>=3.12 compatibility.
|
||||
|
||||
qq-botpy 1.2.1 defines botpy.http._FormData._gen_form_data() and expects
|
||||
aiohttp.FormData to have a private flag named _is_processed, which is no
|
||||
longer present in newer aiohttp versions.
|
||||
"""
|
||||
|
||||
try:
|
||||
from botpy.http import _FormData # type: ignore
|
||||
|
||||
if not hasattr(_FormData, "_is_processed"):
|
||||
setattr(_FormData, "_is_processed", False)
|
||||
except Exception:
|
||||
logger.debug("[QQOfficial] Skip botpy FormData patch.")
|
||||
|
||||
|
||||
_patch_qq_botpy_formdata()
|
||||
|
||||
|
||||
class QQOfficialMessageEvent(AstrMessageEvent):
|
||||
MARKDOWN_NOT_ALLOWED_ERROR = "不允许发送原生 markdown"
|
||||
|
||||
@@ -200,6 +220,8 @@ class QQOfficialMessageEvent(AstrMessageEvent):
|
||||
case botpy.message.Message():
|
||||
if image_path:
|
||||
payload["file_image"] = image_path
|
||||
# Guild text-channel send API (/channels/{channel_id}/messages) does not use v2 msg_type.
|
||||
payload.pop("msg_type", None)
|
||||
ret = await self._send_with_markdown_fallback(
|
||||
send_func=lambda retry_payload: self.bot.api.post_message(
|
||||
channel_id=source.channel_id,
|
||||
@@ -212,6 +234,8 @@ class QQOfficialMessageEvent(AstrMessageEvent):
|
||||
case botpy.message.DirectMessage():
|
||||
if image_path:
|
||||
payload["file_image"] = image_path
|
||||
# Guild DM send API (/dms/{guild_id}/messages) does not use v2 msg_type.
|
||||
payload.pop("msg_type", None)
|
||||
ret = await self._send_with_markdown_fallback(
|
||||
send_func=lambda retry_payload: self.bot.api.post_dms(
|
||||
guild_id=source.guild_id,
|
||||
|
||||
@@ -174,13 +174,18 @@ class TelegramPlatformAdapter(Platform):
|
||||
if not handler_metadata.enabled:
|
||||
continue
|
||||
for event_filter in handler_metadata.event_filters:
|
||||
cmd_info = self._extract_command_info(
|
||||
cmd_info_list = self._extract_command_info(
|
||||
event_filter,
|
||||
handler_metadata,
|
||||
skip_commands,
|
||||
)
|
||||
if cmd_info:
|
||||
cmd_name, description = cmd_info
|
||||
if cmd_info_list:
|
||||
for cmd_name, description in cmd_info_list:
|
||||
if cmd_name in command_dict:
|
||||
logger.warning(
|
||||
f"命令名 '{cmd_name}' 重复注册,将使用首次注册的定义: "
|
||||
f"'{command_dict[cmd_name]}'"
|
||||
)
|
||||
command_dict.setdefault(cmd_name, description)
|
||||
|
||||
commands_a = sorted(command_dict.keys())
|
||||
@@ -191,9 +196,9 @@ class TelegramPlatformAdapter(Platform):
|
||||
event_filter,
|
||||
handler_metadata,
|
||||
skip_commands: set,
|
||||
) -> tuple[str, str] | None:
|
||||
"""从事件过滤器中提取指令信息"""
|
||||
cmd_name = None
|
||||
) -> list[tuple[str, str]] | None:
|
||||
"""从事件过滤器中提取指令信息,包括所有别名"""
|
||||
cmd_names = []
|
||||
is_group = False
|
||||
if isinstance(event_filter, CommandFilter) and event_filter.command_name:
|
||||
if (
|
||||
@@ -201,26 +206,32 @@ class TelegramPlatformAdapter(Platform):
|
||||
and event_filter.parent_command_names != [""]
|
||||
):
|
||||
return None
|
||||
cmd_name = event_filter.command_name
|
||||
# 收集主命令名和所有别名
|
||||
cmd_names = [event_filter.command_name]
|
||||
if event_filter.alias:
|
||||
cmd_names.extend(event_filter.alias)
|
||||
elif isinstance(event_filter, CommandGroupFilter):
|
||||
if event_filter.parent_group:
|
||||
return None
|
||||
cmd_name = event_filter.group_name
|
||||
cmd_names = [event_filter.group_name]
|
||||
is_group = True
|
||||
|
||||
result = []
|
||||
for cmd_name in cmd_names:
|
||||
if not cmd_name or cmd_name in skip_commands:
|
||||
return None
|
||||
|
||||
continue
|
||||
if not re.match(r"^[a-z0-9_]+$", cmd_name) or len(cmd_name) > 32:
|
||||
return None
|
||||
continue
|
||||
|
||||
# Build description.
|
||||
description = handler_metadata.desc or (
|
||||
f"指令组: {cmd_name} (包含多个子指令)" if is_group else f"指令: {cmd_name}"
|
||||
f"Command group: {cmd_name}" if is_group else f"Command: {cmd_name}"
|
||||
)
|
||||
if len(description) > 30:
|
||||
description = description[:30] + "..."
|
||||
return cmd_name, description
|
||||
result.append((cmd_name, description))
|
||||
|
||||
return result if result else None
|
||||
|
||||
async def start(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
|
||||
if not update.effective_chat:
|
||||
|
||||
@@ -11,13 +11,13 @@ from astrbot.core.utils.astrbot_path import get_astrbot_data_path
|
||||
|
||||
from .webchat_queue_mgr import webchat_queue_mgr
|
||||
|
||||
imgs_dir = os.path.join(get_astrbot_data_path(), "webchat", "imgs")
|
||||
attachments_dir = os.path.join(get_astrbot_data_path(), "attachments")
|
||||
|
||||
|
||||
class WebChatMessageEvent(AstrMessageEvent):
|
||||
def __init__(self, message_str, message_obj, platform_meta, session_id) -> None:
|
||||
super().__init__(message_str, message_obj, platform_meta, session_id)
|
||||
os.makedirs(imgs_dir, exist_ok=True)
|
||||
os.makedirs(attachments_dir, exist_ok=True)
|
||||
|
||||
@staticmethod
|
||||
async def _send(
|
||||
@@ -69,7 +69,7 @@ class WebChatMessageEvent(AstrMessageEvent):
|
||||
elif isinstance(comp, Image):
|
||||
# save image to local
|
||||
filename = f"{str(uuid.uuid4())}.jpg"
|
||||
path = os.path.join(imgs_dir, filename)
|
||||
path = os.path.join(attachments_dir, filename)
|
||||
image_base64 = await comp.convert_to_base64()
|
||||
with open(path, "wb") as f:
|
||||
f.write(base64.b64decode(image_base64))
|
||||
@@ -85,7 +85,7 @@ class WebChatMessageEvent(AstrMessageEvent):
|
||||
elif isinstance(comp, Record):
|
||||
# save record to local
|
||||
filename = f"{str(uuid.uuid4())}.wav"
|
||||
path = os.path.join(imgs_dir, filename)
|
||||
path = os.path.join(attachments_dir, filename)
|
||||
record_base64 = await comp.convert_to_base64()
|
||||
with open(path, "wb") as f:
|
||||
f.write(base64.b64decode(record_base64))
|
||||
@@ -104,7 +104,7 @@ class WebChatMessageEvent(AstrMessageEvent):
|
||||
original_name = comp.name or os.path.basename(file_path)
|
||||
ext = os.path.splitext(original_name)[1] or ""
|
||||
filename = f"{uuid.uuid4()!s}{ext}"
|
||||
dest_path = os.path.join(imgs_dir, filename)
|
||||
dest_path = os.path.join(attachments_dir, filename)
|
||||
shutil.copy2(file_path, dest_path)
|
||||
data = f"[FILE]{filename}"
|
||||
await web_chat_back_queue.put(
|
||||
|
||||
@@ -301,6 +301,10 @@ class ProviderManager:
|
||||
from .sources.oai_aihubmix_source import (
|
||||
ProviderAIHubMix as ProviderAIHubMix,
|
||||
)
|
||||
case "openrouter_chat_completion":
|
||||
from .sources.openrouter_source import (
|
||||
ProviderOpenRouter as ProviderOpenRouter,
|
||||
)
|
||||
case "anthropic_chat_completion":
|
||||
from .sources.anthropic_source import (
|
||||
ProviderAnthropic as ProviderAnthropic,
|
||||
|
||||
@@ -33,20 +33,29 @@ class ProviderAnthropic(Provider):
|
||||
self,
|
||||
provider_config,
|
||||
provider_settings,
|
||||
*,
|
||||
use_api_key: bool = True,
|
||||
) -> None:
|
||||
super().__init__(
|
||||
provider_config,
|
||||
provider_settings,
|
||||
)
|
||||
|
||||
self.chosen_api_key: str = ""
|
||||
self.api_keys: list = super().get_keys()
|
||||
self.chosen_api_key = self.api_keys[0] if len(self.api_keys) > 0 else ""
|
||||
self.base_url = provider_config.get("api_base", "https://api.anthropic.com")
|
||||
self.timeout = provider_config.get("timeout", 120)
|
||||
if isinstance(self.timeout, str):
|
||||
self.timeout = int(self.timeout)
|
||||
self.thinking_config = provider_config.get("anth_thinking_config", {})
|
||||
|
||||
if use_api_key:
|
||||
self._init_api_key(provider_config)
|
||||
|
||||
self.set_model(provider_config.get("model", "unknown"))
|
||||
|
||||
def _init_api_key(self, provider_config: dict) -> None:
|
||||
self.chosen_api_key: str = ""
|
||||
self.api_keys: list = super().get_keys()
|
||||
self.chosen_api_key = self.api_keys[0] if len(self.api_keys) > 0 else ""
|
||||
self.client = AsyncAnthropic(
|
||||
api_key=self.chosen_api_key,
|
||||
timeout=self.timeout,
|
||||
@@ -54,15 +63,27 @@ class ProviderAnthropic(Provider):
|
||||
http_client=self._create_http_client(provider_config),
|
||||
)
|
||||
|
||||
self.thinking_config = provider_config.get("anth_thinking_config", {})
|
||||
|
||||
self.set_model(provider_config.get("model", "unknown"))
|
||||
|
||||
def _create_http_client(self, provider_config: dict) -> httpx.AsyncClient | None:
|
||||
"""创建带代理的 HTTP 客户端"""
|
||||
proxy = provider_config.get("proxy", "")
|
||||
return create_proxy_client("Anthropic", proxy)
|
||||
|
||||
def _apply_thinking_config(self, payloads: dict) -> None:
|
||||
thinking_type = self.thinking_config.get("type", "")
|
||||
if thinking_type == "adaptive":
|
||||
payloads["thinking"] = {"type": "adaptive"}
|
||||
effort = self.thinking_config.get("effort", "")
|
||||
output_cfg = dict(payloads.get("output_config", {}))
|
||||
if effort:
|
||||
output_cfg["effort"] = effort
|
||||
if output_cfg:
|
||||
payloads["output_config"] = output_cfg
|
||||
elif not thinking_type and self.thinking_config.get("budget"):
|
||||
payloads["thinking"] = {
|
||||
"budget_tokens": self.thinking_config.get("budget"),
|
||||
"type": "enabled",
|
||||
}
|
||||
|
||||
def _prepare_payload(self, messages: list[dict]):
|
||||
"""准备 Anthropic API 的请求 payload
|
||||
|
||||
@@ -213,11 +234,7 @@ class ProviderAnthropic(Provider):
|
||||
|
||||
if "max_tokens" not in payloads:
|
||||
payloads["max_tokens"] = 1024
|
||||
if self.thinking_config.get("budget"):
|
||||
payloads["thinking"] = {
|
||||
"budget_tokens": self.thinking_config.get("budget"),
|
||||
"type": "enabled",
|
||||
}
|
||||
self._apply_thinking_config(payloads)
|
||||
|
||||
try:
|
||||
completion = await self.client.messages.create(
|
||||
@@ -287,11 +304,7 @@ class ProviderAnthropic(Provider):
|
||||
|
||||
if "max_tokens" not in payloads:
|
||||
payloads["max_tokens"] = 1024
|
||||
if self.thinking_config.get("budget"):
|
||||
payloads["thinking"] = {
|
||||
"budget_tokens": self.thinking_config.get("budget"),
|
||||
"type": "enabled",
|
||||
}
|
||||
self._apply_thinking_config(payloads)
|
||||
|
||||
async with self.client.messages.stream(
|
||||
**payloads, extra_body=extra_body
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
from ..register import register_provider_adapter
|
||||
from .openai_source import ProviderOpenAIOfficial
|
||||
|
||||
|
||||
@register_provider_adapter(
|
||||
"openrouter_chat_completion", "OpenRouter Chat Completion Provider Adapter"
|
||||
)
|
||||
class ProviderOpenRouter(ProviderOpenAIOfficial):
|
||||
def __init__(
|
||||
self,
|
||||
provider_config: dict,
|
||||
provider_settings: dict,
|
||||
) -> None:
|
||||
super().__init__(provider_config, provider_settings)
|
||||
# Reference to: https://openrouter.ai/docs/api/reference/overview#headers
|
||||
self.client._custom_headers["HTTP-Referer"] = ( # type: ignore
|
||||
"https://github.com/AstrBotDevs/AstrBot"
|
||||
)
|
||||
self.client._custom_headers["X-TITLE"] = "AstrBot" # type: ignore
|
||||
@@ -14,6 +14,8 @@ from .star_handler import (
|
||||
register_on_llm_tool_respond,
|
||||
register_on_platform_loaded,
|
||||
register_on_plugin_error,
|
||||
register_on_plugin_loaded,
|
||||
register_on_plugin_unloaded,
|
||||
register_on_using_llm_tool,
|
||||
register_on_waiting_llm_request,
|
||||
register_permission_type,
|
||||
@@ -34,6 +36,8 @@ __all__ = [
|
||||
"register_on_llm_request",
|
||||
"register_on_llm_response",
|
||||
"register_on_plugin_error",
|
||||
"register_on_plugin_loaded",
|
||||
"register_on_plugin_unloaded",
|
||||
"register_on_platform_loaded",
|
||||
"register_on_waiting_llm_request",
|
||||
"register_permission_type",
|
||||
|
||||
@@ -357,6 +357,40 @@ def register_on_plugin_error(**kwargs):
|
||||
return decorator
|
||||
|
||||
|
||||
def register_on_plugin_loaded(**kwargs):
|
||||
"""当有插件加载完成时
|
||||
|
||||
Hook 参数:
|
||||
metadata
|
||||
|
||||
说明:
|
||||
当有插件加载完成时,触发该事件并获取到该插件的元数据
|
||||
"""
|
||||
|
||||
def decorator(awaitable):
|
||||
_ = get_handler_or_create(awaitable, EventType.OnPluginLoadedEvent, **kwargs)
|
||||
return awaitable
|
||||
|
||||
return decorator
|
||||
|
||||
|
||||
def register_on_plugin_unloaded(**kwargs):
|
||||
"""当有插件卸载完成时
|
||||
|
||||
Hook 参数:
|
||||
metadata
|
||||
|
||||
说明:
|
||||
当有插件卸载完成时,触发该事件并获取到该插件的元数据
|
||||
"""
|
||||
|
||||
def decorator(awaitable):
|
||||
_ = get_handler_or_create(awaitable, EventType.OnPluginUnloadedEvent, **kwargs)
|
||||
return awaitable
|
||||
|
||||
return decorator
|
||||
|
||||
|
||||
def register_on_waiting_llm_request(**kwargs):
|
||||
"""当等待调用 LLM 时的通知事件(在获取锁之前)
|
||||
|
||||
|
||||
@@ -144,6 +144,8 @@ class StarHandlerRegistry(Generic[T]):
|
||||
not in (
|
||||
EventType.OnAstrBotLoadedEvent,
|
||||
EventType.OnPlatformLoadedEvent,
|
||||
EventType.OnPluginLoadedEvent,
|
||||
EventType.OnPluginUnloadedEvent,
|
||||
)
|
||||
and not plugin.reserved
|
||||
):
|
||||
@@ -201,6 +203,8 @@ class EventType(enum.Enum):
|
||||
OnLLMToolRespondEvent = enum.auto() # 调用函数工具后
|
||||
OnAfterMessageSentEvent = enum.auto() # 发送消息后
|
||||
OnPluginErrorEvent = enum.auto() # 插件处理消息异常时
|
||||
OnPluginLoadedEvent = enum.auto() # 插件加载完成
|
||||
OnPluginUnloadedEvent = enum.auto() # 插件卸载完成
|
||||
|
||||
|
||||
H = TypeVar("H", bound=Callable[..., Any])
|
||||
|
||||
@@ -33,7 +33,7 @@ from .command_management import sync_command_configs
|
||||
from .context import Context
|
||||
from .filter.permission import PermissionType, PermissionTypeFilter
|
||||
from .star import star_map, star_registry
|
||||
from .star_handler import star_handlers_registry
|
||||
from .star_handler import EventType, star_handlers_registry
|
||||
from .updator import PluginUpdator
|
||||
|
||||
try:
|
||||
@@ -529,8 +529,19 @@ class PluginManager:
|
||||
requirements_path=requirements_path,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(traceback.format_exc())
|
||||
error_trace = traceback.format_exc()
|
||||
logger.error(error_trace)
|
||||
logger.error(f"插件 {root_dir_name} 导入失败。原因:{e!s}")
|
||||
fail_rec += f"加载 {root_dir_name} 插件时出现问题,原因 {e!s}。\n"
|
||||
self.failed_plugin_dict[root_dir_name] = {
|
||||
"error": str(e),
|
||||
"traceback": error_trace,
|
||||
}
|
||||
if path in star_map:
|
||||
logger.info("失败插件依旧在插件列表中,正在清理...")
|
||||
metadata = star_map.pop(path)
|
||||
if metadata in star_registry:
|
||||
star_registry.remove(metadata)
|
||||
continue
|
||||
|
||||
# 检查 _conf_schema.json
|
||||
@@ -772,6 +783,19 @@ class PluginManager:
|
||||
if hasattr(metadata.star_cls, "initialize") and metadata.star_cls:
|
||||
await metadata.star_cls.initialize()
|
||||
|
||||
# 触发插件加载事件
|
||||
handlers = star_handlers_registry.get_handlers_by_event_type(
|
||||
EventType.OnPluginLoadedEvent,
|
||||
)
|
||||
for handler in handlers:
|
||||
try:
|
||||
logger.info(
|
||||
f"hook(on_plugin_loaded) -> {star_map[handler.handler_module_path].name} - {handler.handler_name}",
|
||||
)
|
||||
await handler.handler(metadata)
|
||||
except Exception:
|
||||
logger.error(traceback.format_exc())
|
||||
|
||||
except BaseException as e:
|
||||
logger.error(f"----- 插件 {root_dir_name} 载入失败 -----")
|
||||
errors = traceback.format_exc()
|
||||
@@ -784,6 +808,11 @@ class PluginManager:
|
||||
"traceback": errors,
|
||||
}
|
||||
# 记录注册失败的插件名称,以便后续重载插件
|
||||
if path in star_map:
|
||||
logger.info("失败插件依旧在插件列表中,正在清理...")
|
||||
metadata = star_map.pop(path)
|
||||
if metadata in star_registry:
|
||||
star_registry.remove(metadata)
|
||||
|
||||
# 清除 pip.main 导致的多余的 logging handlers
|
||||
for handler in logging.root.handlers[:]:
|
||||
@@ -1159,6 +1188,19 @@ class PluginManager:
|
||||
elif "terminate" in star_metadata.star_cls_type.__dict__:
|
||||
await star_metadata.star_cls.terminate()
|
||||
|
||||
# 触发插件卸载事件
|
||||
handlers = star_handlers_registry.get_handlers_by_event_type(
|
||||
EventType.OnPluginUnloadedEvent,
|
||||
)
|
||||
for handler in handlers:
|
||||
try:
|
||||
logger.info(
|
||||
f"hook(on_plugin_unloaded) -> {star_map[handler.handler_module_path].name} - {handler.handler_name}",
|
||||
)
|
||||
await handler.handler(star_metadata)
|
||||
except Exception:
|
||||
logger.error(traceback.format_exc())
|
||||
|
||||
async def turn_on_plugin(self, plugin_name: str) -> None:
|
||||
plugin = self.context.get_registered_star(plugin_name)
|
||||
if plugin is None:
|
||||
|
||||
@@ -46,5 +46,22 @@ class ActiveEventRegistry:
|
||||
count += 1
|
||||
return count
|
||||
|
||||
def request_agent_stop_all(
|
||||
self,
|
||||
umo: str,
|
||||
exclude: AstrMessageEvent | None = None,
|
||||
) -> int:
|
||||
"""请求停止指定 UMO 的所有活跃事件中的 Agent 运行。
|
||||
|
||||
与 stop_all 不同,这里不会调用 event.stop_event(),
|
||||
因此不会中断事件传播,后续流程(如历史记录保存)仍可继续。
|
||||
"""
|
||||
count = 0
|
||||
for event in list(self._events.get(umo, [])):
|
||||
if event is not exclude:
|
||||
event.set_extra("agent_stop_requested", True)
|
||||
count += 1
|
||||
return count
|
||||
|
||||
|
||||
active_event_registry = ActiveEventRegistry()
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
from .api_key import ApiKeyRoute
|
||||
from .auth import AuthRoute
|
||||
from .backup import BackupRoute
|
||||
from .chat import ChatRoute
|
||||
@@ -9,6 +10,7 @@ from .cron import CronRoute
|
||||
from .file import FileRoute
|
||||
from .knowledge_base import KnowledgeBaseRoute
|
||||
from .log import LogRoute
|
||||
from .open_api import OpenApiRoute
|
||||
from .persona import PersonaRoute
|
||||
from .platform import PlatformRoute
|
||||
from .plugin import PluginRoute
|
||||
@@ -21,6 +23,7 @@ from .tools import ToolsRoute
|
||||
from .update import UpdateRoute
|
||||
|
||||
__all__ = [
|
||||
"ApiKeyRoute",
|
||||
"AuthRoute",
|
||||
"BackupRoute",
|
||||
"ChatRoute",
|
||||
@@ -32,6 +35,7 @@ __all__ = [
|
||||
"FileRoute",
|
||||
"KnowledgeBaseRoute",
|
||||
"LogRoute",
|
||||
"OpenApiRoute",
|
||||
"PersonaRoute",
|
||||
"PlatformRoute",
|
||||
"PluginRoute",
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
import hashlib
|
||||
import secrets
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from quart import g, request
|
||||
|
||||
from astrbot.core.db import BaseDatabase
|
||||
|
||||
from .route import Response, Route, RouteContext
|
||||
|
||||
ALL_OPEN_API_SCOPES = ("chat", "config", "file", "im")
|
||||
|
||||
|
||||
class ApiKeyRoute(Route):
|
||||
def __init__(self, context: RouteContext, db: BaseDatabase) -> None:
|
||||
super().__init__(context)
|
||||
self.db = db
|
||||
self.routes = {
|
||||
"/apikey/list": ("GET", self.list_api_keys),
|
||||
"/apikey/create": ("POST", self.create_api_key),
|
||||
"/apikey/revoke": ("POST", self.revoke_api_key),
|
||||
"/apikey/delete": ("POST", self.delete_api_key),
|
||||
}
|
||||
self.register_routes()
|
||||
|
||||
@staticmethod
|
||||
def _normalize_utc(dt: datetime | None) -> datetime | None:
|
||||
if dt is None:
|
||||
return None
|
||||
if dt.tzinfo is None or dt.tzinfo.utcoffset(dt) is None:
|
||||
return dt.replace(tzinfo=timezone.utc)
|
||||
return dt.astimezone(timezone.utc)
|
||||
|
||||
@classmethod
|
||||
def _serialize_datetime(cls, dt: datetime | None) -> str | None:
|
||||
normalized = cls._normalize_utc(dt)
|
||||
if normalized is None:
|
||||
return None
|
||||
return normalized.astimezone().isoformat()
|
||||
|
||||
@staticmethod
|
||||
def _hash_key(raw_key: str) -> str:
|
||||
return hashlib.pbkdf2_hmac(
|
||||
"sha256",
|
||||
raw_key.encode("utf-8"),
|
||||
b"astrbot_api_key",
|
||||
100_000,
|
||||
).hex()
|
||||
|
||||
@staticmethod
|
||||
def _serialize_api_key(key) -> dict:
|
||||
expires_at = ApiKeyRoute._normalize_utc(key.expires_at)
|
||||
return {
|
||||
"key_id": key.key_id,
|
||||
"name": key.name,
|
||||
"key_prefix": key.key_prefix,
|
||||
"scopes": key.scopes or [],
|
||||
"created_by": key.created_by,
|
||||
"created_at": ApiKeyRoute._serialize_datetime(key.created_at),
|
||||
"updated_at": ApiKeyRoute._serialize_datetime(key.updated_at),
|
||||
"last_used_at": ApiKeyRoute._serialize_datetime(key.last_used_at),
|
||||
"expires_at": ApiKeyRoute._serialize_datetime(key.expires_at),
|
||||
"revoked_at": ApiKeyRoute._serialize_datetime(key.revoked_at),
|
||||
"is_revoked": key.revoked_at is not None,
|
||||
"is_expired": bool(expires_at and expires_at < datetime.now(timezone.utc)),
|
||||
}
|
||||
|
||||
async def list_api_keys(self):
|
||||
keys = await self.db.list_api_keys()
|
||||
return (
|
||||
Response().ok(data=[self._serialize_api_key(key) for key in keys]).__dict__
|
||||
)
|
||||
|
||||
async def create_api_key(self):
|
||||
post_data = await request.json or {}
|
||||
|
||||
name = str(post_data.get("name", "")).strip() or "Untitled API Key"
|
||||
scopes = post_data.get("scopes")
|
||||
if scopes is None:
|
||||
normalized_scopes = list(ALL_OPEN_API_SCOPES)
|
||||
elif isinstance(scopes, list):
|
||||
normalized_scopes = [
|
||||
scope
|
||||
for scope in scopes
|
||||
if isinstance(scope, str) and scope in ALL_OPEN_API_SCOPES
|
||||
]
|
||||
normalized_scopes = list(dict.fromkeys(normalized_scopes))
|
||||
if not normalized_scopes:
|
||||
return Response().error("At least one valid scope is required").__dict__
|
||||
else:
|
||||
return Response().error("Invalid scopes").__dict__
|
||||
|
||||
expires_at = None
|
||||
expires_in_days = post_data.get("expires_in_days")
|
||||
if expires_in_days is not None:
|
||||
try:
|
||||
expires_in_days_int = int(expires_in_days)
|
||||
except (TypeError, ValueError):
|
||||
return Response().error("expires_in_days must be an integer").__dict__
|
||||
if expires_in_days_int <= 0:
|
||||
return (
|
||||
Response().error("expires_in_days must be greater than 0").__dict__
|
||||
)
|
||||
expires_at = datetime.now(timezone.utc) + timedelta(
|
||||
days=expires_in_days_int
|
||||
)
|
||||
|
||||
raw_key = f"abk_{secrets.token_urlsafe(32)}"
|
||||
key_hash = self._hash_key(raw_key)
|
||||
key_prefix = raw_key[:12]
|
||||
created_by = g.get("username", "unknown")
|
||||
|
||||
api_key = await self.db.create_api_key(
|
||||
name=name,
|
||||
key_hash=key_hash,
|
||||
key_prefix=key_prefix,
|
||||
scopes=normalized_scopes, # type: ignore
|
||||
created_by=created_by,
|
||||
expires_at=expires_at,
|
||||
)
|
||||
|
||||
payload = self._serialize_api_key(api_key)
|
||||
payload["api_key"] = raw_key
|
||||
return Response().ok(data=payload).__dict__
|
||||
|
||||
async def revoke_api_key(self):
|
||||
post_data = await request.json or {}
|
||||
key_id = post_data.get("key_id")
|
||||
if not key_id:
|
||||
return Response().error("Missing key: key_id").__dict__
|
||||
|
||||
success = await self.db.revoke_api_key(key_id)
|
||||
if not success:
|
||||
return Response().error("API key not found").__dict__
|
||||
return Response().ok().__dict__
|
||||
|
||||
async def delete_api_key(self):
|
||||
post_data = await request.json or {}
|
||||
key_id = post_data.get("key_id")
|
||||
if not key_id:
|
||||
return Response().error("Missing key: key_id").__dict__
|
||||
|
||||
success = await self.db.delete_api_key(key_id)
|
||||
if not success:
|
||||
return Response().error("API key not found").__dict__
|
||||
return Response().ok().__dict__
|
||||
@@ -13,7 +13,9 @@ from quart import g, make_response, request, send_file
|
||||
from astrbot.core import logger, sp
|
||||
from astrbot.core.core_lifecycle import AstrBotCoreLifecycle
|
||||
from astrbot.core.db import BaseDatabase
|
||||
from astrbot.core.platform.message_type import MessageType
|
||||
from astrbot.core.platform.sources.webchat.webchat_queue_mgr import webchat_queue_mgr
|
||||
from astrbot.core.utils.active_event_registry import active_event_registry
|
||||
from astrbot.core.utils.astrbot_path import get_astrbot_data_path
|
||||
|
||||
from .route import Response, Route, RouteContext
|
||||
@@ -41,6 +43,7 @@ class ChatRoute(Route):
|
||||
"/chat/new_session": ("GET", self.new_session),
|
||||
"/chat/sessions": ("GET", self.get_sessions),
|
||||
"/chat/get_session": ("GET", self.get_session),
|
||||
"/chat/stop": ("POST", self.stop_session),
|
||||
"/chat/delete_session": ("GET", self.delete_webchat_session),
|
||||
"/chat/update_session_display_name": (
|
||||
"POST",
|
||||
@@ -52,8 +55,9 @@ class ChatRoute(Route):
|
||||
}
|
||||
self.core_lifecycle = core_lifecycle
|
||||
self.register_routes()
|
||||
self.imgs_dir = os.path.join(get_astrbot_data_path(), "webchat", "imgs")
|
||||
os.makedirs(self.imgs_dir, exist_ok=True)
|
||||
self.attachments_dir = os.path.join(get_astrbot_data_path(), "attachments")
|
||||
self.legacy_img_dir = os.path.join(get_astrbot_data_path(), "webchat", "imgs")
|
||||
os.makedirs(self.attachments_dir, exist_ok=True)
|
||||
|
||||
self.supported_imgs = ["jpg", "jpeg", "png", "gif", "webp"]
|
||||
self.conv_mgr = core_lifecycle.conversation_manager
|
||||
@@ -69,9 +73,18 @@ class ChatRoute(Route):
|
||||
return Response().error("Missing key: filename").__dict__
|
||||
|
||||
try:
|
||||
file_path = os.path.join(self.imgs_dir, os.path.basename(filename))
|
||||
file_path = os.path.join(self.attachments_dir, os.path.basename(filename))
|
||||
real_file_path = os.path.realpath(file_path)
|
||||
real_imgs_dir = os.path.realpath(self.imgs_dir)
|
||||
real_imgs_dir = os.path.realpath(self.attachments_dir)
|
||||
|
||||
if not os.path.exists(real_file_path):
|
||||
# try legacy
|
||||
file_path = os.path.join(
|
||||
self.legacy_img_dir, os.path.basename(filename)
|
||||
)
|
||||
if os.path.exists(file_path):
|
||||
real_file_path = os.path.realpath(file_path)
|
||||
real_imgs_dir = os.path.realpath(self.legacy_img_dir)
|
||||
|
||||
if not real_file_path.startswith(real_imgs_dir):
|
||||
return Response().error("Invalid file path").__dict__
|
||||
@@ -125,7 +138,7 @@ class ChatRoute(Route):
|
||||
else:
|
||||
attach_type = "file"
|
||||
|
||||
path = os.path.join(self.imgs_dir, filename)
|
||||
path = os.path.join(self.attachments_dir, filename)
|
||||
await file.save(path)
|
||||
|
||||
# 创建 attachment 记录
|
||||
@@ -202,8 +215,13 @@ class ChatRoute(Route):
|
||||
filename: 存储的文件名
|
||||
attach_type: 附件类型 (image, record, file, video)
|
||||
"""
|
||||
file_path = os.path.join(self.imgs_dir, os.path.basename(filename))
|
||||
if not os.path.exists(file_path):
|
||||
basename = os.path.basename(filename)
|
||||
candidate_paths = [
|
||||
os.path.join(self.attachments_dir, basename),
|
||||
os.path.join(self.legacy_img_dir, basename),
|
||||
]
|
||||
file_path = next((p for p in candidate_paths if os.path.exists(p)), None)
|
||||
if not file_path:
|
||||
return None
|
||||
|
||||
# guess mime type
|
||||
@@ -317,10 +335,13 @@ class ChatRoute(Route):
|
||||
)
|
||||
return record
|
||||
|
||||
async def chat(self):
|
||||
async def chat(self, post_data: dict | None = None):
|
||||
username = g.get("username", "guest")
|
||||
|
||||
if post_data is None:
|
||||
post_data = await request.json
|
||||
if post_data is None:
|
||||
return Response().error("Missing JSON body").__dict__
|
||||
if "message" not in post_data and "files" not in post_data:
|
||||
return Response().error("Missing key: message or files").__dict__
|
||||
|
||||
@@ -373,6 +394,14 @@ class ChatRoute(Route):
|
||||
agent_stats = {}
|
||||
refs = {}
|
||||
try:
|
||||
# Emit session_id first so clients can bind the stream immediately.
|
||||
session_info = {
|
||||
"type": "session_id",
|
||||
"data": None,
|
||||
"session_id": webchat_conv_id,
|
||||
}
|
||||
yield f"data: {json.dumps(session_info, ensure_ascii=False)}\n\n"
|
||||
|
||||
async with track_conversation(self.running_convs, webchat_conv_id):
|
||||
while True:
|
||||
try:
|
||||
@@ -582,6 +611,36 @@ class ChatRoute(Route):
|
||||
response.timeout = None # fix SSE auto disconnect issue
|
||||
return response
|
||||
|
||||
async def stop_session(self):
|
||||
"""Stop active agent runs for a session."""
|
||||
post_data = await request.json
|
||||
if post_data is None:
|
||||
return Response().error("Missing JSON body").__dict__
|
||||
|
||||
session_id = post_data.get("session_id")
|
||||
if not session_id:
|
||||
return Response().error("Missing key: session_id").__dict__
|
||||
|
||||
username = g.get("username", "guest")
|
||||
session = await self.db.get_platform_session_by_id(session_id)
|
||||
if not session:
|
||||
return Response().error(f"Session {session_id} not found").__dict__
|
||||
if session.creator != username:
|
||||
return Response().error("Permission denied").__dict__
|
||||
|
||||
message_type = (
|
||||
MessageType.GROUP_MESSAGE.value
|
||||
if session.is_group
|
||||
else MessageType.FRIEND_MESSAGE.value
|
||||
)
|
||||
umo = (
|
||||
f"{session.platform_id}:{message_type}:"
|
||||
f"{session.platform_id}!{username}!{session_id}"
|
||||
)
|
||||
stopped_count = active_event_registry.request_agent_stop_all(umo)
|
||||
|
||||
return Response().ok(data={"stopped_count": stopped_count}).__dict__
|
||||
|
||||
async def delete_webchat_session(self):
|
||||
"""Delete a Platform session and all its related data."""
|
||||
session_id = request.args.get("session_id")
|
||||
@@ -705,23 +764,18 @@ class ChatRoute(Route):
|
||||
# 获取可选的 platform_id 参数
|
||||
platform_id = request.args.get("platform_id")
|
||||
|
||||
sessions = await self.db.get_platform_sessions_by_creator(
|
||||
sessions, _ = await self.db.get_platform_sessions_by_creator_paginated(
|
||||
creator=username,
|
||||
platform_id=platform_id,
|
||||
page=1,
|
||||
page_size=100, # 暂时返回前100个
|
||||
exclude_project_sessions=True,
|
||||
)
|
||||
|
||||
# 转换为字典格式,并添加项目信息
|
||||
# get_platform_sessions_by_creator 现在返回 list[dict] 包含 session 和项目字段
|
||||
# 转换为字典格式
|
||||
sessions_data = []
|
||||
for item in sessions:
|
||||
session = item["session"]
|
||||
project_id = item["project_id"]
|
||||
|
||||
# 跳过属于项目的会话(在侧边栏对话列表中不显示)
|
||||
if project_id is not None:
|
||||
continue
|
||||
|
||||
sessions_data.append(
|
||||
{
|
||||
|
||||
@@ -0,0 +1,388 @@
|
||||
from pathlib import Path
|
||||
from uuid import uuid4
|
||||
|
||||
from quart import g, request
|
||||
|
||||
from astrbot.core import logger
|
||||
from astrbot.core.core_lifecycle import AstrBotCoreLifecycle
|
||||
from astrbot.core.db import BaseDatabase
|
||||
from astrbot.core.message.components import File, Image, Plain, Record, Reply, Video
|
||||
from astrbot.core.message.message_event_result import MessageChain
|
||||
from astrbot.core.platform.message_session import MessageSesion
|
||||
|
||||
from .chat import ChatRoute
|
||||
from .route import Response, Route, RouteContext
|
||||
|
||||
|
||||
class OpenApiRoute(Route):
|
||||
def __init__(
|
||||
self,
|
||||
context: RouteContext,
|
||||
db: BaseDatabase,
|
||||
core_lifecycle: AstrBotCoreLifecycle,
|
||||
chat_route: ChatRoute,
|
||||
) -> None:
|
||||
super().__init__(context)
|
||||
self.db = db
|
||||
self.core_lifecycle = core_lifecycle
|
||||
self.platform_manager = core_lifecycle.platform_manager
|
||||
self.chat_route = chat_route
|
||||
|
||||
self.routes = {
|
||||
"/v1/chat": ("POST", self.chat_send),
|
||||
"/v1/chat/sessions": ("GET", self.get_chat_sessions),
|
||||
"/v1/configs": ("GET", self.get_chat_configs),
|
||||
"/v1/file": ("POST", self.upload_file),
|
||||
"/v1/im/message": ("POST", self.send_message),
|
||||
"/v1/im/bots": ("GET", self.get_bots),
|
||||
}
|
||||
self.register_routes()
|
||||
|
||||
@staticmethod
|
||||
def _resolve_open_username(
|
||||
raw_username: str | None,
|
||||
) -> tuple[str | None, str | None]:
|
||||
if raw_username is None:
|
||||
return None, "Missing key: username"
|
||||
username = str(raw_username).strip()
|
||||
if not username:
|
||||
return None, "username is empty"
|
||||
return username, None
|
||||
|
||||
def _get_chat_config_list(self) -> list[dict]:
|
||||
conf_list = self.core_lifecycle.astrbot_config_mgr.get_conf_list()
|
||||
|
||||
result = []
|
||||
for conf_info in conf_list:
|
||||
conf_id = str(conf_info.get("id", "")).strip()
|
||||
result.append(
|
||||
{
|
||||
"id": conf_id,
|
||||
"name": str(conf_info.get("name", "")).strip(),
|
||||
"path": str(conf_info.get("path", "")).strip(),
|
||||
"is_default": conf_id == "default",
|
||||
}
|
||||
)
|
||||
return result
|
||||
|
||||
def _resolve_chat_config_id(self, post_data: dict) -> tuple[str | None, str | None]:
|
||||
raw_config_id = post_data.get("config_id")
|
||||
raw_config_name = post_data.get("config_name")
|
||||
config_id = str(raw_config_id).strip() if raw_config_id is not None else ""
|
||||
config_name = (
|
||||
str(raw_config_name).strip() if raw_config_name is not None else ""
|
||||
)
|
||||
|
||||
if not config_id and not config_name:
|
||||
return None, None
|
||||
|
||||
conf_list = self._get_chat_config_list()
|
||||
conf_map = {item["id"]: item for item in conf_list}
|
||||
|
||||
if config_id:
|
||||
if config_id not in conf_map:
|
||||
return None, f"config_id not found: {config_id}"
|
||||
return config_id, None
|
||||
|
||||
if not config_name:
|
||||
return None, "config_name is empty"
|
||||
|
||||
matched = [item for item in conf_list if item["name"] == config_name]
|
||||
if not matched:
|
||||
return None, f"config_name not found: {config_name}"
|
||||
if len(matched) > 1:
|
||||
return (
|
||||
None,
|
||||
f"config_name is ambiguous, please use config_id: {config_name}",
|
||||
)
|
||||
|
||||
return matched[0]["id"], None
|
||||
|
||||
async def _ensure_chat_session(
|
||||
self,
|
||||
username: str,
|
||||
session_id: str,
|
||||
) -> str | None:
|
||||
session = await self.db.get_platform_session_by_id(session_id)
|
||||
if session:
|
||||
if session.creator != username:
|
||||
return "session_id belongs to another username"
|
||||
return None
|
||||
|
||||
try:
|
||||
await self.db.create_platform_session(
|
||||
creator=username,
|
||||
platform_id="webchat",
|
||||
session_id=session_id,
|
||||
is_group=0,
|
||||
)
|
||||
except Exception as e:
|
||||
# Handle rare race when same session_id is created concurrently.
|
||||
existing = await self.db.get_platform_session_by_id(session_id)
|
||||
if existing and existing.creator == username:
|
||||
return None
|
||||
logger.error("Failed to create chat session %s: %s", session_id, e)
|
||||
return f"Failed to create session: {e}"
|
||||
|
||||
return None
|
||||
|
||||
async def chat_send(self):
|
||||
post_data = await request.get_json(silent=True) or {}
|
||||
effective_username, username_err = self._resolve_open_username(
|
||||
post_data.get("username")
|
||||
)
|
||||
if username_err:
|
||||
return Response().error(username_err).__dict__
|
||||
if not effective_username:
|
||||
return Response().error("Invalid username").__dict__
|
||||
|
||||
raw_session_id = post_data.get("session_id", post_data.get("conversation_id"))
|
||||
session_id = str(raw_session_id).strip() if raw_session_id is not None else ""
|
||||
if not session_id:
|
||||
session_id = str(uuid4())
|
||||
post_data["session_id"] = session_id
|
||||
ensure_session_err = await self._ensure_chat_session(
|
||||
effective_username,
|
||||
session_id,
|
||||
)
|
||||
if ensure_session_err:
|
||||
return Response().error(ensure_session_err).__dict__
|
||||
|
||||
config_id, resolve_err = self._resolve_chat_config_id(post_data)
|
||||
if resolve_err:
|
||||
return Response().error(resolve_err).__dict__
|
||||
|
||||
original_username = g.get("username", "guest")
|
||||
g.username = effective_username
|
||||
if config_id:
|
||||
umo = f"webchat:FriendMessage:webchat!{effective_username}!{session_id}"
|
||||
try:
|
||||
if config_id == "default":
|
||||
await self.core_lifecycle.umop_config_router.delete_route(umo)
|
||||
else:
|
||||
await self.core_lifecycle.umop_config_router.update_route(
|
||||
umo, config_id
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"Failed to update chat config route for %s with %s: %s",
|
||||
umo,
|
||||
config_id,
|
||||
e,
|
||||
exc_info=True,
|
||||
)
|
||||
return (
|
||||
Response()
|
||||
.error(f"Failed to update chat config route: {e}")
|
||||
.__dict__
|
||||
)
|
||||
try:
|
||||
return await self.chat_route.chat(post_data=post_data)
|
||||
finally:
|
||||
g.username = original_username
|
||||
|
||||
async def upload_file(self):
|
||||
return await self.chat_route.post_file()
|
||||
|
||||
async def get_chat_sessions(self):
|
||||
username, username_err = self._resolve_open_username(
|
||||
request.args.get("username")
|
||||
)
|
||||
if username_err:
|
||||
return Response().error(username_err).__dict__
|
||||
|
||||
assert username is not None # for type checker
|
||||
|
||||
try:
|
||||
page = int(request.args.get("page", 1))
|
||||
page_size = int(request.args.get("page_size", 20))
|
||||
except ValueError:
|
||||
return Response().error("page and page_size must be integers").__dict__
|
||||
|
||||
if page < 1:
|
||||
page = 1
|
||||
if page_size < 1:
|
||||
page_size = 1
|
||||
if page_size > 100:
|
||||
page_size = 100
|
||||
|
||||
platform_id = request.args.get("platform_id")
|
||||
|
||||
(
|
||||
paginated_sessions,
|
||||
total,
|
||||
) = await self.db.get_platform_sessions_by_creator_paginated(
|
||||
creator=username,
|
||||
platform_id=platform_id,
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
exclude_project_sessions=True,
|
||||
)
|
||||
|
||||
sessions_data = []
|
||||
for item in paginated_sessions:
|
||||
session = item["session"]
|
||||
sessions_data.append(
|
||||
{
|
||||
"session_id": session.session_id,
|
||||
"platform_id": session.platform_id,
|
||||
"creator": session.creator,
|
||||
"display_name": session.display_name,
|
||||
"is_group": session.is_group,
|
||||
"created_at": session.created_at.astimezone().isoformat(),
|
||||
"updated_at": session.updated_at.astimezone().isoformat(),
|
||||
}
|
||||
)
|
||||
|
||||
return (
|
||||
Response()
|
||||
.ok(
|
||||
data={
|
||||
"sessions": sessions_data,
|
||||
"page": page,
|
||||
"page_size": page_size,
|
||||
"total": total,
|
||||
}
|
||||
)
|
||||
.__dict__
|
||||
)
|
||||
|
||||
async def get_chat_configs(self):
|
||||
conf_list = self._get_chat_config_list()
|
||||
return Response().ok(data={"configs": conf_list}).__dict__
|
||||
|
||||
async def _build_message_chain_from_payload(
|
||||
self,
|
||||
message_payload: str | list,
|
||||
) -> MessageChain:
|
||||
if isinstance(message_payload, str):
|
||||
text = message_payload.strip()
|
||||
if not text:
|
||||
raise ValueError("Message is empty")
|
||||
return MessageChain(chain=[Plain(text=text)])
|
||||
|
||||
if not isinstance(message_payload, list):
|
||||
raise ValueError("message must be a string or list")
|
||||
|
||||
components = []
|
||||
has_content = False
|
||||
|
||||
for part in message_payload:
|
||||
if not isinstance(part, dict):
|
||||
raise ValueError("message part must be an object")
|
||||
|
||||
part_type = str(part.get("type", "")).strip()
|
||||
if part_type == "plain":
|
||||
text = str(part.get("text", ""))
|
||||
if text:
|
||||
has_content = True
|
||||
components.append(Plain(text=text))
|
||||
continue
|
||||
|
||||
if part_type == "reply":
|
||||
message_id = part.get("message_id")
|
||||
if message_id is None:
|
||||
raise ValueError("reply part missing message_id")
|
||||
components.append(
|
||||
Reply(
|
||||
id=str(message_id),
|
||||
message_str=str(part.get("selected_text", "")),
|
||||
chain=[],
|
||||
)
|
||||
)
|
||||
continue
|
||||
|
||||
if part_type not in {"image", "record", "file", "video"}:
|
||||
raise ValueError(f"unsupported message part type: {part_type}")
|
||||
|
||||
has_content = True
|
||||
file_path: Path | None = None
|
||||
resolved_type = part_type
|
||||
filename = str(part.get("filename", "")).strip()
|
||||
|
||||
attachment_id = part.get("attachment_id")
|
||||
if attachment_id:
|
||||
attachment = await self.db.get_attachment_by_id(str(attachment_id))
|
||||
if not attachment:
|
||||
raise ValueError(f"attachment not found: {attachment_id}")
|
||||
file_path = Path(attachment.path)
|
||||
resolved_type = attachment.type
|
||||
if not filename:
|
||||
filename = file_path.name
|
||||
else:
|
||||
raise ValueError(f"{part_type} part missing attachment_id")
|
||||
|
||||
if not file_path.exists():
|
||||
raise ValueError(f"file not found: {file_path!s}")
|
||||
|
||||
file_path_str = str(file_path.resolve())
|
||||
if resolved_type == "image":
|
||||
components.append(Image.fromFileSystem(file_path_str))
|
||||
elif resolved_type == "record":
|
||||
components.append(Record.fromFileSystem(file_path_str))
|
||||
elif resolved_type == "video":
|
||||
components.append(Video.fromFileSystem(file_path_str))
|
||||
else:
|
||||
components.append(
|
||||
File(name=filename or file_path.name, file=file_path_str)
|
||||
)
|
||||
|
||||
if not components or not has_content:
|
||||
raise ValueError("Message content is empty (reply only is not allowed)")
|
||||
|
||||
return MessageChain(chain=components)
|
||||
|
||||
async def send_message(self):
|
||||
post_data = await request.json or {}
|
||||
message_payload = post_data.get("message", {})
|
||||
umo = post_data.get("umo")
|
||||
|
||||
if message_payload is None:
|
||||
return Response().error("Missing key: message").__dict__
|
||||
if not umo:
|
||||
return Response().error("Missing key: umo").__dict__
|
||||
|
||||
try:
|
||||
session = MessageSesion.from_str(str(umo))
|
||||
except Exception as e:
|
||||
return Response().error(f"Invalid umo: {e}").__dict__
|
||||
|
||||
platform_id = session.platform_name
|
||||
platform_inst = next(
|
||||
(
|
||||
inst
|
||||
for inst in self.platform_manager.platform_insts
|
||||
if inst.meta().id == platform_id
|
||||
),
|
||||
None,
|
||||
)
|
||||
if not platform_inst:
|
||||
return (
|
||||
Response()
|
||||
.error(f"Bot not found or not running for platform: {platform_id}")
|
||||
.__dict__
|
||||
)
|
||||
|
||||
try:
|
||||
message_chain = await self._build_message_chain_from_payload(
|
||||
message_payload
|
||||
)
|
||||
await platform_inst.send_by_session(session, message_chain)
|
||||
return Response().ok().__dict__
|
||||
except ValueError as e:
|
||||
return Response().error(str(e)).__dict__
|
||||
except Exception as e:
|
||||
logger.error(f"Open API send_message failed: {e}", exc_info=True)
|
||||
return Response().error(f"Failed to send message: {e}").__dict__
|
||||
|
||||
async def get_bots(self):
|
||||
bot_ids = []
|
||||
for platform in self.core_lifecycle.astrbot_config.get("platform", []):
|
||||
platform_id = platform.get("id") if isinstance(platform, dict) else None
|
||||
if (
|
||||
isinstance(platform_id, str)
|
||||
and platform_id
|
||||
and platform_id not in bot_ids
|
||||
):
|
||||
bot_ids.append(platform_id)
|
||||
return Response().ok(data={"bot_ids": bot_ids}).__dict__
|
||||
@@ -698,9 +698,15 @@ class PluginRoute(Route):
|
||||
logger.warning(f"插件 {plugin_name} 目录不存在")
|
||||
return Response().error(f"插件 {plugin_name} 目录不存在").__dict__
|
||||
|
||||
if plugin_obj.reserved:
|
||||
plugin_dir = os.path.join(
|
||||
self.plugin_manager.reserved_plugin_path,
|
||||
plugin_obj.root_dir_name,
|
||||
)
|
||||
else:
|
||||
plugin_dir = os.path.join(
|
||||
self.plugin_manager.plugin_store_path,
|
||||
plugin_obj.root_dir_name or "",
|
||||
plugin_obj.root_dir_name,
|
||||
)
|
||||
|
||||
if not os.path.isdir(plugin_dir):
|
||||
@@ -735,6 +741,7 @@ class PluginRoute(Route):
|
||||
logger.debug(f"正在获取插件 {plugin_name} 的更新日志")
|
||||
|
||||
if not plugin_name:
|
||||
logger.warning("插件名称为空")
|
||||
return Response().error("插件名称不能为空").__dict__
|
||||
|
||||
# 查找插件
|
||||
@@ -745,16 +752,28 @@ class PluginRoute(Route):
|
||||
break
|
||||
|
||||
if not plugin_obj:
|
||||
logger.warning(f"插件 {plugin_name} 不存在")
|
||||
return Response().error(f"插件 {plugin_name} 不存在").__dict__
|
||||
|
||||
if not plugin_obj.root_dir_name:
|
||||
logger.warning(f"插件 {plugin_name} 目录不存在")
|
||||
return Response().error(f"插件 {plugin_name} 目录不存在").__dict__
|
||||
|
||||
if plugin_obj.reserved:
|
||||
plugin_dir = os.path.join(
|
||||
self.plugin_manager.reserved_plugin_path,
|
||||
plugin_obj.root_dir_name,
|
||||
)
|
||||
else:
|
||||
plugin_dir = os.path.join(
|
||||
self.plugin_manager.plugin_store_path,
|
||||
plugin_obj.root_dir_name,
|
||||
)
|
||||
|
||||
if not os.path.isdir(plugin_dir):
|
||||
logger.warning(f"无法找到插件目录: {plugin_dir}")
|
||||
return Response().error(f"无法找到插件 {plugin_name} 的目录").__dict__
|
||||
|
||||
# 尝试多种可能的文件名
|
||||
changelog_names = ["CHANGELOG.md", "changelog.md", "CHANGELOG", "changelog"]
|
||||
for name in changelog_names:
|
||||
@@ -773,6 +792,7 @@ class PluginRoute(Route):
|
||||
return Response().error(f"读取更新日志失败: {e!s}").__dict__
|
||||
|
||||
# 没有找到 changelog 文件,返回 ok 但 content 为 null
|
||||
logger.warning(f"插件 {plugin_name} 没有更新日志文件")
|
||||
return Response().ok({"content": None}, "该插件没有更新日志文件").__dict__
|
||||
|
||||
async def get_custom_source(self):
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import asyncio
|
||||
import hashlib
|
||||
import logging
|
||||
import os
|
||||
import socket
|
||||
@@ -21,6 +22,7 @@ from astrbot.core.utils.astrbot_path import get_astrbot_data_path
|
||||
from astrbot.core.utils.io import get_local_ip_addresses
|
||||
|
||||
from .routes import *
|
||||
from .routes.api_key import ALL_OPEN_API_SCOPES
|
||||
from .routes.backup import BackupRoute
|
||||
from .routes.live_chat import LiveChatRoute
|
||||
from .routes.platform import PlatformRoute
|
||||
@@ -53,6 +55,7 @@ class AstrBotDashboard:
|
||||
) -> None:
|
||||
self.core_lifecycle = core_lifecycle
|
||||
self.config = core_lifecycle.astrbot_config
|
||||
self.db = db
|
||||
|
||||
# 参数指定webui目录
|
||||
if webui_dir and os.path.exists(webui_dir):
|
||||
@@ -88,7 +91,14 @@ class AstrBotDashboard:
|
||||
self.lr = LogRoute(self.context, core_lifecycle.log_broker)
|
||||
self.sfr = StaticFileRoute(self.context)
|
||||
self.ar = AuthRoute(self.context)
|
||||
self.api_key_route = ApiKeyRoute(self.context, db)
|
||||
self.chat_route = ChatRoute(self.context, db, core_lifecycle)
|
||||
self.open_api_route = OpenApiRoute(
|
||||
self.context,
|
||||
db,
|
||||
core_lifecycle,
|
||||
self.chat_route,
|
||||
)
|
||||
self.chatui_project_route = ChatUIProjectRoute(self.context, db)
|
||||
self.tools_root = ToolsRoute(self.context, core_lifecycle)
|
||||
self.subagent_route = SubAgentRoute(self.context, core_lifecycle)
|
||||
@@ -130,6 +140,40 @@ class AstrBotDashboard:
|
||||
async def auth_middleware(self):
|
||||
if not request.path.startswith("/api"):
|
||||
return None
|
||||
if request.path.startswith("/api/v1"):
|
||||
raw_key = self._extract_raw_api_key()
|
||||
if not raw_key:
|
||||
r = jsonify(Response().error("Missing API key").__dict__)
|
||||
r.status_code = 401
|
||||
return r
|
||||
key_hash = hashlib.pbkdf2_hmac(
|
||||
"sha256",
|
||||
raw_key.encode("utf-8"),
|
||||
b"astrbot_api_key",
|
||||
100_000,
|
||||
).hex()
|
||||
api_key = await self.db.get_active_api_key_by_hash(key_hash)
|
||||
if not api_key:
|
||||
r = jsonify(Response().error("Invalid API key").__dict__)
|
||||
r.status_code = 401
|
||||
return r
|
||||
|
||||
if isinstance(api_key.scopes, list):
|
||||
scopes = api_key.scopes
|
||||
else:
|
||||
scopes = list(ALL_OPEN_API_SCOPES)
|
||||
required_scope = self._get_required_open_api_scope(request.path)
|
||||
if required_scope and "*" not in scopes and required_scope not in scopes:
|
||||
r = jsonify(Response().error("Insufficient API key scope").__dict__)
|
||||
r.status_code = 403
|
||||
return r
|
||||
|
||||
g.api_key_id = api_key.key_id
|
||||
g.api_key_scopes = scopes
|
||||
g.username = f"api_key:{api_key.key_id}"
|
||||
await self.db.touch_api_key(api_key.key_id)
|
||||
return None
|
||||
|
||||
allowed_endpoints = [
|
||||
"/api/auth/login",
|
||||
"/api/file",
|
||||
@@ -158,6 +202,29 @@ class AstrBotDashboard:
|
||||
r.status_code = 401
|
||||
return r
|
||||
|
||||
@staticmethod
|
||||
def _extract_raw_api_key() -> str | None:
|
||||
if key := request.headers.get("X-API-Key"):
|
||||
return key.strip()
|
||||
auth_header = request.headers.get("Authorization", "").strip()
|
||||
if auth_header.startswith("Bearer "):
|
||||
return auth_header.removeprefix("Bearer ").strip()
|
||||
if auth_header.startswith("ApiKey "):
|
||||
return auth_header.removeprefix("ApiKey ").strip()
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _get_required_open_api_scope(path: str) -> str | None:
|
||||
scope_map = {
|
||||
"/api/v1/chat": "chat",
|
||||
"/api/v1/chat/sessions": "chat",
|
||||
"/api/v1/configs": "config",
|
||||
"/api/v1/file": "file",
|
||||
"/api/v1/im/message": "im",
|
||||
"/api/v1/im/bots": "im",
|
||||
}
|
||||
return scope_map.get(path)
|
||||
|
||||
def check_port_in_use(self, port: int) -> bool:
|
||||
"""跨平台检测端口是否被占用"""
|
||||
try:
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
## What's Changed
|
||||
|
||||
### 新增
|
||||
- 新增 AstrBot HTTP API,支持基于 API Key 的对话、会话查询、配置查询、文件上传与 IM 消息发送能力。详见[AstrBot HTTP API (Beta)](https://docs.astrbot.app/dev/openapi.html) ([#5280](https://github.com/AstrBotDevs/AstrBot/issues/5280))。
|
||||
- 新增 Telegram 指令别名注册能力,别名可同步展示在 Telegram 指令菜单中 ([#5234](https://github.com/AstrBotDevs/AstrBot/issues/5234))。
|
||||
- 新增 Anthropic 自适应思考参数配置(type/effort),增强思考策略可控性 ([#5209](https://github.com/AstrBotDevs/AstrBot/issues/5209))。
|
||||
|
||||
### 修复
|
||||
- 修复 QQ 官方频道消息发送异常问题,提升消息下发稳定性 ([#5287](https://github.com/AstrBotDevs/AstrBot/issues/5287))。
|
||||
- 修复 ChatUI 使用非 default 配置文件对话时仍然使用 default 配置的问题 ([#5292](https://github.com/AstrBotDevs/AstrBot/issues/5292))。
|
||||
|
||||
### 优化
|
||||
- 优化插件市场卡片的平台支持展示,改进移动端可用性与交互体验 ([#5271](https://github.com/AstrBotDevs/AstrBot/issues/5271))。
|
||||
- 重构 Dashboard 桌面运行时桥接字段,从 `isElectron` 统一迁移至 `isDesktop`,提升跨端语义一致性 ([#5269](https://github.com/AstrBotDevs/AstrBot/issues/5269))。
|
||||
|
||||
## What's Changed (EN)
|
||||
|
||||
### New Features
|
||||
- Added AstrBot HTTP API with API Key support for chat, session listing, config listing, file upload, and IM message sending. See [AstrBot HTTP API (Beta)](https://docs.astrbot.app/en/dev/openapi.html) ([#5280](https://github.com/AstrBotDevs/AstrBot/issues/5280)).
|
||||
- Added Telegram command alias registration so aliases can also appear in the Telegram command menu ([#5234](https://github.com/AstrBotDevs/AstrBot/issues/5234)).
|
||||
- Added Anthropic adaptive thinking parameters (`type`/`effort`) for more flexible reasoning strategy control ([#5209](https://github.com/AstrBotDevs/AstrBot/issues/5209)).
|
||||
|
||||
### Fixes
|
||||
- Fixed QQ official guild message sending errors to improve delivery stability ([#5287](https://github.com/AstrBotDevs/AstrBot/issues/5287)).
|
||||
- Fixed chat config binding failures caused by missing session IDs when creating new chats, and improved localStorage fault tolerance ([#5292](https://github.com/AstrBotDevs/AstrBot/issues/5292)).
|
||||
|
||||
### Improvements
|
||||
- Improved plugin marketplace card display for platform compatibility, with better mobile accessibility and interaction ([#5271](https://github.com/AstrBotDevs/AstrBot/issues/5271)).
|
||||
- Refactored desktop runtime bridge fields in the dashboard from `isElectron` to `isDesktop` for clearer cross-platform semantics ([#5269](https://github.com/AstrBotDevs/AstrBot/issues/5269)).
|
||||
@@ -0,0 +1,17 @@
|
||||
## What's Changed
|
||||
|
||||
### 修复
|
||||
- fix: 修复插件市场出现插件显示为空白的 bug;纠正已安装插件卡片的排版,统一大小 ([#5309](https://github.com/AstrBotDevs/AstrBot/issues/5309))
|
||||
|
||||
### 新增
|
||||
- SubAgent 支持后台执行模式配置:当 `background: true` 时,子代理将在后台运行,主对话无需等待子代理完成即可继续进行。当子代理完成后,会收到通知。适用于长时间运行或用户不需要立即结果的任务。([#5081](https://github.com/AstrBotDevs/AstrBot/issues/5081))
|
||||
- 配置 Schema 新增密码渲染支持:`string` 与 `text` 类型可通过 `password: true`(或 `render_type: "password"`)在 WebUI 中按密码输入方式显示。
|
||||
|
||||
## What's Changed (EN)
|
||||
|
||||
### Fixes
|
||||
- fix: Fixed a bug where the plugin marketplace would show blank cards for plugins; corrected the layout of installed plugin cards for consistent sizing ([#5309](https://github.com/AstrBotDevs/AstrBot/issues/5309))
|
||||
|
||||
### New Features
|
||||
- Added background execution mode support for sub-agents: when `background: true` is set, the sub-agent will run in the background, allowing the main conversation to continue without waiting for the sub-agent to finish. You will be notified when the sub-agent completes. This is suitable for long-running tasks or when the user does not need immediate results. ([#5081](https://github.com/AstrBotDevs/AstrBot/issues/5081))
|
||||
- Added password rendering support in config schema: `string` and `text` fields can be rendered as password inputs in WebUI with `password: true` (or `render_type: "password"`).
|
||||
@@ -1,3 +1,10 @@
|
||||
# AstrBot 管理面板
|
||||
|
||||
基于 CodedThemes/Berry 模板开发。
|
||||
|
||||
## 环境变量
|
||||
|
||||
- `VITE_ASTRBOT_RELEASE_BASE_URL`(可选)
|
||||
- 默认值:`https://github.com/AstrBotDevs/AstrBot/releases`
|
||||
- 用途:管理面板内“更新到最新版本”外部跳转所使用的 release 基地址。集成方可按需覆盖(例如 Desktop 指向其自身发布页)。
|
||||
- 建议传入仓库的 `.../releases` 基地址(不带 `/latest`)。
|
||||
|
||||
Vendored
+8
@@ -1 +1,9 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
interface ImportMetaEnv {
|
||||
readonly VITE_ASTRBOT_RELEASE_BASE_URL?: string;
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
readonly env: ImportMetaEnv;
|
||||
}
|
||||
|
||||
@@ -18,7 +18,6 @@ import { RouterView } from 'vue-router';
|
||||
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
import { useToastStore } from '@/stores/toast'
|
||||
import WaitingForRestart from '@/components/shared/WaitingForRestart.vue'
|
||||
import { restartAstrBot } from '@/utils/restartAstrBot'
|
||||
|
||||
const toastStore = useToastStore()
|
||||
const globalWaitingRef = ref(null)
|
||||
@@ -33,12 +32,12 @@ const snackbarShow = computed({
|
||||
|
||||
onMounted(() => {
|
||||
const desktopBridge = window.astrbotDesktop
|
||||
if (!desktopBridge?.isElectron || !desktopBridge.onTrayRestartBackend) {
|
||||
if (!desktopBridge?.onTrayRestartBackend) {
|
||||
return
|
||||
}
|
||||
disposeTrayRestartListener = desktopBridge.onTrayRestartBackend(async () => {
|
||||
try {
|
||||
await restartAstrBot(globalWaitingRef.value)
|
||||
await globalWaitingRef.value?.check?.()
|
||||
} catch (error) {
|
||||
globalWaitingRef.value?.stop?.()
|
||||
console.error('Tray restart backend failed:', error)
|
||||
|
||||
@@ -77,12 +77,14 @@
|
||||
:stagedAudioUrl="stagedAudioUrl"
|
||||
:stagedFiles="stagedNonImageFiles"
|
||||
:disabled="isStreaming"
|
||||
:is-running="isStreaming || isConvRunning"
|
||||
:enableStreaming="enableStreaming"
|
||||
:isRecording="isRecording"
|
||||
:session-id="currSessionId || null"
|
||||
:current-session="getCurrentSession"
|
||||
:replyTo="replyTo"
|
||||
@send="handleSendMessage"
|
||||
@stop="handleStopMessage"
|
||||
@toggleStreaming="toggleStreaming"
|
||||
@removeImage="removeImage"
|
||||
@removeAudio="removeAudio"
|
||||
@@ -106,12 +108,14 @@
|
||||
:stagedAudioUrl="stagedAudioUrl"
|
||||
:stagedFiles="stagedNonImageFiles"
|
||||
:disabled="isStreaming"
|
||||
:is-running="isStreaming || isConvRunning"
|
||||
:enableStreaming="enableStreaming"
|
||||
:isRecording="isRecording"
|
||||
:session-id="currSessionId || null"
|
||||
:current-session="getCurrentSession"
|
||||
:replyTo="replyTo"
|
||||
@send="handleSendMessage"
|
||||
@stop="handleStopMessage"
|
||||
@toggleStreaming="toggleStreaming"
|
||||
@removeImage="removeImage"
|
||||
@removeAudio="removeAudio"
|
||||
@@ -134,12 +138,14 @@
|
||||
:stagedAudioUrl="stagedAudioUrl"
|
||||
:stagedFiles="stagedNonImageFiles"
|
||||
:disabled="isStreaming"
|
||||
:is-running="isStreaming || isConvRunning"
|
||||
:enableStreaming="enableStreaming"
|
||||
:isRecording="isRecording"
|
||||
:session-id="currSessionId || null"
|
||||
:current-session="getCurrentSession"
|
||||
:replyTo="replyTo"
|
||||
@send="handleSendMessage"
|
||||
@stop="handleStopMessage"
|
||||
@toggleStreaming="toggleStreaming"
|
||||
@removeImage="removeImage"
|
||||
@removeAudio="removeAudio"
|
||||
@@ -298,6 +304,7 @@ const {
|
||||
currentSessionProject,
|
||||
getSessionMessages: getSessionMsg,
|
||||
sendMessage: sendMsg,
|
||||
stopMessage: stopMsg,
|
||||
toggleStreaming
|
||||
} = useMessages(currSessionId, getMediaFile, updateSessionTitle, getSessions);
|
||||
|
||||
@@ -631,6 +638,10 @@ async function handleSendMessage() {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleStopMessage() {
|
||||
await stopMsg();
|
||||
}
|
||||
|
||||
// 路由变化监听
|
||||
watch(
|
||||
() => route.path,
|
||||
|
||||
@@ -94,8 +94,29 @@
|
||||
{{ isRecording ? tm('voice.speaking') : tm('voice.startRecording') }}
|
||||
</v-tooltip>
|
||||
</v-btn>
|
||||
<v-btn @click="$emit('send')" icon="mdi-send" variant="text" color="deep-purple"
|
||||
:disabled="!canSend" class="send-btn" size="small" />
|
||||
<v-btn
|
||||
icon
|
||||
v-if="isRunning"
|
||||
@click="$emit('stop')"
|
||||
variant="text"
|
||||
class="send-btn"
|
||||
size="small"
|
||||
>
|
||||
<v-icon icon="mdi-stop" variant="text" plain></v-icon>
|
||||
<v-tooltip activator="parent" location="top">
|
||||
{{ tm('input.stopGenerating') }}
|
||||
</v-tooltip>
|
||||
</v-btn>
|
||||
<v-btn
|
||||
v-else
|
||||
@click="$emit('send')"
|
||||
icon="mdi-send"
|
||||
variant="text"
|
||||
color="deep-purple"
|
||||
:disabled="!canSend"
|
||||
class="send-btn"
|
||||
size="small"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -160,6 +181,7 @@ interface Props {
|
||||
disabled: boolean;
|
||||
enableStreaming: boolean;
|
||||
isRecording: boolean;
|
||||
isRunning: boolean;
|
||||
sessionId?: string | null;
|
||||
currentSession?: Session | null;
|
||||
configId?: string | null;
|
||||
@@ -177,6 +199,7 @@ const props = withDefaults(defineProps<Props>(), {
|
||||
const emit = defineEmits<{
|
||||
'update:prompt': [value: string];
|
||||
send: [];
|
||||
stop: [];
|
||||
toggleStreaming: [];
|
||||
removeImage: [index: number];
|
||||
removeAudio: [];
|
||||
|
||||
@@ -77,6 +77,11 @@ import { computed, onMounted, ref, watch } from 'vue';
|
||||
import axios from 'axios';
|
||||
import { useToast } from '@/utils/toast';
|
||||
import { useModuleI18n } from '@/i18n/composables';
|
||||
import {
|
||||
getStoredDashboardUsername,
|
||||
getStoredSelectedChatConfigId,
|
||||
setStoredSelectedChatConfigId
|
||||
} from '@/utils/chatConfigBinding';
|
||||
|
||||
interface ConfigInfo {
|
||||
id: string;
|
||||
@@ -88,8 +93,6 @@ interface ConfigChangedPayload {
|
||||
agentRunnerType: string;
|
||||
}
|
||||
|
||||
const STORAGE_KEY = 'chat.selectedConfigId';
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
sessionId?: string | null;
|
||||
platformId?: string;
|
||||
@@ -128,7 +131,7 @@ const hasActiveSession = computed(() => !!normalizedSessionId.value);
|
||||
|
||||
const messageType = computed(() => (props.isGroup ? 'GroupMessage' : 'FriendMessage'));
|
||||
|
||||
const username = computed(() => localStorage.getItem('user') || 'guest');
|
||||
const username = computed(() => getStoredDashboardUsername());
|
||||
|
||||
const sessionKey = computed(() => {
|
||||
if (!normalizedSessionId.value) {
|
||||
@@ -265,10 +268,10 @@ async function confirmSelection() {
|
||||
}
|
||||
const previousId = selectedConfigId.value;
|
||||
await setSelection(tempSelectedConfig.value);
|
||||
localStorage.setItem(STORAGE_KEY, tempSelectedConfig.value);
|
||||
setStoredSelectedChatConfigId(tempSelectedConfig.value);
|
||||
const applied = await applySelectionToBackend(tempSelectedConfig.value);
|
||||
if (!applied) {
|
||||
localStorage.setItem(STORAGE_KEY, previousId);
|
||||
setStoredSelectedChatConfigId(previousId);
|
||||
await setSelection(previousId);
|
||||
}
|
||||
dialog.value = false;
|
||||
@@ -287,7 +290,7 @@ async function syncSelectionForSession() {
|
||||
await fetchRoutingEntries();
|
||||
const resolved = resolveConfigId(targetUmo.value);
|
||||
await setSelection(resolved);
|
||||
localStorage.setItem(STORAGE_KEY, resolved);
|
||||
setStoredSelectedChatConfigId(resolved);
|
||||
}
|
||||
|
||||
watch(
|
||||
@@ -299,7 +302,7 @@ watch(
|
||||
|
||||
onMounted(async () => {
|
||||
await fetchConfigList();
|
||||
const stored = props.initialConfigId || localStorage.getItem(STORAGE_KEY) || 'default';
|
||||
const stored = props.initialConfigId || getStoredSelectedChatConfigId();
|
||||
selectedConfigId.value = stored;
|
||||
await setSelection(stored);
|
||||
await syncSelectionForSession();
|
||||
|
||||
@@ -143,8 +143,8 @@
|
||||
</v-card>
|
||||
</v-menu>
|
||||
<v-btn :icon="getCopyIcon(index)" size="x-small" variant="text" class="copy-message-btn"
|
||||
:class="{ 'copy-success': isCopySuccess(index) }"
|
||||
@click="copyBotMessage(msg.content.message, index)" :title="t('core.common.copy')" />
|
||||
:class="{ 'copy-success': isCopySuccess(index), 'copy-failed': isCopyFailure(index) }"
|
||||
@click="copyBotMessage(msg.content.message, index)" :title="getCopyTitle(index)" />
|
||||
<v-btn icon="mdi-reply-outline" size="x-small" variant="text" class="reply-message-btn"
|
||||
@click="$emit('replyMessage', msg, index)" :title="tm('actions.reply')" />
|
||||
|
||||
@@ -185,6 +185,7 @@ import 'markstream-vue/index.css'
|
||||
import 'katex/dist/katex.min.css'
|
||||
import 'highlight.js/styles/github.css';
|
||||
import axios from 'axios';
|
||||
import { useToast } from '@/utils/toast'
|
||||
import ReasoningBlock from './message_list_comps/ReasoningBlock.vue';
|
||||
import MessagePartsRenderer from './message_list_comps/MessagePartsRenderer.vue';
|
||||
import RefNode from './message_list_comps/RefNode.vue';
|
||||
@@ -226,10 +227,12 @@ export default {
|
||||
setup() {
|
||||
const { t } = useI18n();
|
||||
const { tm } = useModuleI18n('features/chat');
|
||||
const toast = useToast()
|
||||
|
||||
return {
|
||||
t,
|
||||
tm
|
||||
tm,
|
||||
toast
|
||||
};
|
||||
},
|
||||
provide() {
|
||||
@@ -241,6 +244,7 @@ export default {
|
||||
data() {
|
||||
return {
|
||||
copiedMessages: new Set(),
|
||||
copyFailedMessages: new Set(),
|
||||
isUserNearBottom: true,
|
||||
scrollThreshold: 1,
|
||||
scrollTimer: null,
|
||||
@@ -496,91 +500,142 @@ export default {
|
||||
},
|
||||
|
||||
// 复制代码到剪贴板
|
||||
copyCodeToClipboard(code) {
|
||||
navigator.clipboard.writeText(code).then(() => {
|
||||
console.log('代码已复制到剪贴板');
|
||||
}).catch(err => {
|
||||
console.error('复制失败:', err);
|
||||
// 如果现代API失败,使用传统方法
|
||||
const textArea = document.createElement('textarea');
|
||||
textArea.value = code;
|
||||
document.body.appendChild(textArea);
|
||||
textArea.select();
|
||||
tryExecCommandCopy(text) {
|
||||
let textArea = null;
|
||||
try {
|
||||
document.execCommand('copy');
|
||||
console.log('代码已复制到剪贴板 (fallback)');
|
||||
} catch (fallbackErr) {
|
||||
console.error('复制失败 (fallback):', fallbackErr);
|
||||
textArea = document.createElement('textarea');
|
||||
textArea.value = text;
|
||||
document.body.appendChild(textArea);
|
||||
textArea.focus();
|
||||
textArea.select();
|
||||
const ok = document.execCommand('copy');
|
||||
return ok;
|
||||
} catch (_) {
|
||||
return false;
|
||||
} finally {
|
||||
try {
|
||||
textArea?.remove?.();
|
||||
} catch (_) {
|
||||
// ignore cleanup errors
|
||||
}
|
||||
}
|
||||
document.body.removeChild(textArea);
|
||||
});
|
||||
},
|
||||
|
||||
// 复制bot消息到剪贴板
|
||||
copyBotMessage(messageParts, messageIndex) {
|
||||
let textToCopy = '';
|
||||
async copyTextToClipboard(text) {
|
||||
// 优先使用同步复制,尽量保留用户手势上下文;
|
||||
// 在非安全来源(例如通过局域网 IP + vite --host)时成功率更高。
|
||||
if (this.tryExecCommandCopy(text)) {
|
||||
return { ok: true, method: 'execCommand' };
|
||||
}
|
||||
|
||||
if (navigator.clipboard?.writeText) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
return { ok: true, method: 'clipboard' };
|
||||
} catch (error) {
|
||||
return { ok: false, method: 'clipboard', error };
|
||||
}
|
||||
}
|
||||
|
||||
return { ok: false, method: 'unavailable' };
|
||||
},
|
||||
|
||||
async copyWithFeedback(text, messageIndex = null) {
|
||||
const result = await this.copyTextToClipboard(text);
|
||||
const ok = !!result?.ok;
|
||||
|
||||
if (messageIndex !== null && messageIndex !== undefined) {
|
||||
if (ok) this.showCopySuccess(messageIndex);
|
||||
else this.showCopyFailure(messageIndex);
|
||||
}
|
||||
|
||||
if (ok) {
|
||||
this.toast?.success?.(this.t('core.common.copied'));
|
||||
} else {
|
||||
this.toast?.error?.(this.t('core.common.copyFailed'));
|
||||
}
|
||||
|
||||
return result;
|
||||
},
|
||||
|
||||
buildCopyTextFromParts(messageParts) {
|
||||
if (typeof messageParts === 'string') {
|
||||
return messageParts.trim();
|
||||
}
|
||||
if (!Array.isArray(messageParts)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
if (Array.isArray(messageParts)) {
|
||||
// 提取所有文本内容
|
||||
const textContents = messageParts
|
||||
.filter(part => part.type === 'plain' && part.text)
|
||||
.filter(part => part && typeof part === 'object' && part.type === 'plain' && part.text)
|
||||
.map(part => part.text);
|
||||
textToCopy = textContents.join('\n');
|
||||
|
||||
// 检查是否有图片
|
||||
const imageCount = messageParts.filter(part => part.type === 'image' && part.embedded_url).length;
|
||||
let textToCopy = textContents.join('\n');
|
||||
|
||||
const imageCount = messageParts.filter(part => part?.type === 'image' && part.embedded_url).length;
|
||||
if (imageCount > 0) {
|
||||
if (textToCopy) textToCopy += '\n\n';
|
||||
textToCopy += `[包含 ${imageCount} 张图片]`;
|
||||
}
|
||||
|
||||
// 检查是否有音频
|
||||
const hasAudio = messageParts.some(part => part.type === 'record' && part.embedded_url);
|
||||
const hasAudio = messageParts.some(part => part?.type === 'record' && part.embedded_url);
|
||||
if (hasAudio) {
|
||||
if (textToCopy) textToCopy += '\n\n';
|
||||
textToCopy += '[包含音频内容]';
|
||||
}
|
||||
}
|
||||
|
||||
// 如果没有任何内容,使用默认文本
|
||||
if (!textToCopy.trim()) {
|
||||
textToCopy = '[媒体内容]';
|
||||
}
|
||||
return String(textToCopy || '').trim();
|
||||
},
|
||||
|
||||
navigator.clipboard.writeText(textToCopy).then(() => {
|
||||
console.log('消息已复制到剪贴板');
|
||||
this.showCopySuccess(messageIndex);
|
||||
}).catch(err => {
|
||||
console.error('复制失败:', err);
|
||||
// 如果现代API失败,使用传统方法
|
||||
const textArea = document.createElement('textarea');
|
||||
textArea.value = textToCopy;
|
||||
document.body.appendChild(textArea);
|
||||
textArea.select();
|
||||
try {
|
||||
document.execCommand('copy');
|
||||
console.log('消息已复制到剪贴板 (fallback)');
|
||||
this.showCopySuccess(messageIndex);
|
||||
} catch (fallbackErr) {
|
||||
console.error('复制失败 (fallback):', fallbackErr);
|
||||
}
|
||||
document.body.removeChild(textArea);
|
||||
});
|
||||
async copyCodeToClipboard(code) {
|
||||
const text = String(code ?? '');
|
||||
if (!text) return { ok: false, method: 'empty' };
|
||||
return await this.copyWithFeedback(text, null);
|
||||
},
|
||||
|
||||
// 复制bot消息到剪贴板
|
||||
async copyBotMessage(messageParts, messageIndex) {
|
||||
let textToCopy = this.buildCopyTextFromParts(messageParts);
|
||||
if (!textToCopy) textToCopy = '[媒体内容]';
|
||||
await this.copyWithFeedback(textToCopy, messageIndex);
|
||||
},
|
||||
|
||||
// 显示复制成功提示
|
||||
showCopySuccess(messageIndex) {
|
||||
if (this.copyFailedMessages.has(messageIndex)) {
|
||||
this.copyFailedMessages.delete(messageIndex);
|
||||
this.copyFailedMessages = new Set(this.copyFailedMessages);
|
||||
}
|
||||
this.copiedMessages.add(messageIndex);
|
||||
this.copiedMessages = new Set(this.copiedMessages);
|
||||
|
||||
// 2秒后移除成功状态
|
||||
setTimeout(() => {
|
||||
this.copiedMessages.delete(messageIndex);
|
||||
this.copiedMessages = new Set(this.copiedMessages);
|
||||
}, 2000);
|
||||
},
|
||||
|
||||
// 显示复制失败提示
|
||||
showCopyFailure(messageIndex) {
|
||||
if (this.copiedMessages.has(messageIndex)) {
|
||||
this.copiedMessages.delete(messageIndex);
|
||||
this.copiedMessages = new Set(this.copiedMessages);
|
||||
}
|
||||
this.copyFailedMessages.add(messageIndex);
|
||||
this.copyFailedMessages = new Set(this.copyFailedMessages);
|
||||
|
||||
setTimeout(() => {
|
||||
this.copyFailedMessages.delete(messageIndex);
|
||||
this.copyFailedMessages = new Set(this.copyFailedMessages);
|
||||
}, 2000);
|
||||
},
|
||||
|
||||
// 获取复制按钮图标
|
||||
getCopyIcon(messageIndex) {
|
||||
return this.copiedMessages.has(messageIndex) ? 'mdi-check' : 'mdi-content-copy';
|
||||
if (this.copiedMessages.has(messageIndex)) return 'mdi-check';
|
||||
if (this.copyFailedMessages.has(messageIndex)) return 'mdi-alert-circle-outline';
|
||||
return 'mdi-content-copy';
|
||||
},
|
||||
|
||||
// 检查是否为复制成功状态
|
||||
@@ -588,6 +643,18 @@ export default {
|
||||
return this.copiedMessages.has(messageIndex);
|
||||
},
|
||||
|
||||
// 检查是否为复制失败状态
|
||||
isCopyFailure(messageIndex) {
|
||||
return this.copyFailedMessages.has(messageIndex);
|
||||
},
|
||||
|
||||
// 获取复制按钮提示文本
|
||||
getCopyTitle(messageIndex) {
|
||||
if (this.isCopySuccess(messageIndex)) return this.t('core.common.copied');
|
||||
if (this.isCopyFailure(messageIndex)) return this.t('core.common.copyFailed');
|
||||
return this.t('core.common.copy');
|
||||
},
|
||||
|
||||
// 获取复制图标SVG
|
||||
getCopyIconSvg() {
|
||||
return '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path></svg>';
|
||||
@@ -598,6 +665,11 @@ export default {
|
||||
return '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="20,6 9,17 4,12"></polyline></svg>';
|
||||
},
|
||||
|
||||
// 获取失败图标SVG
|
||||
getErrorIconSvg() {
|
||||
return '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"></circle><line x1="12" y1="8" x2="12" y2="13"></line><circle cx="12" cy="16.5" r="1"></circle></svg>';
|
||||
},
|
||||
|
||||
// 初始化代码块复制按钮
|
||||
initCodeCopyButtons() {
|
||||
this.$nextTick(() => {
|
||||
@@ -608,15 +680,19 @@ export default {
|
||||
const button = document.createElement('button');
|
||||
button.className = 'copy-code-btn';
|
||||
button.innerHTML = this.getCopyIconSvg();
|
||||
button.title = '复制代码';
|
||||
button.addEventListener('click', () => {
|
||||
this.copyCodeToClipboard(codeBlock.textContent);
|
||||
// 显示复制成功提示
|
||||
button.innerHTML = this.getSuccessIconSvg();
|
||||
button.style.color = '#4caf50';
|
||||
button.title = this.t('core.common.copy');
|
||||
button.addEventListener('click', async () => {
|
||||
const res = await this.copyCodeToClipboard(codeBlock.textContent || '');
|
||||
const ok = !!res?.ok;
|
||||
button.innerHTML = ok ? this.getSuccessIconSvg() : this.getErrorIconSvg();
|
||||
button.style.color = ok
|
||||
? 'rgb(var(--v-theme-success))'
|
||||
: 'rgb(var(--v-theme-error))';
|
||||
button.setAttribute("title", this.t(`core.common.${ok ? "copied" : "copyFailed"}`));
|
||||
setTimeout(() => {
|
||||
button.innerHTML = this.getCopyIconSvg();
|
||||
button.style.color = '';
|
||||
button.setAttribute("title", this.t('core.common.copy'));
|
||||
}, 2000);
|
||||
});
|
||||
pre.style.position = 'relative';
|
||||
@@ -1077,13 +1153,23 @@ export default {
|
||||
}
|
||||
|
||||
.copy-message-btn.copy-success {
|
||||
color: #4caf50;
|
||||
color: rgb(var(--v-theme-success));
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.copy-message-btn.copy-success:hover {
|
||||
color: #4caf50;
|
||||
background-color: rgba(76, 175, 80, 0.1);
|
||||
color: rgb(var(--v-theme-success));
|
||||
background-color: rgba(var(--v-theme-success), 0.1);
|
||||
}
|
||||
|
||||
.copy-message-btn.copy-failed {
|
||||
color: rgb(var(--v-theme-error));
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.copy-message-btn.copy-failed:hover {
|
||||
color: rgb(var(--v-theme-error));
|
||||
background-color: rgba(var(--v-theme-error), 0.1);
|
||||
}
|
||||
|
||||
.reply-message-btn {
|
||||
|
||||
@@ -23,12 +23,14 @@
|
||||
:stagedImagesUrl="stagedImagesUrl"
|
||||
:stagedAudioUrl="stagedAudioUrl"
|
||||
:disabled="isStreaming"
|
||||
:is-running="isStreaming || isConvRunning"
|
||||
:enableStreaming="enableStreaming"
|
||||
:isRecording="isRecording"
|
||||
:session-id="currSessionId || null"
|
||||
:current-session="getCurrentSession"
|
||||
:config-id="configId"
|
||||
@send="handleSendMessage"
|
||||
@stop="handleStopMessage"
|
||||
@toggleStreaming="toggleStreaming"
|
||||
@removeImage="removeImage"
|
||||
@removeAudio="removeAudio"
|
||||
@@ -70,6 +72,7 @@ import { useMessages } from '@/composables/useMessages';
|
||||
import { useMediaHandling } from '@/composables/useMediaHandling';
|
||||
import { useRecording } from '@/composables/useRecording';
|
||||
import { useToast } from '@/utils/toast';
|
||||
import { buildWebchatUmoDetails } from '@/utils/chatConfigBinding';
|
||||
|
||||
interface Props {
|
||||
configId?: string | null;
|
||||
@@ -82,6 +85,7 @@ const props = withDefaults(defineProps<Props>(), {
|
||||
const { t } = useI18n();
|
||||
const { error: showError } = useToast();
|
||||
|
||||
|
||||
// UI 状态
|
||||
const imagePreviewDialog = ref(false);
|
||||
const previewImageUrl = ref('');
|
||||
@@ -90,11 +94,33 @@ const previewImageUrl = ref('');
|
||||
const currSessionId = ref('');
|
||||
const getCurrentSession = computed(() => null); // 独立测试模式不需要会话信息
|
||||
|
||||
async function bindConfigToSession(sessionId: string) {
|
||||
const confId = (props.configId || '').trim();
|
||||
if (!confId || confId === 'default') {
|
||||
return;
|
||||
}
|
||||
|
||||
const umoDetails = buildWebchatUmoDetails(sessionId, false);
|
||||
|
||||
await axios.post('/api/config/umo_abconf_route/update', {
|
||||
umo: umoDetails.umo,
|
||||
conf_id: confId
|
||||
});
|
||||
}
|
||||
|
||||
async function newSession() {
|
||||
try {
|
||||
const response = await axios.get('/api/chat/new_session');
|
||||
const sessionId = response.data.data.session_id;
|
||||
|
||||
try {
|
||||
await bindConfigToSession(sessionId);
|
||||
} catch (err) {
|
||||
console.error('Failed to bind config to session', err);
|
||||
}
|
||||
|
||||
currSessionId.value = sessionId;
|
||||
|
||||
return sessionId;
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
@@ -132,6 +158,7 @@ const {
|
||||
enableStreaming,
|
||||
getSessionMessages: getSessionMsg,
|
||||
sendMessage: sendMsg,
|
||||
stopMessage: stopMsg,
|
||||
toggleStreaming
|
||||
} = useMessages(currSessionId, getMediaFile, updateSessionTitle, getSessions);
|
||||
|
||||
@@ -212,6 +239,10 @@ async function handleSendMessage() {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleStopMessage() {
|
||||
await stopMsg();
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
// 独立模式在挂载时创建新会话
|
||||
try {
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
<script setup>
|
||||
import { ref, computed } from "vue";
|
||||
import { useModuleI18n } from "@/i18n/composables";
|
||||
import { getPlatformDisplayName } from "@/utils/platformUtils";
|
||||
import PluginPlatformChip from "@/components/shared/PluginPlatformChip.vue";
|
||||
|
||||
const { tm } = useModuleI18n("features/extension");
|
||||
|
||||
defineProps({
|
||||
const props = defineProps({
|
||||
plugin: {
|
||||
type: Object,
|
||||
required: true,
|
||||
@@ -26,11 +27,9 @@ const normalizePlatformList = (platforms) => {
|
||||
return platforms.filter((item) => typeof item === "string");
|
||||
};
|
||||
|
||||
const getPlatformDisplayList = (platforms) => {
|
||||
return normalizePlatformList(platforms).map((platformId) =>
|
||||
getPlatformDisplayName(platformId),
|
||||
);
|
||||
};
|
||||
const platformDisplayList = computed(() =>
|
||||
normalizePlatformList(props.plugin?.support_platforms),
|
||||
);
|
||||
|
||||
const handleInstall = (plugin) => {
|
||||
emit("install", plugin);
|
||||
@@ -165,9 +164,9 @@ const handleInstall = (plugin) => {
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="plugin.astrbot_version || normalizePlatformList(plugin.support_platforms).length"
|
||||
v-if="plugin.astrbot_version || platformDisplayList.length"
|
||||
class="d-flex align-center flex-wrap"
|
||||
style="gap: 4px; margin-top: 4px; margin-bottom: 4px;"
|
||||
style="gap: 4px; margin-top: 4px; margin-bottom: 4px"
|
||||
>
|
||||
<v-chip
|
||||
v-if="plugin.astrbot_version"
|
||||
@@ -178,26 +177,11 @@ const handleInstall = (plugin) => {
|
||||
>
|
||||
AstrBot: {{ plugin.astrbot_version }}
|
||||
</v-chip>
|
||||
<v-chip
|
||||
v-if="normalizePlatformList(plugin.support_platforms).length"
|
||||
<PluginPlatformChip
|
||||
:platforms="plugin.support_platforms"
|
||||
size="x-small"
|
||||
color="info"
|
||||
variant="outlined"
|
||||
style="height: 20px"
|
||||
>
|
||||
<v-tooltip location="top">
|
||||
<template v-slot:activator="{ props: tooltipProps }">
|
||||
<span v-bind="tooltipProps">
|
||||
{{
|
||||
tm("card.status.supportPlatformsCount", {
|
||||
count: getPlatformDisplayList(plugin.support_platforms).length,
|
||||
})
|
||||
}}
|
||||
</span>
|
||||
</template>
|
||||
<span>{{ getPlatformDisplayList(plugin.support_platforms).join(", ") }}</span>
|
||||
</v-tooltip>
|
||||
</v-chip>
|
||||
:chip-style="{ height: '20px' }"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="d-flex align-center" style="gap: 8px; margin-top: auto">
|
||||
|
||||
@@ -2,8 +2,9 @@
|
||||
import { ref, computed, inject } from "vue";
|
||||
import { useCustomizerStore } from "@/stores/customizer";
|
||||
import { useModuleI18n } from "@/i18n/composables";
|
||||
import { getPlatformDisplayName } from "@/utils/platformUtils";
|
||||
import { getPlatformDisplayName, getPlatformIcon } from "@/utils/platformUtils";
|
||||
import UninstallConfirmDialog from "./UninstallConfirmDialog.vue";
|
||||
import PluginPlatformChip from "./PluginPlatformChip.vue";
|
||||
|
||||
const props = defineProps({
|
||||
extension: {
|
||||
@@ -107,8 +108,9 @@ const viewChangelog = () => {
|
||||
|
||||
<template>
|
||||
<v-card
|
||||
class="mx-auto d-flex flex-column"
|
||||
class="mx-auto d-flex flex-column h-100"
|
||||
elevation="0"
|
||||
height="100%"
|
||||
:style="{
|
||||
position: 'relative',
|
||||
backgroundColor:
|
||||
@@ -336,27 +338,10 @@ const viewChangelog = () => {
|
||||
>
|
||||
{{ tag === "danger" ? tm("tags.danger") : tag }}
|
||||
</v-chip>
|
||||
<v-chip
|
||||
v-if="supportPlatforms.length"
|
||||
color="info"
|
||||
variant="outlined"
|
||||
label
|
||||
size="small"
|
||||
<PluginPlatformChip
|
||||
:platforms="supportPlatforms"
|
||||
class="ml-2"
|
||||
>
|
||||
<v-tooltip location="top">
|
||||
<template v-slot:activator="{ props: tooltipProps }">
|
||||
<span v-bind="tooltipProps">
|
||||
{{
|
||||
tm("card.status.supportPlatformsCount", {
|
||||
count: supportPlatformDisplayNames.length,
|
||||
})
|
||||
}}
|
||||
</span>
|
||||
</template>
|
||||
<span>{{ supportPlatformDisplayNames.join(", ") }}</span>
|
||||
</v-tooltip>
|
||||
</v-chip>
|
||||
/>
|
||||
<v-chip
|
||||
v-if="astrbotVersionRequirement"
|
||||
color="secondary"
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from "vue";
|
||||
import { getPlatformDisplayName, getPlatformIcon } from "@/utils/platformUtils";
|
||||
import { useModuleI18n } from "@/i18n/composables";
|
||||
|
||||
const props = defineProps({
|
||||
platforms: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
size: {
|
||||
type: String,
|
||||
default: "small",
|
||||
},
|
||||
chipStyle: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
},
|
||||
});
|
||||
|
||||
const { tm } = useModuleI18n("features/extension");
|
||||
|
||||
const showMenu = ref(false);
|
||||
|
||||
const platformDetails = computed(() => {
|
||||
if (!Array.isArray(props.platforms)) return [];
|
||||
return props.platforms
|
||||
.filter((item) => typeof item === "string")
|
||||
.map((platformId) => ({
|
||||
name: getPlatformDisplayName(platformId as string),
|
||||
icon: getPlatformIcon(platformId as string),
|
||||
}));
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="d-inline-block">
|
||||
<v-chip
|
||||
v-if="platformDetails.length"
|
||||
color="info"
|
||||
variant="outlined"
|
||||
label
|
||||
:size="size"
|
||||
class="plugin-platform-chip"
|
||||
:style="{ cursor: 'pointer', ...chipStyle }"
|
||||
@click.stop="showMenu = !showMenu"
|
||||
>
|
||||
<div class="d-flex align-center" style="gap: 2px">
|
||||
<!-- 显示图标,最多 5 个 -->
|
||||
<div class="d-flex align-center mr-1" v-if="platformDetails.some(p => p.icon)">
|
||||
<v-avatar
|
||||
v-for="(platform, index) in platformDetails.slice(0, 5)"
|
||||
:key="index"
|
||||
:size="size === 'x-small' ? 12 : 14"
|
||||
class="platform-mini-icon"
|
||||
:style="{ marginLeft: index > 0 ? '-4px' : '0', zIndex: 10 - index }"
|
||||
>
|
||||
<v-img v-if="platform.icon" :src="platform.icon"></v-img>
|
||||
<v-icon v-else icon="mdi-circle-small" :size="size === 'x-small' ? 8 : 10"></v-icon>
|
||||
</v-avatar>
|
||||
</div>
|
||||
|
||||
<span class="text-caption font-weight-bold">
|
||||
{{
|
||||
tm("card.status.supportPlatformsCount", {
|
||||
count: platformDetails.length,
|
||||
})
|
||||
}}
|
||||
</span>
|
||||
|
||||
<v-icon
|
||||
:icon="showMenu ? 'mdi-chevron-up' : 'mdi-chevron-down'"
|
||||
:size="size === 'x-small' ? 14 : 16"
|
||||
class="ml-n1"
|
||||
></v-icon>
|
||||
</div>
|
||||
|
||||
<v-menu
|
||||
v-model="showMenu"
|
||||
activator="parent"
|
||||
location="top"
|
||||
:close-on-content-click="false"
|
||||
transition="scale-transition"
|
||||
open-on-hover
|
||||
>
|
||||
<v-list density="compact" border elevation="12" class="rounded-lg pa-1">
|
||||
<v-list-item
|
||||
v-for="platform in platformDetails"
|
||||
:key="platform.name"
|
||||
min-height="24"
|
||||
class="px-2"
|
||||
>
|
||||
<template v-slot:prepend>
|
||||
<v-avatar size="14" class="mr-2" v-if="platform.icon">
|
||||
<v-img :src="platform.icon"></v-img>
|
||||
</v-avatar>
|
||||
<v-icon v-else icon="mdi-platform" size="12" class="mr-2"></v-icon>
|
||||
</template>
|
||||
<v-list-item-title class="text-caption font-weight-bold" style="font-size: 0.75rem !important">
|
||||
{{ platform.name }}
|
||||
</v-list-item-title>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
</v-menu>
|
||||
</v-chip>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.plugin-platform-chip {
|
||||
padding-left: 6px !important;
|
||||
padding-right: 4px !important;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.platform-mini-icon {
|
||||
border: 1px solid rgba(var(--v-theme-info), 0.3);
|
||||
background: rgba(var(--v-theme-surface));
|
||||
}
|
||||
|
||||
.plugin-platform-chip:hover {
|
||||
background: rgba(var(--v-theme-info), 0.08);
|
||||
}
|
||||
</style>
|
||||
@@ -82,6 +82,10 @@ export function useMessages(
|
||||
const activeSSECount = ref(0);
|
||||
const enableStreaming = ref(true);
|
||||
const attachmentCache = new Map<string, string>(); // attachment_id -> blob URL
|
||||
const currentRequestController = ref<AbortController | null>(null);
|
||||
const currentReader = ref<ReadableStreamDefaultReader<Uint8Array> | null>(null);
|
||||
const currentRunningSessionId = ref('');
|
||||
const userStopRequested = ref(false);
|
||||
|
||||
// 当前会话的项目信息
|
||||
const currentSessionProject = ref<{ project_id: string; title: string; emoji: string } | null>(null);
|
||||
@@ -289,6 +293,8 @@ export function useMessages(
|
||||
if (activeSSECount.value === 1) {
|
||||
isConvRunning.value = true;
|
||||
}
|
||||
userStopRequested.value = false;
|
||||
currentRunningSessionId.value = currSessionId.value;
|
||||
|
||||
// 收集所有 attachment_id
|
||||
const files = stagedFiles.map(f => f.attachment_id);
|
||||
@@ -330,12 +336,15 @@ export function useMessages(
|
||||
messageToSend = prompt;
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
currentRequestController.value = controller;
|
||||
const response = await fetch('/api/chat/send', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': 'Bearer ' + localStorage.getItem('token')
|
||||
},
|
||||
signal: controller.signal,
|
||||
body: JSON.stringify({
|
||||
message: messageToSend,
|
||||
session_id: currSessionId.value,
|
||||
@@ -350,6 +359,7 @@ export function useMessages(
|
||||
}
|
||||
|
||||
const reader = response.body!.getReader();
|
||||
currentReader.value = reader;
|
||||
const decoder = new TextDecoder();
|
||||
let in_streaming = false;
|
||||
let message_obj: MessageContent | null = null;
|
||||
@@ -388,6 +398,10 @@ export function useMessages(
|
||||
continue;
|
||||
}
|
||||
|
||||
if (chunk_json.type === 'session_id') {
|
||||
continue;
|
||||
}
|
||||
|
||||
const lastMsg = messages.value[messages.value.length - 1];
|
||||
if (lastMsg?.content?.isLoading) {
|
||||
messages.value.pop();
|
||||
@@ -556,7 +570,9 @@ export function useMessages(
|
||||
}
|
||||
}
|
||||
} catch (readError) {
|
||||
if (!userStopRequested.value) {
|
||||
console.error('SSE读取错误:', readError);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -565,7 +581,9 @@ export function useMessages(
|
||||
onSessionsUpdate();
|
||||
|
||||
} catch (err) {
|
||||
if (!userStopRequested.value) {
|
||||
console.error('发送消息失败:', err);
|
||||
}
|
||||
// 移除加载占位符
|
||||
const lastMsg = messages.value[messages.value.length - 1];
|
||||
if (lastMsg?.content?.isLoading) {
|
||||
@@ -573,6 +591,10 @@ export function useMessages(
|
||||
}
|
||||
} finally {
|
||||
isStreaming.value = false;
|
||||
currentReader.value = null;
|
||||
currentRequestController.value = null;
|
||||
currentRunningSessionId.value = '';
|
||||
userStopRequested.value = false;
|
||||
activeSSECount.value--;
|
||||
if (activeSSECount.value === 0) {
|
||||
isConvRunning.value = false;
|
||||
@@ -580,6 +602,33 @@ export function useMessages(
|
||||
}
|
||||
}
|
||||
|
||||
async function stopMessage() {
|
||||
const sessionId = currentRunningSessionId.value || currSessionId.value;
|
||||
if (!sessionId) {
|
||||
return;
|
||||
}
|
||||
|
||||
userStopRequested.value = true;
|
||||
try {
|
||||
await axios.post('/api/chat/stop', {
|
||||
session_id: sessionId
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('停止会话失败:', err);
|
||||
}
|
||||
|
||||
try {
|
||||
await currentReader.value?.cancel();
|
||||
} catch (err) {
|
||||
// ignore reader cancel failures
|
||||
}
|
||||
currentReader.value = null;
|
||||
currentRequestController.value?.abort();
|
||||
currentRequestController.value = null;
|
||||
|
||||
isStreaming.value = false;
|
||||
}
|
||||
|
||||
return {
|
||||
messages,
|
||||
isStreaming,
|
||||
@@ -588,6 +637,7 @@ export function useMessages(
|
||||
currentSessionProject,
|
||||
getSessionMessages,
|
||||
sendMessage,
|
||||
stopMessage,
|
||||
toggleStreaming,
|
||||
getAttachment
|
||||
};
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { ref, computed } from 'vue';
|
||||
import axios from 'axios';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { buildWebchatUmoDetails, getStoredSelectedChatConfigId } from '@/utils/chatConfigBinding';
|
||||
|
||||
export interface Session {
|
||||
session_id: string;
|
||||
@@ -62,10 +63,25 @@ export function useSessions(chatboxMode: boolean = false) {
|
||||
|
||||
async function newSession() {
|
||||
try {
|
||||
const selectedConfigId = getStoredSelectedChatConfigId();
|
||||
const response = await axios.get('/api/chat/new_session');
|
||||
const sessionId = response.data.data.session_id;
|
||||
const platformId = response.data.data.platform_id;
|
||||
|
||||
currSessionId.value = sessionId;
|
||||
|
||||
if (selectedConfigId && selectedConfigId !== 'default' && platformId === 'webchat') {
|
||||
try {
|
||||
const umoDetails = buildWebchatUmoDetails(sessionId, false);
|
||||
await axios.post('/api/config/umo_abconf_route/update', {
|
||||
umo: umoDetails.umo,
|
||||
conf_id: selectedConfigId
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Failed to bind config to session', err);
|
||||
}
|
||||
}
|
||||
|
||||
// 更新 URL
|
||||
const basePath = chatboxMode ? '/chatbox' : '/chat';
|
||||
router.push(`${basePath}/${sessionId}`);
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
"close": "Close",
|
||||
"copy": "Copy",
|
||||
"copied": "Copied",
|
||||
"copyFailed": "Copy failed",
|
||||
"delete": "Delete",
|
||||
"edit": "Edit",
|
||||
"add": "Add",
|
||||
|
||||
@@ -9,7 +9,8 @@
|
||||
"voice": "Voice Input",
|
||||
"recordingPrompt": "Recording, please speak...",
|
||||
"chatPrompt": "Let's chat!",
|
||||
"dropToUpload": "Drop files to upload"
|
||||
"dropToUpload": "Drop files to upload",
|
||||
"stopGenerating": "Stop generating"
|
||||
},
|
||||
"message": {
|
||||
"user": "User",
|
||||
|
||||
@@ -1178,9 +1178,17 @@
|
||||
},
|
||||
"anth_thinking_config": {
|
||||
"description": "Thinking Config",
|
||||
"type": {
|
||||
"description": "Thinking Type",
|
||||
"hint": "Set 'adaptive' for Opus 4.6+ / Sonnet 4.6+ (recommended). Leave empty to use manual budget mode. See: https://platform.claude.com/docs/en/build-with-claude/adaptive-thinking"
|
||||
},
|
||||
"budget": {
|
||||
"description": "Thinking Budget",
|
||||
"hint": "Anthropic thinking.budget_tokens param. Must >= 1024. See: https://platform.claude.com/docs/en/build-with-claude/extended-thinking"
|
||||
"hint": "Anthropic thinking.budget_tokens param. Must >= 1024. Only used when type is empty. Deprecated on Opus 4.6 / Sonnet 4.6. See: https://platform.claude.com/docs/en/build-with-claude/extended-thinking"
|
||||
},
|
||||
"effort": {
|
||||
"description": "Effort Level",
|
||||
"hint": "Controls thinking depth when type is 'adaptive'. 'high' is the default. 'max' is Opus 4.6 only. See: https://platform.claude.com/docs/en/build-with-claude/effort"
|
||||
}
|
||||
},
|
||||
"minimax-group-id": {
|
||||
|
||||
@@ -128,5 +128,53 @@
|
||||
"renameFailed": "Rename failed",
|
||||
"ftpHint": "For large backup files, you can also upload directly to the data/backups directory via FTP/SFTP"
|
||||
}
|
||||
},
|
||||
"apiKey": {
|
||||
"title": "API Keys",
|
||||
"manageTitle": "Developer Access Keys",
|
||||
"subtitle": "Create API keys for external developers to call open HTTP APIs.",
|
||||
"name": "Key Name",
|
||||
"expiresInDays": "Expiration",
|
||||
"expiryOptions": {
|
||||
"day1": "1 day",
|
||||
"day7": "7 days",
|
||||
"day30": "30 days",
|
||||
"day90": "90 days",
|
||||
"permanent": "Permanent"
|
||||
},
|
||||
"permanentWarning": "Permanent API keys are high risk. Store them securely and use only when necessary.",
|
||||
"scopes": "Scopes",
|
||||
"create": "Create API Key",
|
||||
"revoke": "Revoke",
|
||||
"delete": "Delete",
|
||||
"copy": "Copy",
|
||||
"docsLink": "Open docs",
|
||||
"plaintextHint": "Save this key now. The plaintext will not be shown again.",
|
||||
"empty": "No API keys",
|
||||
"status": {
|
||||
"active": "Active",
|
||||
"inactive": "Inactive"
|
||||
},
|
||||
"table": {
|
||||
"name": "Name",
|
||||
"prefix": "Prefix",
|
||||
"scopes": "Scopes",
|
||||
"status": "Status",
|
||||
"lastUsed": "Last Used",
|
||||
"createdAt": "Created At",
|
||||
"actions": "Actions"
|
||||
},
|
||||
"messages": {
|
||||
"loadFailed": "Failed to load API keys",
|
||||
"scopeRequired": "Please select at least one scope",
|
||||
"createSuccess": "API key created",
|
||||
"createFailed": "Failed to create API key",
|
||||
"revokeSuccess": "API key revoked",
|
||||
"revokeFailed": "Failed to revoke API key",
|
||||
"deleteSuccess": "API key deleted",
|
||||
"deleteFailed": "Failed to delete API key",
|
||||
"copySuccess": "API key copied",
|
||||
"copyFailed": "Failed to copy API key"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
"close": "关闭",
|
||||
"copy": "复制",
|
||||
"copied": "已复制",
|
||||
"copyFailed": "复制失败",
|
||||
"delete": "删除",
|
||||
"edit": "编辑",
|
||||
"add": "添加",
|
||||
|
||||
@@ -9,7 +9,8 @@
|
||||
"voice": "语音输入",
|
||||
"recordingPrompt": "录音中,请说话...",
|
||||
"chatPrompt": "聊天吧!",
|
||||
"dropToUpload": "松开鼠标上传文件"
|
||||
"dropToUpload": "松开鼠标上传文件",
|
||||
"stopGenerating": "停止生成"
|
||||
},
|
||||
"message": {
|
||||
"user": "用户",
|
||||
|
||||
@@ -1181,9 +1181,17 @@
|
||||
},
|
||||
"anth_thinking_config": {
|
||||
"description": "思考配置",
|
||||
"type": {
|
||||
"description": "思考类型",
|
||||
"hint": "设为 'adaptive' 以使用自适应思考(推荐 Opus 4.6+ / Sonnet 4.6+)。留空则使用手动预算模式。参见: https://platform.claude.com/docs/en/build-with-claude/adaptive-thinking"
|
||||
},
|
||||
"budget": {
|
||||
"description": "思考预算",
|
||||
"hint": "Anthropic thinking.budget_tokens 参数。必须 >= 1024。参见: https://platform.claude.com/docs/en/build-with-claude/extended-thinking"
|
||||
"hint": "Anthropic thinking.budget_tokens 参数。必须 >= 1024。仅在思考类型为空时生效。Opus 4.6 / Sonnet 4.6 已弃用。参见: https://platform.claude.com/docs/en/build-with-claude/extended-thinking"
|
||||
},
|
||||
"effort": {
|
||||
"description": "思考深度",
|
||||
"hint": "当思考类型为 'adaptive' 时控制思考深度。'high' 为默认值。'max' 仅限 Opus 4.6。参见: https://platform.claude.com/docs/en/build-with-claude/effort"
|
||||
}
|
||||
},
|
||||
"minimax-group-id": {
|
||||
|
||||
@@ -128,5 +128,53 @@
|
||||
"renameFailed": "重命名失败",
|
||||
"ftpHint": "对于较大的备份文件,也可以通过 FTP/SFTP 等方式直接上传到 data/backups 目录"
|
||||
}
|
||||
},
|
||||
"apiKey": {
|
||||
"title": "API Key",
|
||||
"manageTitle": "开发者访问密钥",
|
||||
"subtitle": "为外部开发者创建 API Key,用于调用开放 HTTP API。",
|
||||
"name": "Key 名称",
|
||||
"expiresInDays": "有效期",
|
||||
"expiryOptions": {
|
||||
"day1": "1 天",
|
||||
"day7": "7 天",
|
||||
"day30": "30 天",
|
||||
"day90": "90 天",
|
||||
"permanent": "永久"
|
||||
},
|
||||
"permanentWarning": "永久有效的 API Key 风险较高,请妥善保存并建议仅在必要场景使用。",
|
||||
"scopes": "权限范围",
|
||||
"create": "创建 API Key",
|
||||
"revoke": "吊销",
|
||||
"delete": "删除",
|
||||
"copy": "复制",
|
||||
"docsLink": "查看文档",
|
||||
"plaintextHint": "请立即保存该 Key,关闭后将无法再次查看明文。",
|
||||
"empty": "暂无 API Key",
|
||||
"status": {
|
||||
"active": "有效",
|
||||
"inactive": "无效"
|
||||
},
|
||||
"table": {
|
||||
"name": "名称",
|
||||
"prefix": "前缀",
|
||||
"scopes": "权限",
|
||||
"status": "状态",
|
||||
"lastUsed": "最近使用",
|
||||
"createdAt": "创建时间",
|
||||
"actions": "操作"
|
||||
},
|
||||
"messages": {
|
||||
"loadFailed": "加载 API Key 失败",
|
||||
"scopeRequired": "请至少选择一个权限",
|
||||
"createSuccess": "API Key 创建成功",
|
||||
"createFailed": "创建 API Key 失败",
|
||||
"revokeSuccess": "API Key 已吊销",
|
||||
"revokeFailed": "吊销 API Key 失败",
|
||||
"deleteSuccess": "API Key 已删除",
|
||||
"deleteFailed": "删除 API Key 失败",
|
||||
"copySuccess": "已复制 API Key",
|
||||
"copyFailed": "复制 API Key 失败"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ import StyledMenu from '@/components/shared/StyledMenu.vue';
|
||||
import { useLanguageSwitcher } from '@/i18n/composables';
|
||||
import type { Locale } from '@/i18n/types';
|
||||
import AboutPage from '@/views/AboutPage.vue';
|
||||
import { getDesktopRuntimeInfo } from '@/utils/desktopRuntime';
|
||||
|
||||
enableKatex();
|
||||
enableMermaid();
|
||||
@@ -46,14 +47,28 @@ let version = ref('');
|
||||
let releases = ref([]);
|
||||
let updatingDashboardLoading = ref(false);
|
||||
let installLoading = ref(false);
|
||||
const isElectronApp = ref(
|
||||
typeof window !== 'undefined' && !!window.astrbotDesktop?.isElectron
|
||||
const isDesktopReleaseMode = ref(
|
||||
typeof window !== 'undefined' && !!window.astrbotDesktop?.isDesktop
|
||||
);
|
||||
const redirectConfirmDialog = ref(false);
|
||||
const pendingRedirectUrl = ref('');
|
||||
const resolvingReleaseTarget = ref(false);
|
||||
const desktopReleaseBaseUrl = 'https://github.com/AstrBotDevs/AstrBot-desktop/releases';
|
||||
const fallbackReleaseUrl = desktopReleaseBaseUrl;
|
||||
const DEFAULT_ASTRBOT_RELEASE_BASE_URL = 'https://github.com/AstrBotDevs/AstrBot/releases';
|
||||
const resolveReleaseBaseUrl = () => {
|
||||
const raw = import.meta.env.VITE_ASTRBOT_RELEASE_BASE_URL;
|
||||
// Keep upstream default on AstrBot releases; desktop distributors can override via env injection.
|
||||
const normalized = raw?.trim()?.replace(/\/+$/, '') || '';
|
||||
const withoutLatestSuffix = normalized.replace(/\/latest$/i, '');
|
||||
return withoutLatestSuffix || DEFAULT_ASTRBOT_RELEASE_BASE_URL;
|
||||
};
|
||||
const releaseBaseUrl = resolveReleaseBaseUrl();
|
||||
const getReleaseUrlByTag = (tag: string | null | undefined) => {
|
||||
const normalizedTag = (tag || '').trim();
|
||||
if (!normalizedTag || normalizedTag.toLowerCase() === 'latest') {
|
||||
return `${releaseBaseUrl}/latest`;
|
||||
}
|
||||
return `${releaseBaseUrl}/tag/${normalizedTag}`;
|
||||
};
|
||||
|
||||
const getSelectedGitHubProxy = () => {
|
||||
if (typeof window === "undefined" || !window.localStorage) return "";
|
||||
@@ -133,26 +148,23 @@ function confirmExternalRedirect() {
|
||||
}
|
||||
}
|
||||
|
||||
const getReleaseUrlForElectron = () => {
|
||||
const getReleaseUrlForDesktop = () => {
|
||||
const firstRelease = (releases.value as any[])?.[0];
|
||||
if (firstRelease?.tag_name) {
|
||||
const tag = firstRelease.tag_name as string;
|
||||
return `${desktopReleaseBaseUrl}/tag/${tag}`;
|
||||
return getReleaseUrlByTag(firstRelease.tag_name as string);
|
||||
}
|
||||
if (hasNewVersion.value) return fallbackReleaseUrl;
|
||||
if (hasNewVersion.value) return getReleaseUrlByTag('latest');
|
||||
const tag = botCurrVersion.value?.startsWith('v') ? botCurrVersion.value : 'latest';
|
||||
return tag === 'latest'
|
||||
? fallbackReleaseUrl
|
||||
: `${desktopReleaseBaseUrl}/tag/${tag}`;
|
||||
return getReleaseUrlByTag(tag);
|
||||
};
|
||||
|
||||
function handleUpdateClick() {
|
||||
if (isElectronApp.value) {
|
||||
if (isDesktopReleaseMode.value) {
|
||||
requestExternalRedirect('');
|
||||
resolvingReleaseTarget.value = true;
|
||||
checkUpdate();
|
||||
void getReleases().finally(() => {
|
||||
pendingRedirectUrl.value = getReleaseUrlForElectron() || fallbackReleaseUrl;
|
||||
pendingRedirectUrl.value = getReleaseUrlForDesktop() || getReleaseUrlByTag('latest');
|
||||
resolvingReleaseTarget.value = false;
|
||||
});
|
||||
return;
|
||||
@@ -246,7 +258,7 @@ function checkUpdate() {
|
||||
} else {
|
||||
updateStatus.value = res.data.message;
|
||||
}
|
||||
dashboardHasNewVersion.value = isElectronApp.value
|
||||
dashboardHasNewVersion.value = isDesktopReleaseMode.value
|
||||
? false
|
||||
: res.data.data.dashboard_has_new_version;
|
||||
})
|
||||
@@ -388,13 +400,9 @@ const changeLanguage = async (langCode: string) => {
|
||||
};
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
isElectronApp.value = !!window.astrbotDesktop?.isElectron ||
|
||||
!!(await window.astrbotDesktop?.isElectronRuntime?.());
|
||||
} catch {
|
||||
isElectronApp.value = false;
|
||||
}
|
||||
if (isElectronApp.value) {
|
||||
const runtimeInfo = await getDesktopRuntimeInfo();
|
||||
isDesktopReleaseMode.value = runtimeInfo.isDesktopRuntime;
|
||||
if (isDesktopReleaseMode.value) {
|
||||
dashboardHasNewVersion.value = false;
|
||||
}
|
||||
});
|
||||
@@ -441,7 +449,7 @@ onMounted(async () => {
|
||||
<small v-if="hasNewVersion">
|
||||
{{ t('core.header.version.hasNewVersion') }}
|
||||
</small>
|
||||
<small v-else-if="dashboardHasNewVersion && !isElectronApp">
|
||||
<small v-else-if="dashboardHasNewVersion && !isDesktopReleaseMode">
|
||||
{{ t('core.header.version.dashboardHasNewVersion') }}
|
||||
</small>
|
||||
</div>
|
||||
@@ -524,7 +532,7 @@ onMounted(async () => {
|
||||
<v-icon>mdi-arrow-up-circle</v-icon>
|
||||
</template>
|
||||
<v-list-item-title>{{ t('core.header.updateDialog.title') }}</v-list-item-title>
|
||||
<template v-slot:append v-if="hasNewVersion || (dashboardHasNewVersion && !isElectronApp)">
|
||||
<template v-slot:append v-if="hasNewVersion || (dashboardHasNewVersion && !isDesktopReleaseMode)">
|
||||
<v-chip size="x-small" color="primary" variant="tonal" class="ml-2">!</v-chip>
|
||||
</template>
|
||||
</v-list-item>
|
||||
|
||||
+2
-2
@@ -3,8 +3,8 @@ export {};
|
||||
declare global {
|
||||
interface Window {
|
||||
astrbotDesktop?: {
|
||||
isElectron: boolean;
|
||||
isElectronRuntime: () => Promise<boolean>;
|
||||
isDesktop: boolean;
|
||||
isDesktopRuntime: () => Promise<boolean>;
|
||||
getBackendState: () => Promise<{
|
||||
running: boolean;
|
||||
spawning: boolean;
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
export const CHAT_SELECTED_CONFIG_STORAGE_KEY = 'chat.selectedConfigId';
|
||||
|
||||
export type ChatMessageType = 'FriendMessage' | 'GroupMessage';
|
||||
|
||||
export interface WebchatUmoDetails {
|
||||
platformId: string;
|
||||
messageType: ChatMessageType;
|
||||
username: string;
|
||||
sessionKey: string;
|
||||
umo: string;
|
||||
}
|
||||
|
||||
function getFromLocalStorage(key: string, fallback: string): string {
|
||||
try {
|
||||
if (typeof localStorage === 'undefined') {
|
||||
return fallback;
|
||||
}
|
||||
const value = localStorage.getItem(key);
|
||||
return value == null ? fallback : value;
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
function setToLocalStorage(key: string, value: string): void {
|
||||
try {
|
||||
if (typeof localStorage === 'undefined') {
|
||||
return;
|
||||
}
|
||||
localStorage.setItem(key, value);
|
||||
} catch {
|
||||
// Ignore storage errors (e.g. private mode / restricted storage).
|
||||
}
|
||||
}
|
||||
|
||||
export function getStoredDashboardUsername(): string {
|
||||
return getFromLocalStorage('user', '').trim() || 'guest';
|
||||
}
|
||||
|
||||
export function getStoredSelectedChatConfigId(): string {
|
||||
return getFromLocalStorage(CHAT_SELECTED_CONFIG_STORAGE_KEY, '').trim() || 'default';
|
||||
}
|
||||
|
||||
export function setStoredSelectedChatConfigId(configId: string): void {
|
||||
setToLocalStorage(CHAT_SELECTED_CONFIG_STORAGE_KEY, configId);
|
||||
}
|
||||
|
||||
export function buildWebchatUmoDetails(sessionId: string, isGroup = false): WebchatUmoDetails {
|
||||
const platformId = 'webchat';
|
||||
const username = getStoredDashboardUsername();
|
||||
const messageType: ChatMessageType = isGroup ? 'GroupMessage' : 'FriendMessage';
|
||||
const sessionKey = `${platformId}!${username}!${sessionId}`;
|
||||
return {
|
||||
platformId,
|
||||
messageType,
|
||||
username,
|
||||
sessionKey,
|
||||
umo: `${platformId}:${messageType}:${sessionKey}`
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
export type DesktopRuntimeInfo = {
|
||||
bridge: Window['astrbotDesktop'] | undefined
|
||||
hasDesktopRuntimeProbe: boolean
|
||||
hasDesktopRestartCapability: boolean
|
||||
isDesktopRuntime: boolean
|
||||
}
|
||||
|
||||
export async function getDesktopRuntimeInfo(): Promise<DesktopRuntimeInfo> {
|
||||
const bridge = window.astrbotDesktop
|
||||
const hasDesktopRuntimeProbe =
|
||||
!!bridge && typeof bridge.isDesktopRuntime === 'function'
|
||||
const hasDesktopRestartCapability =
|
||||
!!bridge &&
|
||||
typeof bridge.restartBackend === 'function' &&
|
||||
hasDesktopRuntimeProbe
|
||||
|
||||
let isDesktopRuntime = !!bridge?.isDesktop
|
||||
if (hasDesktopRuntimeProbe) {
|
||||
try {
|
||||
isDesktopRuntime = isDesktopRuntime || !!(await bridge.isDesktopRuntime())
|
||||
} catch (error) {
|
||||
console.warn('[desktop-runtime] Failed to detect desktop runtime.', error)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
bridge,
|
||||
hasDesktopRuntimeProbe,
|
||||
hasDesktopRestartCapability,
|
||||
isDesktopRuntime,
|
||||
}
|
||||
}
|
||||
@@ -34,6 +34,7 @@ export function getProviderIcon(type) {
|
||||
'vllm': 'https://registry.npmmirror.com/@lobehub/icons-static-svg/latest/files/icons/vllm.svg',
|
||||
'groq': 'https://registry.npmmirror.com/@lobehub/icons-static-svg/latest/files/icons/groq.svg',
|
||||
'aihubmix': 'https://registry.npmmirror.com/@lobehub/icons-static-svg/latest/files/icons/aihubmix-color.svg',
|
||||
'openrouter': 'https://registry.npmmirror.com/@lobehub/icons-static-svg/latest/files/icons/openrouter.svg',
|
||||
"tokenpony": "https://tokenpony.cn/tokenpony-web/logo.png",
|
||||
"compshare": "https://compshare.cn/favicon.ico"
|
||||
};
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import axios from 'axios'
|
||||
import { getDesktopRuntimeInfo } from '@/utils/desktopRuntime'
|
||||
|
||||
type WaitingForRestartRef = {
|
||||
check: (initialStartTime?: number | null) => void | Promise<void>
|
||||
@@ -27,9 +28,10 @@ async function fetchCurrentStartTime(): Promise<number | null> {
|
||||
export async function restartAstrBot(
|
||||
waitingRef?: WaitingForRestartRef | null
|
||||
): Promise<void> {
|
||||
const desktopBridge = window.astrbotDesktop
|
||||
const { bridge: desktopBridge, hasDesktopRestartCapability, isDesktopRuntime } =
|
||||
await getDesktopRuntimeInfo()
|
||||
|
||||
if (desktopBridge?.isElectron) {
|
||||
if (desktopBridge && hasDesktopRestartCapability && isDesktopRuntime) {
|
||||
const authToken = localStorage.getItem('token')
|
||||
const initialStartTime = await fetchCurrentStartTime()
|
||||
try {
|
||||
|
||||
@@ -747,12 +747,13 @@ const showPluginInfo = (plugin) => {
|
||||
const reloadPlugin = async (plugin_name) => {
|
||||
try {
|
||||
const res = await axios.post("/api/plugin/reload", { name: plugin_name });
|
||||
await getExtensions();
|
||||
if (res.data.status === "error") {
|
||||
toast(res.data.message, "error");
|
||||
return;
|
||||
}
|
||||
toast(tm("messages.reloadSuccess"), "success");
|
||||
getExtensions();
|
||||
//getExtensions();
|
||||
} catch (err) {
|
||||
toast(err, "error");
|
||||
}
|
||||
|
||||
@@ -63,10 +63,156 @@
|
||||
<v-btn style="margin-top: 16px;" color="error" @click="restartAstrBot">{{ tm('system.restart.button') }}</v-btn>
|
||||
</v-list-item>
|
||||
|
||||
<v-list-subheader>{{ tm('apiKey.title') }}</v-list-subheader>
|
||||
|
||||
<v-list-item :subtitle="tm('apiKey.subtitle')">
|
||||
<template #title>
|
||||
<div class="d-flex align-center">
|
||||
<span>{{ tm('apiKey.manageTitle') }}</span>
|
||||
<v-tooltip location="top">
|
||||
<template #activator="{ props }">
|
||||
<v-btn
|
||||
v-bind="props"
|
||||
icon
|
||||
size="x-small"
|
||||
variant="text"
|
||||
class="ml-2"
|
||||
:aria-label="tm('apiKey.docsLink')"
|
||||
href="https://docs.astrbot.app/dev/openapi.html"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<v-icon size="18">mdi-help-circle-outline</v-icon>
|
||||
</v-btn>
|
||||
</template>
|
||||
<span>{{ tm('apiKey.docsLink') }}</span>
|
||||
</v-tooltip>
|
||||
</div>
|
||||
</template>
|
||||
<v-row class="mt-2" dense>
|
||||
<v-col cols="12" md="4">
|
||||
<v-text-field
|
||||
v-model="newApiKeyName"
|
||||
:label="tm('apiKey.name')"
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
hide-details
|
||||
/>
|
||||
</v-col>
|
||||
<v-col cols="12" md="3">
|
||||
<v-select
|
||||
v-model="newApiKeyExpiresInDays"
|
||||
:items="apiKeyExpiryOptions"
|
||||
:label="tm('apiKey.expiresInDays')"
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
hide-details
|
||||
/>
|
||||
</v-col>
|
||||
<v-col v-if="newApiKeyExpiresInDays === 'permanent'" cols="12">
|
||||
<v-alert type="warning" variant="tonal" density="comfortable">
|
||||
{{ tm('apiKey.permanentWarning') }}
|
||||
</v-alert>
|
||||
</v-col>
|
||||
<v-col cols="12" md="5" class="d-flex align-center">
|
||||
<v-btn color="primary" :loading="apiKeyCreating" @click="createApiKey">
|
||||
<v-icon class="mr-2">mdi-key-plus</v-icon>
|
||||
{{ tm('apiKey.create') }}
|
||||
</v-btn>
|
||||
</v-col>
|
||||
|
||||
<v-col cols="12">
|
||||
<div class="text-caption text-medium-emphasis mb-1">{{ tm('apiKey.scopes') }}</div>
|
||||
<v-chip-group v-model="newApiKeyScopes" multiple>
|
||||
<v-chip
|
||||
v-for="scope in availableScopes"
|
||||
:key="scope.value"
|
||||
:value="scope.value"
|
||||
:color="newApiKeyScopes.includes(scope.value) ? 'primary' : undefined"
|
||||
:variant="newApiKeyScopes.includes(scope.value) ? 'flat' : 'tonal'"
|
||||
>
|
||||
{{ scope.label }}
|
||||
</v-chip>
|
||||
</v-chip-group>
|
||||
</v-col>
|
||||
|
||||
<v-col v-if="createdApiKeyPlaintext" cols="12">
|
||||
<v-alert type="warning" variant="tonal">
|
||||
<div class="d-flex align-center justify-space-between flex-wrap">
|
||||
<span>{{ tm('apiKey.plaintextHint') }}</span>
|
||||
<v-btn size="small" variant="text" color="primary" @click="copyCreatedApiKey">
|
||||
<v-icon class="mr-1">mdi-content-copy</v-icon>{{ tm('apiKey.copy') }}
|
||||
</v-btn>
|
||||
</div>
|
||||
<code style="word-break: break-all;">{{ createdApiKeyPlaintext }}</code>
|
||||
</v-alert>
|
||||
</v-col>
|
||||
|
||||
<v-col cols="12">
|
||||
<v-table density="compact">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{{ tm('apiKey.table.name') }}</th>
|
||||
<th>{{ tm('apiKey.table.prefix') }}</th>
|
||||
<th>{{ tm('apiKey.table.scopes') }}</th>
|
||||
<th>{{ tm('apiKey.table.status') }}</th>
|
||||
<th>{{ tm('apiKey.table.lastUsed') }}</th>
|
||||
<th>{{ tm('apiKey.table.createdAt') }}</th>
|
||||
<th>{{ tm('apiKey.table.actions') }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="item in apiKeys" :key="item.key_id">
|
||||
<td>{{ item.name }}</td>
|
||||
<td><code>{{ item.key_prefix }}</code></td>
|
||||
<td>{{ (item.scopes || []).join(', ') }}</td>
|
||||
<td>
|
||||
<v-chip
|
||||
size="small"
|
||||
:color="item.is_revoked || item.is_expired ? 'error' : 'success'"
|
||||
variant="tonal"
|
||||
>
|
||||
{{ item.is_revoked || item.is_expired ? tm('apiKey.status.inactive') : tm('apiKey.status.active') }}
|
||||
</v-chip>
|
||||
</td>
|
||||
<td>{{ formatDate(item.last_used_at) }}</td>
|
||||
<td>{{ formatDate(item.created_at) }}</td>
|
||||
<td>
|
||||
<v-btn
|
||||
v-if="!item.is_revoked"
|
||||
size="x-small"
|
||||
color="warning"
|
||||
variant="tonal"
|
||||
class="mr-2"
|
||||
@click="revokeApiKey(item.key_id)"
|
||||
>
|
||||
{{ tm('apiKey.revoke') }}
|
||||
</v-btn>
|
||||
<v-btn
|
||||
size="x-small"
|
||||
color="error"
|
||||
variant="tonal"
|
||||
@click="deleteApiKey(item.key_id)"
|
||||
>
|
||||
{{ tm('apiKey.delete') }}
|
||||
</v-btn>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="apiKeys.length === 0">
|
||||
<td colspan="7" class="text-center text-medium-emphasis">
|
||||
{{ tm('apiKey.empty') }}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</v-table>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
|
||||
<v-list-item :subtitle="tm('system.migration.subtitle')" :title="tm('system.migration.title')">
|
||||
<v-btn style="margin-top: 16px;" color="primary" @click="startMigration">{{ tm('system.migration.button') }}</v-btn>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
|
||||
</div>
|
||||
|
||||
@@ -77,7 +223,8 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, watch } from 'vue';
|
||||
import { computed, onMounted, ref, watch } from 'vue';
|
||||
import axios from 'axios';
|
||||
import WaitingForRestart from '@/components/shared/WaitingForRestart.vue';
|
||||
import ProxySelector from '@/components/shared/ProxySelector.vue';
|
||||
import MigrationDialog from '@/components/shared/MigrationDialog.vue';
|
||||
@@ -87,8 +234,10 @@ import { restartAstrBot as restartAstrBotRuntime } from '@/utils/restartAstrBot'
|
||||
import { useModuleI18n } from '@/i18n/composables';
|
||||
import { useTheme } from 'vuetify';
|
||||
import { PurpleTheme } from '@/theme/LightTheme';
|
||||
import { useToastStore } from '@/stores/toast';
|
||||
|
||||
const { tm } = useModuleI18n('features/settings');
|
||||
const toastStore = useToastStore();
|
||||
const theme = useTheme();
|
||||
|
||||
const getStoredColor = (key, fallback) => {
|
||||
@@ -135,6 +284,127 @@ watch(secondaryColor, (value) => {
|
||||
const wfr = ref(null);
|
||||
const migrationDialog = ref(null);
|
||||
const backupDialog = ref(null);
|
||||
const apiKeys = ref([]);
|
||||
const apiKeyCreating = ref(false);
|
||||
const newApiKeyName = ref('');
|
||||
const newApiKeyExpiresInDays = ref(30);
|
||||
const newApiKeyScopes = ref(['chat', 'config', 'file', 'im']);
|
||||
const createdApiKeyPlaintext = ref('');
|
||||
const apiKeyExpiryOptions = computed(() => [
|
||||
{ title: tm('apiKey.expiryOptions.day1'), value: 1 },
|
||||
{ title: tm('apiKey.expiryOptions.day7'), value: 7 },
|
||||
{ title: tm('apiKey.expiryOptions.day30'), value: 30 },
|
||||
{ title: tm('apiKey.expiryOptions.day90'), value: 90 },
|
||||
{ title: tm('apiKey.expiryOptions.permanent'), value: 'permanent' }
|
||||
]);
|
||||
|
||||
const availableScopes = [
|
||||
{ value: 'chat', label: 'chat' },
|
||||
{ value: 'config', label: 'config' },
|
||||
{ value: 'file', label: 'file' },
|
||||
{ value: 'im', label: 'im' }
|
||||
];
|
||||
|
||||
const showToast = (message, color = 'success') => {
|
||||
toastStore.add({
|
||||
message,
|
||||
color,
|
||||
timeout: 3000
|
||||
});
|
||||
};
|
||||
|
||||
const formatDate = (value) => {
|
||||
if (!value) return '-';
|
||||
const dt = new Date(value);
|
||||
if (Number.isNaN(dt.getTime())) return '-';
|
||||
return dt.toLocaleString();
|
||||
};
|
||||
|
||||
const loadApiKeys = async () => {
|
||||
try {
|
||||
const res = await axios.get('/api/apikey/list');
|
||||
if (res.data.status !== 'ok') {
|
||||
showToast(res.data.message || tm('apiKey.messages.loadFailed'), 'error');
|
||||
return;
|
||||
}
|
||||
apiKeys.value = res.data.data || [];
|
||||
} catch (e) {
|
||||
showToast(e?.response?.data?.message || tm('apiKey.messages.loadFailed'), 'error');
|
||||
}
|
||||
};
|
||||
|
||||
const copyCreatedApiKey = async () => {
|
||||
if (!createdApiKeyPlaintext.value) return;
|
||||
try {
|
||||
await navigator.clipboard.writeText(createdApiKeyPlaintext.value);
|
||||
showToast(tm('apiKey.messages.copySuccess'), 'success');
|
||||
} catch (_) {
|
||||
showToast(tm('apiKey.messages.copyFailed'), 'error');
|
||||
}
|
||||
};
|
||||
|
||||
const createApiKey = async () => {
|
||||
const selectedScopes = availableScopes
|
||||
.map((scope) => scope.value)
|
||||
.filter((scope) => newApiKeyScopes.value.includes(scope));
|
||||
|
||||
if (selectedScopes.length === 0) {
|
||||
showToast(tm('apiKey.messages.scopeRequired'), 'warning');
|
||||
return;
|
||||
}
|
||||
apiKeyCreating.value = true;
|
||||
try {
|
||||
const payload = {
|
||||
name: newApiKeyName.value,
|
||||
scopes: selectedScopes
|
||||
};
|
||||
if (newApiKeyExpiresInDays.value !== 'permanent') {
|
||||
payload.expires_in_days = Number(newApiKeyExpiresInDays.value);
|
||||
}
|
||||
const res = await axios.post('/api/apikey/create', payload);
|
||||
if (res.data.status !== 'ok') {
|
||||
showToast(res.data.message || tm('apiKey.messages.createFailed'), 'error');
|
||||
return;
|
||||
}
|
||||
createdApiKeyPlaintext.value = res.data.data?.api_key || '';
|
||||
newApiKeyName.value = '';
|
||||
newApiKeyExpiresInDays.value = 30;
|
||||
showToast(tm('apiKey.messages.createSuccess'), 'success');
|
||||
await loadApiKeys();
|
||||
} catch (e) {
|
||||
showToast(e?.response?.data?.message || tm('apiKey.messages.createFailed'), 'error');
|
||||
} finally {
|
||||
apiKeyCreating.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const revokeApiKey = async (keyId) => {
|
||||
try {
|
||||
const res = await axios.post('/api/apikey/revoke', { key_id: keyId });
|
||||
if (res.data.status !== 'ok') {
|
||||
showToast(res.data.message || tm('apiKey.messages.revokeFailed'), 'error');
|
||||
return;
|
||||
}
|
||||
showToast(tm('apiKey.messages.revokeSuccess'), 'success');
|
||||
await loadApiKeys();
|
||||
} catch (e) {
|
||||
showToast(e?.response?.data?.message || tm('apiKey.messages.revokeFailed'), 'error');
|
||||
}
|
||||
};
|
||||
|
||||
const deleteApiKey = async (keyId) => {
|
||||
try {
|
||||
const res = await axios.post('/api/apikey/delete', { key_id: keyId });
|
||||
if (res.data.status !== 'ok') {
|
||||
showToast(res.data.message || tm('apiKey.messages.deleteFailed'), 'error');
|
||||
return;
|
||||
}
|
||||
showToast(tm('apiKey.messages.deleteSuccess'), 'success');
|
||||
await loadApiKeys();
|
||||
} catch (e) {
|
||||
showToast(e?.response?.data?.message || tm('apiKey.messages.deleteFailed'), 'error');
|
||||
}
|
||||
};
|
||||
|
||||
const restartAstrBot = async () => {
|
||||
try {
|
||||
@@ -170,4 +440,8 @@ const resetThemeColors = () => {
|
||||
localStorage.removeItem('themeSecondary');
|
||||
applyThemeColors(primaryColor.value, secondaryColor.value);
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
loadApiKeys();
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -138,14 +138,13 @@
|
||||
</div>
|
||||
|
||||
<!-- Controls (stop propagation on clicks) -->
|
||||
<div class="d-flex align-center gap-2" @click.stop>
|
||||
<div class="d-flex align-center gap-2 flex-shrink-0" @click.stop>
|
||||
<v-switch
|
||||
v-model="agent.enabled"
|
||||
color="success"
|
||||
hide-details
|
||||
inset
|
||||
density="compact"
|
||||
class="mr-2"
|
||||
/>
|
||||
<v-btn
|
||||
icon="mdi-delete-outline"
|
||||
|
||||
+685
@@ -0,0 +1,685 @@
|
||||
{
|
||||
"openapi": "3.1.0",
|
||||
"info": {
|
||||
"title": "AstrBot Open API",
|
||||
"version": "1.0.0",
|
||||
"description": "Developer HTTP APIs for AstrBot. Use API Key authentication for /api/v1/* endpoints."
|
||||
},
|
||||
"servers": [
|
||||
{
|
||||
"url": "http://localhost:6185"
|
||||
}
|
||||
],
|
||||
"tags": [
|
||||
{
|
||||
"name": "Open API",
|
||||
"description": "Developer APIs authenticated by API Key"
|
||||
}
|
||||
],
|
||||
"paths": {
|
||||
"/api/v1/im/bots": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"Open API"
|
||||
],
|
||||
"summary": "List bot IDs",
|
||||
"description": "Returns configured bot/platform IDs.",
|
||||
"security": [
|
||||
{
|
||||
"ApiKeyHeader": []
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiResponseBotList"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"$ref": "#/components/responses/Unauthorized"
|
||||
},
|
||||
"403": {
|
||||
"$ref": "#/components/responses/Forbidden"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/file": {
|
||||
"post": {
|
||||
"tags": [
|
||||
"Open API"
|
||||
],
|
||||
"summary": "Upload attachment file",
|
||||
"description": "Upload a file and get attachment_id for later use in chat/message APIs.",
|
||||
"security": [
|
||||
{
|
||||
"ApiKeyHeader": []
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {
|
||||
"multipart/form-data": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"file"
|
||||
],
|
||||
"properties": {
|
||||
"file": {
|
||||
"type": "string",
|
||||
"format": "binary"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiResponseUpload"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"$ref": "#/components/responses/Unauthorized"
|
||||
},
|
||||
"403": {
|
||||
"$ref": "#/components/responses/Forbidden"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/chat": {
|
||||
"post": {
|
||||
"tags": [
|
||||
"Open API"
|
||||
],
|
||||
"summary": "Send chat message (SSE)",
|
||||
"description": "Send message to AstrBot chat pipeline and receive streaming SSE response. Reuses /api/chat/send behavior. If session_id/conversation_id is omitted, server will create a new UUID session_id.",
|
||||
"security": [
|
||||
{
|
||||
"ApiKeyHeader": []
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ChatSendRequest"
|
||||
},
|
||||
"examples": {
|
||||
"plain": {
|
||||
"value": {
|
||||
"message": "Hello",
|
||||
"username": "alice",
|
||||
"session_id": "my_session_001",
|
||||
"enable_streaming": true
|
||||
}
|
||||
},
|
||||
"multipartMessage": {
|
||||
"value": {
|
||||
"message": [
|
||||
{
|
||||
"type": "plain",
|
||||
"text": "Please analyze this file"
|
||||
},
|
||||
{
|
||||
"type": "file",
|
||||
"attachment_id": "9a2f8c72-e7af-4c0e-b352-111111111111"
|
||||
}
|
||||
],
|
||||
"username": "alice",
|
||||
"session_id": "my_session_001",
|
||||
"selected_provider": "openai_chat_completion",
|
||||
"selected_model": "gpt-4.1-mini",
|
||||
"enable_streaming": true
|
||||
}
|
||||
},
|
||||
"withConfig": {
|
||||
"value": {
|
||||
"message": "Use a specific config for this session",
|
||||
"username": "alice",
|
||||
"session_id": "my_session_001",
|
||||
"config_id": "default",
|
||||
"enable_streaming": true
|
||||
}
|
||||
},
|
||||
"autoSessionWithUsername": {
|
||||
"value": {
|
||||
"message": "hello",
|
||||
"username": "alice",
|
||||
"enable_streaming": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "SSE stream",
|
||||
"content": {
|
||||
"text/event-stream": {
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"$ref": "#/components/responses/Unauthorized"
|
||||
},
|
||||
"403": {
|
||||
"$ref": "#/components/responses/Forbidden"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/chat/sessions": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"Open API"
|
||||
],
|
||||
"summary": "List chat sessions with pagination",
|
||||
"description": "List chat sessions for the specified username.",
|
||||
"security": [
|
||||
{
|
||||
"ApiKeyHeader": []
|
||||
}
|
||||
],
|
||||
"parameters": [
|
||||
{
|
||||
"name": "page",
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"type": "integer",
|
||||
"default": 1,
|
||||
"minimum": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "page_size",
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"type": "integer",
|
||||
"default": 20,
|
||||
"minimum": 1,
|
||||
"maximum": 100
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "platform_id",
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
},
|
||||
"description": "Optional platform filter"
|
||||
},
|
||||
{
|
||||
"name": "username",
|
||||
"in": "query",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
},
|
||||
"description": "Target username."
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiResponseChatSessions"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"$ref": "#/components/responses/Unauthorized"
|
||||
},
|
||||
"403": {
|
||||
"$ref": "#/components/responses/Forbidden"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/im/message": {
|
||||
"post": {
|
||||
"tags": [
|
||||
"Open API"
|
||||
],
|
||||
"summary": "Send proactive message to a platform bot",
|
||||
"description": "Send message directly to platform bot by umo + message chain payload.",
|
||||
"security": [
|
||||
{
|
||||
"ApiKeyHeader": []
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/SendMessageRequest"
|
||||
},
|
||||
"examples": {
|
||||
"plain": {
|
||||
"value": {
|
||||
"umo": "webchat:FriendMessage:openapi_probe",
|
||||
"message": "ping from api key"
|
||||
}
|
||||
},
|
||||
"chain": {
|
||||
"value": {
|
||||
"umo": "webchat:FriendMessage:openapi_probe",
|
||||
"message": [
|
||||
{
|
||||
"type": "plain",
|
||||
"text": "hello"
|
||||
},
|
||||
{
|
||||
"type": "image",
|
||||
"attachment_id": "9a2f8c72-e7af-4c0e-b352-111111111111"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiResponseEmpty"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"$ref": "#/components/responses/Unauthorized"
|
||||
},
|
||||
"403": {
|
||||
"$ref": "#/components/responses/Forbidden"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/configs": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"Open API"
|
||||
],
|
||||
"summary": "List available chat config files",
|
||||
"description": "Returns all available AstrBot config files that can be selected by Chat API using config_id/config_name.",
|
||||
"security": [
|
||||
{
|
||||
"ApiKeyHeader": []
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiResponseChatConfigList"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"$ref": "#/components/responses/Unauthorized"
|
||||
},
|
||||
"403": {
|
||||
"$ref": "#/components/responses/Forbidden"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"components": {
|
||||
"securitySchemes": {
|
||||
"ApiKeyHeader": {
|
||||
"type": "apiKey",
|
||||
"in": "header",
|
||||
"name": "X-API-Key",
|
||||
"description": "Open API key. Authorization: Bearer <api_key> is also accepted."
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"Unauthorized": {
|
||||
"description": "Unauthorized"
|
||||
},
|
||||
"Forbidden": {
|
||||
"description": "Forbidden"
|
||||
}
|
||||
},
|
||||
"schemas": {
|
||||
"ApiResponseEmpty": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"status": {
|
||||
"type": "string",
|
||||
"example": "ok"
|
||||
},
|
||||
"message": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"data": {
|
||||
"type": "object",
|
||||
"additionalProperties": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"ApiResponseBotList": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"status": {
|
||||
"type": "string",
|
||||
"example": "ok"
|
||||
},
|
||||
"message": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"data": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"bot_ids": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"ApiResponseUpload": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"status": {
|
||||
"type": "string",
|
||||
"example": "ok"
|
||||
},
|
||||
"message": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"data": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"attachment_id": {
|
||||
"type": "string"
|
||||
},
|
||||
"filename": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"ApiResponseChatSessions": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"status": {
|
||||
"type": "string",
|
||||
"example": "ok"
|
||||
},
|
||||
"message": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"data": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"sessions": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/ChatSessionItem"
|
||||
}
|
||||
},
|
||||
"page": {
|
||||
"type": "integer"
|
||||
},
|
||||
"page_size": {
|
||||
"type": "integer"
|
||||
},
|
||||
"total": {
|
||||
"type": "integer"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"ChatSessionItem": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"session_id": {
|
||||
"type": "string"
|
||||
},
|
||||
"platform_id": {
|
||||
"type": "string"
|
||||
},
|
||||
"creator": {
|
||||
"type": "string"
|
||||
},
|
||||
"display_name": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"is_group": {
|
||||
"type": "integer"
|
||||
},
|
||||
"created_at": {
|
||||
"type": "string",
|
||||
"format": "date-time"
|
||||
},
|
||||
"updated_at": {
|
||||
"type": "string",
|
||||
"format": "date-time"
|
||||
}
|
||||
}
|
||||
},
|
||||
"MessagePart": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"plain",
|
||||
"reply",
|
||||
"image",
|
||||
"record",
|
||||
"file",
|
||||
"video"
|
||||
]
|
||||
},
|
||||
"text": {
|
||||
"type": "string"
|
||||
},
|
||||
"message_id": {
|
||||
"type": [
|
||||
"string",
|
||||
"integer"
|
||||
]
|
||||
},
|
||||
"selected_text": {
|
||||
"type": "string"
|
||||
},
|
||||
"attachment_id": {
|
||||
"type": "string"
|
||||
},
|
||||
"filename": {
|
||||
"type": "string"
|
||||
},
|
||||
"path": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"type"
|
||||
]
|
||||
},
|
||||
"ChatSendRequest": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"message",
|
||||
"username"
|
||||
],
|
||||
"properties": {
|
||||
"message": {
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/MessagePart"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"session_id": {
|
||||
"type": "string",
|
||||
"description": "Optional chat session ID. If omitted (and conversation_id is also omitted), server creates a UUID automatically."
|
||||
},
|
||||
"conversation_id": {
|
||||
"type": "string",
|
||||
"description": "Alias of session_id."
|
||||
},
|
||||
"username": {
|
||||
"type": "string",
|
||||
"description": "Target username."
|
||||
},
|
||||
"selected_provider": {
|
||||
"type": "string"
|
||||
},
|
||||
"selected_model": {
|
||||
"type": "string"
|
||||
},
|
||||
"enable_streaming": {
|
||||
"type": "boolean",
|
||||
"default": true
|
||||
},
|
||||
"config_id": {
|
||||
"type": "string",
|
||||
"description": "Optional AstrBot config file ID. If provided, the chat session will use this config file. Use \"default\" to reset to default config."
|
||||
},
|
||||
"config_name": {
|
||||
"type": "string",
|
||||
"description": "Optional AstrBot config file name. Used only when config_id is not provided."
|
||||
}
|
||||
}
|
||||
},
|
||||
"SendMessageRequest": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"umo",
|
||||
"message"
|
||||
],
|
||||
"properties": {
|
||||
"umo": {
|
||||
"type": "string",
|
||||
"description": "Unified message origin. Format: platform:message_type:session_id"
|
||||
},
|
||||
"message": {
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/MessagePart"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"ChatConfigFile": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"path": {
|
||||
"type": "string"
|
||||
},
|
||||
"is_default": {
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"id",
|
||||
"name",
|
||||
"path",
|
||||
"is_default"
|
||||
]
|
||||
},
|
||||
"ApiResponseChatConfigList": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"status": {
|
||||
"type": "string",
|
||||
"example": "ok"
|
||||
},
|
||||
"message": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"data": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"configs": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/ChatConfigFile"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "AstrBot"
|
||||
version = "4.17.6"
|
||||
version = "4.18.1"
|
||||
description = "Easy-to-use multi-platform LLM chatbot and development framework"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.12"
|
||||
|
||||
@@ -0,0 +1,334 @@
|
||||
import asyncio
|
||||
import uuid
|
||||
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
from quart import Quart, g, request
|
||||
|
||||
from astrbot.core import LogBroker
|
||||
from astrbot.core.core_lifecycle import AstrBotCoreLifecycle
|
||||
from astrbot.core.db.sqlite import SQLiteDatabase
|
||||
from astrbot.dashboard.routes.route import Response
|
||||
from astrbot.dashboard.server import AstrBotDashboard
|
||||
|
||||
|
||||
@pytest_asyncio.fixture(scope="module")
|
||||
async def core_lifecycle_td(tmp_path_factory):
|
||||
tmp_db_path = tmp_path_factory.mktemp("data") / "test_data_api_key.db"
|
||||
db = SQLiteDatabase(str(tmp_db_path))
|
||||
log_broker = LogBroker()
|
||||
core_lifecycle = AstrBotCoreLifecycle(log_broker, db)
|
||||
await core_lifecycle.initialize()
|
||||
try:
|
||||
yield core_lifecycle
|
||||
finally:
|
||||
try:
|
||||
stop_result = core_lifecycle.stop()
|
||||
if asyncio.iscoroutine(stop_result):
|
||||
await stop_result
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def app(core_lifecycle_td: AstrBotCoreLifecycle):
|
||||
shutdown_event = asyncio.Event()
|
||||
server = AstrBotDashboard(core_lifecycle_td, core_lifecycle_td.db, shutdown_event)
|
||||
return server.app
|
||||
|
||||
|
||||
@pytest_asyncio.fixture(scope="module")
|
||||
async def authenticated_header(app: Quart, core_lifecycle_td: AstrBotCoreLifecycle):
|
||||
test_client = app.test_client()
|
||||
response = await test_client.post(
|
||||
"/api/auth/login",
|
||||
json={
|
||||
"username": core_lifecycle_td.astrbot_config["dashboard"]["username"],
|
||||
"password": core_lifecycle_td.astrbot_config["dashboard"]["password"],
|
||||
},
|
||||
)
|
||||
data = await response.get_json()
|
||||
token = data["data"]["token"]
|
||||
return {"Authorization": f"Bearer {token}"}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_api_key_scope_and_revoke(app: Quart, authenticated_header: dict):
|
||||
test_client = app.test_client()
|
||||
|
||||
create_res = await test_client.post(
|
||||
"/api/apikey/create",
|
||||
json={"name": "im-scope-key", "scopes": ["im"]},
|
||||
headers=authenticated_header,
|
||||
)
|
||||
assert create_res.status_code == 200
|
||||
create_data = await create_res.get_json()
|
||||
assert create_data["status"] == "ok"
|
||||
raw_key = create_data["data"]["api_key"]
|
||||
key_id = create_data["data"]["key_id"]
|
||||
|
||||
open_bot_res = await test_client.get(
|
||||
"/api/v1/im/bots",
|
||||
headers={"X-API-Key": raw_key},
|
||||
)
|
||||
assert open_bot_res.status_code == 200
|
||||
open_bot_data = await open_bot_res.get_json()
|
||||
assert open_bot_data["status"] == "ok"
|
||||
assert isinstance(open_bot_data["data"]["bot_ids"], list)
|
||||
|
||||
denied_chat_sessions_res = await test_client.get(
|
||||
"/api/v1/chat/sessions?page=1&page_size=10",
|
||||
headers={"X-API-Key": raw_key},
|
||||
)
|
||||
assert denied_chat_sessions_res.status_code == 403
|
||||
|
||||
denied_chat_configs_res = await test_client.get(
|
||||
"/api/v1/configs",
|
||||
headers={"X-API-Key": raw_key},
|
||||
)
|
||||
assert denied_chat_configs_res.status_code == 403
|
||||
|
||||
denied_res = await test_client.post(
|
||||
"/api/v1/file",
|
||||
data={},
|
||||
headers={"X-API-Key": raw_key},
|
||||
)
|
||||
assert denied_res.status_code == 403
|
||||
|
||||
revoke_res = await test_client.post(
|
||||
"/api/apikey/revoke",
|
||||
json={"key_id": key_id},
|
||||
headers=authenticated_header,
|
||||
)
|
||||
assert revoke_res.status_code == 200
|
||||
revoke_data = await revoke_res.get_json()
|
||||
assert revoke_data["status"] == "ok"
|
||||
|
||||
revoked_access_res = await test_client.get(
|
||||
"/api/v1/im/bots",
|
||||
headers={"X-API-Key": raw_key},
|
||||
)
|
||||
assert revoked_access_res.status_code == 401
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_open_send_message_with_api_key(app: Quart, authenticated_header: dict):
|
||||
test_client = app.test_client()
|
||||
|
||||
create_res = await test_client.post(
|
||||
"/api/apikey/create",
|
||||
json={"name": "send-message-key", "scopes": ["im"]},
|
||||
headers=authenticated_header,
|
||||
)
|
||||
create_data = await create_res.get_json()
|
||||
assert create_data["status"] == "ok"
|
||||
raw_key = create_data["data"]["api_key"]
|
||||
|
||||
send_res = await test_client.post(
|
||||
"/api/v1/im/message",
|
||||
json={
|
||||
"umo": "webchat:FriendMessage:open_api_test_session",
|
||||
"message": "hello",
|
||||
},
|
||||
headers={"X-API-Key": raw_key},
|
||||
)
|
||||
assert send_res.status_code == 200
|
||||
send_data = await send_res.get_json()
|
||||
assert send_data["status"] == "ok"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_open_chat_send_auto_session_id_and_username(
|
||||
app: Quart,
|
||||
authenticated_header: dict,
|
||||
core_lifecycle_td: AstrBotCoreLifecycle,
|
||||
):
|
||||
test_client = app.test_client()
|
||||
|
||||
create_res = await test_client.post(
|
||||
"/api/apikey/create",
|
||||
json={"name": "chat-send-key", "scopes": ["chat"]},
|
||||
headers=authenticated_header,
|
||||
)
|
||||
create_data = await create_res.get_json()
|
||||
assert create_data["status"] == "ok"
|
||||
raw_key = create_data["data"]["api_key"]
|
||||
|
||||
rule = next(
|
||||
(
|
||||
item
|
||||
for item in app.url_map.iter_rules()
|
||||
if item.rule == "/api/v1/chat" and "POST" in item.methods
|
||||
),
|
||||
None,
|
||||
)
|
||||
assert rule is not None
|
||||
open_api_route = app.view_functions[rule.endpoint].__self__
|
||||
|
||||
original_chat = open_api_route.chat_route.chat
|
||||
|
||||
async def fake_chat(post_data: dict | None = None):
|
||||
payload = post_data or await request.get_json()
|
||||
return (
|
||||
Response()
|
||||
.ok(
|
||||
data={
|
||||
"session_id": payload.get("session_id"),
|
||||
"creator": g.get("username"),
|
||||
}
|
||||
)
|
||||
.__dict__
|
||||
)
|
||||
|
||||
open_api_route.chat_route.chat = fake_chat
|
||||
try:
|
||||
send_res = await test_client.post(
|
||||
"/api/v1/chat",
|
||||
json={
|
||||
"message": "hello",
|
||||
"username": "alice",
|
||||
"enable_streaming": False,
|
||||
},
|
||||
headers={"X-API-Key": raw_key},
|
||||
)
|
||||
finally:
|
||||
open_api_route.chat_route.chat = original_chat
|
||||
|
||||
assert send_res.status_code == 200
|
||||
send_data = await send_res.get_json()
|
||||
assert send_data["status"] == "ok"
|
||||
created_session_id = send_data["data"]["session_id"]
|
||||
assert isinstance(created_session_id, str)
|
||||
uuid.UUID(created_session_id)
|
||||
assert send_data["data"]["creator"] == "alice"
|
||||
created_session = await core_lifecycle_td.db.get_platform_session_by_id(
|
||||
created_session_id
|
||||
)
|
||||
assert created_session is not None
|
||||
assert created_session.creator == "alice"
|
||||
assert created_session.platform_id == "webchat"
|
||||
|
||||
await core_lifecycle_td.db.create_platform_session(
|
||||
creator="bob",
|
||||
platform_id="webchat",
|
||||
session_id="open_api_existing_bob_session",
|
||||
is_group=0,
|
||||
)
|
||||
another_user_session_res = await test_client.post(
|
||||
"/api/v1/chat",
|
||||
json={
|
||||
"message": "hello",
|
||||
"username": "alice",
|
||||
"session_id": "open_api_existing_bob_session",
|
||||
"enable_streaming": False,
|
||||
},
|
||||
headers={"X-API-Key": raw_key},
|
||||
)
|
||||
another_user_session_data = await another_user_session_res.get_json()
|
||||
assert another_user_session_data["status"] == "error"
|
||||
assert (
|
||||
another_user_session_data["message"]
|
||||
== "session_id belongs to another username"
|
||||
)
|
||||
|
||||
missing_username_res = await test_client.post(
|
||||
"/api/v1/chat",
|
||||
json={"message": "hello"},
|
||||
headers={"X-API-Key": raw_key},
|
||||
)
|
||||
missing_username_data = await missing_username_res.get_json()
|
||||
assert missing_username_data["status"] == "error"
|
||||
assert missing_username_data["message"] == "Missing key: username"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_open_chat_sessions_pagination(
|
||||
app: Quart,
|
||||
authenticated_header: dict,
|
||||
core_lifecycle_td: AstrBotCoreLifecycle,
|
||||
):
|
||||
test_client = app.test_client()
|
||||
|
||||
create_res = await test_client.post(
|
||||
"/api/apikey/create",
|
||||
json={"name": "chat-scope-key", "scopes": ["chat"]},
|
||||
headers=authenticated_header,
|
||||
)
|
||||
create_data = await create_res.get_json()
|
||||
assert create_data["status"] == "ok"
|
||||
raw_key = create_data["data"]["api_key"]
|
||||
|
||||
creator = "alice"
|
||||
for idx in range(3):
|
||||
await core_lifecycle_td.db.create_platform_session(
|
||||
creator=creator,
|
||||
platform_id="webchat",
|
||||
session_id=f"open_api_paginated_{idx}",
|
||||
display_name=f"Open API Session {idx}",
|
||||
is_group=0,
|
||||
)
|
||||
await core_lifecycle_td.db.create_platform_session(
|
||||
creator="bob",
|
||||
platform_id="webchat",
|
||||
session_id="open_api_paginated_bob",
|
||||
display_name="Open API Session Bob",
|
||||
is_group=0,
|
||||
)
|
||||
|
||||
page_1_res = await test_client.get(
|
||||
"/api/v1/chat/sessions?page=1&page_size=2&username=alice",
|
||||
headers={"X-API-Key": raw_key},
|
||||
)
|
||||
assert page_1_res.status_code == 200
|
||||
page_1_data = await page_1_res.get_json()
|
||||
assert page_1_data["status"] == "ok"
|
||||
assert page_1_data["data"]["page"] == 1
|
||||
assert page_1_data["data"]["page_size"] == 2
|
||||
assert page_1_data["data"]["total"] == 3
|
||||
assert len(page_1_data["data"]["sessions"]) == 2
|
||||
assert all(item["creator"] == "alice" for item in page_1_data["data"]["sessions"])
|
||||
|
||||
page_2_res = await test_client.get(
|
||||
"/api/v1/chat/sessions?page=2&page_size=2&username=alice",
|
||||
headers={"X-API-Key": raw_key},
|
||||
)
|
||||
assert page_2_res.status_code == 200
|
||||
page_2_data = await page_2_res.get_json()
|
||||
assert page_2_data["status"] == "ok"
|
||||
assert page_2_data["data"]["page"] == 2
|
||||
assert len(page_2_data["data"]["sessions"]) == 1
|
||||
|
||||
missing_username_res = await test_client.get(
|
||||
"/api/v1/chat/sessions?page=1&page_size=2",
|
||||
headers={"X-API-Key": raw_key},
|
||||
)
|
||||
missing_username_data = await missing_username_res.get_json()
|
||||
assert missing_username_data["status"] == "error"
|
||||
assert missing_username_data["message"] == "Missing key: username"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_open_chat_configs_list(
|
||||
app: Quart,
|
||||
authenticated_header: dict,
|
||||
):
|
||||
test_client = app.test_client()
|
||||
|
||||
create_res = await test_client.post(
|
||||
"/api/apikey/create",
|
||||
json={"name": "chat-config-key", "scopes": ["config"]},
|
||||
headers=authenticated_header,
|
||||
)
|
||||
create_data = await create_res.get_json()
|
||||
assert create_data["status"] == "ok"
|
||||
raw_key = create_data["data"]["api_key"]
|
||||
|
||||
configs_res = await test_client.get(
|
||||
"/api/v1/configs",
|
||||
headers={"X-API-Key": raw_key},
|
||||
)
|
||||
assert configs_res.status_code == 200
|
||||
configs_data = await configs_res.get_json()
|
||||
assert configs_data["status"] == "ok"
|
||||
assert isinstance(configs_data["data"]["configs"], list)
|
||||
assert any(item["id"] == "default" for item in configs_data["data"]["configs"])
|
||||
@@ -0,0 +1,110 @@
|
||||
import os
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..")))
|
||||
|
||||
from astrbot.core.agent.tool_call_approval import (
|
||||
ToolCallApprovalContext,
|
||||
ToolCallApprovalResult,
|
||||
request_tool_call_approval,
|
||||
)
|
||||
|
||||
|
||||
class DummyEvent:
|
||||
def __init__(self) -> None:
|
||||
self.unified_msg_origin = "test:friend:test_user"
|
||||
self.sent_messages = []
|
||||
self.message_str = ""
|
||||
|
||||
async def send(self, message) -> None:
|
||||
self.sent_messages.append(message)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_request_tool_call_approval_disabled_returns_approved():
|
||||
event = DummyEvent()
|
||||
result = await request_tool_call_approval(
|
||||
config={"enable": False},
|
||||
ctx=ToolCallApprovalContext(
|
||||
event=event,
|
||||
tool_name="test_tool",
|
||||
tool_args={},
|
||||
tool_call_id="call_1",
|
||||
),
|
||||
)
|
||||
assert result.approved is True
|
||||
assert result.reason == "approved"
|
||||
assert len(event.sent_messages) == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dynamic_code_approval_passed(monkeypatch):
|
||||
async def _mock_wait(*args, **kwargs):
|
||||
return ToolCallApprovalResult(approved=True, reason="approved")
|
||||
|
||||
monkeypatch.setattr(
|
||||
"astrbot.core.agent.tool_call_approval._wait_for_code_input",
|
||||
_mock_wait,
|
||||
)
|
||||
|
||||
event = DummyEvent()
|
||||
result = await request_tool_call_approval(
|
||||
config={"enable": True, "strategy": "dynamic_code"},
|
||||
ctx=ToolCallApprovalContext(
|
||||
event=event,
|
||||
tool_name="test_tool",
|
||||
tool_args={"query": "hello"},
|
||||
tool_call_id="call_2",
|
||||
),
|
||||
)
|
||||
assert result.approved is True
|
||||
assert result.reason == "approved"
|
||||
assert len(event.sent_messages) == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dynamic_code_approval_rejected(monkeypatch):
|
||||
async def _mock_wait(*args, **kwargs):
|
||||
return ToolCallApprovalResult(
|
||||
approved=False,
|
||||
reason="rejected",
|
||||
detail="not_code",
|
||||
)
|
||||
|
||||
monkeypatch.setattr(
|
||||
"astrbot.core.agent.tool_call_approval._wait_for_code_input",
|
||||
_mock_wait,
|
||||
)
|
||||
|
||||
event = DummyEvent()
|
||||
result = await request_tool_call_approval(
|
||||
config={"enable": True, "strategy": "dynamic_code"},
|
||||
ctx=ToolCallApprovalContext(
|
||||
event=event,
|
||||
tool_name="test_tool",
|
||||
tool_args={},
|
||||
tool_call_id="call_3",
|
||||
),
|
||||
)
|
||||
assert result.approved is False
|
||||
assert result.reason == "rejected"
|
||||
assert len(event.sent_messages) == 2
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_request_tool_call_approval_unsupported_strategy():
|
||||
event = DummyEvent()
|
||||
result = await request_tool_call_approval(
|
||||
config={"enable": True, "strategy": "unknown_strategy"},
|
||||
ctx=ToolCallApprovalContext(
|
||||
event=event,
|
||||
tool_name="test_tool",
|
||||
tool_args={},
|
||||
tool_call_id="call_4",
|
||||
),
|
||||
)
|
||||
assert result.approved is False
|
||||
assert result.reason == "unsupported_strategy"
|
||||
assert len(event.sent_messages) == 0
|
||||
@@ -105,6 +105,28 @@ class MockErrProvider(MockProvider):
|
||||
)
|
||||
|
||||
|
||||
class MockAbortableStreamProvider(MockProvider):
|
||||
async def text_chat_stream(self, **kwargs):
|
||||
abort_signal = kwargs.get("abort_signal")
|
||||
yield LLMResponse(
|
||||
role="assistant",
|
||||
completion_text="partial ",
|
||||
is_chunk=True,
|
||||
)
|
||||
if abort_signal and abort_signal.is_set():
|
||||
yield LLMResponse(
|
||||
role="assistant",
|
||||
completion_text="partial ",
|
||||
is_chunk=False,
|
||||
)
|
||||
return
|
||||
yield LLMResponse(
|
||||
role="assistant",
|
||||
completion_text="partial final",
|
||||
is_chunk=False,
|
||||
)
|
||||
|
||||
|
||||
class MockHooks(BaseAgentRunHooks):
|
||||
"""模拟钩子函数"""
|
||||
|
||||
@@ -394,6 +416,41 @@ async def test_fallback_provider_used_when_primary_returns_err(
|
||||
assert fallback_provider.call_count == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stop_signal_returns_aborted_and_persists_partial_message(
|
||||
runner, provider_request, mock_tool_executor, mock_hooks
|
||||
):
|
||||
provider = MockAbortableStreamProvider()
|
||||
|
||||
await runner.reset(
|
||||
provider=provider,
|
||||
request=provider_request,
|
||||
run_context=ContextWrapper(context=None),
|
||||
tool_executor=mock_tool_executor,
|
||||
agent_hooks=mock_hooks,
|
||||
streaming=True,
|
||||
)
|
||||
|
||||
step_iter = runner.step()
|
||||
first_resp = await step_iter.__anext__()
|
||||
assert first_resp.type == "streaming_delta"
|
||||
|
||||
runner.request_stop()
|
||||
|
||||
rest_responses = []
|
||||
async for response in step_iter:
|
||||
rest_responses.append(response)
|
||||
|
||||
assert any(resp.type == "aborted" for resp in rest_responses)
|
||||
assert runner.was_aborted() is True
|
||||
|
||||
final_resp = runner.get_final_llm_resp()
|
||||
assert final_resp is not None
|
||||
assert final_resp.role == "assistant"
|
||||
assert final_resp.completion_text == "partial "
|
||||
assert runner.run_context.messages[-1].role == "assistant"
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# 运行测试
|
||||
pytest.main([__file__, "-v"])
|
||||
|
||||
Reference in New Issue
Block a user