Compare commits
25 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a0656483b0 | |||
| f6ac6b9007 | |||
| b8c73430fb | |||
| 3141ed52bd | |||
| 63ff234f10 | |||
| 5219ba5c4e | |||
| 48c2d98dde | |||
| af09b5cb16 | |||
| 31f46045d7 | |||
| d6455d774b | |||
| 3e928b9659 | |||
| df1299b192 | |||
| 15ee17724d | |||
| 437c186a66 | |||
| 3610a42ebf | |||
| bf1bde79ec | |||
| f309638192 | |||
| 6439e4e152 | |||
| 4b1395b2c9 | |||
| 1859206007 | |||
| 3b93429353 | |||
| d68ccfcc96 | |||
| 68b8a1a01c | |||
| 75ee46715a | |||
| a8cad50f27 |
@@ -2,12 +2,14 @@
|
|||||||
|
|
||||||
<div align="center">
|
<div align="center">
|
||||||
|
|
||||||
<a href="https://github.com/AstrBotDevs/AstrBot/blob/master/README_en.md">English</a> |
|
<a href="https://github.com/AstrBotDevs/AstrBot/blob/master/README_zh.md">中文</a> |
|
||||||
<a href="https://github.com/AstrBotDevs/AstrBot/blob/master/README_ja.md">日本語</a> |
|
<a href="https://github.com/AstrBotDevs/AstrBot/blob/master/README_ja.md">日本語</a> |
|
||||||
<a href="https://github.com/AstrBotDevs/AstrBot/blob/master/README_zh-TW.md">繁體中文</a> |
|
<a href="https://github.com/AstrBotDevs/AstrBot/blob/master/README_zh-TW.md">繁體中文</a> |
|
||||||
<a href="https://github.com/AstrBotDevs/AstrBot/blob/master/README_fr.md">Français</a> |
|
<a href="https://github.com/AstrBotDevs/AstrBot/blob/master/README_fr.md">Français</a> |
|
||||||
<a href="https://github.com/AstrBotDevs/AstrBot/blob/master/README_ru.md">Русский</a>
|
<a href="https://github.com/AstrBotDevs/AstrBot/blob/master/README_ru.md">Русский</a>
|
||||||
|
|
||||||
|
<br>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<a href="https://trendshift.io/repositories/12875" target="_blank"><img src="https://trendshift.io/api/badge/repositories/12875" alt="Soulter%2FAstrBot | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
|
<a href="https://trendshift.io/repositories/12875" target="_blank"><img src="https://trendshift.io/api/badge/repositories/12875" alt="Soulter%2FAstrBot | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
|
||||||
<a href="https://hellogithub.com/repository/AstrBotDevs/AstrBot" target="_blank"><img src="https://api.hellogithub.com/v1/widgets/recommend.svg?rid=d127d50cd5e54c5382328acc3bb25483&claim_uid=ZO9by7qCXgSd6Lp&t=2" alt="Featured|HelloGitHub" style="width: 250px; height: 54px;" width="250" height="54" /></a>
|
<a href="https://hellogithub.com/repository/AstrBotDevs/AstrBot" target="_blank"><img src="https://api.hellogithub.com/v1/widgets/recommend.svg?rid=d127d50cd5e54c5382328acc3bb25483&claim_uid=ZO9by7qCXgSd6Lp&t=2" alt="Featured|HelloGitHub" style="width: 250px; height: 54px;" width="250" height="54" /></a>
|
||||||
@@ -21,42 +23,42 @@
|
|||||||
<img src="https://deepwiki.com/badge.svg" href="https://deepwiki.com/AstrBotDevs/AstrBot">
|
<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%2CPHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHZpZXdCb3g9IjAgMCAxNiAxNiIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPHBhdGggZD0iTTQuOTYxNTYgMS42MDAxSDIuMjQxNTZDMS44ODgxIDEuNjAwMSAxLjYwMTU2IDEuODg2NjQgMS42MDE1NiAyLjI0MDFWNC45NjAxQzEuNjAxNTYgNS4zMTM1NiAxLjg4ODEgNS42MDAxIDIuMjQxNTYgNS42MDAxSDQuOTYxNTZDNS4zMTUwMiA1LjYwMDEgNS42MDE1NiA1LjMxMzU2IDUuNjAxNTYgNC45NjAxVjIuMjQwMUM1LjYwMTU2IDEuODg2NjQgNS4zMTUwMiAxLjYwMDEgNC45NjE1NiAxLjYwMDFaIiBmaWxsPSIjZmZmIi8%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>
|
<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=%20plugins&label=Marketplace&cacheSeconds=3600">
|
||||||
<img src="https://gitcode.com/Soulter/AstrBot/star/badge.svg" href="https://gitcode.com/Soulter/AstrBot">
|
<img src="https://gitcode.com/Soulter/AstrBot/star/badge.svg" href="https://gitcode.com/Soulter/AstrBot">
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<br>
|
<br>
|
||||||
|
|
||||||
<a href="https://astrbot.app/">文档</a> |
|
<a href="https://astrbot.app/">Documentation</a> |
|
||||||
<a href="https://blog.astrbot.app/">Blog</a> |
|
<a href="https://blog.astrbot.app/">Blog</a> |
|
||||||
<a href="https://astrbot.featurebase.app/roadmap">路线图</a> |
|
<a href="https://astrbot.featurebase.app/roadmap">Roadmap</a> |
|
||||||
<a href="https://github.com/AstrBotDevs/AstrBot/issues">问题提交</a>
|
<a href="https://github.com/AstrBotDevs/AstrBot/issues">Issue Tracker</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
AstrBot 是一个开源的一站式 Agentic 个人和群聊助手,可在 QQ、Telegram、企业微信、飞书、钉钉、Slack、等数十款主流即时通讯软件上部署,此外还内置类似 OpenWebUI 的轻量化 ChatUI,为个人、开发者和团队打造可靠、可扩展的对话式智能基础设施。无论是个人 AI 伙伴、智能客服、自动化助手,还是企业知识库,AstrBot 都能在你的即时通讯软件平台的工作流中快速构建 AI 应用。
|
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
|
||||||
|
|
||||||
1. 💯 免费 & 开源。
|
1. 💯 Free & Open Source.
|
||||||
2. ✨ AI 大模型对话,多模态,Agent,MCP,Skills,知识库,人格设定,自动压缩对话。
|
2. ✨ AI LLM Conversations, Multimodal, Agent, MCP, Skills, Knowledge Base, Persona Settings, Auto Context Compression.
|
||||||
3. 🤖 支持接入 Dify、阿里云百炼、Coze 等智能体平台。
|
3. 🤖 Supports integration with Dify, Alibaba Cloud Bailian, Coze, and other agent platforms.
|
||||||
4. 🌐 多平台,支持 QQ、企业微信、飞书、钉钉、微信公众号、Telegram、Slack 以及[更多](#支持的消息平台)。
|
4. 🌐 Multi-Platform: QQ, WeChat Work, Feishu, DingTalk, WeChat Official Accounts, Telegram, Slack, and [more](#supported-messaging-platforms).
|
||||||
5. 📦 插件扩展,已有 1000+ 个插件可一键安装。
|
5. 📦 Plugin Extensions with 1000+ plugins available for one-click installation.
|
||||||
6. 🛡️ [Agent Sandbox](https://docs.astrbot.app/use/astrbot-agent-sandbox.html) 隔离化环境,安全地执行任何代码、调用 Shell、会话级资源复用。
|
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 支持。
|
7. 💻 WebUI Support.
|
||||||
8. 🌈 Web ChatUI 支持,ChatUI 内置代理沙盒、网页搜索等。
|
8. 🌈 Web ChatUI Support with built-in agent sandbox and web search.
|
||||||
9. 🌐 国际化(i18n)支持。
|
9. 🌐 Internationalization (i18n) Support.
|
||||||
|
|
||||||
<br>
|
<br>
|
||||||
|
|
||||||
<table align="center">
|
<table align="center">
|
||||||
<tr align="center">
|
<tr align="center">
|
||||||
<th>💙 角色扮演 & 情感陪伴</th>
|
<th>💙 Role-playing & Emotional Companionship</th>
|
||||||
<th>✨ 主动式 Agent</th>
|
<th>✨ Proactive Agent</th>
|
||||||
<th>🚀 通用 Agentic 能力</th>
|
<th>🚀 General Agentic Capabilities</th>
|
||||||
<th>🧩 1000+ 社区插件</th>
|
<th>🧩 1000+ Community Plugins</th>
|
||||||
</tr>
|
</tr>
|
||||||
<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>
|
<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>
|
||||||
@@ -66,172 +68,163 @@ AstrBot 是一个开源的一站式 Agentic 个人和群聊助手,可在 QQ、
|
|||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
## 快速开始
|
## Quick Start
|
||||||
|
|
||||||
#### Docker 部署(推荐 🥳)
|
#### Docker Deployment (Recommended 🥳)
|
||||||
|
|
||||||
推荐使用 Docker / Docker Compose 方式部署 AstrBot。
|
We recommend deploying AstrBot using Docker or Docker Compose.
|
||||||
|
|
||||||
请参阅官方文档 [使用 Docker 部署 AstrBot](https://astrbot.app/deploy/astrbot/docker.html#%E4%BD%BF%E7%94%A8-docker-%E9%83%A8%E7%BD%B2-astrbot) 。
|
Please refer to the official documentation: [Deploy AstrBot with Docker](https://astrbot.app/deploy/astrbot/docker.html#%E4%BD%BF%E7%94%A8-docker-%E9%83%A8%E7%BD%B2-astrbot).
|
||||||
|
|
||||||
#### uv 部署
|
#### uv Deployment
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
uv tool install astrbot
|
uv tool install astrbot
|
||||||
astrbot
|
astrbot
|
||||||
```
|
```
|
||||||
|
|
||||||
#### 桌面应用部署(Tauri)
|
#### System Package Manager Installation
|
||||||
|
|
||||||
桌面应用仓库 [AstrBot-desktop](https://github.com/AstrBotDevs/AstrBot-desktop)。
|
##### Arch Linux
|
||||||
|
|
||||||
支持多系统架构,安装包直接安装,开箱即用,最适合新手和懒人的一键桌面部署方案,不推荐服务器场景。
|
```bash
|
||||||
|
yay -S astrbot-git
|
||||||
|
# or use paru
|
||||||
|
paru -S astrbot-git
|
||||||
|
```
|
||||||
|
|
||||||
#### 启动器一键部署(AstrBot Launcher)
|
#### Desktop Application (Tauri)
|
||||||
|
|
||||||
快速部署和多开方案,实现环境隔离,进入 [AstrBot Launcher](https://github.com/Raven95676/astrbot-launcher) 仓库,在 Releases 页最新版本下找到对应的系统安装包安装即可。
|
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 与宝塔面板合作,已上架至宝塔面板。
|
#### AstrBot Launcher
|
||||||
|
|
||||||
请参阅官方文档 [宝塔面板部署](https://astrbot.app/deploy/astrbot/btpanel.html) 。
|
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.
|
||||||
|
|
||||||
#### 1Panel 部署
|
#### BT-Panel Deployment
|
||||||
|
|
||||||
AstrBot 已由 1Panel 官方上架至 1Panel 面板。
|
AstrBot has partnered with BT-Panel and is now available in their marketplace.
|
||||||
|
|
||||||
请参阅官方文档 [1Panel 部署](https://astrbot.app/deploy/astrbot/1panel.html) 。
|
Please refer to the official documentation: [BT-Panel Deployment](https://astrbot.app/deploy/astrbot/btpanel.html).
|
||||||
|
|
||||||
#### 在 雨云 上部署
|
#### 1Panel Deployment
|
||||||
|
|
||||||
AstrBot 已由雨云官方上架至云应用平台,可一键部署。
|
AstrBot has been officially listed on the 1Panel marketplace.
|
||||||
|
|
||||||
|
Please refer to the official documentation: [1Panel Deployment](https://astrbot.app/deploy/astrbot/1panel.html).
|
||||||
|
|
||||||
|
#### Deploy on RainYun
|
||||||
|
|
||||||
|
For Chinese users:
|
||||||
|
|
||||||
|
AstrBot has been officially listed on RainYun's cloud application platform with one-click deployment.
|
||||||
|
|
||||||
[](https://app.rainyun.com/apps/rca/store/5994?ref=NjU1ODg0)
|
[](https://app.rainyun.com/apps/rca/store/5994?ref=NjU1ODg0)
|
||||||
|
|
||||||
#### 在 Replit 上部署
|
#### Deploy on Replit
|
||||||
|
|
||||||
社区贡献的部署方式。
|
Community-contributed deployment method.
|
||||||
|
|
||||||
[](https://repl.it/github/AstrBotDevs/AstrBot)
|
[](https://repl.it/github/AstrBotDevs/AstrBot)
|
||||||
|
|
||||||
#### Windows 一键安装器部署
|
#### Windows One-Click Installer
|
||||||
|
|
||||||
请参阅官方文档 [使用 Windows 一键安装器部署 AstrBot](https://astrbot.app/deploy/astrbot/windows.html) 。
|
Please refer to the official documentation: [Deploy AstrBot with Windows One-Click Installer](https://astrbot.app/deploy/astrbot/windows.html).
|
||||||
|
|
||||||
#### CasaOS 部署
|
#### CasaOS Deployment
|
||||||
|
|
||||||
社区贡献的部署方式。
|
Community-contributed deployment method.
|
||||||
|
|
||||||
请参阅官方文档 [CasaOS 部署](https://astrbot.app/deploy/astrbot/casaos.html) 。
|
Please refer to the official documentation: [CasaOS Deployment](https://astrbot.app/deploy/astrbot/casaos.html).
|
||||||
|
|
||||||
#### 手动部署
|
#### Manual Deployment
|
||||||
|
|
||||||
首先安装 uv:
|
First, install uv:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
pip install uv
|
pip install uv
|
||||||
```
|
```
|
||||||
|
|
||||||
通过 Git Clone 安装 AstrBot:
|
Install AstrBot via Git Clone:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git clone https://github.com/AstrBotDevs/AstrBot && cd AstrBot
|
git clone https://github.com/AstrBotDevs/AstrBot && cd AstrBot
|
||||||
uv run main.py
|
uv run main.py
|
||||||
```
|
```
|
||||||
|
|
||||||
或者请参阅官方文档 [通过源码部署 AstrBot](https://astrbot.app/deploy/astrbot/cli.html) 。
|
Or refer to the official documentation: [Deploy AstrBot from Source](https://astrbot.app/deploy/astrbot/cli.html).
|
||||||
|
|
||||||
#### 系统包管理器安装
|
## Supported Messaging Platforms
|
||||||
|
|
||||||
##### Arch Linux
|
Connect AstrBot to your favorite chat platform.
|
||||||
|
|
||||||
```bash
|
| Platform | Maintainer |
|
||||||
yay -S astrbot-git
|
|---------|---------------|
|
||||||
# 或者使用 paru
|
| QQ | Official |
|
||||||
paru -S astrbot-git
|
| OneBot v11 protocol implementation | Official |
|
||||||
```
|
| Telegram | Official |
|
||||||
|
| WeChat Work Application & WeChat Work Intelligent Bot | Official |
|
||||||
|
| WeChat Customer Service & WeChat Official Accounts | Official |
|
||||||
|
| Feishu (Lark) | Official |
|
||||||
|
| DingTalk | Official |
|
||||||
|
| Slack | Official |
|
||||||
|
| Discord | Official |
|
||||||
|
| LINE | Official |
|
||||||
|
| Satori | Official |
|
||||||
|
| Misskey | Official |
|
||||||
|
| WhatsApp (Coming Soon) | Official |
|
||||||
|
| [Matrix](https://github.com/stevessr/astrbot_plugin_matrix_adapter) | Community |
|
||||||
|
| [KOOK](https://github.com/wuyan1003/astrbot_plugin_kook_adapter) | Community |
|
||||||
|
| [VoceChat](https://github.com/HikariFroya/astrbot_plugin_vocechat) | Community |
|
||||||
|
|
||||||
## 支持的消息平台
|
## Supported Model Services
|
||||||
|
|
||||||
**官方维护**
|
| Service | Type |
|
||||||
|
|---------|---------------|
|
||||||
|
| OpenAI and Compatible Services | LLM Services |
|
||||||
|
| Anthropic | LLM Services |
|
||||||
|
| Google Gemini | LLM Services |
|
||||||
|
| Moonshot AI | LLM Services |
|
||||||
|
| Zhipu AI | LLM Services |
|
||||||
|
| DeepSeek | LLM Services |
|
||||||
|
| Ollama (Self-hosted) | LLM Services |
|
||||||
|
| LM Studio (Self-hosted) | LLM Services |
|
||||||
|
| [CompShare](https://www.compshare.cn/?ytag=GPU_YY-gh_astrbot&referral_code=FV7DcGowN4hB5UuXKgpE74) | LLM Services |
|
||||||
|
| [302.AI](https://share.302.ai/rr1M3l) | LLM Services |
|
||||||
|
| [TokenPony](https://www.tokenpony.cn/3YPyf) | LLM Services |
|
||||||
|
| [SiliconFlow](https://docs.siliconflow.cn/cn/usercases/use-siliconcloud-in-astrbot) | LLM Services |
|
||||||
|
| [PPIO Cloud](https://ppio.com/user/register?invited_by=AIOONE) | LLM Services |
|
||||||
|
| ModelScope | LLM Services |
|
||||||
|
| OneAPI | LLM Services |
|
||||||
|
| Dify | LLMOps Platforms |
|
||||||
|
| Alibaba Cloud Bailian Applications | LLMOps Platforms |
|
||||||
|
| Coze | LLMOps Platforms |
|
||||||
|
| OpenAI Whisper | Speech-to-Text Services |
|
||||||
|
| SenseVoice | Speech-to-Text Services |
|
||||||
|
| OpenAI TTS | Text-to-Speech Services |
|
||||||
|
| Gemini TTS | Text-to-Speech Services |
|
||||||
|
| GPT-Sovits-Inference | Text-to-Speech Services |
|
||||||
|
| GPT-Sovits | Text-to-Speech Services |
|
||||||
|
| FishAudio | Text-to-Speech Services |
|
||||||
|
| Edge TTS | Text-to-Speech Services |
|
||||||
|
| Alibaba Cloud Bailian TTS | Text-to-Speech Services |
|
||||||
|
| Azure TTS | Text-to-Speech Services |
|
||||||
|
| Minimax TTS | Text-to-Speech Services |
|
||||||
|
| Volcano Engine TTS | Text-to-Speech Services |
|
||||||
|
|
||||||
- QQ
|
## ❤️ Contributing
|
||||||
- OneBot v11 协议实现
|
|
||||||
- Telegram
|
|
||||||
- 企微应用 & 企微智能机器人
|
|
||||||
- 微信客服 & 微信公众号
|
|
||||||
- 飞书
|
|
||||||
- 钉钉
|
|
||||||
- Slack
|
|
||||||
- Discord
|
|
||||||
- LINE
|
|
||||||
- Satori
|
|
||||||
- Misskey
|
|
||||||
- Whatsapp (将支持)
|
|
||||||
|
|
||||||
**社区维护**
|
Issues and Pull Requests are always welcome! Feel free to submit your changes to this project :)
|
||||||
|
|
||||||
- [Matrix](https://github.com/stevessr/astrbot_plugin_matrix_adapter)
|
### How to Contribute
|
||||||
- [KOOK](https://github.com/wuyan1003/astrbot_plugin_kook_adapter)
|
|
||||||
- [VoceChat](https://github.com/HikariFroya/astrbot_plugin_vocechat)
|
|
||||||
|
|
||||||
## 支持的模型服务
|
You can contribute by reviewing issues or helping with pull request reviews. Any issues or PRs are welcome to encourage community participation. Of course, these are just suggestions—you can contribute in any way you like. For adding new features, please discuss through an Issue first.
|
||||||
|
|
||||||
**大模型服务**
|
### Development Environment
|
||||||
|
|
||||||
- OpenAI 及兼容服务
|
AstrBot uses `ruff` for code formatting and linting.
|
||||||
- Anthropic
|
|
||||||
- Google Gemini
|
|
||||||
- Moonshot AI
|
|
||||||
- 智谱 AI
|
|
||||||
- DeepSeek
|
|
||||||
- Ollama (本地部署)
|
|
||||||
- LM Studio (本地部署)
|
|
||||||
- [AIHubMix](https://aihubmix.com/?aff=4bfH)
|
|
||||||
- [优云智算](https://www.compshare.cn/?ytag=GPU_YY-gh_astrbot&referral_code=FV7DcGowN4hB5UuXKgpE74)
|
|
||||||
- [302.AI](https://share.302.ai/rr1M3l)
|
|
||||||
- [小马算力](https://www.tokenpony.cn/3YPyf)
|
|
||||||
- [硅基流动](https://docs.siliconflow.cn/cn/usercases/use-siliconcloud-in-astrbot)
|
|
||||||
- [PPIO 派欧云](https://ppio.com/user/register?invited_by=AIOONE)
|
|
||||||
- ModelScope
|
|
||||||
- OneAPI
|
|
||||||
|
|
||||||
**LLMOps 平台**
|
|
||||||
|
|
||||||
- Dify
|
|
||||||
- 阿里云百炼应用
|
|
||||||
- Coze
|
|
||||||
|
|
||||||
**语音转文本服务**
|
|
||||||
|
|
||||||
- OpenAI Whisper
|
|
||||||
- SenseVoice
|
|
||||||
|
|
||||||
**文本转语音服务**
|
|
||||||
|
|
||||||
- OpenAI TTS
|
|
||||||
- Gemini TTS
|
|
||||||
- GPT-Sovits-Inference
|
|
||||||
- GPT-Sovits
|
|
||||||
- FishAudio
|
|
||||||
- Edge TTS
|
|
||||||
- 阿里云百炼 TTS
|
|
||||||
- Azure TTS
|
|
||||||
- Minimax TTS
|
|
||||||
- 火山引擎 TTS
|
|
||||||
|
|
||||||
## ❤️ 贡献
|
|
||||||
|
|
||||||
欢迎任何 Issues/Pull Requests!只需要将你的更改提交到此项目 :)
|
|
||||||
|
|
||||||
### 如何贡献
|
|
||||||
|
|
||||||
你可以通过查看问题或帮助审核 PR(拉取请求)来贡献。任何问题或 PR 都欢迎参与,以促进社区贡献。当然,这些只是建议,你可以以任何方式进行贡献。对于新功能的添加,请先通过 Issue 讨论。
|
|
||||||
|
|
||||||
### 开发环境
|
|
||||||
|
|
||||||
AstrBot 使用 `ruff` 进行代码格式化和检查。
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git clone https://github.com/AstrBotDevs/AstrBot
|
git clone https://github.com/AstrBotDevs/AstrBot
|
||||||
@@ -239,52 +232,42 @@ pip install pre-commit
|
|||||||
pre-commit install
|
pre-commit install
|
||||||
```
|
```
|
||||||
|
|
||||||
## 🌍 社区
|
## 🌍 Community
|
||||||
|
|
||||||
### QQ 群组
|
### QQ Groups
|
||||||
|
|
||||||
- 1 群:322154837
|
- Group 1: 322154837
|
||||||
- 3 群:630166526
|
- Group 3: 630166526
|
||||||
- 5 群:822130018
|
- Group 5: 822130018
|
||||||
- 6 群:753075035
|
- Group 6: 753075035
|
||||||
- 7 群:743746109
|
- Group 7: 743746109
|
||||||
- 8 群:1030353265
|
- Group 8: 1030353265
|
||||||
- 开发者群:975206796
|
- Developer Group: 975206796
|
||||||
|
|
||||||
### Telegram 群组
|
### Telegram Group
|
||||||
|
|
||||||
<a href="https://t.me/+hAsD2Ebl5as3NmY1"><img alt="Telegram_community" src="https://img.shields.io/badge/Telegram-AstrBot-purple?style=for-the-badge&color=76bad9"></a>
|
<a href="https://t.me/+hAsD2Ebl5as3NmY1"><img alt="Telegram_community" src="https://img.shields.io/badge/Telegram-AstrBot-purple?style=for-the-badge&color=76bad9"></a>
|
||||||
|
|
||||||
### Discord 群组
|
### Discord Server
|
||||||
|
|
||||||
<a href="https://discord.gg/hAVk6tgV36"><img alt="Discord_community" src="https://img.shields.io/badge/Discord-AstrBot-purple?style=for-the-badge&color=76bad9"></a>
|
<a href="https://discord.gg/hAVk6tgV36"><img alt="Discord_community" src="https://img.shields.io/badge/Discord-AstrBot-purple?style=for-the-badge&color=76bad9"></a>
|
||||||
|
|
||||||
## ❤️ Special Thanks
|
## ❤️ Special Thanks
|
||||||
|
|
||||||
特别感谢所有 Contributors 和插件开发者对 AstrBot 的贡献 ❤️
|
Special thanks to all Contributors and plugin developers for their contributions to AstrBot ❤️
|
||||||
|
|
||||||
<a href="https://github.com/AstrBotDevs/AstrBot/graphs/contributors">
|
<a href="https://github.com/AstrBotDevs/AstrBot/graphs/contributors">
|
||||||
<img src="https://contrib.rocks/image?repo=AstrBotDevs/AstrBot&max=200&columns=14" />
|
<img src="https://contrib.rocks/image?repo=AstrBotDevs/AstrBot&max=200&columns=14" />
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
此外,本项目的诞生离不开以下开源项目的帮助:
|
Additionally, the birth of this project would not have been possible without the help of the following open-source projects:
|
||||||
|
|
||||||
- [NapNeko/NapCatQQ](https://github.com/NapNeko/NapCatQQ) - 伟大的猫猫框架
|
- [NapNeko/NapCatQQ](https://github.com/NapNeko/NapCatQQ) - The amazing cat framework
|
||||||
|
|
||||||
开源项目友情链接:
|
|
||||||
|
|
||||||
- [NoneBot2](https://github.com/nonebot/nonebot2) - 优秀的 Python 异步 ChatBot 框架
|
|
||||||
- [Koishi](https://github.com/koishijs/koishi) - 优秀的 Node.js ChatBot 框架
|
|
||||||
- [MaiBot](https://github.com/Mai-with-u/MaiBot) - 优秀的拟人化 AI ChatBot
|
|
||||||
- [nekro-agent](https://github.com/KroMiose/nekro-agent) - 优秀的 Agent ChatBot
|
|
||||||
- [LangBot](https://github.com/langbot-app/LangBot) - 优秀的多平台 AI ChatBot
|
|
||||||
- [ChatLuna](https://github.com/ChatLunaLab/chatluna) - 优秀的多平台 AI ChatBot Koishi 插件
|
|
||||||
- [Operit AI](https://github.com/AAswordman/Operit) - 优秀的 AI 智能助手 Android APP
|
|
||||||
|
|
||||||
## ⭐ Star History
|
## ⭐ Star History
|
||||||
|
|
||||||
> [!TIP]
|
> [!TIP]
|
||||||
> 如果本项目对您的生活 / 工作产生了帮助,或者您关注本项目的未来发展,请给项目 Star,这是我们维护这个开源项目的动力 <3
|
> If this project has helped you in your life or work, or if you're interested in its future development, please give the project a Star. It's the driving force behind maintaining this open-source project <3
|
||||||
|
|
||||||
<div align="center">
|
<div align="center">
|
||||||
|
|
||||||
@@ -294,10 +277,9 @@ pre-commit install
|
|||||||
|
|
||||||
<div align="center">
|
<div align="center">
|
||||||
|
|
||||||
_陪伴与能力从来不应该是对立面。我们希望创造的是一个既能理解情绪、给予陪伴,也能可靠完成工作的机器人。_
|
_Companionship and capability should never be at odds. What we aim to create is a robot that can understand emotions, provide genuine companionship, and reliably accomplish tasks._
|
||||||
|
|
||||||
_私は、高性能ですから!_
|
_私は、高性能ですから!_
|
||||||
|
|
||||||
<img src="https://files.astrbot.app/watashiwa-koseino-desukara.gif" width="100"/>
|
<img src="https://files.astrbot.app/watashiwa-koseino-desukara.gif" width="100"/>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+55
-62
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
<div align="center">
|
<div align="center">
|
||||||
|
|
||||||
<a href="https://github.com/AstrBotDevs/AstrBot/blob/master/README.md">中文</a> |
|
<a href="https://github.com/AstrBotDevs/AstrBot/blob/master/README_zh.md">中文</a> |
|
||||||
<a href="https://github.com/AstrBotDevs/AstrBot/blob/master/README_ja.md">日本語</a> |
|
<a href="https://github.com/AstrBotDevs/AstrBot/blob/master/README_ja.md">日本語</a> |
|
||||||
<a href="https://github.com/AstrBotDevs/AstrBot/blob/master/README_zh-TW.md">繁體中文</a> |
|
<a href="https://github.com/AstrBotDevs/AstrBot/blob/master/README_zh-TW.md">繁體中文</a> |
|
||||||
<a href="https://github.com/AstrBotDevs/AstrBot/blob/master/README_fr.md">Français</a> |
|
<a href="https://github.com/AstrBotDevs/AstrBot/blob/master/README_fr.md">Français</a> |
|
||||||
@@ -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.
|
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
|
## Key Features
|
||||||
|
|
||||||
@@ -117,6 +117,8 @@ Please refer to the official documentation: [1Panel Deployment](https://astrbot.
|
|||||||
|
|
||||||
#### Deploy on RainYun
|
#### Deploy on RainYun
|
||||||
|
|
||||||
|
For Chinese users:
|
||||||
|
|
||||||
AstrBot has been officially listed on RainYun's cloud application platform with one-click deployment.
|
AstrBot has been officially listed on RainYun's cloud application platform with one-click deployment.
|
||||||
|
|
||||||
[](https://app.rainyun.com/apps/rca/store/5994?ref=NjU1ODg0)
|
[](https://app.rainyun.com/apps/rca/store/5994?ref=NjU1ODg0)
|
||||||
@@ -156,70 +158,61 @@ Or refer to the official documentation: [Deploy AstrBot from Source](https://ast
|
|||||||
|
|
||||||
## Supported Messaging Platforms
|
## Supported Messaging Platforms
|
||||||
|
|
||||||
**Officially Maintained**
|
Connect AstrBot to your favorite chat platform.
|
||||||
|
|
||||||
- QQ (Official Platform & OneBot)
|
| Platform | Maintainer |
|
||||||
- Telegram
|
|---------|---------------|
|
||||||
- WeChat Work Application & WeChat Work Intelligent Bot
|
| QQ | Official |
|
||||||
- WeChat Customer Service & WeChat Official Accounts
|
| OneBot v11 protocol implementation | Official |
|
||||||
- Feishu (Lark)
|
| Telegram | Official |
|
||||||
- DingTalk
|
| WeChat Work Application & WeChat Work Intelligent Bot | Official |
|
||||||
- Slack
|
| WeChat Customer Service & WeChat Official Accounts | Official |
|
||||||
- Discord
|
| Feishu (Lark) | Official |
|
||||||
- Satori
|
| DingTalk | Official |
|
||||||
- Misskey
|
| Slack | Official |
|
||||||
- LINE
|
| Discord | Official |
|
||||||
- WhatsApp (Coming Soon)
|
| LINE | Official |
|
||||||
|
| Satori | Official |
|
||||||
**Community Maintained**
|
| Misskey | Official |
|
||||||
|
| WhatsApp (Coming Soon) | Official |
|
||||||
- [Matrix](https://github.com/stevessr/astrbot_plugin_matrix_adapter)
|
| [Matrix](https://github.com/stevessr/astrbot_plugin_matrix_adapter) | Community |
|
||||||
- [KOOK](https://github.com/wuyan1003/astrbot_plugin_kook_adapter)
|
| [KOOK](https://github.com/wuyan1003/astrbot_plugin_kook_adapter) | Community |
|
||||||
- [VoceChat](https://github.com/HikariFroya/astrbot_plugin_vocechat)
|
| [VoceChat](https://github.com/HikariFroya/astrbot_plugin_vocechat) | Community |
|
||||||
|
|
||||||
## Supported Model Services
|
## Supported Model Services
|
||||||
|
|
||||||
**LLM Services**
|
| Service | Type |
|
||||||
|
|---------|---------------|
|
||||||
- OpenAI and Compatible Services
|
| OpenAI and Compatible Services | LLM Services |
|
||||||
- Anthropic
|
| Anthropic | LLM Services |
|
||||||
- Google Gemini
|
| Google Gemini | LLM Services |
|
||||||
- Moonshot AI
|
| Moonshot AI | LLM Services |
|
||||||
- Zhipu AI
|
| Zhipu AI | LLM Services |
|
||||||
- DeepSeek
|
| DeepSeek | LLM Services |
|
||||||
- Ollama (Self-hosted)
|
| Ollama (Self-hosted) | LLM Services |
|
||||||
- LM Studio (Self-hosted)
|
| LM Studio (Self-hosted) | LLM Services |
|
||||||
- [CompShare](https://www.compshare.cn/?ytag=GPU_YY-gh_astrbot&referral_code=FV7DcGowN4hB5UuXKgpE74)
|
| [CompShare](https://www.compshare.cn/?ytag=GPU_YY-gh_astrbot&referral_code=FV7DcGowN4hB5UuXKgpE74) | LLM Services |
|
||||||
- [302.AI](https://share.302.ai/rr1M3l)
|
| [302.AI](https://share.302.ai/rr1M3l) | LLM Services |
|
||||||
- [TokenPony](https://www.tokenpony.cn/3YPyf)
|
| [TokenPony](https://www.tokenpony.cn/3YPyf) | LLM Services |
|
||||||
- [SiliconFlow](https://docs.siliconflow.cn/cn/usercases/use-siliconcloud-in-astrbot)
|
| [SiliconFlow](https://docs.siliconflow.cn/cn/usercases/use-siliconcloud-in-astrbot) | LLM Services |
|
||||||
- [PPIO Cloud](https://ppio.com/user/register?invited_by=AIOONE)
|
| [PPIO Cloud](https://ppio.com/user/register?invited_by=AIOONE) | LLM Services |
|
||||||
- ModelScope
|
| ModelScope | LLM Services |
|
||||||
- OneAPI
|
| OneAPI | LLM Services |
|
||||||
|
| Dify | LLMOps Platforms |
|
||||||
**LLMOps Platforms**
|
| Alibaba Cloud Bailian Applications | LLMOps Platforms |
|
||||||
|
| Coze | LLMOps Platforms |
|
||||||
- Dify
|
| OpenAI Whisper | Speech-to-Text Services |
|
||||||
- Alibaba Cloud Bailian Applications
|
| SenseVoice | Speech-to-Text Services |
|
||||||
- Coze
|
| OpenAI TTS | Text-to-Speech Services |
|
||||||
|
| Gemini TTS | Text-to-Speech Services |
|
||||||
**Speech-to-Text Services**
|
| GPT-Sovits-Inference | Text-to-Speech Services |
|
||||||
|
| GPT-Sovits | Text-to-Speech Services |
|
||||||
- OpenAI Whisper
|
| FishAudio | Text-to-Speech Services |
|
||||||
- SenseVoice
|
| Edge TTS | Text-to-Speech Services |
|
||||||
|
| Alibaba Cloud Bailian TTS | Text-to-Speech Services |
|
||||||
**Text-to-Speech Services**
|
| Azure TTS | Text-to-Speech Services |
|
||||||
|
| Minimax TTS | Text-to-Speech Services |
|
||||||
- OpenAI TTS
|
| Volcano Engine TTS | Text-to-Speech Services |
|
||||||
- Gemini TTS
|
|
||||||
- GPT-Sovits-Inference
|
|
||||||
- GPT-Sovits
|
|
||||||
- FishAudio
|
|
||||||
- Edge TTS
|
|
||||||
- Alibaba Cloud Bailian TTS
|
|
||||||
- Azure TTS
|
|
||||||
- Minimax TTS
|
|
||||||
- Volcano Engine TTS
|
|
||||||
|
|
||||||
## ❤️ Contributing
|
## ❤️ Contributing
|
||||||
|
|
||||||
|
|||||||
+55
-62
@@ -2,8 +2,8 @@
|
|||||||
|
|
||||||
<div align="center">
|
<div align="center">
|
||||||
|
|
||||||
<a href="https://github.com/AstrBotDevs/AstrBot/blob/master/README.md">中文</a> |
|
<a href="https://github.com/AstrBotDevs/AstrBot/blob/master/README_zh.md">中文</a> |
|
||||||
<a href="https://github.com/AstrBotDevs/AstrBot/blob/master/README_en.md">English</a> |
|
<a href="https://github.com/AstrBotDevs/AstrBot/blob/master/README.md">English</a> |
|
||||||
<a href="https://github.com/AstrBotDevs/AstrBot/blob/master/README_ja.md">日本語</a> |
|
<a href="https://github.com/AstrBotDevs/AstrBot/blob/master/README_ja.md">日本語</a> |
|
||||||
<a href="https://github.com/AstrBotDevs/AstrBot/blob/master/README_zh-TW.md">繁體中文</a> |
|
<a href="https://github.com/AstrBotDevs/AstrBot/blob/master/README_zh-TW.md">繁體中文</a> |
|
||||||
<a href="https://github.com/AstrBotDevs/AstrBot/blob/master/README_ru.md">Русский</a>
|
<a href="https://github.com/AstrBotDevs/AstrBot/blob/master/README_ru.md">Русский</a>
|
||||||
@@ -107,6 +107,8 @@ Veuillez consulter la documentation officielle : [Déploiement 1Panel](https://a
|
|||||||
|
|
||||||
#### Déployer sur RainYun
|
#### Déployer sur RainYun
|
||||||
|
|
||||||
|
For Chinese users:
|
||||||
|
|
||||||
AstrBot a été officiellement listé sur la plateforme d'applications cloud de RainYun avec un déploiement en un clic.
|
AstrBot a été officiellement listé sur la plateforme d'applications cloud de RainYun avec un déploiement en un clic.
|
||||||
|
|
||||||
[](https://app.rainyun.com/apps/rca/store/5994?ref=NjU1ODg0)
|
[](https://app.rainyun.com/apps/rca/store/5994?ref=NjU1ODg0)
|
||||||
@@ -156,70 +158,61 @@ paru -S astrbot-git
|
|||||||
|
|
||||||
## Plateformes de messagerie prises en charge
|
## Plateformes de messagerie prises en charge
|
||||||
|
|
||||||
**Maintenues officiellement**
|
Connectez AstrBot à vos plateformes de chat préférées.
|
||||||
|
|
||||||
- QQ (Plateforme officielle & OneBot)
|
| Plateforme | Maintenance |
|
||||||
- Telegram
|
|---------|---------------|
|
||||||
- Application WeChat Work & Bot intelligent WeChat Work
|
| QQ | Officielle |
|
||||||
- Service client WeChat & Comptes officiels WeChat
|
| Implémentation du protocole OneBot v11 | Officielle |
|
||||||
- Feishu (Lark)
|
| Telegram | Officielle |
|
||||||
- DingTalk
|
| Application WeChat Work & Bot intelligent WeChat Work | Officielle |
|
||||||
- Slack
|
| Service client WeChat & Comptes officiels WeChat | Officielle |
|
||||||
- Discord
|
| Feishu (Lark) | Officielle |
|
||||||
- Satori
|
| DingTalk | Officielle |
|
||||||
- Misskey
|
| Slack | Officielle |
|
||||||
- LINE
|
| Discord | Officielle |
|
||||||
- WhatsApp (Bientôt disponible)
|
| LINE | Officielle |
|
||||||
|
| Satori | Officielle |
|
||||||
**Maintenues par la communauté**
|
| Misskey | Officielle |
|
||||||
|
| WhatsApp (Bientôt disponible) | Officielle |
|
||||||
- [Matrix](https://github.com/stevessr/astrbot_plugin_matrix_adapter)
|
| [Matrix](https://github.com/stevessr/astrbot_plugin_matrix_adapter) | Communauté |
|
||||||
- [KOOK](https://github.com/wuyan1003/astrbot_plugin_kook_adapter)
|
| [KOOK](https://github.com/wuyan1003/astrbot_plugin_kook_adapter) | Communauté |
|
||||||
- [VoceChat](https://github.com/HikariFroya/astrbot_plugin_vocechat)
|
| [VoceChat](https://github.com/HikariFroya/astrbot_plugin_vocechat) | Communauté |
|
||||||
|
|
||||||
## Services de modèles pris en charge
|
## Services de modèles pris en charge
|
||||||
|
|
||||||
**Services LLM**
|
| Service | Type |
|
||||||
|
|---------|---------------|
|
||||||
- OpenAI et services compatibles
|
| OpenAI et services compatibles | Services LLM |
|
||||||
- Anthropic
|
| Anthropic | Services LLM |
|
||||||
- Google Gemini
|
| Google Gemini | Services LLM |
|
||||||
- Moonshot AI
|
| Moonshot AI | Services LLM |
|
||||||
- Zhipu AI
|
| Zhipu AI | Services LLM |
|
||||||
- DeepSeek
|
| DeepSeek | Services LLM |
|
||||||
- Ollama (Auto-hébergé)
|
| Ollama (Auto-hébergé) | Services LLM |
|
||||||
- LM Studio (Auto-hébergé)
|
| LM Studio (Auto-hébergé) | Services LLM |
|
||||||
- [CompShare](https://www.compshare.cn/?ytag=GPU_YY-gh_astrbot&referral_code=FV7DcGowN4hB5UuXKgpE74)
|
| [CompShare](https://www.compshare.cn/?ytag=GPU_YY-gh_astrbot&referral_code=FV7DcGowN4hB5UuXKgpE74) | Services LLM |
|
||||||
- [302.AI](https://share.302.ai/rr1M3l)
|
| [302.AI](https://share.302.ai/rr1M3l) | Services LLM |
|
||||||
- [TokenPony](https://www.tokenpony.cn/3YPyf)
|
| [TokenPony](https://www.tokenpony.cn/3YPyf) | Services LLM |
|
||||||
- [SiliconFlow](https://docs.siliconflow.cn/cn/usercases/use-siliconcloud-in-astrbot)
|
| [SiliconFlow](https://docs.siliconflow.cn/cn/usercases/use-siliconcloud-in-astrbot) | Services LLM |
|
||||||
- [PPIO Cloud](https://ppio.com/user/register?invited_by=AIOONE)
|
| [PPIO Cloud](https://ppio.com/user/register?invited_by=AIOONE) | Services LLM |
|
||||||
- ModelScope
|
| ModelScope | Services LLM |
|
||||||
- OneAPI
|
| OneAPI | Services LLM |
|
||||||
|
| Dify | Plateformes LLMOps |
|
||||||
**Plateformes LLMOps**
|
| Applications Alibaba Cloud Bailian | Plateformes LLMOps |
|
||||||
|
| Coze | Plateformes LLMOps |
|
||||||
- Dify
|
| OpenAI Whisper | Services de reconnaissance vocale |
|
||||||
- Applications Alibaba Cloud Bailian
|
| SenseVoice | Services de reconnaissance vocale |
|
||||||
- Coze
|
| OpenAI TTS | Services de synthèse vocale |
|
||||||
|
| Gemini TTS | Services de synthèse vocale |
|
||||||
**Services de reconnaissance vocale**
|
| GPT-Sovits-Inference | Services de synthèse vocale |
|
||||||
|
| GPT-Sovits | Services de synthèse vocale |
|
||||||
- OpenAI Whisper
|
| FishAudio | Services de synthèse vocale |
|
||||||
- SenseVoice
|
| Edge TTS | Services de synthèse vocale |
|
||||||
|
| Alibaba Cloud Bailian TTS | Services de synthèse vocale |
|
||||||
**Services de synthèse vocale**
|
| Azure TTS | Services de synthèse vocale |
|
||||||
|
| Minimax TTS | Services de synthèse vocale |
|
||||||
- OpenAI TTS
|
| Volcano Engine TTS | Services de synthèse vocale |
|
||||||
- Gemini TTS
|
|
||||||
- GPT-Sovits-Inference
|
|
||||||
- GPT-Sovits
|
|
||||||
- FishAudio
|
|
||||||
- Edge TTS
|
|
||||||
- Alibaba Cloud Bailian TTS
|
|
||||||
- Azure TTS
|
|
||||||
- Minimax TTS
|
|
||||||
- Volcano Engine TTS
|
|
||||||
|
|
||||||
## ❤️ Contribuer
|
## ❤️ Contribuer
|
||||||
|
|
||||||
|
|||||||
+56
-63
@@ -2,8 +2,8 @@
|
|||||||
|
|
||||||
<div align="center">
|
<div align="center">
|
||||||
|
|
||||||
<a href="https://github.com/AstrBotDevs/AstrBot/blob/master/README.md">中文</a> |
|
<a href="https://github.com/AstrBotDevs/AstrBot/blob/master/README_zh.md">中文</a> |
|
||||||
<a href="https://github.com/AstrBotDevs/AstrBot/blob/master/README_en.md">English</a> |
|
<a href="https://github.com/AstrBotDevs/AstrBot/blob/master/README.md">English</a> |
|
||||||
<a href="https://github.com/AstrBotDevs/AstrBot/blob/master/README_zh-TW.md">繁體中文</a> |
|
<a href="https://github.com/AstrBotDevs/AstrBot/blob/master/README_zh-TW.md">繁體中文</a> |
|
||||||
<a href="https://github.com/AstrBotDevs/AstrBot/blob/master/README_fr.md">Français</a> |
|
<a href="https://github.com/AstrBotDevs/AstrBot/blob/master/README_fr.md">Français</a> |
|
||||||
<a href="https://github.com/AstrBotDevs/AstrBot/blob/master/README_ru.md">Русский</a>
|
<a href="https://github.com/AstrBotDevs/AstrBot/blob/master/README_ru.md">Русский</a>
|
||||||
@@ -37,7 +37,7 @@
|
|||||||
|
|
||||||
AstrBot は、主要なインスタントメッセージングアプリと統合できるオープンソースのオールインワン Agent チャットボットプラットフォームです。個人、開発者、チームに信頼性が高くスケーラブルな会話型 AI インフラストラクチャを提供します。パーソナル AI コンパニオン、インテリジェントカスタマーサービス、オートメーションアシスタント、エンタープライズナレッジベースなど、AstrBot を使用すると、IM プラットフォームのワークフロー内で本番環境対応の AI アプリケーションを迅速に構築できます。
|
AstrBot は、主要なインスタントメッセージングアプリと統合できるオープンソースのオールインワン Agent チャットボットプラットフォームです。個人、開発者、チームに信頼性が高くスケーラブルな会話型 AI インフラストラクチャを提供します。パーソナル AI コンパニオン、インテリジェントカスタマーサービス、オートメーションアシスタント、エンタープライズナレッジベースなど、AstrBot を使用すると、IM プラットフォームのワークフロー内で本番環境対応の AI アプリケーションを迅速に構築できます。
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
## 主な機能
|
## 主な機能
|
||||||
|
|
||||||
@@ -107,6 +107,8 @@ AstrBot は 1Panel 公式により 1Panel パネルに公開されています
|
|||||||
|
|
||||||
#### 雨云でのデプロイ
|
#### 雨云でのデプロイ
|
||||||
|
|
||||||
|
For Chinese users:
|
||||||
|
|
||||||
AstrBot は雨云公式によりクラウドアプリケーションプラットフォームに公開され、ワンクリックでデプロイ可能です。
|
AstrBot は雨云公式によりクラウドアプリケーションプラットフォームに公開され、ワンクリックでデプロイ可能です。
|
||||||
|
|
||||||
[](https://app.rainyun.com/apps/rca/store/5994?ref=NjU1ODg0)
|
[](https://app.rainyun.com/apps/rca/store/5994?ref=NjU1ODg0)
|
||||||
@@ -156,71 +158,62 @@ paru -S astrbot-git
|
|||||||
|
|
||||||
## サポートされているメッセージプラットフォーム
|
## サポートされているメッセージプラットフォーム
|
||||||
|
|
||||||
**公式メンテナンス**
|
AstrBot をよく使うチャットプラットフォームに接続できます。
|
||||||
|
|
||||||
- QQ (公式プラットフォーム & OneBot)
|
| プラットフォーム | 保守 |
|
||||||
- Telegram
|
|---------|---------------|
|
||||||
- WeChat Work アプリケーション & WeChat Work インテリジェントボット
|
| QQ | 公式 |
|
||||||
- WeChat カスタマーサービス & WeChat 公式アカウント
|
| OneBot v11 プロトコル実装 | 公式 |
|
||||||
- Feishu (Lark)
|
| Telegram | 公式 |
|
||||||
- DingTalk
|
| WeChat Work アプリケーション & WeChat Work インテリジェントボット | 公式 |
|
||||||
- Slack
|
| WeChat カスタマーサービス & WeChat 公式アカウント | 公式 |
|
||||||
- Discord
|
| Feishu (Lark) | 公式 |
|
||||||
- Satori
|
| DingTalk | 公式 |
|
||||||
- Misskey
|
| Slack | 公式 |
|
||||||
- LINE
|
| Discord | 公式 |
|
||||||
- WhatsApp (近日対応予定)
|
| LINE | 公式 |
|
||||||
|
| Satori | 公式 |
|
||||||
**コミュニティメンテナンス**
|
| Misskey | 公式 |
|
||||||
|
| WhatsApp (近日対応予定) | 公式 |
|
||||||
- [Matrix](https://github.com/stevessr/astrbot_plugin_matrix_adapter)
|
| [Matrix](https://github.com/stevessr/astrbot_plugin_matrix_adapter) | コミュニティ |
|
||||||
- [KOOK](https://github.com/wuyan1003/astrbot_plugin_kook_adapter)
|
| [KOOK](https://github.com/wuyan1003/astrbot_plugin_kook_adapter) | コミュニティ |
|
||||||
- [VoceChat](https://github.com/HikariFroya/astrbot_plugin_vocechat)
|
| [VoceChat](https://github.com/HikariFroya/astrbot_plugin_vocechat) | コミュニティ |
|
||||||
|
|
||||||
|
|
||||||
## サポートされているモデルサービス
|
## サポートされているモデルサービス
|
||||||
|
|
||||||
**大規模言語モデルサービス**
|
| サービス | 種類 |
|
||||||
|
|---------|---------------|
|
||||||
- OpenAI および互換サービス
|
| OpenAI および互換サービス | 大規模言語モデルサービス |
|
||||||
- Anthropic
|
| Anthropic | 大規模言語モデルサービス |
|
||||||
- Google Gemini
|
| Google Gemini | 大規模言語モデルサービス |
|
||||||
- Moonshot AI
|
| Moonshot AI | 大規模言語モデルサービス |
|
||||||
- 智谱 AI
|
| 智谱 AI | 大規模言語モデルサービス |
|
||||||
- DeepSeek
|
| DeepSeek | 大規模言語モデルサービス |
|
||||||
- Ollama (セルフホスト)
|
| Ollama (セルフホスト) | 大規模言語モデルサービス |
|
||||||
- LM Studio (セルフホスト)
|
| LM Studio (セルフホスト) | 大規模言語モデルサービス |
|
||||||
- [優云智算](https://www.compshare.cn/?ytag=GPU_YY-gh_astrbot&referral_code=FV7DcGowN4hB5UuXKgpE74)
|
| [優云智算](https://www.compshare.cn/?ytag=GPU_YY-gh_astrbot&referral_code=FV7DcGowN4hB5UuXKgpE74) | 大規模言語モデルサービス |
|
||||||
- [302.AI](https://share.302.ai/rr1M3l)
|
| [302.AI](https://share.302.ai/rr1M3l) | 大規模言語モデルサービス |
|
||||||
- [小馬算力](https://www.tokenpony.cn/3YPyf)
|
| [小馬算力](https://www.tokenpony.cn/3YPyf) | 大規模言語モデルサービス |
|
||||||
- [硅基流動](https://docs.siliconflow.cn/cn/usercases/use-siliconcloud-in-astrbot)
|
| [硅基流動](https://docs.siliconflow.cn/cn/usercases/use-siliconcloud-in-astrbot) | 大規模言語モデルサービス |
|
||||||
- [PPIO 派欧云](https://ppio.com/user/register?invited_by=AIOONE)
|
| [PPIO 派欧云](https://ppio.com/user/register?invited_by=AIOONE) | 大規模言語モデルサービス |
|
||||||
- ModelScope
|
| ModelScope | 大規模言語モデルサービス |
|
||||||
- OneAPI
|
| OneAPI | 大規模言語モデルサービス |
|
||||||
|
| Dify | LLMOps プラットフォーム |
|
||||||
**LLMOps プラットフォーム**
|
| Alibaba Cloud 百炼アプリケーション | LLMOps プラットフォーム |
|
||||||
|
| Coze | LLMOps プラットフォーム |
|
||||||
- Dify
|
| OpenAI Whisper | 音声認識サービス |
|
||||||
- Alibaba Cloud 百炼アプリケーション
|
| SenseVoice | 音声認識サービス |
|
||||||
- Coze
|
| OpenAI TTS | 音声合成サービス |
|
||||||
|
| Gemini TTS | 音声合成サービス |
|
||||||
**音声認識サービス**
|
| GPT-Sovits-Inference | 音声合成サービス |
|
||||||
|
| GPT-Sovits | 音声合成サービス |
|
||||||
- OpenAI Whisper
|
| FishAudio | 音声合成サービス |
|
||||||
- SenseVoice
|
| Edge TTS | 音声合成サービス |
|
||||||
|
| Alibaba Cloud 百炼 TTS | 音声合成サービス |
|
||||||
**音声合成サービス**
|
| Azure TTS | 音声合成サービス |
|
||||||
|
| Minimax TTS | 音声合成サービス |
|
||||||
- OpenAI TTS
|
| Volcano Engine TTS | 音声合成サービス |
|
||||||
- Gemini TTS
|
|
||||||
- GPT-Sovits-Inference
|
|
||||||
- GPT-Sovits
|
|
||||||
- FishAudio
|
|
||||||
- Edge TTS
|
|
||||||
- Alibaba Cloud 百炼 TTS
|
|
||||||
- Azure TTS
|
|
||||||
- Minimax TTS
|
|
||||||
- Volcano Engine TTS
|
|
||||||
|
|
||||||
## ❤️ コントリビューション
|
## ❤️ コントリビューション
|
||||||
|
|
||||||
|
|||||||
+55
-63
@@ -2,8 +2,8 @@
|
|||||||
|
|
||||||
<div align="center">
|
<div align="center">
|
||||||
|
|
||||||
<a href="https://github.com/AstrBotDevs/AstrBot/blob/master/README.md">中文</a> |
|
<a href="https://github.com/AstrBotDevs/AstrBot/blob/master/README_zh.md">中文</a> |
|
||||||
<a href="https://github.com/AstrBotDevs/AstrBot/blob/master/README_en.md">English</a> |
|
<a href="https://github.com/AstrBotDevs/AstrBot/blob/master/README.md">English</a> |
|
||||||
<a href="https://github.com/AstrBotDevs/AstrBot/blob/master/README_ja.md">日本語</a> |
|
<a href="https://github.com/AstrBotDevs/AstrBot/blob/master/README_ja.md">日本語</a> |
|
||||||
<a href="https://github.com/AstrBotDevs/AstrBot/blob/master/README_zh-TW.md">繁體中文</a> |
|
<a href="https://github.com/AstrBotDevs/AstrBot/blob/master/README_zh-TW.md">繁體中文</a> |
|
||||||
<a href="https://github.com/AstrBotDevs/AstrBot/blob/master/README_fr.md">Français</a>
|
<a href="https://github.com/AstrBotDevs/AstrBot/blob/master/README_fr.md">Français</a>
|
||||||
@@ -107,6 +107,8 @@ AstrBot официально размещён на маркетплейсе 1Pan
|
|||||||
|
|
||||||
#### Развёртывание на RainYun
|
#### Развёртывание на RainYun
|
||||||
|
|
||||||
|
For Chinese users:
|
||||||
|
|
||||||
AstrBot официально размещён на облачной платформе приложений RainYun с развёртыванием в один клик.
|
AstrBot официально размещён на облачной платформе приложений RainYun с развёртыванием в один клик.
|
||||||
|
|
||||||
[](https://app.rainyun.com/apps/rca/store/5994?ref=NjU1ODg0)
|
[](https://app.rainyun.com/apps/rca/store/5994?ref=NjU1ODg0)
|
||||||
@@ -156,71 +158,61 @@ paru -S astrbot-git
|
|||||||
|
|
||||||
## Поддерживаемые платформы обмена сообщениями
|
## Поддерживаемые платформы обмена сообщениями
|
||||||
|
|
||||||
**Официально поддерживаемые**
|
Подключите AstrBot к вашим любимым чат-платформам.
|
||||||
|
|
||||||
- QQ (Официальная платформа и OneBot)
|
| Платформа | Поддержка |
|
||||||
- Telegram
|
|---------|---------------|
|
||||||
- Приложение WeChat Work и интеллектуальный бот WeChat Work
|
| QQ | Официальная |
|
||||||
- Служба поддержки WeChat и официальные аккаунты WeChat
|
| Реализация протокола OneBot v11 | Официальная |
|
||||||
- Feishu (Lark)
|
| Telegram | Официальная |
|
||||||
- DingTalk
|
| Приложение WeChat Work и интеллектуальный бот WeChat Work | Официальная |
|
||||||
- Slack
|
| Служба поддержки WeChat и официальные аккаунты WeChat | Официальная |
|
||||||
- Discord
|
| Feishu (Lark) | Официальная |
|
||||||
- Satori
|
| DingTalk | Официальная |
|
||||||
- Misskey
|
| Slack | Официальная |
|
||||||
- LINE
|
| Discord | Официальная |
|
||||||
- WhatsApp (Скоро)
|
| LINE | Официальная |
|
||||||
|
| Satori | Официальная |
|
||||||
|
| Misskey | Официальная |
|
||||||
**Поддерживаемые сообществом**
|
| WhatsApp (Скоро) | Официальная |
|
||||||
|
| [Matrix](https://github.com/stevessr/astrbot_plugin_matrix_adapter) | Сообщество |
|
||||||
- [Matrix](https://github.com/stevessr/astrbot_plugin_matrix_adapter)
|
| [KOOK](https://github.com/wuyan1003/astrbot_plugin_kook_adapter) | Сообщество |
|
||||||
- [KOOK](https://github.com/wuyan1003/astrbot_plugin_kook_adapter)
|
| [VoceChat](https://github.com/HikariFroya/astrbot_plugin_vocechat) | Сообщество |
|
||||||
- [VoceChat](https://github.com/HikariFroya/astrbot_plugin_vocechat)
|
|
||||||
|
|
||||||
## Поддерживаемые сервисы моделей
|
## Поддерживаемые сервисы моделей
|
||||||
|
|
||||||
**Сервисы LLM**
|
| Сервис | Тип |
|
||||||
|
|---------|---------------|
|
||||||
- OpenAI и совместимые сервисы
|
| OpenAI и совместимые сервисы | Сервисы LLM |
|
||||||
- Anthropic
|
| Anthropic | Сервисы LLM |
|
||||||
- Google Gemini
|
| Google Gemini | Сервисы LLM |
|
||||||
- Moonshot AI
|
| Moonshot AI | Сервисы LLM |
|
||||||
- Zhipu AI
|
| Zhipu AI | Сервисы LLM |
|
||||||
- DeepSeek
|
| DeepSeek | Сервисы LLM |
|
||||||
- Ollama (Самостоятельное размещение)
|
| Ollama (Самостоятельное размещение) | Сервисы LLM |
|
||||||
- LM Studio (Самостоятельное размещение)
|
| LM Studio (Самостоятельное размещение) | Сервисы LLM |
|
||||||
- [CompShare](https://www.compshare.cn/?ytag=GPU_YY-gh_astrbot&referral_code=FV7DcGowN4hB5UuXKgpE74)
|
| [CompShare](https://www.compshare.cn/?ytag=GPU_YY-gh_astrbot&referral_code=FV7DcGowN4hB5UuXKgpE74) | Сервисы LLM |
|
||||||
- [302.AI](https://share.302.ai/rr1M3l)
|
| [302.AI](https://share.302.ai/rr1M3l) | Сервисы LLM |
|
||||||
- [TokenPony](https://www.tokenpony.cn/3YPyf)
|
| [TokenPony](https://www.tokenpony.cn/3YPyf) | Сервисы LLM |
|
||||||
- [SiliconFlow](https://docs.siliconflow.cn/cn/usercases/use-siliconcloud-in-astrbot)
|
| [SiliconFlow](https://docs.siliconflow.cn/cn/usercases/use-siliconcloud-in-astrbot) | Сервисы LLM |
|
||||||
- [PPIO Cloud](https://ppio.com/user/register?invited_by=AIOONE)
|
| [PPIO Cloud](https://ppio.com/user/register?invited_by=AIOONE) | Сервисы LLM |
|
||||||
- ModelScope
|
| ModelScope | Сервисы LLM |
|
||||||
- OneAPI
|
| OneAPI | Сервисы LLM |
|
||||||
|
| Dify | Платформы LLMOps |
|
||||||
**Платформы LLMOps**
|
| Приложения Alibaba Cloud Bailian | Платформы LLMOps |
|
||||||
|
| Coze | Платформы LLMOps |
|
||||||
- Dify
|
| OpenAI Whisper | Сервисы распознавания речи |
|
||||||
- Приложения Alibaba Cloud Bailian
|
| SenseVoice | Сервисы распознавания речи |
|
||||||
- Coze
|
| OpenAI TTS | Сервисы синтеза речи |
|
||||||
|
| Gemini TTS | Сервисы синтеза речи |
|
||||||
**Сервисы распознавания речи**
|
| GPT-Sovits-Inference | Сервисы синтеза речи |
|
||||||
|
| GPT-Sovits | Сервисы синтеза речи |
|
||||||
- OpenAI Whisper
|
| FishAudio | Сервисы синтеза речи |
|
||||||
- SenseVoice
|
| Edge TTS | Сервисы синтеза речи |
|
||||||
|
| Alibaba Cloud Bailian TTS | Сервисы синтеза речи |
|
||||||
**Сервисы синтеза речи**
|
| Azure TTS | Сервисы синтеза речи |
|
||||||
|
| Minimax TTS | Сервисы синтеза речи |
|
||||||
- OpenAI TTS
|
| Volcano Engine TTS | Сервисы синтеза речи |
|
||||||
- Gemini TTS
|
|
||||||
- GPT-Sovits-Inference
|
|
||||||
- GPT-Sovits
|
|
||||||
- FishAudio
|
|
||||||
- Edge TTS
|
|
||||||
- Alibaba Cloud Bailian TTS
|
|
||||||
- Azure TTS
|
|
||||||
- Minimax TTS
|
|
||||||
- Volcano Engine TTS
|
|
||||||
|
|
||||||
## ❤️ Вклад в проект
|
## ❤️ Вклад в проект
|
||||||
|
|
||||||
|
|||||||
+56
-64
@@ -2,8 +2,8 @@
|
|||||||
|
|
||||||
<div align="center">
|
<div align="center">
|
||||||
|
|
||||||
<a href="https://github.com/AstrBotDevs/AstrBot/blob/master/README.md">简体中文</a> |
|
<a href="https://github.com/AstrBotDevs/AstrBot/blob/master/README_zh.md">简体中文</a> |
|
||||||
<a href="https://github.com/AstrBotDevs/AstrBot/blob/master/README_en.md">English</a> |
|
<a href="https://github.com/AstrBotDevs/AstrBot/blob/master/README.md">English</a> |
|
||||||
<a href="https://github.com/AstrBotDevs/AstrBot/blob/master/README_ja.md">日本語</a> |
|
<a href="https://github.com/AstrBotDevs/AstrBot/blob/master/README_ja.md">日本語</a> |
|
||||||
<a href="https://github.com/AstrBotDevs/AstrBot/blob/master/README_fr.md">Français</a> |
|
<a href="https://github.com/AstrBotDevs/AstrBot/blob/master/README_fr.md">Français</a> |
|
||||||
<a href="https://github.com/AstrBotDevs/AstrBot/blob/master/README_ru.md">Русский</a>
|
<a href="https://github.com/AstrBotDevs/AstrBot/blob/master/README_ru.md">Русский</a>
|
||||||
@@ -37,7 +37,7 @@
|
|||||||
|
|
||||||
AstrBot 是一個開源的一站式 Agent 聊天機器人平台,可接入主流即時通訊軟體,為個人、開發者和團隊打造可靠、可擴展的對話式智慧基礎設施。無論是個人 AI 夥伴、智慧客服、自動化助手,還是企業知識庫,AstrBot 都能在您的即時通訊軟體平台的工作流程中快速構建生產可用的 AI 應用程式。
|
AstrBot 是一個開源的一站式 Agent 聊天機器人平台,可接入主流即時通訊軟體,為個人、開發者和團隊打造可靠、可擴展的對話式智慧基礎設施。無論是個人 AI 夥伴、智慧客服、自動化助手,還是企業知識庫,AstrBot 都能在您的即時通訊軟體平台的工作流程中快速構建生產可用的 AI 應用程式。
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
## 主要功能
|
## 主要功能
|
||||||
|
|
||||||
@@ -107,6 +107,8 @@ AstrBot 已由 1Panel 官方上架至 1Panel 面板。
|
|||||||
|
|
||||||
#### 在雨雲上部署
|
#### 在雨雲上部署
|
||||||
|
|
||||||
|
For Chinese users:
|
||||||
|
|
||||||
AstrBot 已由雨雲官方上架至雲端應用程式平台,可一鍵部署。
|
AstrBot 已由雨雲官方上架至雲端應用程式平台,可一鍵部署。
|
||||||
|
|
||||||
[](https://app.rainyun.com/apps/rca/store/5994?ref=NjU1ODg0)
|
[](https://app.rainyun.com/apps/rca/store/5994?ref=NjU1ODg0)
|
||||||
@@ -156,71 +158,61 @@ paru -S astrbot-git
|
|||||||
|
|
||||||
## 支援的訊息平台
|
## 支援的訊息平台
|
||||||
|
|
||||||
**官方維護**
|
將 AstrBot 連接到你常用的聊天平台。
|
||||||
|
|
||||||
- QQ(官方平台 & OneBot)
|
| 平台 | 維護方 |
|
||||||
- Telegram
|
|---------|---------------|
|
||||||
- 企微應用 & 企微智慧機器人
|
| QQ | 官方維護 |
|
||||||
- 微信客服 & 微信公眾號
|
| OneBot v11 協議實作 | 官方維護 |
|
||||||
- 飛書
|
| Telegram | 官方維護 |
|
||||||
- 釘釘
|
| 企微應用 & 企微智慧機器人 | 官方維護 |
|
||||||
- Slack
|
| 微信客服 & 微信公眾號 | 官方維護 |
|
||||||
- Discord
|
| 飛書 | 官方維護 |
|
||||||
- Satori
|
| 釘釘 | 官方維護 |
|
||||||
- Misskey
|
| Slack | 官方維護 |
|
||||||
- LINE
|
| Discord | 官方維護 |
|
||||||
- Whatsapp(即將支援)
|
| LINE | 官方維護 |
|
||||||
|
| Satori | 官方維護 |
|
||||||
|
| Misskey | 官方維護 |
|
||||||
**社群維護**
|
| Whatsapp(即將支援) | 官方維護 |
|
||||||
|
| [Matrix](https://github.com/stevessr/astrbot_plugin_matrix_adapter) | 社群維護 |
|
||||||
- [Matrix](https://github.com/stevessr/astrbot_plugin_matrix_adapter)
|
| [KOOK](https://github.com/wuyan1003/astrbot_plugin_kook_adapter) | 社群維護 |
|
||||||
- [KOOK](https://github.com/wuyan1003/astrbot_plugin_kook_adapter)
|
| [VoceChat](https://github.com/HikariFroya/astrbot_plugin_vocechat) | 社群維護 |
|
||||||
- [VoceChat](https://github.com/HikariFroya/astrbot_plugin_vocechat)
|
|
||||||
|
|
||||||
## 支援的模型服務
|
## 支援的模型服務
|
||||||
|
|
||||||
**大型模型服務**
|
| 服務 | 類型 |
|
||||||
|
|---------|---------------|
|
||||||
- OpenAI 及相容服務
|
| OpenAI 及相容服務 | 大型模型服務 |
|
||||||
- Anthropic
|
| Anthropic | 大型模型服務 |
|
||||||
- Google Gemini
|
| Google Gemini | 大型模型服務 |
|
||||||
- Moonshot AI
|
| Moonshot AI | 大型模型服務 |
|
||||||
- 智譜 AI
|
| 智譜 AI | 大型模型服務 |
|
||||||
- DeepSeek
|
| DeepSeek | 大型模型服務 |
|
||||||
- Ollama(本機部署)
|
| Ollama(本機部署) | 大型模型服務 |
|
||||||
- LM Studio(本機部署)
|
| LM Studio(本機部署) | 大型模型服務 |
|
||||||
- [優雲智算](https://www.compshare.cn/?ytag=GPU_YY-gh_astrbot&referral_code=FV7DcGowN4hB5UuXKgpE74)
|
| [優雲智算](https://www.compshare.cn/?ytag=GPU_YY-gh_astrbot&referral_code=FV7DcGowN4hB5UuXKgpE74) | 大型模型服務 |
|
||||||
- [302.AI](https://share.302.ai/rr1M3l)
|
| [302.AI](https://share.302.ai/rr1M3l) | 大型模型服務 |
|
||||||
- [小馬算力](https://www.tokenpony.cn/3YPyf)
|
| [小馬算力](https://www.tokenpony.cn/3YPyf) | 大型模型服務 |
|
||||||
- [矽基流動](https://docs.siliconflow.cn/cn/usercases/use-siliconcloud-in-astrbot)
|
| [矽基流動](https://docs.siliconflow.cn/cn/usercases/use-siliconcloud-in-astrbot) | 大型模型服務 |
|
||||||
- [PPIO 派歐雲](https://ppio.com/user/register?invited_by=AIOONE)
|
| [PPIO 派歐雲](https://ppio.com/user/register?invited_by=AIOONE) | 大型模型服務 |
|
||||||
- ModelScope
|
| ModelScope | 大型模型服務 |
|
||||||
- OneAPI
|
| OneAPI | 大型模型服務 |
|
||||||
|
| Dify | LLMOps 平台 |
|
||||||
**LLMOps 平台**
|
| 阿里雲百煉應用 | LLMOps 平台 |
|
||||||
|
| Coze | LLMOps 平台 |
|
||||||
- Dify
|
| OpenAI Whisper | 語音轉文字服務 |
|
||||||
- 阿里雲百煉應用
|
| SenseVoice | 語音轉文字服務 |
|
||||||
- Coze
|
| OpenAI TTS | 文字轉語音服務 |
|
||||||
|
| Gemini TTS | 文字轉語音服務 |
|
||||||
**語音轉文字服務**
|
| GPT-Sovits-Inference | 文字轉語音服務 |
|
||||||
|
| GPT-Sovits | 文字轉語音服務 |
|
||||||
- OpenAI Whisper
|
| FishAudio | 文字轉語音服務 |
|
||||||
- SenseVoice
|
| Edge TTS | 文字轉語音服務 |
|
||||||
|
| 阿里雲百煉 TTS | 文字轉語音服務 |
|
||||||
**文字轉語音服務**
|
| Azure TTS | 文字轉語音服務 |
|
||||||
|
| Minimax TTS | 文字轉語音服務 |
|
||||||
- OpenAI TTS
|
| 火山引擎 TTS | 文字轉語音服務 |
|
||||||
- Gemini TTS
|
|
||||||
- GPT-Sovits-Inference
|
|
||||||
- GPT-Sovits
|
|
||||||
- FishAudio
|
|
||||||
- Edge TTS
|
|
||||||
- 阿里雲百煉 TTS
|
|
||||||
- Azure TTS
|
|
||||||
- Minimax TTS
|
|
||||||
- 火山引擎 TTS
|
|
||||||
|
|
||||||
## ❤️ 貢獻
|
## ❤️ 貢獻
|
||||||
|
|
||||||
|
|||||||
+252
@@ -0,0 +1,252 @@
|
|||||||
|

|
||||||
|
|
||||||
|
<div align="center">
|
||||||
|
|
||||||
|
<a href="https://github.com/AstrBotDevs/AstrBot/blob/master/README.md">English</a> |
|
||||||
|
<a href="https://github.com/AstrBotDevs/AstrBot/blob/master/README_ja.md">日本語</a> |
|
||||||
|
<a href="https://github.com/AstrBotDevs/AstrBot/blob/master/README_zh-TW.md">繁體中文</a> |
|
||||||
|
<a href="https://github.com/AstrBotDevs/AstrBot/blob/master/README_fr.md">Français</a> |
|
||||||
|
<a href="https://github.com/AstrBotDevs/AstrBot/blob/master/README_ru.md">Русский</a>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<a href="https://trendshift.io/repositories/12875" target="_blank"><img src="https://trendshift.io/api/badge/repositories/12875" alt="Soulter%2FAstrBot | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
|
||||||
|
<a href="https://hellogithub.com/repository/AstrBotDevs/AstrBot" target="_blank"><img src="https://api.hellogithub.com/v1/widgets/recommend.svg?rid=d127d50cd5e54c5382328acc3bb25483&claim_uid=ZO9by7qCXgSd6Lp&t=2" alt="Featured|HelloGitHub" style="width: 250px; height: 54px;" width="250" height="54" /></a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<br>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<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://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://gitcode.com/Soulter/AstrBot/star/badge.svg" href="https://gitcode.com/Soulter/AstrBot">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<br>
|
||||||
|
|
||||||
|
<a href="https://astrbot.app/">主页</a> |
|
||||||
|
<a href="https://astrbot.app/">文档</a> |
|
||||||
|
<a href="https://blog.astrbot.app/">博客</a> |
|
||||||
|
<a href="https://astrbot.featurebase.app/roadmap">路线图</a> |
|
||||||
|
<a href="https://github.com/AstrBotDevs/AstrBot/issues">问题提交</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
AstrBot 是一个开源的一站式 Agentic 个人和群聊助手,可在 QQ、Telegram、企业微信、飞书、钉钉、Slack、等数十款主流即时通讯软件上部署,此外还内置类似 OpenWebUI 的轻量化 ChatUI,为个人、开发者和团队打造可靠、可扩展的对话式智能基础设施。无论是个人 AI 伙伴、智能客服、自动化助手,还是企业知识库,AstrBot 都能在你的即时通讯软件平台的工作流中快速构建 AI 应用。
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
## 主要功能
|
||||||
|
|
||||||
|
1. 💯 免费 & 开源。
|
||||||
|
2. ✨ AI 大模型对话,多模态,Agent,MCP,Skills,知识库,人格设定,自动压缩对话。
|
||||||
|
3. 🤖 支持接入 Dify、阿里云百炼、Coze 等智能体平台。
|
||||||
|
4. 🌐 多平台,支持 QQ、企业微信、飞书、钉钉、微信公众号、Telegram、Slack 以及[更多](#支持的消息平台)。
|
||||||
|
5. 📦 插件扩展,已有 1000+ 个插件可一键安装。
|
||||||
|
6. 🛡️ [Agent Sandbox](https://docs.astrbot.app/use/astrbot-agent-sandbox.html) 隔离化环境,安全地执行任何代码、调用 Shell、会话级资源复用。
|
||||||
|
7. 💻 WebUI 支持。
|
||||||
|
8. 🌈 Web ChatUI 支持,ChatUI 内置代理沙盒、网页搜索等。
|
||||||
|
9. 🌐 国际化(i18n)支持。
|
||||||
|
|
||||||
|
<br>
|
||||||
|
|
||||||
|
<table align="center">
|
||||||
|
<tr align="center">
|
||||||
|
<th>💙 角色扮演 & 情感陪伴</th>
|
||||||
|
<th>✨ 主动式 Agent</th>
|
||||||
|
<th>🚀 通用 Agentic 能力</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>
|
||||||
|
<td align="center"><p align="center"><img width="976" height="1612" alt="c449acd838c41d0915cc08a3824025b1" src="https://github.com/user-attachments/assets/f75368b4-e022-41dc-a9e0-131c3e73e32e" /></p></td>
|
||||||
|
<td align="center"><p align="center"><img width="974" height="1732" alt="image" src="https://github.com/user-attachments/assets/e22a3968-87d7-4708-a7cd-e7f198c7c32e" /></p></td>
|
||||||
|
<td align="center"><p align="center"><img width="976" height="1734" alt="image" src="https://github.com/user-attachments/assets/0952b395-6b4a-432a-8a50-c294b7f89750" /></p></td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
## 快速开始
|
||||||
|
|
||||||
|
### 一键部署
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv tool install astrbot
|
||||||
|
astrbot
|
||||||
|
```
|
||||||
|
|
||||||
|
> 需要安装 [uv](https://docs.astral.sh/uv/)。
|
||||||
|
|
||||||
|
### Docker 部署
|
||||||
|
|
||||||
|
推荐使用 Docker / Docker Compose 方式部署 AstrBot。
|
||||||
|
|
||||||
|
请参阅官方文档 [使用 Docker 部署 AstrBot](https://astrbot.app/deploy/astrbot/docker.html#%E4%BD%BF%E7%94%A8-docker-%E9%83%A8%E7%BD%B2-astrbot) 。
|
||||||
|
|
||||||
|
### 在 雨云 上部署
|
||||||
|
|
||||||
|
AstrBot 已由雨云官方上架至云应用平台,可一键部署。
|
||||||
|
|
||||||
|
[](https://app.rainyun.com/apps/rca/store/5994?ref=NjU1ODg0)
|
||||||
|
|
||||||
|
### 桌面客户端(Tauri)
|
||||||
|
|
||||||
|
桌面应用仓库 [AstrBot-desktop](https://github.com/AstrBotDevs/AstrBot-desktop)。
|
||||||
|
|
||||||
|
支持多系统架构,安装包直接安装,开箱即用,最适合新手和懒人的一键桌面部署方案,不推荐服务器场景。
|
||||||
|
|
||||||
|
### 启动器一键部署(AstrBot Launcher)
|
||||||
|
|
||||||
|
快速部署和多开方案,实现环境隔离,进入 [AstrBot Launcher](https://github.com/Raven95676/astrbot-launcher) 仓库,在 Releases 页最新版本下找到对应的系统安装包安装即可。
|
||||||
|
|
||||||
|
### 在 Replit 上部署
|
||||||
|
|
||||||
|
社区贡献的部署方式。
|
||||||
|
|
||||||
|
[](https://repl.it/github/AstrBotDevs/AstrBot)
|
||||||
|
|
||||||
|
### AUR
|
||||||
|
|
||||||
|
```bash
|
||||||
|
yay -S astrbot-git
|
||||||
|
```
|
||||||
|
|
||||||
|
**更多部署方式**:[宝塔面板](https://astrbot.app/deploy/astrbot/btpanel.html) | [1Panel](https://astrbot.app/deploy/astrbot/1panel.html) | [CasaOS](https://astrbot.app/deploy/astrbot/casaos.html) | [手动部署](https://astrbot.app/deploy/astrbot/cli.html)
|
||||||
|
|
||||||
|
## 支持的消息平台
|
||||||
|
|
||||||
|
将 AstrBot 连接到你常用的聊天平台。
|
||||||
|
|
||||||
|
| 平台 | 维护方 |
|
||||||
|
|---------|---------------|
|
||||||
|
| **QQ** | 官方维护 |
|
||||||
|
| **OneBot v11** | 官方维护 |
|
||||||
|
| **Telegram** | 官方维护 |
|
||||||
|
| **企微应用 & 企微智能机器人** | 官方维护 |
|
||||||
|
| **微信客服 & 微信公众号** | 官方维护 |
|
||||||
|
| **飞书** | 官方维护 |
|
||||||
|
| **钉钉** | 官方维护 |
|
||||||
|
| **Slack** | 官方维护 |
|
||||||
|
| **Discord** | 官方维护 |
|
||||||
|
| **LINE** | 官方维护 |
|
||||||
|
| **Satori** | 官方维护 |
|
||||||
|
| **Misskey** | 官方维护 |
|
||||||
|
| **Whatsapp (将支持)** | 官方维护 |
|
||||||
|
| [**Matrix**](https://github.com/stevessr/astrbot_plugin_matrix_adapter) | 社区维护 |
|
||||||
|
| [**KOOK**](https://github.com/wuyan1003/astrbot_plugin_kook_adapter) | 社区维护 |
|
||||||
|
| [**VoceChat**](https://github.com/HikariFroya/astrbot_plugin_vocechat) | 社区维护 |
|
||||||
|
|
||||||
|
## 支持的模型提供商
|
||||||
|
|
||||||
|
| 提供商 | 类型 |
|
||||||
|
|---------|---------------|
|
||||||
|
| 自定义 | 任何 OpenAI API 兼容的服务 |
|
||||||
|
| OpenAI | LLM |
|
||||||
|
| Anthropic | LLM |
|
||||||
|
| Google Gemini | LLM |
|
||||||
|
| Moonshot AI | LLM |
|
||||||
|
| 智谱 AI | LLM |
|
||||||
|
| DeepSeek | LLM |
|
||||||
|
| Ollama (本地部署) | LLM |
|
||||||
|
| LM Studio (本地部署) | LLM |
|
||||||
|
| [AIHubMix](https://aihubmix.com/?aff=4bfH) | LLM (API 网关, 支持所有模型) |
|
||||||
|
| [优云智算](https://www.compshare.cn/?ytag=GPU_YY-gh_astrbot&referral_code=FV7DcGowN4hB5UuXKgpE74) | LLM (API 网关, 支持所有模型) |
|
||||||
|
| [硅基流动](https://docs.siliconflow.cn/cn/usercases/use-siliconcloud-in-astrbot) | LLM (API 网关, 支持所有模型) |
|
||||||
|
| [PPIO 派欧云](https://ppio.com/user/register?invited_by=AIOONE) | LLM (API 网关, 支持所有模型) |
|
||||||
|
| [302.AI](https://share.302.ai/rr1M3l) | LLM (API 网关, 支持所有模型)|
|
||||||
|
| [小马算力](https://www.tokenpony.cn/3YPyf) | LLM (API 网关, 支持所有模型)|
|
||||||
|
| ModelScope | LLM |
|
||||||
|
| OneAPI | LLM |
|
||||||
|
| Dify | LLMOps 平台 |
|
||||||
|
| 阿里云百炼应用 | LLMOps 平台 |
|
||||||
|
| Coze | LLMOps 平台 |
|
||||||
|
| OpenAI Whisper | 语音转文本 |
|
||||||
|
| SenseVoice | 语音转文本 |
|
||||||
|
| OpenAI TTS | 文本转语音 |
|
||||||
|
| Gemini TTS | 文本转语音 |
|
||||||
|
| GPT-Sovits-Inference | 文本转语音 |
|
||||||
|
| GPT-Sovits | 文本转语音 |
|
||||||
|
| FishAudio | 文本转语音 |
|
||||||
|
| Edge TTS | 文本转语音 |
|
||||||
|
| 阿里云百炼 TTS | 文本转语音 |
|
||||||
|
| Azure TTS | 文本转语音 |
|
||||||
|
| Minimax TTS | 文本转语音 |
|
||||||
|
| 火山引擎 TTS | 文本转语音 |
|
||||||
|
|
||||||
|
## ❤️ 贡献
|
||||||
|
|
||||||
|
欢迎任何 Issues/Pull Requests!只需要将你的更改提交到此项目 :)
|
||||||
|
|
||||||
|
### 如何贡献
|
||||||
|
|
||||||
|
你可以通过查看问题或帮助审核 PR(拉取请求)来贡献。任何问题或 PR 都欢迎参与,以促进社区贡献。当然,这些只是建议,你可以以任何方式进行贡献。对于新功能的添加,请先通过 Issue 讨论。
|
||||||
|
|
||||||
|
### 开发环境
|
||||||
|
|
||||||
|
AstrBot 使用 `ruff` 进行代码格式化和检查。
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone https://github.com/AstrBotDevs/AstrBot
|
||||||
|
pip install pre-commit
|
||||||
|
pre-commit install
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🌍 社区
|
||||||
|
|
||||||
|
### QQ 群组
|
||||||
|
|
||||||
|
- 1 群:322154837
|
||||||
|
- 3 群:630166526
|
||||||
|
- 5 群:822130018
|
||||||
|
- 6 群:753075035
|
||||||
|
- 7 群:743746109
|
||||||
|
- 8 群:1030353265
|
||||||
|
- 开发者群:975206796
|
||||||
|
|
||||||
|
### Discord 频道
|
||||||
|
|
||||||
|
- [Discord](https://discord.gg/hAVk6tgV36)
|
||||||
|
|
||||||
|
## ❤️ Special Thanks
|
||||||
|
|
||||||
|
特别感谢所有 Contributors 和插件开发者对 AstrBot 的贡献 ❤️
|
||||||
|
|
||||||
|
<a href="https://github.com/AstrBotDevs/AstrBot/graphs/contributors">
|
||||||
|
<img src="https://contrib.rocks/image?repo=AstrBotDevs/AstrBot&max=200&columns=14" />
|
||||||
|
</a>
|
||||||
|
|
||||||
|
此外,本项目的诞生离不开以下开源项目的帮助:
|
||||||
|
|
||||||
|
- [NapNeko/NapCatQQ](https://github.com/NapNeko/NapCatQQ) - 伟大的猫猫框架
|
||||||
|
|
||||||
|
开源项目友情链接:
|
||||||
|
|
||||||
|
- [NoneBot2](https://github.com/nonebot/nonebot2) - 优秀的 Python 异步 ChatBot 框架
|
||||||
|
- [Koishi](https://github.com/koishijs/koishi) - 优秀的 Node.js ChatBot 框架
|
||||||
|
- [MaiBot](https://github.com/Mai-with-u/MaiBot) - 优秀的拟人化 AI ChatBot
|
||||||
|
- [nekro-agent](https://github.com/KroMiose/nekro-agent) - 优秀的 Agent ChatBot
|
||||||
|
- [LangBot](https://github.com/langbot-app/LangBot) - 优秀的多平台 AI ChatBot
|
||||||
|
- [ChatLuna](https://github.com/ChatLunaLab/chatluna) - 优秀的多平台 AI ChatBot Koishi 插件
|
||||||
|
- [Operit AI](https://github.com/AAswordman/Operit) - 优秀的 AI 智能助手 Android APP
|
||||||
|
|
||||||
|
## ⭐ Star History
|
||||||
|
|
||||||
|
> [!TIP]
|
||||||
|
> 如果本项目对您的生活 / 工作产生了帮助,或者您关注本项目的未来发展,请给项目 Star,这是我们维护这个开源项目的动力 <3
|
||||||
|
|
||||||
|
<div align="center">
|
||||||
|
|
||||||
|
[](https://star-history.com/#astrbotdevs/astrbot&Date)
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div align="center">
|
||||||
|
|
||||||
|
_陪伴与能力从来不应该是对立面。我们希望创造的是一个既能理解情绪、给予陪伴,也能可靠完成工作的机器人。_
|
||||||
|
|
||||||
|
_私は、高性能ですから!_
|
||||||
|
|
||||||
|
<img src="https://files.astrbot.app/watashiwa-koseino-desukara.gif" width="100"/>
|
||||||
|
|
||||||
|
</div>
|
||||||
@@ -1,9 +1,10 @@
|
|||||||
|
import asyncio
|
||||||
import copy
|
import copy
|
||||||
import sys
|
import sys
|
||||||
import time
|
import time
|
||||||
import traceback
|
import traceback
|
||||||
import typing as T
|
import typing as T
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass, field
|
||||||
|
|
||||||
from mcp.types import (
|
from mcp.types import (
|
||||||
BlobResourceContents,
|
BlobResourceContents,
|
||||||
@@ -68,6 +69,14 @@ class _HandleFunctionToolsResult:
|
|||||||
return cls(kind="cached_image", cached_image=image)
|
return cls(kind="cached_image", cached_image=image)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True)
|
||||||
|
class FollowUpTicket:
|
||||||
|
seq: int
|
||||||
|
text: str
|
||||||
|
consumed: bool = False
|
||||||
|
resolved: asyncio.Event = field(default_factory=asyncio.Event)
|
||||||
|
|
||||||
|
|
||||||
class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
|
class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
|
||||||
@override
|
@override
|
||||||
async def reset(
|
async def reset(
|
||||||
@@ -139,6 +148,8 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
|
|||||||
self.run_context = run_context
|
self.run_context = run_context
|
||||||
self._stop_requested = False
|
self._stop_requested = False
|
||||||
self._aborted = False
|
self._aborted = False
|
||||||
|
self._pending_follow_ups: list[FollowUpTicket] = []
|
||||||
|
self._follow_up_seq = 0
|
||||||
|
|
||||||
# These two are used for tool schema mode handling
|
# These two are used for tool schema mode handling
|
||||||
# We now have two modes:
|
# We now have two modes:
|
||||||
@@ -277,6 +288,55 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
|
|||||||
roles.append(message.role)
|
roles.append(message.role)
|
||||||
logger.debug(f"{tag} RunCtx.messages -> [{len(roles)}] {','.join(roles)}")
|
logger.debug(f"{tag} RunCtx.messages -> [{len(roles)}] {','.join(roles)}")
|
||||||
|
|
||||||
|
def follow_up(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
message_text: str,
|
||||||
|
) -> FollowUpTicket | None:
|
||||||
|
"""Queue a follow-up message for the next tool result."""
|
||||||
|
if self.done():
|
||||||
|
return None
|
||||||
|
text = (message_text or "").strip()
|
||||||
|
if not text:
|
||||||
|
return None
|
||||||
|
ticket = FollowUpTicket(seq=self._follow_up_seq, text=text)
|
||||||
|
self._follow_up_seq += 1
|
||||||
|
self._pending_follow_ups.append(ticket)
|
||||||
|
return ticket
|
||||||
|
|
||||||
|
def _resolve_unconsumed_follow_ups(self) -> None:
|
||||||
|
if not self._pending_follow_ups:
|
||||||
|
return
|
||||||
|
follow_ups = self._pending_follow_ups
|
||||||
|
self._pending_follow_ups = []
|
||||||
|
for ticket in follow_ups:
|
||||||
|
ticket.resolved.set()
|
||||||
|
|
||||||
|
def _consume_follow_up_notice(self) -> str:
|
||||||
|
if not self._pending_follow_ups:
|
||||||
|
return ""
|
||||||
|
follow_ups = self._pending_follow_ups
|
||||||
|
self._pending_follow_ups = []
|
||||||
|
for ticket in follow_ups:
|
||||||
|
ticket.consumed = True
|
||||||
|
ticket.resolved.set()
|
||||||
|
follow_up_lines = "\n".join(
|
||||||
|
f"{idx}. {ticket.text}" for idx, ticket in enumerate(follow_ups, start=1)
|
||||||
|
)
|
||||||
|
return (
|
||||||
|
"\n\n[SYSTEM NOTICE] User sent follow-up messages while tool execution "
|
||||||
|
"was in progress. Prioritize these follow-up instructions in your next "
|
||||||
|
"actions. In your very next action, briefly acknowledge to the user "
|
||||||
|
"that their follow-up message(s) were received before continuing.\n"
|
||||||
|
f"{follow_up_lines}"
|
||||||
|
)
|
||||||
|
|
||||||
|
def _merge_follow_up_notice(self, content: str) -> str:
|
||||||
|
notice = self._consume_follow_up_notice()
|
||||||
|
if not notice:
|
||||||
|
return content
|
||||||
|
return f"{content}{notice}"
|
||||||
|
|
||||||
@override
|
@override
|
||||||
async def step(self):
|
async def step(self):
|
||||||
"""Process a single step of the agent.
|
"""Process a single step of the agent.
|
||||||
@@ -391,6 +451,7 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
|
|||||||
type="aborted",
|
type="aborted",
|
||||||
data=AgentResponseData(chain=MessageChain(type="aborted")),
|
data=AgentResponseData(chain=MessageChain(type="aborted")),
|
||||||
)
|
)
|
||||||
|
self._resolve_unconsumed_follow_ups()
|
||||||
return
|
return
|
||||||
|
|
||||||
# 处理 LLM 响应
|
# 处理 LLM 响应
|
||||||
@@ -401,6 +462,7 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
|
|||||||
self.final_llm_resp = llm_resp
|
self.final_llm_resp = llm_resp
|
||||||
self.stats.end_time = time.time()
|
self.stats.end_time = time.time()
|
||||||
self._transition_state(AgentState.ERROR)
|
self._transition_state(AgentState.ERROR)
|
||||||
|
self._resolve_unconsumed_follow_ups()
|
||||||
yield AgentResponse(
|
yield AgentResponse(
|
||||||
type="err",
|
type="err",
|
||||||
data=AgentResponseData(
|
data=AgentResponseData(
|
||||||
@@ -439,6 +501,7 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
|
|||||||
await self.agent_hooks.on_agent_done(self.run_context, llm_resp)
|
await self.agent_hooks.on_agent_done(self.run_context, llm_resp)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error in on_agent_done hook: {e}", exc_info=True)
|
logger.error(f"Error in on_agent_done hook: {e}", exc_info=True)
|
||||||
|
self._resolve_unconsumed_follow_ups()
|
||||||
|
|
||||||
# 返回 LLM 结果
|
# 返回 LLM 结果
|
||||||
if llm_resp.result_chain:
|
if llm_resp.result_chain:
|
||||||
@@ -583,6 +646,15 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
|
|||||||
tool_call_result_blocks: list[ToolCallMessageSegment] = []
|
tool_call_result_blocks: list[ToolCallMessageSegment] = []
|
||||||
logger.info(f"Agent 使用工具: {llm_response.tools_call_name}")
|
logger.info(f"Agent 使用工具: {llm_response.tools_call_name}")
|
||||||
|
|
||||||
|
def _append_tool_call_result(tool_call_id: str, content: str) -> None:
|
||||||
|
tool_call_result_blocks.append(
|
||||||
|
ToolCallMessageSegment(
|
||||||
|
role="tool",
|
||||||
|
tool_call_id=tool_call_id,
|
||||||
|
content=self._merge_follow_up_notice(content),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
# 执行函数调用
|
# 执行函数调用
|
||||||
for func_tool_name, func_tool_args, func_tool_id in zip(
|
for func_tool_name, func_tool_args, func_tool_id in zip(
|
||||||
llm_response.tools_call_name,
|
llm_response.tools_call_name,
|
||||||
@@ -622,12 +694,9 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
|
|||||||
|
|
||||||
if not func_tool:
|
if not func_tool:
|
||||||
logger.warning(f"未找到指定的工具: {func_tool_name},将跳过。")
|
logger.warning(f"未找到指定的工具: {func_tool_name},将跳过。")
|
||||||
tool_call_result_blocks.append(
|
_append_tool_call_result(
|
||||||
ToolCallMessageSegment(
|
func_tool_id,
|
||||||
role="tool",
|
f"error: Tool {func_tool_name} not found.",
|
||||||
tool_call_id=func_tool_id,
|
|
||||||
content=f"error: Tool {func_tool_name} not found.",
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
@@ -680,12 +749,9 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
|
|||||||
res = resp
|
res = resp
|
||||||
_final_resp = resp
|
_final_resp = resp
|
||||||
if isinstance(res.content[0], TextContent):
|
if isinstance(res.content[0], TextContent):
|
||||||
tool_call_result_blocks.append(
|
_append_tool_call_result(
|
||||||
ToolCallMessageSegment(
|
func_tool_id,
|
||||||
role="tool",
|
res.content[0].text,
|
||||||
tool_call_id=func_tool_id,
|
|
||||||
content=res.content[0].text,
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
elif isinstance(res.content[0], ImageContent):
|
elif isinstance(res.content[0], ImageContent):
|
||||||
# Cache the image instead of sending directly
|
# Cache the image instead of sending directly
|
||||||
@@ -696,15 +762,12 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
|
|||||||
index=0,
|
index=0,
|
||||||
mime_type=res.content[0].mimeType or "image/png",
|
mime_type=res.content[0].mimeType or "image/png",
|
||||||
)
|
)
|
||||||
tool_call_result_blocks.append(
|
_append_tool_call_result(
|
||||||
ToolCallMessageSegment(
|
func_tool_id,
|
||||||
role="tool",
|
(
|
||||||
tool_call_id=func_tool_id,
|
f"Image returned and cached at path='{cached_img.file_path}'. "
|
||||||
content=(
|
f"Review the image below. Use send_message_to_user to send it to the user if satisfied, "
|
||||||
f"Image returned and cached at path='{cached_img.file_path}'. "
|
f"with type='image' and path='{cached_img.file_path}'."
|
||||||
f"Review the image below. Use send_message_to_user to send it to the user if satisfied, "
|
|
||||||
f"with type='image' and path='{cached_img.file_path}'."
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
# Yield image info for LLM visibility (will be handled in step())
|
# Yield image info for LLM visibility (will be handled in step())
|
||||||
@@ -714,12 +777,9 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
|
|||||||
elif isinstance(res.content[0], EmbeddedResource):
|
elif isinstance(res.content[0], EmbeddedResource):
|
||||||
resource = res.content[0].resource
|
resource = res.content[0].resource
|
||||||
if isinstance(resource, TextResourceContents):
|
if isinstance(resource, TextResourceContents):
|
||||||
tool_call_result_blocks.append(
|
_append_tool_call_result(
|
||||||
ToolCallMessageSegment(
|
func_tool_id,
|
||||||
role="tool",
|
resource.text,
|
||||||
tool_call_id=func_tool_id,
|
|
||||||
content=resource.text,
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
elif (
|
elif (
|
||||||
isinstance(resource, BlobResourceContents)
|
isinstance(resource, BlobResourceContents)
|
||||||
@@ -734,15 +794,12 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
|
|||||||
index=0,
|
index=0,
|
||||||
mime_type=resource.mimeType,
|
mime_type=resource.mimeType,
|
||||||
)
|
)
|
||||||
tool_call_result_blocks.append(
|
_append_tool_call_result(
|
||||||
ToolCallMessageSegment(
|
func_tool_id,
|
||||||
role="tool",
|
(
|
||||||
tool_call_id=func_tool_id,
|
f"Image returned and cached at path='{cached_img.file_path}'. "
|
||||||
content=(
|
f"Review the image below. Use send_message_to_user to send it to the user if satisfied, "
|
||||||
f"Image returned and cached at path='{cached_img.file_path}'. "
|
f"with type='image' and path='{cached_img.file_path}'."
|
||||||
f"Review the image below. Use send_message_to_user to send it to the user if satisfied, "
|
|
||||||
f"with type='image' and path='{cached_img.file_path}'."
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
# Yield image info for LLM visibility
|
# Yield image info for LLM visibility
|
||||||
@@ -750,12 +807,9 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
|
|||||||
cached_img
|
cached_img
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
tool_call_result_blocks.append(
|
_append_tool_call_result(
|
||||||
ToolCallMessageSegment(
|
func_tool_id,
|
||||||
role="tool",
|
"The tool has returned a data type that is not supported.",
|
||||||
tool_call_id=func_tool_id,
|
|
||||||
content="The tool has returned a data type that is not supported.",
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
elif resp is None:
|
elif resp is None:
|
||||||
@@ -767,24 +821,18 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
|
|||||||
)
|
)
|
||||||
self._transition_state(AgentState.DONE)
|
self._transition_state(AgentState.DONE)
|
||||||
self.stats.end_time = time.time()
|
self.stats.end_time = time.time()
|
||||||
tool_call_result_blocks.append(
|
_append_tool_call_result(
|
||||||
ToolCallMessageSegment(
|
func_tool_id,
|
||||||
role="tool",
|
"The tool has no return value, or has sent the result directly to the user.",
|
||||||
tool_call_id=func_tool_id,
|
|
||||||
content="The tool has no return value, or has sent the result directly to the user.",
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
# 不应该出现其他类型
|
# 不应该出现其他类型
|
||||||
logger.warning(
|
logger.warning(
|
||||||
f"Tool 返回了不支持的类型: {type(resp)}。",
|
f"Tool 返回了不支持的类型: {type(resp)}。",
|
||||||
)
|
)
|
||||||
tool_call_result_blocks.append(
|
_append_tool_call_result(
|
||||||
ToolCallMessageSegment(
|
func_tool_id,
|
||||||
role="tool",
|
"*The tool has returned an unsupported type. Please tell the user to check the definition and implementation of this tool.*",
|
||||||
tool_call_id=func_tool_id,
|
|
||||||
content="*The tool has returned an unsupported type. Please tell the user to check the definition and implementation of this tool.*",
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -798,12 +846,9 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
|
|||||||
logger.error(f"Error in on_tool_end hook: {e}", exc_info=True)
|
logger.error(f"Error in on_tool_end hook: {e}", exc_info=True)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(traceback.format_exc())
|
logger.warning(traceback.format_exc())
|
||||||
tool_call_result_blocks.append(
|
_append_tool_call_result(
|
||||||
ToolCallMessageSegment(
|
func_tool_id,
|
||||||
role="tool",
|
f"error: {e!s}",
|
||||||
tool_call_id=func_tool_id,
|
|
||||||
content=f"error: {e!s}",
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# yield the last tool call result
|
# yield the last tool call result
|
||||||
|
|||||||
@@ -0,0 +1,227 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
from astrbot import logger
|
||||||
|
from astrbot.core.agent.runners.tool_loop_agent_runner import FollowUpTicket
|
||||||
|
from astrbot.core.astr_agent_run_util import AgentRunner
|
||||||
|
from astrbot.core.platform.astr_message_event import AstrMessageEvent
|
||||||
|
|
||||||
|
_ACTIVE_AGENT_RUNNERS: dict[str, AgentRunner] = {}
|
||||||
|
_FOLLOW_UP_ORDER_STATE: dict[str, dict[str, object]] = {}
|
||||||
|
"""UMO-level follow-up order state.
|
||||||
|
|
||||||
|
State fields:
|
||||||
|
- `statuses`: seq -> {"pending"|"active"|"consumed"|"finished"}
|
||||||
|
- `next_order`: monotonically increasing sequence allocator
|
||||||
|
- `next_turn`: next sequence allowed to proceed when not consumed
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True)
|
||||||
|
class FollowUpCapture:
|
||||||
|
umo: str
|
||||||
|
ticket: FollowUpTicket
|
||||||
|
order_seq: int
|
||||||
|
monitor_task: asyncio.Task[None]
|
||||||
|
|
||||||
|
|
||||||
|
def _event_follow_up_text(event: AstrMessageEvent) -> str:
|
||||||
|
text = (event.get_message_str() or "").strip()
|
||||||
|
if text:
|
||||||
|
return text
|
||||||
|
return event.get_message_outline().strip()
|
||||||
|
|
||||||
|
|
||||||
|
def register_active_runner(umo: str, runner: AgentRunner) -> None:
|
||||||
|
_ACTIVE_AGENT_RUNNERS[umo] = runner
|
||||||
|
|
||||||
|
|
||||||
|
def unregister_active_runner(umo: str, runner: AgentRunner) -> None:
|
||||||
|
if _ACTIVE_AGENT_RUNNERS.get(umo) is runner:
|
||||||
|
_ACTIVE_AGENT_RUNNERS.pop(umo, None)
|
||||||
|
|
||||||
|
|
||||||
|
def _get_follow_up_order_state(umo: str) -> dict[str, object]:
|
||||||
|
state = _FOLLOW_UP_ORDER_STATE.get(umo)
|
||||||
|
if state is None:
|
||||||
|
state = {
|
||||||
|
"condition": asyncio.Condition(),
|
||||||
|
# Sequence status map for strict in-order resume after unresolved follow-ups.
|
||||||
|
"statuses": {},
|
||||||
|
# Stable allocator for arrival order; never decreases for the same UMO state.
|
||||||
|
"next_order": 0,
|
||||||
|
# The sequence currently allowed to continue main internal flow.
|
||||||
|
"next_turn": 0,
|
||||||
|
}
|
||||||
|
_FOLLOW_UP_ORDER_STATE[umo] = state
|
||||||
|
return state
|
||||||
|
|
||||||
|
|
||||||
|
def _advance_follow_up_turn_locked(state: dict[str, object]) -> None:
|
||||||
|
# Skip slots that are already handled, and stop at the first unfinished slot.
|
||||||
|
statuses = state["statuses"]
|
||||||
|
assert isinstance(statuses, dict)
|
||||||
|
next_turn = state["next_turn"]
|
||||||
|
assert isinstance(next_turn, int)
|
||||||
|
|
||||||
|
while True:
|
||||||
|
curr = statuses.get(next_turn)
|
||||||
|
if curr in ("consumed", "finished"):
|
||||||
|
statuses.pop(next_turn, None)
|
||||||
|
next_turn += 1
|
||||||
|
continue
|
||||||
|
break
|
||||||
|
|
||||||
|
state["next_turn"] = next_turn
|
||||||
|
|
||||||
|
|
||||||
|
def _allocate_follow_up_order(umo: str) -> int:
|
||||||
|
state = _get_follow_up_order_state(umo)
|
||||||
|
next_order = state["next_order"]
|
||||||
|
assert isinstance(next_order, int)
|
||||||
|
seq = next_order
|
||||||
|
state["next_order"] = seq + 1
|
||||||
|
statuses = state["statuses"]
|
||||||
|
assert isinstance(statuses, dict)
|
||||||
|
statuses[seq] = "pending"
|
||||||
|
return seq
|
||||||
|
|
||||||
|
|
||||||
|
async def _mark_follow_up_consumed(umo: str, seq: int) -> None:
|
||||||
|
state = _FOLLOW_UP_ORDER_STATE.get(umo)
|
||||||
|
if not state:
|
||||||
|
return
|
||||||
|
condition = state["condition"]
|
||||||
|
assert isinstance(condition, asyncio.Condition)
|
||||||
|
async with condition:
|
||||||
|
statuses = state["statuses"]
|
||||||
|
assert isinstance(statuses, dict)
|
||||||
|
if seq in statuses and statuses[seq] != "finished":
|
||||||
|
statuses[seq] = "consumed"
|
||||||
|
_advance_follow_up_turn_locked(state)
|
||||||
|
condition.notify_all()
|
||||||
|
|
||||||
|
# Release state only when this UMO has no pending statuses and no active runner.
|
||||||
|
if not statuses and _ACTIVE_AGENT_RUNNERS.get(umo) is None:
|
||||||
|
_FOLLOW_UP_ORDER_STATE.pop(umo, None)
|
||||||
|
|
||||||
|
|
||||||
|
async def _activate_and_wait_follow_up_turn(umo: str, seq: int) -> None:
|
||||||
|
state = _FOLLOW_UP_ORDER_STATE.get(umo)
|
||||||
|
if not state:
|
||||||
|
return
|
||||||
|
condition = state["condition"]
|
||||||
|
assert isinstance(condition, asyncio.Condition)
|
||||||
|
async with condition:
|
||||||
|
statuses = state["statuses"]
|
||||||
|
assert isinstance(statuses, dict)
|
||||||
|
if seq in statuses:
|
||||||
|
statuses[seq] = "active"
|
||||||
|
|
||||||
|
# Strict ordering: only the head (`next_turn`) can continue.
|
||||||
|
while True:
|
||||||
|
next_turn = state["next_turn"]
|
||||||
|
assert isinstance(next_turn, int)
|
||||||
|
if next_turn == seq:
|
||||||
|
break
|
||||||
|
await condition.wait()
|
||||||
|
|
||||||
|
|
||||||
|
async def _finish_follow_up_turn(umo: str, seq: int) -> None:
|
||||||
|
state = _FOLLOW_UP_ORDER_STATE.get(umo)
|
||||||
|
if not state:
|
||||||
|
return
|
||||||
|
condition = state["condition"]
|
||||||
|
assert isinstance(condition, asyncio.Condition)
|
||||||
|
async with condition:
|
||||||
|
statuses = state["statuses"]
|
||||||
|
assert isinstance(statuses, dict)
|
||||||
|
if seq in statuses:
|
||||||
|
statuses[seq] = "finished"
|
||||||
|
_advance_follow_up_turn_locked(state)
|
||||||
|
condition.notify_all()
|
||||||
|
|
||||||
|
if not statuses and _ACTIVE_AGENT_RUNNERS.get(umo) is None:
|
||||||
|
_FOLLOW_UP_ORDER_STATE.pop(umo, None)
|
||||||
|
|
||||||
|
|
||||||
|
async def _monitor_follow_up_ticket(
|
||||||
|
umo: str,
|
||||||
|
ticket: FollowUpTicket,
|
||||||
|
order_seq: int,
|
||||||
|
) -> None:
|
||||||
|
"""Advance consumed slots immediately on resolution to avoid wake-order drift."""
|
||||||
|
await ticket.resolved.wait()
|
||||||
|
if ticket.consumed:
|
||||||
|
await _mark_follow_up_consumed(umo, order_seq)
|
||||||
|
|
||||||
|
|
||||||
|
def try_capture_follow_up(event: AstrMessageEvent) -> FollowUpCapture | None:
|
||||||
|
sender_id = event.get_sender_id()
|
||||||
|
if not sender_id:
|
||||||
|
return None
|
||||||
|
runner = _ACTIVE_AGENT_RUNNERS.get(event.unified_msg_origin)
|
||||||
|
if not runner:
|
||||||
|
return None
|
||||||
|
runner_event = getattr(getattr(runner.run_context, "context", None), "event", None)
|
||||||
|
if runner_event is None:
|
||||||
|
return None
|
||||||
|
active_sender_id = runner_event.get_sender_id()
|
||||||
|
if not active_sender_id or active_sender_id != sender_id:
|
||||||
|
return None
|
||||||
|
|
||||||
|
ticket = runner.follow_up(message_text=_event_follow_up_text(event))
|
||||||
|
if not ticket:
|
||||||
|
return None
|
||||||
|
# Allocate strict order at capture time (arrival order), not at wake time.
|
||||||
|
order_seq = _allocate_follow_up_order(event.unified_msg_origin)
|
||||||
|
monitor_task = asyncio.create_task(
|
||||||
|
_monitor_follow_up_ticket(
|
||||||
|
event.unified_msg_origin,
|
||||||
|
ticket,
|
||||||
|
order_seq,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
logger.info(
|
||||||
|
"Captured follow-up message for active agent run, umo=%s, order_seq=%s",
|
||||||
|
event.unified_msg_origin,
|
||||||
|
order_seq,
|
||||||
|
)
|
||||||
|
return FollowUpCapture(
|
||||||
|
umo=event.unified_msg_origin,
|
||||||
|
ticket=ticket,
|
||||||
|
order_seq=order_seq,
|
||||||
|
monitor_task=monitor_task,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def prepare_follow_up_capture(capture: FollowUpCapture) -> tuple[bool, bool]:
|
||||||
|
"""Return `(consumed_marked, activated)` for internal stage branch handling."""
|
||||||
|
await capture.ticket.resolved.wait()
|
||||||
|
if capture.ticket.consumed:
|
||||||
|
await _mark_follow_up_consumed(capture.umo, capture.order_seq)
|
||||||
|
return True, False
|
||||||
|
await _activate_and_wait_follow_up_turn(capture.umo, capture.order_seq)
|
||||||
|
return False, True
|
||||||
|
|
||||||
|
|
||||||
|
async def finalize_follow_up_capture(
|
||||||
|
capture: FollowUpCapture,
|
||||||
|
*,
|
||||||
|
activated: bool,
|
||||||
|
consumed_marked: bool,
|
||||||
|
) -> None:
|
||||||
|
# Best-effort cancellation: monitor task is auxiliary and should not leak.
|
||||||
|
if not capture.monitor_task.done():
|
||||||
|
capture.monitor_task.cancel()
|
||||||
|
try:
|
||||||
|
await capture.monitor_task
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
if activated:
|
||||||
|
await _finish_follow_up_turn(capture.umo, capture.order_seq)
|
||||||
|
elif not consumed_marked:
|
||||||
|
await _mark_follow_up_consumed(capture.umo, capture.order_seq)
|
||||||
@@ -29,8 +29,16 @@ from astrbot.core.star.star_handler import EventType
|
|||||||
from astrbot.core.utils.metrics import Metric
|
from astrbot.core.utils.metrics import Metric
|
||||||
from astrbot.core.utils.session_lock import session_lock_manager
|
from astrbot.core.utils.session_lock import session_lock_manager
|
||||||
|
|
||||||
from .....astr_agent_run_util import run_agent, run_live_agent
|
from .....astr_agent_run_util import AgentRunner, run_agent, run_live_agent
|
||||||
from ....context import PipelineContext, call_event_hook
|
from ....context import PipelineContext, call_event_hook
|
||||||
|
from ...follow_up import (
|
||||||
|
FollowUpCapture,
|
||||||
|
finalize_follow_up_capture,
|
||||||
|
prepare_follow_up_capture,
|
||||||
|
register_active_runner,
|
||||||
|
try_capture_follow_up,
|
||||||
|
unregister_active_runner,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class InternalAgentSubStage(Stage):
|
class InternalAgentSubStage(Stage):
|
||||||
@@ -130,6 +138,9 @@ class InternalAgentSubStage(Stage):
|
|||||||
async def process(
|
async def process(
|
||||||
self, event: AstrMessageEvent, provider_wake_prefix: str
|
self, event: AstrMessageEvent, provider_wake_prefix: str
|
||||||
) -> AsyncGenerator[None, None]:
|
) -> AsyncGenerator[None, None]:
|
||||||
|
follow_up_capture: FollowUpCapture | None = None
|
||||||
|
follow_up_consumed_marked = False
|
||||||
|
follow_up_activated = False
|
||||||
try:
|
try:
|
||||||
streaming_response = self.streaming_response
|
streaming_response = self.streaming_response
|
||||||
if (enable_streaming := event.get_extra("enable_streaming")) is not None:
|
if (enable_streaming := event.get_extra("enable_streaming")) is not None:
|
||||||
@@ -150,188 +161,208 @@ class InternalAgentSubStage(Stage):
|
|||||||
return
|
return
|
||||||
|
|
||||||
logger.debug("ready to request llm provider")
|
logger.debug("ready to request llm provider")
|
||||||
|
follow_up_capture = try_capture_follow_up(event)
|
||||||
|
if follow_up_capture:
|
||||||
|
(
|
||||||
|
follow_up_consumed_marked,
|
||||||
|
follow_up_activated,
|
||||||
|
) = await prepare_follow_up_capture(follow_up_capture)
|
||||||
|
if follow_up_consumed_marked:
|
||||||
|
logger.info(
|
||||||
|
"Follow-up ticket already consumed, stopping processing. umo=%s, seq=%s",
|
||||||
|
event.unified_msg_origin,
|
||||||
|
follow_up_capture.ticket.seq,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
await event.send_typing()
|
await event.send_typing()
|
||||||
await call_event_hook(event, EventType.OnWaitingLLMRequestEvent)
|
await call_event_hook(event, EventType.OnWaitingLLMRequestEvent)
|
||||||
|
|
||||||
async with session_lock_manager.acquire_lock(event.unified_msg_origin):
|
async with session_lock_manager.acquire_lock(event.unified_msg_origin):
|
||||||
logger.debug("acquired session lock for llm request")
|
logger.debug("acquired session lock for llm request")
|
||||||
|
agent_runner: AgentRunner | None = None
|
||||||
|
runner_registered = False
|
||||||
|
try:
|
||||||
|
build_cfg = replace(
|
||||||
|
self.main_agent_cfg,
|
||||||
|
provider_wake_prefix=provider_wake_prefix,
|
||||||
|
streaming_response=streaming_response,
|
||||||
|
)
|
||||||
|
|
||||||
build_cfg = replace(
|
build_result: MainAgentBuildResult | None = await build_main_agent(
|
||||||
self.main_agent_cfg,
|
event=event,
|
||||||
provider_wake_prefix=provider_wake_prefix,
|
plugin_context=self.ctx.plugin_manager.context,
|
||||||
streaming_response=streaming_response,
|
config=build_cfg,
|
||||||
)
|
apply_reset=False,
|
||||||
|
)
|
||||||
|
|
||||||
build_result: MainAgentBuildResult | None = await build_main_agent(
|
if build_result is None:
|
||||||
event=event,
|
|
||||||
plugin_context=self.ctx.plugin_manager.context,
|
|
||||||
config=build_cfg,
|
|
||||||
apply_reset=False,
|
|
||||||
)
|
|
||||||
|
|
||||||
if build_result is None:
|
|
||||||
return
|
|
||||||
|
|
||||||
agent_runner = build_result.agent_runner
|
|
||||||
req = build_result.provider_request
|
|
||||||
provider = build_result.provider
|
|
||||||
reset_coro = build_result.reset_coro
|
|
||||||
|
|
||||||
api_base = provider.provider_config.get("api_base", "")
|
|
||||||
for host in decoded_blocked:
|
|
||||||
if host in api_base:
|
|
||||||
logger.error(
|
|
||||||
"Provider API base %s is blocked due to security reasons. Please use another ai provider.",
|
|
||||||
api_base,
|
|
||||||
)
|
|
||||||
return
|
return
|
||||||
|
|
||||||
stream_to_general = (
|
agent_runner = build_result.agent_runner
|
||||||
self.unsupported_streaming_strategy == "turn_off"
|
req = build_result.provider_request
|
||||||
and not event.platform_meta.support_streaming_message
|
provider = build_result.provider
|
||||||
)
|
reset_coro = build_result.reset_coro
|
||||||
|
|
||||||
if await call_event_hook(event, EventType.OnLLMRequestEvent, req):
|
api_base = provider.provider_config.get("api_base", "")
|
||||||
|
for host in decoded_blocked:
|
||||||
|
if host in api_base:
|
||||||
|
logger.error(
|
||||||
|
"Provider API base %s is blocked due to security reasons. Please use another ai provider.",
|
||||||
|
api_base,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
stream_to_general = (
|
||||||
|
self.unsupported_streaming_strategy == "turn_off"
|
||||||
|
and not event.platform_meta.support_streaming_message
|
||||||
|
)
|
||||||
|
|
||||||
|
if await call_event_hook(event, EventType.OnLLMRequestEvent, req):
|
||||||
|
if reset_coro:
|
||||||
|
reset_coro.close()
|
||||||
|
return
|
||||||
|
|
||||||
|
# apply reset
|
||||||
if reset_coro:
|
if reset_coro:
|
||||||
reset_coro.close()
|
await reset_coro
|
||||||
return
|
|
||||||
|
|
||||||
# apply reset
|
register_active_runner(event.unified_msg_origin, agent_runner)
|
||||||
if reset_coro:
|
runner_registered = True
|
||||||
await reset_coro
|
action_type = event.get_extra("action_type")
|
||||||
|
|
||||||
action_type = event.get_extra("action_type")
|
event.trace.record(
|
||||||
|
"astr_agent_prepare",
|
||||||
event.trace.record(
|
system_prompt=req.system_prompt,
|
||||||
"astr_agent_prepare",
|
tools=req.func_tool.names() if req.func_tool else [],
|
||||||
system_prompt=req.system_prompt,
|
stream=streaming_response,
|
||||||
tools=req.func_tool.names() if req.func_tool else [],
|
chat_provider={
|
||||||
stream=streaming_response,
|
"id": provider.provider_config.get("id", ""),
|
||||||
chat_provider={
|
"model": provider.get_model(),
|
||||||
"id": provider.provider_config.get("id", ""),
|
},
|
||||||
"model": provider.get_model(),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
# 检测 Live Mode
|
|
||||||
if action_type == "live":
|
|
||||||
# Live Mode: 使用 run_live_agent
|
|
||||||
logger.info("[Internal Agent] 检测到 Live Mode,启用 TTS 处理")
|
|
||||||
|
|
||||||
# 获取 TTS Provider
|
|
||||||
tts_provider = (
|
|
||||||
self.ctx.plugin_manager.context.get_using_tts_provider(
|
|
||||||
event.unified_msg_origin
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
|
|
||||||
if not tts_provider:
|
# 检测 Live Mode
|
||||||
logger.warning(
|
if action_type == "live":
|
||||||
"[Live Mode] TTS Provider 未配置,将使用普通流式模式"
|
# Live Mode: 使用 run_live_agent
|
||||||
|
logger.info("[Internal Agent] 检测到 Live Mode,启用 TTS 处理")
|
||||||
|
|
||||||
|
# 获取 TTS Provider
|
||||||
|
tts_provider = (
|
||||||
|
self.ctx.plugin_manager.context.get_using_tts_provider(
|
||||||
|
event.unified_msg_origin
|
||||||
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
# 使用 run_live_agent,总是使用流式响应
|
if not tts_provider:
|
||||||
event.set_result(
|
logger.warning(
|
||||||
MessageEventResult()
|
"[Live Mode] TTS Provider 未配置,将使用普通流式模式"
|
||||||
.set_result_content_type(ResultContentType.STREAMING_RESULT)
|
)
|
||||||
.set_async_stream(
|
|
||||||
run_live_agent(
|
# 使用 run_live_agent,总是使用流式响应
|
||||||
agent_runner,
|
event.set_result(
|
||||||
tts_provider,
|
MessageEventResult()
|
||||||
self.max_step,
|
.set_result_content_type(ResultContentType.STREAMING_RESULT)
|
||||||
self.show_tool_use,
|
.set_async_stream(
|
||||||
self.show_tool_call_result,
|
run_live_agent(
|
||||||
show_reasoning=self.show_reasoning,
|
agent_runner,
|
||||||
|
tts_provider,
|
||||||
|
self.max_step,
|
||||||
|
self.show_tool_use,
|
||||||
|
self.show_tool_call_result,
|
||||||
|
show_reasoning=self.show_reasoning,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
)
|
||||||
)
|
yield
|
||||||
yield
|
|
||||||
|
|
||||||
# 保存历史记录
|
# 保存历史记录
|
||||||
if agent_runner.done() and (
|
if agent_runner.done() and (
|
||||||
not event.is_stopped() or agent_runner.was_aborted()
|
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:
|
||||||
|
# 流式响应
|
||||||
|
event.set_result(
|
||||||
|
MessageEventResult()
|
||||||
|
.set_result_content_type(ResultContentType.STREAMING_RESULT)
|
||||||
|
.set_async_stream(
|
||||||
|
run_agent(
|
||||||
|
agent_runner,
|
||||||
|
self.max_step,
|
||||||
|
self.show_tool_use,
|
||||||
|
self.show_tool_call_result,
|
||||||
|
show_reasoning=self.show_reasoning,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
yield
|
||||||
|
if agent_runner.done():
|
||||||
|
if final_llm_resp := agent_runner.get_final_llm_resp():
|
||||||
|
if final_llm_resp.completion_text:
|
||||||
|
chain = (
|
||||||
|
MessageChain()
|
||||||
|
.message(final_llm_resp.completion_text)
|
||||||
|
.chain
|
||||||
|
)
|
||||||
|
elif final_llm_resp.result_chain:
|
||||||
|
chain = final_llm_resp.result_chain.chain
|
||||||
|
else:
|
||||||
|
chain = MessageChain().chain
|
||||||
|
event.set_result(
|
||||||
|
MessageEventResult(
|
||||||
|
chain=chain,
|
||||||
|
result_content_type=ResultContentType.STREAMING_FINISH,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
async for _ in run_agent(
|
||||||
|
agent_runner,
|
||||||
|
self.max_step,
|
||||||
|
self.show_tool_use,
|
||||||
|
self.show_tool_call_result,
|
||||||
|
stream_to_general,
|
||||||
|
show_reasoning=self.show_reasoning,
|
||||||
|
):
|
||||||
|
yield
|
||||||
|
|
||||||
|
final_resp = agent_runner.get_final_llm_resp()
|
||||||
|
|
||||||
|
event.trace.record(
|
||||||
|
"astr_agent_complete",
|
||||||
|
stats=agent_runner.stats.to_dict(),
|
||||||
|
resp=final_resp.completion_text if final_resp else None,
|
||||||
|
)
|
||||||
|
|
||||||
|
# 检查事件是否被停止,如果被停止则不保存历史记录
|
||||||
|
if not event.is_stopped() or agent_runner.was_aborted():
|
||||||
await self._save_to_history(
|
await self._save_to_history(
|
||||||
event,
|
event,
|
||||||
req,
|
req,
|
||||||
agent_runner.get_final_llm_resp(),
|
final_resp,
|
||||||
agent_runner.run_context.messages,
|
agent_runner.run_context.messages,
|
||||||
agent_runner.stats,
|
agent_runner.stats,
|
||||||
user_aborted=agent_runner.was_aborted(),
|
user_aborted=agent_runner.was_aborted(),
|
||||||
)
|
)
|
||||||
|
|
||||||
elif streaming_response and not stream_to_general:
|
asyncio.create_task(
|
||||||
# 流式响应
|
Metric.upload(
|
||||||
event.set_result(
|
llm_tick=1,
|
||||||
MessageEventResult()
|
model_name=agent_runner.provider.get_model(),
|
||||||
.set_result_content_type(ResultContentType.STREAMING_RESULT)
|
provider_type=agent_runner.provider.meta().type,
|
||||||
.set_async_stream(
|
|
||||||
run_agent(
|
|
||||||
agent_runner,
|
|
||||||
self.max_step,
|
|
||||||
self.show_tool_use,
|
|
||||||
self.show_tool_call_result,
|
|
||||||
show_reasoning=self.show_reasoning,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
yield
|
finally:
|
||||||
if agent_runner.done():
|
if runner_registered and agent_runner is not None:
|
||||||
if final_llm_resp := agent_runner.get_final_llm_resp():
|
unregister_active_runner(event.unified_msg_origin, agent_runner)
|
||||||
if final_llm_resp.completion_text:
|
|
||||||
chain = (
|
|
||||||
MessageChain()
|
|
||||||
.message(final_llm_resp.completion_text)
|
|
||||||
.chain
|
|
||||||
)
|
|
||||||
elif final_llm_resp.result_chain:
|
|
||||||
chain = final_llm_resp.result_chain.chain
|
|
||||||
else:
|
|
||||||
chain = MessageChain().chain
|
|
||||||
event.set_result(
|
|
||||||
MessageEventResult(
|
|
||||||
chain=chain,
|
|
||||||
result_content_type=ResultContentType.STREAMING_FINISH,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
async for _ in run_agent(
|
|
||||||
agent_runner,
|
|
||||||
self.max_step,
|
|
||||||
self.show_tool_use,
|
|
||||||
self.show_tool_call_result,
|
|
||||||
stream_to_general,
|
|
||||||
show_reasoning=self.show_reasoning,
|
|
||||||
):
|
|
||||||
yield
|
|
||||||
|
|
||||||
final_resp = agent_runner.get_final_llm_resp()
|
|
||||||
|
|
||||||
event.trace.record(
|
|
||||||
"astr_agent_complete",
|
|
||||||
stats=agent_runner.stats.to_dict(),
|
|
||||||
resp=final_resp.completion_text if final_resp else None,
|
|
||||||
)
|
|
||||||
|
|
||||||
# 检查事件是否被停止,如果被停止则不保存历史记录
|
|
||||||
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(
|
|
||||||
Metric.upload(
|
|
||||||
llm_tick=1,
|
|
||||||
model_name=agent_runner.provider.get_model(),
|
|
||||||
provider_type=agent_runner.provider.meta().type,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error occurred while processing agent: {e}")
|
logger.error(f"Error occurred while processing agent: {e}")
|
||||||
@@ -340,6 +371,13 @@ class InternalAgentSubStage(Stage):
|
|||||||
f"Error occurred while processing agent request: {e}"
|
f"Error occurred while processing agent request: {e}"
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
finally:
|
||||||
|
if follow_up_capture:
|
||||||
|
await finalize_follow_up_capture(
|
||||||
|
follow_up_capture,
|
||||||
|
activated=follow_up_activated,
|
||||||
|
consumed_marked=follow_up_consumed_marked,
|
||||||
|
)
|
||||||
|
|
||||||
async def _save_to_history(
|
async def _save_to_history(
|
||||||
self,
|
self,
|
||||||
|
|||||||
@@ -0,0 +1,465 @@
|
|||||||
|
import json
|
||||||
|
import mimetypes
|
||||||
|
import shutil
|
||||||
|
import uuid
|
||||||
|
from collections.abc import Awaitable, Callable, Sequence
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from astrbot.core.db.po import Attachment
|
||||||
|
from astrbot.core.message.components import (
|
||||||
|
File,
|
||||||
|
Image,
|
||||||
|
Json,
|
||||||
|
Plain,
|
||||||
|
Record,
|
||||||
|
Reply,
|
||||||
|
Video,
|
||||||
|
)
|
||||||
|
from astrbot.core.message.message_event_result import MessageChain
|
||||||
|
|
||||||
|
AttachmentGetter = Callable[[str], Awaitable[Attachment | None]]
|
||||||
|
AttachmentInserter = Callable[[str, str, str], Awaitable[Attachment | None]]
|
||||||
|
ReplyHistoryGetter = Callable[
|
||||||
|
[Any],
|
||||||
|
Awaitable[tuple[list[dict], str | None, str | None] | None],
|
||||||
|
]
|
||||||
|
|
||||||
|
MEDIA_PART_TYPES = {"image", "record", "file", "video"}
|
||||||
|
|
||||||
|
|
||||||
|
def strip_message_parts_path_fields(message_parts: list[dict]) -> list[dict]:
|
||||||
|
return [{k: v for k, v in part.items() if k != "path"} for part in message_parts]
|
||||||
|
|
||||||
|
|
||||||
|
def webchat_message_parts_have_content(message_parts: list[dict]) -> bool:
|
||||||
|
return any(
|
||||||
|
part.get("type") in ("plain", "image", "record", "file", "video")
|
||||||
|
and (part.get("text") or part.get("attachment_id") or part.get("filename"))
|
||||||
|
for part in message_parts
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def parse_webchat_message_parts(
|
||||||
|
message_parts: list,
|
||||||
|
*,
|
||||||
|
strict: bool = False,
|
||||||
|
include_empty_plain: bool = False,
|
||||||
|
verify_media_path_exists: bool = True,
|
||||||
|
reply_history_getter: ReplyHistoryGetter | None = None,
|
||||||
|
current_depth: int = 0,
|
||||||
|
max_reply_depth: int = 0,
|
||||||
|
cast_reply_id_to_str: bool = True,
|
||||||
|
) -> tuple[list, list[str], bool]:
|
||||||
|
"""Parse webchat message parts into components/text parts.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
tuple[list, list[str], bool]:
|
||||||
|
(components, plain_text_parts, has_non_reply_content)
|
||||||
|
"""
|
||||||
|
components = []
|
||||||
|
text_parts: list[str] = []
|
||||||
|
has_content = False
|
||||||
|
|
||||||
|
for part in message_parts:
|
||||||
|
if not isinstance(part, dict):
|
||||||
|
if strict:
|
||||||
|
raise ValueError("message part must be an object")
|
||||||
|
continue
|
||||||
|
|
||||||
|
part_type = str(part.get("type", "")).strip()
|
||||||
|
if part_type == "plain":
|
||||||
|
text = str(part.get("text", ""))
|
||||||
|
if text or include_empty_plain:
|
||||||
|
components.append(Plain(text=text))
|
||||||
|
text_parts.append(text)
|
||||||
|
if text:
|
||||||
|
has_content = True
|
||||||
|
continue
|
||||||
|
|
||||||
|
if part_type == "reply":
|
||||||
|
message_id = part.get("message_id")
|
||||||
|
if message_id is None:
|
||||||
|
if strict:
|
||||||
|
raise ValueError("reply part missing message_id")
|
||||||
|
continue
|
||||||
|
|
||||||
|
reply_chain = []
|
||||||
|
reply_message_str = str(part.get("selected_text", ""))
|
||||||
|
sender_id = None
|
||||||
|
sender_name = None
|
||||||
|
|
||||||
|
if reply_message_str:
|
||||||
|
reply_chain = [Plain(text=reply_message_str)]
|
||||||
|
elif (
|
||||||
|
reply_history_getter
|
||||||
|
and current_depth < max_reply_depth
|
||||||
|
and message_id is not None
|
||||||
|
):
|
||||||
|
reply_info = await reply_history_getter(message_id)
|
||||||
|
if reply_info:
|
||||||
|
reply_parts, sender_id, sender_name = reply_info
|
||||||
|
(
|
||||||
|
reply_chain,
|
||||||
|
reply_text_parts,
|
||||||
|
_,
|
||||||
|
) = await parse_webchat_message_parts(
|
||||||
|
reply_parts,
|
||||||
|
strict=strict,
|
||||||
|
include_empty_plain=include_empty_plain,
|
||||||
|
verify_media_path_exists=verify_media_path_exists,
|
||||||
|
reply_history_getter=reply_history_getter,
|
||||||
|
current_depth=current_depth + 1,
|
||||||
|
max_reply_depth=max_reply_depth,
|
||||||
|
cast_reply_id_to_str=cast_reply_id_to_str,
|
||||||
|
)
|
||||||
|
reply_message_str = "".join(reply_text_parts)
|
||||||
|
|
||||||
|
reply_id = str(message_id) if cast_reply_id_to_str else message_id
|
||||||
|
components.append(
|
||||||
|
Reply(
|
||||||
|
id=reply_id,
|
||||||
|
message_str=reply_message_str,
|
||||||
|
chain=reply_chain,
|
||||||
|
sender_id=sender_id,
|
||||||
|
sender_nickname=sender_name,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
if part_type not in MEDIA_PART_TYPES:
|
||||||
|
if strict:
|
||||||
|
raise ValueError(f"unsupported message part type: {part_type}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
path = part.get("path")
|
||||||
|
if not path:
|
||||||
|
if strict:
|
||||||
|
raise ValueError(f"{part_type} part missing path")
|
||||||
|
continue
|
||||||
|
|
||||||
|
file_path = Path(str(path))
|
||||||
|
if verify_media_path_exists and not file_path.exists():
|
||||||
|
if strict:
|
||||||
|
raise ValueError(f"file not found: {file_path!s}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
file_path_str = (
|
||||||
|
str(file_path.resolve()) if verify_media_path_exists else str(file_path)
|
||||||
|
)
|
||||||
|
has_content = True
|
||||||
|
if part_type == "image":
|
||||||
|
components.append(Image.fromFileSystem(file_path_str))
|
||||||
|
elif part_type == "record":
|
||||||
|
components.append(Record.fromFileSystem(file_path_str))
|
||||||
|
elif part_type == "video":
|
||||||
|
components.append(Video.fromFileSystem(file_path_str))
|
||||||
|
else:
|
||||||
|
filename = str(part.get("filename", "")).strip() or file_path.name
|
||||||
|
components.append(File(name=filename, file=file_path_str))
|
||||||
|
|
||||||
|
return components, text_parts, has_content
|
||||||
|
|
||||||
|
|
||||||
|
async def build_webchat_message_parts(
|
||||||
|
message_payload: str | list,
|
||||||
|
*,
|
||||||
|
get_attachment_by_id: AttachmentGetter,
|
||||||
|
strict: bool = False,
|
||||||
|
) -> list[dict]:
|
||||||
|
if isinstance(message_payload, str):
|
||||||
|
text = message_payload.strip()
|
||||||
|
return [{"type": "plain", "text": text}] if text else []
|
||||||
|
|
||||||
|
if not isinstance(message_payload, list):
|
||||||
|
if strict:
|
||||||
|
raise ValueError("message must be a string or list")
|
||||||
|
return []
|
||||||
|
|
||||||
|
message_parts: list[dict] = []
|
||||||
|
for part in message_payload:
|
||||||
|
if not isinstance(part, dict):
|
||||||
|
if strict:
|
||||||
|
raise ValueError("message part must be an object")
|
||||||
|
continue
|
||||||
|
|
||||||
|
part_type = str(part.get("type", "")).strip()
|
||||||
|
if part_type == "plain":
|
||||||
|
text = str(part.get("text", ""))
|
||||||
|
if text:
|
||||||
|
message_parts.append({"type": "plain", "text": text})
|
||||||
|
continue
|
||||||
|
|
||||||
|
if part_type == "reply":
|
||||||
|
message_id = part.get("message_id")
|
||||||
|
if message_id is None:
|
||||||
|
if strict:
|
||||||
|
raise ValueError("reply part missing message_id")
|
||||||
|
continue
|
||||||
|
message_parts.append(
|
||||||
|
{
|
||||||
|
"type": "reply",
|
||||||
|
"message_id": message_id,
|
||||||
|
"selected_text": str(part.get("selected_text", "")),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
if part_type not in MEDIA_PART_TYPES:
|
||||||
|
if strict:
|
||||||
|
raise ValueError(f"unsupported message part type: {part_type}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
attachment_id = part.get("attachment_id")
|
||||||
|
if not attachment_id:
|
||||||
|
if strict:
|
||||||
|
raise ValueError(f"{part_type} part missing attachment_id")
|
||||||
|
continue
|
||||||
|
|
||||||
|
attachment = await get_attachment_by_id(str(attachment_id))
|
||||||
|
if not attachment:
|
||||||
|
if strict:
|
||||||
|
raise ValueError(f"attachment not found: {attachment_id}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
attachment_path = Path(attachment.path)
|
||||||
|
message_parts.append(
|
||||||
|
{
|
||||||
|
"type": attachment.type,
|
||||||
|
"attachment_id": attachment.attachment_id,
|
||||||
|
"filename": attachment_path.name,
|
||||||
|
"path": str(attachment_path),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return message_parts
|
||||||
|
|
||||||
|
|
||||||
|
def webchat_message_parts_to_message_chain(
|
||||||
|
message_parts: list[dict],
|
||||||
|
*,
|
||||||
|
strict: bool = False,
|
||||||
|
) -> MessageChain:
|
||||||
|
components = []
|
||||||
|
has_content = False
|
||||||
|
|
||||||
|
for part in message_parts:
|
||||||
|
if not isinstance(part, dict):
|
||||||
|
if strict:
|
||||||
|
raise ValueError("message part must be an object")
|
||||||
|
continue
|
||||||
|
|
||||||
|
part_type = str(part.get("type", "")).strip()
|
||||||
|
if part_type == "plain":
|
||||||
|
text = str(part.get("text", ""))
|
||||||
|
if text:
|
||||||
|
components.append(Plain(text=text))
|
||||||
|
has_content = True
|
||||||
|
continue
|
||||||
|
|
||||||
|
if part_type == "reply":
|
||||||
|
message_id = part.get("message_id")
|
||||||
|
if message_id is None:
|
||||||
|
if strict:
|
||||||
|
raise ValueError("reply part missing message_id")
|
||||||
|
continue
|
||||||
|
components.append(
|
||||||
|
Reply(
|
||||||
|
id=str(message_id),
|
||||||
|
message_str=str(part.get("selected_text", "")),
|
||||||
|
chain=[],
|
||||||
|
)
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
if part_type not in MEDIA_PART_TYPES:
|
||||||
|
if strict:
|
||||||
|
raise ValueError(f"unsupported message part type: {part_type}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
path = part.get("path")
|
||||||
|
if not path:
|
||||||
|
if strict:
|
||||||
|
raise ValueError(f"{part_type} part missing path")
|
||||||
|
continue
|
||||||
|
|
||||||
|
file_path = Path(str(path))
|
||||||
|
if not file_path.exists():
|
||||||
|
if strict:
|
||||||
|
raise ValueError(f"file not found: {file_path!s}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
file_path_str = str(file_path.resolve())
|
||||||
|
has_content = True
|
||||||
|
if part_type == "image":
|
||||||
|
components.append(Image.fromFileSystem(file_path_str))
|
||||||
|
elif part_type == "record":
|
||||||
|
components.append(Record.fromFileSystem(file_path_str))
|
||||||
|
elif part_type == "video":
|
||||||
|
components.append(Video.fromFileSystem(file_path_str))
|
||||||
|
else:
|
||||||
|
filename = str(part.get("filename", "")).strip() or file_path.name
|
||||||
|
components.append(File(name=filename, file=file_path_str))
|
||||||
|
|
||||||
|
if strict and (not components or not has_content):
|
||||||
|
raise ValueError("Message content is empty (reply only is not allowed)")
|
||||||
|
|
||||||
|
return MessageChain(chain=components)
|
||||||
|
|
||||||
|
|
||||||
|
async def build_message_chain_from_payload(
|
||||||
|
message_payload: str | list,
|
||||||
|
*,
|
||||||
|
get_attachment_by_id: AttachmentGetter,
|
||||||
|
strict: bool = True,
|
||||||
|
) -> MessageChain:
|
||||||
|
message_parts = await build_webchat_message_parts(
|
||||||
|
message_payload,
|
||||||
|
get_attachment_by_id=get_attachment_by_id,
|
||||||
|
strict=strict,
|
||||||
|
)
|
||||||
|
components, _, has_content = await parse_webchat_message_parts(
|
||||||
|
message_parts,
|
||||||
|
strict=strict,
|
||||||
|
)
|
||||||
|
if strict and (not components or not has_content):
|
||||||
|
raise ValueError("Message content is empty (reply only is not allowed)")
|
||||||
|
return MessageChain(chain=components)
|
||||||
|
|
||||||
|
|
||||||
|
async def create_attachment_part_from_existing_file(
|
||||||
|
filename: str,
|
||||||
|
*,
|
||||||
|
attach_type: str,
|
||||||
|
insert_attachment: AttachmentInserter,
|
||||||
|
attachments_dir: str | Path,
|
||||||
|
fallback_dirs: Sequence[str | Path] = (),
|
||||||
|
) -> dict | None:
|
||||||
|
basename = Path(filename).name
|
||||||
|
candidate_paths = [Path(attachments_dir) / basename]
|
||||||
|
candidate_paths.extend(Path(p) / basename for p in fallback_dirs)
|
||||||
|
|
||||||
|
file_path = next((path for path in candidate_paths if path.exists()), None)
|
||||||
|
if not file_path:
|
||||||
|
return None
|
||||||
|
|
||||||
|
mime_type, _ = mimetypes.guess_type(str(file_path))
|
||||||
|
attachment = await insert_attachment(
|
||||||
|
str(file_path),
|
||||||
|
attach_type,
|
||||||
|
mime_type or "application/octet-stream",
|
||||||
|
)
|
||||||
|
if not attachment:
|
||||||
|
return None
|
||||||
|
|
||||||
|
return {
|
||||||
|
"type": attach_type,
|
||||||
|
"attachment_id": attachment.attachment_id,
|
||||||
|
"filename": file_path.name,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def message_chain_to_storage_message_parts(
|
||||||
|
message_chain: MessageChain,
|
||||||
|
*,
|
||||||
|
insert_attachment: AttachmentInserter,
|
||||||
|
attachments_dir: str | Path,
|
||||||
|
) -> list[dict]:
|
||||||
|
target_dir = Path(attachments_dir)
|
||||||
|
target_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
parts: list[dict] = []
|
||||||
|
for comp in message_chain.chain:
|
||||||
|
if isinstance(comp, Plain):
|
||||||
|
if comp.text:
|
||||||
|
parts.append({"type": "plain", "text": comp.text})
|
||||||
|
continue
|
||||||
|
|
||||||
|
if isinstance(comp, Json):
|
||||||
|
parts.append(
|
||||||
|
{"type": "plain", "text": json.dumps(comp.data, ensure_ascii=False)}
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
if isinstance(comp, Image):
|
||||||
|
file_path = await comp.convert_to_file_path()
|
||||||
|
attachment_part = await _copy_file_to_attachment_part(
|
||||||
|
file_path=file_path,
|
||||||
|
attach_type="image",
|
||||||
|
insert_attachment=insert_attachment,
|
||||||
|
attachments_dir=target_dir,
|
||||||
|
)
|
||||||
|
if attachment_part:
|
||||||
|
parts.append(attachment_part)
|
||||||
|
continue
|
||||||
|
|
||||||
|
if isinstance(comp, Record):
|
||||||
|
file_path = await comp.convert_to_file_path()
|
||||||
|
attachment_part = await _copy_file_to_attachment_part(
|
||||||
|
file_path=file_path,
|
||||||
|
attach_type="record",
|
||||||
|
insert_attachment=insert_attachment,
|
||||||
|
attachments_dir=target_dir,
|
||||||
|
)
|
||||||
|
if attachment_part:
|
||||||
|
parts.append(attachment_part)
|
||||||
|
continue
|
||||||
|
|
||||||
|
if isinstance(comp, Video):
|
||||||
|
file_path = await comp.convert_to_file_path()
|
||||||
|
attachment_part = await _copy_file_to_attachment_part(
|
||||||
|
file_path=file_path,
|
||||||
|
attach_type="video",
|
||||||
|
insert_attachment=insert_attachment,
|
||||||
|
attachments_dir=target_dir,
|
||||||
|
)
|
||||||
|
if attachment_part:
|
||||||
|
parts.append(attachment_part)
|
||||||
|
continue
|
||||||
|
|
||||||
|
if isinstance(comp, File):
|
||||||
|
file_path = await comp.get_file()
|
||||||
|
attachment_part = await _copy_file_to_attachment_part(
|
||||||
|
file_path=file_path,
|
||||||
|
attach_type="file",
|
||||||
|
insert_attachment=insert_attachment,
|
||||||
|
attachments_dir=target_dir,
|
||||||
|
display_name=comp.name,
|
||||||
|
)
|
||||||
|
if attachment_part:
|
||||||
|
parts.append(attachment_part)
|
||||||
|
continue
|
||||||
|
|
||||||
|
return parts
|
||||||
|
|
||||||
|
|
||||||
|
async def _copy_file_to_attachment_part(
|
||||||
|
*,
|
||||||
|
file_path: str,
|
||||||
|
attach_type: str,
|
||||||
|
insert_attachment: AttachmentInserter,
|
||||||
|
attachments_dir: Path,
|
||||||
|
display_name: str | None = None,
|
||||||
|
) -> dict | None:
|
||||||
|
src_path = Path(file_path)
|
||||||
|
if not src_path.exists() or not src_path.is_file():
|
||||||
|
return None
|
||||||
|
|
||||||
|
suffix = src_path.suffix
|
||||||
|
target_path = attachments_dir / f"{uuid.uuid4().hex}{suffix}"
|
||||||
|
shutil.copy2(src_path, target_path)
|
||||||
|
|
||||||
|
mime_type, _ = mimetypes.guess_type(target_path.name)
|
||||||
|
attachment = await insert_attachment(
|
||||||
|
str(target_path),
|
||||||
|
attach_type,
|
||||||
|
mime_type or "application/octet-stream",
|
||||||
|
)
|
||||||
|
if not attachment:
|
||||||
|
return None
|
||||||
|
|
||||||
|
return {
|
||||||
|
"type": attach_type,
|
||||||
|
"attachment_id": attachment.attachment_id,
|
||||||
|
"filename": display_name or src_path.name,
|
||||||
|
}
|
||||||
@@ -3,12 +3,12 @@ import os
|
|||||||
import time
|
import time
|
||||||
import uuid
|
import uuid
|
||||||
from collections.abc import Callable, Coroutine
|
from collections.abc import Callable, Coroutine
|
||||||
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from astrbot import logger
|
from astrbot import logger
|
||||||
from astrbot.core import db_helper
|
from astrbot.core import db_helper
|
||||||
from astrbot.core.db.po import PlatformMessageHistory
|
from astrbot.core.db.po import PlatformMessageHistory
|
||||||
from astrbot.core.message.components import File, Image, Plain, Record, Reply, Video
|
|
||||||
from astrbot.core.message.message_event_result import MessageChain
|
from astrbot.core.message.message_event_result import MessageChain
|
||||||
from astrbot.core.platform import (
|
from astrbot.core.platform import (
|
||||||
AstrBotMessage,
|
AstrBotMessage,
|
||||||
@@ -21,10 +21,23 @@ from astrbot.core.platform.astr_message_event import MessageSesion
|
|||||||
from astrbot.core.utils.astrbot_path import get_astrbot_data_path
|
from astrbot.core.utils.astrbot_path import get_astrbot_data_path
|
||||||
|
|
||||||
from ...register import register_platform_adapter
|
from ...register import register_platform_adapter
|
||||||
|
from .message_parts_helper import (
|
||||||
|
message_chain_to_storage_message_parts,
|
||||||
|
parse_webchat_message_parts,
|
||||||
|
)
|
||||||
from .webchat_event import WebChatMessageEvent
|
from .webchat_event import WebChatMessageEvent
|
||||||
from .webchat_queue_mgr import WebChatQueueMgr, webchat_queue_mgr
|
from .webchat_queue_mgr import WebChatQueueMgr, webchat_queue_mgr
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_conversation_id(session_id: str) -> str:
|
||||||
|
"""Extract raw webchat conversation id from event/session id."""
|
||||||
|
if session_id.startswith("webchat!"):
|
||||||
|
parts = session_id.split("!", 2)
|
||||||
|
if len(parts) == 3:
|
||||||
|
return parts[2]
|
||||||
|
return session_id
|
||||||
|
|
||||||
|
|
||||||
class QueueListener:
|
class QueueListener:
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
@@ -57,13 +70,15 @@ class WebChatAdapter(Platform):
|
|||||||
|
|
||||||
self.settings = platform_settings
|
self.settings = platform_settings
|
||||||
self.imgs_dir = os.path.join(get_astrbot_data_path(), "webchat", "imgs")
|
self.imgs_dir = os.path.join(get_astrbot_data_path(), "webchat", "imgs")
|
||||||
|
self.attachments_dir = Path(get_astrbot_data_path()) / "attachments"
|
||||||
os.makedirs(self.imgs_dir, exist_ok=True)
|
os.makedirs(self.imgs_dir, exist_ok=True)
|
||||||
|
self.attachments_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
self.metadata = PlatformMetadata(
|
self.metadata = PlatformMetadata(
|
||||||
name="webchat",
|
name="webchat",
|
||||||
description="webchat",
|
description="webchat",
|
||||||
id="webchat",
|
id="webchat",
|
||||||
support_proactive_message=False,
|
support_proactive_message=True,
|
||||||
)
|
)
|
||||||
self._shutdown_event = asyncio.Event()
|
self._shutdown_event = asyncio.Event()
|
||||||
self._webchat_queue_mgr = webchat_queue_mgr
|
self._webchat_queue_mgr = webchat_queue_mgr
|
||||||
@@ -73,10 +88,67 @@ class WebChatAdapter(Platform):
|
|||||||
session: MessageSesion,
|
session: MessageSesion,
|
||||||
message_chain: MessageChain,
|
message_chain: MessageChain,
|
||||||
) -> None:
|
) -> None:
|
||||||
message_id = f"active_{str(uuid.uuid4())}"
|
conversation_id = _extract_conversation_id(session.session_id)
|
||||||
await WebChatMessageEvent._send(message_id, message_chain, session.session_id)
|
active_request_ids = self._webchat_queue_mgr.list_back_request_ids(
|
||||||
|
conversation_id
|
||||||
|
)
|
||||||
|
subscription_request_ids = [
|
||||||
|
req_id for req_id in active_request_ids if req_id.startswith("ws_sub_")
|
||||||
|
]
|
||||||
|
target_request_ids = subscription_request_ids or active_request_ids
|
||||||
|
|
||||||
|
if target_request_ids:
|
||||||
|
for request_id in target_request_ids:
|
||||||
|
await WebChatMessageEvent._send(
|
||||||
|
request_id,
|
||||||
|
message_chain,
|
||||||
|
session.session_id,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
message_id = f"active_{uuid.uuid4()!s}"
|
||||||
|
await WebChatMessageEvent._send(
|
||||||
|
message_id,
|
||||||
|
message_chain,
|
||||||
|
session.session_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
should_persist = (
|
||||||
|
bool(subscription_request_ids)
|
||||||
|
or not active_request_ids
|
||||||
|
or all(req_id.startswith("active_") for req_id in active_request_ids)
|
||||||
|
)
|
||||||
|
if should_persist:
|
||||||
|
try:
|
||||||
|
await self._save_proactive_message(conversation_id, message_chain)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(
|
||||||
|
f"[WebChatAdapter] Failed to save proactive message: {e}",
|
||||||
|
exc_info=True,
|
||||||
|
)
|
||||||
|
|
||||||
await super().send_by_session(session, message_chain)
|
await super().send_by_session(session, message_chain)
|
||||||
|
|
||||||
|
async def _save_proactive_message(
|
||||||
|
self,
|
||||||
|
conversation_id: str,
|
||||||
|
message_chain: MessageChain,
|
||||||
|
) -> None:
|
||||||
|
message_parts = await message_chain_to_storage_message_parts(
|
||||||
|
message_chain,
|
||||||
|
insert_attachment=db_helper.insert_attachment,
|
||||||
|
attachments_dir=self.attachments_dir,
|
||||||
|
)
|
||||||
|
if not message_parts:
|
||||||
|
return
|
||||||
|
|
||||||
|
await db_helper.insert_platform_message_history(
|
||||||
|
platform_id="webchat",
|
||||||
|
user_id=conversation_id,
|
||||||
|
content={"type": "bot", "message": message_parts},
|
||||||
|
sender_id="bot",
|
||||||
|
sender_name="bot",
|
||||||
|
)
|
||||||
|
|
||||||
async def _get_message_history(
|
async def _get_message_history(
|
||||||
self, message_id: int
|
self, message_id: int
|
||||||
) -> PlatformMessageHistory | None:
|
) -> PlatformMessageHistory | None:
|
||||||
@@ -98,72 +170,30 @@ class WebChatAdapter(Platform):
|
|||||||
Returns:
|
Returns:
|
||||||
tuple[list, list[str]]: (消息组件列表, 纯文本列表)
|
tuple[list, list[str]]: (消息组件列表, 纯文本列表)
|
||||||
"""
|
"""
|
||||||
components = []
|
|
||||||
text_parts = []
|
|
||||||
|
|
||||||
for part in message_parts:
|
async def get_reply_parts(
|
||||||
part_type = part.get("type")
|
message_id: Any,
|
||||||
if part_type == "plain":
|
) -> tuple[list[dict], str | None, str | None] | None:
|
||||||
text = part.get("text", "")
|
history = await self._get_message_history(message_id)
|
||||||
components.append(Plain(text=text))
|
if not history or not history.content:
|
||||||
text_parts.append(text)
|
return None
|
||||||
elif part_type == "reply":
|
|
||||||
message_id = part.get("message_id")
|
|
||||||
reply_chain = []
|
|
||||||
reply_message_str = part.get("selected_text", "")
|
|
||||||
sender_id = None
|
|
||||||
sender_name = None
|
|
||||||
|
|
||||||
if reply_message_str:
|
reply_parts = history.content.get("message", [])
|
||||||
reply_chain = [Plain(text=reply_message_str)]
|
if not isinstance(reply_parts, list):
|
||||||
|
return None
|
||||||
|
|
||||||
# recursively get the content of the referenced message, if selected_text is empty
|
return reply_parts, history.sender_id, history.sender_name
|
||||||
if not reply_message_str and depth < max_depth and message_id:
|
|
||||||
history = await self._get_message_history(message_id)
|
|
||||||
if history and history.content:
|
|
||||||
reply_parts = history.content.get("message", [])
|
|
||||||
if isinstance(reply_parts, list):
|
|
||||||
(
|
|
||||||
reply_chain,
|
|
||||||
reply_text_parts,
|
|
||||||
) = await self._parse_message_parts(
|
|
||||||
reply_parts,
|
|
||||||
depth=depth + 1,
|
|
||||||
max_depth=max_depth,
|
|
||||||
)
|
|
||||||
reply_message_str = "".join(reply_text_parts)
|
|
||||||
sender_id = history.sender_id
|
|
||||||
sender_name = history.sender_name
|
|
||||||
|
|
||||||
components.append(
|
|
||||||
Reply(
|
|
||||||
id=message_id,
|
|
||||||
chain=reply_chain,
|
|
||||||
message_str=reply_message_str,
|
|
||||||
sender_id=sender_id,
|
|
||||||
sender_nickname=sender_name,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
elif part_type == "image":
|
|
||||||
path = part.get("path")
|
|
||||||
if path:
|
|
||||||
components.append(Image.fromFileSystem(path))
|
|
||||||
elif part_type == "record":
|
|
||||||
path = part.get("path")
|
|
||||||
if path:
|
|
||||||
components.append(Record.fromFileSystem(path))
|
|
||||||
elif part_type == "file":
|
|
||||||
path = part.get("path")
|
|
||||||
if path:
|
|
||||||
filename = part.get("filename") or (
|
|
||||||
os.path.basename(path) if path else "file"
|
|
||||||
)
|
|
||||||
components.append(File(name=filename, file=path))
|
|
||||||
elif part_type == "video":
|
|
||||||
path = part.get("path")
|
|
||||||
if path:
|
|
||||||
components.append(Video.fromFileSystem(path))
|
|
||||||
|
|
||||||
|
components, text_parts, _ = await parse_webchat_message_parts(
|
||||||
|
message_parts,
|
||||||
|
strict=False,
|
||||||
|
include_empty_plain=True,
|
||||||
|
verify_media_path_exists=False,
|
||||||
|
reply_history_getter=get_reply_parts,
|
||||||
|
current_depth=depth,
|
||||||
|
max_reply_depth=max_depth,
|
||||||
|
cast_reply_id_to_str=False,
|
||||||
|
)
|
||||||
return components, text_parts
|
return components, text_parts
|
||||||
|
|
||||||
async def convert_message(self, data: tuple) -> AstrBotMessage:
|
async def convert_message(self, data: tuple) -> AstrBotMessage:
|
||||||
|
|||||||
@@ -14,6 +14,15 @@ from .webchat_queue_mgr import webchat_queue_mgr
|
|||||||
attachments_dir = os.path.join(get_astrbot_data_path(), "attachments")
|
attachments_dir = os.path.join(get_astrbot_data_path(), "attachments")
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_conversation_id(session_id: str) -> str:
|
||||||
|
"""Extract raw webchat conversation id from event/session id."""
|
||||||
|
if session_id.startswith("webchat!"):
|
||||||
|
parts = session_id.split("!", 2)
|
||||||
|
if len(parts) == 3:
|
||||||
|
return parts[2]
|
||||||
|
return session_id
|
||||||
|
|
||||||
|
|
||||||
class WebChatMessageEvent(AstrMessageEvent):
|
class WebChatMessageEvent(AstrMessageEvent):
|
||||||
def __init__(self, message_str, message_obj, platform_meta, session_id) -> None:
|
def __init__(self, message_str, message_obj, platform_meta, session_id) -> None:
|
||||||
super().__init__(message_str, message_obj, platform_meta, session_id)
|
super().__init__(message_str, message_obj, platform_meta, session_id)
|
||||||
@@ -27,7 +36,7 @@ class WebChatMessageEvent(AstrMessageEvent):
|
|||||||
streaming: bool = False,
|
streaming: bool = False,
|
||||||
) -> str | None:
|
) -> str | None:
|
||||||
request_id = str(message_id)
|
request_id = str(message_id)
|
||||||
conversation_id = session_id.split("!")[-1]
|
conversation_id = _extract_conversation_id(session_id)
|
||||||
web_chat_back_queue = webchat_queue_mgr.get_or_create_back_queue(
|
web_chat_back_queue = webchat_queue_mgr.get_or_create_back_queue(
|
||||||
request_id,
|
request_id,
|
||||||
conversation_id,
|
conversation_id,
|
||||||
@@ -130,7 +139,7 @@ class WebChatMessageEvent(AstrMessageEvent):
|
|||||||
reasoning_content = ""
|
reasoning_content = ""
|
||||||
message_id = self.message_obj.message_id
|
message_id = self.message_obj.message_id
|
||||||
request_id = str(message_id)
|
request_id = str(message_id)
|
||||||
conversation_id = self.session_id.split("!")[-1]
|
conversation_id = _extract_conversation_id(self.session_id)
|
||||||
web_chat_back_queue = webchat_queue_mgr.get_or_create_back_queue(
|
web_chat_back_queue = webchat_queue_mgr.get_or_create_back_queue(
|
||||||
request_id,
|
request_id,
|
||||||
conversation_id,
|
conversation_id,
|
||||||
|
|||||||
@@ -75,6 +75,10 @@ class WebChatQueueMgr:
|
|||||||
if task is not None:
|
if task is not None:
|
||||||
task.cancel()
|
task.cancel()
|
||||||
|
|
||||||
|
def list_back_request_ids(self, conversation_id: str) -> list[str]:
|
||||||
|
"""List active back-queue request IDs for a conversation."""
|
||||||
|
return list(self._conversation_back_requests.get(conversation_id, set()))
|
||||||
|
|
||||||
def has_queue(self, conversation_id: str) -> bool:
|
def has_queue(self, conversation_id: str) -> bool:
|
||||||
"""Check if a queue exists for the given conversation ID"""
|
"""Check if a queue exists for the given conversation ID"""
|
||||||
return conversation_id in self.queues
|
return conversation_id in self.queues
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
import json
|
import json
|
||||||
import mimetypes
|
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
import uuid
|
import uuid
|
||||||
@@ -14,6 +13,12 @@ from astrbot.core import logger, sp
|
|||||||
from astrbot.core.core_lifecycle import AstrBotCoreLifecycle
|
from astrbot.core.core_lifecycle import AstrBotCoreLifecycle
|
||||||
from astrbot.core.db import BaseDatabase
|
from astrbot.core.db import BaseDatabase
|
||||||
from astrbot.core.platform.message_type import MessageType
|
from astrbot.core.platform.message_type import MessageType
|
||||||
|
from astrbot.core.platform.sources.webchat.message_parts_helper import (
|
||||||
|
build_webchat_message_parts,
|
||||||
|
create_attachment_part_from_existing_file,
|
||||||
|
strip_message_parts_path_fields,
|
||||||
|
webchat_message_parts_have_content,
|
||||||
|
)
|
||||||
from astrbot.core.platform.sources.webchat.webchat_queue_mgr import webchat_queue_mgr
|
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.active_event_registry import active_event_registry
|
||||||
from astrbot.core.utils.astrbot_path import get_astrbot_data_path
|
from astrbot.core.utils.astrbot_path import get_astrbot_data_path
|
||||||
@@ -166,83 +171,24 @@ class ChatRoute(Route):
|
|||||||
)
|
)
|
||||||
|
|
||||||
async def _build_user_message_parts(self, message: str | list) -> list[dict]:
|
async def _build_user_message_parts(self, message: str | list) -> list[dict]:
|
||||||
"""构建用户消息的部分列表
|
"""构建用户消息的部分列表。"""
|
||||||
|
return await build_webchat_message_parts(
|
||||||
Args:
|
message,
|
||||||
message: 文本消息 (str) 或消息段列表 (list)
|
get_attachment_by_id=self.db.get_attachment_by_id,
|
||||||
"""
|
strict=False,
|
||||||
parts = []
|
)
|
||||||
|
|
||||||
if isinstance(message, list):
|
|
||||||
for part in message:
|
|
||||||
part_type = part.get("type")
|
|
||||||
if part_type == "plain":
|
|
||||||
parts.append({"type": "plain", "text": part.get("text", "")})
|
|
||||||
elif part_type == "reply":
|
|
||||||
parts.append(
|
|
||||||
{
|
|
||||||
"type": "reply",
|
|
||||||
"message_id": part.get("message_id"),
|
|
||||||
"selected_text": part.get("selected_text", ""),
|
|
||||||
}
|
|
||||||
)
|
|
||||||
elif attachment_id := part.get("attachment_id"):
|
|
||||||
attachment = await self.db.get_attachment_by_id(attachment_id)
|
|
||||||
if attachment:
|
|
||||||
parts.append(
|
|
||||||
{
|
|
||||||
"type": attachment.type,
|
|
||||||
"attachment_id": attachment.attachment_id,
|
|
||||||
"filename": os.path.basename(attachment.path),
|
|
||||||
"path": attachment.path, # will be deleted
|
|
||||||
}
|
|
||||||
)
|
|
||||||
return parts
|
|
||||||
|
|
||||||
if message:
|
|
||||||
parts.append({"type": "plain", "text": message})
|
|
||||||
|
|
||||||
return parts
|
|
||||||
|
|
||||||
async def _create_attachment_from_file(
|
async def _create_attachment_from_file(
|
||||||
self, filename: str, attach_type: str
|
self, filename: str, attach_type: str
|
||||||
) -> dict | None:
|
) -> dict | None:
|
||||||
"""从本地文件创建 attachment 并返回消息部分
|
"""从本地文件创建 attachment 并返回消息部分。"""
|
||||||
|
return await create_attachment_part_from_existing_file(
|
||||||
用于处理 bot 回复中的媒体文件
|
filename,
|
||||||
|
attach_type=attach_type,
|
||||||
Args:
|
insert_attachment=self.db.insert_attachment,
|
||||||
filename: 存储的文件名
|
attachments_dir=self.attachments_dir,
|
||||||
attach_type: 附件类型 (image, record, file, video)
|
fallback_dirs=[self.legacy_img_dir],
|
||||||
"""
|
|
||||||
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
|
|
||||||
mime_type, _ = mimetypes.guess_type(filename)
|
|
||||||
if not mime_type:
|
|
||||||
mime_type = "application/octet-stream"
|
|
||||||
|
|
||||||
# insert attachment
|
|
||||||
attachment = await self.db.insert_attachment(
|
|
||||||
path=file_path,
|
|
||||||
type=attach_type,
|
|
||||||
mime_type=mime_type,
|
|
||||||
)
|
)
|
||||||
if not attachment:
|
|
||||||
return None
|
|
||||||
|
|
||||||
return {
|
|
||||||
"type": attach_type,
|
|
||||||
"attachment_id": attachment.attachment_id,
|
|
||||||
"filename": os.path.basename(file_path),
|
|
||||||
}
|
|
||||||
|
|
||||||
def _extract_web_search_refs(
|
def _extract_web_search_refs(
|
||||||
self, accumulated_text: str, accumulated_parts: list
|
self, accumulated_text: str, accumulated_parts: list
|
||||||
@@ -356,21 +302,6 @@ class ChatRoute(Route):
|
|||||||
selected_model = post_data.get("selected_model")
|
selected_model = post_data.get("selected_model")
|
||||||
enable_streaming = post_data.get("enable_streaming", True)
|
enable_streaming = post_data.get("enable_streaming", True)
|
||||||
|
|
||||||
# 检查消息是否为空
|
|
||||||
if isinstance(message, list):
|
|
||||||
has_content = any(
|
|
||||||
part.get("type") in ("plain", "image", "record", "file", "video")
|
|
||||||
for part in message
|
|
||||||
)
|
|
||||||
if not has_content:
|
|
||||||
return (
|
|
||||||
Response()
|
|
||||||
.error("Message content is empty (reply only is not allowed)")
|
|
||||||
.__dict__
|
|
||||||
)
|
|
||||||
elif not message:
|
|
||||||
return Response().error("Message are both empty").__dict__
|
|
||||||
|
|
||||||
if not session_id:
|
if not session_id:
|
||||||
return Response().error("session_id is empty").__dict__
|
return Response().error("session_id is empty").__dict__
|
||||||
|
|
||||||
@@ -378,6 +309,12 @@ class ChatRoute(Route):
|
|||||||
|
|
||||||
# 构建用户消息段(包含 path 用于传递给 adapter)
|
# 构建用户消息段(包含 path 用于传递给 adapter)
|
||||||
message_parts = await self._build_user_message_parts(message)
|
message_parts = await self._build_user_message_parts(message)
|
||||||
|
if not webchat_message_parts_have_content(message_parts):
|
||||||
|
return (
|
||||||
|
Response()
|
||||||
|
.error("Message content is empty (reply only is not allowed)")
|
||||||
|
.__dict__
|
||||||
|
)
|
||||||
|
|
||||||
message_id = str(uuid.uuid4())
|
message_id = str(uuid.uuid4())
|
||||||
back_queue = webchat_queue_mgr.get_or_create_back_queue(
|
back_queue = webchat_queue_mgr.get_or_create_back_queue(
|
||||||
@@ -583,10 +520,7 @@ class ChatRoute(Route):
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
message_parts_for_storage = []
|
message_parts_for_storage = strip_message_parts_path_fields(message_parts)
|
||||||
for part in message_parts:
|
|
||||||
part_copy = {k: v for k, v in part.items() if k != "path"}
|
|
||||||
message_parts_for_storage.append(part_copy)
|
|
||||||
|
|
||||||
await self.platform_history_mgr.insert(
|
await self.platform_history_mgr.insert(
|
||||||
platform_id="webchat",
|
platform_id="webchat",
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
|
import re
|
||||||
import time
|
import time
|
||||||
import uuid
|
import uuid
|
||||||
import wave
|
import wave
|
||||||
@@ -10,9 +11,16 @@ import jwt
|
|||||||
from quart import websocket
|
from quart import websocket
|
||||||
|
|
||||||
from astrbot import logger
|
from astrbot import logger
|
||||||
|
from astrbot.core import sp
|
||||||
from astrbot.core.core_lifecycle import AstrBotCoreLifecycle
|
from astrbot.core.core_lifecycle import AstrBotCoreLifecycle
|
||||||
|
from astrbot.core.platform.sources.webchat.message_parts_helper import (
|
||||||
|
build_webchat_message_parts,
|
||||||
|
create_attachment_part_from_existing_file,
|
||||||
|
strip_message_parts_path_fields,
|
||||||
|
webchat_message_parts_have_content,
|
||||||
|
)
|
||||||
from astrbot.core.platform.sources.webchat.webchat_queue_mgr import webchat_queue_mgr
|
from astrbot.core.platform.sources.webchat.webchat_queue_mgr import webchat_queue_mgr
|
||||||
from astrbot.core.utils.astrbot_path import get_astrbot_temp_path
|
from astrbot.core.utils.astrbot_path import get_astrbot_data_path, get_astrbot_temp_path
|
||||||
|
|
||||||
from .route import Route, RouteContext
|
from .route import Route, RouteContext
|
||||||
|
|
||||||
@@ -30,6 +38,9 @@ class LiveChatSession:
|
|||||||
self.audio_frames: list[bytes] = []
|
self.audio_frames: list[bytes] = []
|
||||||
self.current_stamp: str | None = None
|
self.current_stamp: str | None = None
|
||||||
self.temp_audio_path: str | None = None
|
self.temp_audio_path: str | None = None
|
||||||
|
self.chat_subscriptions: dict[str, str] = {}
|
||||||
|
self.chat_subscription_tasks: dict[str, asyncio.Task] = {}
|
||||||
|
self.ws_send_lock = asyncio.Lock()
|
||||||
|
|
||||||
def start_speaking(self, stamp: str) -> None:
|
def start_speaking(self, stamp: str) -> None:
|
||||||
"""开始说话"""
|
"""开始说话"""
|
||||||
@@ -106,13 +117,26 @@ class LiveChatRoute(Route):
|
|||||||
self.core_lifecycle = core_lifecycle
|
self.core_lifecycle = core_lifecycle
|
||||||
self.db = db
|
self.db = db
|
||||||
self.plugin_manager = core_lifecycle.plugin_manager
|
self.plugin_manager = core_lifecycle.plugin_manager
|
||||||
|
self.platform_history_mgr = core_lifecycle.platform_message_history_manager
|
||||||
self.sessions: dict[str, LiveChatSession] = {}
|
self.sessions: dict[str, LiveChatSession] = {}
|
||||||
|
self.attachments_dir = os.path.join(get_astrbot_data_path(), "attachments")
|
||||||
|
self.legacy_img_dir = os.path.join(get_astrbot_data_path(), "webchat", "imgs")
|
||||||
|
os.makedirs(self.attachments_dir, exist_ok=True)
|
||||||
|
|
||||||
# 注册 WebSocket 路由
|
# 注册 WebSocket 路由
|
||||||
self.app.websocket("/api/live_chat/ws")(self.live_chat_ws)
|
self.app.websocket("/api/live_chat/ws")(self.live_chat_ws)
|
||||||
|
self.app.websocket("/api/unified_chat/ws")(self.unified_chat_ws)
|
||||||
|
|
||||||
async def live_chat_ws(self) -> None:
|
async def live_chat_ws(self) -> None:
|
||||||
"""Live Chat WebSocket 处理器"""
|
"""Legacy Live Chat WebSocket 处理器(默认 ct=live)"""
|
||||||
|
await self._unified_ws_loop(force_ct="live")
|
||||||
|
|
||||||
|
async def unified_chat_ws(self) -> None:
|
||||||
|
"""Unified Chat WebSocket 处理器(支持 ct=live/chat)"""
|
||||||
|
await self._unified_ws_loop(force_ct=None)
|
||||||
|
|
||||||
|
async def _unified_ws_loop(self, force_ct: str | None = None) -> None:
|
||||||
|
"""统一 WebSocket 循环"""
|
||||||
# WebSocket 不能通过 header 传递 token,需要从 query 参数获取
|
# WebSocket 不能通过 header 传递 token,需要从 query 参数获取
|
||||||
# 注意:WebSocket 上下文使用 websocket.args 而不是 request.args
|
# 注意:WebSocket 上下文使用 websocket.args 而不是 request.args
|
||||||
token = websocket.args.get("token")
|
token = websocket.args.get("token")
|
||||||
@@ -140,7 +164,11 @@ class LiveChatRoute(Route):
|
|||||||
try:
|
try:
|
||||||
while True:
|
while True:
|
||||||
message = await websocket.receive_json()
|
message = await websocket.receive_json()
|
||||||
await self._handle_message(live_session, message)
|
ct = force_ct or message.get("ct", "live")
|
||||||
|
if ct == "chat":
|
||||||
|
await self._handle_chat_message(live_session, message)
|
||||||
|
else:
|
||||||
|
await self._handle_message(live_session, message)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"[Live Chat] WebSocket 错误: {e}", exc_info=True)
|
logger.error(f"[Live Chat] WebSocket 错误: {e}", exc_info=True)
|
||||||
@@ -148,10 +176,488 @@ class LiveChatRoute(Route):
|
|||||||
finally:
|
finally:
|
||||||
# 清理会话
|
# 清理会话
|
||||||
if session_id in self.sessions:
|
if session_id in self.sessions:
|
||||||
|
await self._cleanup_chat_subscriptions(live_session)
|
||||||
live_session.cleanup()
|
live_session.cleanup()
|
||||||
del self.sessions[session_id]
|
del self.sessions[session_id]
|
||||||
logger.info(f"[Live Chat] WebSocket 连接关闭: {username}")
|
logger.info(f"[Live Chat] WebSocket 连接关闭: {username}")
|
||||||
|
|
||||||
|
async def _create_attachment_from_file(
|
||||||
|
self, filename: str, attach_type: str
|
||||||
|
) -> dict | None:
|
||||||
|
"""从本地文件创建 attachment 并返回消息部分。"""
|
||||||
|
return await create_attachment_part_from_existing_file(
|
||||||
|
filename,
|
||||||
|
attach_type=attach_type,
|
||||||
|
insert_attachment=self.db.insert_attachment,
|
||||||
|
attachments_dir=self.attachments_dir,
|
||||||
|
fallback_dirs=[self.legacy_img_dir],
|
||||||
|
)
|
||||||
|
|
||||||
|
def _extract_web_search_refs(
|
||||||
|
self, accumulated_text: str, accumulated_parts: list
|
||||||
|
) -> dict:
|
||||||
|
"""从消息中提取 web_search 引用。"""
|
||||||
|
supported = ["web_search_tavily", "web_search_bocha"]
|
||||||
|
web_search_results = {}
|
||||||
|
tool_call_parts = [
|
||||||
|
p
|
||||||
|
for p in accumulated_parts
|
||||||
|
if p.get("type") == "tool_call" and p.get("tool_calls")
|
||||||
|
]
|
||||||
|
|
||||||
|
for part in tool_call_parts:
|
||||||
|
for tool_call in part["tool_calls"]:
|
||||||
|
if tool_call.get("name") not in supported or not tool_call.get(
|
||||||
|
"result"
|
||||||
|
):
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
result_data = json.loads(tool_call["result"])
|
||||||
|
for item in result_data.get("results", []):
|
||||||
|
if idx := item.get("index"):
|
||||||
|
web_search_results[idx] = {
|
||||||
|
"url": item.get("url"),
|
||||||
|
"title": item.get("title"),
|
||||||
|
"snippet": item.get("snippet"),
|
||||||
|
}
|
||||||
|
except (json.JSONDecodeError, KeyError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
if not web_search_results:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
ref_indices = {
|
||||||
|
m.strip() for m in re.findall(r"<ref>(.*?)</ref>", accumulated_text)
|
||||||
|
}
|
||||||
|
|
||||||
|
used_refs = []
|
||||||
|
for ref_index in ref_indices:
|
||||||
|
if ref_index not in web_search_results:
|
||||||
|
continue
|
||||||
|
payload = {"index": ref_index, **web_search_results[ref_index]}
|
||||||
|
if favicon := sp.temporary_cache.get("_ws_favicon", {}).get(payload["url"]):
|
||||||
|
payload["favicon"] = favicon
|
||||||
|
used_refs.append(payload)
|
||||||
|
|
||||||
|
return {"used": used_refs} if used_refs else {}
|
||||||
|
|
||||||
|
async def _save_bot_message(
|
||||||
|
self,
|
||||||
|
webchat_conv_id: str,
|
||||||
|
text: str,
|
||||||
|
media_parts: list,
|
||||||
|
reasoning: str,
|
||||||
|
agent_stats: dict,
|
||||||
|
refs: dict,
|
||||||
|
):
|
||||||
|
"""保存 bot 消息到历史记录。"""
|
||||||
|
bot_message_parts = []
|
||||||
|
bot_message_parts.extend(media_parts)
|
||||||
|
if text:
|
||||||
|
bot_message_parts.append({"type": "plain", "text": text})
|
||||||
|
|
||||||
|
new_his = {"type": "bot", "message": bot_message_parts}
|
||||||
|
if reasoning:
|
||||||
|
new_his["reasoning"] = reasoning
|
||||||
|
if agent_stats:
|
||||||
|
new_his["agent_stats"] = agent_stats
|
||||||
|
if refs:
|
||||||
|
new_his["refs"] = refs
|
||||||
|
|
||||||
|
return await self.platform_history_mgr.insert(
|
||||||
|
platform_id="webchat",
|
||||||
|
user_id=webchat_conv_id,
|
||||||
|
content=new_his,
|
||||||
|
sender_id="bot",
|
||||||
|
sender_name="bot",
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _send_chat_payload(self, session: LiveChatSession, payload: dict) -> None:
|
||||||
|
async with session.ws_send_lock:
|
||||||
|
await websocket.send_json(payload)
|
||||||
|
|
||||||
|
async def _forward_chat_subscription(
|
||||||
|
self,
|
||||||
|
session: LiveChatSession,
|
||||||
|
chat_session_id: str,
|
||||||
|
request_id: str,
|
||||||
|
) -> None:
|
||||||
|
back_queue = webchat_queue_mgr.get_or_create_back_queue(
|
||||||
|
request_id, chat_session_id
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
result = await back_queue.get()
|
||||||
|
if not result:
|
||||||
|
continue
|
||||||
|
await self._send_chat_payload(session, {"ct": "chat", **result})
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
pass
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(
|
||||||
|
f"[Live Chat] chat subscription forward failed ({chat_session_id}): {e}",
|
||||||
|
exc_info=True,
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
webchat_queue_mgr.remove_back_queue(request_id)
|
||||||
|
if session.chat_subscriptions.get(chat_session_id) == request_id:
|
||||||
|
session.chat_subscriptions.pop(chat_session_id, None)
|
||||||
|
session.chat_subscription_tasks.pop(chat_session_id, None)
|
||||||
|
|
||||||
|
async def _ensure_chat_subscription(
|
||||||
|
self,
|
||||||
|
session: LiveChatSession,
|
||||||
|
chat_session_id: str,
|
||||||
|
) -> str:
|
||||||
|
existing_request_id = session.chat_subscriptions.get(chat_session_id)
|
||||||
|
existing_task = session.chat_subscription_tasks.get(chat_session_id)
|
||||||
|
if existing_request_id and existing_task and not existing_task.done():
|
||||||
|
return existing_request_id
|
||||||
|
|
||||||
|
request_id = f"ws_sub_{uuid.uuid4().hex}"
|
||||||
|
session.chat_subscriptions[chat_session_id] = request_id
|
||||||
|
task = asyncio.create_task(
|
||||||
|
self._forward_chat_subscription(session, chat_session_id, request_id),
|
||||||
|
name=f"chat_ws_sub_{chat_session_id}",
|
||||||
|
)
|
||||||
|
session.chat_subscription_tasks[chat_session_id] = task
|
||||||
|
return request_id
|
||||||
|
|
||||||
|
async def _cleanup_chat_subscriptions(self, session: LiveChatSession) -> None:
|
||||||
|
tasks = list(session.chat_subscription_tasks.values())
|
||||||
|
for task in tasks:
|
||||||
|
task.cancel()
|
||||||
|
if tasks:
|
||||||
|
await asyncio.gather(*tasks, return_exceptions=True)
|
||||||
|
|
||||||
|
for request_id in list(session.chat_subscriptions.values()):
|
||||||
|
webchat_queue_mgr.remove_back_queue(request_id)
|
||||||
|
session.chat_subscriptions.clear()
|
||||||
|
session.chat_subscription_tasks.clear()
|
||||||
|
|
||||||
|
async def _handle_chat_message(
|
||||||
|
self, session: LiveChatSession, message: dict
|
||||||
|
) -> None:
|
||||||
|
"""处理 Chat Mode 消息(ct=chat)"""
|
||||||
|
msg_type = message.get("t")
|
||||||
|
|
||||||
|
if msg_type == "bind":
|
||||||
|
chat_session_id = message.get("session_id")
|
||||||
|
if not isinstance(chat_session_id, str) or not chat_session_id:
|
||||||
|
await self._send_chat_payload(
|
||||||
|
session,
|
||||||
|
{
|
||||||
|
"ct": "chat",
|
||||||
|
"t": "error",
|
||||||
|
"data": "session_id is required",
|
||||||
|
"code": "INVALID_MESSAGE_FORMAT",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
request_id = await self._ensure_chat_subscription(session, chat_session_id)
|
||||||
|
await self._send_chat_payload(
|
||||||
|
session,
|
||||||
|
{
|
||||||
|
"ct": "chat",
|
||||||
|
"type": "session_bound",
|
||||||
|
"session_id": chat_session_id,
|
||||||
|
"message_id": request_id,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
if msg_type == "interrupt":
|
||||||
|
session.should_interrupt = True
|
||||||
|
await self._send_chat_payload(
|
||||||
|
session,
|
||||||
|
{
|
||||||
|
"ct": "chat",
|
||||||
|
"t": "error",
|
||||||
|
"data": "INTERRUPTED",
|
||||||
|
"code": "INTERRUPTED",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
if msg_type != "send":
|
||||||
|
await self._send_chat_payload(
|
||||||
|
session,
|
||||||
|
{
|
||||||
|
"ct": "chat",
|
||||||
|
"t": "error",
|
||||||
|
"data": f"Unsupported message type: {msg_type}",
|
||||||
|
"code": "INVALID_MESSAGE_FORMAT",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
if session.is_processing:
|
||||||
|
await self._send_chat_payload(
|
||||||
|
session,
|
||||||
|
{
|
||||||
|
"ct": "chat",
|
||||||
|
"t": "error",
|
||||||
|
"data": "Session is busy",
|
||||||
|
"code": "PROCESSING_ERROR",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
payload = message.get("message")
|
||||||
|
session_id = message.get("session_id") or session.session_id
|
||||||
|
message_id = message.get("message_id") or str(uuid.uuid4())
|
||||||
|
selected_provider = message.get("selected_provider")
|
||||||
|
selected_model = message.get("selected_model")
|
||||||
|
selected_stt_provider = message.get("selected_stt_provider")
|
||||||
|
selected_tts_provider = message.get("selected_tts_provider")
|
||||||
|
persona_prompt = message.get("persona_prompt")
|
||||||
|
show_reasoning = message.get("show_reasoning")
|
||||||
|
enable_streaming = message.get("enable_streaming", True)
|
||||||
|
|
||||||
|
if not isinstance(payload, list):
|
||||||
|
await self._send_chat_payload(
|
||||||
|
session,
|
||||||
|
{
|
||||||
|
"ct": "chat",
|
||||||
|
"t": "error",
|
||||||
|
"data": "message must be list",
|
||||||
|
"code": "INVALID_MESSAGE_FORMAT",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
message_parts = await self._build_chat_message_parts(payload)
|
||||||
|
has_content = webchat_message_parts_have_content(message_parts)
|
||||||
|
if not has_content:
|
||||||
|
await self._send_chat_payload(
|
||||||
|
session,
|
||||||
|
{
|
||||||
|
"ct": "chat",
|
||||||
|
"t": "error",
|
||||||
|
"data": "Message content is empty",
|
||||||
|
"code": "INVALID_MESSAGE_FORMAT",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
await self._ensure_chat_subscription(session, session_id)
|
||||||
|
|
||||||
|
session.is_processing = True
|
||||||
|
session.should_interrupt = False
|
||||||
|
back_queue = webchat_queue_mgr.get_or_create_back_queue(message_id, session_id)
|
||||||
|
|
||||||
|
try:
|
||||||
|
chat_queue = webchat_queue_mgr.get_or_create_queue(session_id)
|
||||||
|
await chat_queue.put(
|
||||||
|
(
|
||||||
|
session.username,
|
||||||
|
session_id,
|
||||||
|
{
|
||||||
|
"message": message_parts,
|
||||||
|
"selected_provider": selected_provider,
|
||||||
|
"selected_model": selected_model,
|
||||||
|
"selected_stt_provider": selected_stt_provider,
|
||||||
|
"selected_tts_provider": selected_tts_provider,
|
||||||
|
"persona_prompt": persona_prompt,
|
||||||
|
"show_reasoning": show_reasoning,
|
||||||
|
"enable_streaming": enable_streaming,
|
||||||
|
"message_id": message_id,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
message_parts_for_storage = strip_message_parts_path_fields(message_parts)
|
||||||
|
await self.platform_history_mgr.insert(
|
||||||
|
platform_id="webchat",
|
||||||
|
user_id=session_id,
|
||||||
|
content={"type": "user", "message": message_parts_for_storage},
|
||||||
|
sender_id=session.username,
|
||||||
|
sender_name=session.username,
|
||||||
|
)
|
||||||
|
|
||||||
|
accumulated_parts = []
|
||||||
|
accumulated_text = ""
|
||||||
|
accumulated_reasoning = ""
|
||||||
|
tool_calls = {}
|
||||||
|
agent_stats = {}
|
||||||
|
refs = {}
|
||||||
|
|
||||||
|
while True:
|
||||||
|
if session.should_interrupt:
|
||||||
|
session.should_interrupt = False
|
||||||
|
break
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = await asyncio.wait_for(back_queue.get(), timeout=1)
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if not result:
|
||||||
|
continue
|
||||||
|
if result.get("message_id") and result.get("message_id") != message_id:
|
||||||
|
continue
|
||||||
|
|
||||||
|
result_text = result.get("data", "")
|
||||||
|
msg_type = result.get("type")
|
||||||
|
streaming = result.get("streaming", False)
|
||||||
|
chain_type = result.get("chain_type")
|
||||||
|
if chain_type == "agent_stats":
|
||||||
|
try:
|
||||||
|
parsed_agent_stats = json.loads(result_text)
|
||||||
|
agent_stats = parsed_agent_stats
|
||||||
|
await self._send_chat_payload(
|
||||||
|
session,
|
||||||
|
{
|
||||||
|
"ct": "chat",
|
||||||
|
"type": "agent_stats",
|
||||||
|
"data": parsed_agent_stats,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
continue
|
||||||
|
|
||||||
|
outgoing = {"ct": "chat", **result}
|
||||||
|
await self._send_chat_payload(session, outgoing)
|
||||||
|
|
||||||
|
if msg_type == "plain":
|
||||||
|
if chain_type == "tool_call":
|
||||||
|
try:
|
||||||
|
tool_call = json.loads(result_text)
|
||||||
|
tool_calls[tool_call.get("id")] = tool_call
|
||||||
|
if accumulated_text:
|
||||||
|
accumulated_parts.append(
|
||||||
|
{"type": "plain", "text": accumulated_text}
|
||||||
|
)
|
||||||
|
accumulated_text = ""
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
elif chain_type == "tool_call_result":
|
||||||
|
try:
|
||||||
|
tcr = json.loads(result_text)
|
||||||
|
tc_id = tcr.get("id")
|
||||||
|
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)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
elif chain_type == "reasoning":
|
||||||
|
accumulated_reasoning += result_text
|
||||||
|
elif streaming:
|
||||||
|
accumulated_text += result_text
|
||||||
|
else:
|
||||||
|
accumulated_text = result_text
|
||||||
|
elif msg_type == "image":
|
||||||
|
filename = str(result_text).replace("[IMAGE]", "")
|
||||||
|
part = await self._create_attachment_from_file(filename, "image")
|
||||||
|
if part:
|
||||||
|
accumulated_parts.append(part)
|
||||||
|
elif msg_type == "record":
|
||||||
|
filename = str(result_text).replace("[RECORD]", "")
|
||||||
|
part = await self._create_attachment_from_file(filename, "record")
|
||||||
|
if part:
|
||||||
|
accumulated_parts.append(part)
|
||||||
|
elif msg_type == "file":
|
||||||
|
filename = str(result_text).replace("[FILE]", "").split("|", 1)[0]
|
||||||
|
part = await self._create_attachment_from_file(filename, "file")
|
||||||
|
if part:
|
||||||
|
accumulated_parts.append(part)
|
||||||
|
elif msg_type == "video":
|
||||||
|
filename = str(result_text).replace("[VIDEO]", "").split("|", 1)[0]
|
||||||
|
part = await self._create_attachment_from_file(filename, "video")
|
||||||
|
if part:
|
||||||
|
accumulated_parts.append(part)
|
||||||
|
|
||||||
|
should_save = False
|
||||||
|
if msg_type == "end":
|
||||||
|
should_save = bool(
|
||||||
|
accumulated_parts
|
||||||
|
or accumulated_text
|
||||||
|
or accumulated_reasoning
|
||||||
|
or refs
|
||||||
|
or agent_stats
|
||||||
|
)
|
||||||
|
elif (streaming and msg_type == "complete") or not streaming:
|
||||||
|
if chain_type not in (
|
||||||
|
"tool_call",
|
||||||
|
"tool_call_result",
|
||||||
|
"agent_stats",
|
||||||
|
):
|
||||||
|
should_save = True
|
||||||
|
|
||||||
|
if should_save:
|
||||||
|
try:
|
||||||
|
refs = self._extract_web_search_refs(
|
||||||
|
accumulated_text,
|
||||||
|
accumulated_parts,
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception(
|
||||||
|
f"[Live Chat] Failed to extract web search refs: {e}",
|
||||||
|
exc_info=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
saved_record = await self._save_bot_message(
|
||||||
|
session_id,
|
||||||
|
accumulated_text,
|
||||||
|
accumulated_parts,
|
||||||
|
accumulated_reasoning,
|
||||||
|
agent_stats,
|
||||||
|
refs,
|
||||||
|
)
|
||||||
|
if saved_record:
|
||||||
|
await self._send_chat_payload(
|
||||||
|
session,
|
||||||
|
{
|
||||||
|
"ct": "chat",
|
||||||
|
"type": "message_saved",
|
||||||
|
"data": {
|
||||||
|
"id": saved_record.id,
|
||||||
|
"created_at": saved_record.created_at.astimezone().isoformat(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
accumulated_parts = []
|
||||||
|
accumulated_text = ""
|
||||||
|
accumulated_reasoning = ""
|
||||||
|
agent_stats = {}
|
||||||
|
refs = {}
|
||||||
|
|
||||||
|
if msg_type == "end":
|
||||||
|
break
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"[Live Chat] 处理 chat 消息失败: {e}", exc_info=True)
|
||||||
|
await self._send_chat_payload(
|
||||||
|
session,
|
||||||
|
{
|
||||||
|
"ct": "chat",
|
||||||
|
"t": "error",
|
||||||
|
"data": f"处理失败: {str(e)}",
|
||||||
|
"code": "PROCESSING_ERROR",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
session.is_processing = False
|
||||||
|
webchat_queue_mgr.remove_back_queue(message_id)
|
||||||
|
|
||||||
|
async def _build_chat_message_parts(self, message: list[dict]) -> list[dict]:
|
||||||
|
"""构建 chat websocket 用户消息段(复用 webchat 逻辑)"""
|
||||||
|
return await build_webchat_message_parts(
|
||||||
|
message,
|
||||||
|
get_attachment_by_id=self.db.get_attachment_by_id,
|
||||||
|
strict=False,
|
||||||
|
)
|
||||||
|
|
||||||
async def _handle_message(self, session: LiveChatSession, message: dict) -> None:
|
async def _handle_message(self, session: LiveChatSession, message: dict) -> None:
|
||||||
"""处理 WebSocket 消息"""
|
"""处理 WebSocket 消息"""
|
||||||
msg_type = message.get("t") # 使用 t 代替 type
|
msg_type = message.get("t") # 使用 t 代替 type
|
||||||
|
|||||||
@@ -1,15 +1,22 @@
|
|||||||
from pathlib import Path
|
import asyncio
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
|
|
||||||
from quart import g, request
|
from quart import g, request, websocket
|
||||||
|
|
||||||
from astrbot.core import logger
|
from astrbot.core import logger
|
||||||
from astrbot.core.core_lifecycle import AstrBotCoreLifecycle
|
from astrbot.core.core_lifecycle import AstrBotCoreLifecycle
|
||||||
from astrbot.core.db import BaseDatabase
|
from astrbot.core.db import BaseDatabase
|
||||||
from astrbot.core.message.components import File, Image, Plain, Record, Reply, Video
|
|
||||||
from astrbot.core.message.message_event_result import MessageChain
|
|
||||||
from astrbot.core.platform.message_session import MessageSesion
|
from astrbot.core.platform.message_session import MessageSesion
|
||||||
|
from astrbot.core.platform.sources.webchat.message_parts_helper import (
|
||||||
|
build_message_chain_from_payload,
|
||||||
|
strip_message_parts_path_fields,
|
||||||
|
webchat_message_parts_have_content,
|
||||||
|
)
|
||||||
|
from astrbot.core.platform.sources.webchat.webchat_queue_mgr import webchat_queue_mgr
|
||||||
|
|
||||||
|
from .api_key import ALL_OPEN_API_SCOPES
|
||||||
from .chat import ChatRoute
|
from .chat import ChatRoute
|
||||||
from .route import Response, Route, RouteContext
|
from .route import Response, Route, RouteContext
|
||||||
|
|
||||||
@@ -37,6 +44,7 @@ class OpenApiRoute(Route):
|
|||||||
"/v1/im/bots": ("GET", self.get_bots),
|
"/v1/im/bots": ("GET", self.get_bots),
|
||||||
}
|
}
|
||||||
self.register_routes()
|
self.register_routes()
|
||||||
|
self.app.websocket("/api/v1/chat/ws")(self.chat_ws)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _resolve_open_username(
|
def _resolve_open_username(
|
||||||
@@ -181,6 +189,348 @@ class OpenApiRoute(Route):
|
|||||||
finally:
|
finally:
|
||||||
g.username = original_username
|
g.username = original_username
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _extract_ws_api_key() -> str | None:
|
||||||
|
if key := websocket.args.get("api_key"):
|
||||||
|
return key.strip()
|
||||||
|
if key := websocket.args.get("key"):
|
||||||
|
return key.strip()
|
||||||
|
if key := websocket.headers.get("X-API-Key"):
|
||||||
|
return key.strip()
|
||||||
|
|
||||||
|
auth_header = websocket.headers.get("Authorization", "").strip()
|
||||||
|
if auth_header.startswith("Bearer "):
|
||||||
|
return auth_header.removeprefix("Bearer ").strip()
|
||||||
|
if auth_header.startswith("ApiKey "):
|
||||||
|
return auth_header.removeprefix("ApiKey ").strip()
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def _authenticate_chat_ws_api_key(self) -> tuple[bool, str | None]:
|
||||||
|
raw_key = self._extract_ws_api_key()
|
||||||
|
if not raw_key:
|
||||||
|
return False, "Missing API key"
|
||||||
|
|
||||||
|
key_hash = hashlib.pbkdf2_hmac(
|
||||||
|
"sha256",
|
||||||
|
raw_key.encode("utf-8"),
|
||||||
|
b"astrbot_api_key",
|
||||||
|
100_000,
|
||||||
|
).hex()
|
||||||
|
api_key = await self.db.get_active_api_key_by_hash(key_hash)
|
||||||
|
if not api_key:
|
||||||
|
return False, "Invalid API key"
|
||||||
|
|
||||||
|
if isinstance(api_key.scopes, list):
|
||||||
|
scopes = api_key.scopes
|
||||||
|
else:
|
||||||
|
scopes = list(ALL_OPEN_API_SCOPES)
|
||||||
|
|
||||||
|
if "*" not in scopes and "chat" not in scopes:
|
||||||
|
return False, "Insufficient API key scope"
|
||||||
|
|
||||||
|
await self.db.touch_api_key(api_key.key_id)
|
||||||
|
return True, None
|
||||||
|
|
||||||
|
async def _send_chat_ws_error(self, message: str, code: str) -> None:
|
||||||
|
await websocket.send_json(
|
||||||
|
{
|
||||||
|
"type": "error",
|
||||||
|
"code": code,
|
||||||
|
"data": message,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _update_session_config_route(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
username: str,
|
||||||
|
session_id: str,
|
||||||
|
config_id: str | None,
|
||||||
|
) -> str | None:
|
||||||
|
if not config_id:
|
||||||
|
return None
|
||||||
|
|
||||||
|
umo = f"webchat:FriendMessage:webchat!{username}!{session_id}"
|
||||||
|
try:
|
||||||
|
if config_id == "default":
|
||||||
|
await self.core_lifecycle.umop_config_router.delete_route(umo)
|
||||||
|
else:
|
||||||
|
await self.core_lifecycle.umop_config_router.update_route(
|
||||||
|
umo, config_id
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(
|
||||||
|
"Failed to update chat config route for %s with %s: %s",
|
||||||
|
umo,
|
||||||
|
config_id,
|
||||||
|
e,
|
||||||
|
exc_info=True,
|
||||||
|
)
|
||||||
|
return f"Failed to update chat config route: {e}"
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def _handle_chat_ws_send(self, post_data: dict) -> None:
|
||||||
|
effective_username, username_err = self._resolve_open_username(
|
||||||
|
post_data.get("username")
|
||||||
|
)
|
||||||
|
if username_err or not effective_username:
|
||||||
|
await self._send_chat_ws_error(
|
||||||
|
username_err or "Invalid username", "BAD_USER"
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
message = post_data.get("message")
|
||||||
|
if message is None:
|
||||||
|
await self._send_chat_ws_error("Missing key: message", "INVALID_MESSAGE")
|
||||||
|
return
|
||||||
|
|
||||||
|
raw_session_id = post_data.get("session_id", post_data.get("conversation_id"))
|
||||||
|
session_id = str(raw_session_id).strip() if raw_session_id is not None else ""
|
||||||
|
if not session_id:
|
||||||
|
session_id = str(uuid4())
|
||||||
|
|
||||||
|
ensure_session_err = await self._ensure_chat_session(
|
||||||
|
effective_username,
|
||||||
|
session_id,
|
||||||
|
)
|
||||||
|
if ensure_session_err:
|
||||||
|
await self._send_chat_ws_error(ensure_session_err, "SESSION_ERROR")
|
||||||
|
return
|
||||||
|
|
||||||
|
config_id, resolve_err = self._resolve_chat_config_id(post_data)
|
||||||
|
if resolve_err:
|
||||||
|
await self._send_chat_ws_error(resolve_err, "CONFIG_ERROR")
|
||||||
|
return
|
||||||
|
|
||||||
|
config_err = await self._update_session_config_route(
|
||||||
|
username=effective_username,
|
||||||
|
session_id=session_id,
|
||||||
|
config_id=config_id,
|
||||||
|
)
|
||||||
|
if config_err:
|
||||||
|
await self._send_chat_ws_error(config_err, "CONFIG_ERROR")
|
||||||
|
return
|
||||||
|
|
||||||
|
message_parts = await self.chat_route._build_user_message_parts(message)
|
||||||
|
if not webchat_message_parts_have_content(message_parts):
|
||||||
|
await self._send_chat_ws_error(
|
||||||
|
"Message content is empty (reply only is not allowed)",
|
||||||
|
"INVALID_MESSAGE",
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
message_id = str(post_data.get("message_id") or uuid4())
|
||||||
|
selected_provider = post_data.get("selected_provider")
|
||||||
|
selected_model = post_data.get("selected_model")
|
||||||
|
enable_streaming = post_data.get("enable_streaming", True)
|
||||||
|
|
||||||
|
back_queue = webchat_queue_mgr.get_or_create_back_queue(message_id, session_id)
|
||||||
|
try:
|
||||||
|
chat_queue = webchat_queue_mgr.get_or_create_queue(session_id)
|
||||||
|
await chat_queue.put(
|
||||||
|
(
|
||||||
|
effective_username,
|
||||||
|
session_id,
|
||||||
|
{
|
||||||
|
"message": message_parts,
|
||||||
|
"selected_provider": selected_provider,
|
||||||
|
"selected_model": selected_model,
|
||||||
|
"enable_streaming": enable_streaming,
|
||||||
|
"message_id": message_id,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
message_parts_for_storage = strip_message_parts_path_fields(message_parts)
|
||||||
|
await self.chat_route.platform_history_mgr.insert(
|
||||||
|
platform_id="webchat",
|
||||||
|
user_id=session_id,
|
||||||
|
content={"type": "user", "message": message_parts_for_storage},
|
||||||
|
sender_id=effective_username,
|
||||||
|
sender_name=effective_username,
|
||||||
|
)
|
||||||
|
|
||||||
|
await websocket.send_json(
|
||||||
|
{
|
||||||
|
"type": "session_id",
|
||||||
|
"data": None,
|
||||||
|
"session_id": session_id,
|
||||||
|
"message_id": message_id,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
accumulated_parts = []
|
||||||
|
accumulated_text = ""
|
||||||
|
accumulated_reasoning = ""
|
||||||
|
tool_calls = {}
|
||||||
|
agent_stats = {}
|
||||||
|
refs = {}
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
result = await asyncio.wait_for(back_queue.get(), timeout=1)
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if not result:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if "message_id" in result and result["message_id"] != message_id:
|
||||||
|
logger.warning("openapi ws stream message_id mismatch")
|
||||||
|
continue
|
||||||
|
|
||||||
|
result_text = result.get("data", "")
|
||||||
|
msg_type = result.get("type")
|
||||||
|
streaming = result.get("streaming", False)
|
||||||
|
chain_type = result.get("chain_type")
|
||||||
|
|
||||||
|
if chain_type == "agent_stats":
|
||||||
|
try:
|
||||||
|
stats_info = {
|
||||||
|
"type": "agent_stats",
|
||||||
|
"data": json.loads(result_text),
|
||||||
|
}
|
||||||
|
await websocket.send_json(stats_info)
|
||||||
|
agent_stats = stats_info["data"]
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
continue
|
||||||
|
|
||||||
|
await websocket.send_json(result)
|
||||||
|
|
||||||
|
if msg_type == "plain":
|
||||||
|
if chain_type == "tool_call":
|
||||||
|
tool_call = json.loads(result_text)
|
||||||
|
tool_calls[tool_call.get("id")] = tool_call
|
||||||
|
if accumulated_text:
|
||||||
|
accumulated_parts.append(
|
||||||
|
{"type": "plain", "text": accumulated_text}
|
||||||
|
)
|
||||||
|
accumulated_text = ""
|
||||||
|
elif chain_type == "tool_call_result":
|
||||||
|
tcr = json.loads(result_text)
|
||||||
|
tc_id = tcr.get("id")
|
||||||
|
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)
|
||||||
|
elif chain_type == "reasoning":
|
||||||
|
accumulated_reasoning += result_text
|
||||||
|
elif streaming:
|
||||||
|
accumulated_text += result_text
|
||||||
|
else:
|
||||||
|
accumulated_text = result_text
|
||||||
|
elif msg_type == "image":
|
||||||
|
filename = str(result_text).replace("[IMAGE]", "")
|
||||||
|
part = await self.chat_route._create_attachment_from_file(
|
||||||
|
filename, "image"
|
||||||
|
)
|
||||||
|
if part:
|
||||||
|
accumulated_parts.append(part)
|
||||||
|
elif msg_type == "record":
|
||||||
|
filename = str(result_text).replace("[RECORD]", "")
|
||||||
|
part = await self.chat_route._create_attachment_from_file(
|
||||||
|
filename, "record"
|
||||||
|
)
|
||||||
|
if part:
|
||||||
|
accumulated_parts.append(part)
|
||||||
|
elif msg_type == "file":
|
||||||
|
filename = str(result_text).replace("[FILE]", "")
|
||||||
|
part = await self.chat_route._create_attachment_from_file(
|
||||||
|
filename, "file"
|
||||||
|
)
|
||||||
|
if part:
|
||||||
|
accumulated_parts.append(part)
|
||||||
|
elif msg_type == "video":
|
||||||
|
filename = str(result_text).replace("[VIDEO]", "")
|
||||||
|
part = await self.chat_route._create_attachment_from_file(
|
||||||
|
filename, "video"
|
||||||
|
)
|
||||||
|
if part:
|
||||||
|
accumulated_parts.append(part)
|
||||||
|
|
||||||
|
if msg_type == "end":
|
||||||
|
break
|
||||||
|
if (streaming and msg_type == "complete") or not streaming:
|
||||||
|
if chain_type in ("tool_call", "tool_call_result"):
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
refs = self.chat_route._extract_web_search_refs(
|
||||||
|
accumulated_text,
|
||||||
|
accumulated_parts,
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception(
|
||||||
|
f"Open API WS failed to extract web search refs: {e}",
|
||||||
|
exc_info=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
saved_record = await self.chat_route._save_bot_message(
|
||||||
|
session_id,
|
||||||
|
accumulated_text,
|
||||||
|
accumulated_parts,
|
||||||
|
accumulated_reasoning,
|
||||||
|
agent_stats,
|
||||||
|
refs,
|
||||||
|
)
|
||||||
|
if saved_record:
|
||||||
|
await websocket.send_json(
|
||||||
|
{
|
||||||
|
"type": "message_saved",
|
||||||
|
"data": {
|
||||||
|
"id": saved_record.id,
|
||||||
|
"created_at": saved_record.created_at.astimezone().isoformat(),
|
||||||
|
},
|
||||||
|
"session_id": session_id,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
accumulated_parts = []
|
||||||
|
accumulated_text = ""
|
||||||
|
accumulated_reasoning = ""
|
||||||
|
agent_stats = {}
|
||||||
|
refs = {}
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception(f"Open API WS chat failed: {e}", exc_info=True)
|
||||||
|
await self._send_chat_ws_error(
|
||||||
|
f"Failed to process message: {e}", "PROCESSING_ERROR"
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
webchat_queue_mgr.remove_back_queue(message_id)
|
||||||
|
|
||||||
|
async def chat_ws(self) -> None:
|
||||||
|
authed, auth_err = await self._authenticate_chat_ws_api_key()
|
||||||
|
if not authed:
|
||||||
|
await self._send_chat_ws_error(auth_err or "Unauthorized", "UNAUTHORIZED")
|
||||||
|
await websocket.close(1008, auth_err or "Unauthorized")
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
message = await websocket.receive_json()
|
||||||
|
if not isinstance(message, dict):
|
||||||
|
await self._send_chat_ws_error(
|
||||||
|
"message must be an object",
|
||||||
|
"INVALID_MESSAGE",
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
msg_type = message.get("t", "send")
|
||||||
|
if msg_type == "ping":
|
||||||
|
await websocket.send_json({"type": "pong"})
|
||||||
|
continue
|
||||||
|
if msg_type != "send":
|
||||||
|
await self._send_chat_ws_error(
|
||||||
|
f"Unsupported message type: {msg_type}",
|
||||||
|
"INVALID_MESSAGE",
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
await self._handle_chat_ws_send(message)
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug("Open API WS connection closed: %s", e)
|
||||||
|
|
||||||
async def upload_file(self):
|
async def upload_file(self):
|
||||||
return await self.chat_route.post_file()
|
return await self.chat_route.post_file()
|
||||||
|
|
||||||
@@ -254,83 +604,12 @@ class OpenApiRoute(Route):
|
|||||||
async def _build_message_chain_from_payload(
|
async def _build_message_chain_from_payload(
|
||||||
self,
|
self,
|
||||||
message_payload: str | list,
|
message_payload: str | list,
|
||||||
) -> MessageChain:
|
):
|
||||||
if isinstance(message_payload, str):
|
return await build_message_chain_from_payload(
|
||||||
text = message_payload.strip()
|
message_payload,
|
||||||
if not text:
|
get_attachment_by_id=self.db.get_attachment_by_id,
|
||||||
raise ValueError("Message is empty")
|
strict=True,
|
||||||
return MessageChain(chain=[Plain(text=text)])
|
)
|
||||||
|
|
||||||
if not isinstance(message_payload, list):
|
|
||||||
raise ValueError("message must be a string or list")
|
|
||||||
|
|
||||||
components = []
|
|
||||||
has_content = False
|
|
||||||
|
|
||||||
for part in message_payload:
|
|
||||||
if not isinstance(part, dict):
|
|
||||||
raise ValueError("message part must be an object")
|
|
||||||
|
|
||||||
part_type = str(part.get("type", "")).strip()
|
|
||||||
if part_type == "plain":
|
|
||||||
text = str(part.get("text", ""))
|
|
||||||
if text:
|
|
||||||
has_content = True
|
|
||||||
components.append(Plain(text=text))
|
|
||||||
continue
|
|
||||||
|
|
||||||
if part_type == "reply":
|
|
||||||
message_id = part.get("message_id")
|
|
||||||
if message_id is None:
|
|
||||||
raise ValueError("reply part missing message_id")
|
|
||||||
components.append(
|
|
||||||
Reply(
|
|
||||||
id=str(message_id),
|
|
||||||
message_str=str(part.get("selected_text", "")),
|
|
||||||
chain=[],
|
|
||||||
)
|
|
||||||
)
|
|
||||||
continue
|
|
||||||
|
|
||||||
if part_type not in {"image", "record", "file", "video"}:
|
|
||||||
raise ValueError(f"unsupported message part type: {part_type}")
|
|
||||||
|
|
||||||
has_content = True
|
|
||||||
file_path: Path | None = None
|
|
||||||
resolved_type = part_type
|
|
||||||
filename = str(part.get("filename", "")).strip()
|
|
||||||
|
|
||||||
attachment_id = part.get("attachment_id")
|
|
||||||
if attachment_id:
|
|
||||||
attachment = await self.db.get_attachment_by_id(str(attachment_id))
|
|
||||||
if not attachment:
|
|
||||||
raise ValueError(f"attachment not found: {attachment_id}")
|
|
||||||
file_path = Path(attachment.path)
|
|
||||||
resolved_type = attachment.type
|
|
||||||
if not filename:
|
|
||||||
filename = file_path.name
|
|
||||||
else:
|
|
||||||
raise ValueError(f"{part_type} part missing attachment_id")
|
|
||||||
|
|
||||||
if not file_path.exists():
|
|
||||||
raise ValueError(f"file not found: {file_path!s}")
|
|
||||||
|
|
||||||
file_path_str = str(file_path.resolve())
|
|
||||||
if resolved_type == "image":
|
|
||||||
components.append(Image.fromFileSystem(file_path_str))
|
|
||||||
elif resolved_type == "record":
|
|
||||||
components.append(Record.fromFileSystem(file_path_str))
|
|
||||||
elif resolved_type == "video":
|
|
||||||
components.append(Video.fromFileSystem(file_path_str))
|
|
||||||
else:
|
|
||||||
components.append(
|
|
||||||
File(name=filename or file_path.name, file=file_path_str)
|
|
||||||
)
|
|
||||||
|
|
||||||
if not components or not has_content:
|
|
||||||
raise ValueError("Message content is empty (reply only is not allowed)")
|
|
||||||
|
|
||||||
return MessageChain(chain=components)
|
|
||||||
|
|
||||||
async def send_message(self):
|
async def send_message(self):
|
||||||
post_data = await request.json or {}
|
post_data = await request.json or {}
|
||||||
|
|||||||
@@ -204,6 +204,10 @@ class AstrBotDashboard:
|
|||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _extract_raw_api_key() -> str | None:
|
def _extract_raw_api_key() -> str | None:
|
||||||
|
if key := request.args.get("api_key"):
|
||||||
|
return key.strip()
|
||||||
|
if key := request.args.get("key"):
|
||||||
|
return key.strip()
|
||||||
if key := request.headers.get("X-API-Key"):
|
if key := request.headers.get("X-API-Key"):
|
||||||
return key.strip()
|
return key.strip()
|
||||||
auth_header = request.headers.get("Authorization", "").strip()
|
auth_header = request.headers.get("Authorization", "").strip()
|
||||||
@@ -217,6 +221,7 @@ class AstrBotDashboard:
|
|||||||
def _get_required_open_api_scope(path: str) -> str | None:
|
def _get_required_open_api_scope(path: str) -> str | None:
|
||||||
scope_map = {
|
scope_map = {
|
||||||
"/api/v1/chat": "chat",
|
"/api/v1/chat": "chat",
|
||||||
|
"/api/v1/chat/ws": "chat",
|
||||||
"/api/v1/chat/sessions": "chat",
|
"/api/v1/chat/sessions": "chat",
|
||||||
"/api/v1/configs": "config",
|
"/api/v1/configs": "config",
|
||||||
"/api/v1/file": "file",
|
"/api/v1/file": "file",
|
||||||
|
|||||||
@@ -10,6 +10,7 @@
|
|||||||
:selectedSessions="selectedSessions"
|
:selectedSessions="selectedSessions"
|
||||||
:currSessionId="currSessionId"
|
:currSessionId="currSessionId"
|
||||||
:selectedProjectId="selectedProjectId"
|
:selectedProjectId="selectedProjectId"
|
||||||
|
:transportMode="transportMode"
|
||||||
:isDark="isDark"
|
:isDark="isDark"
|
||||||
:chatboxMode="chatboxMode"
|
:chatboxMode="chatboxMode"
|
||||||
:isMobile="isMobile"
|
:isMobile="isMobile"
|
||||||
@@ -26,6 +27,7 @@
|
|||||||
@createProject="showCreateProjectDialog"
|
@createProject="showCreateProjectDialog"
|
||||||
@editProject="showEditProjectDialog"
|
@editProject="showEditProjectDialog"
|
||||||
@deleteProject="handleDeleteProject"
|
@deleteProject="handleDeleteProject"
|
||||||
|
@updateTransportMode="setTransportMode"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<!-- 右侧聊天内容区域 -->
|
<!-- 右侧聊天内容区域 -->
|
||||||
@@ -301,11 +303,14 @@ const {
|
|||||||
isStreaming,
|
isStreaming,
|
||||||
isConvRunning,
|
isConvRunning,
|
||||||
enableStreaming,
|
enableStreaming,
|
||||||
|
transportMode,
|
||||||
currentSessionProject,
|
currentSessionProject,
|
||||||
getSessionMessages: getSessionMsg,
|
getSessionMessages: getSessionMsg,
|
||||||
sendMessage: sendMsg,
|
sendMessage: sendMsg,
|
||||||
stopMessage: stopMsg,
|
stopMessage: stopMsg,
|
||||||
toggleStreaming
|
toggleStreaming,
|
||||||
|
setTransportMode,
|
||||||
|
cleanupTransport
|
||||||
} = useMessages(currSessionId, getMediaFile, updateSessionTitle, getSessions);
|
} = useMessages(currSessionId, getMediaFile, updateSessionTitle, getSessions);
|
||||||
|
|
||||||
// 组件引用
|
// 组件引用
|
||||||
@@ -695,6 +700,7 @@ onMounted(() => {
|
|||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
window.removeEventListener('resize', checkMobile);
|
window.removeEventListener('resize', checkMobile);
|
||||||
cleanupMediaCache();
|
cleanupMediaCache();
|
||||||
|
cleanupTransport();
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -117,6 +117,27 @@
|
|||||||
<v-list-item-title>{{ isDark ? tm('modes.lightMode') : tm('modes.darkMode') }}</v-list-item-title>
|
<v-list-item-title>{{ isDark ? tm('modes.lightMode') : tm('modes.darkMode') }}</v-list-item-title>
|
||||||
</v-list-item>
|
</v-list-item>
|
||||||
|
|
||||||
|
<!-- 通信传输模式 -->
|
||||||
|
<v-list-item class="styled-menu-item">
|
||||||
|
<template v-slot:prepend>
|
||||||
|
<v-icon>mdi-lan-connect</v-icon>
|
||||||
|
</template>
|
||||||
|
<v-list-item-title>{{ tm('transport.title') }}</v-list-item-title>
|
||||||
|
<template v-slot:append>
|
||||||
|
<v-select
|
||||||
|
:model-value="transportMode"
|
||||||
|
:items="transportOptions"
|
||||||
|
item-title="label"
|
||||||
|
item-value="value"
|
||||||
|
density="compact"
|
||||||
|
variant="underlined"
|
||||||
|
hide-details
|
||||||
|
class="transport-mode-select"
|
||||||
|
@update:model-value="handleTransportModeChange"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</v-list-item>
|
||||||
|
|
||||||
<!-- 全屏/退出全屏 -->
|
<!-- 全屏/退出全屏 -->
|
||||||
<v-list-item class="styled-menu-item" @click="$emit('toggleFullscreen')">
|
<v-list-item class="styled-menu-item" @click="$emit('toggleFullscreen')">
|
||||||
<template v-slot:prepend>
|
<template v-slot:prepend>
|
||||||
@@ -156,6 +177,7 @@ interface Props {
|
|||||||
selectedSessions: string[];
|
selectedSessions: string[];
|
||||||
currSessionId: string;
|
currSessionId: string;
|
||||||
selectedProjectId?: string | null;
|
selectedProjectId?: string | null;
|
||||||
|
transportMode: 'sse' | 'websocket';
|
||||||
isDark: boolean;
|
isDark: boolean;
|
||||||
chatboxMode: boolean;
|
chatboxMode: boolean;
|
||||||
isMobile: boolean;
|
isMobile: boolean;
|
||||||
@@ -179,6 +201,7 @@ const emit = defineEmits<{
|
|||||||
createProject: [];
|
createProject: [];
|
||||||
editProject: [project: Project];
|
editProject: [project: Project];
|
||||||
deleteProject: [projectId: string];
|
deleteProject: [projectId: string];
|
||||||
|
updateTransportMode: [mode: 'sse' | 'websocket'];
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
@@ -188,6 +211,10 @@ const confirmDialog = useConfirmDialog();
|
|||||||
|
|
||||||
const sidebarCollapsed = ref(true);
|
const sidebarCollapsed = ref(true);
|
||||||
const showProviderConfigDialog = ref(false);
|
const showProviderConfigDialog = ref(false);
|
||||||
|
const transportOptions = [
|
||||||
|
{ label: tm('transport.sse'), value: 'sse' as const },
|
||||||
|
{ label: tm('transport.websocket'), value: 'websocket' as const }
|
||||||
|
];
|
||||||
|
|
||||||
// 从 localStorage 读取侧边栏折叠状态
|
// 从 localStorage 读取侧边栏折叠状态
|
||||||
const savedCollapsedState = localStorage.getItem('sidebarCollapsed');
|
const savedCollapsedState = localStorage.getItem('sidebarCollapsed');
|
||||||
@@ -209,6 +236,12 @@ async function handleDeleteConversation(session: Session) {
|
|||||||
emit('deleteConversation', session.session_id);
|
emit('deleteConversation', session.session_id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleTransportModeChange(mode: string | null) {
|
||||||
|
if (mode === 'sse' || mode === 'websocket') {
|
||||||
|
emit('updateTransportMode', mode);
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
@@ -361,4 +394,8 @@ async function handleDeleteConversation(session: Session) {
|
|||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.transport-mode-select {
|
||||||
|
min-width: 120px;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -81,9 +81,16 @@
|
|||||||
"disabled": "Streaming disabled",
|
"disabled": "Streaming disabled",
|
||||||
"on": "Stream",
|
"on": "Stream",
|
||||||
"off": "Normal"
|
"off": "Normal"
|
||||||
}, "config": {
|
},
|
||||||
|
"transport": {
|
||||||
|
"title": "Transport Mode",
|
||||||
|
"sse": "SSE",
|
||||||
|
"websocket": "WebSocket"
|
||||||
|
},
|
||||||
|
"config": {
|
||||||
"title": "Config"
|
"title": "Config"
|
||||||
}, "reasoning": {
|
},
|
||||||
|
"reasoning": {
|
||||||
"thinking": "Thinking Process"
|
"thinking": "Thinking Process"
|
||||||
},
|
},
|
||||||
"reply": {
|
"reply": {
|
||||||
|
|||||||
@@ -82,6 +82,11 @@
|
|||||||
"on": "流式",
|
"on": "流式",
|
||||||
"off": "普通"
|
"off": "普通"
|
||||||
},
|
},
|
||||||
|
"transport": {
|
||||||
|
"title": "通信传输模式",
|
||||||
|
"sse": "SSE",
|
||||||
|
"websocket": "WebSocket"
|
||||||
|
},
|
||||||
"config": {
|
"config": {
|
||||||
"title": "配置文件"
|
"title": "配置文件"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ export default defineConfig({
|
|||||||
'/api': {
|
'/api': {
|
||||||
target: 'http://127.0.0.1:6185/',
|
target: 'http://127.0.0.1:6185/',
|
||||||
changeOrigin: true,
|
changeOrigin: true,
|
||||||
|
ws: true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -149,6 +149,20 @@ class MockHooks(BaseAgentRunHooks):
|
|||||||
self.agent_done_called = True
|
self.agent_done_called = True
|
||||||
|
|
||||||
|
|
||||||
|
class MockEvent:
|
||||||
|
def __init__(self, umo: str, sender_id: str):
|
||||||
|
self.unified_msg_origin = umo
|
||||||
|
self._sender_id = sender_id
|
||||||
|
|
||||||
|
def get_sender_id(self):
|
||||||
|
return self._sender_id
|
||||||
|
|
||||||
|
|
||||||
|
class MockAgentContext:
|
||||||
|
def __init__(self, event):
|
||||||
|
self.event = event
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def mock_provider():
|
def mock_provider():
|
||||||
return MockProvider()
|
return MockProvider()
|
||||||
@@ -451,6 +465,76 @@ async def test_stop_signal_returns_aborted_and_persists_partial_message(
|
|||||||
assert runner.run_context.messages[-1].role == "assistant"
|
assert runner.run_context.messages[-1].role == "assistant"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_tool_result_injects_follow_up_notice(
|
||||||
|
runner, mock_provider, provider_request, mock_tool_executor, mock_hooks
|
||||||
|
):
|
||||||
|
mock_event = MockEvent("test:FriendMessage:follow_up", "u1")
|
||||||
|
run_context = ContextWrapper(context=MockAgentContext(mock_event))
|
||||||
|
|
||||||
|
await runner.reset(
|
||||||
|
provider=mock_provider,
|
||||||
|
request=provider_request,
|
||||||
|
run_context=run_context,
|
||||||
|
tool_executor=mock_tool_executor,
|
||||||
|
agent_hooks=mock_hooks,
|
||||||
|
streaming=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
ticket1 = runner.follow_up(
|
||||||
|
message_text="follow up 1",
|
||||||
|
)
|
||||||
|
ticket2 = runner.follow_up(
|
||||||
|
message_text="follow up 2",
|
||||||
|
)
|
||||||
|
assert ticket1 is not None
|
||||||
|
assert ticket2 is not None
|
||||||
|
|
||||||
|
async for _ in runner.step():
|
||||||
|
pass
|
||||||
|
|
||||||
|
assert provider_request.tool_calls_result is not None
|
||||||
|
assert isinstance(provider_request.tool_calls_result, list)
|
||||||
|
assert provider_request.tool_calls_result
|
||||||
|
tool_result = str(
|
||||||
|
provider_request.tool_calls_result[0].tool_calls_result[0].content
|
||||||
|
)
|
||||||
|
assert "SYSTEM NOTICE" in tool_result
|
||||||
|
assert "1. follow up 1" in tool_result
|
||||||
|
assert "2. follow up 2" in tool_result
|
||||||
|
assert ticket1.resolved.is_set() is True
|
||||||
|
assert ticket2.resolved.is_set() is True
|
||||||
|
assert ticket1.consumed is True
|
||||||
|
assert ticket2.consumed is True
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_follow_up_ticket_not_consumed_when_no_next_tool_call(
|
||||||
|
runner, mock_provider, provider_request, mock_tool_executor, mock_hooks
|
||||||
|
):
|
||||||
|
mock_provider.should_call_tools = False
|
||||||
|
mock_event = MockEvent("test:FriendMessage:follow_up_no_tool", "u1")
|
||||||
|
run_context = ContextWrapper(context=MockAgentContext(mock_event))
|
||||||
|
|
||||||
|
await runner.reset(
|
||||||
|
provider=mock_provider,
|
||||||
|
request=provider_request,
|
||||||
|
run_context=run_context,
|
||||||
|
tool_executor=mock_tool_executor,
|
||||||
|
agent_hooks=mock_hooks,
|
||||||
|
streaming=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
ticket = runner.follow_up(message_text="follow up without tool")
|
||||||
|
assert ticket is not None
|
||||||
|
|
||||||
|
async for _ in runner.step():
|
||||||
|
pass
|
||||||
|
|
||||||
|
assert ticket.resolved.is_set() is True
|
||||||
|
assert ticket.consumed is False
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
# 运行测试
|
# 运行测试
|
||||||
pytest.main([__file__, "-v"])
|
pytest.main([__file__, "-v"])
|
||||||
|
|||||||
Reference in New Issue
Block a user