Compare commits
54 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 84994b5d98 | |||
| 1554f71106 | |||
| 476c01469f | |||
| 10163ec78a | |||
| 98b89ebcc5 | |||
| 39b9e55434 | |||
| 3eb15089af | |||
| c5b23d12a8 | |||
| 69f2fb291a | |||
| 78660da995 | |||
| c951b14aa2 | |||
| c384439b44 | |||
| 87d2750ff8 | |||
| 6d76d55452 | |||
| d80598b9c3 | |||
| c7d318304b | |||
| bcdbc15635 | |||
| 4749159bb9 | |||
| 5530a2260a | |||
| c24de24ca4 | |||
| b54b4c79ed | |||
| c6cc7aae84 | |||
| 84cd209074 | |||
| afda44fbe3 | |||
| f5d3b93437 | |||
| 069a3628fa | |||
| c81ef2672a | |||
| a5ae27cae0 | |||
| 73faaf6577 | |||
| 29dbd085d4 | |||
| 00b011809a | |||
| 0b46ca7ff3 | |||
| 9294b44831 | |||
| 80fd51119b | |||
| 5af5ad9e36 | |||
| 7b731ebda8 | |||
| 28bfb3b8b2 | |||
| 351895ae66 | |||
| c1009adf52 | |||
| ecaec41208 | |||
| 997b51102b | |||
| c5bd074c28 | |||
| 4c09ed3c09 | |||
| a56e43d17e | |||
| e357d9de74 | |||
| 94736ff199 | |||
| aff92a48bf | |||
| d0998a9dfb | |||
| 3678688433 | |||
| 0c03177840 | |||
| 20ff719c00 | |||
| 8a8ec492d7 | |||
| 02c1443dd1 | |||
| 79301f192c |
@@ -17,7 +17,6 @@ ENV/
|
||||
.conda/
|
||||
dashboard/
|
||||
data/
|
||||
changelogs/
|
||||
tests/
|
||||
.ruff_cache/
|
||||
.astrbot
|
||||
|
||||
@@ -37,7 +37,7 @@ jobs:
|
||||
mkdir -p data/temp
|
||||
export TESTING=true
|
||||
export ZHIPU_API_KEY=${{ secrets.OPENAI_API_KEY }}
|
||||
pytest --cov=. -v -o log_cli=true -o log_level=DEBUG
|
||||
pytest --cov=astrbot -v -o log_cli=true -o log_level=DEBUG
|
||||
|
||||
- name: Upload results to Codecov
|
||||
uses: codecov/codecov-action@v5
|
||||
|
||||
@@ -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>
|
||||
|
||||
+13
-17
@@ -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**
|
||||
|
||||
+14
-14
@@ -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
|
||||
```
|
||||
|
||||
|
||||
+14
-14
@@ -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
|
||||
```
|
||||
|
||||
|
||||
+17
-7
@@ -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 теперь доступен на их маркетплейсе.
|
||||
|
||||
+13
-3
@@ -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 與寶塔面板合作,已上架至寶塔面板。
|
||||
|
||||
@@ -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):
|
||||
@@ -182,16 +206,33 @@ class ConversationCommands:
|
||||
_titles[conv.cid] = title
|
||||
|
||||
"""遍历分页后的对话生成列表显示"""
|
||||
provider_settings = cfg.get("provider_settings", {})
|
||||
platform_name = message.get_platform_name()
|
||||
for conv in conversations_paged:
|
||||
persona_id = conv.persona_id
|
||||
if not persona_id or persona_id == "[%None]":
|
||||
persona = await self.context.persona_manager.get_default_persona_v3(
|
||||
umo=message.unified_msg_origin,
|
||||
)
|
||||
persona_id = persona["name"]
|
||||
(
|
||||
persona_id,
|
||||
_,
|
||||
force_applied_persona_id,
|
||||
_,
|
||||
) = await self.context.persona_manager.resolve_selected_persona(
|
||||
umo=message.unified_msg_origin,
|
||||
conversation_persona_id=conv.persona_id,
|
||||
platform_name=platform_name,
|
||||
provider_settings=provider_settings,
|
||||
)
|
||||
if persona_id == "[%None]":
|
||||
persona_name = "无"
|
||||
elif persona_id:
|
||||
persona_name = persona_id
|
||||
else:
|
||||
persona_name = "无"
|
||||
|
||||
if force_applied_persona_id:
|
||||
persona_name = f"{persona_name} (自定义规则)"
|
||||
|
||||
title = _titles.get(conv.cid, "新对话")
|
||||
parts.append(
|
||||
f"{global_index}. {title}({conv.cid[:4]})\n 人格情景: {persona_id}\n 上次更新: {datetime.datetime.fromtimestamp(conv.updated_at).strftime('%m-%d %H:%M')}\n"
|
||||
f"{global_index}. {title}({conv.cid[:4]})\n 人格情景: {persona_name}\n 上次更新: {datetime.datetime.fromtimestamp(conv.updated_at).strftime('%m-%d %H:%M')}\n"
|
||||
)
|
||||
global_index += 1
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import builtins
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from astrbot.api import sp, star
|
||||
from astrbot.api import star
|
||||
from astrbot.api.event import AstrMessageEvent, MessageEventResult
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -59,12 +59,7 @@ class PersonaCommands:
|
||||
default_persona = await self.context.persona_manager.get_default_persona_v3(
|
||||
umo=umo,
|
||||
)
|
||||
|
||||
force_applied_persona_id = (
|
||||
await sp.get_async(
|
||||
scope="umo", scope_id=umo, key="session_service_config", default={}
|
||||
)
|
||||
).get("persona_id")
|
||||
force_applied_persona_id = None
|
||||
|
||||
curr_cid_title = "无"
|
||||
if cid:
|
||||
@@ -80,10 +75,27 @@ class PersonaCommands:
|
||||
),
|
||||
)
|
||||
return
|
||||
if not conv.persona_id and conv.persona_id != "[%None]":
|
||||
curr_persona_name = default_persona["name"]
|
||||
else:
|
||||
curr_persona_name = conv.persona_id
|
||||
|
||||
provider_settings = self.context.get_config(umo=umo).get(
|
||||
"provider_settings",
|
||||
{},
|
||||
)
|
||||
(
|
||||
persona_id,
|
||||
_,
|
||||
force_applied_persona_id,
|
||||
_,
|
||||
) = await self.context.persona_manager.resolve_selected_persona(
|
||||
umo=umo,
|
||||
conversation_persona_id=conv.persona_id,
|
||||
platform_name=message.get_platform_name(),
|
||||
provider_settings=provider_settings,
|
||||
)
|
||||
|
||||
if persona_id == "[%None]":
|
||||
curr_persona_name = "无"
|
||||
elif persona_id:
|
||||
curr_persona_name = persona_id
|
||||
|
||||
if force_applied_persona_id:
|
||||
curr_persona_name = f"{curr_persona_name} (自定义规则)"
|
||||
|
||||
@@ -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.18.0"
|
||||
__version__ = "4.18.3"
|
||||
|
||||
@@ -4,19 +4,60 @@ from ..message import Message
|
||||
class ContextTruncator:
|
||||
"""Context truncator."""
|
||||
|
||||
def _has_tool_calls(self, message: Message) -> bool:
|
||||
"""Check if a message contains tool calls."""
|
||||
return (
|
||||
message.role == "assistant"
|
||||
and message.tool_calls is not None
|
||||
and len(message.tool_calls) > 0
|
||||
)
|
||||
|
||||
def fix_messages(self, messages: list[Message]) -> list[Message]:
|
||||
fixed_messages = []
|
||||
for message in messages:
|
||||
if message.role == "tool":
|
||||
# tool block 前面必须要有 user 和 assistant block
|
||||
if len(fixed_messages) < 2:
|
||||
# 这种情况可能是上下文被截断导致的
|
||||
# 我们直接将之前的上下文都清空
|
||||
fixed_messages = []
|
||||
else:
|
||||
fixed_messages.append(message)
|
||||
else:
|
||||
fixed_messages.append(message)
|
||||
"""修复消息列表,确保 tool call 和 tool response 的配对关系有效。
|
||||
|
||||
此方法确保:
|
||||
1. 每个 `tool` 消息前面都有一个包含 tool_calls 的 `assistant` 消息
|
||||
2. 每个包含 tool_calls 的 `assistant` 消息后面都有对应的 `tool` 响应
|
||||
|
||||
这是 OpenAI Chat Completions API 规范的要求(Gemini 对此执行严格检查)。
|
||||
"""
|
||||
if not messages:
|
||||
return messages
|
||||
|
||||
fixed_messages: list[Message] = []
|
||||
pending_assistant: Message | None = None
|
||||
pending_tools: list[Message] = []
|
||||
|
||||
def flush_pending_if_valid() -> None:
|
||||
nonlocal pending_assistant, pending_tools
|
||||
if pending_assistant is not None and pending_tools:
|
||||
fixed_messages.append(pending_assistant)
|
||||
fixed_messages.extend(pending_tools)
|
||||
pending_assistant = None
|
||||
pending_tools = []
|
||||
|
||||
for msg in messages:
|
||||
if msg.role == "tool":
|
||||
# 只有在有挂起的 assistant(tool_calls) 时才记录 tool 响应
|
||||
if pending_assistant is not None:
|
||||
pending_tools.append(msg)
|
||||
# else: 孤立的 tool 消息,直接忽略
|
||||
continue
|
||||
|
||||
if self._has_tool_calls(msg):
|
||||
# 遇到新的 assistant(tool_calls) 前,先处理旧的 pending 链
|
||||
flush_pending_if_valid()
|
||||
pending_assistant = msg
|
||||
continue
|
||||
|
||||
# 非 tool,且不含 tool_calls 的消息
|
||||
# 先结束任何 pending 链,再正常追加
|
||||
flush_pending_if_valid()
|
||||
fixed_messages.append(msg)
|
||||
|
||||
# 结束时处理最后一个 pending 链
|
||||
flush_pending_if_valid()
|
||||
|
||||
return fixed_messages
|
||||
|
||||
def truncate_by_turns(
|
||||
|
||||
@@ -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."
|
||||
),
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -137,6 +137,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 +330,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 +349,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 响应
|
||||
@@ -848,5 +900,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
|
||||
|
||||
@@ -20,15 +20,81 @@ 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"))
|
||||
|
||||
|
||||
def _truncate_tool_result(text: str, limit: int = 70) -> str:
|
||||
if limit <= 0:
|
||||
return ""
|
||||
if len(text) <= limit:
|
||||
return text
|
||||
if limit <= 3:
|
||||
return text[:limit]
|
||||
return f"{text[: limit - 3]}..."
|
||||
|
||||
|
||||
def _extract_chain_json_data(msg_chain: MessageChain) -> dict | None:
|
||||
if not msg_chain.chain:
|
||||
return None
|
||||
first_comp = msg_chain.chain[0]
|
||||
if isinstance(first_comp, Json) and isinstance(first_comp.data, dict):
|
||||
return first_comp.data
|
||||
return None
|
||||
|
||||
|
||||
def _record_tool_call_name(
|
||||
tool_info: dict | None, tool_name_by_call_id: dict[str, str]
|
||||
) -> None:
|
||||
if not isinstance(tool_info, dict):
|
||||
return
|
||||
tool_call_id = tool_info.get("id")
|
||||
tool_name = tool_info.get("name")
|
||||
if tool_call_id is None or tool_name is None:
|
||||
return
|
||||
tool_name_by_call_id[str(tool_call_id)] = str(tool_name)
|
||||
|
||||
|
||||
def _build_tool_call_status_message(tool_info: dict | None) -> str:
|
||||
if tool_info:
|
||||
return f"🔨 调用工具: {tool_info.get('name', 'unknown')}"
|
||||
return "🔨 调用工具..."
|
||||
|
||||
|
||||
def _build_tool_result_status_message(
|
||||
msg_chain: MessageChain, tool_name_by_call_id: dict[str, str]
|
||||
) -> str:
|
||||
tool_name = "unknown"
|
||||
tool_result = ""
|
||||
|
||||
result_data = _extract_chain_json_data(msg_chain)
|
||||
if result_data:
|
||||
tool_call_id = result_data.get("id")
|
||||
if tool_call_id is not None:
|
||||
tool_name = tool_name_by_call_id.pop(str(tool_call_id), "unknown")
|
||||
tool_result = str(result_data.get("result", ""))
|
||||
|
||||
if not tool_result:
|
||||
tool_result = msg_chain.get_plain_text(with_other_comps_mark=True)
|
||||
tool_result = _truncate_tool_result(tool_result, 70)
|
||||
|
||||
status_msg = f"🔨 调用工具: {tool_name}"
|
||||
if tool_result:
|
||||
status_msg = f"{status_msg}\n📎 返回结果: {tool_result}"
|
||||
return status_msg
|
||||
|
||||
|
||||
async def run_agent(
|
||||
agent_runner: AgentRunner,
|
||||
max_step: int = 30,
|
||||
show_tool_use: bool = True,
|
||||
show_tool_call_result: bool = False,
|
||||
stream_to_general: bool = False,
|
||||
show_reasoning: bool = False,
|
||||
) -> AsyncGenerator[MessageChain | None, None]:
|
||||
step_idx = 0
|
||||
astr_event = agent_runner.run_context.context.event
|
||||
tool_name_by_call_id: dict[str, str] = {}
|
||||
while step_idx < max_step + 1:
|
||||
step_idx += 1
|
||||
|
||||
@@ -48,10 +114,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"]
|
||||
|
||||
@@ -68,6 +152,13 @@ async def run_agent(
|
||||
continue
|
||||
if astr_event.get_platform_id() == "webchat":
|
||||
await astr_event.send(msg_chain)
|
||||
elif show_tool_use and show_tool_call_result:
|
||||
status_msg = _build_tool_result_status_message(
|
||||
msg_chain, tool_name_by_call_id
|
||||
)
|
||||
await astr_event.send(
|
||||
MessageChain(type="tool_call").message(status_msg)
|
||||
)
|
||||
# 对于其他情况,暂时先不处理
|
||||
continue
|
||||
elif resp.type == "tool_call":
|
||||
@@ -75,25 +166,22 @@ async def run_agent(
|
||||
# 用来标记流式响应需要分节
|
||||
yield MessageChain(chain=[], type="break")
|
||||
|
||||
tool_info = None
|
||||
|
||||
if resp.data["chain"].chain:
|
||||
json_comp = resp.data["chain"].chain[0]
|
||||
if isinstance(json_comp, Json):
|
||||
tool_info = json_comp.data
|
||||
astr_event.trace.record(
|
||||
"agent_tool_call",
|
||||
tool_name=tool_info if tool_info else "unknown",
|
||||
)
|
||||
tool_info = _extract_chain_json_data(resp.data["chain"])
|
||||
astr_event.trace.record(
|
||||
"agent_tool_call",
|
||||
tool_name=tool_info if tool_info else "unknown",
|
||||
)
|
||||
_record_tool_call_name(tool_info, tool_name_by_call_id)
|
||||
|
||||
if astr_event.get_platform_name() == "webchat":
|
||||
await astr_event.send(resp.data["chain"])
|
||||
elif show_tool_use:
|
||||
if tool_info:
|
||||
m = f"🔨 调用工具: {tool_info.get('name', 'unknown')}"
|
||||
else:
|
||||
m = "🔨 调用工具..."
|
||||
chain = MessageChain(type="tool_call").message(m)
|
||||
if show_tool_call_result and isinstance(tool_info, dict):
|
||||
# Delay tool status notification until tool_call_result.
|
||||
continue
|
||||
chain = MessageChain(type="tool_call").message(
|
||||
_build_tool_call_status_message(tool_info)
|
||||
)
|
||||
await astr_event.send(chain)
|
||||
continue
|
||||
|
||||
@@ -120,6 +208,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 +227,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,11 +255,20 @@ 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,
|
||||
max_step: int = 30,
|
||||
show_tool_use: bool = True,
|
||||
show_tool_call_result: bool = False,
|
||||
show_reasoning: bool = False,
|
||||
) -> AsyncGenerator[MessageChain | None, None]:
|
||||
"""Live Mode 的 Agent 运行器,支持流式 TTS
|
||||
@@ -169,6 +278,7 @@ async def run_live_agent(
|
||||
tts_provider: TTS Provider 实例
|
||||
max_step: 最大步数
|
||||
show_tool_use: 是否显示工具使用
|
||||
show_tool_call_result: 是否显示工具返回结果
|
||||
show_reasoning: 是否显示推理过程
|
||||
|
||||
Yields:
|
||||
@@ -180,6 +290,7 @@ async def run_live_agent(
|
||||
agent_runner,
|
||||
max_step=max_step,
|
||||
show_tool_use=show_tool_use,
|
||||
show_tool_call_result=show_tool_call_result,
|
||||
stream_to_general=False,
|
||||
show_reasoning=show_reasoning,
|
||||
):
|
||||
@@ -208,7 +319,12 @@ async def run_live_agent(
|
||||
# 1. 启动 Agent Feeder 任务:负责运行 Agent 并将文本分句喂给 text_queue
|
||||
feeder_task = asyncio.create_task(
|
||||
_run_agent_feeder(
|
||||
agent_runner, text_queue, max_step, show_tool_use, show_reasoning
|
||||
agent_runner,
|
||||
text_queue,
|
||||
max_step,
|
||||
show_tool_use,
|
||||
show_tool_call_result,
|
||||
show_reasoning,
|
||||
)
|
||||
)
|
||||
|
||||
@@ -294,6 +410,7 @@ async def _run_agent_feeder(
|
||||
text_queue: asyncio.Queue,
|
||||
max_step: int,
|
||||
show_tool_use: bool,
|
||||
show_tool_call_result: bool,
|
||||
show_reasoning: bool,
|
||||
) -> None:
|
||||
"""运行 Agent 并将文本输出分句放入队列"""
|
||||
@@ -303,6 +420,7 @@ async def _run_agent_feeder(
|
||||
agent_runner,
|
||||
max_step=max_step,
|
||||
show_tool_use=show_tool_use,
|
||||
show_tool_call_result=show_tool_call_result,
|
||||
stream_to_general=False,
|
||||
show_reasoning=show_reasoning,
|
||||
):
|
||||
|
||||
@@ -17,6 +17,12 @@ from astrbot.core.agent.tool_executor import BaseFunctionToolExecutor
|
||||
from astrbot.core.astr_agent_context import AstrAgentContext
|
||||
from astrbot.core.astr_main_agent_resources import (
|
||||
BACKGROUND_TASK_RESULT_WOKE_SYSTEM_PROMPT,
|
||||
EXECUTE_SHELL_TOOL,
|
||||
FILE_DOWNLOAD_TOOL,
|
||||
FILE_UPLOAD_TOOL,
|
||||
LOCAL_EXECUTE_SHELL_TOOL,
|
||||
LOCAL_PYTHON_TOOL,
|
||||
PYTHON_TOOL,
|
||||
SEND_MESSAGE_TO_USER_TOOL,
|
||||
)
|
||||
from astrbot.core.cron.events import CronMessageEvent
|
||||
@@ -45,6 +51,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
|
||||
@@ -84,6 +97,65 @@ class FunctionToolExecutor(BaseFunctionToolExecutor[AstrAgentContext]):
|
||||
yield r
|
||||
return
|
||||
|
||||
@classmethod
|
||||
def _get_runtime_computer_tools(cls, runtime: str) -> dict[str, FunctionTool]:
|
||||
if runtime == "sandbox":
|
||||
return {
|
||||
EXECUTE_SHELL_TOOL.name: EXECUTE_SHELL_TOOL,
|
||||
PYTHON_TOOL.name: PYTHON_TOOL,
|
||||
FILE_UPLOAD_TOOL.name: FILE_UPLOAD_TOOL,
|
||||
FILE_DOWNLOAD_TOOL.name: FILE_DOWNLOAD_TOOL,
|
||||
}
|
||||
if runtime == "local":
|
||||
return {
|
||||
LOCAL_EXECUTE_SHELL_TOOL.name: LOCAL_EXECUTE_SHELL_TOOL,
|
||||
LOCAL_PYTHON_TOOL.name: LOCAL_PYTHON_TOOL,
|
||||
}
|
||||
return {}
|
||||
|
||||
@classmethod
|
||||
def _build_handoff_toolset(
|
||||
cls,
|
||||
run_context: ContextWrapper[AstrAgentContext],
|
||||
tools: list[str | FunctionTool] | None,
|
||||
) -> ToolSet | None:
|
||||
ctx = run_context.context.context
|
||||
event = run_context.context.event
|
||||
cfg = ctx.get_config(umo=event.unified_msg_origin)
|
||||
provider_settings = cfg.get("provider_settings", {})
|
||||
runtime = str(provider_settings.get("computer_use_runtime", "local"))
|
||||
runtime_computer_tools = cls._get_runtime_computer_tools(runtime)
|
||||
|
||||
# Keep persona semantics aligned with the main agent: tools=None means
|
||||
# "all tools", including runtime computer-use tools.
|
||||
if tools is None:
|
||||
toolset = ToolSet()
|
||||
for registered_tool in llm_tools.func_list:
|
||||
if isinstance(registered_tool, HandoffTool):
|
||||
continue
|
||||
if registered_tool.active:
|
||||
toolset.add_tool(registered_tool)
|
||||
for runtime_tool in runtime_computer_tools.values():
|
||||
toolset.add_tool(runtime_tool)
|
||||
return None if toolset.empty() else toolset
|
||||
|
||||
if not tools:
|
||||
return None
|
||||
|
||||
toolset = ToolSet()
|
||||
for tool_name_or_obj in tools:
|
||||
if isinstance(tool_name_or_obj, str):
|
||||
registered_tool = llm_tools.get_func(tool_name_or_obj)
|
||||
if registered_tool and registered_tool.active:
|
||||
toolset.add_tool(registered_tool)
|
||||
continue
|
||||
runtime_tool = runtime_computer_tools.get(tool_name_or_obj)
|
||||
if runtime_tool:
|
||||
toolset.add_tool(runtime_tool)
|
||||
elif isinstance(tool_name_or_obj, FunctionTool):
|
||||
toolset.add_tool(tool_name_or_obj)
|
||||
return None if toolset.empty() else toolset
|
||||
|
||||
@classmethod
|
||||
async def _execute_handoff(
|
||||
cls,
|
||||
@@ -92,20 +164,10 @@ 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
|
||||
if tools:
|
||||
toolset = ToolSet()
|
||||
for t in tools:
|
||||
if isinstance(t, str):
|
||||
_t = llm_tools.get_func(t)
|
||||
if _t:
|
||||
toolset.add_tool(_t)
|
||||
elif isinstance(t, FunctionTool):
|
||||
toolset.add_tool(t)
|
||||
else:
|
||||
toolset = None
|
||||
# Build handoff toolset from registered tools plus runtime computer tools.
|
||||
toolset = cls._build_handoff_toolset(run_context, tool.agent.tools)
|
||||
|
||||
ctx = run_context.context.context
|
||||
event = run_context.context.event
|
||||
@@ -136,16 +198,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 +298,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:
|
||||
@@ -177,21 +315,53 @@ class FunctionToolExecutor(BaseFunctionToolExecutor[AstrAgentContext]):
|
||||
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 {tool.name} finished."
|
||||
),
|
||||
summary_name=tool.name,
|
||||
)
|
||||
|
||||
@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
|
||||
|
||||
note = (
|
||||
event.get_extra("background_note")
|
||||
or f"Background task {tool.name} finished."
|
||||
)
|
||||
extras = {
|
||||
"background_task_result": {
|
||||
"task_id": task_id,
|
||||
"tool_name": tool.name,
|
||||
"result": result_text or "",
|
||||
"tool_args": tool_args,
|
||||
}
|
||||
task_result = {
|
||||
"task_id": task_id,
|
||||
"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 +371,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 +397,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 +411,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 +421,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'}"
|
||||
)
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import builtins
|
||||
import copy
|
||||
import datetime
|
||||
import json
|
||||
@@ -10,7 +9,6 @@ import zoneinfo
|
||||
from collections.abc import Coroutine
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
from astrbot.api import sp
|
||||
from astrbot.core import logger
|
||||
from astrbot.core.agent.handoff import HandoffTool
|
||||
from astrbot.core.agent.mcp_client import MCPTool
|
||||
@@ -275,47 +273,26 @@ async def _ensure_persona_and_skills(
|
||||
if not req.conversation:
|
||||
return
|
||||
|
||||
# get persona ID
|
||||
|
||||
# 1. from session service config - highest priority
|
||||
persona_id = (
|
||||
await sp.get_async(
|
||||
scope="umo",
|
||||
scope_id=event.unified_msg_origin,
|
||||
key="session_service_config",
|
||||
default={},
|
||||
)
|
||||
).get("persona_id")
|
||||
|
||||
if not persona_id:
|
||||
# 2. from conversation setting - second priority
|
||||
persona_id = req.conversation.persona_id
|
||||
|
||||
if persona_id == "[%None]":
|
||||
# explicitly set to no persona
|
||||
pass
|
||||
elif persona_id is None:
|
||||
# 3. from config default persona setting - last priority
|
||||
persona_id = cfg.get("default_personality")
|
||||
|
||||
persona = next(
|
||||
builtins.filter(
|
||||
lambda persona: persona["name"] == persona_id,
|
||||
plugin_context.persona_manager.personas_v3,
|
||||
),
|
||||
None,
|
||||
(
|
||||
persona_id,
|
||||
persona,
|
||||
_,
|
||||
use_webchat_special_default,
|
||||
) = await plugin_context.persona_manager.resolve_selected_persona(
|
||||
umo=event.unified_msg_origin,
|
||||
conversation_persona_id=req.conversation.persona_id,
|
||||
platform_name=event.get_platform_name(),
|
||||
provider_settings=cfg,
|
||||
)
|
||||
|
||||
if persona:
|
||||
# Inject persona system prompt
|
||||
if prompt := persona["prompt"]:
|
||||
req.system_prompt += f"\n# Persona Instructions\n\n{prompt}\n"
|
||||
if begin_dialogs := copy.deepcopy(persona.get("_begin_dialogs_processed")):
|
||||
req.contexts[:0] = begin_dialogs
|
||||
else:
|
||||
# special handling for webchat persona
|
||||
if event.get_platform_name() == "webchat" and persona_id != "[%None]":
|
||||
persona_id = "_chatui_default_"
|
||||
req.system_prompt += CHATUI_SPECIAL_DEFAULT_PERSONA_PROMPT
|
||||
elif use_webchat_special_default:
|
||||
req.system_prompt += CHATUI_SPECIAL_DEFAULT_PERSONA_PROMPT
|
||||
|
||||
# Inject skills prompt
|
||||
runtime = cfg.get("computer_use_runtime", "local")
|
||||
|
||||
@@ -11,6 +11,7 @@ from astrbot.core.message.components import File
|
||||
from astrbot.core.utils.astrbot_path import get_astrbot_temp_path
|
||||
|
||||
from ..computer_client import get_booter
|
||||
from .permissions import check_admin_permission
|
||||
|
||||
# @dataclass
|
||||
# class CreateFileTool(FunctionTool):
|
||||
@@ -102,6 +103,8 @@ class FileUploadTool(FunctionTool):
|
||||
context: ContextWrapper[AstrAgentContext],
|
||||
local_path: str,
|
||||
) -> str | None:
|
||||
if permission_error := check_admin_permission(context, "File upload/download"):
|
||||
return permission_error
|
||||
sb = await get_booter(
|
||||
context.context.context,
|
||||
context.context.event.unified_msg_origin,
|
||||
@@ -161,6 +164,8 @@ class FileDownloadTool(FunctionTool):
|
||||
remote_path: str,
|
||||
also_send_to_user: bool = True,
|
||||
) -> ToolExecResult:
|
||||
if permission_error := check_admin_permission(context, "File upload/download"):
|
||||
return permission_error
|
||||
sb = await get_booter(
|
||||
context.context.context,
|
||||
context.context.event.unified_msg_origin,
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
from astrbot.core.agent.run_context import ContextWrapper
|
||||
from astrbot.core.astr_agent_context import AstrAgentContext
|
||||
|
||||
|
||||
def check_admin_permission(
|
||||
context: ContextWrapper[AstrAgentContext], operation_name: str
|
||||
) -> str | None:
|
||||
cfg = context.context.context.get_config(
|
||||
umo=context.context.event.unified_msg_origin
|
||||
)
|
||||
provider_settings = cfg.get("provider_settings", {})
|
||||
require_admin = provider_settings.get("computer_use_require_admin", True)
|
||||
if require_admin and context.context.event.role != "admin":
|
||||
return (
|
||||
f"error: Permission denied. {operation_name} is only allowed for admin users. "
|
||||
"Tell user to set admins in `AstrBot WebUI -> Config -> General Config` by adding their user ID to the admins list if they need this feature. "
|
||||
f"User's ID is: {context.context.event.get_sender_id()}. User's ID can be found by using /sid command."
|
||||
)
|
||||
return None
|
||||
@@ -7,6 +7,7 @@ from astrbot.core.agent.run_context import ContextWrapper
|
||||
from astrbot.core.agent.tool import ToolExecResult
|
||||
from astrbot.core.astr_agent_context import AstrAgentContext, AstrMessageEvent
|
||||
from astrbot.core.computer.computer_client import get_booter, get_local_booter
|
||||
from astrbot.core.computer.tools.permissions import check_admin_permission
|
||||
from astrbot.core.message.message_event_result import MessageChain
|
||||
|
||||
param_schema = {
|
||||
@@ -26,21 +27,6 @@ param_schema = {
|
||||
}
|
||||
|
||||
|
||||
def _check_admin_permission(context: ContextWrapper[AstrAgentContext]) -> str | None:
|
||||
cfg = context.context.context.get_config(
|
||||
umo=context.context.event.unified_msg_origin
|
||||
)
|
||||
provider_settings = cfg.get("provider_settings", {})
|
||||
require_admin = provider_settings.get("computer_use_require_admin", True)
|
||||
if require_admin and context.context.event.role != "admin":
|
||||
return (
|
||||
"error: Permission denied. Python execution is only allowed for admin users. "
|
||||
"Tell user to set admins in `AstrBot WebUI -> Config -> General Config` by adding their user ID to the admins list if they need this feature."
|
||||
f"User's ID is: {context.context.event.get_sender_id()}. User's ID can be found by using /sid command."
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
async def handle_result(result: dict, event: AstrMessageEvent) -> ToolExecResult:
|
||||
data = result.get("data", {})
|
||||
output = data.get("output", {})
|
||||
@@ -81,7 +67,7 @@ class PythonTool(FunctionTool):
|
||||
async def call(
|
||||
self, context: ContextWrapper[AstrAgentContext], code: str, silent: bool = False
|
||||
) -> ToolExecResult:
|
||||
if permission_error := _check_admin_permission(context):
|
||||
if permission_error := check_admin_permission(context, "Python execution"):
|
||||
return permission_error
|
||||
sb = await get_booter(
|
||||
context.context.context,
|
||||
@@ -104,7 +90,7 @@ class LocalPythonTool(FunctionTool):
|
||||
async def call(
|
||||
self, context: ContextWrapper[AstrAgentContext], code: str, silent: bool = False
|
||||
) -> ToolExecResult:
|
||||
if permission_error := _check_admin_permission(context):
|
||||
if permission_error := check_admin_permission(context, "Python execution"):
|
||||
return permission_error
|
||||
sb = get_local_booter()
|
||||
try:
|
||||
|
||||
@@ -7,21 +7,7 @@ from astrbot.core.agent.tool import ToolExecResult
|
||||
from astrbot.core.astr_agent_context import AstrAgentContext
|
||||
|
||||
from ..computer_client import get_booter, get_local_booter
|
||||
|
||||
|
||||
def _check_admin_permission(context: ContextWrapper[AstrAgentContext]) -> str | None:
|
||||
cfg = context.context.context.get_config(
|
||||
umo=context.context.event.unified_msg_origin
|
||||
)
|
||||
provider_settings = cfg.get("provider_settings", {})
|
||||
require_admin = provider_settings.get("computer_use_require_admin", True)
|
||||
if require_admin and context.context.event.role != "admin":
|
||||
return (
|
||||
"error: Permission denied. Shell execution is only allowed for admin users. "
|
||||
"Tell user to set admins in `AstrBot WebUI -> Config -> General Config` by adding their user ID to the admins list if they need this feature."
|
||||
f"User's ID is: {context.context.event.get_sender_id()}. User's ID can be found by using /sid command."
|
||||
)
|
||||
return None
|
||||
from .permissions import check_admin_permission
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -61,7 +47,7 @@ class ExecuteShellTool(FunctionTool):
|
||||
background: bool = False,
|
||||
env: dict = {},
|
||||
) -> ToolExecResult:
|
||||
if permission_error := _check_admin_permission(context):
|
||||
if permission_error := check_admin_permission(context, "Shell execution"):
|
||||
return permission_error
|
||||
|
||||
if self.is_local:
|
||||
|
||||
@@ -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.18.0"
|
||||
VERSION = "4.18.3"
|
||||
DB_PATH = os.path.join(get_astrbot_data_path(), "data_v4.db")
|
||||
|
||||
WEBHOOK_SUPPORTED_PLATFORMS = [
|
||||
@@ -100,6 +100,7 @@ DEFAULT_CONFIG = {
|
||||
"dequeue_context_length": 1,
|
||||
"streaming_response": False,
|
||||
"show_tool_use_status": False,
|
||||
"show_tool_call_result": False,
|
||||
"sanitize_context_by_modalities": False,
|
||||
"max_quoted_fallback_images": 20,
|
||||
"quoted_message_parser": {
|
||||
@@ -424,7 +425,15 @@ CONFIG_METADATA_2 = {
|
||||
"slack_webhook_port": 6197,
|
||||
"slack_webhook_path": "/astrbot-slack-webhook/callback",
|
||||
},
|
||||
# LINE's config is located in line_adapter.py
|
||||
"Line": {
|
||||
"id": "line",
|
||||
"type": "line",
|
||||
"enable": False,
|
||||
"channel_access_token": "",
|
||||
"channel_secret": "",
|
||||
"unified_webhook_mode": True,
|
||||
"webhook_uuid": "",
|
||||
},
|
||||
"Satori": {
|
||||
"id": "satori",
|
||||
"type": "satori",
|
||||
@@ -1462,6 +1471,7 @@ CONFIG_METADATA_2 = {
|
||||
"type": "openai_embedding",
|
||||
"provider": "openai",
|
||||
"provider_type": "embedding",
|
||||
"hint": "provider_group.provider.openai_embedding.hint",
|
||||
"enable": True,
|
||||
"embedding_api_key": "",
|
||||
"embedding_api_base": "",
|
||||
@@ -1475,6 +1485,7 @@ CONFIG_METADATA_2 = {
|
||||
"type": "gemini_embedding",
|
||||
"provider": "google",
|
||||
"provider_type": "embedding",
|
||||
"hint": "provider_group.provider.gemini_embedding.hint",
|
||||
"enable": True,
|
||||
"embedding_api_key": "",
|
||||
"embedding_api_base": "",
|
||||
@@ -2191,9 +2202,9 @@ CONFIG_METADATA_2 = {
|
||||
"type": "string",
|
||||
},
|
||||
"proxy": {
|
||||
"description": "代理地址",
|
||||
"description": "provider_group.provider.proxy.description",
|
||||
"type": "string",
|
||||
"hint": "HTTP/HTTPS 代理地址,格式如 http://127.0.0.1:7890。仅对该提供商的 API 请求生效,不影响 Docker 内网通信。",
|
||||
"hint": "provider_group.provider.proxy.hint",
|
||||
},
|
||||
"model": {
|
||||
"description": "模型 ID",
|
||||
@@ -2306,6 +2317,9 @@ CONFIG_METADATA_2 = {
|
||||
"show_tool_use_status": {
|
||||
"type": "bool",
|
||||
},
|
||||
"show_tool_call_result": {
|
||||
"type": "bool",
|
||||
},
|
||||
"unsupported_streaming_strategy": {
|
||||
"type": "string",
|
||||
},
|
||||
@@ -2994,6 +3008,15 @@ CONFIG_METADATA_3 = {
|
||||
"provider_settings.agent_runner_type": "local",
|
||||
},
|
||||
},
|
||||
"provider_settings.show_tool_call_result": {
|
||||
"description": "输出函数调用返回结果",
|
||||
"type": "bool",
|
||||
"hint": "仅在输出函数调用状态启用时生效,展示结果前 70 个字符。",
|
||||
"condition": {
|
||||
"provider_settings.agent_runner_type": "local",
|
||||
"provider_settings.show_tool_use_status": True,
|
||||
},
|
||||
},
|
||||
"provider_settings.sanitize_context_by_modalities": {
|
||||
"description": "按模型能力清理历史上下文",
|
||||
"type": "bool",
|
||||
|
||||
@@ -4,7 +4,7 @@ import typing as T
|
||||
from collections.abc import Awaitable, Callable
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from sqlalchemy import CursorResult
|
||||
from sqlalchemy import CursorResult, Row
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlmodel import col, delete, desc, func, or_, select, text, update
|
||||
|
||||
@@ -626,7 +626,7 @@ class SQLiteDatabase(BaseDatabase):
|
||||
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),
|
||||
or_(col(ApiKey.expires_at).is_(None), col(ApiKey.expires_at) > now),
|
||||
)
|
||||
result = await session.execute(query)
|
||||
return result.scalar_one_or_none()
|
||||
@@ -638,7 +638,7 @@ class SQLiteDatabase(BaseDatabase):
|
||||
async with session.begin():
|
||||
await session.execute(
|
||||
update(ApiKey)
|
||||
.where(ApiKey.key_id == key_id)
|
||||
.where(col(ApiKey.key_id) == key_id)
|
||||
.values(last_used_at=datetime.now(timezone.utc)),
|
||||
)
|
||||
|
||||
@@ -649,7 +649,7 @@ class SQLiteDatabase(BaseDatabase):
|
||||
async with session.begin():
|
||||
query = (
|
||||
update(ApiKey)
|
||||
.where(ApiKey.key_id == key_id)
|
||||
.where(col(ApiKey.key_id) == key_id)
|
||||
.values(revoked_at=datetime.now(timezone.utc))
|
||||
)
|
||||
result = T.cast(CursorResult, await session.execute(query))
|
||||
@@ -663,7 +663,7 @@ class SQLiteDatabase(BaseDatabase):
|
||||
result = T.cast(
|
||||
CursorResult,
|
||||
await session.execute(
|
||||
delete(ApiKey).where(ApiKey.key_id == key_id)
|
||||
delete(ApiKey).where(col(ApiKey.key_id) == key_id)
|
||||
),
|
||||
)
|
||||
return result.rowcount > 0
|
||||
@@ -1457,7 +1457,7 @@ class SQLiteDatabase(BaseDatabase):
|
||||
return query
|
||||
|
||||
@staticmethod
|
||||
def _rows_to_session_dicts(rows: list[tuple]) -> list[dict]:
|
||||
def _rows_to_session_dicts(rows: T.Sequence[Row[tuple]]) -> list[dict]:
|
||||
sessions_with_projects = []
|
||||
for row in rows:
|
||||
platform_session = row[0]
|
||||
|
||||
@@ -256,6 +256,46 @@ class KBSQLiteDatabase:
|
||||
"knowledge_base": row[1],
|
||||
}
|
||||
|
||||
async def get_documents_with_metadata_batch(
|
||||
self, doc_ids: set[str]
|
||||
) -> dict[str, dict]:
|
||||
"""批量获取文档及其所属知识库元数据
|
||||
|
||||
Args:
|
||||
doc_ids: 文档 ID 集合
|
||||
|
||||
Returns:
|
||||
dict: doc_id -> {"document": KBDocument, "knowledge_base": KnowledgeBase}
|
||||
|
||||
"""
|
||||
if not doc_ids:
|
||||
return {}
|
||||
|
||||
metadata_map: dict[str, dict] = {}
|
||||
# SQLite 参数上限为 999,分片查询避免超限
|
||||
chunk_size = 900
|
||||
doc_id_list = list(doc_ids)
|
||||
|
||||
async with self.get_db() as session:
|
||||
for i in range(0, len(doc_id_list), chunk_size):
|
||||
chunk = doc_id_list[i : i + chunk_size]
|
||||
stmt = (
|
||||
select(KBDocument, KnowledgeBase)
|
||||
.join(
|
||||
KnowledgeBase,
|
||||
col(KBDocument.kb_id) == col(KnowledgeBase.kb_id),
|
||||
)
|
||||
.where(col(KBDocument.doc_id).in_(chunk))
|
||||
)
|
||||
result = await session.execute(stmt)
|
||||
for row in result.all():
|
||||
metadata_map[row[0].doc_id] = {
|
||||
"document": row[0],
|
||||
"knowledge_base": row[1],
|
||||
}
|
||||
|
||||
return metadata_map
|
||||
|
||||
async def delete_document_by_id(self, doc_id: str, vec_db: FaissVecDB) -> None:
|
||||
"""删除单个文档及其相关数据"""
|
||||
# 在知识库表中删除
|
||||
|
||||
@@ -142,10 +142,13 @@ class RetrievalManager:
|
||||
f"Rank fusion took {time_end - time_start:.2f}s and returned {len(fused_results)} results.",
|
||||
)
|
||||
|
||||
# 4. 转换为 RetrievalResult (获取元数据)
|
||||
# 4. 转换为 RetrievalResult (批量获取元数据)
|
||||
doc_ids = {fr.doc_id for fr in fused_results}
|
||||
metadata_map = await self.kb_db.get_documents_with_metadata_batch(doc_ids)
|
||||
|
||||
retrieval_results = []
|
||||
for fr in fused_results:
|
||||
metadata_dict = await self.kb_db.get_document_with_metadata(fr.doc_id)
|
||||
metadata_dict = metadata_map.get(fr.doc_id)
|
||||
if metadata_dict:
|
||||
retrieval_results.append(
|
||||
RetrievalResult(
|
||||
|
||||
@@ -720,13 +720,38 @@ class File(BaseMessageComponent):
|
||||
if allow_return_url and self.url:
|
||||
return self.url
|
||||
|
||||
if self.file_ and os.path.exists(self.file_):
|
||||
return os.path.abspath(self.file_)
|
||||
if self.file_:
|
||||
path = self.file_
|
||||
if path.startswith("file://"):
|
||||
# 处理 file:// (2 slashes) 或 file:/// (3 slashes)
|
||||
# pathlib.as_uri() 通常生成 file:///
|
||||
path = path[7:]
|
||||
# 兼容 Windows: file:///C:/path -> /C:/path -> C:/path
|
||||
if (
|
||||
os.name == "nt"
|
||||
and len(path) > 2
|
||||
and path[0] == "/"
|
||||
and path[2] == ":"
|
||||
):
|
||||
path = path[1:]
|
||||
|
||||
if os.path.exists(path):
|
||||
return os.path.abspath(path)
|
||||
|
||||
if self.url:
|
||||
await self._download_file()
|
||||
if self.file_:
|
||||
return os.path.abspath(self.file_)
|
||||
path = self.file_
|
||||
if path.startswith("file://"):
|
||||
path = path[7:]
|
||||
if (
|
||||
os.name == "nt"
|
||||
and len(path) > 2
|
||||
and path[0] == "/"
|
||||
and path[2] == ":"
|
||||
):
|
||||
path = path[1:]
|
||||
return os.path.abspath(path)
|
||||
|
||||
return ""
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
from astrbot import logger
|
||||
from astrbot.api import sp
|
||||
from astrbot.core.astrbot_config_mgr import AstrBotConfigManager
|
||||
from astrbot.core.db import BaseDatabase
|
||||
from astrbot.core.db.po import Persona, PersonaFolder, Personality
|
||||
@@ -58,6 +59,60 @@ class PersonaManager:
|
||||
except Exception:
|
||||
return DEFAULT_PERSONALITY
|
||||
|
||||
async def resolve_selected_persona(
|
||||
self,
|
||||
*,
|
||||
umo: str | MessageSession,
|
||||
conversation_persona_id: str | None,
|
||||
platform_name: str,
|
||||
provider_settings: dict | None = None,
|
||||
) -> tuple[str | None, Personality | None, str | None, bool]:
|
||||
"""解析当前会话最终生效的人格。
|
||||
|
||||
Returns:
|
||||
tuple:
|
||||
- selected persona_id
|
||||
- selected persona object
|
||||
- force applied persona_id from session rule
|
||||
- whether use webchat special default persona
|
||||
"""
|
||||
session_service_config = (
|
||||
await sp.get_async(
|
||||
scope="umo",
|
||||
scope_id=str(umo),
|
||||
key="session_service_config",
|
||||
default={},
|
||||
)
|
||||
or {}
|
||||
)
|
||||
|
||||
force_applied_persona_id = session_service_config.get("persona_id")
|
||||
persona_id = force_applied_persona_id
|
||||
|
||||
if not persona_id:
|
||||
persona_id = conversation_persona_id
|
||||
if persona_id == "[%None]":
|
||||
pass
|
||||
elif persona_id is None:
|
||||
persona_id = (provider_settings or {}).get("default_personality")
|
||||
|
||||
persona = next(
|
||||
(item for item in self.personas_v3 if item["name"] == persona_id),
|
||||
None,
|
||||
)
|
||||
|
||||
use_webchat_special_default = False
|
||||
if not persona and platform_name == "webchat" and persona_id != "[%None]":
|
||||
persona_id = "_chatui_default_"
|
||||
use_webchat_special_default = True
|
||||
|
||||
return (
|
||||
persona_id,
|
||||
persona,
|
||||
force_applied_persona_id,
|
||||
use_webchat_special_default,
|
||||
)
|
||||
|
||||
async def delete_persona(self, persona_id: str) -> None:
|
||||
"""删除指定 persona"""
|
||||
if not await self.db.get_persona_by_id(persona_id):
|
||||
|
||||
@@ -1,30 +1,71 @@
|
||||
"""Pipeline package exports.
|
||||
|
||||
This module intentionally avoids eager imports of all pipeline stage modules to
|
||||
prevent import-time cycles. Stage classes remain available via lazy attribute
|
||||
resolution for backward compatibility.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from importlib import import_module
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from astrbot.core.message.message_event_result import (
|
||||
EventResultType,
|
||||
MessageEventResult,
|
||||
)
|
||||
|
||||
from .content_safety_check.stage import ContentSafetyCheckStage
|
||||
from .preprocess_stage.stage import PreProcessStage
|
||||
from .process_stage.stage import ProcessStage
|
||||
from .rate_limit_check.stage import RateLimitStage
|
||||
from .respond.stage import RespondStage
|
||||
from .result_decorate.stage import ResultDecorateStage
|
||||
from .session_status_check.stage import SessionStatusCheckStage
|
||||
from .waking_check.stage import WakingCheckStage
|
||||
from .whitelist_check.stage import WhitelistCheckStage
|
||||
from .stage_order import STAGES_ORDER
|
||||
|
||||
# 管道阶段顺序
|
||||
STAGES_ORDER = [
|
||||
"WakingCheckStage", # 检查是否需要唤醒
|
||||
"WhitelistCheckStage", # 检查是否在群聊/私聊白名单
|
||||
"SessionStatusCheckStage", # 检查会话是否整体启用
|
||||
"RateLimitStage", # 检查会话是否超过频率限制
|
||||
"ContentSafetyCheckStage", # 检查内容安全
|
||||
"PreProcessStage", # 预处理
|
||||
"ProcessStage", # 交由 Stars 处理(a.k.a 插件),或者 LLM 调用
|
||||
"ResultDecorateStage", # 处理结果,比如添加回复前缀、t2i、转换为语音 等
|
||||
"RespondStage", # 发送消息
|
||||
]
|
||||
if TYPE_CHECKING:
|
||||
from .content_safety_check.stage import ContentSafetyCheckStage
|
||||
from .preprocess_stage.stage import PreProcessStage
|
||||
from .process_stage.stage import ProcessStage
|
||||
from .rate_limit_check.stage import RateLimitStage
|
||||
from .respond.stage import RespondStage
|
||||
from .result_decorate.stage import ResultDecorateStage
|
||||
from .session_status_check.stage import SessionStatusCheckStage
|
||||
from .waking_check.stage import WakingCheckStage
|
||||
from .whitelist_check.stage import WhitelistCheckStage
|
||||
|
||||
_LAZY_EXPORTS = {
|
||||
"ContentSafetyCheckStage": (
|
||||
"astrbot.core.pipeline.content_safety_check.stage",
|
||||
"ContentSafetyCheckStage",
|
||||
),
|
||||
"PreProcessStage": (
|
||||
"astrbot.core.pipeline.preprocess_stage.stage",
|
||||
"PreProcessStage",
|
||||
),
|
||||
"ProcessStage": (
|
||||
"astrbot.core.pipeline.process_stage.stage",
|
||||
"ProcessStage",
|
||||
),
|
||||
"RateLimitStage": (
|
||||
"astrbot.core.pipeline.rate_limit_check.stage",
|
||||
"RateLimitStage",
|
||||
),
|
||||
"RespondStage": (
|
||||
"astrbot.core.pipeline.respond.stage",
|
||||
"RespondStage",
|
||||
),
|
||||
"ResultDecorateStage": (
|
||||
"astrbot.core.pipeline.result_decorate.stage",
|
||||
"ResultDecorateStage",
|
||||
),
|
||||
"SessionStatusCheckStage": (
|
||||
"astrbot.core.pipeline.session_status_check.stage",
|
||||
"SessionStatusCheckStage",
|
||||
),
|
||||
"WakingCheckStage": (
|
||||
"astrbot.core.pipeline.waking_check.stage",
|
||||
"WakingCheckStage",
|
||||
),
|
||||
"WhitelistCheckStage": (
|
||||
"astrbot.core.pipeline.whitelist_check.stage",
|
||||
"WhitelistCheckStage",
|
||||
),
|
||||
}
|
||||
|
||||
__all__ = [
|
||||
"ContentSafetyCheckStage",
|
||||
@@ -36,6 +77,21 @@ __all__ = [
|
||||
"RespondStage",
|
||||
"ResultDecorateStage",
|
||||
"SessionStatusCheckStage",
|
||||
"STAGES_ORDER",
|
||||
"WakingCheckStage",
|
||||
"WhitelistCheckStage",
|
||||
]
|
||||
|
||||
|
||||
def __getattr__(name: str) -> Any:
|
||||
if name not in _LAZY_EXPORTS:
|
||||
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
||||
module_path, attr_name = _LAZY_EXPORTS[name]
|
||||
module = import_module(module_path)
|
||||
value = getattr(module, attr_name)
|
||||
globals()[name] = value
|
||||
return value
|
||||
|
||||
|
||||
def __dir__() -> list[str]:
|
||||
return sorted(set(globals()) | set(__all__))
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
"""Pipeline bootstrap utilities."""
|
||||
|
||||
from importlib import import_module
|
||||
|
||||
from .stage import registered_stages
|
||||
|
||||
_BUILTIN_STAGE_MODULES = (
|
||||
"astrbot.core.pipeline.waking_check.stage",
|
||||
"astrbot.core.pipeline.whitelist_check.stage",
|
||||
"astrbot.core.pipeline.session_status_check.stage",
|
||||
"astrbot.core.pipeline.rate_limit_check.stage",
|
||||
"astrbot.core.pipeline.content_safety_check.stage",
|
||||
"astrbot.core.pipeline.preprocess_stage.stage",
|
||||
"astrbot.core.pipeline.process_stage.stage",
|
||||
"astrbot.core.pipeline.result_decorate.stage",
|
||||
"astrbot.core.pipeline.respond.stage",
|
||||
)
|
||||
|
||||
_EXPECTED_STAGE_NAMES = {
|
||||
"WakingCheckStage",
|
||||
"WhitelistCheckStage",
|
||||
"SessionStatusCheckStage",
|
||||
"RateLimitStage",
|
||||
"ContentSafetyCheckStage",
|
||||
"PreProcessStage",
|
||||
"ProcessStage",
|
||||
"ResultDecorateStage",
|
||||
"RespondStage",
|
||||
}
|
||||
|
||||
_builtin_stages_registered = False
|
||||
|
||||
|
||||
def ensure_builtin_stages_registered() -> None:
|
||||
"""Ensure built-in pipeline stages are imported and registered."""
|
||||
global _builtin_stages_registered
|
||||
|
||||
if _builtin_stages_registered:
|
||||
return
|
||||
|
||||
stage_names = {stage_cls.__name__ for stage_cls in registered_stages}
|
||||
if _EXPECTED_STAGE_NAMES.issubset(stage_names):
|
||||
_builtin_stages_registered = True
|
||||
return
|
||||
|
||||
for module_path in _BUILTIN_STAGE_MODULES:
|
||||
import_module(module_path)
|
||||
|
||||
_builtin_stages_registered = True
|
||||
|
||||
|
||||
__all__ = ["ensure_builtin_stages_registered"]
|
||||
@@ -1,7 +1,9 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
from astrbot.core.config import AstrBotConfig
|
||||
from astrbot.core.star import PluginManager
|
||||
|
||||
from .context_utils import call_event_hook, call_handler
|
||||
|
||||
@@ -11,7 +13,7 @@ class PipelineContext:
|
||||
"""上下文对象,包含管道执行所需的上下文信息"""
|
||||
|
||||
astrbot_config: AstrBotConfig # AstrBot 配置对象
|
||||
plugin_manager: PluginManager # 插件管理器对象
|
||||
plugin_manager: Any # 插件管理器对象
|
||||
astrbot_config_id: str
|
||||
call_handler = call_handler
|
||||
call_event_hook = call_event_hook
|
||||
|
||||
@@ -19,6 +19,7 @@ from astrbot.core.message.message_event_result import (
|
||||
MessageEventResult,
|
||||
ResultContentType,
|
||||
)
|
||||
from astrbot.core.pipeline.stage import Stage
|
||||
from astrbot.core.platform.astr_message_event import AstrMessageEvent
|
||||
from astrbot.core.provider.entities import (
|
||||
LLMResponse,
|
||||
@@ -30,7 +31,6 @@ from astrbot.core.utils.session_lock import session_lock_manager
|
||||
|
||||
from .....astr_agent_run_util import run_agent, run_live_agent
|
||||
from ....context import PipelineContext, call_event_hook
|
||||
from ...stage import Stage
|
||||
|
||||
|
||||
class InternalAgentSubStage(Stage):
|
||||
@@ -54,6 +54,7 @@ class InternalAgentSubStage(Stage):
|
||||
if isinstance(self.max_step, bool): # workaround: #2622
|
||||
self.max_step = 30
|
||||
self.show_tool_use: bool = settings.get("show_tool_use_status", True)
|
||||
self.show_tool_call_result: bool = settings.get("show_tool_call_result", False)
|
||||
self.show_reasoning = settings.get("display_reasoning_text", False)
|
||||
self.sanitize_context_by_modalities: bool = settings.get(
|
||||
"sanitize_context_by_modalities",
|
||||
@@ -240,6 +241,7 @@ class InternalAgentSubStage(Stage):
|
||||
tts_provider,
|
||||
self.max_step,
|
||||
self.show_tool_use,
|
||||
self.show_tool_call_result,
|
||||
show_reasoning=self.show_reasoning,
|
||||
),
|
||||
),
|
||||
@@ -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:
|
||||
@@ -266,6 +271,7 @@ class InternalAgentSubStage(Stage):
|
||||
agent_runner,
|
||||
self.max_step,
|
||||
self.show_tool_use,
|
||||
self.show_tool_call_result,
|
||||
show_reasoning=self.show_reasoning,
|
||||
),
|
||||
),
|
||||
@@ -294,6 +300,7 @@ class InternalAgentSubStage(Stage):
|
||||
agent_runner,
|
||||
self.max_step,
|
||||
self.show_tool_use,
|
||||
self.show_tool_call_result,
|
||||
stream_to_general,
|
||||
show_reasoning=self.show_reasoning,
|
||||
):
|
||||
@@ -308,13 +315,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 +348,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 +384,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
|
||||
|
||||
@@ -8,6 +8,7 @@ from astrbot.core.agent.runners.dashscope.dashscope_agent_runner import (
|
||||
DashscopeAgentRunner,
|
||||
)
|
||||
from astrbot.core.agent.runners.dify.dify_agent_runner import DifyAgentRunner
|
||||
from astrbot.core.astr_agent_hooks import MAIN_AGENT_HOOKS
|
||||
from astrbot.core.message.components import Image
|
||||
from astrbot.core.message.message_event_result import (
|
||||
MessageChain,
|
||||
@@ -17,6 +18,7 @@ from astrbot.core.message.message_event_result import (
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from astrbot.core.agent.runners.base import BaseAgentRunner
|
||||
from astrbot.core.pipeline.stage import Stage
|
||||
from astrbot.core.platform.astr_message_event import AstrMessageEvent
|
||||
from astrbot.core.provider.entities import (
|
||||
ProviderRequest,
|
||||
@@ -25,9 +27,7 @@ from astrbot.core.star.star_handler import EventType
|
||||
from astrbot.core.utils.metrics import Metric
|
||||
|
||||
from .....astr_agent_context import AgentContextWrapper, AstrAgentContext
|
||||
from .....astr_agent_hooks import MAIN_AGENT_HOOKS
|
||||
from ....context import PipelineContext, call_event_hook
|
||||
from ...stage import Stage
|
||||
|
||||
AGENT_RUNNER_TYPE_KEY = {
|
||||
"dify": "dify_agent_runner_provider_id",
|
||||
|
||||
@@ -8,15 +8,17 @@ from astrbot.core.platform.sources.wecom_ai_bot.wecomai_event import (
|
||||
)
|
||||
from astrbot.core.utils.active_event_registry import active_event_registry
|
||||
|
||||
from . import STAGES_ORDER
|
||||
from .bootstrap import ensure_builtin_stages_registered
|
||||
from .context import PipelineContext
|
||||
from .stage import registered_stages
|
||||
from .stage_order import STAGES_ORDER
|
||||
|
||||
|
||||
class PipelineScheduler:
|
||||
"""管道调度器,负责调度各个阶段的执行"""
|
||||
|
||||
def __init__(self, context: PipelineContext) -> None:
|
||||
ensure_builtin_stages_registered()
|
||||
registered_stages.sort(
|
||||
key=lambda x: STAGES_ORDER.index(x.__name__),
|
||||
) # 按照顺序排序
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
"""Pipeline stage execution order."""
|
||||
|
||||
STAGES_ORDER = [
|
||||
"WakingCheckStage", # 检查是否需要唤醒
|
||||
"WhitelistCheckStage", # 检查是否在群聊/私聊白名单
|
||||
"SessionStatusCheckStage", # 检查会话是否整体启用
|
||||
"RateLimitStage", # 检查会话是否超过频率限制
|
||||
"ContentSafetyCheckStage", # 检查内容安全
|
||||
"PreProcessStage", # 预处理
|
||||
"ProcessStage", # 交由 Stars 处理(a.k.a 插件),或者 LLM 调用
|
||||
"ResultDecorateStage", # 处理结果,比如添加回复前缀、t2i、转换为语音 等
|
||||
"RespondStage", # 发送消息
|
||||
]
|
||||
|
||||
__all__ = ["STAGES_ORDER"]
|
||||
@@ -52,9 +52,19 @@ class AstrMessageEvent(abc.ABC):
|
||||
self.is_at_or_wake_command = False
|
||||
"""是否是 At 机器人或者带有唤醒词或者是私聊(插件注册的事件监听器会让 is_wake 设为 True, 但是不会让这个属性置为 True)"""
|
||||
self._extras: dict[str, Any] = {}
|
||||
message_type = getattr(message_obj, "type", None)
|
||||
if not isinstance(message_type, MessageType):
|
||||
try:
|
||||
message_type = MessageType(str(message_type))
|
||||
except (ValueError, TypeError, AttributeError):
|
||||
logger.warning(
|
||||
f"Failed to convert message type {message_obj.type!r} to MessageType. "
|
||||
f"Falling back to FRIEND_MESSAGE."
|
||||
)
|
||||
message_type = MessageType.FRIEND_MESSAGE
|
||||
self.session = MessageSession(
|
||||
platform_name=platform_meta.id,
|
||||
message_type=message_obj.type,
|
||||
message_type=message_type,
|
||||
session_id=session_id,
|
||||
)
|
||||
# self.unified_msg_origin = str(self.session)
|
||||
@@ -159,15 +169,18 @@ class AstrMessageEvent(abc.ABC):
|
||||
|
||||
除了文本消息外,其他消息类型会被转换为对应的占位符。如图片消息会被转换为 [图片]。
|
||||
"""
|
||||
return self._outline_chain(self.message_obj.message)
|
||||
return self._outline_chain(getattr(self.message_obj, "message", None))
|
||||
|
||||
def get_messages(self) -> list[BaseMessageComponent]:
|
||||
"""获取消息链。"""
|
||||
return self.message_obj.message
|
||||
return getattr(self.message_obj, "message", [])
|
||||
|
||||
def get_message_type(self) -> MessageType:
|
||||
"""获取消息类型。"""
|
||||
return self.message_obj.type
|
||||
message_type = getattr(self.message_obj, "type", None)
|
||||
if isinstance(message_type, MessageType):
|
||||
return message_type
|
||||
return self.session.message_type
|
||||
|
||||
def get_session_id(self) -> str:
|
||||
"""获取会话id。"""
|
||||
@@ -175,21 +188,30 @@ class AstrMessageEvent(abc.ABC):
|
||||
|
||||
def get_group_id(self) -> str:
|
||||
"""获取群组id。如果不是群组消息,返回空字符串。"""
|
||||
return self.message_obj.group_id
|
||||
return getattr(self.message_obj, "group_id", "")
|
||||
|
||||
def get_self_id(self) -> str:
|
||||
"""获取机器人自身的id。"""
|
||||
return self.message_obj.self_id
|
||||
return getattr(self.message_obj, "self_id", "")
|
||||
|
||||
def get_sender_id(self) -> str:
|
||||
"""获取消息发送者的id。"""
|
||||
return self.message_obj.sender.user_id
|
||||
sender = getattr(self.message_obj, "sender", None)
|
||||
if sender and isinstance(getattr(sender, "user_id", None), str):
|
||||
return sender.user_id
|
||||
return ""
|
||||
|
||||
def get_sender_name(self) -> str:
|
||||
"""获取消息发送者的名称。(可能会返回空字符串)"""
|
||||
if isinstance(self.message_obj.sender.nickname, str):
|
||||
return self.message_obj.sender.nickname
|
||||
return ""
|
||||
sender = getattr(self.message_obj, "sender", None)
|
||||
if not sender:
|
||||
return ""
|
||||
nickname = getattr(sender, "nickname", None)
|
||||
if nickname is None:
|
||||
return ""
|
||||
if isinstance(nickname, str):
|
||||
return nickname
|
||||
return str(nickname)
|
||||
|
||||
def set_extra(self, key, value) -> None:
|
||||
"""设置额外的信息。"""
|
||||
@@ -208,7 +230,7 @@ class AstrMessageEvent(abc.ABC):
|
||||
|
||||
def is_private_chat(self) -> bool:
|
||||
"""是否是私聊。"""
|
||||
return self.message_obj.type.value == (MessageType.FRIEND_MESSAGE).value
|
||||
return self.get_message_type() == MessageType.FRIEND_MESSAGE
|
||||
|
||||
def is_wake_up(self) -> bool:
|
||||
"""是否是唤醒机器人的事件。"""
|
||||
|
||||
@@ -45,6 +45,19 @@ class AiocqhttpMessageEvent(AstrMessageEvent):
|
||||
if isinstance(segment, File):
|
||||
# For File segments, we need to handle the file differently
|
||||
d = await segment.to_dict()
|
||||
file_val = d.get("data", {}).get("file", "")
|
||||
if file_val:
|
||||
import pathlib
|
||||
|
||||
try:
|
||||
# 使用 pathlib 处理路径,能更好地处理 Windows/Linux 差异
|
||||
path_obj = pathlib.Path(file_val)
|
||||
# 如果是绝对路径且不包含协议头 (://),则转换为标准的 file: URI
|
||||
if path_obj.is_absolute() and "://" not in file_val:
|
||||
d["data"]["file"] = path_obj.as_uri()
|
||||
except Exception:
|
||||
# 如果不是合法路径(例如已经是特定的特殊字符串),则跳过转换
|
||||
pass
|
||||
return d
|
||||
if isinstance(segment, Video):
|
||||
d = await segment.to_dict()
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import asyncio
|
||||
import inspect
|
||||
import itertools
|
||||
import logging
|
||||
import time
|
||||
@@ -436,7 +437,42 @@ class AiocqhttpAdapter(Platform):
|
||||
return coro
|
||||
|
||||
async def terminate(self) -> None:
|
||||
self.shutdown_event.set()
|
||||
if hasattr(self, "shutdown_event"):
|
||||
self.shutdown_event.set()
|
||||
await self._close_reverse_ws_connections()
|
||||
|
||||
async def _close_reverse_ws_connections(self) -> None:
|
||||
api_clients = getattr(self.bot, "_wsr_api_clients", None)
|
||||
event_clients = getattr(self.bot, "_wsr_event_clients", None)
|
||||
|
||||
ws_clients: set[Any] = set()
|
||||
if isinstance(api_clients, dict):
|
||||
ws_clients.update(api_clients.values())
|
||||
if isinstance(event_clients, set):
|
||||
ws_clients.update(event_clients)
|
||||
|
||||
close_tasks: list[Awaitable[Any]] = []
|
||||
for ws in ws_clients:
|
||||
close_func = getattr(ws, "close", None)
|
||||
if not callable(close_func):
|
||||
continue
|
||||
try:
|
||||
close_result = close_func(code=1000, reason="Adapter shutdown")
|
||||
except TypeError:
|
||||
close_result = close_func()
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
if inspect.isawaitable(close_result):
|
||||
close_tasks.append(close_result)
|
||||
|
||||
if close_tasks:
|
||||
await asyncio.gather(*close_tasks, return_exceptions=True)
|
||||
|
||||
if isinstance(api_clients, dict):
|
||||
api_clients.clear()
|
||||
if isinstance(event_clients, set):
|
||||
event_clients.clear()
|
||||
|
||||
async def shutdown_trigger_placeholder(self) -> None:
|
||||
await self.shutdown_event.wait()
|
||||
|
||||
@@ -65,15 +65,6 @@ LINE_I18N_RESOURCES = {
|
||||
"line",
|
||||
"LINE Messaging API 适配器",
|
||||
support_streaming_message=False,
|
||||
default_config_tmpl={
|
||||
"id": "line",
|
||||
"type": "line",
|
||||
"enable": False,
|
||||
"channel_access_token": "",
|
||||
"channel_secret": "",
|
||||
"unified_webhook_mode": True,
|
||||
"webhook_uuid": "",
|
||||
},
|
||||
config_metadata=LINE_CONFIG_METADATA,
|
||||
i18n_resources=LINE_I18N_RESOURCES,
|
||||
)
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -142,6 +162,8 @@ class QQOfficialMessageEvent(AstrMessageEvent):
|
||||
)
|
||||
payload["media"] = media
|
||||
payload["msg_type"] = 7
|
||||
payload.pop("markdown", None)
|
||||
payload["content"] = plain_text or None
|
||||
if record_file_path: # group record msg
|
||||
media = await self.upload_group_and_c2c_record(
|
||||
record_file_path,
|
||||
@@ -150,6 +172,8 @@ class QQOfficialMessageEvent(AstrMessageEvent):
|
||||
)
|
||||
payload["media"] = media
|
||||
payload["msg_type"] = 7
|
||||
payload.pop("markdown", None)
|
||||
payload["content"] = plain_text or None
|
||||
ret = await self._send_with_markdown_fallback(
|
||||
send_func=lambda retry_payload: self.bot.api.post_group_message(
|
||||
group_openid=source.group_openid, # type: ignore
|
||||
@@ -168,6 +192,8 @@ class QQOfficialMessageEvent(AstrMessageEvent):
|
||||
)
|
||||
payload["media"] = media
|
||||
payload["msg_type"] = 7
|
||||
payload.pop("markdown", None)
|
||||
payload["content"] = plain_text or None
|
||||
if record_file_path: # c2c record
|
||||
media = await self.upload_group_and_c2c_record(
|
||||
record_file_path,
|
||||
@@ -176,6 +202,8 @@ class QQOfficialMessageEvent(AstrMessageEvent):
|
||||
)
|
||||
payload["media"] = media
|
||||
payload["msg_type"] = 7
|
||||
payload.pop("markdown", None)
|
||||
payload["content"] = plain_text or None
|
||||
if stream:
|
||||
ret = await self._send_with_markdown_fallback(
|
||||
send_func=lambda retry_payload: self.post_c2c_message(
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import asyncio
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import uuid
|
||||
from typing import cast
|
||||
|
||||
from apscheduler.schedulers.asyncio import AsyncIOScheduler
|
||||
from telegram import BotCommand, Update
|
||||
@@ -25,6 +27,9 @@ from astrbot.core.star.filter.command import CommandFilter
|
||||
from astrbot.core.star.filter.command_group import CommandGroupFilter
|
||||
from astrbot.core.star.star import star_map
|
||||
from astrbot.core.star.star_handler import star_handlers_registry
|
||||
from astrbot.core.utils.astrbot_path import get_astrbot_temp_path
|
||||
from astrbot.core.utils.io import download_file
|
||||
from astrbot.core.utils.media_utils import convert_audio_to_wav
|
||||
|
||||
from .tg_event import TelegramPlatformEvent
|
||||
|
||||
@@ -375,8 +380,19 @@ class TelegramPlatformAdapter(Platform):
|
||||
|
||||
elif update.message.voice:
|
||||
file = await update.message.voice.get_file()
|
||||
|
||||
file_basename = os.path.basename(cast(str, file.file_path))
|
||||
temp_dir = get_astrbot_temp_path()
|
||||
temp_path = os.path.join(temp_dir, file_basename)
|
||||
await download_file(cast(str, file.file_path), path=temp_path)
|
||||
path_wav = os.path.join(
|
||||
temp_dir,
|
||||
f"{file_basename}.wav",
|
||||
)
|
||||
path_wav = await convert_audio_to_wav(temp_path, path_wav)
|
||||
|
||||
message.message = [
|
||||
Comp.Record(file=file.file_path, url=file.file_path),
|
||||
Comp.Record(file=path_wav, url=path_wav),
|
||||
]
|
||||
|
||||
elif update.message.photo:
|
||||
|
||||
@@ -18,6 +18,7 @@ from astrbot.api.message_components import (
|
||||
Plain,
|
||||
Record,
|
||||
Reply,
|
||||
Video,
|
||||
)
|
||||
from astrbot.api.platform import AstrBotMessage, MessageType, PlatformMetadata
|
||||
|
||||
@@ -36,6 +37,7 @@ class TelegramPlatformEvent(AstrMessageEvent):
|
||||
# 消息类型到 chat action 的映射,用于优先级判断
|
||||
ACTION_BY_TYPE: dict[type, str] = {
|
||||
Record: ChatAction.UPLOAD_VOICE,
|
||||
Video: ChatAction.UPLOAD_VIDEO,
|
||||
File: ChatAction.UPLOAD_DOCUMENT,
|
||||
Image: ChatAction.UPLOAD_PHOTO,
|
||||
Plain: ChatAction.TYPING,
|
||||
@@ -114,10 +116,18 @@ class TelegramPlatformEvent(AstrMessageEvent):
|
||||
**payload: Any,
|
||||
) -> None:
|
||||
"""发送媒体时显示 upload action,发送完成后恢复 typing"""
|
||||
await cls._send_chat_action(client, user_name, upload_action, message_thread_id)
|
||||
await send_coro(**payload)
|
||||
effective_thread_id = message_thread_id or cast(
|
||||
str | None, payload.get("message_thread_id")
|
||||
)
|
||||
await cls._send_chat_action(
|
||||
client, user_name, ChatAction.TYPING, message_thread_id
|
||||
client, user_name, upload_action, effective_thread_id
|
||||
)
|
||||
send_payload = dict(payload)
|
||||
if effective_thread_id and "message_thread_id" not in send_payload:
|
||||
send_payload["message_thread_id"] = effective_thread_id
|
||||
await send_coro(**send_payload)
|
||||
await cls._send_chat_action(
|
||||
client, user_name, ChatAction.TYPING, effective_thread_id
|
||||
)
|
||||
|
||||
@classmethod
|
||||
@@ -141,14 +151,16 @@ class TelegramPlatformEvent(AstrMessageEvent):
|
||||
"""
|
||||
try:
|
||||
if use_media_action:
|
||||
media_payload = dict(payload)
|
||||
if message_thread_id and "message_thread_id" not in media_payload:
|
||||
media_payload["message_thread_id"] = message_thread_id
|
||||
await cls._send_media_with_action(
|
||||
client,
|
||||
ChatAction.UPLOAD_VOICE,
|
||||
client.send_voice,
|
||||
user_name=user_name,
|
||||
message_thread_id=message_thread_id,
|
||||
voice=path,
|
||||
**cast(Any, payload),
|
||||
**cast(Any, media_payload),
|
||||
)
|
||||
else:
|
||||
await client.send_voice(voice=path, **cast(Any, payload))
|
||||
@@ -162,15 +174,17 @@ class TelegramPlatformEvent(AstrMessageEvent):
|
||||
"To enable voice messages, go to Telegram Settings → Privacy and Security → Voice Messages → set to 'Everyone'."
|
||||
)
|
||||
if use_media_action:
|
||||
media_payload = dict(payload)
|
||||
if message_thread_id and "message_thread_id" not in media_payload:
|
||||
media_payload["message_thread_id"] = message_thread_id
|
||||
await cls._send_media_with_action(
|
||||
client,
|
||||
ChatAction.UPLOAD_DOCUMENT,
|
||||
client.send_document,
|
||||
user_name=user_name,
|
||||
message_thread_id=message_thread_id,
|
||||
document=path,
|
||||
caption=caption,
|
||||
**cast(Any, payload),
|
||||
**cast(Any, media_payload),
|
||||
)
|
||||
else:
|
||||
await client.send_document(
|
||||
@@ -278,6 +292,13 @@ class TelegramPlatformEvent(AstrMessageEvent):
|
||||
caption=i.text or None,
|
||||
use_media_action=False,
|
||||
)
|
||||
elif isinstance(i, Video):
|
||||
path = await i.convert_to_file_path()
|
||||
await client.send_video(
|
||||
video=path,
|
||||
caption=getattr(i, "text", None) or None,
|
||||
**cast(Any, payload),
|
||||
)
|
||||
|
||||
async def send(self, message: MessageChain) -> None:
|
||||
if self.get_message_type() == MessageType.GROUP_MESSAGE:
|
||||
@@ -333,7 +354,7 @@ class TelegramPlatformEvent(AstrMessageEvent):
|
||||
"chat_id": user_name,
|
||||
}
|
||||
if message_thread_id:
|
||||
payload["reply_to_message_id"] = message_thread_id
|
||||
payload["message_thread_id"] = message_thread_id
|
||||
|
||||
delta = ""
|
||||
current_content = ""
|
||||
@@ -375,7 +396,6 @@ class TelegramPlatformEvent(AstrMessageEvent):
|
||||
ChatAction.UPLOAD_PHOTO,
|
||||
self.client.send_photo,
|
||||
user_name=user_name,
|
||||
message_thread_id=message_thread_id,
|
||||
photo=image_path,
|
||||
**cast(Any, payload),
|
||||
)
|
||||
@@ -388,7 +408,6 @@ class TelegramPlatformEvent(AstrMessageEvent):
|
||||
ChatAction.UPLOAD_DOCUMENT,
|
||||
self.client.send_document,
|
||||
user_name=user_name,
|
||||
message_thread_id=message_thread_id,
|
||||
document=path,
|
||||
filename=name,
|
||||
**cast(Any, payload),
|
||||
@@ -406,6 +425,17 @@ class TelegramPlatformEvent(AstrMessageEvent):
|
||||
use_media_action=True,
|
||||
)
|
||||
continue
|
||||
elif isinstance(i, Video):
|
||||
path = await i.convert_to_file_path()
|
||||
await self._send_media_with_action(
|
||||
self.client,
|
||||
ChatAction.UPLOAD_VIDEO,
|
||||
self.client.send_video,
|
||||
user_name=user_name,
|
||||
video=path,
|
||||
**cast(Any, payload),
|
||||
)
|
||||
continue
|
||||
else:
|
||||
logger.warning(f"不支持的消息类型: {type(i)}")
|
||||
continue
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -3,7 +3,7 @@ import os
|
||||
import sys
|
||||
import time
|
||||
import uuid
|
||||
from collections.abc import Awaitable, Callable
|
||||
from collections.abc import Callable, Coroutine
|
||||
from typing import Any, cast
|
||||
|
||||
import quart
|
||||
@@ -65,7 +65,9 @@ class WeixinOfficialAccountServer:
|
||||
|
||||
self.event_queue = event_queue
|
||||
|
||||
self.callback: Callable[[BaseMessage], Awaitable[None]] | None = None
|
||||
self.callback: (
|
||||
Callable[[BaseMessage], Coroutine[Any, Any, str | None]] | None
|
||||
) = None
|
||||
self.shutdown_event = asyncio.Event()
|
||||
|
||||
self._wx_msg_time_out = 4.0 # 微信服务器要求 5 秒内回复
|
||||
|
||||
@@ -48,6 +48,9 @@ class GeminiEmbeddingProvider(EmbeddingProvider):
|
||||
result = await self.client.models.embed_content(
|
||||
model=self.model,
|
||||
contents=text,
|
||||
config=types.EmbedContentConfig(
|
||||
output_dimensionality=self.get_dim(),
|
||||
),
|
||||
)
|
||||
assert result.embeddings is not None
|
||||
assert result.embeddings[0].values is not None
|
||||
@@ -61,6 +64,9 @@ class GeminiEmbeddingProvider(EmbeddingProvider):
|
||||
result = await self.client.models.embed_content(
|
||||
model=self.model,
|
||||
contents=cast(types.ContentListUnion, text),
|
||||
config=types.EmbedContentConfig(
|
||||
output_dimensionality=self.get_dim(),
|
||||
),
|
||||
)
|
||||
assert result.embeddings is not None
|
||||
|
||||
|
||||
@@ -23,12 +23,16 @@ class OpenAIEmbeddingProvider(EmbeddingProvider):
|
||||
if proxy:
|
||||
logger.info(f"[OpenAI Embedding] 使用代理: {proxy}")
|
||||
http_client = httpx.AsyncClient(proxy=proxy)
|
||||
api_base = provider_config.get("embedding_api_base", "").strip()
|
||||
if not api_base:
|
||||
api_base = "https://api.openai.com/v1"
|
||||
else:
|
||||
api_base = api_base.removesuffix("/")
|
||||
if not api_base.endswith("/v1"):
|
||||
api_base = f"{api_base}/v1"
|
||||
self.client = AsyncOpenAI(
|
||||
api_key=provider_config.get("embedding_api_key"),
|
||||
base_url=provider_config.get(
|
||||
"embedding_api_base",
|
||||
"https://api.openai.com/v1",
|
||||
),
|
||||
base_url=api_base,
|
||||
timeout=int(provider_config.get("timeout", 20)),
|
||||
http_client=http_client,
|
||||
)
|
||||
@@ -36,12 +40,20 @@ class OpenAIEmbeddingProvider(EmbeddingProvider):
|
||||
|
||||
async def get_embedding(self, text: str) -> list[float]:
|
||||
"""获取文本的嵌入"""
|
||||
embedding = await self.client.embeddings.create(input=text, model=self.model)
|
||||
embedding = await self.client.embeddings.create(
|
||||
input=text,
|
||||
model=self.model,
|
||||
dimensions=self.get_dim(),
|
||||
)
|
||||
return embedding.data[0].embedding
|
||||
|
||||
async def get_embeddings(self, text: list[str]) -> list[list[float]]:
|
||||
"""批量获取文本的嵌入"""
|
||||
embeddings = await self.client.embeddings.create(input=text, model=self.model)
|
||||
embeddings = await self.client.embeddings.create(
|
||||
input=text,
|
||||
model=self.model,
|
||||
dimensions=self.get_dim(),
|
||||
)
|
||||
return [item.embedding for item in embeddings.data]
|
||||
|
||||
def get_dim(self) -> int:
|
||||
|
||||
@@ -1,68 +1,19 @@
|
||||
from astrbot.core import html_renderer
|
||||
# 兼容导出: Provider 从 provider 模块重新导出
|
||||
from astrbot.core.provider import Provider
|
||||
from astrbot.core.star.star_tools import StarTools
|
||||
from astrbot.core.utils.command_parser import CommandParserMixin
|
||||
from astrbot.core.utils.plugin_kv_store import PluginKVStoreMixin
|
||||
|
||||
from .base import Star
|
||||
from .context import Context
|
||||
from .star import StarMetadata, star_map, star_registry
|
||||
from .star_manager import PluginManager
|
||||
from .star_tools import StarTools
|
||||
|
||||
|
||||
class Star(CommandParserMixin, PluginKVStoreMixin):
|
||||
"""所有插件(Star)的父类,所有插件都应该继承于这个类"""
|
||||
|
||||
author: str
|
||||
name: str
|
||||
|
||||
def __init__(self, context: Context, config: dict | None = None) -> None:
|
||||
StarTools.initialize(context)
|
||||
self.context = context
|
||||
|
||||
def __init_subclass__(cls, **kwargs):
|
||||
super().__init_subclass__(**kwargs)
|
||||
if not star_map.get(cls.__module__):
|
||||
metadata = StarMetadata(
|
||||
star_cls_type=cls,
|
||||
module_path=cls.__module__,
|
||||
)
|
||||
star_map[cls.__module__] = metadata
|
||||
star_registry.append(metadata)
|
||||
else:
|
||||
star_map[cls.__module__].star_cls_type = cls
|
||||
star_map[cls.__module__].module_path = cls.__module__
|
||||
|
||||
async def text_to_image(self, text: str, return_url=True) -> str:
|
||||
"""将文本转换为图片"""
|
||||
return await html_renderer.render_t2i(
|
||||
text,
|
||||
return_url=return_url,
|
||||
template_name=self.context._config.get("t2i_active_template"),
|
||||
)
|
||||
|
||||
async def html_render(
|
||||
self,
|
||||
tmpl: str,
|
||||
data: dict,
|
||||
return_url=True,
|
||||
options: dict | None = None,
|
||||
) -> str:
|
||||
"""渲染 HTML"""
|
||||
return await html_renderer.render_custom_template(
|
||||
tmpl,
|
||||
data,
|
||||
return_url=return_url,
|
||||
options=options,
|
||||
)
|
||||
|
||||
async def initialize(self) -> None:
|
||||
"""当插件被激活时会调用这个方法"""
|
||||
|
||||
async def terminate(self) -> None:
|
||||
"""当插件被禁用、重载插件时会调用这个方法"""
|
||||
|
||||
def __del__(self) -> None:
|
||||
"""[Deprecated] 当插件被禁用、重载插件时会调用这个方法"""
|
||||
|
||||
|
||||
__all__ = ["Context", "PluginManager", "Provider", "Star", "StarMetadata", "StarTools"]
|
||||
__all__ = [
|
||||
"Context",
|
||||
"PluginManager",
|
||||
"Provider",
|
||||
"Star",
|
||||
"StarMetadata",
|
||||
"StarTools",
|
||||
"star_map",
|
||||
"star_registry",
|
||||
]
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any, Protocol
|
||||
|
||||
from astrbot.core import html_renderer
|
||||
from astrbot.core.utils.command_parser import CommandParserMixin
|
||||
from astrbot.core.utils.plugin_kv_store import PluginKVStoreMixin
|
||||
|
||||
from .star import StarMetadata, star_map, star_registry
|
||||
|
||||
logger = logging.getLogger("astrbot")
|
||||
|
||||
|
||||
class Star(CommandParserMixin, PluginKVStoreMixin):
|
||||
"""所有插件(Star)的父类,所有插件都应该继承于这个类"""
|
||||
|
||||
author: str
|
||||
name: str
|
||||
|
||||
class _ContextLike(Protocol):
|
||||
def get_config(self, umo: str | None = None) -> Any: ...
|
||||
|
||||
def __init__(self, context: _ContextLike, config: dict | None = None) -> None:
|
||||
self.context = context
|
||||
|
||||
def _get_context_config(self) -> Any:
|
||||
get_config = getattr(self.context, "get_config", None)
|
||||
if callable(get_config):
|
||||
try:
|
||||
return get_config()
|
||||
except Exception as e:
|
||||
logger.debug(f"get_config() failed: {e}")
|
||||
return None
|
||||
return getattr(self.context, "_config", None)
|
||||
|
||||
def __init_subclass__(cls, **kwargs):
|
||||
super().__init_subclass__(**kwargs)
|
||||
if not star_map.get(cls.__module__):
|
||||
metadata = StarMetadata(
|
||||
star_cls_type=cls,
|
||||
module_path=cls.__module__,
|
||||
)
|
||||
star_map[cls.__module__] = metadata
|
||||
star_registry.append(metadata)
|
||||
else:
|
||||
star_map[cls.__module__].star_cls_type = cls
|
||||
star_map[cls.__module__].module_path = cls.__module__
|
||||
|
||||
async def text_to_image(self, text: str, return_url=True) -> str:
|
||||
"""将文本转换为图片"""
|
||||
config_obj = self._get_context_config()
|
||||
template_name = None
|
||||
if hasattr(config_obj, "get"):
|
||||
try:
|
||||
template_name = config_obj.get("t2i_active_template")
|
||||
except Exception:
|
||||
template_name = None
|
||||
return await html_renderer.render_t2i(
|
||||
text,
|
||||
return_url=return_url,
|
||||
template_name=template_name,
|
||||
)
|
||||
|
||||
async def html_render(
|
||||
self,
|
||||
tmpl: str,
|
||||
data: dict,
|
||||
return_url=True,
|
||||
options: dict | None = None,
|
||||
) -> str:
|
||||
"""渲染 HTML"""
|
||||
return await html_renderer.render_custom_template(
|
||||
tmpl,
|
||||
data,
|
||||
return_url=return_url,
|
||||
options=options,
|
||||
)
|
||||
|
||||
async def initialize(self) -> None:
|
||||
"""当插件被激活时会调用这个方法"""
|
||||
|
||||
async def terminate(self) -> None:
|
||||
"""当插件被禁用、重载插件时会调用这个方法"""
|
||||
|
||||
def __del__(self) -> None:
|
||||
"""[Deprecated] 当插件被禁用、重载插件时会调用这个方法"""
|
||||
@@ -1,7 +1,9 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from asyncio import Queue
|
||||
from collections.abc import Awaitable, Callable
|
||||
from typing import Any
|
||||
from typing import TYPE_CHECKING, Any, Protocol
|
||||
|
||||
from deprecated import deprecated
|
||||
|
||||
@@ -12,14 +14,12 @@ from astrbot.core.agent.tool import ToolSet
|
||||
from astrbot.core.astrbot_config_mgr import AstrBotConfigManager
|
||||
from astrbot.core.config.astrbot_config import AstrBotConfig
|
||||
from astrbot.core.conversation_mgr import ConversationManager
|
||||
from astrbot.core.cron.manager import CronJobManager
|
||||
from astrbot.core.db import BaseDatabase
|
||||
from astrbot.core.knowledge_base.kb_mgr import KnowledgeBaseManager
|
||||
from astrbot.core.message.message_event_result import MessageChain
|
||||
from astrbot.core.persona_mgr import PersonaManager
|
||||
from astrbot.core.platform import Platform
|
||||
from astrbot.core.platform.astr_message_event import AstrMessageEvent, MessageSesion
|
||||
from astrbot.core.platform.manager import PlatformManager
|
||||
from astrbot.core.platform_message_history_mgr import PlatformMessageHistoryManager
|
||||
from astrbot.core.provider.entities import LLMResponse, ProviderRequest, ProviderType
|
||||
from astrbot.core.provider.func_tool_manager import FunctionTool, FunctionToolManager
|
||||
@@ -45,6 +45,15 @@ from .star_handler import EventType, StarHandlerMetadata, star_handlers_registry
|
||||
|
||||
logger = logging.getLogger("astrbot")
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from astrbot.core.cron.manager import CronJobManager
|
||||
else:
|
||||
CronJobManager = Any
|
||||
|
||||
|
||||
class PlatformManagerProtocol(Protocol):
|
||||
platform_insts: list[Platform]
|
||||
|
||||
|
||||
class Context:
|
||||
"""暴露给插件的接口上下文。"""
|
||||
@@ -61,7 +70,7 @@ class Context:
|
||||
config: AstrBotConfig,
|
||||
db: BaseDatabase,
|
||||
provider_manager: ProviderManager,
|
||||
platform_manager: PlatformManager,
|
||||
platform_manager: PlatformManagerProtocol,
|
||||
conversation_manager: ConversationManager,
|
||||
message_history_manager: PlatformMessageHistoryManager,
|
||||
persona_manager: PersonaManager,
|
||||
@@ -448,6 +457,9 @@ class Context:
|
||||
if platform.meta().id == session.platform_name:
|
||||
await platform.send_by_session(session, message_chain)
|
||||
return True
|
||||
logger.warning(
|
||||
f"cannot find platform for session {str(session)}, message not sent"
|
||||
)
|
||||
return False
|
||||
|
||||
def add_llm_tools(self, *tools: FunctionTool) -> None:
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import warnings
|
||||
|
||||
from astrbot.core.star import StarMetadata, star_map
|
||||
from astrbot.core.star.star import StarMetadata, star_map
|
||||
|
||||
_warned_register_star = False
|
||||
|
||||
|
||||
@@ -11,7 +11,6 @@ from astrbot.core.agent.agent import Agent
|
||||
from astrbot.core.agent.handoff import HandoffTool
|
||||
from astrbot.core.agent.hooks import BaseAgentRunHooks
|
||||
from astrbot.core.agent.tool import FunctionTool
|
||||
from astrbot.core.astr_agent_context import AstrAgentContext
|
||||
from astrbot.core.message.message_event_result import MessageEventResult
|
||||
from astrbot.core.provider.func_tool_manager import PY_TO_JSON_TYPE, SUPPORTED_TYPES
|
||||
from astrbot.core.provider.register import llm_tools
|
||||
@@ -357,6 +356,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 时的通知事件(在获取锁之前)
|
||||
|
||||
@@ -583,7 +616,7 @@ class RegisteringAgent:
|
||||
kwargs["registering_agent"] = self
|
||||
return register_llm_tool(*args, **kwargs)
|
||||
|
||||
def __init__(self, agent: Agent[AstrAgentContext]) -> None:
|
||||
def __init__(self, agent: Agent[Any]) -> None:
|
||||
self._agent = agent
|
||||
|
||||
|
||||
@@ -591,7 +624,7 @@ def register_agent(
|
||||
name: str,
|
||||
instruction: str,
|
||||
tools: list[str | FunctionTool] | None = None,
|
||||
run_hooks: BaseAgentRunHooks[AstrAgentContext] | None = None,
|
||||
run_hooks: BaseAgentRunHooks[Any] | None = None,
|
||||
):
|
||||
"""注册一个 Agent
|
||||
|
||||
@@ -605,12 +638,12 @@ def register_agent(
|
||||
tools_ = tools or []
|
||||
|
||||
def decorator(awaitable: Callable[..., Awaitable[Any]]):
|
||||
AstrAgent = Agent[AstrAgentContext]
|
||||
AstrAgent = Agent[Any]
|
||||
agent = AstrAgent(
|
||||
name=name,
|
||||
instructions=instruction,
|
||||
tools=tools_,
|
||||
run_hooks=run_hooks or BaseAgentRunHooks[AstrAgentContext](),
|
||||
run_hooks=run_hooks or BaseAgentRunHooks[Any](),
|
||||
)
|
||||
handoff_tool = HandoffTool(agent=agent)
|
||||
handoff_tool.handler = awaitable
|
||||
|
||||
@@ -105,6 +105,22 @@ class StarHandlerRegistry(Generic[T]):
|
||||
plugins_name: list[str] | None = None,
|
||||
) -> list[StarHandlerMetadata[Callable[..., Awaitable[Any]]]]: ...
|
||||
|
||||
@overload
|
||||
def get_handlers_by_event_type(
|
||||
self,
|
||||
event_type: Literal[EventType.OnPluginLoadedEvent],
|
||||
only_activated=True,
|
||||
plugins_name: list[str] | None = None,
|
||||
) -> list[StarHandlerMetadata[Callable[..., Awaitable[Any]]]]: ...
|
||||
|
||||
@overload
|
||||
def get_handlers_by_event_type(
|
||||
self,
|
||||
event_type: Literal[EventType.OnPluginUnloadedEvent],
|
||||
only_activated=True,
|
||||
plugins_name: list[str] | None = None,
|
||||
) -> list[StarHandlerMetadata[Callable[..., Awaitable[Any]]]]: ...
|
||||
|
||||
@overload
|
||||
def get_handlers_by_event_type(
|
||||
self,
|
||||
@@ -144,6 +160,8 @@ class StarHandlerRegistry(Generic[T]):
|
||||
not in (
|
||||
EventType.OnAstrBotLoadedEvent,
|
||||
EventType.OnPlatformLoadedEvent,
|
||||
EventType.OnPluginLoadedEvent,
|
||||
EventType.OnPluginUnloadedEvent,
|
||||
)
|
||||
and not plugin.reserved
|
||||
):
|
||||
@@ -201,6 +219,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:
|
||||
@@ -49,10 +49,13 @@ class PluginVersionIncompatibleError(Exception):
|
||||
|
||||
class PluginManager:
|
||||
def __init__(self, context: Context, config: AstrBotConfig) -> None:
|
||||
from .star_tools import StarTools
|
||||
|
||||
self.updator = PluginUpdator()
|
||||
|
||||
self.context = context
|
||||
self.context._star_manager = self # type: ignore
|
||||
StarTools.initialize(context)
|
||||
|
||||
self.config = config
|
||||
self.plugin_store_path = get_astrbot_plugin_path()
|
||||
@@ -385,6 +388,33 @@ class PluginManager:
|
||||
except KeyError:
|
||||
logger.warning(f"模块 {module_name} 未载入")
|
||||
|
||||
def _cleanup_plugin_state(self, dir_name: str) -> None:
|
||||
plugin_root_name = "data.plugins."
|
||||
|
||||
# 清理 sys.modules
|
||||
for key in list(sys.modules.keys()):
|
||||
if key.startswith(f"{plugin_root_name}{dir_name}"):
|
||||
logger.info(f"清除了插件{dir_name}中的{key}模块")
|
||||
del sys.modules[key]
|
||||
|
||||
possible_paths = [
|
||||
f"{plugin_root_name}{dir_name}.main",
|
||||
f"{plugin_root_name}{dir_name}.{dir_name}",
|
||||
]
|
||||
|
||||
# 清理 handlers
|
||||
for path in possible_paths:
|
||||
handlers = star_handlers_registry.get_handlers_by_module_name(path)
|
||||
for handler in handlers:
|
||||
star_handlers_registry.remove(handler)
|
||||
logger.info(f"清理处理器: {handler.handler_name}")
|
||||
|
||||
# 清理工具
|
||||
for tool in list(llm_tools.func_list):
|
||||
if tool.handler_module_path in possible_paths:
|
||||
llm_tools.func_list.remove(tool)
|
||||
logger.info(f"清理工具: {tool.name}")
|
||||
|
||||
async def reload_failed_plugin(self, dir_name):
|
||||
"""
|
||||
重新加载未注册(加载失败)的插件
|
||||
@@ -395,17 +425,21 @@ class PluginManager:
|
||||
- success (bool): 重载是否成功
|
||||
- error_message (str|None): 错误信息,成功时为 None
|
||||
"""
|
||||
|
||||
async with self._pm_lock:
|
||||
if dir_name in self.failed_plugin_dict:
|
||||
success, error = await self.load(specified_dir_name=dir_name)
|
||||
if success:
|
||||
self.failed_plugin_dict.pop(dir_name, None)
|
||||
if not self.failed_plugin_dict:
|
||||
self.failed_plugin_info = ""
|
||||
return success, None
|
||||
else:
|
||||
return False, error
|
||||
return False, "插件不存在于失败列表中"
|
||||
if dir_name not in self.failed_plugin_dict:
|
||||
return False, "插件不存在于失败列表中"
|
||||
|
||||
self._cleanup_plugin_state(dir_name)
|
||||
|
||||
success, error = await self.load(specified_dir_name=dir_name)
|
||||
if success:
|
||||
self.failed_plugin_dict.pop(dir_name, None)
|
||||
if not self.failed_plugin_dict:
|
||||
self.failed_plugin_info = ""
|
||||
return success, None
|
||||
else:
|
||||
return False, error
|
||||
|
||||
async def reload(self, specified_plugin_name=None):
|
||||
"""重新加载插件
|
||||
@@ -529,8 +563,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 +817,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 +842,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 +1222,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:
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from pydantic import Field
|
||||
from pydantic.dataclasses import dataclass
|
||||
@@ -8,6 +9,14 @@ from astrbot.core.agent.tool import FunctionTool, ToolExecResult
|
||||
from astrbot.core.astr_agent_context import AstrAgentContext
|
||||
|
||||
|
||||
def _extract_job_session(job: Any) -> str | None:
|
||||
payload = getattr(job, "payload", None)
|
||||
if not isinstance(payload, dict):
|
||||
return None
|
||||
session = payload.get("session")
|
||||
return str(session) if session is not None else None
|
||||
|
||||
|
||||
@dataclass
|
||||
class CreateActiveCronTool(FunctionTool[AstrAgentContext]):
|
||||
name: str = "create_future_task"
|
||||
@@ -119,9 +128,15 @@ class DeleteCronJobTool(FunctionTool[AstrAgentContext]):
|
||||
cron_mgr = context.context.context.cron_manager
|
||||
if cron_mgr is None:
|
||||
return "error: cron manager is not available."
|
||||
current_umo = context.context.event.unified_msg_origin
|
||||
job_id = kwargs.get("job_id")
|
||||
if not job_id:
|
||||
return "error: job_id is required."
|
||||
job = await cron_mgr.db.get_cron_job(str(job_id))
|
||||
if not job:
|
||||
return f"error: cron job {job_id} not found."
|
||||
if _extract_job_session(job) != current_umo:
|
||||
return "error: you can only delete future tasks in the current umo."
|
||||
await cron_mgr.delete_job(str(job_id))
|
||||
return f"Deleted cron job {job_id}."
|
||||
|
||||
@@ -148,8 +163,13 @@ class ListCronJobsTool(FunctionTool[AstrAgentContext]):
|
||||
cron_mgr = context.context.context.cron_manager
|
||||
if cron_mgr is None:
|
||||
return "error: cron manager is not available."
|
||||
current_umo = context.context.event.unified_msg_origin
|
||||
job_type = kwargs.get("job_type")
|
||||
jobs = await cron_mgr.list_jobs(job_type)
|
||||
jobs = [
|
||||
job
|
||||
for job in await cron_mgr.list_jobs(job_type)
|
||||
if _extract_job_session(job) == current_umo
|
||||
]
|
||||
if not jobs:
|
||||
return "No cron jobs found."
|
||||
lines = []
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -19,7 +19,7 @@ from astrbot.core.message.components import (
|
||||
from astrbot.core.platform.astr_message_event import AstrMessageEvent
|
||||
from astrbot.core.utils.string_utils import normalize_and_dedupe_strings
|
||||
|
||||
from .image_refs import looks_like_image_file_name, normalize_file_like_url
|
||||
from .image_refs import looks_like_image_file_name
|
||||
from .settings import SETTINGS, QuotedMessageParserSettings
|
||||
|
||||
_FORWARD_PLACEHOLDER_PATTERN = re.compile(
|
||||
@@ -296,11 +296,11 @@ def _parse_onebot_segments(
|
||||
or "file"
|
||||
)
|
||||
text_parts.append(f"[File:{file_name}]")
|
||||
candidate_url = seg_data.get("url")
|
||||
candidate_url = seg_data.get("url", "")
|
||||
if (
|
||||
isinstance(candidate_url, str)
|
||||
and candidate_url.strip()
|
||||
and looks_like_image_file_name(normalize_file_like_url(candidate_url))
|
||||
and looks_like_image_file_name(candidate_url)
|
||||
):
|
||||
image_refs.append(candidate_url.strip())
|
||||
candidate_file = seg_data.get("file")
|
||||
@@ -308,11 +308,7 @@ def _parse_onebot_segments(
|
||||
isinstance(candidate_file, str)
|
||||
and candidate_file.strip()
|
||||
and looks_like_image_file_name(
|
||||
normalize_file_like_url(
|
||||
seg_data.get("name")
|
||||
or seg_data.get("file_name")
|
||||
or candidate_file
|
||||
)
|
||||
seg_data.get("name") or seg_data.get("file_name") or candidate_file
|
||||
)
|
||||
):
|
||||
image_refs.append(candidate_file.strip())
|
||||
@@ -368,7 +364,9 @@ def _extract_text_forward_ids_and_images_from_forward_nodes(
|
||||
if not isinstance(node, dict):
|
||||
continue
|
||||
|
||||
sender = node.get("sender") if isinstance(node.get("sender"), dict) else {}
|
||||
sender = node.get("sender")
|
||||
if not isinstance(sender, dict):
|
||||
sender = {}
|
||||
sender_name = (
|
||||
sender.get("nickname")
|
||||
or sender.get("card")
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
from collections.abc import Awaitable
|
||||
from typing import Any, Protocol
|
||||
|
||||
from astrbot import logger
|
||||
from astrbot.core.platform.astr_message_event import AstrMessageEvent
|
||||
@@ -17,6 +18,10 @@ def _unwrap_action_response(ret: dict[str, Any] | None) -> dict[str, Any]:
|
||||
return ret
|
||||
|
||||
|
||||
class CallAction(Protocol):
|
||||
def __call__(self, action: str, **params: Any) -> Awaitable[Any] | Any: ...
|
||||
|
||||
|
||||
class OneBotClient:
|
||||
def __init__(
|
||||
self,
|
||||
@@ -27,7 +32,7 @@ class OneBotClient:
|
||||
self._settings = settings
|
||||
|
||||
@staticmethod
|
||||
def _resolve_call_action(event: AstrMessageEvent):
|
||||
def _resolve_call_action(event: AstrMessageEvent) -> CallAction | None:
|
||||
bot = getattr(event, "bot", None)
|
||||
api = getattr(bot, "api", None)
|
||||
call_action = getattr(api, "call_action", None)
|
||||
|
||||
@@ -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",
|
||||
@@ -212,8 +215,13 @@ class ChatRoute(Route):
|
||||
filename: 存储的文件名
|
||||
attach_type: 附件类型 (image, record, file, video)
|
||||
"""
|
||||
file_path = os.path.join(self.attachments_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
|
||||
@@ -466,13 +474,13 @@ class ChatRoute(Route):
|
||||
if tc_id in tool_calls:
|
||||
tool_calls[tc_id]["result"] = tcr.get("result")
|
||||
tool_calls[tc_id]["finished_ts"] = tcr.get("ts")
|
||||
accumulated_parts.append(
|
||||
{
|
||||
"type": "tool_call",
|
||||
"tool_calls": [tool_calls[tc_id]],
|
||||
}
|
||||
)
|
||||
tool_calls.pop(tc_id, None)
|
||||
accumulated_parts.append(
|
||||
{
|
||||
"type": "tool_call",
|
||||
"tool_calls": [tool_calls[tc_id]],
|
||||
}
|
||||
)
|
||||
tool_calls.pop(tc_id, None)
|
||||
elif chain_type == "reasoning":
|
||||
accumulated_reasoning += result_text
|
||||
elif streaming:
|
||||
@@ -603,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")
|
||||
|
||||
@@ -754,6 +754,22 @@ class ConfigRoute(Route):
|
||||
if not provider_type:
|
||||
return Response().error("provider_config 缺少 type 字段").__dict__
|
||||
|
||||
# 首次添加某类提供商时,provider_cls_map 可能尚未注册该适配器
|
||||
if provider_type not in provider_cls_map:
|
||||
try:
|
||||
self.core_lifecycle.provider_manager.dynamic_import_provider(
|
||||
provider_type,
|
||||
)
|
||||
except ImportError:
|
||||
logger.error(traceback.format_exc())
|
||||
return (
|
||||
Response()
|
||||
.error(
|
||||
"提供商适配器加载失败,请检查提供商类型配置或查看服务端日志"
|
||||
)
|
||||
.__dict__
|
||||
)
|
||||
|
||||
# 获取对应的 provider 类
|
||||
if provider_type not in provider_cls_map:
|
||||
return (
|
||||
@@ -779,7 +795,7 @@ class ConfigRoute(Route):
|
||||
if inspect.iscoroutinefunction(init_fn):
|
||||
await init_fn()
|
||||
|
||||
# 获取嵌入向量维度
|
||||
# 通过实际请求验证当前 embedding_dimensions 是否可用
|
||||
vec = await inst.get_embedding("echo")
|
||||
dim = len(vec)
|
||||
|
||||
|
||||
@@ -148,7 +148,6 @@ class ConversationRoute(Route):
|
||||
user_id = data.get("user_id")
|
||||
cid = data.get("cid")
|
||||
title = data.get("title")
|
||||
persona_id = data.get("persona_id", "")
|
||||
|
||||
if not user_id or not cid:
|
||||
return Response().error("缺少必要参数: user_id 和 cid").__dict__
|
||||
@@ -158,6 +157,9 @@ class ConversationRoute(Route):
|
||||
)
|
||||
if not conversation:
|
||||
return Response().error("对话不存在").__dict__
|
||||
|
||||
persona_id = data.get("persona_id", conversation.persona_id)
|
||||
|
||||
if title is not None or persona_id is not None:
|
||||
await self.conv_mgr.update_conversation(
|
||||
unified_msg_origin=user_id,
|
||||
|
||||
@@ -698,10 +698,16 @@ class PluginRoute(Route):
|
||||
logger.warning(f"插件 {plugin_name} 目录不存在")
|
||||
return Response().error(f"插件 {plugin_name} 目录不存在").__dict__
|
||||
|
||||
plugin_dir = os.path.join(
|
||||
self.plugin_manager.plugin_store_path,
|
||||
plugin_obj.root_dir_name or "",
|
||||
)
|
||||
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}")
|
||||
@@ -735,6 +741,7 @@ class PluginRoute(Route):
|
||||
logger.debug(f"正在获取插件 {plugin_name} 的更新日志")
|
||||
|
||||
if not plugin_name:
|
||||
logger.warning("插件名称为空")
|
||||
return Response().error("插件名称不能为空").__dict__
|
||||
|
||||
# 查找插件
|
||||
@@ -745,15 +752,27 @@ 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__
|
||||
|
||||
plugin_dir = os.path.join(
|
||||
self.plugin_manager.plugin_store_path,
|
||||
plugin_obj.root_dir_name,
|
||||
)
|
||||
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"]
|
||||
@@ -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):
|
||||
|
||||
@@ -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"`).
|
||||
@@ -0,0 +1,60 @@
|
||||
## What's Changed
|
||||
|
||||
### 新增
|
||||
- 新增 Agent 会话停止能力,并优化 stop 请求处理流程,支持 /stop 指令终止 Agent 运行并尽量不丢失已运行输出的结果。 ([#5380](https://github.com/AstrBotDevs/AstrBot/issues/5380))。
|
||||
- 新增 SubAgent 交接场景下的 computer-use 工具支持 ([#5399](https://github.com/AstrBotDevs/AstrBot/issues/5399))。
|
||||
- 新增 Agent 执行过程中展示工具调用结果的能力,提升执行过程可观测性 ([#5388](https://github.com/AstrBotDevs/AstrBot/issues/5388))。
|
||||
- 新增插件加载/卸载 Hook,扩展插件生命周期能力 ([#5331](https://github.com/AstrBotDevs/AstrBot/issues/5331))。
|
||||
- 新增插件加载失败后的热重载能力,提升插件开发与恢复效率 ([#5334](https://github.com/AstrBotDevs/AstrBot/issues/5334))。
|
||||
- 新增 SubAgent 图片 URL/本地路径输入支持 ([#5348](https://github.com/AstrBotDevs/AstrBot/issues/5348))。
|
||||
- 新增 Dashboard 发布跳转基础 URL 可配置项 ([#5330](https://github.com/AstrBotDevs/AstrBot/issues/5330))。
|
||||
|
||||
### 修复
|
||||
- 修复 Tavily 请求的硬编码 6 秒超时。
|
||||
- 修复 OneBot v11 适配器关闭之后仍然在连接的问题([#5412](https://github.com/AstrBotDevs/AstrBot/issues/5412))。
|
||||
- 修复上下文会话中平台缺失时的日志处理,补充 warning 并改进排查信息。
|
||||
- 修复 embedding 维度未透传到 provider API 的问题 ([#5411](https://github.com/AstrBotDevs/AstrBot/issues/5411))。
|
||||
- 修复 File 组件处理逻辑并增强 OneBot 驱动层路径兼容性 ([#5391](https://github.com/AstrBotDevs/AstrBot/issues/5391))。
|
||||
- 修复 sandbox 文件传输工具缺少管理员权限校验的问题 ([#5402](https://github.com/AstrBotDevs/AstrBot/issues/5402))。
|
||||
- 修复 pipeline 与 `from ... import *` 引发的循环依赖问题 ([#5353](https://github.com/AstrBotDevs/AstrBot/issues/5353))。
|
||||
- 修复配置文件存在 UTF-8 BOM 时的解析问题 ([#5376](https://github.com/AstrBotDevs/AstrBot/issues/5376))。
|
||||
- 修复 ChatUI 复制回滚路径缺失与错误提示不清晰的问题 ([#5352](https://github.com/AstrBotDevs/AstrBot/issues/5352))。
|
||||
- 修复保留插件目录处理逻辑,避免插件目录行为异常 ([#5369](https://github.com/AstrBotDevs/AstrBot/issues/5369))。
|
||||
- 修复 ChatUI 文件消息段无法持久化的问题 ([#5386](https://github.com/AstrBotDevs/AstrBot/issues/5386))。
|
||||
- 修复 `.dockerignore` 误排除 `changelogs` 目录的问题。
|
||||
- 修复 aiohttp 版本过新导致 qq-botpy 报错的问题 ([#5316](https://github.com/AstrBotDevs/AstrBot/issues/5316))。
|
||||
|
||||
### 优化
|
||||
- 完成 SubAgent 编排页面国际化,补齐多语言支持 ([#5400](https://github.com/AstrBotDevs/AstrBot/issues/5400))。
|
||||
- 增补消息事件处理相关测试,并完善测试框架的 fixtures/mocks 覆盖 ([#5355](https://github.com/AstrBotDevs/AstrBot/issues/5355), [#5354](https://github.com/AstrBotDevs/AstrBot/issues/5354))。
|
||||
|
||||
## What's Changed (EN)
|
||||
|
||||
### New Features
|
||||
- Added computer-use tools support in sub-agent handoff scenarios ([#5399](https://github.com/AstrBotDevs/AstrBot/issues/5399)).
|
||||
- Added support for displaying tool call results during agent execution for better observability ([#5388](https://github.com/AstrBotDevs/AstrBot/issues/5388)).
|
||||
- Added plugin load/unload hooks to extend plugin lifecycle capabilities ([#5331](https://github.com/AstrBotDevs/AstrBot/issues/5331)).
|
||||
- Added hot reload support when plugin loading fails, improving recovery during plugin development ([#5334](https://github.com/AstrBotDevs/AstrBot/issues/5334)).
|
||||
- Added image URL/local path input support for sub-agents ([#5348](https://github.com/AstrBotDevs/AstrBot/issues/5348)).
|
||||
- Added stop control for active agent sessions and improved stop request handling ([#5380](https://github.com/AstrBotDevs/AstrBot/issues/5380)).
|
||||
- Added configurable base URL for dashboard release redirects ([#5330](https://github.com/AstrBotDevs/AstrBot/issues/5330)).
|
||||
|
||||
### Fixes
|
||||
- Fixed logging behavior when platform information is missing in context sessions, with clearer warning and diagnostics.
|
||||
- Fixed missing embedding dimensions being passed to provider APIs ([#5411](https://github.com/AstrBotDevs/AstrBot/issues/5411)).
|
||||
- Fixed shutdown stability issues in the aiocqhttp adapter ([#5412](https://github.com/AstrBotDevs/AstrBot/issues/5412)).
|
||||
- Fixed File component handling and improved path compatibility in the OneBot driver layer ([#5391](https://github.com/AstrBotDevs/AstrBot/issues/5391)).
|
||||
- Fixed missing admin guard for sandbox file transfer tools ([#5402](https://github.com/AstrBotDevs/AstrBot/issues/5402)).
|
||||
- Fixed circular import issues related to pipeline and `from ... import *` usage ([#5353](https://github.com/AstrBotDevs/AstrBot/issues/5353)).
|
||||
- Fixed config parsing issues when files contain UTF-8 BOM ([#5376](https://github.com/AstrBotDevs/AstrBot/issues/5376)).
|
||||
- Fixed missing copy rollback path and unclear error messaging in ChatUI ([#5352](https://github.com/AstrBotDevs/AstrBot/issues/5352)).
|
||||
- Fixed reserved plugin directory handling to avoid abnormal plugin path behavior ([#5369](https://github.com/AstrBotDevs/AstrBot/issues/5369)).
|
||||
- Fixed ChatUI file segment persistence issues ([#5386](https://github.com/AstrBotDevs/AstrBot/issues/5386)).
|
||||
- Fixed accidental exclusion of the `changelogs` directory in `.dockerignore`.
|
||||
- Fixed compatibility issues caused by a hard-coded 6-second timeout in Tavily requests.
|
||||
- Fixed qq-botpy runtime errors caused by overly new aiohttp versions ([#5316](https://github.com/AstrBotDevs/AstrBot/issues/5316)).
|
||||
|
||||
### Improvements
|
||||
- Completed internationalization for the sub-agent orchestration page ([#5400](https://github.com/AstrBotDevs/AstrBot/issues/5400)).
|
||||
- Added broader message-event test coverage and improved fixtures/mocks in the test framework ([#5355](https://github.com/AstrBotDevs/AstrBot/issues/5355), [#5354](https://github.com/AstrBotDevs/AstrBot/issues/5354)).
|
||||
- Updated README content and applied repository-wide formatting cleanup (ruff format) ([#5375](https://github.com/AstrBotDevs/AstrBot/issues/5375)).
|
||||
@@ -0,0 +1,49 @@
|
||||
## What's Changed
|
||||
|
||||
### 新增
|
||||
|
||||
- 新增桌面端通用更新桥接能力,便于接入客户端内更新流程 ([#5424](https://github.com/AstrBotDevs/AstrBot/issues/5424))。
|
||||
|
||||
### 修复
|
||||
|
||||
- 修复新增平台对话框中 Line 适配器未显示的问题。
|
||||
- 修复 Telegram 无法发送 Video 的问题 ([#5430](https://github.com/AstrBotDevs/AstrBot/issues/5430))。
|
||||
- 修复创建 embedding provider 时无法自动识别向量维度的问题 ([#5442](https://github.com/AstrBotDevs/AstrBot/issues/5442))。
|
||||
- 修复 QQ 官方平台发送媒体消息时 markdown 字段未清理的问题 ([#5445](https://github.com/AstrBotDevs/AstrBot/issues/5445))。
|
||||
- 修复上下文管理策略 -> 上下文截断时 tool call / response 配对丢失的问题 ([#5417](https://github.com/AstrBotDevs/AstrBot/issues/5417))。
|
||||
- 修复会话更新时 `persona_id` 被覆盖的问题,并增强 persona 解析逻辑。
|
||||
- 修复 WebUI 中 GitHub 代理地址显示异常的问题 ([#5438](https://github.com/AstrBotDevs/AstrBot/issues/5438))。
|
||||
- 修复设置页新建开发者 API Key 后复制失败的问题 ([#5439](https://github.com/AstrBotDevs/AstrBot/issues/5439))。
|
||||
- 修复 Telegram 语音消息格式与 OpenAI STT 兼容性问题(使用 OGG) ([#5389](https://github.com/AstrBotDevs/AstrBot/issues/5389))。
|
||||
|
||||
### 优化
|
||||
|
||||
- 优化知识库检索流程,改为批量查询元数据,修复 N+1 查询性能问题 ([#5463](https://github.com/AstrBotDevs/AstrBot/issues/5463))。
|
||||
- 优化 Cron 未来任务执行的会话隔离能力,提升并发稳定性。
|
||||
- 优化 WebUI 插件页的交互。
|
||||
|
||||
## What's Changed (EN)
|
||||
|
||||
### New Features
|
||||
|
||||
- Added `useExtensionPage` composable for unified plugin extension page state management.
|
||||
- Added a generic desktop app updater bridge to support in-app update workflows ([#5424](https://github.com/AstrBotDevs/AstrBot/issues/5424)).
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Fixed the Line adapter not appearing in the "Add Platform" dialog.
|
||||
- Fixed Telegram video sending issues ([#5430](https://github.com/AstrBotDevs/AstrBot/issues/5430)).
|
||||
- Fixed Pyright static type checking errors ([#5437](https://github.com/AstrBotDevs/AstrBot/issues/5437)).
|
||||
- Fixed embedding dimension auto-detection when creating embedding providers ([#5442](https://github.com/AstrBotDevs/AstrBot/issues/5442)).
|
||||
- Fixed stale markdown fields when sending media messages via QQ Official Platform ([#5445](https://github.com/AstrBotDevs/AstrBot/issues/5445)).
|
||||
- Fixed tool call/response pairing loss during context truncation ([#5417](https://github.com/AstrBotDevs/AstrBot/issues/5417)).
|
||||
- Fixed `persona_id` being overwritten during conversation updates and improved persona resolution logic.
|
||||
- Fixed incorrect GitHub proxy display in WebUI ([#5438](https://github.com/AstrBotDevs/AstrBot/issues/5438)).
|
||||
- Fixed API key copy failure after creating a new key in settings ([#5439](https://github.com/AstrBotDevs/AstrBot/issues/5439)).
|
||||
- Fixed Telegram voice format compatibility with OpenAI STT by using OGG ([#5389](https://github.com/AstrBotDevs/AstrBot/issues/5389)).
|
||||
|
||||
### Improvements
|
||||
|
||||
- Improved knowledge base retrieval by batching metadata queries to eliminate the N+1 query pattern ([#5463](https://github.com/AstrBotDevs/AstrBot/issues/5463)).
|
||||
- Improved session isolation for future cron tasks to increase stability under concurrency.
|
||||
- Improved WebUI plugin page interactions.
|
||||
+8
-1
@@ -1,3 +1,10 @@
|
||||
# AstrBot 管理面板
|
||||
|
||||
基于 CodedThemes/Berry 模板开发。
|
||||
基于 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;
|
||||
}
|
||||
|
||||
@@ -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: [];
|
||||
|
||||
@@ -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;
|
||||
tryExecCommandCopy(text) {
|
||||
let textArea = null;
|
||||
try {
|
||||
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 {
|
||||
document.execCommand('copy');
|
||||
console.log('代码已复制到剪贴板 (fallback)');
|
||||
} catch (fallbackErr) {
|
||||
console.error('复制失败 (fallback):', fallbackErr);
|
||||
textArea?.remove?.();
|
||||
} catch (_) {
|
||||
// ignore cleanup errors
|
||||
}
|
||||
document.body.removeChild(textArea);
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
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 '';
|
||||
}
|
||||
|
||||
const textContents = messageParts
|
||||
.filter(part => part && typeof part === 'object' && part.type === 'plain' && part.text)
|
||||
.map(part => part.text);
|
||||
|
||||
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);
|
||||
if (hasAudio) {
|
||||
if (textToCopy) textToCopy += '\n\n';
|
||||
textToCopy += '[包含音频内容]';
|
||||
}
|
||||
|
||||
return String(textToCopy || '').trim();
|
||||
},
|
||||
|
||||
async copyCodeToClipboard(code) {
|
||||
const text = String(code ?? '');
|
||||
if (!text) return { ok: false, method: 'empty' };
|
||||
return await this.copyWithFeedback(text, null);
|
||||
},
|
||||
|
||||
// 复制bot消息到剪贴板
|
||||
copyBotMessage(messageParts, messageIndex) {
|
||||
let textToCopy = '';
|
||||
|
||||
if (Array.isArray(messageParts)) {
|
||||
// 提取所有文本内容
|
||||
const textContents = messageParts
|
||||
.filter(part => 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;
|
||||
if (imageCount > 0) {
|
||||
if (textToCopy) textToCopy += '\n\n';
|
||||
textToCopy += `[包含 ${imageCount} 张图片]`;
|
||||
}
|
||||
|
||||
// 检查是否有音频
|
||||
const hasAudio = messageParts.some(part => part.type === 'record' && part.embedded_url);
|
||||
if (hasAudio) {
|
||||
if (textToCopy) textToCopy += '\n\n';
|
||||
textToCopy += '[包含音频内容]';
|
||||
}
|
||||
}
|
||||
|
||||
// 如果没有任何内容,使用默认文本
|
||||
if (!textToCopy.trim()) {
|
||||
textToCopy = '[媒体内容]';
|
||||
}
|
||||
|
||||
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 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"
|
||||
@@ -156,6 +158,7 @@ const {
|
||||
enableStreaming,
|
||||
getSessionMessages: getSessionMsg,
|
||||
sendMessage: sendMsg,
|
||||
stopMessage: stopMsg,
|
||||
toggleStreaming
|
||||
} = useMessages(currSessionId, getMediaFile, updateSessionTitle, getSessions);
|
||||
|
||||
@@ -236,6 +239,10 @@ async function handleSendMessage() {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleStopMessage() {
|
||||
await stopMsg();
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
// 独立模式在挂载时创建新会话
|
||||
try {
|
||||
|
||||
@@ -27,9 +27,14 @@ const normalizePlatformList = (platforms) => {
|
||||
return platforms.filter((item) => typeof item === "string");
|
||||
};
|
||||
|
||||
const platformDisplayList = computed(() =>
|
||||
normalizePlatformList(props.plugin?.support_platforms),
|
||||
);
|
||||
|
||||
const handleInstall = (plugin) => {
|
||||
emit("install", plugin);
|
||||
};
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -119,6 +124,7 @@ const handleInstall = (plugin) => {
|
||||
v-if="plugin?.social_link"
|
||||
:href="plugin.social_link"
|
||||
target="_blank"
|
||||
@click.stop
|
||||
class="text-subtitle-2 font-weight-medium"
|
||||
style="
|
||||
text-decoration: none;
|
||||
@@ -209,7 +215,10 @@ const handleInstall = (plugin) => {
|
||||
</div>
|
||||
</v-card-text>
|
||||
|
||||
<v-card-actions style="gap: 6px; padding: 8px 12px; padding-top: 0">
|
||||
<v-card-actions
|
||||
style="gap: 6px; padding: 8px 12px; padding-top: 0"
|
||||
@click.stop
|
||||
>
|
||||
<v-chip
|
||||
v-for="tag in plugin.tags?.slice(0, 2)"
|
||||
:key="tag"
|
||||
@@ -244,22 +253,24 @@ const handleInstall = (plugin) => {
|
||||
<v-btn
|
||||
v-if="plugin?.repo"
|
||||
color="secondary"
|
||||
size="x-small"
|
||||
size="small"
|
||||
variant="tonal"
|
||||
class="market-action-btn"
|
||||
:href="plugin.repo"
|
||||
target="_blank"
|
||||
style="height: 24px"
|
||||
style="height: 32px"
|
||||
>
|
||||
<v-icon icon="mdi-github" start size="x-small"></v-icon>
|
||||
<v-icon icon="mdi-github" start size="small"></v-icon>
|
||||
{{ tm("buttons.viewRepo") }}
|
||||
</v-btn>
|
||||
<v-btn
|
||||
v-if="!plugin?.installed"
|
||||
color="primary"
|
||||
size="x-small"
|
||||
size="small"
|
||||
@click="handleInstall(plugin)"
|
||||
variant="flat"
|
||||
style="height: 24px"
|
||||
class="market-action-btn"
|
||||
style="height: 32px"
|
||||
>
|
||||
{{ tm("buttons.install") }}
|
||||
</v-btn>
|
||||
@@ -302,4 +313,9 @@ const handleInstall = (plugin) => {
|
||||
.plugin-description::-webkit-scrollbar-thumb:hover {
|
||||
background-color: rgba(var(--v-theme-primary-rgb), 0.6);
|
||||
}
|
||||
|
||||
.market-action-btn {
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -48,6 +48,40 @@ const filteredIterable = computed(() => {
|
||||
return rest
|
||||
})
|
||||
|
||||
const providerHint = computed(() => {
|
||||
const hint = props.iterable?.hint
|
||||
if (typeof hint !== 'string' || !hint) return ''
|
||||
|
||||
if (
|
||||
hint === 'provider_group.provider.openai_embedding.hint'
|
||||
|| hint === 'provider_group.provider.gemini_embedding.hint'
|
||||
) {
|
||||
return ''
|
||||
}
|
||||
|
||||
return hint
|
||||
})
|
||||
|
||||
const getItemHint = (itemKey, itemMeta) => {
|
||||
if (itemMeta?.hint) return itemMeta.hint
|
||||
|
||||
if (itemKey !== 'embedding_api_base') return ''
|
||||
|
||||
const providerType = props.iterable?.type
|
||||
if (providerType === 'openai_embedding') {
|
||||
return getRaw('provider_group.provider.openai_embedding.hint')
|
||||
? 'provider_group.provider.openai_embedding.hint'
|
||||
: ''
|
||||
}
|
||||
if (providerType === 'gemini_embedding') {
|
||||
return getRaw('provider_group.provider.gemini_embedding.hint')
|
||||
? 'provider_group.provider.gemini_embedding.hint'
|
||||
: ''
|
||||
}
|
||||
|
||||
return ''
|
||||
}
|
||||
|
||||
const dialog = ref(false)
|
||||
const currentEditingKey = ref('')
|
||||
const currentEditingLanguage = ref('json')
|
||||
@@ -153,14 +187,14 @@ function hasVisibleItemsAfter(items, currentIndex) {
|
||||
<div v-if="metadata[metadataKey]?.type === 'object' || metadata[metadataKey]?.config_template" class="object-config">
|
||||
<!-- Provider-level hint -->
|
||||
<v-alert
|
||||
v-if="iterable.hint && !isEditing"
|
||||
v-if="providerHint"
|
||||
type="info"
|
||||
variant="tonal"
|
||||
class="mb-4"
|
||||
border="start"
|
||||
density="compact"
|
||||
>
|
||||
{{ iterable.hint }}
|
||||
{{ translateIfKey(providerHint) }}
|
||||
</v-alert>
|
||||
|
||||
<div v-for="(val, key, index) in filteredIterable" :key="key" class="config-item">
|
||||
@@ -218,9 +252,9 @@ function hasVisibleItemsAfter(items, currentIndex) {
|
||||
</v-list-item-title>
|
||||
|
||||
<v-list-item-subtitle class="property-hint">
|
||||
<span v-if="metadata[metadataKey].items[key]?.obvious_hint && metadata[metadataKey].items[key]?.hint"
|
||||
<span v-if="metadata[metadataKey].items[key]?.obvious_hint && getItemHint(key, metadata[metadataKey].items[key])"
|
||||
class="important-hint">‼️</span>
|
||||
{{ translateIfKey(metadata[metadataKey].items[key]?.hint) }}
|
||||
{{ translateIfKey(getItemHint(key, metadata[metadataKey].items[key])) }}
|
||||
</v-list-item-subtitle>
|
||||
</v-list-item>
|
||||
</v-col>
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, inject } from "vue";
|
||||
import { ref, computed, inject, watch } from "vue";
|
||||
import { useCustomizerStore } from "@/stores/customizer";
|
||||
import { useModuleI18n } from "@/i18n/composables";
|
||||
import { getPlatformDisplayName, getPlatformIcon } from "@/utils/platformUtils";
|
||||
import UninstallConfirmDialog from "./UninstallConfirmDialog.vue";
|
||||
import PluginPlatformChip from "./PluginPlatformChip.vue";
|
||||
import StyledMenu from "./StyledMenu.vue";
|
||||
import defaultPluginIcon from "@/assets/images/plugin_icon.png";
|
||||
|
||||
const props = defineProps({
|
||||
extension: {
|
||||
@@ -59,6 +61,25 @@ const astrbotVersionRequirement = computed(() => {
|
||||
: "";
|
||||
});
|
||||
|
||||
const logoLoadFailed = ref(false);
|
||||
|
||||
const logoSrc = computed(() => {
|
||||
const logo = props.extension?.logo;
|
||||
if (logoLoadFailed.value) {
|
||||
return defaultPluginIcon;
|
||||
}
|
||||
return typeof logo === "string" && logo.trim().length
|
||||
? logo
|
||||
: defaultPluginIcon;
|
||||
});
|
||||
|
||||
watch(
|
||||
() => props.extension?.logo,
|
||||
() => {
|
||||
logoLoadFailed.value = false;
|
||||
},
|
||||
);
|
||||
|
||||
// 操作函数
|
||||
const configure = () => {
|
||||
emit("configure", props.extension);
|
||||
@@ -104,12 +125,14 @@ const viewReadme = () => {
|
||||
const viewChangelog = () => {
|
||||
emit("view-changelog", props.extension);
|
||||
};
|
||||
|
||||
</script>
|
||||
|
||||
<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:
|
||||
@@ -128,249 +151,292 @@ const viewChangelog = () => {
|
||||
style="
|
||||
padding: 16px;
|
||||
padding-bottom: 0px;
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
width: 100%;
|
||||
"
|
||||
>
|
||||
<div v-if="extension?.logo">
|
||||
<img :src="extension.logo" :alt="extension.name" cover width="100" />
|
||||
</div>
|
||||
|
||||
<div style="overflow-x: auto">
|
||||
<!-- Top-right three-dot menu -->
|
||||
<div style="position: absolute; right: 8px; top: 8px; z-index: 5">
|
||||
<v-menu offset-y>
|
||||
<template v-slot:activator="{ props: menuProps }">
|
||||
<v-btn
|
||||
icon
|
||||
variant="text"
|
||||
aria-label="more"
|
||||
v-if="extension?.repo"
|
||||
:href="extension?.repo"
|
||||
target="_blank"
|
||||
>
|
||||
<v-icon icon="mdi-github"></v-icon>
|
||||
</v-btn>
|
||||
<v-btn v-bind="menuProps" icon variant="text" aria-label="more">
|
||||
<v-icon icon="mdi-dots-vertical"></v-icon>
|
||||
</v-btn>
|
||||
</template>
|
||||
|
||||
<v-list>
|
||||
<v-list-item @click="viewReadme">
|
||||
<v-list-item-title
|
||||
>📄 {{ tm("buttons.viewDocs") }}</v-list-item-title
|
||||
>
|
||||
</v-list-item>
|
||||
|
||||
<v-list-item v-if="!marketMode" @click="viewChangelog">
|
||||
<v-list-item-title
|
||||
>📝 {{ tm("pluginChangelog.menuTitle") }}</v-list-item-title
|
||||
>
|
||||
</v-list-item>
|
||||
|
||||
<v-list-item
|
||||
v-if="marketMode && !extension?.installed"
|
||||
@click="installExtension"
|
||||
>
|
||||
<v-list-item-title>
|
||||
{{ tm("buttons.install") }}</v-list-item-title
|
||||
>
|
||||
</v-list-item>
|
||||
|
||||
<v-list-item v-if="marketMode && extension?.installed">
|
||||
<v-list-item-title class="text--disabled">{{
|
||||
tm("status.installed")
|
||||
}}</v-list-item-title>
|
||||
</v-list-item>
|
||||
|
||||
<!-- Divider between market actions and plugin actions -->
|
||||
<v-divider v-if="!marketMode" />
|
||||
|
||||
<template v-if="!marketMode">
|
||||
<v-list-item @click="configure">
|
||||
<v-list-item-title>
|
||||
{{ tm("card.actions.pluginConfig") }}</v-list-item-title
|
||||
>
|
||||
</v-list-item>
|
||||
|
||||
<v-list-item @click="uninstallExtension">
|
||||
<v-list-item-title class="text-error">{{
|
||||
tm("card.actions.uninstallPlugin")
|
||||
}}</v-list-item-title>
|
||||
</v-list-item>
|
||||
|
||||
<v-list-item @click="reloadExtension">
|
||||
<v-list-item-title>{{
|
||||
tm("card.actions.reloadPlugin")
|
||||
}}</v-list-item-title>
|
||||
</v-list-item>
|
||||
|
||||
<v-list-item @click="toggleActivation">
|
||||
<v-list-item-title>
|
||||
{{
|
||||
extension.activated
|
||||
? tm("buttons.disable")
|
||||
: tm("buttons.enable")
|
||||
}}{{ tm("card.actions.togglePlugin") }}
|
||||
</v-list-item-title>
|
||||
</v-list-item>
|
||||
|
||||
<v-list-item @click="viewHandlers">
|
||||
<v-list-item-title
|
||||
>{{ tm("card.actions.viewHandlers") }} ({{
|
||||
extension.handlers.length
|
||||
}})</v-list-item-title
|
||||
>
|
||||
</v-list-item>
|
||||
|
||||
<v-list-item @click="updateExtension">
|
||||
<v-list-item-title>
|
||||
{{
|
||||
extension.has_update
|
||||
? tm("card.actions.updateTo") +
|
||||
" " +
|
||||
extension.online_version
|
||||
: tm("card.actions.reinstall")
|
||||
}}
|
||||
</v-list-item-title>
|
||||
</v-list-item>
|
||||
</template>
|
||||
</v-list>
|
||||
</v-menu>
|
||||
</div>
|
||||
|
||||
<div style="overflow-x: auto; width: 100%">
|
||||
<div style="width: 100%; margin-bottom: 24px">
|
||||
<!-- 最多一行 -->
|
||||
<div
|
||||
class="text-caption"
|
||||
style="
|
||||
color: gray;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
margin-right: 84px;
|
||||
"
|
||||
>
|
||||
{{ extension.author }} / {{ extension.name }}
|
||||
</div>
|
||||
<p
|
||||
class="text-h3 font-weight-black extension-title"
|
||||
:class="{ 'text-h4': $vuetify.display.xs }"
|
||||
>
|
||||
<span class="extension-title__text">{{
|
||||
extension.display_name?.length
|
||||
? extension.display_name
|
||||
: extension.name
|
||||
}}</span>
|
||||
<v-tooltip
|
||||
location="top"
|
||||
v-if="extension?.has_update && !marketMode"
|
||||
<div class="extension-title-row">
|
||||
<p
|
||||
class="text-h3 font-weight-black extension-title"
|
||||
:class="{ 'text-h4': $vuetify.display.xs }"
|
||||
>
|
||||
<template v-slot:activator="{ props: tooltipProps }">
|
||||
<v-icon
|
||||
v-bind="tooltipProps"
|
||||
color="warning"
|
||||
class="ml-2"
|
||||
icon="mdi-update"
|
||||
size="small"
|
||||
></v-icon>
|
||||
</template>
|
||||
<span
|
||||
>{{ tm("card.status.hasUpdate") }}:
|
||||
{{ extension.online_version }}</span
|
||||
<v-tooltip
|
||||
location="top"
|
||||
:text="
|
||||
extension.display_name?.length &&
|
||||
extension.display_name !== extension.name
|
||||
? `${extension.display_name} (${extension.name})`
|
||||
: extension.name
|
||||
"
|
||||
>
|
||||
</v-tooltip>
|
||||
<v-tooltip
|
||||
location="top"
|
||||
v-if="!extension.activated && !marketMode"
|
||||
>
|
||||
<template v-slot:activator="{ props: tooltipProps }">
|
||||
<v-icon
|
||||
v-bind="tooltipProps"
|
||||
color="error"
|
||||
class="ml-2"
|
||||
icon="mdi-cancel"
|
||||
size="small"
|
||||
></v-icon>
|
||||
</template>
|
||||
<span>{{ tm("card.status.disabled") }}</span>
|
||||
</v-tooltip>
|
||||
</p>
|
||||
<template v-slot:activator="{ props: titleTooltipProps }">
|
||||
<span v-bind="titleTooltipProps" class="extension-title__text">{{
|
||||
extension.display_name?.length
|
||||
? extension.display_name
|
||||
: extension.name
|
||||
}}</span>
|
||||
</template>
|
||||
</v-tooltip>
|
||||
<v-tooltip
|
||||
location="top"
|
||||
v-if="extension?.has_update && !marketMode"
|
||||
>
|
||||
<template v-slot:activator="{ props: tooltipProps }">
|
||||
<v-icon
|
||||
v-bind="tooltipProps"
|
||||
color="warning"
|
||||
class="ml-2"
|
||||
icon="mdi-update"
|
||||
size="small"
|
||||
></v-icon>
|
||||
</template>
|
||||
<span
|
||||
>{{ tm("card.status.hasUpdate") }}:
|
||||
{{ extension.online_version }}</span
|
||||
>
|
||||
</v-tooltip>
|
||||
<v-tooltip
|
||||
location="top"
|
||||
v-if="!extension.activated && !marketMode"
|
||||
>
|
||||
<template v-slot:activator="{ props: tooltipProps }">
|
||||
<v-icon
|
||||
v-bind="tooltipProps"
|
||||
color="error"
|
||||
class="ml-2"
|
||||
icon="mdi-cancel"
|
||||
size="small"
|
||||
></v-icon>
|
||||
</template>
|
||||
<span>{{ tm("card.status.disabled") }}</span>
|
||||
</v-tooltip>
|
||||
</p>
|
||||
|
||||
<div class="mt-1 d-flex flex-wrap">
|
||||
<v-chip color="primary" label size="small">
|
||||
<v-icon icon="mdi-source-branch" start></v-icon>
|
||||
{{ extension.version }}
|
||||
</v-chip>
|
||||
<v-chip
|
||||
v-if="extension?.has_update"
|
||||
color="warning"
|
||||
label
|
||||
size="small"
|
||||
class="ml-2"
|
||||
>
|
||||
<v-icon icon="mdi-arrow-up-bold" start></v-icon>
|
||||
{{ extension.online_version }}
|
||||
</v-chip>
|
||||
<v-chip
|
||||
color="primary"
|
||||
label
|
||||
size="small"
|
||||
class="ml-2"
|
||||
v-if="extension.handlers?.length"
|
||||
@click="viewHandlers"
|
||||
style="cursor: pointer"
|
||||
>
|
||||
<v-icon icon="mdi-cogs" start></v-icon>
|
||||
{{ extension.handlers?.length
|
||||
}}{{ tm("card.status.handlersCount") }}
|
||||
</v-chip>
|
||||
<v-chip
|
||||
v-for="tag in extension.tags"
|
||||
:key="tag"
|
||||
:color="tag === 'danger' ? 'error' : 'primary'"
|
||||
label
|
||||
size="small"
|
||||
class="ml-2"
|
||||
>
|
||||
{{ tag === "danger" ? tm("tags.danger") : tag }}
|
||||
</v-chip>
|
||||
<PluginPlatformChip
|
||||
:platforms="supportPlatforms"
|
||||
class="ml-2"
|
||||
/>
|
||||
<v-chip
|
||||
v-if="astrbotVersionRequirement"
|
||||
color="secondary"
|
||||
variant="outlined"
|
||||
label
|
||||
size="small"
|
||||
class="ml-2"
|
||||
>
|
||||
AstrBot: {{ astrbotVersionRequirement }}
|
||||
</v-chip>
|
||||
<template v-if="!marketMode">
|
||||
<v-tooltip location="left">
|
||||
<template v-slot:activator="{ props: tooltipProps }">
|
||||
<div v-bind="tooltipProps" class="extension-switch-wrap" @click.stop>
|
||||
<v-switch
|
||||
:model-value="extension.activated"
|
||||
color="success"
|
||||
density="compact"
|
||||
hide-details
|
||||
inset
|
||||
@update:model-value="toggleActivation"
|
||||
></v-switch>
|
||||
</div>
|
||||
</template>
|
||||
<span>{{
|
||||
extension.activated ? tm("buttons.disable") : tm("buttons.enable")
|
||||
}}</span>
|
||||
</v-tooltip>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="extension-market-menu-wrap">
|
||||
<v-menu offset-y>
|
||||
<template v-slot:activator="{ props: menuProps }">
|
||||
<v-btn
|
||||
icon
|
||||
variant="text"
|
||||
aria-label="more"
|
||||
v-if="extension?.repo"
|
||||
:href="extension?.repo"
|
||||
target="_blank"
|
||||
>
|
||||
<v-icon icon="mdi-github"></v-icon>
|
||||
</v-btn>
|
||||
<v-btn v-bind="menuProps" icon variant="text" aria-label="more">
|
||||
<v-icon icon="mdi-dots-vertical"></v-icon>
|
||||
</v-btn>
|
||||
</template>
|
||||
|
||||
<v-list>
|
||||
<v-list-item @click="viewReadme">
|
||||
<v-list-item-title
|
||||
>📄 {{ tm("buttons.viewDocs") }}</v-list-item-title
|
||||
>
|
||||
</v-list-item>
|
||||
|
||||
<v-list-item
|
||||
v-if="marketMode && !extension?.installed"
|
||||
@click="installExtension"
|
||||
>
|
||||
<v-list-item-title>
|
||||
{{ tm("buttons.install") }}</v-list-item-title
|
||||
>
|
||||
</v-list-item>
|
||||
|
||||
<v-list-item v-if="marketMode && extension?.installed">
|
||||
<v-list-item-title class="text--disabled">{{
|
||||
tm("status.installed")
|
||||
}}</v-list-item-title>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
</v-menu>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="mt-2"
|
||||
:class="{ 'text-caption': $vuetify.display.xs }"
|
||||
style="overflow-y: auto; height: 70px; font-size: 90%"
|
||||
>
|
||||
{{ extension.desc }}
|
||||
<div class="extension-content-row mt-2">
|
||||
<div class="extension-image-container">
|
||||
<img
|
||||
:src="logoSrc"
|
||||
:alt="extension.name"
|
||||
class="extension-logo"
|
||||
@error="logoLoadFailed = true"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="extension-meta-group">
|
||||
<div class="extension-chip-group d-flex flex-wrap">
|
||||
<v-chip color="primary" label size="small">
|
||||
<v-icon icon="mdi-source-branch" start></v-icon>
|
||||
{{ extension.version }}
|
||||
</v-chip>
|
||||
<v-chip
|
||||
v-if="extension?.has_update"
|
||||
color="warning"
|
||||
label
|
||||
size="small"
|
||||
>
|
||||
<v-icon icon="mdi-arrow-up-bold" start></v-icon>
|
||||
{{ extension.online_version }}
|
||||
</v-chip>
|
||||
<v-chip
|
||||
v-if="extension.handlers?.length"
|
||||
color="primary"
|
||||
label
|
||||
size="small"
|
||||
@click="viewHandlers"
|
||||
style="cursor: pointer"
|
||||
>
|
||||
<v-icon icon="mdi-cogs" start></v-icon>
|
||||
{{ extension.handlers?.length
|
||||
}}{{ tm("card.status.handlersCount") }}
|
||||
</v-chip>
|
||||
<v-chip
|
||||
v-for="tag in extension.tags"
|
||||
:key="tag"
|
||||
:color="tag === 'danger' ? 'error' : 'primary'"
|
||||
label
|
||||
size="small"
|
||||
>
|
||||
{{ tag === "danger" ? tm("tags.danger") : tag }}
|
||||
</v-chip>
|
||||
<PluginPlatformChip :platforms="supportPlatforms" />
|
||||
<v-chip
|
||||
v-if="astrbotVersionRequirement"
|
||||
color="secondary"
|
||||
variant="outlined"
|
||||
label
|
||||
size="small"
|
||||
>
|
||||
AstrBot: {{ astrbotVersionRequirement }}
|
||||
</v-chip>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="extension-desc"
|
||||
:class="{ 'text-caption': $vuetify.display.xs }"
|
||||
>
|
||||
{{ extension.desc }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</v-card-text>
|
||||
|
||||
<v-card-actions class="extension-actions">
|
||||
<v-btn color="primary" size="small" @click="viewReadme">
|
||||
{{ tm("buttons.viewDocs") }}
|
||||
</v-btn>
|
||||
<v-btn v-if="!marketMode" color="primary" size="small" @click="configure">
|
||||
{{ tm("card.actions.pluginConfig") }}
|
||||
</v-btn>
|
||||
<v-card-actions class="extension-actions" @click.stop>
|
||||
<template v-if="!marketMode">
|
||||
<v-spacer></v-spacer>
|
||||
<v-tooltip location="top" :text="tm('buttons.viewDocs')">
|
||||
<template v-slot:activator="{ props: actionProps }">
|
||||
<v-btn
|
||||
v-bind="actionProps"
|
||||
icon="mdi-book-open-page-variant"
|
||||
size="small"
|
||||
variant="tonal"
|
||||
color="info"
|
||||
@click="viewReadme"
|
||||
></v-btn>
|
||||
</template>
|
||||
</v-tooltip>
|
||||
|
||||
<v-tooltip location="top" :text="tm('card.actions.pluginConfig')">
|
||||
<template v-slot:activator="{ props: actionProps }">
|
||||
<v-btn
|
||||
v-bind="actionProps"
|
||||
icon="mdi-cog"
|
||||
size="small"
|
||||
variant="tonal"
|
||||
color="primary"
|
||||
@click="configure"
|
||||
></v-btn>
|
||||
</template>
|
||||
</v-tooltip>
|
||||
|
||||
<v-tooltip v-if="extension?.repo" location="top" :text="tm('buttons.viewRepo')">
|
||||
<template v-slot:activator="{ props: actionProps }">
|
||||
<v-btn
|
||||
v-bind="actionProps"
|
||||
icon="mdi-github"
|
||||
size="small"
|
||||
variant="tonal"
|
||||
color="secondary"
|
||||
:href="extension.repo"
|
||||
target="_blank"
|
||||
></v-btn>
|
||||
</template>
|
||||
</v-tooltip>
|
||||
|
||||
<v-tooltip location="top" :text="tm('card.actions.reloadPlugin')">
|
||||
<template v-slot:activator="{ props: actionProps }">
|
||||
<v-btn
|
||||
v-bind="actionProps"
|
||||
icon="mdi-refresh"
|
||||
size="small"
|
||||
variant="tonal"
|
||||
color="primary"
|
||||
@click="reloadExtension"
|
||||
></v-btn>
|
||||
</template>
|
||||
</v-tooltip>
|
||||
|
||||
<StyledMenu location="top end" offset="8">
|
||||
<template #activator="{ props: menuProps }">
|
||||
<v-btn
|
||||
v-bind="menuProps"
|
||||
icon="mdi-dots-horizontal"
|
||||
size="small"
|
||||
variant="tonal"
|
||||
color="secondary"
|
||||
></v-btn>
|
||||
</template>
|
||||
|
||||
<v-list-item class="styled-menu-item" prepend-icon="mdi-information" @click="viewHandlers">
|
||||
<v-list-item-title>{{ tm("buttons.viewInfo") }}</v-list-item-title>
|
||||
</v-list-item>
|
||||
|
||||
<v-list-item class="styled-menu-item" prepend-icon="mdi-update" @click="updateExtension">
|
||||
<v-list-item-title>{{
|
||||
extension.has_update
|
||||
? tm("card.actions.updateTo") + " " + extension.online_version
|
||||
: tm("card.actions.reinstall")
|
||||
}}</v-list-item-title>
|
||||
</v-list-item>
|
||||
|
||||
<v-list-item class="styled-menu-item" prepend-icon="mdi-delete" @click="uninstallExtension">
|
||||
<v-list-item-title class="text-error">{{ tm("card.actions.uninstallPlugin") }}</v-list-item-title>
|
||||
</v-list-item>
|
||||
</StyledMenu>
|
||||
</template>
|
||||
<template v-else>
|
||||
<v-btn color="primary" size="small" @click="viewReadme">
|
||||
{{ tm("buttons.viewDocs") }}
|
||||
</v-btn>
|
||||
</template>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
|
||||
@@ -384,13 +450,52 @@ const viewChangelog = () => {
|
||||
<style scoped>
|
||||
.extension-image-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-left: 12px;
|
||||
align-items: flex-start;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.extension-logo {
|
||||
width: 72px;
|
||||
height: 72px;
|
||||
border-radius: 12px;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.extension-content-row {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.extension-meta-group {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.extension-chip-group {
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.extension-desc {
|
||||
margin-top: 8px;
|
||||
font-size: 90%;
|
||||
overflow-y: auto;
|
||||
height: 70px;
|
||||
}
|
||||
|
||||
.extension-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.extension-title-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.extension-title__text {
|
||||
@@ -398,17 +503,38 @@ const viewChangelog = () => {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
padding-top: 6px;
|
||||
}
|
||||
|
||||
.extension-switch-wrap {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.extension-switch-wrap :deep(.v-switch) {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.extension-market-menu-wrap {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.extension-image-container {
|
||||
margin-left: 8px;
|
||||
.extension-content-row {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.extension-logo {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
}
|
||||
}
|
||||
|
||||
.extension-actions {
|
||||
margin-top: auto;
|
||||
gap: 8px;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
<v-expand-transition>
|
||||
<div v-if="radioValue === '1'" style="margin-left: 16px;">
|
||||
<v-radio-group v-model="githubProxyRadioControl" class="mt-2" hide-details="true">
|
||||
<v-radio color="success" v-for="(proxy, idx) in githubProxies" :key="proxy" :value="idx">
|
||||
<v-radio color="success" v-for="(proxy, idx) in githubProxies" :key="proxy" :value="String(idx)">
|
||||
<template v-slot:label>
|
||||
<div class="d-flex align-center">
|
||||
<span class="mr-2">{{ proxy }}</span>
|
||||
@@ -37,7 +37,7 @@
|
||||
</template>
|
||||
</v-radio>
|
||||
<v-radio color="primary" value="-1" :label="tm('network.proxySelector.custom')">
|
||||
<template v-slot:label v-if="githubProxyRadioControl === '-1'">
|
||||
<template v-slot:label v-if="String(githubProxyRadioControl) === '-1'">
|
||||
<v-text-field density="compact" v-model="selectedGitHubProxy" variant="outlined"
|
||||
style="width: 100vw;" :placeholder="tm('network.proxySelector.custom')" hide-details="true">
|
||||
</v-text-field>
|
||||
@@ -72,9 +72,21 @@ export default {
|
||||
loadingTestingConnection: false,
|
||||
testingProxies: {},
|
||||
proxyStatus: {},
|
||||
initializing: true,
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
getProxyByControl(control) {
|
||||
const normalizedControl = String(control);
|
||||
if (normalizedControl === "-1") {
|
||||
return "";
|
||||
}
|
||||
const index = Number.parseInt(normalizedControl, 10);
|
||||
if (Number.isNaN(index)) {
|
||||
return "";
|
||||
}
|
||||
return this.githubProxies[index] || "";
|
||||
},
|
||||
async testSingleProxy(idx) {
|
||||
this.testingProxies[idx] = true;
|
||||
|
||||
@@ -118,42 +130,60 @@ export default {
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
this.selectedGitHubProxy = localStorage.getItem('selectedGitHubProxy') || "";
|
||||
this.radioValue = localStorage.getItem('githubProxyRadioValue') || "0";
|
||||
this.githubProxyRadioControl = localStorage.getItem('githubProxyRadioControl') || "0";
|
||||
if (this.radioValue === "1") {
|
||||
if (this.githubProxyRadioControl !== "-1") {
|
||||
this.selectedGitHubProxy = this.githubProxies[this.githubProxyRadioControl] || "";
|
||||
this.initializing = true;
|
||||
|
||||
const savedProxy = localStorage.getItem('selectedGitHubProxy') || "";
|
||||
const savedRadio = localStorage.getItem('githubProxyRadioValue') || "0";
|
||||
const savedControl = String(localStorage.getItem('githubProxyRadioControl') || "0");
|
||||
|
||||
this.radioValue = savedRadio;
|
||||
this.githubProxyRadioControl = savedControl;
|
||||
|
||||
if (savedRadio === "1") {
|
||||
if (savedControl !== "-1") {
|
||||
this.selectedGitHubProxy = this.getProxyByControl(savedControl);
|
||||
} else {
|
||||
this.selectedGitHubProxy = savedProxy;
|
||||
}
|
||||
} else {
|
||||
this.selectedGitHubProxy = "";
|
||||
}
|
||||
|
||||
this.initializing = false;
|
||||
},
|
||||
watch: {
|
||||
selectedGitHubProxy: function (newVal, oldVal) {
|
||||
if (this.initializing) {
|
||||
return;
|
||||
}
|
||||
if (!newVal) {
|
||||
newVal = ""
|
||||
}
|
||||
localStorage.setItem('selectedGitHubProxy', newVal);
|
||||
},
|
||||
radioValue: function (newVal) {
|
||||
if (this.initializing) {
|
||||
return;
|
||||
}
|
||||
localStorage.setItem('githubProxyRadioValue', newVal);
|
||||
if (newVal === "0") {
|
||||
if (String(newVal) === "0") {
|
||||
this.selectedGitHubProxy = "";
|
||||
} else if (this.githubProxyRadioControl !== "-1") {
|
||||
this.selectedGitHubProxy = this.githubProxies[this.githubProxyRadioControl] || "";
|
||||
} else if (String(this.githubProxyRadioControl) !== "-1") {
|
||||
this.selectedGitHubProxy = this.getProxyByControl(this.githubProxyRadioControl);
|
||||
}
|
||||
},
|
||||
githubProxyRadioControl: function (newVal) {
|
||||
localStorage.setItem('githubProxyRadioControl', newVal);
|
||||
if (this.radioValue !== "1") {
|
||||
if (this.initializing) {
|
||||
return;
|
||||
}
|
||||
const normalizedVal = String(newVal);
|
||||
localStorage.setItem('githubProxyRadioControl', normalizedVal);
|
||||
if (String(this.radioValue) !== "1") {
|
||||
this.selectedGitHubProxy = "";
|
||||
return;
|
||||
}
|
||||
if (newVal !== "-1") {
|
||||
this.selectedGitHubProxy = this.githubProxies[newVal] || "";
|
||||
} else {
|
||||
this.selectedGitHubProxy = "";
|
||||
if (normalizedVal !== "-1") {
|
||||
this.selectedGitHubProxy = this.getProxyByControl(normalizedVal);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -560,7 +570,9 @@ export function useMessages(
|
||||
}
|
||||
}
|
||||
} catch (readError) {
|
||||
console.error('SSE读取错误:', readError);
|
||||
if (!userStopRequested.value) {
|
||||
console.error('SSE读取错误:', readError);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -569,7 +581,9 @@ export function useMessages(
|
||||
onSessionsUpdate();
|
||||
|
||||
} catch (err) {
|
||||
console.error('发送消息失败:', err);
|
||||
if (!userStopRequested.value) {
|
||||
console.error('发送消息失败:', err);
|
||||
}
|
||||
// 移除加载占位符
|
||||
const lastMsg = messages.value[messages.value.length - 1];
|
||||
if (lastMsg?.content?.isLoading) {
|
||||
@@ -577,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;
|
||||
@@ -584,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,
|
||||
@@ -592,6 +637,7 @@ export function useMessages(
|
||||
currentSessionProject,
|
||||
getSessionMessages,
|
||||
sendMessage,
|
||||
stopMessage,
|
||||
toggleStreaming,
|
||||
getAttachment
|
||||
};
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
"close": "Close",
|
||||
"copy": "Copy",
|
||||
"copied": "Copied",
|
||||
"copyFailed": "Copy failed",
|
||||
"delete": "Delete",
|
||||
"edit": "Edit",
|
||||
"add": "Add",
|
||||
|
||||
@@ -58,6 +58,18 @@
|
||||
"guideStep2": "Install it and restart AstrBot.",
|
||||
"guideStep3": "If you use Docker, prefer the image update path."
|
||||
},
|
||||
"desktopApp": {
|
||||
"title": "Update Desktop App",
|
||||
"message": "Check and upgrade the AstrBot desktop application.",
|
||||
"currentVersion": "Current version: ",
|
||||
"latestVersion": "Latest version: ",
|
||||
"checking": "Checking desktop app updates...",
|
||||
"hasNewVersion": "A new version is available. Click confirm to upgrade.",
|
||||
"isLatest": "Already on the latest version",
|
||||
"installing": "Downloading and installing update. The app will restart automatically...",
|
||||
"checkFailed": "Failed to check updates. Please try again later.",
|
||||
"installFailed": "Upgrade failed. Please try again later."
|
||||
},
|
||||
"dashboardUpdate": {
|
||||
"title": "Update Dashboard to Latest Version Only",
|
||||
"currentVersion": "Current Version",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -251,6 +251,10 @@
|
||||
"show_tool_use_status": {
|
||||
"description": "Output Function Call Status"
|
||||
},
|
||||
"show_tool_call_result": {
|
||||
"description": "Output Tool Call Results",
|
||||
"hint": "Only takes effect when \"Output Function Call Status\" is enabled, and shows at most 70 characters."
|
||||
},
|
||||
"sanitize_context_by_modalities": {
|
||||
"description": "Sanitize History by Modalities",
|
||||
"hint": "When enabled, sanitizes contexts before each LLM request by removing image blocks and tool-call structures that the current provider's modalities do not support (this changes what the model sees)."
|
||||
@@ -1082,6 +1086,12 @@
|
||||
"embedding_api_base": {
|
||||
"description": "API Base URL"
|
||||
},
|
||||
"openai_embedding": {
|
||||
"hint": "OpenAI Embedding automatically appends /v1 at request time."
|
||||
},
|
||||
"gemini_embedding": {
|
||||
"hint": "Gemini Embedding does not require manually adding /v1beta."
|
||||
},
|
||||
"volcengine_cluster": {
|
||||
"description": "Volcengine cluster",
|
||||
"hint": "For voice cloning models, choose volcano_icl or volcano_icl_concurr; default is volcano_tts."
|
||||
@@ -1309,6 +1319,10 @@
|
||||
"api_base": {
|
||||
"description": "API Base URL"
|
||||
},
|
||||
"proxy": {
|
||||
"description": "Proxy address",
|
||||
"hint": "HTTP/HTTPS proxy URL, e.g. http://127.0.0.1:7890. Applies only to this provider's API requests and does not affect Docker internal networking."
|
||||
},
|
||||
"model": {
|
||||
"description": "Model ID",
|
||||
"hint": "Model name, e.g., gpt-4o-mini, deepseek-chat."
|
||||
|
||||
@@ -8,6 +8,9 @@
|
||||
"handlersOperation": "Manage Handlers",
|
||||
"market": "AstrBot Plugin Market"
|
||||
},
|
||||
"titles": {
|
||||
"installedAstrBotPlugins": "Installed AstrBot Plugins"
|
||||
},
|
||||
"search": {
|
||||
"placeholder": "Search extensions...",
|
||||
"marketPlaceholder": "Search market extensions..."
|
||||
|
||||
@@ -8,11 +8,14 @@
|
||||
"refresh": "Refresh",
|
||||
"save": "Save",
|
||||
"add": "Add SubAgent",
|
||||
"delete": "Delete"
|
||||
"delete": "Delete",
|
||||
"close": "Close"
|
||||
},
|
||||
"switches": {
|
||||
"enable": "Enable SubAgent orchestration",
|
||||
"dedupe": "Deduplicate main LLM tools (hide tools duplicated by SubAgents)"
|
||||
"enableHint": "Enable sub-agent functionality",
|
||||
"dedupe": "Deduplicate main LLM tools (hide tools duplicated by SubAgents)",
|
||||
"dedupeHint": "Remove duplicate tools from main agent"
|
||||
},
|
||||
"description": {
|
||||
"disabled": "When off: SubAgent is disabled; the main LLM mounts tools via persona rules (all by default) and calls them directly.",
|
||||
@@ -29,7 +32,8 @@
|
||||
"transferPrefix": "transfer_to_{name}",
|
||||
"switchLabel": "Enable",
|
||||
"previewTitle": "Preview: handoff tool shown to the main LLM",
|
||||
"personaChip": "Persona: {id}"
|
||||
"personaChip": "Persona: {id}",
|
||||
"personaPreview": "PERSONA PREVIEW"
|
||||
},
|
||||
"form": {
|
||||
"nameLabel": "Agent name (used for transfer_to_{name})",
|
||||
@@ -49,6 +53,13 @@
|
||||
"nameDuplicate": "Duplicate SubAgent name: {name}",
|
||||
"personaMissing": "SubAgent {name} has no persona selected",
|
||||
"saveSuccess": "Saved successfully",
|
||||
"saveFailed": "Failed to save"
|
||||
"saveFailed": "Failed to save",
|
||||
"nameRequired": "Name is required",
|
||||
"namePattern": "Lowercase letters, numbers, underscore only"
|
||||
},
|
||||
"empty": {
|
||||
"title": "No Agents Configured",
|
||||
"subtitle": "Add a new sub-agent to get started",
|
||||
"action": "Create First Agent"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
"close": "关闭",
|
||||
"copy": "复制",
|
||||
"copied": "已复制",
|
||||
"copyFailed": "复制失败",
|
||||
"delete": "删除",
|
||||
"edit": "编辑",
|
||||
"add": "添加",
|
||||
|
||||
@@ -58,6 +58,18 @@
|
||||
"guideStep2": "完成安装后重启 AstrBot。",
|
||||
"guideStep3": "如果你使用 Docker,请优先使用镜像更新方式。"
|
||||
},
|
||||
"desktopApp": {
|
||||
"title": "更新桌面应用",
|
||||
"message": "将检查并升级 AstrBot 桌面端程序。",
|
||||
"currentVersion": "当前版本:",
|
||||
"latestVersion": "最新版本:",
|
||||
"checking": "正在检查桌面应用更新...",
|
||||
"hasNewVersion": "发现新版本,可点击确认升级。",
|
||||
"isLatest": "已经是最新版本",
|
||||
"installing": "正在下载并安装更新,完成后将自动重启应用...",
|
||||
"checkFailed": "检查更新失败,请稍后重试。",
|
||||
"installFailed": "升级失败,请稍后重试。"
|
||||
},
|
||||
"dashboardUpdate": {
|
||||
"title": "单独更新管理面板到最新版本",
|
||||
"currentVersion": "当前版本",
|
||||
|
||||
@@ -9,7 +9,8 @@
|
||||
"voice": "语音输入",
|
||||
"recordingPrompt": "录音中,请说话...",
|
||||
"chatPrompt": "聊天吧!",
|
||||
"dropToUpload": "松开鼠标上传文件"
|
||||
"dropToUpload": "松开鼠标上传文件",
|
||||
"stopGenerating": "停止生成"
|
||||
},
|
||||
"message": {
|
||||
"user": "用户",
|
||||
|
||||
@@ -254,6 +254,10 @@
|
||||
"show_tool_use_status": {
|
||||
"description": "输出函数调用状态"
|
||||
},
|
||||
"show_tool_call_result": {
|
||||
"description": "输出函数调用返回结果",
|
||||
"hint": "仅在启用“输出函数调用状态”时生效,且最多展示 70 个字符。"
|
||||
},
|
||||
"sanitize_context_by_modalities": {
|
||||
"description": "按模型能力清理历史上下文",
|
||||
"hint": "开启后,在每次请求 LLM 前会按当前模型提供商中所选择的模型能力删除对话中不支持的图片/工具调用结构(会改变模型看到的历史)"
|
||||
@@ -1085,6 +1089,12 @@
|
||||
"embedding_api_base": {
|
||||
"description": "API Base URL"
|
||||
},
|
||||
"openai_embedding": {
|
||||
"hint": "OpenAI Embedding 会在请求时自动补上 /v1。"
|
||||
},
|
||||
"gemini_embedding": {
|
||||
"hint": "Gemini Embedding 无需手动添加 /v1beta。"
|
||||
},
|
||||
"volcengine_cluster": {
|
||||
"description": "火山引擎集群",
|
||||
"hint": "若使用语音复刻大模型,可选volcano_icl或volcano_icl_concurr,默认使用volcano_tts"
|
||||
@@ -1312,6 +1322,10 @@
|
||||
"api_base": {
|
||||
"description": "API Base URL"
|
||||
},
|
||||
"proxy": {
|
||||
"description": "代理地址",
|
||||
"hint": "HTTP/HTTPS 代理地址,格式如 http://127.0.0.1:7890。仅对该提供商的 API 请求生效,不影响 Docker 内网通信。"
|
||||
},
|
||||
"model": {
|
||||
"description": "模型 ID",
|
||||
"hint": "模型名称,如 gpt-4o-mini, deepseek-chat。"
|
||||
|
||||
@@ -8,6 +8,9 @@
|
||||
"skills": "Skills",
|
||||
"handlersOperation": "管理行为"
|
||||
},
|
||||
"titles": {
|
||||
"installedAstrBotPlugins": "已安装的 AstrBot 插件"
|
||||
},
|
||||
"search": {
|
||||
"placeholder": "搜索插件...",
|
||||
"marketPlaceholder": "搜索市场插件..."
|
||||
|
||||
@@ -8,11 +8,14 @@
|
||||
"refresh": "刷新",
|
||||
"save": "保存",
|
||||
"add": "新增 SubAgent",
|
||||
"delete": "删除"
|
||||
"delete": "删除",
|
||||
"close": "关闭"
|
||||
},
|
||||
"switches": {
|
||||
"enable": "启用 SubAgent 编排",
|
||||
"dedupe": "主 LLM 去重重复工具(与 SubAgent 重叠的工具将被隐藏)"
|
||||
"enableHint": "启用子代理功能",
|
||||
"dedupe": "主 LLM 去重重复工具(与 SubAgent 重叠的工具将被隐藏)",
|
||||
"dedupeHint": "从主代理中移除重复工具"
|
||||
},
|
||||
"description": {
|
||||
"disabled": "不启动:SubAgent 关闭;主 LLM 按 persona 规则挂载工具(默认全部),并直接调用。",
|
||||
@@ -39,6 +42,7 @@
|
||||
"providerHint": "留空表示跟随全局默认 provider。",
|
||||
"personaLabel": "选择人格设定",
|
||||
"personaHint": "SubAgent 将直接继承所选 Persona 的系统设定与工具。在人格设定页管理和新建人格。",
|
||||
"personaPreview": "人格预览",
|
||||
"descriptionLabel": "对主 LLM 的描述(用于决定是否 handoff)",
|
||||
"descriptionHint": "这段会作为 transfer_to_* 工具的描述给主 LLM 看,建议简短明确。"
|
||||
},
|
||||
@@ -50,6 +54,13 @@
|
||||
"nameDuplicate": "SubAgent 名称重复:{name}",
|
||||
"personaMissing": "SubAgent {name} 未选择 Persona",
|
||||
"saveSuccess": "保存成功",
|
||||
"saveFailed": "保存失败"
|
||||
"saveFailed": "保存失败",
|
||||
"nameRequired": "名称必填",
|
||||
"namePattern": "仅支持小写字母、数字和下划线"
|
||||
},
|
||||
"empty": {
|
||||
"title": "未配置 SubAgent",
|
||||
"subtitle": "添加一个新的子代理以开始",
|
||||
"action": "创建第一个 Agent"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -50,11 +50,28 @@ let installLoading = ref(false);
|
||||
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 desktopUpdateDialog = ref(false);
|
||||
const desktopUpdateChecking = ref(false);
|
||||
const desktopUpdateInstalling = ref(false);
|
||||
const desktopUpdateHasNewVersion = ref(false);
|
||||
const desktopUpdateCurrentVersion = ref('-');
|
||||
const desktopUpdateLatestVersion = ref('-');
|
||||
const desktopUpdateStatus = ref('');
|
||||
|
||||
const getAppUpdaterBridge = (): AstrBotAppUpdaterBridge | null => {
|
||||
if (typeof window === 'undefined') {
|
||||
return null;
|
||||
}
|
||||
const bridge = window.astrbotAppUpdater;
|
||||
if (
|
||||
bridge &&
|
||||
typeof bridge.checkForAppUpdate === 'function' &&
|
||||
typeof bridge.installAppUpdate === 'function'
|
||||
) {
|
||||
return bridge;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const getSelectedGitHubProxy = () => {
|
||||
if (typeof window === "undefined" || !window.localStorage) return "";
|
||||
@@ -75,16 +92,6 @@ const releasesHeader = computed(() => [
|
||||
{ title: t('core.header.updateDialog.table.sourceUrl'), key: 'zipball_url' },
|
||||
{ title: t('core.header.updateDialog.table.actions'), key: 'switch' }
|
||||
]);
|
||||
const latestReleaseTag = computed(() => {
|
||||
const firstRelease = (releases.value as any[])?.[0];
|
||||
if (firstRelease?.tag_name) {
|
||||
return firstRelease.tag_name as string;
|
||||
}
|
||||
return hasNewVersion.value
|
||||
? t('core.header.updateDialog.redirectConfirm.latestLabel')
|
||||
: (botCurrVersion.value || '-');
|
||||
});
|
||||
|
||||
// Form validation
|
||||
const formValid = ref(true);
|
||||
const passwordRules = computed(() => [
|
||||
@@ -112,50 +119,88 @@ const accountEditStatus = ref({
|
||||
message: ''
|
||||
});
|
||||
|
||||
const open = (link: string) => {
|
||||
window.open(link, '_blank');
|
||||
};
|
||||
|
||||
function requestExternalRedirect(link: string) {
|
||||
pendingRedirectUrl.value = link;
|
||||
redirectConfirmDialog.value = true;
|
||||
function cancelDesktopUpdate() {
|
||||
if (desktopUpdateInstalling.value) {
|
||||
return;
|
||||
}
|
||||
desktopUpdateDialog.value = false;
|
||||
}
|
||||
|
||||
function cancelExternalRedirect() {
|
||||
redirectConfirmDialog.value = false;
|
||||
pendingRedirectUrl.value = '';
|
||||
}
|
||||
async function openDesktopUpdateDialog() {
|
||||
desktopUpdateDialog.value = true;
|
||||
desktopUpdateChecking.value = true;
|
||||
desktopUpdateInstalling.value = false;
|
||||
desktopUpdateHasNewVersion.value = false;
|
||||
desktopUpdateCurrentVersion.value = '-';
|
||||
desktopUpdateLatestVersion.value = '-';
|
||||
desktopUpdateStatus.value = t('core.header.updateDialog.desktopApp.checking');
|
||||
|
||||
function confirmExternalRedirect() {
|
||||
const targetUrl = pendingRedirectUrl.value;
|
||||
cancelExternalRedirect();
|
||||
if (targetUrl) {
|
||||
open(targetUrl);
|
||||
const bridge = getAppUpdaterBridge();
|
||||
if (!bridge) {
|
||||
desktopUpdateChecking.value = false;
|
||||
desktopUpdateStatus.value = t('core.header.updateDialog.desktopApp.checkFailed');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await bridge.checkForAppUpdate();
|
||||
if (!result?.ok) {
|
||||
desktopUpdateCurrentVersion.value = result?.currentVersion || '-';
|
||||
desktopUpdateLatestVersion.value =
|
||||
result?.latestVersion || result?.currentVersion || '-';
|
||||
desktopUpdateStatus.value =
|
||||
result?.reason || t('core.header.updateDialog.desktopApp.checkFailed');
|
||||
return;
|
||||
}
|
||||
|
||||
desktopUpdateCurrentVersion.value = result.currentVersion || '-';
|
||||
desktopUpdateLatestVersion.value =
|
||||
result.latestVersion || result.currentVersion || '-';
|
||||
desktopUpdateHasNewVersion.value = !!result.hasUpdate;
|
||||
desktopUpdateStatus.value = result.hasUpdate
|
||||
? t('core.header.updateDialog.desktopApp.hasNewVersion')
|
||||
: t('core.header.updateDialog.desktopApp.isLatest');
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
desktopUpdateStatus.value = t('core.header.updateDialog.desktopApp.checkFailed');
|
||||
} finally {
|
||||
desktopUpdateChecking.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
const getReleaseUrlForDesktop = () => {
|
||||
const firstRelease = (releases.value as any[])?.[0];
|
||||
if (firstRelease?.tag_name) {
|
||||
const tag = firstRelease.tag_name as string;
|
||||
return `${desktopReleaseBaseUrl}/tag/${tag}`;
|
||||
async function confirmDesktopUpdate() {
|
||||
if (!desktopUpdateHasNewVersion.value || desktopUpdateInstalling.value) {
|
||||
return;
|
||||
}
|
||||
if (hasNewVersion.value) return fallbackReleaseUrl;
|
||||
const tag = botCurrVersion.value?.startsWith('v') ? botCurrVersion.value : 'latest';
|
||||
return tag === 'latest'
|
||||
? fallbackReleaseUrl
|
||||
: `${desktopReleaseBaseUrl}/tag/${tag}`;
|
||||
};
|
||||
|
||||
const bridge = getAppUpdaterBridge();
|
||||
if (!bridge) {
|
||||
desktopUpdateStatus.value = t('core.header.updateDialog.desktopApp.installFailed');
|
||||
return;
|
||||
}
|
||||
|
||||
desktopUpdateInstalling.value = true;
|
||||
desktopUpdateStatus.value = t('core.header.updateDialog.desktopApp.installing');
|
||||
|
||||
try {
|
||||
const result = await bridge.installAppUpdate();
|
||||
if (result?.ok) {
|
||||
desktopUpdateDialog.value = false;
|
||||
return;
|
||||
}
|
||||
desktopUpdateStatus.value =
|
||||
result?.reason || t('core.header.updateDialog.desktopApp.installFailed');
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
desktopUpdateStatus.value = t('core.header.updateDialog.desktopApp.installFailed');
|
||||
} finally {
|
||||
desktopUpdateInstalling.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleUpdateClick() {
|
||||
if (isDesktopReleaseMode.value) {
|
||||
requestExternalRedirect('');
|
||||
resolvingReleaseTarget.value = true;
|
||||
checkUpdate();
|
||||
void getReleases().finally(() => {
|
||||
pendingRedirectUrl.value = getReleaseUrlForDesktop() || fallbackReleaseUrl;
|
||||
resolvingReleaseTarget.value = false;
|
||||
});
|
||||
void openDesktopUpdateDialog();
|
||||
return;
|
||||
}
|
||||
checkUpdate();
|
||||
@@ -669,40 +714,38 @@ onMounted(async () => {
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
|
||||
<v-dialog v-model="redirectConfirmDialog" max-width="460">
|
||||
<v-dialog v-model="desktopUpdateDialog" max-width="460">
|
||||
<v-card>
|
||||
<v-card-title class="text-h3 pa-4 pl-6 pb-0">
|
||||
{{ t('core.header.updateDialog.redirectConfirm.title') }}
|
||||
{{ t('core.header.updateDialog.desktopApp.title') }}
|
||||
</v-card-title>
|
||||
<v-card-text>
|
||||
<div class="mb-3">
|
||||
{{ t('core.header.updateDialog.redirectConfirm.message') }}
|
||||
{{ t('core.header.updateDialog.desktopApp.message') }}
|
||||
</div>
|
||||
<v-alert type="info" variant="tonal" density="compact">
|
||||
<div>
|
||||
{{ t('core.header.updateDialog.redirectConfirm.targetVersion') }}
|
||||
<strong v-if="!resolvingReleaseTarget">{{ latestReleaseTag }}</strong>
|
||||
<v-progress-circular v-else indeterminate size="16" width="2" class="ml-1" />
|
||||
{{ t('core.header.updateDialog.desktopApp.currentVersion') }}
|
||||
<strong>{{ desktopUpdateCurrentVersion }}</strong>
|
||||
</div>
|
||||
<div class="text-caption">
|
||||
{{ t('core.header.updateDialog.redirectConfirm.currentVersion') }}
|
||||
{{ botCurrVersion || '-' }}
|
||||
<div>
|
||||
{{ t('core.header.updateDialog.desktopApp.latestVersion') }}
|
||||
<strong v-if="!desktopUpdateChecking">{{ desktopUpdateLatestVersion }}</strong>
|
||||
<v-progress-circular v-else indeterminate size="16" width="2" class="ml-1" />
|
||||
</div>
|
||||
</v-alert>
|
||||
<div class="text-caption mt-3">
|
||||
<div>{{ t('core.header.updateDialog.redirectConfirm.guideTitle') }}</div>
|
||||
<div>1. {{ t('core.header.updateDialog.redirectConfirm.guideStep1') }}</div>
|
||||
<div>2. {{ t('core.header.updateDialog.redirectConfirm.guideStep2') }}</div>
|
||||
<div>3. {{ t('core.header.updateDialog.redirectConfirm.guideStep3') }}</div>
|
||||
{{ desktopUpdateStatus }}
|
||||
</div>
|
||||
</v-card-text>
|
||||
<v-card-actions>
|
||||
<v-spacer></v-spacer>
|
||||
<v-btn color="grey" variant="text" @click="cancelExternalRedirect">
|
||||
<v-btn color="grey" variant="text" @click="cancelDesktopUpdate" :disabled="desktopUpdateInstalling">
|
||||
{{ t('core.common.dialog.cancelButton') }}
|
||||
</v-btn>
|
||||
<v-btn color="primary" variant="flat" @click="confirmExternalRedirect"
|
||||
:loading="resolvingReleaseTarget" :disabled="resolvingReleaseTarget || !pendingRedirectUrl">
|
||||
<v-btn color="primary" variant="flat" @click="confirmDesktopUpdate"
|
||||
:loading="desktopUpdateInstalling"
|
||||
:disabled="desktopUpdateChecking || desktopUpdateInstalling || !desktopUpdateHasNewVersion">
|
||||
{{ t('core.common.dialog.confirmButton') }}
|
||||
</v-btn>
|
||||
</v-card-actions>
|
||||
|
||||
+19
@@ -1,7 +1,26 @@
|
||||
export {};
|
||||
|
||||
declare global {
|
||||
interface AstrBotDesktopAppUpdateCheckResult {
|
||||
ok: boolean;
|
||||
reason?: string | null;
|
||||
currentVersion?: string;
|
||||
latestVersion?: string | null;
|
||||
hasUpdate: boolean;
|
||||
}
|
||||
|
||||
interface AstrBotDesktopAppUpdateResult {
|
||||
ok: boolean;
|
||||
reason?: string | null;
|
||||
}
|
||||
|
||||
interface AstrBotAppUpdaterBridge {
|
||||
checkForAppUpdate: () => Promise<AstrBotDesktopAppUpdateCheckResult>;
|
||||
installAppUpdate: () => Promise<AstrBotDesktopAppUpdateResult>;
|
||||
}
|
||||
|
||||
interface Window {
|
||||
astrbotAppUpdater?: AstrBotAppUpdaterBridge;
|
||||
astrbotDesktop?: {
|
||||
isDesktop: boolean;
|
||||
isDesktopRuntime: () => Promise<boolean>;
|
||||
@@ -61,6 +61,7 @@ export function getTutorialLink(platformType) {
|
||||
"vocechat": "https://docs.astrbot.app/deploy/platform/vocechat.html",
|
||||
"satori": "https://docs.astrbot.app/deploy/platform/satori/llonebot.html",
|
||||
"misskey": "https://docs.astrbot.app/deploy/platform/misskey.html",
|
||||
"line": "https://docs.astrbot.app/deploy/platform/line.html",
|
||||
}
|
||||
return tutorialMap[platformType] || "https://docs.astrbot.app";
|
||||
}
|
||||
|
||||
+250
-2182
File diff suppressed because it is too large
Load Diff
@@ -333,12 +333,53 @@ const loadApiKeys = async () => {
|
||||
}
|
||||
};
|
||||
|
||||
const tryExecCommandCopy = (text) => {
|
||||
let textArea = null;
|
||||
try {
|
||||
if (typeof document === 'undefined' || !document.body) return false;
|
||||
textArea = document.createElement('textarea');
|
||||
textArea.value = text;
|
||||
textArea.setAttribute('readonly', '');
|
||||
textArea.style.position = 'fixed';
|
||||
textArea.style.opacity = '0';
|
||||
textArea.style.pointerEvents = 'none';
|
||||
textArea.style.left = '-9999px';
|
||||
document.body.appendChild(textArea);
|
||||
textArea.focus();
|
||||
textArea.select();
|
||||
textArea.setSelectionRange(0, text.length);
|
||||
return document.execCommand('copy');
|
||||
} catch (_) {
|
||||
return false;
|
||||
} finally {
|
||||
try {
|
||||
if (textArea?.parentNode) {
|
||||
textArea.parentNode.removeChild(textArea);
|
||||
}
|
||||
} catch (_) {
|
||||
// ignore cleanup errors
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const copyTextToClipboard = async (text) => {
|
||||
if (!text) return false;
|
||||
if (tryExecCommandCopy(text)) return true;
|
||||
if (typeof navigator === 'undefined' || !navigator.clipboard?.writeText) return false;
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
return true;
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const copyCreatedApiKey = async () => {
|
||||
if (!createdApiKeyPlaintext.value) return;
|
||||
try {
|
||||
await navigator.clipboard.writeText(createdApiKeyPlaintext.value);
|
||||
const ok = await copyTextToClipboard(createdApiKeyPlaintext.value);
|
||||
if (ok) {
|
||||
showToast(tm('apiKey.messages.copySuccess'), 'success');
|
||||
} catch (_) {
|
||||
} else {
|
||||
showToast(tm('apiKey.messages.copyFailed'), 'error');
|
||||
}
|
||||
};
|
||||
|
||||
@@ -62,7 +62,7 @@
|
||||
<template #label>
|
||||
<div class="d-flex flex-column">
|
||||
<span class="text-body-2 font-weight-medium">{{ tm('switches.enable') }}</span>
|
||||
<span class="text-caption text-medium-emphasis">Enable sub-agent functionality</span>
|
||||
<span class="text-caption text-medium-emphasis">{{ tm('switches.enableHint') }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</v-switch>
|
||||
@@ -80,7 +80,7 @@
|
||||
<template #label>
|
||||
<div class="d-flex flex-column">
|
||||
<span class="text-body-2 font-weight-medium">{{ tm('switches.dedupe') }}</span>
|
||||
<span class="text-caption text-medium-emphasis">Remove duplicate tools from main agent</span>
|
||||
<span class="text-caption text-medium-emphasis">{{ tm('switches.dedupeHint') }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</v-switch>
|
||||
@@ -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"
|
||||
@@ -167,7 +166,7 @@
|
||||
<v-text-field
|
||||
v-model="agent.name"
|
||||
:label="tm('form.nameLabel')"
|
||||
:rules="[v => !!v || 'Name is required', v => /^[a-z][a-z0-9_]*$/.test(v) || 'Lowercase letters, numbers, underscore only']"
|
||||
:rules="[v => !!v || tm('messages.nameRequired'), v => /^[a-z][a-z0-9_]*$/.test(v) || tm('messages.namePattern')]"
|
||||
variant="outlined"
|
||||
density="comfortable"
|
||||
hide-details="auto"
|
||||
@@ -216,7 +215,7 @@
|
||||
<v-col cols="12" md="6">
|
||||
<div class="h-100">
|
||||
<div class="text-caption font-weight-bold text-medium-emphasis mb-2 ml-1">
|
||||
PERSONA PREVIEW
|
||||
{{ tm('cards.personaPreview') }}
|
||||
</div>
|
||||
<PersonaQuickPreview
|
||||
:model-value="agent.persona_id"
|
||||
@@ -232,17 +231,17 @@
|
||||
<!-- Empty State -->
|
||||
<div v-if="cfg.agents.length === 0" class="d-flex flex-column align-center justify-center py-12 text-medium-emphasis">
|
||||
<v-icon icon="mdi-robot-off" size="64" class="mb-4 opacity-50" />
|
||||
<div class="text-h6">No Agents Configured</div>
|
||||
<div class="text-body-2 mb-4">Add a new sub-agent to get started</div>
|
||||
<div class="text-h6">{{ tm('empty.title') }}</div>
|
||||
<div class="text-body-2 mb-4">{{ tm('empty.subtitle') }}</div>
|
||||
<v-btn color="primary" variant="tonal" @click="addAgent">
|
||||
Create First Agent
|
||||
{{ tm('empty.action') }}
|
||||
</v-btn>
|
||||
</div>
|
||||
|
||||
<v-snackbar v-model="snackbar.show" :color="snackbar.color" timeout="3000" location="top">
|
||||
{{ snackbar.message }}
|
||||
<template #actions>
|
||||
<v-btn variant="text" @click="snackbar.show = false">Close</v-btn>
|
||||
<v-btn variant="text" @click="snackbar.show = false">{{ tm('actions.close') }}</v-btn>
|
||||
</template>
|
||||
</v-snackbar>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,639 @@
|
||||
<script setup>
|
||||
import ExtensionCard from "@/components/shared/ExtensionCard.vue";
|
||||
import StyledMenu from "@/components/shared/StyledMenu.vue";
|
||||
import defaultPluginIcon from "@/assets/images/plugin_icon.png";
|
||||
|
||||
const props = defineProps({
|
||||
state: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
const {
|
||||
commonStore,
|
||||
t,
|
||||
tm,
|
||||
router,
|
||||
route,
|
||||
getSelectedGitHubProxy,
|
||||
conflictDialog,
|
||||
checkAndPromptConflicts,
|
||||
handleConflictConfirm,
|
||||
fileInput,
|
||||
activeTab,
|
||||
validTabs,
|
||||
isValidTab,
|
||||
getLocationHash,
|
||||
extractTabFromHash,
|
||||
syncTabFromHash,
|
||||
extension_data,
|
||||
getInitialShowReserved,
|
||||
showReserved,
|
||||
snack_message,
|
||||
snack_show,
|
||||
snack_success,
|
||||
configDialog,
|
||||
extension_config,
|
||||
pluginMarketData,
|
||||
loadingDialog,
|
||||
showPluginInfoDialog,
|
||||
selectedPlugin,
|
||||
curr_namespace,
|
||||
updatingAll,
|
||||
readmeDialog,
|
||||
forceUpdateDialog,
|
||||
updateAllConfirmDialog,
|
||||
changelogDialog,
|
||||
getInitialListViewMode,
|
||||
isListView,
|
||||
pluginSearch,
|
||||
loading_,
|
||||
currentPage,
|
||||
dangerConfirmDialog,
|
||||
selectedDangerPlugin,
|
||||
selectedMarketInstallPlugin,
|
||||
installCompat,
|
||||
versionCompatibilityDialog,
|
||||
showUninstallDialog,
|
||||
pluginToUninstall,
|
||||
showSourceDialog,
|
||||
showSourceManagerDialog,
|
||||
sourceName,
|
||||
sourceUrl,
|
||||
customSources,
|
||||
selectedSource,
|
||||
showRemoveSourceDialog,
|
||||
sourceToRemove,
|
||||
editingSource,
|
||||
originalSourceUrl,
|
||||
extension_url,
|
||||
dialog,
|
||||
upload_file,
|
||||
uploadTab,
|
||||
showPluginFullName,
|
||||
marketSearch,
|
||||
debouncedMarketSearch,
|
||||
refreshingMarket,
|
||||
sortBy,
|
||||
sortOrder,
|
||||
randomPluginNames,
|
||||
normalizeStr,
|
||||
toPinyinText,
|
||||
toInitials,
|
||||
marketCustomFilter,
|
||||
plugin_handler_info_headers,
|
||||
pluginHeaders,
|
||||
filteredExtensions,
|
||||
filteredPlugins,
|
||||
filteredMarketPlugins,
|
||||
sortedPlugins,
|
||||
RANDOM_PLUGINS_COUNT,
|
||||
randomPlugins,
|
||||
shufflePlugins,
|
||||
refreshRandomPlugins,
|
||||
displayItemsPerPage,
|
||||
totalPages,
|
||||
paginatedPlugins,
|
||||
updatableExtensions,
|
||||
toggleShowReserved,
|
||||
toast,
|
||||
resetLoadingDialog,
|
||||
onLoadingDialogResult,
|
||||
failedPluginsDict,
|
||||
getExtensions,
|
||||
handleReloadAllFailed,
|
||||
checkUpdate,
|
||||
uninstallExtension,
|
||||
handleUninstallConfirm,
|
||||
updateExtension,
|
||||
showUpdateAllConfirm,
|
||||
confirmUpdateAll,
|
||||
cancelUpdateAll,
|
||||
confirmForceUpdate,
|
||||
updateAllExtensions,
|
||||
pluginOn,
|
||||
pluginOff,
|
||||
openExtensionConfig,
|
||||
updateConfig,
|
||||
showPluginInfo,
|
||||
reloadPlugin,
|
||||
viewReadme,
|
||||
viewChangelog,
|
||||
handleInstallPlugin,
|
||||
confirmDangerInstall,
|
||||
cancelDangerInstall,
|
||||
loadCustomSources,
|
||||
saveCustomSources,
|
||||
addCustomSource,
|
||||
openSourceManagerDialog,
|
||||
selectPluginSource,
|
||||
sourceSelectItems,
|
||||
editCustomSource,
|
||||
removeCustomSource,
|
||||
confirmRemoveSource,
|
||||
saveCustomSource,
|
||||
trimExtensionName,
|
||||
checkAlreadyInstalled,
|
||||
showVersionCompatibilityWarning,
|
||||
continueInstallIgnoringVersionWarning,
|
||||
cancelInstallOnVersionWarning,
|
||||
newExtension,
|
||||
normalizePlatformList,
|
||||
getPlatformDisplayList,
|
||||
resolveSelectedInstallPlugin,
|
||||
selectedInstallPlugin,
|
||||
checkInstallCompatibility,
|
||||
refreshPluginMarket,
|
||||
handleLocaleChange,
|
||||
searchDebounceTimer,
|
||||
} = props.state;
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<v-tab-item v-show="activeTab === 'installed'">
|
||||
<div class="mb-4 pt-4 pb-4">
|
||||
<div class="d-flex align-center flex-wrap" style="gap: 12px">
|
||||
<h2 class="text-h2 mb-0">{{ tm("titles.installedAstrBotPlugins") }}</h2>
|
||||
|
||||
<div class="d-flex align-center flex-wrap ml-auto" style="gap: 8px">
|
||||
<v-text-field
|
||||
v-model="pluginSearch"
|
||||
density="compact"
|
||||
:label="tm('search.placeholder')"
|
||||
prepend-inner-icon="mdi-magnify"
|
||||
variant="solo-filled"
|
||||
flat
|
||||
hide-details
|
||||
single-line
|
||||
style="min-width: 220px; max-width: 340px"
|
||||
>
|
||||
</v-text-field>
|
||||
|
||||
<v-btn-toggle
|
||||
v-model="isListView"
|
||||
mandatory
|
||||
density="compact"
|
||||
color="primary"
|
||||
class="view-mode-toggle"
|
||||
>
|
||||
<v-btn :value="false" icon="mdi-view-grid"></v-btn>
|
||||
<v-btn :value="true" icon="mdi-view-list"></v-btn>
|
||||
</v-btn-toggle>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<v-row class="mb-4">
|
||||
<v-col cols="12" class="d-flex align-center flex-wrap ga-2">
|
||||
<v-btn variant="tonal" @click="toggleShowReserved">
|
||||
<v-icon>{{
|
||||
showReserved ? "mdi-eye-off" : "mdi-eye"
|
||||
}}</v-icon>
|
||||
{{
|
||||
showReserved
|
||||
? tm("buttons.hideSystemPlugins")
|
||||
: tm("buttons.showSystemPlugins")
|
||||
}}
|
||||
</v-btn>
|
||||
|
||||
<v-btn
|
||||
class="ml-2"
|
||||
color="warning"
|
||||
variant="tonal"
|
||||
:disabled="updatableExtensions.length === 0"
|
||||
:loading="updatingAll"
|
||||
@click="showUpdateAllConfirm"
|
||||
>
|
||||
<v-icon>mdi-update</v-icon>
|
||||
{{ tm("buttons.updateAll") }}
|
||||
</v-btn>
|
||||
|
||||
<v-dialog max-width="500px" v-if="extension_data.message">
|
||||
<template v-slot:activator="{ props }">
|
||||
<v-btn
|
||||
v-bind="props"
|
||||
icon
|
||||
size="small"
|
||||
color="error"
|
||||
class="ml-auto"
|
||||
variant="tonal"
|
||||
>
|
||||
<v-icon>mdi-alert-circle</v-icon>
|
||||
</v-btn>
|
||||
</template>
|
||||
<template v-slot:default="{ isActive }">
|
||||
<v-card class="rounded-lg">
|
||||
<v-card-title class="headline d-flex align-center">
|
||||
<v-icon color="error" class="mr-2"
|
||||
>mdi-alert-circle</v-icon
|
||||
>
|
||||
{{ tm("dialogs.error.title") }}
|
||||
</v-card-title>
|
||||
<v-card-text>
|
||||
<p class="text-body-1">
|
||||
{{ extension_data.message }}
|
||||
</p>
|
||||
<p class="text-caption mt-2">
|
||||
{{ tm("dialogs.error.checkConsole") }}
|
||||
</p>
|
||||
</v-card-text>
|
||||
<v-card-actions>
|
||||
<v-btn
|
||||
color="error"
|
||||
variant="tonal"
|
||||
prepend-icon="mdi-refresh"
|
||||
@click="handleReloadAllFailed"
|
||||
>
|
||||
尝试一键重载修复
|
||||
</v-btn>
|
||||
<v-spacer></v-spacer>
|
||||
<v-btn
|
||||
color="primary"
|
||||
@click="isActive.value = false"
|
||||
>{{ tm("buttons.close") }}</v-btn
|
||||
>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</template>
|
||||
</v-dialog>
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
<v-fade-transition hide-on-leave>
|
||||
<!-- 表格视图 -->
|
||||
<div v-if="isListView">
|
||||
<v-card class="rounded-lg overflow-hidden elevation-0">
|
||||
<v-data-table
|
||||
:headers="pluginHeaders"
|
||||
:items="filteredPlugins"
|
||||
:loading="loading_"
|
||||
item-key="name"
|
||||
hover
|
||||
>
|
||||
<template v-slot:loader>
|
||||
<v-row class="py-8 d-flex align-center justify-center">
|
||||
<v-progress-circular
|
||||
indeterminate
|
||||
color="primary"
|
||||
></v-progress-circular>
|
||||
<span class="ml-2">{{ tm("status.loading") }}</span>
|
||||
</v-row>
|
||||
</template>
|
||||
|
||||
<template v-slot:item.name="{ item }">
|
||||
<div class="d-flex align-center py-2">
|
||||
<div
|
||||
v-if="item.logo"
|
||||
class="mr-3"
|
||||
style="flex-shrink: 0"
|
||||
>
|
||||
<img
|
||||
:src="item.logo"
|
||||
:alt="item.name"
|
||||
style="
|
||||
height: 40px;
|
||||
width: 40px;
|
||||
border-radius: 8px;
|
||||
object-fit: cover;
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
<div v-else class="mr-3" style="flex-shrink: 0">
|
||||
<img
|
||||
:src="defaultPluginIcon"
|
||||
:alt="item.name"
|
||||
style="
|
||||
height: 40px;
|
||||
width: 40px;
|
||||
border-radius: 8px;
|
||||
object-fit: cover;
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-h5" style="font-family: inherit;">
|
||||
{{
|
||||
item.display_name && item.display_name.length
|
||||
? item.display_name
|
||||
: item.name
|
||||
}}
|
||||
</div>
|
||||
<div
|
||||
v-if="item.display_name && item.display_name.length"
|
||||
class="text-caption text-medium-emphasis mt-1"
|
||||
>
|
||||
{{ item.name }}
|
||||
</div>
|
||||
<div
|
||||
v-if="item.reserved"
|
||||
class="d-flex align-center mt-1"
|
||||
>
|
||||
<v-chip
|
||||
color="primary"
|
||||
size="x-small"
|
||||
class="font-weight-medium"
|
||||
>{{ tm("status.system") }}</v-chip
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-slot:item.desc="{ item }">
|
||||
<div class="py-2">
|
||||
<div
|
||||
class="text-body-2 text-medium-emphasis"
|
||||
style="
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 3;
|
||||
line-clamp: 3;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
"
|
||||
>
|
||||
{{ item.desc }}
|
||||
</div>
|
||||
<div
|
||||
v-if="item.support_platforms?.length"
|
||||
class="d-flex align-center flex-wrap mt-2"
|
||||
>
|
||||
<span class="text-caption text-medium-emphasis mr-2">
|
||||
{{ tm("card.status.supportPlatform") }}:
|
||||
</span>
|
||||
<v-chip
|
||||
v-for="platformId in item.support_platforms"
|
||||
:key="platformId"
|
||||
size="x-small"
|
||||
color="info"
|
||||
variant="outlined"
|
||||
class="mr-1 mb-1"
|
||||
>
|
||||
{{ platformId }}
|
||||
</v-chip>
|
||||
</div>
|
||||
<div
|
||||
v-if="item.astrbot_version"
|
||||
class="d-flex align-center flex-wrap mt-1"
|
||||
>
|
||||
<span class="text-caption text-medium-emphasis mr-2">
|
||||
{{ tm("card.status.astrbotVersion") }}:
|
||||
</span>
|
||||
<v-chip
|
||||
size="x-small"
|
||||
color="secondary"
|
||||
variant="outlined"
|
||||
class="mr-1 mb-1"
|
||||
>
|
||||
{{ item.astrbot_version }}
|
||||
</v-chip>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-slot:item.version="{ item }">
|
||||
<div class="d-flex align-center">
|
||||
<span class="text-body-2">{{ item.version }}</span>
|
||||
<v-icon
|
||||
v-if="item.has_update"
|
||||
color="warning"
|
||||
size="small"
|
||||
class="ml-1"
|
||||
>mdi-alert</v-icon
|
||||
>
|
||||
<v-tooltip v-if="item.has_update" activator="parent">
|
||||
<span
|
||||
>{{ tm("messages.hasUpdate") }}
|
||||
{{ item.online_version }}</span
|
||||
>
|
||||
</v-tooltip>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-slot:item.author="{ item }">
|
||||
<div class="text-body-2">{{ item.author }}</div>
|
||||
</template>
|
||||
|
||||
<template v-slot:item.actions="{ item }">
|
||||
<div class="table-action-row d-flex align-center flex-nowrap ga-2 py-1">
|
||||
<v-btn
|
||||
v-if="!item.activated"
|
||||
size="small"
|
||||
variant="tonal"
|
||||
color="success"
|
||||
class="table-action-btn"
|
||||
prepend-icon="mdi-play"
|
||||
@click="pluginOn(item)"
|
||||
>
|
||||
{{ tm("buttons.enable") }}
|
||||
</v-btn>
|
||||
<v-btn
|
||||
v-else
|
||||
size="small"
|
||||
variant="tonal"
|
||||
color="error"
|
||||
class="table-action-btn"
|
||||
prepend-icon="mdi-pause"
|
||||
@click="pluginOff(item)"
|
||||
>
|
||||
{{ tm("buttons.disable") }}
|
||||
</v-btn>
|
||||
|
||||
<v-btn
|
||||
size="small"
|
||||
variant="tonal"
|
||||
color="primary"
|
||||
class="table-action-btn"
|
||||
prepend-icon="mdi-refresh"
|
||||
@click="reloadPlugin(item.name)"
|
||||
>
|
||||
{{ tm("buttons.reload") }}
|
||||
</v-btn>
|
||||
|
||||
<v-btn
|
||||
size="small"
|
||||
variant="tonal"
|
||||
color="primary"
|
||||
class="table-action-btn"
|
||||
prepend-icon="mdi-cog"
|
||||
@click="openExtensionConfig(item.name)"
|
||||
>
|
||||
{{ tm("buttons.configure") }}
|
||||
</v-btn>
|
||||
|
||||
<v-btn
|
||||
size="small"
|
||||
variant="tonal"
|
||||
color="info"
|
||||
class="table-action-btn"
|
||||
prepend-icon="mdi-book-open-page-variant"
|
||||
:disabled="!item.repo"
|
||||
@click="item.repo && viewReadme(item)"
|
||||
>
|
||||
{{ tm("buttons.viewDocs") }}
|
||||
</v-btn>
|
||||
|
||||
<StyledMenu location="bottom end" offset="8">
|
||||
<template #activator="{ props: menuProps }">
|
||||
<v-btn
|
||||
v-bind="menuProps"
|
||||
icon="mdi-dots-horizontal"
|
||||
size="small"
|
||||
variant="tonal"
|
||||
color="secondary"
|
||||
class="table-action-btn"
|
||||
></v-btn>
|
||||
</template>
|
||||
|
||||
<v-list-item
|
||||
class="styled-menu-item"
|
||||
prepend-icon="mdi-information"
|
||||
@click="showPluginInfo(item)"
|
||||
>
|
||||
<v-list-item-title>{{ tm("buttons.viewInfo") }}</v-list-item-title>
|
||||
</v-list-item>
|
||||
|
||||
<v-list-item
|
||||
class="styled-menu-item"
|
||||
prepend-icon="mdi-update"
|
||||
@click="updateExtension(item.name)"
|
||||
>
|
||||
<v-list-item-title>{{ tm("buttons.update") }}</v-list-item-title>
|
||||
</v-list-item>
|
||||
|
||||
<v-list-item
|
||||
class="styled-menu-item"
|
||||
prepend-icon="mdi-delete"
|
||||
:disabled="item.reserved"
|
||||
@click="uninstallExtension(item.name)"
|
||||
>
|
||||
<v-list-item-title>{{ tm("buttons.uninstall") }}</v-list-item-title>
|
||||
</v-list-item>
|
||||
</StyledMenu>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-slot:no-data>
|
||||
<div class="text-center pa-8">
|
||||
<v-icon size="64" color="info" class="mb-4"
|
||||
>mdi-puzzle-outline</v-icon
|
||||
>
|
||||
<div class="text-h5 mb-2">
|
||||
{{ tm("empty.noPlugins") }}
|
||||
</div>
|
||||
<div class="text-body-1 mb-4">
|
||||
{{ tm("empty.noPluginsDesc") }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</v-data-table>
|
||||
</v-card>
|
||||
</div>
|
||||
|
||||
<!-- 卡片视图 -->
|
||||
<div v-else>
|
||||
<v-row v-if="filteredPlugins.length === 0" class="text-center">
|
||||
<v-col cols="12" class="pa-2">
|
||||
<v-icon size="64" color="info" class="mb-4"
|
||||
>mdi-puzzle-outline</v-icon
|
||||
>
|
||||
<div class="text-h5 mb-2">{{ tm("empty.noPlugins") }}</div>
|
||||
<div class="text-body-1 mb-4">
|
||||
{{ tm("empty.noPluginsDesc") }}
|
||||
</div>
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
<v-row>
|
||||
<v-col
|
||||
cols="12"
|
||||
md="6"
|
||||
lg="4"
|
||||
v-for="extension in filteredPlugins"
|
||||
:key="extension.name"
|
||||
class="pb-2"
|
||||
>
|
||||
<ExtensionCard
|
||||
:extension="extension"
|
||||
class="rounded-lg"
|
||||
style="background-color: rgb(var(--v-theme-mcpCardBg))"
|
||||
@configure="openExtensionConfig(extension.name)"
|
||||
@uninstall="
|
||||
(ext, options) => uninstallExtension(ext.name, options)
|
||||
"
|
||||
@update="updateExtension(extension.name)"
|
||||
@reload="reloadPlugin(extension.name)"
|
||||
@toggle-activation="
|
||||
extension.activated
|
||||
? pluginOff(extension)
|
||||
: pluginOn(extension)
|
||||
"
|
||||
@view-handlers="showPluginInfo(extension)"
|
||||
@view-readme="viewReadme(extension)"
|
||||
@view-changelog="viewChangelog(extension)"
|
||||
>
|
||||
</ExtensionCard>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</div>
|
||||
</v-fade-transition>
|
||||
|
||||
<v-tooltip :text="tm('market.installPlugin')" location="left">
|
||||
<template v-slot:activator="{ props }">
|
||||
<button
|
||||
v-bind="props"
|
||||
type="button"
|
||||
class="v-btn v-btn--elevated v-btn--icon v-theme--PurpleThemeDark bg-darkprimary v-btn--density-default v-btn--size-x-large v-btn--variant-elevated fab-button"
|
||||
style="
|
||||
position: fixed;
|
||||
right: 52px;
|
||||
bottom: 52px;
|
||||
z-index: 10000;
|
||||
border-radius: 16px;
|
||||
"
|
||||
@click="dialog = true"
|
||||
>
|
||||
<span class="v-btn__overlay"></span>
|
||||
<span class="v-btn__underlay"></span>
|
||||
<span class="v-btn__content" data-no-activator="">
|
||||
<i
|
||||
class="mdi-plus mdi v-icon notranslate v-theme--PurpleThemeDark v-icon--size-default"
|
||||
aria-hidden="true"
|
||||
style="font-size: 32px"
|
||||
></i>
|
||||
</span>
|
||||
</button>
|
||||
</template>
|
||||
</v-tooltip>
|
||||
</v-tab-item>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.view-mode-toggle :deep(.v-btn) {
|
||||
min-width: 30px;
|
||||
height: 28px;
|
||||
padding: 0 8px;
|
||||
}
|
||||
|
||||
.table-action-btn {
|
||||
min-height: 34px;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.table-action-row {
|
||||
overflow-x: auto;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.fab-button {
|
||||
transition: all 0.3s cubic-bezier(0.25, 0.8, 0.25, 1);
|
||||
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.fab-button:hover {
|
||||
transform: translateY(-4px) scale(1.05);
|
||||
box-shadow: 0 12px 20px rgba(var(--v-theme-primary), 0.4);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,373 @@
|
||||
<script setup>
|
||||
import MarketPluginCard from "@/components/extension/MarketPluginCard.vue";
|
||||
import defaultPluginIcon from "@/assets/images/plugin_icon.png";
|
||||
import { computed } from "vue";
|
||||
|
||||
const props = defineProps({
|
||||
state: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
const {
|
||||
commonStore,
|
||||
t,
|
||||
tm,
|
||||
router,
|
||||
route,
|
||||
getSelectedGitHubProxy,
|
||||
conflictDialog,
|
||||
checkAndPromptConflicts,
|
||||
handleConflictConfirm,
|
||||
fileInput,
|
||||
activeTab,
|
||||
validTabs,
|
||||
isValidTab,
|
||||
getLocationHash,
|
||||
extractTabFromHash,
|
||||
syncTabFromHash,
|
||||
extension_data,
|
||||
getInitialShowReserved,
|
||||
showReserved,
|
||||
snack_message,
|
||||
snack_show,
|
||||
snack_success,
|
||||
configDialog,
|
||||
extension_config,
|
||||
pluginMarketData,
|
||||
loadingDialog,
|
||||
showPluginInfoDialog,
|
||||
selectedPlugin,
|
||||
curr_namespace,
|
||||
updatingAll,
|
||||
readmeDialog,
|
||||
forceUpdateDialog,
|
||||
updateAllConfirmDialog,
|
||||
changelogDialog,
|
||||
getInitialListViewMode,
|
||||
isListView,
|
||||
pluginSearch,
|
||||
loading_,
|
||||
currentPage,
|
||||
dangerConfirmDialog,
|
||||
selectedDangerPlugin,
|
||||
selectedMarketInstallPlugin,
|
||||
installCompat,
|
||||
versionCompatibilityDialog,
|
||||
showUninstallDialog,
|
||||
pluginToUninstall,
|
||||
showSourceDialog,
|
||||
showSourceManagerDialog,
|
||||
sourceName,
|
||||
sourceUrl,
|
||||
customSources,
|
||||
selectedSource,
|
||||
showRemoveSourceDialog,
|
||||
sourceToRemove,
|
||||
editingSource,
|
||||
originalSourceUrl,
|
||||
extension_url,
|
||||
dialog,
|
||||
upload_file,
|
||||
uploadTab,
|
||||
showPluginFullName,
|
||||
marketSearch,
|
||||
debouncedMarketSearch,
|
||||
refreshingMarket,
|
||||
sortBy,
|
||||
sortOrder,
|
||||
randomPluginNames,
|
||||
normalizeStr,
|
||||
toPinyinText,
|
||||
toInitials,
|
||||
marketCustomFilter,
|
||||
plugin_handler_info_headers,
|
||||
pluginHeaders,
|
||||
filteredExtensions,
|
||||
filteredPlugins,
|
||||
filteredMarketPlugins,
|
||||
sortedPlugins,
|
||||
RANDOM_PLUGINS_COUNT,
|
||||
randomPlugins,
|
||||
shufflePlugins,
|
||||
refreshRandomPlugins,
|
||||
displayItemsPerPage,
|
||||
totalPages,
|
||||
paginatedPlugins,
|
||||
updatableExtensions,
|
||||
toggleShowReserved,
|
||||
toast,
|
||||
resetLoadingDialog,
|
||||
onLoadingDialogResult,
|
||||
failedPluginsDict,
|
||||
getExtensions,
|
||||
handleReloadAllFailed,
|
||||
checkUpdate,
|
||||
uninstallExtension,
|
||||
handleUninstallConfirm,
|
||||
updateExtension,
|
||||
showUpdateAllConfirm,
|
||||
confirmUpdateAll,
|
||||
cancelUpdateAll,
|
||||
confirmForceUpdate,
|
||||
updateAllExtensions,
|
||||
pluginOn,
|
||||
pluginOff,
|
||||
openExtensionConfig,
|
||||
updateConfig,
|
||||
showPluginInfo,
|
||||
reloadPlugin,
|
||||
viewReadme,
|
||||
viewChangelog,
|
||||
handleInstallPlugin,
|
||||
confirmDangerInstall,
|
||||
cancelDangerInstall,
|
||||
loadCustomSources,
|
||||
saveCustomSources,
|
||||
addCustomSource,
|
||||
openSourceManagerDialog,
|
||||
selectPluginSource,
|
||||
sourceSelectItems,
|
||||
editCustomSource,
|
||||
removeCustomSource,
|
||||
confirmRemoveSource,
|
||||
saveCustomSource,
|
||||
trimExtensionName,
|
||||
checkAlreadyInstalled,
|
||||
showVersionCompatibilityWarning,
|
||||
continueInstallIgnoringVersionWarning,
|
||||
cancelInstallOnVersionWarning,
|
||||
newExtension,
|
||||
normalizePlatformList,
|
||||
getPlatformDisplayList,
|
||||
resolveSelectedInstallPlugin,
|
||||
selectedInstallPlugin,
|
||||
checkInstallCompatibility,
|
||||
refreshPluginMarket,
|
||||
handleLocaleChange,
|
||||
searchDebounceTimer,
|
||||
} = props.state;
|
||||
|
||||
const currentSourceName = computed(() => {
|
||||
if (!selectedSource.value) {
|
||||
return tm("market.defaultSource");
|
||||
}
|
||||
const matched = customSources.value.find((s) => s.url === selectedSource.value);
|
||||
return matched?.name || tm("market.defaultSource");
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<v-tab-item v-show="activeTab === 'market'">
|
||||
<div class="mb-6 pt-4 pb-4">
|
||||
<div class="d-flex align-center flex-wrap" style="gap: 12px">
|
||||
<h2 class="text-h2 mb-0">{{ tm("tabs.market") }}</h2>
|
||||
|
||||
<v-tooltip location="top" :text="tm('market.sourceManagement')">
|
||||
<template v-slot:activator="{ props }">
|
||||
<v-btn
|
||||
v-bind="props"
|
||||
variant="tonal"
|
||||
rounded="md"
|
||||
color="primary"
|
||||
class="text-none px-2"
|
||||
@click="openSourceManagerDialog"
|
||||
>
|
||||
<v-icon size="18" class="mr-1">mdi-source-branch</v-icon>
|
||||
<span class="text-truncate" style="max-width: 180px">
|
||||
{{ currentSourceName }}
|
||||
</span>
|
||||
</v-btn>
|
||||
</template>
|
||||
</v-tooltip>
|
||||
|
||||
<v-text-field
|
||||
v-model="marketSearch"
|
||||
density="compact"
|
||||
:label="tm('search.marketPlaceholder')"
|
||||
prepend-inner-icon="mdi-magnify"
|
||||
variant="solo-filled"
|
||||
flat
|
||||
hide-details
|
||||
single-line
|
||||
style="min-width: 220px; max-width: 340px"
|
||||
>
|
||||
</v-text-field>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="d-flex align-center text-caption text-medium-emphasis mt-2"
|
||||
style="color: grey; line-height: 1.4"
|
||||
>
|
||||
<v-icon size="16" class="mr-1">mdi-alert-outline</v-icon>
|
||||
<span>{{ tm("market.sourceSafetyWarning") }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- <small style="color: var(--v-theme-secondaryText);">每个插件都是作者无偿提供的的劳动成果。如果您喜欢某个插件,请 Star!</small> -->
|
||||
|
||||
<!-- FAB Button -->
|
||||
<v-tooltip :text="tm('market.installPlugin')" location="left">
|
||||
<template v-slot:activator="{ props }">
|
||||
<button
|
||||
v-bind="props"
|
||||
type="button"
|
||||
class="v-btn v-btn--elevated v-btn--icon v-theme--PurpleThemeDark bg-darkprimary v-btn--density-default v-btn--size-x-large v-btn--variant-elevated fab-button"
|
||||
style="
|
||||
position: fixed;
|
||||
right: 52px;
|
||||
bottom: 52px;
|
||||
z-index: 10000;
|
||||
border-radius: 16px;
|
||||
"
|
||||
@click="dialog = true"
|
||||
>
|
||||
<span class="v-btn__overlay"></span>
|
||||
<span class="v-btn__underlay"></span>
|
||||
<span class="v-btn__content" data-no-activator="">
|
||||
<i
|
||||
class="mdi-plus mdi v-icon notranslate v-theme--PurpleThemeDark v-icon--size-default"
|
||||
aria-hidden="true"
|
||||
style="font-size: 32px"
|
||||
></i>
|
||||
</span>
|
||||
</button>
|
||||
</template>
|
||||
</v-tooltip>
|
||||
|
||||
<div class="mt-4">
|
||||
<div
|
||||
class="d-flex align-center mb-2"
|
||||
style="justify-content: space-between; flex-wrap: wrap; gap: 8px"
|
||||
>
|
||||
<h2>
|
||||
{{ tm("market.randomPlugins") }}
|
||||
</h2>
|
||||
<v-btn
|
||||
color="primary"
|
||||
variant="tonal"
|
||||
prepend-icon="mdi-shuffle-variant"
|
||||
:disabled="pluginMarketData.length === 0"
|
||||
@click="refreshRandomPlugins"
|
||||
>
|
||||
{{ tm("buttons.reshuffle") }}
|
||||
</v-btn>
|
||||
</div>
|
||||
|
||||
<v-row class="mb-6" dense>
|
||||
<v-col
|
||||
v-for="plugin in randomPlugins"
|
||||
:key="`random-${plugin.name}`"
|
||||
cols="12"
|
||||
md="6"
|
||||
lg="4"
|
||||
class="pb-2"
|
||||
>
|
||||
<MarketPluginCard
|
||||
:plugin="plugin"
|
||||
:default-plugin-icon="defaultPluginIcon"
|
||||
:show-plugin-full-name="showPluginFullName"
|
||||
@install="handleInstallPlugin"
|
||||
/>
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
<div
|
||||
class="d-flex align-center mb-2"
|
||||
style="
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
"
|
||||
>
|
||||
<div class="d-flex align-center" style="gap: 6px">
|
||||
<h2>
|
||||
{{ tm("market.allPlugins") }}({{
|
||||
filteredMarketPlugins.length
|
||||
}})
|
||||
</h2>
|
||||
<v-btn
|
||||
icon
|
||||
variant="text"
|
||||
@click="refreshPluginMarket"
|
||||
:loading="refreshingMarket"
|
||||
>
|
||||
<v-icon>mdi-refresh</v-icon>
|
||||
</v-btn>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="d-flex align-center"
|
||||
style="gap: 8px; flex-wrap: wrap"
|
||||
>
|
||||
<v-select
|
||||
v-model="sortBy"
|
||||
:items="[
|
||||
{ title: tm('sort.default'), value: 'default' },
|
||||
{ title: tm('sort.stars'), value: 'stars' },
|
||||
{ title: tm('sort.author'), value: 'author' },
|
||||
{ title: tm('sort.updated'), value: 'updated' },
|
||||
]"
|
||||
density="compact"
|
||||
variant="outlined"
|
||||
hide-details
|
||||
style="max-width: 150px"
|
||||
>
|
||||
<template v-slot:prepend-inner>
|
||||
<v-icon size="small">mdi-sort</v-icon>
|
||||
</template>
|
||||
</v-select>
|
||||
|
||||
<v-btn
|
||||
icon
|
||||
v-if="sortBy !== 'default'"
|
||||
@click="sortOrder = sortOrder === 'desc' ? 'asc' : 'desc'"
|
||||
variant="text"
|
||||
density="compact"
|
||||
>
|
||||
<v-icon>{{
|
||||
sortOrder === "desc"
|
||||
? "mdi-sort-descending"
|
||||
: "mdi-sort-ascending"
|
||||
}}</v-icon>
|
||||
<v-tooltip activator="parent" location="top">
|
||||
{{
|
||||
sortOrder === "desc"
|
||||
? tm("sort.descending")
|
||||
: tm("sort.ascending")
|
||||
}}
|
||||
</v-tooltip>
|
||||
</v-btn>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<v-row style="min-height: 26rem" dense>
|
||||
<v-col
|
||||
v-for="plugin in paginatedPlugins"
|
||||
:key="plugin.name"
|
||||
cols="12"
|
||||
md="6"
|
||||
lg="4"
|
||||
class="pb-2"
|
||||
>
|
||||
<MarketPluginCard
|
||||
:plugin="plugin"
|
||||
:default-plugin-icon="defaultPluginIcon"
|
||||
:show-plugin-full-name="showPluginFullName"
|
||||
@install="handleInstallPlugin"
|
||||
/>
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
<div class="d-flex justify-center mt-4" v-if="totalPages > 1">
|
||||
<v-pagination
|
||||
v-model="currentPage"
|
||||
:length="totalPages"
|
||||
:total-visible="7"
|
||||
size="small"
|
||||
></v-pagination>
|
||||
</div>
|
||||
</div>
|
||||
</v-tab-item>
|
||||
</template>
|
||||
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user