Compare commits
208 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e3b0ca8ef6 | |||
| 9e266eb6d5 | |||
| 7231403e16 | |||
| 344a486fd7 | |||
| 4fd831875d | |||
| 0988d067ea | |||
| 3b6dd7e15a | |||
| 757d2a3947 | |||
| 61b71143f2 | |||
| 1b343a36c9 | |||
| 8e94937060 | |||
| a4f212a18f | |||
| caafb73190 | |||
| 09482799c9 | |||
| 37f93d1760 | |||
| 725f2e5204 | |||
| 967198fae0 | |||
| 43d57f6dcb | |||
| 6afa4db577 | |||
| 3b8c3fb29a | |||
| 921c3b0627 | |||
| c0fadb45ab | |||
| a1481fb179 | |||
| 987cd972d3 | |||
| bdf25976a3 | |||
| 87c3aff4ce | |||
| 99350a957a | |||
| 319068dc7e | |||
| cd18806c39 | |||
| 95b08b2023 | |||
| 0e70f76c86 | |||
| 4d414a2994 | |||
| 3d22772d4e | |||
| 0b381e2570 | |||
| f2cc4311c5 | |||
| e349671fdf | |||
| 01c02d5efa | |||
| b62b1f3870 | |||
| 8844830859 | |||
| 0c51ee4b64 | |||
| 11920d5e31 | |||
| 848ea1eb63 | |||
| a216519486 | |||
| b04606c38e | |||
| 38072beea7 | |||
| b843f1fa03 | |||
| 560d40e571 | |||
| 5f0b8161b7 | |||
| 062d482917 | |||
| 7cd1eeac30 | |||
| bafa473c8e | |||
| 750cf46b2e | |||
| 68885a4bbc | |||
| bcc99a8904 | |||
| 59fbd98db3 | |||
| b70ed425f1 | |||
| 45ef5811c8 | |||
| 3b137ac762 | |||
| 1ddb0caf73 | |||
| ae4c6fe2dd | |||
| db257af58e | |||
| 735368c71b | |||
| 9e04e3679b | |||
| 43b8414727 | |||
| 5a00187147 | |||
| cb525c7c84 | |||
| d88420dd03 | |||
| b9a983f8e0 | |||
| 42431ea7db | |||
| f9459e4abb | |||
| 72f917d611 | |||
| 9fd1d19e93 | |||
| 062af1ac08 | |||
| 41bd76e091 | |||
| cfd3f4b199 | |||
| 79d38f9597 | |||
| b3866559e1 | |||
| 4d186baa35 | |||
| 8ed3d5f3db | |||
| f0c8f39b6d | |||
| 431db8fc9b | |||
| ba252c5356 | |||
| a2812c39c0 | |||
| 0490758820 | |||
| 9b36a5c8a6 | |||
| c1cf2be533 | |||
| 109650faf3 | |||
| e54eaab842 | |||
| 43b6297b5d | |||
| c20f4f5adf | |||
| dc1f222cd2 | |||
| c2b687212c | |||
| 849913276d | |||
| 23579c1e4a | |||
| e031161fd4 | |||
| 4800ee6c0a | |||
| d3a7fef9b0 | |||
| 40822fe77a | |||
| 837b670213 | |||
| 57ce69f3fb | |||
| be022c4894 | |||
| 8a366964bb | |||
| ee86b68470 | |||
| 60352307aa | |||
| 3ebd2f746f | |||
| 1c1a65b637 | |||
| 010e60d029 | |||
| 7a25568861 | |||
| 5f4f913661 | |||
| ccd0e34a53 | |||
| 72f1ffccd3 | |||
| ea7a52945f | |||
| 89d4d1351a | |||
| b757c91d93 | |||
| 27203d7a4d | |||
| 9ad4e18ac5 | |||
| fcdc8f3ce7 | |||
| 78b994b84a | |||
| 58bfc677e2 | |||
| 7d17285a0c | |||
| e9eb00a0d4 | |||
| 48d07af574 | |||
| 2fc62efd88 | |||
| be516d75bd | |||
| 951d5fde85 | |||
| 1389abc052 | |||
| 19ad67a77f | |||
| 641f308344 | |||
| 9f097fa4d5 | |||
| 5ad362c52b | |||
| 614f238a61 | |||
| dec91950bc | |||
| 6cef9c23f0 | |||
| 3f568bf136 | |||
| 5484b421ce | |||
| 02f21e07d3 | |||
| fff1f23a83 | |||
| a056ec0d38 | |||
| 2eb9e5dde3 | |||
| 627d2a4701 | |||
| 76895fe86d | |||
| 64c3c85780 | |||
| 7288348857 | |||
| 62e73299b1 | |||
| fe76c41ed8 | |||
| 1a92edf8be | |||
| b63b606a4e | |||
| 8e2ef3d22b | |||
| c6c4a32283 | |||
| b70b3b158e | |||
| 3d59ab8108 | |||
| b6c3089510 | |||
| bd92aac280 | |||
| 5299e802e9 | |||
| 8e5a57d7dd | |||
| beaa324fb6 | |||
| 79e64fe206 | |||
| 93f525e3fe | |||
| aacb803c64 | |||
| 8a0665b222 | |||
| 20e41a7f73 | |||
| 93a1699a35 | |||
| c33c07e4af | |||
| c7484d0cc9 | |||
| fb85a7bb35 | |||
| 42ff9a4d34 | |||
| 005e9eae7c | |||
| 3e325debcc | |||
| a221de9a2b | |||
| 32b0cc1865 | |||
| bbf85f8a12 | |||
| 67a0172b28 | |||
| fb19d4d45b | |||
| a156b1af14 | |||
| a604b4943c | |||
| 3f0b6435d9 | |||
| e0f029e2cb | |||
| 89d3fd5fab | |||
| a38b00be6b | |||
| 0e8d52b591 | |||
| 298c77740d | |||
| c681aae8ee | |||
| faef98b089 | |||
| 84a3e0a30b | |||
| 69bd553ce0 | |||
| fd0c0f8975 | |||
| 860ceb06b4 | |||
| ecf501bf72 | |||
| 81a2ed1e25 | |||
| 76ab28338a | |||
| 9a56c9630f | |||
| 53b9497c18 | |||
| 750b16b6ee | |||
| 0ee3e0779a | |||
| 333c2d9299 | |||
| ad37ff5048 | |||
| 33f86f3bde | |||
| 8acb969a49 | |||
| b74b5933b8 | |||
| 681c556b7e | |||
| 1746684e52 | |||
| 0b93d06555 | |||
| 8a8b8c7c27 | |||
| 6b6577006d | |||
| 5c14ebb049 | |||
| 9717a736b1 | |||
| 9e7fe773bd | |||
| 5c4326c302 |
@@ -8,3 +8,7 @@
|
||||
### Modifications
|
||||
|
||||
<!--简单解释你的改动-->
|
||||
|
||||
### Check
|
||||
- [ ] 我的 Commit Message 符合良好的[规范](https://www.conventionalcommits.org/en/v1.0.0/#summary)
|
||||
- [ ] 我新增/修复/优化的功能经过良好的测试
|
||||
|
||||
+10
-2
@@ -9,12 +9,20 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
python3-dev \
|
||||
libffi-dev \
|
||||
libssl-dev \
|
||||
ca-certificates \
|
||||
bash \
|
||||
&& apt-get clean \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
RUN python -m pip install -r requirements.txt --no-cache-dir
|
||||
RUN python -m pip install uv
|
||||
RUN uv pip install -r requirements.txt --no-cache-dir --system
|
||||
RUN uv pip install socksio uv pyffmpeg pilk --no-cache-dir --system
|
||||
|
||||
RUN python -m pip install socksio wechatpy cryptography --no-cache-dir
|
||||
# 释出 ffmpeg
|
||||
RUN python -c "from pyffmpeg import FFmpeg; ff = FFmpeg();"
|
||||
|
||||
# add /root/.pyffmpeg/bin/ffmpeg to PATH, inorder to use ffmpeg
|
||||
RUN echo 'export PATH=$PATH:/root/.pyffmpeg/bin' >> ~/.bashrc
|
||||
|
||||
EXPOSE 6185
|
||||
EXPOSE 6186
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
FROM python:3.10-slim
|
||||
|
||||
WORKDIR /AstrBot
|
||||
|
||||
COPY . /AstrBot/
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
gcc \
|
||||
build-essential \
|
||||
python3-dev \
|
||||
libffi-dev \
|
||||
libssl-dev \
|
||||
curl \
|
||||
unzip \
|
||||
ca-certificates \
|
||||
bash \
|
||||
&& apt-get clean \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Installation of Node.js
|
||||
ENV NVM_DIR="/root/.nvm"
|
||||
RUN curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.2/install.sh | bash && \
|
||||
. "$NVM_DIR/nvm.sh" && \
|
||||
nvm install 22 && \
|
||||
nvm use 22
|
||||
RUN /bin/bash -c ". \"$NVM_DIR/nvm.sh\" && node -v && npm -v"
|
||||
|
||||
RUN python -m pip install uv
|
||||
RUN uv pip install -r requirements.txt --no-cache-dir --system
|
||||
RUN uv pip install socksio uv pyffmpeg --no-cache-dir --system
|
||||
|
||||
EXPOSE 6185
|
||||
EXPOSE 6186
|
||||
|
||||
CMD ["python", "main.py"]
|
||||
@@ -1,6 +1,6 @@
|
||||
<p align="center">
|
||||
|
||||

|
||||
|
||||

|
||||
|
||||
</p>
|
||||
|
||||
@@ -15,7 +15,9 @@ _✨ 易上手的多平台 LLM 聊天机器人及开发框架 ✨_
|
||||
<a href="https://hub.docker.com/r/soulter/astrbot"><img alt="Docker pull" src="https://img.shields.io/docker/pulls/soulter/astrbot.svg?style=for-the-badge&color=76bad9"/></a>
|
||||
<a href="https://qm.qq.com/cgi-bin/qm/qr?k=wtbaNx7EioxeaqS9z7RQWVXPIxg2zYr7&jump_from=webapi&authKey=vlqnv/AV2DbJEvGIcxdlNSpfxVy+8vVqijgreRdnVKOaydpc+YSw4MctmEbr0k5"><img alt="Static Badge" src="https://img.shields.io/badge/QQ群-775869627-purple?style=for-the-badge&color=76bad9"></a>
|
||||
[](https://wakatime.com/badge/user/915e5316-99c6-4563-a483-ef186cf000c9/project/018e705a-a1a7-409a-a849-3013485e6c8e)
|
||||

|
||||

|
||||

|
||||
|
||||
|
||||
<a href="https://github.com/Soulter/AstrBot/blob/master/README_en.md">English</a> |
|
||||
<a href="https://github.com/Soulter/AstrBot/blob/master/README_ja.md">日本語</a> |
|
||||
@@ -30,20 +32,26 @@ AstrBot 是一个松耦合、异步、支持多消息平台部署、具有易用
|
||||
<!-- [](https://codecov.io/gh/Soulter/AstrBot)
|
||||
-->
|
||||
|
||||
## ✨ 近期更新
|
||||
|
||||
1. AstrBot 现已支持接入 [MCP](https://modelcontextprotocol.io/) 服务器!
|
||||
|
||||
## ✨ 主要功能
|
||||
|
||||
> [!NOTE]
|
||||
> 🪧 我们正基于前沿科研成果,设计并实现适用于角色扮演和情感陪伴的长短期记忆模型及情绪控制模型,旨在提升对话的真实性与情感表达能力。敬请期待 `v3.6.0` 版本!
|
||||
|
||||
1. **大语言模型对话**。支持各种大语言模型,包括 OpenAI API、Google Gemini、Llama、Deepseek、ChatGLM 等,支持接入本地部署的大模型,通过 Ollama、LLMTuner。具有多轮对话、人格情境、多模态能力,支持图片理解、语音转文字(Whisper)。
|
||||
2. **支持 MCP**。AstrBot 现已支持接入 MCP 服务器。
|
||||
3. **多消息平台接入**。支持接入 QQ(OneBot)、QQ 频道、微信(Gewechat)、飞书、Telegram。后续将支持钉钉、Discord、WhatsApp、小爱音响。支持速率限制、白名单、关键词过滤、百度内容审核。
|
||||
4. **Agent**。原生支持部分 Agent 能力,如代码执行器、自然语言待办、网页搜索。对接 [Dify 平台](https://astrbot.app/others/dify.html),便捷接入 Dify 智能助手、知识库和 Dify 工作流。
|
||||
5. **插件扩展**。深度优化的插件机制,支持[开发插件](https://astrbot.app/dev/plugin.html)扩展功能,极简开发。已支持安装多个插件。
|
||||
6. **可视化管理面板**。支持可视化修改配置、插件管理、日志查看等功能,降低配置难度。集成 WebChat,可在面板上与大模型对话。
|
||||
7. **高稳定性、高模块化**。基于事件总线和流水线的架构设计,高度模块化,低耦合。
|
||||
2. **多消息平台接入**。支持接入 QQ(OneBot)、QQ 频道、微信(Gewechat)、飞书、Telegram。后续将支持钉钉、Discord、WhatsApp、小爱音响。支持速率限制、白名单、关键词过滤、百度内容审核。
|
||||
3. **Agent**。原生支持部分 Agent 能力,如代码执行器、自然语言待办、网页搜索。对接 [Dify 平台](https://dify.ai/),便捷接入 Dify 智能助手、知识库和 Dify 工作流。
|
||||
4. **插件扩展**。深度优化的插件机制,支持[开发插件](https://astrbot.app/dev/plugin.html)扩展功能,极简开发。已支持安装多个插件。
|
||||
5. **可视化管理面板**。支持可视化修改配置、插件管理、日志查看等功能,降低配置难度。集成 WebChat,可在面板上与大模型对话。
|
||||
6. **高稳定性、高模块化**。基于事件总线和流水线的架构设计,高度模块化,低耦合。
|
||||
|
||||
> [!TIP]
|
||||
> 管理面板在线体验 Demo: [https://demo.astrbot.app/](https://demo.astrbot.app/)
|
||||
> WebUI 在线体验 Demo: [https://demo.astrbot.app/](https://demo.astrbot.app/)
|
||||
>
|
||||
> 用户名: `astrbot`, 密码: `astrbot`。未配置 LLM,无法在聊天页使用大模型。(不要再修改 demo 的登录密码了 😭)
|
||||
> 用户名: `astrbot`, 密码: `astrbot`。
|
||||
|
||||
## ✨ 使用方式
|
||||
|
||||
@@ -67,7 +75,15 @@ AstrBot 是一个松耦合、异步、支持多消息平台部署、具有易用
|
||||
|
||||
#### 手动部署
|
||||
|
||||
请参阅官方文档 [通过源码部署 AstrBot](https://astrbot.app/deploy/astrbot/cli.html) 。
|
||||
推荐使用 `uv`。
|
||||
|
||||
```bash
|
||||
git clone https://github.com/AstrBotDevs/AstrBot && cd AstrBot
|
||||
pip install uv
|
||||
uv run main.py
|
||||
```
|
||||
|
||||
或者请参阅官方文档 [通过源码部署 AstrBot](https://astrbot.app/deploy/astrbot/cli.html) 。
|
||||
|
||||
#### Replit 部署
|
||||
|
||||
@@ -93,7 +109,7 @@ AstrBot 是一个松耦合、异步、支持多消息平台部署、具有易用
|
||||
|
||||
| 名称 | 支持性 | 类型 | 备注 |
|
||||
| -------- | ------- | ------- | ------- |
|
||||
| OpenAI API | ✔ | 文本生成 | 同时也支持 DeepSeek、Google Gemini、GLM(智谱)、Moonshot(月之暗面)、阿里云百炼、硅基流动、xAI 等所有兼容 OpenAI API 的服务 |
|
||||
| OpenAI API | ✔ | 文本生成 | 也支持 DeepSeek、Google Gemini、GLM、Kimi、硅基流动、xAI 等兼容 OpenAI API 的服务 |
|
||||
| Claude API | ✔ | 文本生成 | |
|
||||
| Google Gemini API | ✔ | 文本生成 | |
|
||||
| Dify | ✔ | LLMOps | |
|
||||
@@ -135,38 +151,36 @@ pre-commit install
|
||||
|
||||
## ✨ Demo
|
||||
|
||||
> [!NOTE]
|
||||
> 代码执行器的文件输入/输出目前仅测试了 Napcat(QQ), Lagrange(QQ)
|
||||
|
||||
<div align='center'>
|
||||
|
||||
<img src="https://github.com/user-attachments/assets/4ee688d9-467d-45c8-99d6-368f9a8a92d8" width="600">
|
||||
|
||||
_✨基于 Docker 的沙箱化代码执行器(Beta 测试中)✨_
|
||||
_✨基于 Docker 的沙箱化代码执行器(Beta 测试)✨_
|
||||
|
||||
<img src="https://github.com/user-attachments/assets/0378f407-6079-4f64-ae4c-e97ab20611d2" height=500>
|
||||
|
||||
_✨ 多模态、网页搜索、长文本转图片(可配置) ✨_
|
||||
|
||||
<img src="https://github.com/user-attachments/assets/8ec12797-e70f-460a-959e-48eca39ca2bb" height=100>
|
||||
|
||||
_✨ 自然语言待办事项 ✨_
|
||||
|
||||
<img src="https://github.com/user-attachments/assets/e137a9e1-340a-4bf2-bb2b-771132780735" height=150>
|
||||
<img src="https://github.com/user-attachments/assets/480f5e82-cf6a-4955-a869-0d73137aa6e1" height=150>
|
||||
|
||||
_✨ 插件系统——部分插件展示 ✨_
|
||||
|
||||
<img src="https://github.com/user-attachments/assets/592a8630-14c7-4e06-b496-9c0386e4f36c" width=600>
|
||||
<img src="https://github.com/user-attachments/assets/0cdbf564-2f59-4da5-b524-ce0e7ef3d978" width=600>
|
||||
|
||||
_✨ 管理面板 ✨_
|
||||
|
||||

|
||||
|
||||
_✨ 内置 Web Chat,在线与机器人交互 ✨_
|
||||
_✨ WebUI ✨_
|
||||
|
||||
</div>
|
||||
|
||||
## ❤️ Special Thanks
|
||||
|
||||
特别感谢所有 Contributors 和插件开发者对 AstrBot 的贡献 ❤️
|
||||
|
||||
<a href="https://github.com/AstrBotDevs/AstrBot/graphs/contributors">
|
||||
<img src="https://contrib.rocks/image?repo=AstrBotDevs/AstrBot" />
|
||||
</a>
|
||||
|
||||
|
||||
## ⭐ Star History
|
||||
|
||||
> [!TIP]
|
||||
|
||||
+1
-1
@@ -28,7 +28,7 @@ AstrBot is a loosely coupled, asynchronous chatbot and development framework tha
|
||||
|
||||
1. **LLM Conversations** - Supports various LLMs including OpenAI API, Google Gemini, Llama, Deepseek, ChatGLM, etc. Enables local model deployment via Ollama/LLMTuner. Features multi-turn dialogues, personality contexts, multimodal capabilities (image understanding), and speech-to-text (Whisper).
|
||||
2. **Multi-platform Integration** - Supports QQ (OneBot), QQ Channels, WeChat (Gewechat), Feishu, and Telegram. Planned support for DingTalk, Discord, WhatsApp, and Xiaomi Smart Speakers. Includes rate limiting, whitelisting, keyword filtering, and Baidu content moderation.
|
||||
3. **Agent Capabilities** - Native support for code execution, natural language TODO lists, web search. Integrates with [Dify Platform](https://astrbot.app/others/dify.html) for easy access to Dify assistants/knowledge bases/workflows.
|
||||
3. **Agent Capabilities** - Native support for code execution, natural language TODO lists, web search. Integrates with [Dify Platform](https://dify.ai/) for easy access to Dify assistants/knowledge bases/workflows.
|
||||
4. **Plugin System** - Optimized plugin mechanism with minimal development effort. Supports multiple installed plugins.
|
||||
5. **Web Dashboard** - Visual configuration management, plugin controls, logging, and WebChat interface for direct LLM interaction.
|
||||
6. **High Stability & Modularity** - Event bus and pipeline architecture ensures high modularization and loose coupling.
|
||||
|
||||
+1
-1
@@ -28,7 +28,7 @@ AstrBot は、疎結合、非同期、複数のメッセージプラットフォ
|
||||
|
||||
1. **大規模言語モデルの対話**。OpenAI API、Google Gemini、Llama、Deepseek、ChatGLM など、さまざまな大規模言語モデルをサポートし、Ollama、LLMTuner を介してローカルにデプロイされた大規模モデルをサポートします。多輪対話、人格シナリオ、多モーダル機能を備え、画像理解、音声からテキストへの変換(Whisper)をサポートします。
|
||||
2. **複数のメッセージプラットフォームの接続**。QQ(OneBot)、QQ チャンネル、WeChat(Gewechat)、Feishu、Telegram への接続をサポートします。今後、DingTalk、Discord、WhatsApp、Xiaoai 音響をサポートする予定です。レート制限、ホワイトリスト、キーワードフィルタリング、Baidu コンテンツ監査をサポートします。
|
||||
3. **エージェント**。一部のエージェント機能をネイティブにサポートし、コードエグゼキューター、自然言語タスク、ウェブ検索などを提供します。[Dify プラットフォーム](https://astrbot.app/others/dify.html)と連携し、Dify スマートアシスタント、ナレッジベース、Dify ワークフローを簡単に接続できます。
|
||||
3. **エージェント**。一部のエージェント機能をネイティブにサポートし、コードエグゼキューター、自然言語タスク、ウェブ検索などを提供します。[Dify プラットフォーム](https://dify.ai/)と連携し、Dify スマートアシスタント、ナレッジベース、Dify ワークフローを簡単に接続できます。
|
||||
4. **プラグインの拡張**。深く最適化されたプラグインメカニズムを備え、[プラグインの開発](https://astrbot.app/dev/plugin.html)をサポートし、機能を拡張できます。複数のプラグインのインストールをサポートします。
|
||||
5. **ビジュアル管理パネル**。設定の視覚的な変更、プラグイン管理、ログの表示などをサポートし、設定の難易度を低減します。WebChat を統合し、パネル上で大規模モデルと対話できます。
|
||||
6. **高い安定性と高いモジュール性**。イベントバスとパイプラインに基づくアーキテクチャ設計により、高度にモジュール化され、低結合です。
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
from astrbot.core.provider import Provider, STTProvider, Personality
|
||||
from astrbot.core.provider.entites import (
|
||||
from astrbot.core.provider.entities import (
|
||||
ProviderRequest,
|
||||
ProviderType,
|
||||
ProviderMetaData,
|
||||
|
||||
@@ -2,11 +2,7 @@ from astrbot.core.star.register import (
|
||||
register_star as register, # 注册插件(Star)
|
||||
)
|
||||
|
||||
from astrbot.core.star import Context, Star
|
||||
from astrbot.core.star import Context, Star, StarTools
|
||||
from astrbot.core.star.config import *
|
||||
|
||||
__all__ = [
|
||||
"register",
|
||||
"Context",
|
||||
"Star",
|
||||
]
|
||||
__all__ = ["register", "Context", "Star", "StarTools"]
|
||||
|
||||
@@ -8,6 +8,7 @@ from astrbot.core.db.sqlite import SQLiteDatabase
|
||||
from astrbot.core.config.default import DB_PATH
|
||||
from astrbot.core.config import AstrBotConfig
|
||||
|
||||
# 初始化数据存储文件夹
|
||||
os.makedirs("data", exist_ok=True)
|
||||
|
||||
astrbot_config = AstrBotConfig()
|
||||
@@ -19,8 +20,11 @@ if os.environ.get("TESTING", ""):
|
||||
logger.setLevel("DEBUG")
|
||||
|
||||
db_helper = SQLiteDatabase(DB_PATH)
|
||||
sp = SharedPreferences() # 简单的偏好设置存储
|
||||
sp = (
|
||||
SharedPreferences()
|
||||
) # 简单的偏好设置存储, 这里后续应该存储到数据库中, 一些部分可以存储到配置中
|
||||
pip_installer = PipInstaller(astrbot_config.get("pip_install_arg", ""))
|
||||
web_chat_queue = asyncio.Queue(maxsize=32)
|
||||
web_chat_back_queue = asyncio.Queue(maxsize=32)
|
||||
WEBUI_SK = "Advanced_System_for_Text_Response_and_Bot_Operations_Tool"
|
||||
DEMO_MODE = os.getenv("DEMO_MODE", False)
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
如需修改配置,请在 `data/cmd_config.json` 中修改或者在管理面板中可视化修改。
|
||||
"""
|
||||
|
||||
VERSION = "3.5.0"
|
||||
VERSION = "3.5.3.1"
|
||||
DB_PATH = "data/data_v3.db"
|
||||
|
||||
# 默认配置
|
||||
@@ -50,6 +50,8 @@ DEFAULT_CONFIG = {
|
||||
"default_personality": "default",
|
||||
"prompt_prefix": "",
|
||||
"max_context_length": -1,
|
||||
"dequeue_context_length": 1,
|
||||
"streaming_response": False,
|
||||
},
|
||||
"provider_stt_settings": {
|
||||
"enable": False,
|
||||
@@ -98,6 +100,7 @@ DEFAULT_CONFIG = {
|
||||
"plugin_repo_mirror": "",
|
||||
"knowledge_db": {},
|
||||
"persona": [],
|
||||
"timezone": "",
|
||||
}
|
||||
|
||||
|
||||
@@ -246,6 +249,9 @@ CONFIG_METADATA_2 = {
|
||||
"description": "平台设置",
|
||||
"type": "object",
|
||||
"items": {
|
||||
"plugin_enable": {
|
||||
"invisible": True, # 隐藏插件启用配置
|
||||
},
|
||||
"unique_session": {
|
||||
"description": "会话隔离",
|
||||
"type": "bool",
|
||||
@@ -519,7 +525,14 @@ CONFIG_METADATA_2 = {
|
||||
"api_base": "https://generativelanguage.googleapis.com/",
|
||||
"timeout": 120,
|
||||
"model_config": {
|
||||
"model": "gemini-1.5-flash",
|
||||
"model": "gemini-2.0-flash-exp",
|
||||
},
|
||||
"gm_resp_image_modal": False,
|
||||
"gm_safety_settings": {
|
||||
"harassment": "BLOCK_MEDIUM_AND_ABOVE",
|
||||
"hate_speech": "BLOCK_MEDIUM_AND_ABOVE",
|
||||
"sexually_explicit": "BLOCK_MEDIUM_AND_ABOVE",
|
||||
"dangerous_content": "BLOCK_MEDIUM_AND_ABOVE",
|
||||
},
|
||||
},
|
||||
"DeepSeek": {
|
||||
@@ -670,12 +683,82 @@ CONFIG_METADATA_2 = {
|
||||
"fishaudio-tts-character": "可莉",
|
||||
"timeout": "20",
|
||||
},
|
||||
"阿里云百炼_TTS(API)": {
|
||||
"id": "dashscope_tts",
|
||||
"type": "dashscope_tts",
|
||||
"enable": False,
|
||||
"api_key": "",
|
||||
"model": "cosyvoice-v1",
|
||||
"dashscope_tts_voice": "loongstella",
|
||||
"timeout": "20",
|
||||
},
|
||||
},
|
||||
"items": {
|
||||
"dashscope_tts_voice": {
|
||||
"description": "语音合成模型",
|
||||
"type": "string",
|
||||
"hint": "阿里云百炼语音合成模型名称。具体可参考 https://help.aliyun.com/zh/model-studio/developer-reference/cosyvoice-python-api 等内容",
|
||||
},
|
||||
"gm_resp_image_modal": {
|
||||
"description": "启用图片模态",
|
||||
"type": "bool",
|
||||
"hint": "启用后,将支持返回图片内容。需要模型支持,否则会报错。具体支持模型请查看 Google Gemini 官方网站。温馨提示,如果您需要生成图片,请关闭 `启用群员识别` 配置获得更好的效果。",
|
||||
},
|
||||
"gm_safety_settings": {
|
||||
"description": "安全过滤器",
|
||||
"type": "object",
|
||||
"hint": "设置模型输入的内容安全过滤级别。过滤级别分类为NONE(不屏蔽)、HIGH(高风险时屏蔽)、MEDIUM_AND_ABOVE(中等风险及以上屏蔽)、LOW_AND_ABOVE(低风险及以上时屏蔽),具体参见Gemini API文档。",
|
||||
"items": {
|
||||
"harassment": {
|
||||
"description": "骚扰内容",
|
||||
"type": "string",
|
||||
"hint": "负面或有害评论",
|
||||
"options": [
|
||||
"BLOCK_NONE",
|
||||
"BLOCK_ONLY_HIGH",
|
||||
"BLOCK_MEDIUM_AND_ABOVE",
|
||||
"BLOCK_LOW_AND_ABOVE",
|
||||
],
|
||||
},
|
||||
"hate_speech": {
|
||||
"description": "仇恨言论",
|
||||
"type": "string",
|
||||
"hint": "粗鲁、无礼或亵渎性质内容",
|
||||
"options": [
|
||||
"BLOCK_NONE",
|
||||
"BLOCK_ONLY_HIGH",
|
||||
"BLOCK_MEDIUM_AND_ABOVE",
|
||||
"BLOCK_LOW_AND_ABOVE",
|
||||
],
|
||||
},
|
||||
"sexually_explicit": {
|
||||
"description": "露骨色情内容",
|
||||
"type": "string",
|
||||
"hint": "包含性行为或其他淫秽内容的引用",
|
||||
"options": [
|
||||
"BLOCK_NONE",
|
||||
"BLOCK_ONLY_HIGH",
|
||||
"BLOCK_MEDIUM_AND_ABOVE",
|
||||
"BLOCK_LOW_AND_ABOVE",
|
||||
],
|
||||
},
|
||||
"dangerous_content": {
|
||||
"description": "危险内容",
|
||||
"type": "string",
|
||||
"hint": "宣扬、助长或鼓励有害行为的信息",
|
||||
"options": [
|
||||
"BLOCK_NONE",
|
||||
"BLOCK_ONLY_HIGH",
|
||||
"BLOCK_MEDIUM_AND_ABOVE",
|
||||
"BLOCK_LOW_AND_ABOVE",
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
"rag_options": {
|
||||
"description": "RAG 选项",
|
||||
"type": "object",
|
||||
"hint": "检索知识库设置, 非必填。仅 Agent 应用类型支持(智能体应用, 包括 RAG 应用)",
|
||||
"hint": "检索知识库设置, 非必填。仅 Agent 应用类型支持(智能体应用, 包括 RAG 应用)。阿里云百炼应用开启此功能后将无法多轮对话。",
|
||||
"items": {
|
||||
"pipeline_ids": {
|
||||
"description": "知识库 ID 列表",
|
||||
@@ -845,8 +928,8 @@ CONFIG_METADATA_2 = {
|
||||
"dify_api_type": {
|
||||
"description": "Dify 应用类型",
|
||||
"type": "string",
|
||||
"hint": "Dify API 类型。根据 Dify 官网,目前支持 chat, agent, workflow 三种应用类型",
|
||||
"options": ["chat", "agent", "workflow"],
|
||||
"hint": "Dify API 类型。根据 Dify 官网,目前支持 chat, chatflow, agent, workflow 三种应用类型。",
|
||||
"options": ["chat", "chatflow", "agent", "workflow"],
|
||||
},
|
||||
"dify_workflow_output_key": {
|
||||
"description": "Dify Workflow 输出变量名",
|
||||
@@ -915,6 +998,16 @@ CONFIG_METADATA_2 = {
|
||||
"type": "int",
|
||||
"hint": "超出这个数量时将丢弃最旧的部分,用户和AI的一轮聊天记为 1 条。-1 表示不限制,默认为不限制。",
|
||||
},
|
||||
"dequeue_context_length": {
|
||||
"description": "丢弃对话数量(条)",
|
||||
"type": "int",
|
||||
"hint": "超出 最多携带对话数量(条) 时,丢弃多少条记录,用户和AI的一轮聊天记为 1 条。适宜的配置,可以提高超长上下文对话 deepseek 命中缓存效果,理想情况下计费将降低到1/3以下",
|
||||
},
|
||||
"streaming_response": {
|
||||
"description": "启用流式回复",
|
||||
"type": "bool",
|
||||
"hint": "启用后,将会流式输出 LLM 的响应。目前仅支持 OpenAI API提供商 以及 Telegram、QQ Official 私聊 两个平台",
|
||||
},
|
||||
},
|
||||
},
|
||||
"persona": {
|
||||
@@ -1095,6 +1188,12 @@ CONFIG_METADATA_2 = {
|
||||
"type": "string",
|
||||
"hint": "启用后,会以添加环境变量的方式设置代理。格式为 `http://ip:port`",
|
||||
},
|
||||
"timezone": {
|
||||
"description": "时区",
|
||||
"type": "string",
|
||||
"obvious_hint": True,
|
||||
"hint": "时区设置。请填写 IANA 时区名称, 如 Asia/Shanghai, 为空时使用系统默认时区。所有时区请查看: https://data.iana.org/time-zones/tzdb-2021a/zone1970.tab",
|
||||
},
|
||||
"log_level": {
|
||||
"description": "控制台日志级别",
|
||||
"type": "string",
|
||||
|
||||
@@ -1,3 +1,10 @@
|
||||
"""
|
||||
AstrBot 会话-对话管理器, 维护两个本地存储, 其中一个是 json 格式的shared_preferences, 另外一个是数据库
|
||||
|
||||
在 AstrBot 中, 会话和对话是独立的, 会话用于标记对话窗口, 例如群聊"123456789"可以建立一个会话,
|
||||
在一个会话中可以建立多个对话, 并且支持对话的切换和删除
|
||||
"""
|
||||
|
||||
import uuid
|
||||
import json
|
||||
import asyncio
|
||||
@@ -11,24 +18,34 @@ class ConversationManager:
|
||||
"""负责管理会话与 LLM 的对话,某个会话当前正在用哪个对话。"""
|
||||
|
||||
def __init__(self, db_helper: BaseDatabase):
|
||||
# session_conversations 字典记录会话ID-对话ID 映射关系
|
||||
self.session_conversations: Dict[str, str] = sp.get("session_conversation", {})
|
||||
self.db = db_helper
|
||||
self.save_interval = 60 # 每 60 秒保存一次
|
||||
self._start_periodic_save()
|
||||
|
||||
def _start_periodic_save(self):
|
||||
"""启动定时保存任务"""
|
||||
asyncio.create_task(self._periodic_save())
|
||||
|
||||
async def _periodic_save(self):
|
||||
"""定时保存会话对话映射关系到存储中"""
|
||||
while True:
|
||||
await asyncio.sleep(self.save_interval)
|
||||
self._save_to_storage()
|
||||
|
||||
def _save_to_storage(self):
|
||||
"""保存会话对话映射关系到存储中"""
|
||||
sp.put("session_conversation", self.session_conversations)
|
||||
|
||||
async def new_conversation(self, unified_msg_origin: str) -> str:
|
||||
"""新建对话,并将当前会话的对话转移到新对话"""
|
||||
"""新建对话,并将当前会话的对话转移到新对话
|
||||
|
||||
Args:
|
||||
unified_msg_origin (str): 统一的消息来源字符串。格式为 platform_name:message_type:session_id
|
||||
Returns:
|
||||
conversation_id (str): 对话 ID, 是 uuid 格式的字符串
|
||||
"""
|
||||
conversation_id = str(uuid.uuid4())
|
||||
self.db.new_conversation(user_id=unified_msg_origin, cid=conversation_id)
|
||||
self.session_conversations[unified_msg_origin] = conversation_id
|
||||
@@ -36,14 +53,24 @@ class ConversationManager:
|
||||
return conversation_id
|
||||
|
||||
async def switch_conversation(self, unified_msg_origin: str, conversation_id: str):
|
||||
"""切换会话的对话"""
|
||||
"""切换会话的对话
|
||||
|
||||
Args:
|
||||
unified_msg_origin (str): 统一的消息来源字符串。格式为 platform_name:message_type:session_id
|
||||
conversation_id (str): 对话 ID, 是 uuid 格式的字符串
|
||||
"""
|
||||
self.session_conversations[unified_msg_origin] = conversation_id
|
||||
sp.put("session_conversation", self.session_conversations)
|
||||
|
||||
async def delete_conversation(
|
||||
self, unified_msg_origin: str, conversation_id: str = None
|
||||
):
|
||||
"""删除会话的对话,当 conversation_id 为 None 时删除会话当前的对话"""
|
||||
"""删除会话的对话,当 conversation_id 为 None 时删除会话当前的对话
|
||||
|
||||
Args:
|
||||
unified_msg_origin (str): 统一的消息来源字符串。格式为 platform_name:message_type:session_id
|
||||
conversation_id (str): 对话 ID, 是 uuid 格式的字符串
|
||||
"""
|
||||
conversation_id = self.session_conversations.get(unified_msg_origin)
|
||||
if conversation_id:
|
||||
self.db.delete_conversation(user_id=unified_msg_origin, cid=conversation_id)
|
||||
@@ -51,23 +78,48 @@ class ConversationManager:
|
||||
sp.put("session_conversation", self.session_conversations)
|
||||
|
||||
async def get_curr_conversation_id(self, unified_msg_origin: str) -> str:
|
||||
"""获取会话当前的对话 ID"""
|
||||
"""获取会话当前的对话 ID
|
||||
|
||||
Args:
|
||||
unified_msg_origin (str): 统一的消息来源字符串。格式为 platform_name:message_type:session_id
|
||||
Returns:
|
||||
conversation_id (str): 对话 ID, 是 uuid 格式的字符串
|
||||
"""
|
||||
return self.session_conversations.get(unified_msg_origin, None)
|
||||
|
||||
async def get_conversation(
|
||||
self, unified_msg_origin: str, conversation_id: str
|
||||
) -> Conversation:
|
||||
"""获取会话的对话"""
|
||||
"""获取会话的对话
|
||||
|
||||
Args:
|
||||
unified_msg_origin (str): 统一的消息来源字符串。格式为 platform_name:message_type:session_id
|
||||
conversation_id (str): 对话 ID, 是 uuid 格式的字符串
|
||||
Returns:
|
||||
conversation (Conversation): 对话对象
|
||||
"""
|
||||
return self.db.get_conversation_by_user_id(unified_msg_origin, conversation_id)
|
||||
|
||||
async def get_conversations(self, unified_msg_origin: str) -> List[Conversation]:
|
||||
"""获取会话的所有对话"""
|
||||
"""获取会话的所有对话
|
||||
|
||||
Args:
|
||||
unified_msg_origin (str): 统一的消息来源字符串。格式为 platform_name:message_type:session_id
|
||||
Returns:
|
||||
conversations (List[Conversation]): 对话对象列表
|
||||
"""
|
||||
return self.db.get_conversations(unified_msg_origin)
|
||||
|
||||
async def update_conversation(
|
||||
self, unified_msg_origin: str, conversation_id: str, history: List[Dict]
|
||||
):
|
||||
"""更新会话的对话"""
|
||||
"""更新会话的对话
|
||||
|
||||
Args:
|
||||
unified_msg_origin (str): 统一的消息来源字符串。格式为 platform_name:message_type:session_id
|
||||
conversation_id (str): 对话 ID, 是 uuid 格式的字符串
|
||||
history (List[Dict]): 对话历史记录, 是一个字典列表, 每个字典包含 role 和 content 字段
|
||||
"""
|
||||
if conversation_id:
|
||||
self.db.update_conversation(
|
||||
user_id=unified_msg_origin,
|
||||
@@ -76,7 +128,12 @@ class ConversationManager:
|
||||
)
|
||||
|
||||
async def update_conversation_title(self, unified_msg_origin: str, title: str):
|
||||
"""更新会话的对话标题"""
|
||||
"""更新会话的对话标题
|
||||
|
||||
Args:
|
||||
unified_msg_origin (str): 统一的消息来源字符串。格式为 platform_name:message_type:session_id
|
||||
title (str): 对话标题
|
||||
"""
|
||||
conversation_id = self.session_conversations.get(unified_msg_origin)
|
||||
if conversation_id:
|
||||
self.db.update_conversation_title(
|
||||
@@ -86,7 +143,12 @@ class ConversationManager:
|
||||
async def update_conversation_persona_id(
|
||||
self, unified_msg_origin: str, persona_id: str
|
||||
):
|
||||
"""更新会话的对话 Persona ID"""
|
||||
"""更新会话的对话 Persona ID
|
||||
|
||||
Args:
|
||||
unified_msg_origin (str): 统一的消息来源字符串。格式为 platform_name:message_type:session_id
|
||||
persona_id (str): 对话 Persona ID
|
||||
"""
|
||||
conversation_id = self.session_conversations.get(unified_msg_origin)
|
||||
if conversation_id:
|
||||
self.db.update_conversation_persona_id(
|
||||
@@ -96,6 +158,14 @@ class ConversationManager:
|
||||
async def get_human_readable_context(
|
||||
self, unified_msg_origin, conversation_id, page=1, page_size=10
|
||||
):
|
||||
"""获取人类可读的上下文
|
||||
|
||||
Args:
|
||||
unified_msg_origin (str): 统一的消息来源字符串。格式为 platform_name:message_type:session_id
|
||||
conversation_id (str): 对话 ID, 是 uuid 格式的字符串
|
||||
page (int): 页码
|
||||
page_size (int): 每页大小
|
||||
"""
|
||||
conversation = await self.get_conversation(unified_msg_origin, conversation_id)
|
||||
history = json.loads(conversation.history)
|
||||
|
||||
@@ -105,7 +175,15 @@ class ConversationManager:
|
||||
if record["role"] == "user":
|
||||
temp_contexts.append(f"User: {record['content']}")
|
||||
elif record["role"] == "assistant":
|
||||
temp_contexts.append(f"Assistant: {record['content']}")
|
||||
if "content" in record and record["content"]:
|
||||
temp_contexts.append(f"Assistant: {record['content']}")
|
||||
elif "tool_calls" in record:
|
||||
tool_calls_str = json.dumps(
|
||||
record["tool_calls"], ensure_ascii=False
|
||||
)
|
||||
temp_contexts.append(f"Assistant: [函数调用] {tool_calls_str}")
|
||||
else:
|
||||
temp_contexts.append("Assistant: [未知的内容]")
|
||||
contexts.insert(0, temp_contexts)
|
||||
temp_contexts = []
|
||||
|
||||
|
||||
@@ -1,3 +1,14 @@
|
||||
"""
|
||||
Astrbot 核心生命周期管理类, 负责管理 AstrBot 的启动、停止、重启等操作。
|
||||
该类负责初始化各个组件, 包括 ProviderManager、PlatformManager、KnowledgeDBManager、ConversationManager、PluginManager、PipelineScheduler、EventBus等。
|
||||
该类还负责加载和执行插件, 以及处理事件总线的分发。
|
||||
|
||||
工作流程:
|
||||
1. 初始化所有组件
|
||||
2. 启动事件总线和任务, 所有任务都在这里运行
|
||||
3. 执行启动完成事件钩子
|
||||
"""
|
||||
|
||||
import traceback
|
||||
import asyncio
|
||||
import time
|
||||
@@ -24,31 +35,51 @@ from astrbot.core.star.star_handler import star_map
|
||||
|
||||
|
||||
class AstrBotCoreLifecycle:
|
||||
def __init__(self, log_broker: LogBroker, db: BaseDatabase):
|
||||
self.log_broker = log_broker
|
||||
self.astrbot_config = astrbot_config
|
||||
self.db = db
|
||||
"""
|
||||
AstrBot 核心生命周期管理类, 负责管理 AstrBot 的启动、停止、重启等操作。
|
||||
该类负责初始化各个组件, 包括 ProviderManager、PlatformManager、KnowledgeDBManager、ConversationManager、PluginManager、PipelineScheduler、
|
||||
EventBus 等。
|
||||
该类还负责加载和执行插件, 以及处理事件总线的分发。
|
||||
"""
|
||||
|
||||
def __init__(self, log_broker: LogBroker, db: BaseDatabase):
|
||||
self.log_broker = log_broker # 初始化日志代理
|
||||
self.astrbot_config = astrbot_config # 初始化配置
|
||||
self.db = db # 初始化数据库
|
||||
|
||||
# 根据环境变量设置代理
|
||||
os.environ["https_proxy"] = self.astrbot_config["http_proxy"]
|
||||
os.environ["http_proxy"] = self.astrbot_config["http_proxy"]
|
||||
os.environ["no_proxy"] = "localhost"
|
||||
|
||||
async def initialize(self):
|
||||
"""
|
||||
初始化 AstrBot 核心生命周期管理类, 负责初始化各个组件, 包括 ProviderManager、PlatformManager、KnowledgeDBManager、ConversationManager、PluginManager、PipelineScheduler、EventBus、AstrBotUpdator等。
|
||||
"""
|
||||
|
||||
# 初始化日志代理
|
||||
logger.info("AstrBot v" + VERSION)
|
||||
if os.environ.get("TESTING", ""):
|
||||
logger.setLevel("DEBUG")
|
||||
logger.setLevel("DEBUG") # 测试模式下设置日志级别为 DEBUG
|
||||
else:
|
||||
logger.setLevel(self.astrbot_config["log_level"])
|
||||
logger.setLevel(self.astrbot_config["log_level"]) # 设置日志级别
|
||||
|
||||
# 初始化事件队列
|
||||
self.event_queue = Queue()
|
||||
|
||||
# 初始化供应商管理器
|
||||
self.provider_manager = ProviderManager(self.astrbot_config, self.db)
|
||||
|
||||
# 初始化平台管理器
|
||||
self.platform_manager = PlatformManager(self.astrbot_config, self.event_queue)
|
||||
|
||||
# 初始化知识库管理器
|
||||
self.knowledge_db_manager = KnowledgeDBManager(self.astrbot_config)
|
||||
|
||||
# 初始化对话管理器
|
||||
self.conversation_manager = ConversationManager(self.db)
|
||||
|
||||
# 初始化提供给插件的上下文
|
||||
self.star_context = Context(
|
||||
self.event_queue,
|
||||
self.astrbot_config,
|
||||
@@ -58,35 +89,50 @@ class AstrBotCoreLifecycle:
|
||||
self.conversation_manager,
|
||||
self.knowledge_db_manager,
|
||||
)
|
||||
|
||||
# 初始化插件管理器
|
||||
self.plugin_manager = PluginManager(self.star_context, self.astrbot_config)
|
||||
|
||||
# 扫描、注册插件、实例化插件类
|
||||
await self.plugin_manager.reload()
|
||||
"""扫描、注册插件、实例化插件类"""
|
||||
|
||||
# 根据配置实例化各个 Provider
|
||||
await self.provider_manager.initialize()
|
||||
"""根据配置实例化各个 Provider"""
|
||||
|
||||
# 初始化消息事件流水线调度器
|
||||
self.pipeline_scheduler = PipelineScheduler(
|
||||
PipelineContext(self.astrbot_config, self.plugin_manager)
|
||||
)
|
||||
await self.pipeline_scheduler.initialize()
|
||||
"""初始化消息事件流水线调度器"""
|
||||
|
||||
# 初始化更新器
|
||||
self.astrbot_updator = AstrBotUpdator(self.astrbot_config["plugin_repo_mirror"])
|
||||
|
||||
# 初始化事件总线
|
||||
self.event_bus = EventBus(self.event_queue, self.pipeline_scheduler)
|
||||
|
||||
# 记录启动时间
|
||||
self.start_time = int(time.time())
|
||||
|
||||
# 初始化当前任务列表
|
||||
self.curr_tasks: List[asyncio.Task] = []
|
||||
|
||||
# 根据配置实例化各个平台适配器
|
||||
await self.platform_manager.initialize()
|
||||
"""根据配置实例化各个平台适配器"""
|
||||
|
||||
# 初始化关闭控制面板的事件
|
||||
self.dashboard_shutdown_event = asyncio.Event()
|
||||
|
||||
def _load(self):
|
||||
"""加载事件总线和任务并初始化"""
|
||||
|
||||
# 创建一个异步任务来执行事件总线的 dispatch() 方法
|
||||
# dispatch是一个无限循环的协程, 从事件队列中获取事件并处理
|
||||
event_bus_task = asyncio.create_task(
|
||||
self.event_bus.dispatch(), name="event_bus"
|
||||
)
|
||||
|
||||
# 把插件中注册的所有协程函数注册到事件总线中并执行
|
||||
extra_tasks = []
|
||||
for task in self.star_context._register_tasks:
|
||||
extra_tasks.append(asyncio.create_task(task, name=task.__name__))
|
||||
@@ -100,17 +146,24 @@ class AstrBotCoreLifecycle:
|
||||
self.start_time = int(time.time())
|
||||
|
||||
async def _task_wrapper(self, task: asyncio.Task):
|
||||
"""异步任务包装器, 用于处理异步任务执行中出现的各种异常
|
||||
|
||||
Args:
|
||||
task (asyncio.Task): 要执行的异步任务
|
||||
"""
|
||||
try:
|
||||
await task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
pass # 任务被取消, 静默处理
|
||||
except Exception as e:
|
||||
# 获取完整的异常堆栈信息, 按行分割并记录到日志中
|
||||
logger.error(f"------- 任务 {task.get_name()} 发生错误: {e}")
|
||||
for line in traceback.format_exc().split("\n"):
|
||||
logger.error(f"| {line}")
|
||||
logger.error("-------")
|
||||
|
||||
async def start(self):
|
||||
"""启动 AstrBot 核心生命周期管理类, 用load加载事件总线和任务并初始化, 执行启动完成事件钩子"""
|
||||
self._load()
|
||||
logger.info("AstrBot 启动完成。")
|
||||
|
||||
@@ -127,16 +180,29 @@ class AstrBotCoreLifecycle:
|
||||
except BaseException:
|
||||
logger.error(traceback.format_exc())
|
||||
|
||||
# 同时运行curr_tasks中的所有任务
|
||||
await asyncio.gather(*self.curr_tasks, return_exceptions=True)
|
||||
|
||||
async def stop(self):
|
||||
"""停止 AstrBot 核心生命周期管理类, 取消所有当前任务并终止各个管理器"""
|
||||
# 请求停止所有正在运行的异步任务
|
||||
for task in self.curr_tasks:
|
||||
task.cancel()
|
||||
|
||||
for plugin in self.plugin_manager.context.get_all_stars():
|
||||
try:
|
||||
await self.plugin_manager._terminate_plugin(plugin)
|
||||
except Exception as e:
|
||||
logger.warning(traceback.format_exc())
|
||||
logger.warning(
|
||||
f"插件 {plugin.name} 未被正常终止 {e!s}, 可能会导致资源泄露等问题。"
|
||||
)
|
||||
|
||||
await self.provider_manager.terminate()
|
||||
await self.platform_manager.terminate()
|
||||
self.dashboard_shutdown_event.set()
|
||||
|
||||
# 再次遍历curr_tasks等待每个任务真正结束
|
||||
for task in self.curr_tasks:
|
||||
try:
|
||||
await task
|
||||
@@ -146,6 +212,7 @@ class AstrBotCoreLifecycle:
|
||||
logger.error(f"任务 {task.get_name()} 发生错误: {e}")
|
||||
|
||||
async def restart(self):
|
||||
"""重启 AstrBot 核心生命周期管理类, 终止各个管理器并重新加载平台实例"""
|
||||
await self.provider_manager.terminate()
|
||||
await self.platform_manager.terminate()
|
||||
self.dashboard_shutdown_event.set()
|
||||
@@ -154,6 +221,7 @@ class AstrBotCoreLifecycle:
|
||||
).start()
|
||||
|
||||
def load_platform(self) -> List[asyncio.Task]:
|
||||
"""加载平台实例并返回所有平台实例的异步任务列表"""
|
||||
tasks = []
|
||||
platform_insts = self.platform_manager.get_insts()
|
||||
for platform_inst in platform_insts:
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
import json
|
||||
import aiosqlite
|
||||
import os
|
||||
from typing import Any
|
||||
from .plugin_storage import PluginStorage
|
||||
|
||||
DBPATH = "data/plugin_data/sqlite/plugin_data.db"
|
||||
|
||||
|
||||
class SQLitePluginStorage(PluginStorage):
|
||||
"""插件数据的 SQLite 存储实现类。
|
||||
|
||||
该类提供异步方式将插件数据存储到 SQLite 数据库中,支持数据的增删改查操作。
|
||||
所有数据以 (plugin, key) 作为复合主键进行索引。
|
||||
"""
|
||||
|
||||
_instance = None # Standalone instance of the class
|
||||
_db_conn = None
|
||||
db_path = None
|
||||
|
||||
def __new__(cls):
|
||||
"""
|
||||
创建或获取 SQLitePluginStorage 的单例实例。
|
||||
如果实例已存在,则返回现有实例;否则创建一个新实例。
|
||||
数据在 `data/plugin_data/sqlite/plugin_data.db` 下。
|
||||
"""
|
||||
os.makedirs(os.path.dirname(DBPATH), exist_ok=True)
|
||||
if cls._instance is None:
|
||||
cls._instance = super(SQLitePluginStorage, cls).__new__(cls)
|
||||
cls._instance.db_path = DBPATH
|
||||
return cls._instance
|
||||
|
||||
async def _init_db(self):
|
||||
"""初始化数据库连接(只执行一次)"""
|
||||
if SQLitePluginStorage._db_conn is None:
|
||||
SQLitePluginStorage._db_conn = await aiosqlite.connect(self.db_path)
|
||||
await self._setup_db()
|
||||
|
||||
async def _setup_db(self):
|
||||
"""
|
||||
异步初始化数据库。
|
||||
|
||||
创建插件数据表,如果表不存在则创建,表结构包含 plugin、key 和 value 字段,
|
||||
其中 plugin 和 key 组合作为主键。
|
||||
"""
|
||||
await self._db_conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS plugin_data (
|
||||
plugin TEXT,
|
||||
key TEXT,
|
||||
value TEXT,
|
||||
PRIMARY KEY (plugin, key)
|
||||
)
|
||||
""")
|
||||
await self._db_conn.commit()
|
||||
|
||||
async def set(self, plugin: str, key: str, value: Any):
|
||||
"""
|
||||
异步存储数据。
|
||||
|
||||
将指定插件的键值对存入数据库,如果键已存在则更新值。
|
||||
值会被序列化为 JSON 字符串后存储。
|
||||
|
||||
Args:
|
||||
plugin: 插件标识符
|
||||
key: 数据键名
|
||||
value: 要存储的数据值(任意类型,将被 JSON 序列化)
|
||||
"""
|
||||
await self._init_db()
|
||||
await self._db_conn.execute(
|
||||
"INSERT INTO plugin_data (plugin, key, value) VALUES (?, ?, ?) "
|
||||
"ON CONFLICT(plugin, key) DO UPDATE SET value = excluded.value",
|
||||
(plugin, key, json.dumps(value)),
|
||||
)
|
||||
await self._db_conn.commit()
|
||||
|
||||
async def get(self, plugin: str, key: str) -> Any:
|
||||
"""
|
||||
异步获取数据。
|
||||
|
||||
从数据库中获取指定插件和键名对应的值,
|
||||
返回的值会从 JSON 字符串反序列化为原始数据类型。
|
||||
|
||||
Args:
|
||||
plugin: 插件标识符
|
||||
key: 数据键名
|
||||
|
||||
Returns:
|
||||
Any: 存储的数据值,如果未找到则返回 None
|
||||
"""
|
||||
await self._init_db()
|
||||
async with self._db_conn.execute(
|
||||
"SELECT value FROM plugin_data WHERE plugin = ? AND key = ?",
|
||||
(plugin, key),
|
||||
) as cursor:
|
||||
row = await cursor.fetchone()
|
||||
return json.loads(row[0]) if row else None
|
||||
|
||||
async def delete(self, plugin: str, key: str):
|
||||
"""
|
||||
异步删除数据。
|
||||
|
||||
从数据库中删除指定插件和键名对应的数据项。
|
||||
|
||||
Args:
|
||||
plugin: 插件标识符
|
||||
key: 要删除的数据键名
|
||||
"""
|
||||
await self._init_db()
|
||||
await self._db_conn.execute(
|
||||
"DELETE FROM plugin_data WHERE plugin = ? AND key = ?", (plugin, key)
|
||||
)
|
||||
await self._db_conn.commit()
|
||||
@@ -6,6 +6,8 @@ from typing import List
|
||||
|
||||
@dataclass
|
||||
class Platform:
|
||||
"""平台使用统计数据"""
|
||||
|
||||
name: str
|
||||
count: int
|
||||
timestamp: int
|
||||
@@ -13,6 +15,8 @@ class Platform:
|
||||
|
||||
@dataclass
|
||||
class Provider:
|
||||
"""供应商使用统计数据"""
|
||||
|
||||
name: str
|
||||
count: int
|
||||
timestamp: int
|
||||
@@ -20,6 +24,8 @@ class Provider:
|
||||
|
||||
@dataclass
|
||||
class Plugin:
|
||||
"""插件使用统计数据"""
|
||||
|
||||
name: str
|
||||
count: int
|
||||
timestamp: int
|
||||
@@ -27,6 +33,8 @@ class Plugin:
|
||||
|
||||
@dataclass
|
||||
class Command:
|
||||
"""命令使用统计数据"""
|
||||
|
||||
name: str
|
||||
count: int
|
||||
timestamp: int
|
||||
|
||||
@@ -1,3 +1,16 @@
|
||||
"""
|
||||
事件总线, 用于处理事件的分发和处理
|
||||
事件总线是一个异步队列, 用于接收各种消息事件, 并将其发送到Scheduler调度器进行处理
|
||||
其中包含了一个无限循环的调度函数, 用于从事件队列中获取新的事件, 并创建一个新的异步任务来执行管道调度器的处理逻辑
|
||||
|
||||
class:
|
||||
EventBus: 事件总线, 用于处理事件的分发和处理
|
||||
|
||||
工作流程:
|
||||
1. 维护一个异步队列, 来接受各种消息事件
|
||||
2. 无限循环的调度函数, 从事件队列中获取新的事件, 打印日志并创建一个新的异步任务来执行管道调度器的处理逻辑
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
from asyncio import Queue
|
||||
from astrbot.core.pipeline.scheduler import PipelineScheduler
|
||||
@@ -6,21 +19,38 @@ from .platform import AstrMessageEvent
|
||||
|
||||
|
||||
class EventBus:
|
||||
"""事件总线: 用于处理事件的分发和处理
|
||||
|
||||
维护一个异步队列, 来接受各种消息事件
|
||||
"""
|
||||
|
||||
def __init__(self, event_queue: Queue, pipeline_scheduler: PipelineScheduler):
|
||||
self.event_queue = event_queue
|
||||
self.pipeline_scheduler = pipeline_scheduler
|
||||
self.event_queue = event_queue # 事件队列
|
||||
self.pipeline_scheduler = pipeline_scheduler # 管道调度器
|
||||
|
||||
async def dispatch(self):
|
||||
"""无限循环的调度函数, 从事件队列中获取新的事件, 打印日志并创建一个新的异步任务来执行管道调度器的处理逻辑"""
|
||||
while True:
|
||||
event: AstrMessageEvent = await self.event_queue.get()
|
||||
self._print_event(event)
|
||||
asyncio.create_task(self.pipeline_scheduler.execute(event))
|
||||
event: AstrMessageEvent = (
|
||||
await self.event_queue.get()
|
||||
) # 从事件队列中获取新的事件
|
||||
self._print_event(event) # 打印日志
|
||||
asyncio.create_task(
|
||||
self.pipeline_scheduler.execute(event)
|
||||
) # 创建新的异步任务来执行管道调度器的处理逻辑
|
||||
|
||||
def _print_event(self, event: AstrMessageEvent):
|
||||
"""用于记录事件信息
|
||||
|
||||
Args:
|
||||
event (AstrMessageEvent): 事件对象
|
||||
"""
|
||||
# 如果有发送者名称: [平台名] 发送者名称/发送者ID: 消息概要
|
||||
if event.get_sender_name():
|
||||
logger.info(
|
||||
f"[{event.get_platform_name()}] {event.get_sender_name()}/{event.get_sender_id()}: {event.get_message_outline()}"
|
||||
)
|
||||
# 没有发送者名称: [平台名] 发送者ID: 消息概要
|
||||
else:
|
||||
logger.info(
|
||||
f"[{event.get_platform_name()}] {event.get_sender_id()}: {event.get_message_outline()}"
|
||||
|
||||
@@ -1,3 +1,11 @@
|
||||
"""
|
||||
AstrBot 启动器,负责初始化和启动核心组件和仪表板服务器。
|
||||
|
||||
工作流程:
|
||||
1. 初始化核心生命周期, 传递数据库和日志代理实例到核心生命周期
|
||||
2. 运行核心生命周期任务和仪表板服务器
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import traceback
|
||||
from astrbot.core import logger
|
||||
@@ -8,6 +16,8 @@ from astrbot.dashboard.server import AstrBotDashboard
|
||||
|
||||
|
||||
class InitialLoader:
|
||||
"""AstrBot 启动器,负责初始化和启动核心组件和仪表板服务器。"""
|
||||
|
||||
def __init__(self, db: BaseDatabase, log_broker: LogBroker):
|
||||
self.db = db
|
||||
self.logger = logger
|
||||
@@ -27,10 +37,12 @@ class InitialLoader:
|
||||
self.dashboard_server = AstrBotDashboard(
|
||||
core_lifecycle, self.db, core_lifecycle.dashboard_shutdown_event
|
||||
)
|
||||
task = asyncio.gather(core_task, self.dashboard_server.run())
|
||||
task = asyncio.gather(
|
||||
core_task, self.dashboard_server.run()
|
||||
) # 启动核心任务和仪表板服务器
|
||||
|
||||
try:
|
||||
await task
|
||||
await task # 整个AstrBot在这里运行
|
||||
except asyncio.CancelledError:
|
||||
logger.info("🌈 正在关闭 AstrBot...")
|
||||
await core_lifecycle.stop()
|
||||
|
||||
+119
-18
@@ -1,3 +1,26 @@
|
||||
"""
|
||||
日志系统, 用于支持核心组件和插件的日志记录, 提供了日志订阅功能
|
||||
|
||||
const:
|
||||
CACHED_SIZE: 日志缓存大小, 用于限制缓存的日志数量
|
||||
log_color_config: 日志颜色配置, 定义了不同日志级别的颜色
|
||||
|
||||
class:
|
||||
LogBroker: 日志代理类, 用于缓存和分发日志消息
|
||||
LogQueueHandler: 日志处理器, 用于将日志消息发送到 LogBroker
|
||||
LogManager: 日志管理器, 用于创建和配置日志记录器
|
||||
|
||||
function:
|
||||
is_plugin_path: 检查文件路径是否来自插件目录
|
||||
get_short_level_name: 将日志级别名称转换为四个字母的缩写
|
||||
|
||||
工作流程:
|
||||
1. 通过 LogManager.GetLogger() 获取日志器, 配置了控制台输出和多个格式化过滤器
|
||||
2. 通过 set_queue_handler() 设置日志处理器, 将日志消息发送到 LogBroker
|
||||
3. logBroker 维护一个订阅者列表, 负责将日志分发给所有订阅者
|
||||
4. 订阅者可以使用 register() 方法注册到 LogBroker, 订阅日志流
|
||||
"""
|
||||
|
||||
import logging
|
||||
import colorlog
|
||||
import asyncio
|
||||
@@ -6,7 +29,9 @@ from collections import deque
|
||||
from asyncio import Queue
|
||||
from typing import List
|
||||
|
||||
# 日志缓存大小
|
||||
CACHED_SIZE = 200
|
||||
# 日志颜色配置
|
||||
log_color_config = {
|
||||
"DEBUG": "green",
|
||||
"INFO": "bold_cyan",
|
||||
@@ -19,8 +44,13 @@ log_color_config = {
|
||||
|
||||
|
||||
def is_plugin_path(pathname):
|
||||
"""
|
||||
检查文件路径是否来自插件目录
|
||||
"""检查文件路径是否来自插件目录
|
||||
|
||||
Args:
|
||||
pathname (str): 文件路径
|
||||
|
||||
Returns:
|
||||
bool: 如果路径来自插件目录,则返回 True,否则返回 False
|
||||
"""
|
||||
if not pathname:
|
||||
return False
|
||||
@@ -30,8 +60,13 @@ def is_plugin_path(pathname):
|
||||
|
||||
|
||||
def get_short_level_name(level_name):
|
||||
"""
|
||||
将日志级别名称转换为四个字母的缩写
|
||||
"""将日志级别名称转换为四个字母的缩写
|
||||
|
||||
Args:
|
||||
level_name (str): 日志级别名称, 如 "DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"
|
||||
|
||||
Returns:
|
||||
str: 四个字母的日志级别缩写
|
||||
"""
|
||||
level_map = {
|
||||
"DEBUG": "DBUG",
|
||||
@@ -44,12 +79,21 @@ def get_short_level_name(level_name):
|
||||
|
||||
|
||||
class LogBroker:
|
||||
"""日志代理类, 用于缓存和分发日志消息
|
||||
|
||||
发布-订阅模式
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self.log_cache = deque(maxlen=CACHED_SIZE)
|
||||
self.subscribers: List[Queue] = []
|
||||
self.log_cache = deque(maxlen=CACHED_SIZE) # 环形缓冲区, 保存最近的日志
|
||||
self.subscribers: List[Queue] = [] # 订阅者列表
|
||||
|
||||
def register(self) -> Queue:
|
||||
"""给每个订阅者返回一个带有日志缓存的队列"""
|
||||
"""注册新的订阅者, 并给每个订阅者返回一个带有日志缓存的队列
|
||||
|
||||
Returns:
|
||||
Queue: 订阅者的队列, 可用于接收日志消息
|
||||
"""
|
||||
q = Queue(maxsize=CACHED_SIZE + 10)
|
||||
for log in self.log_cache:
|
||||
q.put_nowait(log)
|
||||
@@ -57,11 +101,20 @@ class LogBroker:
|
||||
return q
|
||||
|
||||
def unregister(self, q: Queue):
|
||||
"""取消订阅"""
|
||||
"""取消订阅
|
||||
|
||||
Args:
|
||||
q (Queue): 需要取消订阅的队列
|
||||
"""
|
||||
self.subscribers.remove(q)
|
||||
|
||||
def publish(self, log_entry: str):
|
||||
"""发布消息"""
|
||||
def publish(self, log_entry: dict):
|
||||
"""发布新日志到所有订阅者, 使用非阻塞方式投递, 避免一个订阅者阻塞整个系统
|
||||
|
||||
Args:
|
||||
log_entry (dict): 日志消息, 包含日志级别和日志内容.
|
||||
example: {"level": "INFO", "data": "This is a log message.", "time": "2023-10-01 12:00:00"}
|
||||
"""
|
||||
self.log_cache.append(log_entry)
|
||||
for q in self.subscribers:
|
||||
try:
|
||||
@@ -71,24 +124,59 @@ class LogBroker:
|
||||
|
||||
|
||||
class LogQueueHandler(logging.Handler):
|
||||
"""日志处理器, 用于将日志消息发送到 LogBroker
|
||||
|
||||
继承自 logging.Handler
|
||||
"""
|
||||
|
||||
def __init__(self, log_broker: LogBroker):
|
||||
super().__init__()
|
||||
self.log_broker = log_broker
|
||||
|
||||
def emit(self, record):
|
||||
"""日志处理的入口方法, 接受一个日志记录, 转换为字符串后由 LogBroker 发布
|
||||
这个方法会在每次日志记录时被调用
|
||||
|
||||
Args:
|
||||
record (logging.LogRecord): 日志记录对象, 包含日志信息
|
||||
"""
|
||||
log_entry = self.format(record)
|
||||
self.log_broker.publish(log_entry)
|
||||
self.log_broker.publish(
|
||||
{
|
||||
"level": record.levelname,
|
||||
"time": record.asctime,
|
||||
"data": log_entry,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class LogManager:
|
||||
"""日志管理器, 用于创建和配置日志记录器
|
||||
|
||||
提供了获取默认日志记录器logger和设置队列处理器的方法
|
||||
"""
|
||||
|
||||
@classmethod
|
||||
def GetLogger(cls, log_name: str = "default"):
|
||||
"""获取指定名称的日志记录器logger
|
||||
|
||||
Args:
|
||||
log_name (str): 日志记录器的名称, 默认为 "default"
|
||||
|
||||
Returns:
|
||||
logging.Logger: 返回配置好的日志记录器
|
||||
"""
|
||||
logger = logging.getLogger(log_name)
|
||||
# 检查该logger或父级logger是否已经有处理器, 如果已经有处理器, 直接返回该logger, 避免重复配置
|
||||
if logger.hasHandlers():
|
||||
return logger
|
||||
console_handler = logging.StreamHandler()
|
||||
console_handler.setLevel(logging.DEBUG)
|
||||
# 如果logger没有处理器
|
||||
console_handler = logging.StreamHandler() # 创建一个StreamHandler用于控制台输出
|
||||
console_handler.setLevel(
|
||||
logging.DEBUG
|
||||
) # 将日志级别设置为DEBUG(最低级别, 显示所有日志), *如果插件没有设置级别, 默认为DEBUG
|
||||
|
||||
# 创建彩色日志格式化器, 输出日志格式为: [时间] [插件标签] [日志级别] [文件名:行号]: 日志消息
|
||||
console_formatter = colorlog.ColoredFormatter(
|
||||
fmt="%(log_color)s [%(asctime)s] %(plugin_tag)s [%(short_levelname)-4s] [%(filename)s:%(lineno)d]: %(message)s %(reset)s",
|
||||
datefmt="%H:%M:%S",
|
||||
@@ -96,6 +184,8 @@ class LogManager:
|
||||
)
|
||||
|
||||
class PluginFilter(logging.Filter):
|
||||
"""插件过滤器类, 用于标记日志来源是插件还是核心组件"""
|
||||
|
||||
def filter(self, record):
|
||||
record.plugin_tag = (
|
||||
"[Plug]" if is_plugin_path(record.pathname) else "[Core]"
|
||||
@@ -103,6 +193,9 @@ class LogManager:
|
||||
return True
|
||||
|
||||
class FileNameFilter(logging.Filter):
|
||||
"""文件名过滤器类, 用于修改日志记录的文件名格式
|
||||
例如: 将文件路径 /path/to/file.py 转换为 file.<file> 格式"""
|
||||
|
||||
# 获取这个文件和父文件夹的名字:<folder>.<file> 并且去除 .py
|
||||
def filter(self, record):
|
||||
dirname = os.path.dirname(record.pathname)
|
||||
@@ -114,22 +207,30 @@ class LogManager:
|
||||
return True
|
||||
|
||||
class LevelNameFilter(logging.Filter):
|
||||
"""短日志级别名称过滤器类, 用于将日志级别名称转换为四个字母的缩写"""
|
||||
|
||||
# 添加短日志级别名称
|
||||
def filter(self, record):
|
||||
record.short_levelname = get_short_level_name(record.levelname)
|
||||
return True
|
||||
|
||||
console_handler.setFormatter(console_formatter)
|
||||
logger.addFilter(PluginFilter())
|
||||
logger.addFilter(FileNameFilter())
|
||||
console_handler.setFormatter(console_formatter) # 设置处理器的格式化器
|
||||
logger.addFilter(PluginFilter()) # 添加插件过滤器
|
||||
logger.addFilter(FileNameFilter()) # 添加文件名过滤器
|
||||
logger.addFilter(LevelNameFilter()) # 添加级别名称过滤器
|
||||
logger.setLevel(logging.DEBUG)
|
||||
logger.addHandler(console_handler)
|
||||
logger.setLevel(logging.DEBUG) # 设置日志级别为DEBUG
|
||||
logger.addHandler(console_handler) # 添加处理器到logger
|
||||
|
||||
return logger
|
||||
|
||||
@classmethod
|
||||
def set_queue_handler(cls, logger: logging.Logger, log_broker: LogBroker):
|
||||
"""设置队列处理器, 用于将日志消息发送到 LogBroker
|
||||
|
||||
Args:
|
||||
logger (logging.Logger): 日志记录器
|
||||
log_broker (LogBroker): 日志代理类, 用于缓存和分发日志消息
|
||||
"""
|
||||
handler = LogQueueHandler(log_broker)
|
||||
handler.setLevel(logging.DEBUG)
|
||||
if logger.handlers:
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
import enum
|
||||
|
||||
from typing import List, Optional
|
||||
from typing import List, Optional, Union, AsyncGenerator
|
||||
from dataclasses import dataclass, field
|
||||
from astrbot.core.message.components import BaseMessageComponent, Plain, Image
|
||||
from astrbot.core.message.components import (
|
||||
BaseMessageComponent,
|
||||
Plain,
|
||||
Image,
|
||||
At,
|
||||
AtAll,
|
||||
)
|
||||
from typing_extensions import deprecated
|
||||
|
||||
|
||||
@@ -31,6 +37,30 @@ class MessageChain:
|
||||
self.chain.append(Plain(message))
|
||||
return self
|
||||
|
||||
def at(self, name: str, qq: Union[str, int]):
|
||||
"""添加一条 At 消息到消息链 `chain` 中。
|
||||
|
||||
Example:
|
||||
|
||||
CommandResult().at("张三", "12345678910")
|
||||
# 输出 @张三
|
||||
|
||||
"""
|
||||
self.chain.append(At(name=name, qq=qq))
|
||||
return self
|
||||
|
||||
def at_all(self):
|
||||
"""添加一条 AtAll 消息到消息链 `chain` 中。
|
||||
|
||||
Example:
|
||||
|
||||
CommandResult().at_all()
|
||||
# 输出 @所有人
|
||||
|
||||
"""
|
||||
self.chain.append(AtAll())
|
||||
return self
|
||||
|
||||
@deprecated("请使用 message 方法代替。")
|
||||
def error(self, message: str):
|
||||
"""添加一条错误消息到消息链 `chain` 中
|
||||
@@ -81,6 +111,30 @@ class MessageChain:
|
||||
"""获取纯文本消息。这个方法将获取 chain 中所有 Plain 组件的文本并拼接成一条消息。空格分隔。"""
|
||||
return " ".join([comp.text for comp in self.chain if isinstance(comp, Plain)])
|
||||
|
||||
def squash_plain(self):
|
||||
"""将消息链中的所有 Plain 消息段聚合到第一个 Plain 消息段中。"""
|
||||
if not self.chain:
|
||||
return
|
||||
|
||||
new_chain = []
|
||||
first_plain = None
|
||||
plain_texts = []
|
||||
|
||||
for comp in self.chain:
|
||||
if isinstance(comp, Plain):
|
||||
if first_plain is None:
|
||||
first_plain = comp
|
||||
new_chain.append(comp)
|
||||
plain_texts.append(comp.text)
|
||||
else:
|
||||
new_chain.append(comp)
|
||||
|
||||
if first_plain is not None:
|
||||
first_plain.text = "".join(plain_texts)
|
||||
|
||||
self.chain = new_chain
|
||||
return self
|
||||
|
||||
|
||||
class EventResultType(enum.Enum):
|
||||
"""用于描述事件处理的结果类型。
|
||||
@@ -101,6 +155,10 @@ class ResultContentType(enum.Enum):
|
||||
"""调用 LLM 产生的结果"""
|
||||
GENERAL_RESULT = enum.auto()
|
||||
"""普通的消息结果"""
|
||||
STREAMING_RESULT = enum.auto()
|
||||
"""调用 LLM 产生的流式结果"""
|
||||
STREAMING_FINISH= enum.auto()
|
||||
"""流式输出完成"""
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -122,6 +180,9 @@ class MessageEventResult(MessageChain):
|
||||
default_factory=lambda: ResultContentType.GENERAL_RESULT
|
||||
)
|
||||
|
||||
async_stream: Optional[AsyncGenerator] = None
|
||||
"""异步流"""
|
||||
|
||||
def stop_event(self) -> "MessageEventResult":
|
||||
"""终止事件传播。"""
|
||||
self.result_type = EventResultType.STOP
|
||||
@@ -138,6 +199,11 @@ class MessageEventResult(MessageChain):
|
||||
"""
|
||||
return self.result_type == EventResultType.STOP
|
||||
|
||||
def set_async_stream(self, stream: AsyncGenerator) -> "MessageEventResult":
|
||||
"""设置异步流。"""
|
||||
self.async_stream = stream
|
||||
return self
|
||||
|
||||
def set_result_content_type(self, typ: ResultContentType) -> "MessageEventResult":
|
||||
"""设置事件处理的结果类型。
|
||||
|
||||
@@ -152,4 +218,5 @@ class MessageEventResult(MessageChain):
|
||||
return self.result_content_type == ResultContentType.LLM_RESULT
|
||||
|
||||
|
||||
# 为了兼容旧版代码,保留 CommandResult 的别名
|
||||
CommandResult = MessageEventResult
|
||||
|
||||
@@ -7,16 +7,19 @@ from .waking_check.stage import WakingCheckStage
|
||||
from .whitelist_check.stage import WhitelistCheckStage
|
||||
from .rate_limit_check.stage import RateLimitStage
|
||||
from .content_safety_check.stage import ContentSafetyCheckStage
|
||||
from .platform_compatibility.stage import PlatformCompatibilityStage
|
||||
from .preprocess_stage.stage import PreProcessStage
|
||||
from .process_stage.stage import ProcessStage
|
||||
from .result_decorate.stage import ResultDecorateStage
|
||||
from .respond.stage import RespondStage
|
||||
|
||||
# 管道阶段顺序
|
||||
STAGES_ORDER = [
|
||||
"WakingCheckStage", # 检查是否需要唤醒
|
||||
"WhitelistCheckStage", # 检查是否在群聊/私聊白名单
|
||||
"RateLimitStage", # 检查会话是否超过频率限制
|
||||
"ContentSafetyCheckStage", # 检查内容安全
|
||||
"PlatformCompatibilityStage", # 检查所有处理器的平台兼容性
|
||||
"PreProcessStage", # 预处理
|
||||
"ProcessStage", # 交由 Stars 处理(a.k.a 插件),或者 LLM 调用
|
||||
"ResultDecorateStage", # 处理结果,比如添加回复前缀、t2i、转换为语音 等
|
||||
@@ -28,6 +31,7 @@ __all__ = [
|
||||
"WhitelistCheckStage",
|
||||
"RateLimitStage",
|
||||
"ContentSafetyCheckStage",
|
||||
"PlatformCompatibilityStage",
|
||||
"PreProcessStage",
|
||||
"ProcessStage",
|
||||
"ResultDecorateStage",
|
||||
|
||||
@@ -5,5 +5,7 @@ from astrbot.core.star import PluginManager
|
||||
|
||||
@dataclass
|
||||
class PipelineContext:
|
||||
astrbot_config: AstrBotConfig
|
||||
plugin_manager: PluginManager
|
||||
"""上下文对象,包含管道执行所需的上下文信息"""
|
||||
|
||||
astrbot_config: AstrBotConfig # AstrBot 配置对象
|
||||
plugin_manager: PluginManager # 插件管理器对象
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
from ..stage import Stage, register_stage
|
||||
from ..context import PipelineContext
|
||||
from typing import Union, AsyncGenerator
|
||||
from astrbot.core.platform.astr_message_event import AstrMessageEvent
|
||||
from astrbot.core.star.star import star_map
|
||||
from astrbot.core.star.star_handler import StarHandlerMetadata
|
||||
from astrbot.core import logger
|
||||
|
||||
|
||||
@register_stage
|
||||
class PlatformCompatibilityStage(Stage):
|
||||
"""检查所有处理器的平台兼容性。
|
||||
|
||||
这个阶段会检查所有处理器是否在当前平台启用,如果未启用则设置platform_compatible属性为False。
|
||||
"""
|
||||
|
||||
async def initialize(self, ctx: PipelineContext) -> None:
|
||||
"""初始化平台兼容性检查阶段
|
||||
|
||||
Args:
|
||||
ctx (PipelineContext): 消息管道上下文对象, 包括配置和插件管理器
|
||||
"""
|
||||
self.ctx = ctx
|
||||
|
||||
async def process(
|
||||
self, event: AstrMessageEvent
|
||||
) -> Union[None, AsyncGenerator[None, None]]:
|
||||
# 获取当前平台ID
|
||||
platform_id = event.get_platform_id()
|
||||
|
||||
# 获取已激活的处理器
|
||||
activated_handlers = event.get_extra("activated_handlers")
|
||||
if activated_handlers is None:
|
||||
activated_handlers = []
|
||||
|
||||
# 标记不兼容的处理器
|
||||
for handler in activated_handlers:
|
||||
if not isinstance(handler, StarHandlerMetadata):
|
||||
continue
|
||||
# 检查处理器是否在当前平台启用
|
||||
enabled = handler.is_enabled_for_platform(platform_id)
|
||||
if not enabled:
|
||||
if handler.handler_module_path in star_map:
|
||||
plugin_name = star_map[handler.handler_module_path].name
|
||||
logger.debug(
|
||||
f"[PlatformCompatibilityStage] 插件 {plugin_name} 在平台 {platform_id} 未启用,标记处理器 {handler.handler_name} 为平台不兼容"
|
||||
)
|
||||
# 设置处理器为平台不兼容状态
|
||||
# TODO: 更好的标记方式
|
||||
handler.platform_compatible = False
|
||||
else:
|
||||
# 确保处理器为平台兼容状态
|
||||
handler.platform_compatible = True
|
||||
|
||||
# 更新已激活的处理器列表
|
||||
event.set_extra("activated_handlers", activated_handlers)
|
||||
@@ -12,11 +12,12 @@ from astrbot.core.platform.astr_message_event import AstrMessageEvent
|
||||
from astrbot.core.message.message_event_result import (
|
||||
MessageEventResult,
|
||||
ResultContentType,
|
||||
MessageChain,
|
||||
)
|
||||
from astrbot.core.message.components import Image
|
||||
from astrbot.core import logger
|
||||
from astrbot.core.utils.metrics import Metric
|
||||
from astrbot.core.provider.entites import (
|
||||
from astrbot.core.provider.entities import (
|
||||
ProviderRequest,
|
||||
LLMResponse,
|
||||
ToolCallMessageSegment,
|
||||
@@ -37,6 +38,13 @@ class LLMRequestSubStage(Stage):
|
||||
self.max_context_length = ctx.astrbot_config["provider_settings"][
|
||||
"max_context_length"
|
||||
] # int
|
||||
self.dequeue_context_length = min(
|
||||
max(1, ctx.astrbot_config["provider_settings"]["dequeue_context_length"]),
|
||||
self.max_context_length - 1,
|
||||
) # int
|
||||
self.streaming_response = ctx.astrbot_config["provider_settings"][
|
||||
"streaming_response"
|
||||
] # bool
|
||||
|
||||
for bwp in self.bot_wake_prefixs:
|
||||
if self.provider_wake_prefix.startswith(bwp):
|
||||
@@ -58,12 +66,16 @@ class LLMRequestSubStage(Stage):
|
||||
|
||||
if event.get_extra("provider_request"):
|
||||
req = event.get_extra("provider_request")
|
||||
assert isinstance(req, ProviderRequest), (
|
||||
"provider_request 必须是 ProviderRequest 类型。"
|
||||
)
|
||||
assert isinstance(
|
||||
req, ProviderRequest
|
||||
), "provider_request 必须是 ProviderRequest 类型。"
|
||||
|
||||
if req.conversation:
|
||||
req.contexts = json.loads(req.conversation.history)
|
||||
all_contexts = json.loads(req.conversation.history)
|
||||
req.contexts = self._process_tool_message_pairs(
|
||||
all_contexts, remove_tags=True
|
||||
)
|
||||
|
||||
else:
|
||||
req = ProviderRequest(prompt="", image_urls=[])
|
||||
if self.provider_wake_prefix:
|
||||
@@ -80,7 +92,6 @@ class LLMRequestSubStage(Stage):
|
||||
conversation_id = await self.conv_manager.get_curr_conversation_id(
|
||||
event.unified_msg_origin
|
||||
)
|
||||
req.session_id = event.unified_msg_origin
|
||||
if not conversation_id:
|
||||
conversation_id = await self.conv_manager.new_conversation(
|
||||
event.unified_msg_origin
|
||||
@@ -105,8 +116,10 @@ class LLMRequestSubStage(Stage):
|
||||
|
||||
# 执行请求 LLM 前事件钩子。
|
||||
# 装饰 system_prompt 等功能
|
||||
# 获取当前平台ID
|
||||
platform_id = event.get_platform_id()
|
||||
handlers = star_handlers_registry.get_handlers_by_event_type(
|
||||
EventType.OnLLMRequestEvent
|
||||
EventType.OnLLMRequestEvent, platform_id=platform_id
|
||||
)
|
||||
for handler in handlers:
|
||||
try:
|
||||
@@ -132,72 +145,135 @@ class LLMRequestSubStage(Stage):
|
||||
and len(req.contexts) // 2 > self.max_context_length
|
||||
):
|
||||
logger.debug("上下文长度超过限制,将截断。")
|
||||
req.contexts = req.contexts[-self.max_context_length * 2 :]
|
||||
req.contexts = req.contexts[
|
||||
-(self.max_context_length - self.dequeue_context_length) * 2 :
|
||||
]
|
||||
|
||||
try:
|
||||
need_loop = True
|
||||
while need_loop:
|
||||
need_loop = False
|
||||
logger.debug(f"提供商请求 Payload: {req}")
|
||||
llm_response = await provider.text_chat(**req.__dict__) # 请求 LLM
|
||||
# session_id
|
||||
if not req.session_id:
|
||||
req.session_id = event.unified_msg_origin
|
||||
|
||||
# 执行 LLM 响应后的事件钩子。
|
||||
handlers = star_handlers_registry.get_handlers_by_event_type(
|
||||
EventType.OnLLMResponseEvent
|
||||
)
|
||||
for handler in handlers:
|
||||
try:
|
||||
logger.debug(
|
||||
f"hook(on_llm_response) -> {star_map[handler.handler_module_path].name} - {handler.handler_name}"
|
||||
)
|
||||
await handler.handler(event, llm_response)
|
||||
except BaseException:
|
||||
logger.error(traceback.format_exc())
|
||||
async def requesting(req: ProviderRequest):
|
||||
try:
|
||||
need_loop = True
|
||||
while need_loop:
|
||||
need_loop = False
|
||||
logger.debug(f"提供商请求 Payload: {req}")
|
||||
|
||||
if event.is_stopped():
|
||||
logger.info(
|
||||
f"{star_map[handler.handler_module_path].name} - {handler.handler_name} 终止了事件传播。"
|
||||
)
|
||||
return
|
||||
final_llm_response = None
|
||||
|
||||
async for result in self._handle_llm_response(event, req, llm_response):
|
||||
if isinstance(result, ProviderRequest):
|
||||
# 有函数工具调用并且返回了结果,我们需要再次请求 LLM
|
||||
req = result
|
||||
need_loop = True
|
||||
if self.streaming_response:
|
||||
stream = provider.text_chat_stream(**req.__dict__)
|
||||
async for llm_response in stream:
|
||||
if llm_response.is_chunk:
|
||||
if llm_response.result_chain:
|
||||
yield llm_response.result_chain # MessageChain
|
||||
else:
|
||||
yield MessageChain().message(
|
||||
llm_response.completion_text
|
||||
)
|
||||
else:
|
||||
final_llm_response = llm_response
|
||||
else:
|
||||
yield
|
||||
final_llm_response = await provider.text_chat(
|
||||
**req.__dict__
|
||||
) # 请求 LLM
|
||||
|
||||
asyncio.create_task(
|
||||
Metric.upload(
|
||||
llm_tick=1,
|
||||
model_name=provider.get_model(),
|
||||
provider_type=provider.meta().type,
|
||||
if not final_llm_response:
|
||||
raise Exception("LLM response is None.")
|
||||
|
||||
# 执行 LLM 响应后的事件钩子。
|
||||
handlers = star_handlers_registry.get_handlers_by_event_type(
|
||||
EventType.OnLLMResponseEvent
|
||||
)
|
||||
for handler in handlers:
|
||||
try:
|
||||
logger.debug(
|
||||
f"hook(on_llm_response) -> {star_map[handler.handler_module_path].name} - {handler.handler_name}"
|
||||
)
|
||||
await handler.handler(event, final_llm_response)
|
||||
except BaseException:
|
||||
logger.error(traceback.format_exc())
|
||||
|
||||
if event.is_stopped():
|
||||
logger.info(
|
||||
f"{star_map[handler.handler_module_path].name} - {handler.handler_name} 终止了事件传播。"
|
||||
)
|
||||
return
|
||||
|
||||
if self.streaming_response:
|
||||
# 流式输出的处理
|
||||
async for result in self._handle_llm_stream_response(
|
||||
event, req, final_llm_response
|
||||
):
|
||||
if isinstance(result, ProviderRequest):
|
||||
# 有函数工具调用并且返回了结果,我们需要再次请求 LLM
|
||||
req = result
|
||||
need_loop = True
|
||||
else:
|
||||
yield
|
||||
else:
|
||||
# 非流式输出的处理
|
||||
async for result in self._handle_llm_response(
|
||||
event, req, final_llm_response
|
||||
):
|
||||
if isinstance(result, ProviderRequest):
|
||||
# 有函数工具调用并且返回了结果,我们需要再次请求 LLM
|
||||
req = result
|
||||
need_loop = True
|
||||
else:
|
||||
yield
|
||||
|
||||
asyncio.create_task(
|
||||
Metric.upload(
|
||||
llm_tick=1,
|
||||
model_name=provider.get_model(),
|
||||
provider_type=provider.meta().type,
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
# 保存到历史记录
|
||||
await self._save_to_history(event, req, llm_response)
|
||||
# 保存到历史记录
|
||||
await self._save_to_history(event, req, final_llm_response)
|
||||
|
||||
except BaseException as e:
|
||||
logger.error(traceback.format_exc())
|
||||
except BaseException as e:
|
||||
logger.error(traceback.format_exc())
|
||||
event.set_result(
|
||||
MessageEventResult().message(
|
||||
f"AstrBot 请求失败。\n错误类型: {type(e).__name__}\n错误信息: {str(e)}"
|
||||
)
|
||||
)
|
||||
|
||||
if not self.streaming_response:
|
||||
event.set_extra("tool_call_result", None)
|
||||
async for _ in requesting(req):
|
||||
yield
|
||||
else:
|
||||
event.set_result(
|
||||
MessageEventResult().message(
|
||||
f"AstrBot 请求失败。\n错误类型: {type(e).__name__}\n错误信息: {str(e)}"
|
||||
)
|
||||
MessageEventResult()
|
||||
.set_result_content_type(ResultContentType.STREAMING_RESULT)
|
||||
.set_async_stream(requesting(req))
|
||||
)
|
||||
return
|
||||
# 这里使用yield来暂停当前阶段,等待流式输出完成后继续处理
|
||||
yield
|
||||
|
||||
if event.get_extra("tool_call_result"):
|
||||
event.set_result(event.get_extra("tool_call_result"))
|
||||
event.set_extra("tool_call_result", None)
|
||||
yield
|
||||
|
||||
async def _handle_llm_response(
|
||||
self, event: AstrMessageEvent, req: ProviderRequest, llm_response: LLMResponse
|
||||
) -> AsyncGenerator[None, None]:
|
||||
"""处理 LLM 响应。
|
||||
self,
|
||||
event: AstrMessageEvent,
|
||||
req: ProviderRequest,
|
||||
llm_response: LLMResponse,
|
||||
) -> AsyncGenerator[Union[None, ProviderRequest], None]:
|
||||
"""处理非流式 LLM 响应。
|
||||
|
||||
Returns:
|
||||
bool: 是否需要继续调用 LLM
|
||||
AsyncGenerator[Union[None, ProviderRequest], None]: 如果返回 ProviderRequest,表示需要再次调用 LLM
|
||||
|
||||
Yields:
|
||||
Iterator[bool]: 将 event 交付给下一个 stage
|
||||
Iterator[Union[None, ProviderRequest]]: 将 event 交付给下一个 stage 或者返回 ProviderRequest 表示需要再次调用 LLM
|
||||
"""
|
||||
if llm_response.role == "assistant":
|
||||
# text completion
|
||||
@@ -220,83 +296,152 @@ class LLMRequestSubStage(Stage):
|
||||
)
|
||||
)
|
||||
elif llm_response.role == "tool":
|
||||
# function calling
|
||||
tool_call_result: list[ToolCallMessageSegment] = []
|
||||
logger.info(
|
||||
f"触发 {len(llm_response.tools_call_name)} 个函数调用: {llm_response.tools_call_name}"
|
||||
# 处理函数工具调用
|
||||
async for result in self._handle_function_tools(event, req, llm_response):
|
||||
yield result
|
||||
|
||||
async def _handle_llm_stream_response(
|
||||
self,
|
||||
event: AstrMessageEvent,
|
||||
req: ProviderRequest,
|
||||
llm_response: LLMResponse,
|
||||
) -> AsyncGenerator[Union[None, ProviderRequest], None]:
|
||||
"""处理流式 LLM 响应。
|
||||
|
||||
专门用于处理流式输出完成后的响应,与非流式响应处理分离。
|
||||
|
||||
Returns:
|
||||
AsyncGenerator[Union[None, ProviderRequest], None]: 如果返回 ProviderRequest,表示需要再次调用 LLM
|
||||
|
||||
Yields:
|
||||
Iterator[Union[None, ProviderRequest]]: 将 event 交付给下一个 stage 或者返回 ProviderRequest 表示需要再次调用 LLM
|
||||
"""
|
||||
if llm_response.role == "assistant":
|
||||
# text completion
|
||||
if llm_response.result_chain:
|
||||
event.set_result(
|
||||
MessageEventResult(
|
||||
chain=llm_response.result_chain.chain
|
||||
).set_result_content_type(ResultContentType.STREAMING_FINISH)
|
||||
)
|
||||
else:
|
||||
event.set_result(
|
||||
MessageEventResult()
|
||||
.message(llm_response.completion_text)
|
||||
.set_result_content_type(ResultContentType.STREAMING_FINISH)
|
||||
)
|
||||
elif llm_response.role == "err":
|
||||
event.set_result(
|
||||
MessageEventResult().message(
|
||||
f"AstrBot 请求失败。\n错误信息: {llm_response.completion_text}"
|
||||
)
|
||||
)
|
||||
for func_tool_name, func_tool_args, func_tool_id in zip(
|
||||
llm_response.tools_call_name,
|
||||
llm_response.tools_call_args,
|
||||
llm_response.tools_call_ids,
|
||||
):
|
||||
try:
|
||||
func_tool = req.func_tool.get_func(func_tool_name)
|
||||
if func_tool.origin == "mcp":
|
||||
logger.info(
|
||||
f"从 MCP 服务 {func_tool.mcp_server_name} 调用工具函数:{func_tool.name},参数:{func_tool_args}"
|
||||
elif llm_response.role == "tool":
|
||||
# 处理函数工具调用
|
||||
async for result in self._handle_function_tools(event, req, llm_response):
|
||||
yield result
|
||||
|
||||
async def _handle_function_tools(
|
||||
self,
|
||||
event: AstrMessageEvent,
|
||||
req: ProviderRequest,
|
||||
llm_response: LLMResponse,
|
||||
) -> AsyncGenerator[Union[None, ProviderRequest], None]:
|
||||
"""处理函数工具调用。
|
||||
|
||||
Returns:
|
||||
AsyncGenerator[Union[None, ProviderRequest], None]: 如果返回 ProviderRequest,表示需要再次调用 LLM
|
||||
"""
|
||||
# function calling
|
||||
tool_call_result: list[ToolCallMessageSegment] = []
|
||||
logger.info(
|
||||
f"触发 {len(llm_response.tools_call_name)} 个函数调用: {llm_response.tools_call_name}"
|
||||
)
|
||||
for func_tool_name, func_tool_args, func_tool_id in zip(
|
||||
llm_response.tools_call_name,
|
||||
llm_response.tools_call_args,
|
||||
llm_response.tools_call_ids,
|
||||
):
|
||||
try:
|
||||
func_tool = req.func_tool.get_func(func_tool_name)
|
||||
if func_tool.origin == "mcp":
|
||||
logger.info(
|
||||
f"从 MCP 服务 {func_tool.mcp_server_name} 调用工具函数:{func_tool.name},参数:{func_tool_args}"
|
||||
)
|
||||
client = req.func_tool.mcp_client_dict[func_tool.mcp_server_name]
|
||||
res = await client.session.call_tool(func_tool.name, func_tool_args)
|
||||
if res:
|
||||
# TODO content的类型可能包括list[TextContent | ImageContent | EmbeddedResource],这里只处理了TextContent。
|
||||
tool_call_result.append(
|
||||
ToolCallMessageSegment(
|
||||
role="tool",
|
||||
tool_call_id=func_tool_id,
|
||||
content=res.content[0].text,
|
||||
)
|
||||
)
|
||||
client = req.func_tool.mcp_client_dict[
|
||||
func_tool.mcp_server_name
|
||||
]
|
||||
res = await client.session.call_tool(
|
||||
func_tool.name, func_tool_args
|
||||
else:
|
||||
# 获取处理器,过滤掉平台不兼容的处理器
|
||||
platform_id = event.get_platform_id()
|
||||
star_md = star_map.get(func_tool.handler_module_path)
|
||||
if (
|
||||
star_md and
|
||||
platform_id in star_md.supported_platforms
|
||||
and not star_md.supported_platforms[platform_id]
|
||||
):
|
||||
logger.debug(
|
||||
f"处理器 {func_tool_name}({star_md.name}) 在当前平台不兼容或者被禁用,跳过执行"
|
||||
)
|
||||
if res:
|
||||
# TODO content的类型可能包括list[TextContent | ImageContent | EmbeddedResource],这里只处理了TextContent。
|
||||
# 直接跳过,不添加任何消息到tool_call_result
|
||||
continue
|
||||
|
||||
logger.info(
|
||||
f"调用工具函数:{func_tool_name},参数:{func_tool_args}"
|
||||
)
|
||||
# 尝试调用工具函数
|
||||
wrapper = self._call_handler(
|
||||
self.ctx, event, func_tool.handler, **func_tool_args
|
||||
)
|
||||
async for resp in wrapper:
|
||||
if resp is not None: # 有 return 返回
|
||||
tool_call_result.append(
|
||||
ToolCallMessageSegment(
|
||||
role="tool",
|
||||
tool_call_id=func_tool_id,
|
||||
content=res.content[0].text,
|
||||
content=resp,
|
||||
)
|
||||
)
|
||||
else:
|
||||
logger.info(
|
||||
f"调用工具函数:{func_tool_name},参数:{func_tool_args}"
|
||||
)
|
||||
# 尝试调用工具函数
|
||||
wrapper = self._call_handler(
|
||||
self.ctx, event, func_tool.handler, **func_tool_args
|
||||
)
|
||||
async for resp in wrapper:
|
||||
if resp is not None: # 有 return 返回
|
||||
tool_call_result.append(
|
||||
ToolCallMessageSegment(
|
||||
role="tool",
|
||||
tool_call_id=func_tool_id,
|
||||
content=resp,
|
||||
)
|
||||
)
|
||||
else:
|
||||
yield # 有生成器返回
|
||||
event.clear_result() # 清除上一个 handler 的结果
|
||||
except BaseException as e:
|
||||
logger.warning(traceback.format_exc())
|
||||
tool_call_result.append(
|
||||
ToolCallMessageSegment(
|
||||
role="tool",
|
||||
tool_call_id=func_tool_id,
|
||||
content=f"error: {str(e)}",
|
||||
)
|
||||
else:
|
||||
res = event.get_result()
|
||||
if res and res.chain:
|
||||
event.set_extra("tool_call_result", res)
|
||||
yield # 有生成器返回
|
||||
event.clear_result() # 清除上一个 handler 的结果
|
||||
except BaseException as e:
|
||||
logger.warning(traceback.format_exc())
|
||||
tool_call_result.append(
|
||||
ToolCallMessageSegment(
|
||||
role="tool",
|
||||
tool_call_id=func_tool_id,
|
||||
content=f"error: {str(e)}",
|
||||
)
|
||||
if tool_call_result:
|
||||
# 函数调用结果
|
||||
req.func_tool = None # 暂时不支持递归工具调用
|
||||
assistant_msg_seg = AssistantMessageSegment(
|
||||
role="assistant", tool_calls=llm_response.to_openai_tool_calls()
|
||||
)
|
||||
# 在多轮 Tool 调用的情况下,这里始终保持最新的 Tool 调用结果,减少上下文长度。
|
||||
req.tool_calls_result = ToolCallsResult(
|
||||
tool_calls_info=assistant_msg_seg,
|
||||
tool_calls_result=tool_call_result,
|
||||
if tool_call_result:
|
||||
# 函数调用结果
|
||||
req.func_tool = None # 暂时不支持递归工具调用
|
||||
assistant_msg_seg = AssistantMessageSegment(
|
||||
role="assistant", tool_calls=llm_response.to_openai_tool_calls()
|
||||
)
|
||||
# 在多轮 Tool 调用的情况下,这里始终保持最新的 Tool 调用结果,减少上下文长度。
|
||||
req.tool_calls_result = ToolCallsResult(
|
||||
tool_calls_info=assistant_msg_seg,
|
||||
tool_calls_result=tool_call_result,
|
||||
)
|
||||
yield req # 再次执行 LLM 请求
|
||||
else:
|
||||
if llm_response.completion_text:
|
||||
event.set_result(
|
||||
MessageEventResult().message(llm_response.completion_text)
|
||||
)
|
||||
yield req # 再次执行 LLM 请求
|
||||
else:
|
||||
if llm_response.completion_text:
|
||||
event.set_result(
|
||||
MessageEventResult().message(llm_response.completion_text)
|
||||
)
|
||||
|
||||
async def _save_to_history(
|
||||
self, event: AstrMessageEvent, req: ProviderRequest, llm_response: LLMResponse
|
||||
@@ -306,12 +451,22 @@ class LLMRequestSubStage(Stage):
|
||||
|
||||
if llm_response.role == "assistant":
|
||||
# 文本回复
|
||||
contexts = req.contexts
|
||||
contexts = req.contexts.copy()
|
||||
contexts.append(await req.assemble_context())
|
||||
|
||||
# tool calls result
|
||||
# 记录并标记函数调用结果
|
||||
if req.tool_calls_result:
|
||||
contexts.extend(req.tool_calls_result.to_openai_messages())
|
||||
tool_calls_messages = req.tool_calls_result.to_openai_messages()
|
||||
|
||||
# 添加标记
|
||||
for message in tool_calls_messages:
|
||||
message["_tool_call_history"] = True
|
||||
|
||||
processed_tool_messages = self._process_tool_message_pairs(
|
||||
tool_calls_messages, remove_tags=False
|
||||
)
|
||||
|
||||
contexts.extend(processed_tool_messages)
|
||||
|
||||
contexts.append(
|
||||
{"role": "assistant", "content": llm_response.completion_text}
|
||||
@@ -322,3 +477,59 @@ class LLMRequestSubStage(Stage):
|
||||
await self.conv_manager.update_conversation(
|
||||
event.unified_msg_origin, req.conversation.cid, history=contexts_to_save
|
||||
)
|
||||
|
||||
def _process_tool_message_pairs(self, messages, remove_tags=True):
|
||||
"""处理工具调用消息,确保assistant和tool消息成对出现
|
||||
|
||||
Args:
|
||||
messages (list): 消息列表
|
||||
remove_tags (bool): 是否移除_tool_call_history标记
|
||||
|
||||
Returns:
|
||||
list: 处理后的消息列表,保证了assistant和对应tool消息的成对出现
|
||||
"""
|
||||
result = []
|
||||
i = 0
|
||||
|
||||
while i < len(messages):
|
||||
current_msg = messages[i]
|
||||
|
||||
# 普通消息直接添加
|
||||
if "_tool_call_history" not in current_msg:
|
||||
result.append(current_msg.copy() if remove_tags else current_msg)
|
||||
i += 1
|
||||
continue
|
||||
|
||||
# 工具调用消息成对处理
|
||||
if current_msg.get("role") == "assistant" and "tool_calls" in current_msg:
|
||||
assistant_msg = current_msg.copy()
|
||||
|
||||
if remove_tags and "_tool_call_history" in assistant_msg:
|
||||
del assistant_msg["_tool_call_history"]
|
||||
|
||||
related_tools = []
|
||||
j = i + 1
|
||||
while (
|
||||
j < len(messages)
|
||||
and messages[j].get("role") == "tool"
|
||||
and "_tool_call_history" in messages[j]
|
||||
):
|
||||
tool_msg = messages[j].copy()
|
||||
|
||||
if remove_tags:
|
||||
del tool_msg["_tool_call_history"]
|
||||
|
||||
related_tools.append(tool_msg)
|
||||
j += 1
|
||||
|
||||
# 成对的时候添加到结果
|
||||
if related_tools:
|
||||
result.append(assistant_msg)
|
||||
result.extend(related_tools)
|
||||
|
||||
i = j # 跳过已处理
|
||||
else:
|
||||
# 单独的tool消息
|
||||
i += 1
|
||||
|
||||
return result
|
||||
|
||||
@@ -31,7 +31,18 @@ class StarRequestSubStage(Stage):
|
||||
)
|
||||
if not handlers_parsed_params:
|
||||
handlers_parsed_params = {}
|
||||
|
||||
for handler in activated_handlers:
|
||||
# 检查处理器是否在当前平台兼容
|
||||
if (
|
||||
hasattr(handler, "platform_compatible")
|
||||
and handler.platform_compatible is False
|
||||
):
|
||||
logger.debug(
|
||||
f"处理器 {handler.handler_name} 在当前平台不兼容,跳过执行"
|
||||
)
|
||||
continue
|
||||
|
||||
params = handlers_parsed_params.get(handler.handler_full_name, {})
|
||||
try:
|
||||
if handler.handler_module_path not in star_map:
|
||||
|
||||
@@ -5,7 +5,7 @@ from .method.llm_request import LLMRequestSubStage
|
||||
from .method.star_request import StarRequestSubStage
|
||||
from astrbot.core.platform.astr_message_event import AstrMessageEvent
|
||||
from astrbot.core.star.star_handler import StarHandlerMetadata
|
||||
from astrbot.core.provider.entites import ProviderRequest
|
||||
from astrbot.core.provider.entities import ProviderRequest
|
||||
from astrbot.core import logger
|
||||
|
||||
|
||||
|
||||
@@ -2,20 +2,58 @@ import random
|
||||
import asyncio
|
||||
import math
|
||||
import traceback
|
||||
import astrbot.core.message.components as Comp
|
||||
from typing import Union, AsyncGenerator
|
||||
from ..stage import register_stage, Stage
|
||||
from ..context import PipelineContext
|
||||
from astrbot.core.platform.astr_message_event import AstrMessageEvent
|
||||
from astrbot.core.message.message_event_result import MessageChain
|
||||
from astrbot.core.message.message_event_result import MessageChain, ResultContentType
|
||||
from astrbot.core import logger
|
||||
from astrbot.core.message.message_event_result import BaseMessageComponent
|
||||
from astrbot.core.star.star_handler import star_handlers_registry, EventType
|
||||
from astrbot.core.star.star import star_map
|
||||
from astrbot.core.message.components import Plain, Reply, At
|
||||
|
||||
|
||||
@register_stage
|
||||
class RespondStage(Stage):
|
||||
# 组件类型到其非空判断函数的映射
|
||||
_component_validators = {
|
||||
Comp.Plain: lambda comp: bool(
|
||||
comp.text and comp.text.strip()
|
||||
), # 纯文本消息需要strip
|
||||
Comp.Face: lambda comp: comp.id is not None, # QQ表情
|
||||
Comp.Record: lambda comp: bool(comp.file), # 语音
|
||||
Comp.Video: lambda comp: bool(comp.file), # 视频
|
||||
Comp.At: lambda comp: bool(comp.qq) or bool(comp.name), # @
|
||||
Comp.AtAll: lambda comp: True, # @所有人
|
||||
Comp.RPS: lambda comp: True, # 不知道是啥(未完成)
|
||||
Comp.Dice: lambda comp: True, # 骰子(未完成)
|
||||
Comp.Shake: lambda comp: True, # 摇一摇(未完成)
|
||||
Comp.Anonymous: lambda comp: True, # 匿名(未完成)
|
||||
Comp.Share: lambda comp: bool(comp.url) and bool(comp.title), # 分享
|
||||
Comp.Contact: lambda comp: True, # 联系人(未完成)
|
||||
Comp.Location: lambda comp: bool(comp.lat and comp.lon), # 位置
|
||||
Comp.Music: lambda comp: bool(comp._type)
|
||||
and bool(comp.url)
|
||||
and bool(comp.audio), # 音乐
|
||||
Comp.Image: lambda comp: bool(comp.file), # 图片
|
||||
Comp.Reply: lambda comp: bool(comp.id) and comp.sender_id is not None, # 回复
|
||||
Comp.RedBag: lambda comp: bool(comp.title), # 红包
|
||||
Comp.Poke: lambda comp: comp.id != 0 and comp.qq != 0, # 戳一戳
|
||||
Comp.Forward: lambda comp: bool(comp.id and comp.id.strip()), # 转发
|
||||
Comp.Node: lambda comp: bool(comp.name)
|
||||
and comp.uin != 0
|
||||
and bool(comp.content), # 一个转发节点
|
||||
Comp.Nodes: lambda comp: bool(comp.nodes), # 多个转发节点
|
||||
Comp.Xml: lambda comp: bool(comp.data and comp.data.strip()), # XML
|
||||
Comp.Json: lambda comp: bool(comp.data), # JSON
|
||||
Comp.CardImage: lambda comp: bool(comp.file), # 卡片图片
|
||||
Comp.TTS: lambda comp: bool(comp.text and comp.text.strip()), # 语音合成
|
||||
Comp.Unknown: lambda comp: bool(comp.text and comp.text.strip()), # 未知消息
|
||||
Comp.File: lambda comp: bool(comp.file), # 文件
|
||||
Comp.WechatEmoji: lambda comp: bool(comp.md5), # 微信表情
|
||||
}
|
||||
|
||||
async def initialize(self, ctx: PipelineContext):
|
||||
self.ctx = ctx
|
||||
|
||||
@@ -62,7 +100,7 @@ class RespondStage(Stage):
|
||||
async def _calc_comp_interval(self, comp: BaseMessageComponent) -> float:
|
||||
"""分段回复 计算间隔时间"""
|
||||
if self.interval_method == "log":
|
||||
if isinstance(comp, Plain):
|
||||
if isinstance(comp, Comp.Plain):
|
||||
wc = await self._word_cnt(comp.text)
|
||||
i = math.log(wc + 1, self.log_base)
|
||||
return random.uniform(i, i + 0.5)
|
||||
@@ -72,15 +110,56 @@ class RespondStage(Stage):
|
||||
# random
|
||||
return random.uniform(self.interval[0], self.interval[1])
|
||||
|
||||
async def _is_empty_message_chain(self, chain: list[BaseMessageComponent]):
|
||||
"""检查消息链是否为空
|
||||
|
||||
Args:
|
||||
chain (list[BaseMessageComponent]): 包含消息对象的列表
|
||||
"""
|
||||
if not chain:
|
||||
return True
|
||||
|
||||
for comp in chain:
|
||||
comp_type = type(comp)
|
||||
|
||||
# 检查组件类型是否在字典中
|
||||
if comp_type in self._component_validators:
|
||||
if self._component_validators[comp_type](comp):
|
||||
return False
|
||||
else:
|
||||
logger.info(f"空内容检查: 无法识别的组件类型: {comp_type.__name__}")
|
||||
|
||||
# 如果所有组件都为空
|
||||
return True
|
||||
|
||||
async def process(
|
||||
self, event: AstrMessageEvent
|
||||
) -> Union[None, AsyncGenerator[None, None]]:
|
||||
result = event.get_result()
|
||||
if result is None:
|
||||
return
|
||||
if result.result_content_type == ResultContentType.STREAMING_FINISH:
|
||||
return
|
||||
|
||||
if len(result.chain) > 0:
|
||||
if result.result_content_type == ResultContentType.STREAMING_RESULT:
|
||||
# 流式结果直接交付平台适配器处理
|
||||
logger.info(f"应用流式输出({event.get_platform_name()})")
|
||||
await event._pre_send()
|
||||
await event.send_streaming(result.async_stream)
|
||||
await event._post_send()
|
||||
return
|
||||
elif len(result.chain) > 0:
|
||||
await event._pre_send()
|
||||
|
||||
# 检查消息链是否为空
|
||||
try:
|
||||
if await self._is_empty_message_chain(result.chain):
|
||||
logger.info("消息为空,跳过发送阶段")
|
||||
event.clear_result()
|
||||
event.stop_event()
|
||||
return
|
||||
except Exception as e:
|
||||
logger.warning(f"空内容检查异常: {e}")
|
||||
|
||||
if self.enable_seg and (
|
||||
(self.only_llm_result and result.is_llm_result())
|
||||
@@ -89,13 +168,13 @@ class RespondStage(Stage):
|
||||
decorated_comps = []
|
||||
if self.reply_with_mention:
|
||||
for comp in result.chain:
|
||||
if isinstance(comp, At):
|
||||
if isinstance(comp, Comp.At):
|
||||
decorated_comps.append(comp)
|
||||
result.chain.remove(comp)
|
||||
break
|
||||
if self.reply_with_quote:
|
||||
for comp in result.chain:
|
||||
if isinstance(comp, Reply):
|
||||
if isinstance(comp, Comp.Reply):
|
||||
decorated_comps.append(comp)
|
||||
result.chain.remove(comp)
|
||||
break
|
||||
@@ -119,7 +198,7 @@ class RespondStage(Stage):
|
||||
)
|
||||
|
||||
handlers = star_handlers_registry.get_handlers_by_event_type(
|
||||
EventType.OnAfterMessageSentEvent
|
||||
EventType.OnAfterMessageSentEvent, platform_id=event.get_platform_id()
|
||||
)
|
||||
for handler in handlers:
|
||||
try:
|
||||
|
||||
@@ -5,6 +5,7 @@ from typing import Union, AsyncGenerator
|
||||
from ..stage import Stage, register_stage, registered_stages
|
||||
from ..context import PipelineContext
|
||||
from astrbot.core.platform.astr_message_event import AstrMessageEvent
|
||||
from astrbot.core.message.message_event_result import ResultContentType
|
||||
from astrbot.core.platform.message_type import MessageType
|
||||
from astrbot.core import logger
|
||||
from astrbot.core.message.components import Plain, Image, At, Reply, Record, File, Node
|
||||
@@ -72,11 +73,17 @@ class ResultDecorateStage(Stage):
|
||||
if result is None or not result.chain:
|
||||
return
|
||||
|
||||
if result.result_content_type == ResultContentType.STREAMING_RESULT:
|
||||
return
|
||||
|
||||
is_stream = result.result_content_type == ResultContentType.STREAMING_FINISH
|
||||
|
||||
# 回复时检查内容安全
|
||||
if (
|
||||
self.content_safe_check_reply
|
||||
and self.content_safe_check_stage
|
||||
and result.is_llm_result()
|
||||
and not is_stream # 流式输出不检查内容安全
|
||||
):
|
||||
text = ""
|
||||
for comp in result.chain:
|
||||
@@ -89,13 +96,17 @@ class ResultDecorateStage(Stage):
|
||||
|
||||
# 发送消息前事件钩子
|
||||
handlers = star_handlers_registry.get_handlers_by_event_type(
|
||||
EventType.OnDecoratingResultEvent
|
||||
EventType.OnDecoratingResultEvent, platform_id=event.get_platform_id()
|
||||
)
|
||||
for handler in handlers:
|
||||
try:
|
||||
logger.debug(
|
||||
f"hook(on_decorating_result) -> {star_map[handler.handler_module_path].name} - {handler.handler_name}"
|
||||
)
|
||||
if is_stream:
|
||||
logger.warning(
|
||||
"启用流式输出时,依赖发送消息前事件钩子的插件可能无法正常工作"
|
||||
)
|
||||
await handler.handler(event)
|
||||
if event.get_result() is None or not event.get_result().chain:
|
||||
logger.debug(
|
||||
@@ -110,6 +121,11 @@ class ResultDecorateStage(Stage):
|
||||
)
|
||||
return
|
||||
|
||||
# 流式输出不执行下面的逻辑
|
||||
if is_stream:
|
||||
logger.info("流式输出已启用,跳过结果装饰阶段")
|
||||
return
|
||||
|
||||
# 需要再获取一次。插件可能直接对 chain 进行了替换。
|
||||
result = event.get_result()
|
||||
if result is None:
|
||||
|
||||
@@ -7,49 +7,72 @@ from astrbot.core import logger
|
||||
|
||||
|
||||
class PipelineScheduler:
|
||||
"""管道调度器,负责调度各个阶段的执行"""
|
||||
|
||||
def __init__(self, context: PipelineContext):
|
||||
registered_stages.sort(key=lambda x: STAGES_ORDER.index(x.__class__.__name__))
|
||||
self.ctx = context
|
||||
registered_stages.sort(
|
||||
key=lambda x: STAGES_ORDER.index(x.__class__.__name__)
|
||||
) # 按照顺序排序
|
||||
self.ctx = context # 上下文对象
|
||||
|
||||
async def initialize(self):
|
||||
"""初始化管道调度器时, 初始化所有阶段"""
|
||||
for stage in registered_stages:
|
||||
# logger.debug(f"初始化阶段 {stage.__class__ .__name__}")
|
||||
|
||||
await stage.initialize(self.ctx)
|
||||
|
||||
async def _process_stages(self, event: AstrMessageEvent, from_stage=0):
|
||||
"""依次执行各个阶段
|
||||
|
||||
Args:
|
||||
event (AstrMessageEvent): 事件对象
|
||||
from_stage (int): 从第几个阶段开始执行, 默认从0开始
|
||||
"""
|
||||
for i in range(from_stage, len(registered_stages)):
|
||||
stage = registered_stages[i]
|
||||
stage = registered_stages[i] # 获取当前要执行的阶段
|
||||
# logger.debug(f"执行阶段 {stage.__class__ .__name__}")
|
||||
coro = stage.process(event)
|
||||
if isinstance(coro, AsyncGenerator):
|
||||
async for _ in coro:
|
||||
coroutine = stage.process(
|
||||
event
|
||||
) # 调用阶段的process方法, 返回协程或者异步生成器
|
||||
|
||||
if isinstance(coroutine, AsyncGenerator):
|
||||
# 如果返回的是异步生成器, 实现洋葱模型的核心
|
||||
async for _ in coroutine:
|
||||
# 此处是前置处理完成后的暂停点(yield), 下面开始执行后续阶段
|
||||
if event.is_stopped():
|
||||
logger.debug(
|
||||
f"阶段 {stage.__class__.__name__} 已终止事件传播。"
|
||||
)
|
||||
break
|
||||
|
||||
# 递归调用, 处理所有后续阶段
|
||||
await self._process_stages(event, i + 1)
|
||||
|
||||
# 此处是后续所有阶段处理完毕后返回的点, 执行后置处理
|
||||
if event.is_stopped():
|
||||
logger.debug(
|
||||
f"阶段 {stage.__class__.__name__} 已终止事件传播。"
|
||||
)
|
||||
break
|
||||
else:
|
||||
await coro
|
||||
# 如果返回的是普通协程(不含yield的async函数), 则不进入下一层(基线条件)
|
||||
# 简单地等待它执行完成, 然后继续执行下一个阶段
|
||||
await coroutine
|
||||
|
||||
if event.is_stopped():
|
||||
logger.debug(f"阶段 {stage.__class__.__name__} 已终止事件传播。")
|
||||
break
|
||||
|
||||
if event.is_stopped():
|
||||
logger.debug(f"阶段 {stage.__class__.__name__} 已终止事件传播。")
|
||||
break
|
||||
|
||||
async def execute(self, event: AstrMessageEvent):
|
||||
"""执行 pipeline"""
|
||||
"""执行 pipeline
|
||||
|
||||
Args:
|
||||
event (AstrMessageEvent): 事件对象
|
||||
"""
|
||||
await self._process_stages(event)
|
||||
|
||||
# 如果没有发送操作, 则发送一个空消息, 以便于后续的处理
|
||||
if not event._has_send_oper and event.get_platform_name() == "webchat":
|
||||
await event.send(None)
|
||||
|
||||
|
||||
@@ -8,8 +8,7 @@ from astrbot.core.platform.astr_message_event import AstrMessageEvent
|
||||
from .context import PipelineContext
|
||||
from astrbot.core.message.message_event_result import MessageEventResult, CommandResult
|
||||
|
||||
registered_stages: List[Stage] = []
|
||||
"""维护了所有已注册的 Stage 实现类"""
|
||||
registered_stages: List[Stage] = [] # 维护了所有已注册的 Stage 实现类
|
||||
|
||||
|
||||
def register_stage(cls):
|
||||
@@ -23,14 +22,24 @@ class Stage(abc.ABC):
|
||||
|
||||
@abc.abstractmethod
|
||||
async def initialize(self, ctx: PipelineContext) -> None:
|
||||
"""初始化阶段"""
|
||||
"""初始化阶段
|
||||
|
||||
Args:
|
||||
ctx (PipelineContext): 消息管道上下文对象, 包括配置和插件管理器
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
@abc.abstractmethod
|
||||
async def process(
|
||||
self, event: AstrMessageEvent
|
||||
) -> Union[None, AsyncGenerator[None, None]]:
|
||||
"""处理事件"""
|
||||
"""处理事件
|
||||
|
||||
Args:
|
||||
event (AstrMessageEvent): 事件对象,包含事件的相关信息
|
||||
Returns:
|
||||
Union[None, AsyncGenerator[None, None]]: 处理结果,可能是 None 或者异步生成器, 如果为 None 则表示不需要继续处理, 如果为异步生成器则表示需要继续处理(进入下一个阶段)
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
async def _call_handler(
|
||||
@@ -41,9 +50,23 @@ class Stage(abc.ABC):
|
||||
*args,
|
||||
**kwargs,
|
||||
) -> AsyncGenerator[None, None]:
|
||||
"""调用 Handler。"""
|
||||
# 判断 handler 是否是类方法(通过装饰器注册的没有 __self__ 属性)
|
||||
ready_to_call = None
|
||||
"""执行事件处理函数并处理其返回结果
|
||||
|
||||
该方法负责调用处理函数并处理不同类型的返回值。它支持两种类型的处理函数:
|
||||
1. 异步生成器: 实现洋葱模型,每次yield都会将控制权交回上层
|
||||
2. 协程: 执行一次并处理返回值
|
||||
|
||||
Args:
|
||||
ctx (PipelineContext): 消息管道上下文对象
|
||||
event (AstrMessageEvent): 待处理的事件对象
|
||||
handler (Awaitable): 事件处理函数
|
||||
*args: 传递给handler的位置参数
|
||||
**kwargs: 传递给handler的关键字参数
|
||||
|
||||
Returns:
|
||||
AsyncGenerator[None, None]: 异步生成器,用于在管道中传递控制流
|
||||
"""
|
||||
ready_to_call = None # 一个协程或者异步生成器(async def)
|
||||
|
||||
trace_ = None
|
||||
|
||||
@@ -52,29 +75,36 @@ class Stage(abc.ABC):
|
||||
except TypeError as _:
|
||||
# 向下兼容
|
||||
trace_ = traceback.format_exc()
|
||||
# 以前的handler会额外传入一个参数, 但是context对象实际上在插件实例中有一份
|
||||
ready_to_call = handler(event, ctx.plugin_manager.context, *args, **kwargs)
|
||||
|
||||
if isinstance(ready_to_call, AsyncGenerator):
|
||||
_has_yielded = False
|
||||
# 如果是一个异步生成器, 进入洋葱模型
|
||||
_has_yielded = False # 是否返回过值
|
||||
try:
|
||||
async for ret in ready_to_call:
|
||||
# 如果处理函数是生成器,返回值只能是 MessageEventResult 或者 None(无返回值)
|
||||
# 这里逐步执行异步生成器, 对于每个yield返回的ret, 执行下面的代码
|
||||
# 返回值只能是 MessageEventResult 或者 None(无返回值)
|
||||
_has_yielded = True
|
||||
if isinstance(ret, (MessageEventResult, CommandResult)):
|
||||
# 如果返回值是 MessageEventResult, 设置结果并继续
|
||||
event.set_result(ret)
|
||||
yield
|
||||
yield # 传递控制权给上一层的process函数
|
||||
else:
|
||||
yield ret
|
||||
# 如果返回值是 None, 则不设置结果并继续
|
||||
# 继续执行后续阶段
|
||||
yield ret # 传递控制权给上一层的process函数
|
||||
if not _has_yielded:
|
||||
# 如果这个异步生成器没有执行到yield分支
|
||||
yield
|
||||
except Exception as e:
|
||||
logger.error(f"Previous Error: {trace_}")
|
||||
raise e
|
||||
elif inspect.iscoroutine(ready_to_call):
|
||||
# 如果只是一个 coroutine
|
||||
# 如果只是一个协程, 直接执行
|
||||
ret = await ready_to_call
|
||||
if isinstance(ret, (MessageEventResult, CommandResult)):
|
||||
event.set_result(ret)
|
||||
yield
|
||||
yield # 传递控制权给上一层的process函数
|
||||
else:
|
||||
yield ret
|
||||
yield ret # 传递控制权给上一层的process函数
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
from ..stage import Stage, register_stage
|
||||
from ..context import PipelineContext
|
||||
from astrbot import logger
|
||||
from typing import Union, AsyncGenerator
|
||||
from astrbot.core.platform.astr_message_event import AstrMessageEvent
|
||||
from astrbot.core.message.message_event_result import MessageEventResult, MessageChain
|
||||
@@ -21,6 +22,11 @@ class WakingCheckStage(Stage):
|
||||
"""
|
||||
|
||||
async def initialize(self, ctx: PipelineContext) -> None:
|
||||
"""初始化唤醒检查阶段
|
||||
|
||||
Args:
|
||||
ctx (PipelineContext): 消息管道上下文对象, 包括配置和插件管理器
|
||||
"""
|
||||
self.ctx = ctx
|
||||
self.no_permission_reply = self.ctx.astrbot_config["platform_settings"].get(
|
||||
"no_permission_reply", True
|
||||
@@ -88,6 +94,7 @@ class WakingCheckStage(Stage):
|
||||
# filter 需满足 AND 逻辑关系
|
||||
passed = True
|
||||
permission_not_pass = False
|
||||
permission_filter_raise_error = False
|
||||
if len(handler.event_filters) == 0:
|
||||
continue
|
||||
|
||||
@@ -96,6 +103,7 @@ class WakingCheckStage(Stage):
|
||||
if isinstance(filter, PermissionTypeFilter):
|
||||
if not filter.filter(event, self.ctx.astrbot_config):
|
||||
permission_not_pass = True
|
||||
permission_filter_raise_error = filter.raise_error
|
||||
else:
|
||||
if not filter.filter(event, self.ctx.astrbot_config):
|
||||
passed = False
|
||||
@@ -112,6 +120,9 @@ class WakingCheckStage(Stage):
|
||||
break
|
||||
if passed:
|
||||
if permission_not_pass:
|
||||
if not permission_filter_raise_error:
|
||||
# 跳过
|
||||
continue
|
||||
if self.no_permission_reply:
|
||||
await event.send(
|
||||
MessageChain().message(
|
||||
@@ -119,6 +130,9 @@ class WakingCheckStage(Stage):
|
||||
)
|
||||
)
|
||||
await event._post_send()
|
||||
logger.info(
|
||||
f"触发 {star_map[handler.handler_module_path].name} 时, 用户(ID={event.get_sender_id()}) 权限不足。"
|
||||
)
|
||||
event.stop_event()
|
||||
return
|
||||
|
||||
|
||||
@@ -15,6 +15,9 @@ class WhitelistCheckStage(Stage):
|
||||
"enable_id_white_list"
|
||||
]
|
||||
self.whitelist = ctx.astrbot_config["platform_settings"]["id_whitelist"]
|
||||
self.whitelist = [
|
||||
str(i).strip() for i in self.whitelist if str(i).strip() != ""
|
||||
]
|
||||
self.wl_ignore_admin_on_group = ctx.astrbot_config["platform_settings"][
|
||||
"wl_ignore_admin_on_group"
|
||||
]
|
||||
@@ -53,7 +56,7 @@ class WhitelistCheckStage(Stage):
|
||||
return
|
||||
if (
|
||||
event.unified_msg_origin not in self.whitelist
|
||||
and event.get_group_id() not in self.whitelist
|
||||
and str(event.get_group_id()).strip() not in self.whitelist
|
||||
):
|
||||
if self.wl_log:
|
||||
logger.info(
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import abc
|
||||
import asyncio
|
||||
from dataclasses import dataclass
|
||||
from typing import List, Union, Optional
|
||||
from typing import List, Union, Optional, AsyncGenerator
|
||||
|
||||
from astrbot.core.db.po import Conversation
|
||||
from astrbot.core.message.components import (
|
||||
@@ -16,7 +16,7 @@ from astrbot.core.message.components import (
|
||||
)
|
||||
from astrbot.core.message.message_event_result import MessageEventResult, MessageChain
|
||||
from astrbot.core.platform.message_type import MessageType
|
||||
from astrbot.core.provider.entites import ProviderRequest
|
||||
from astrbot.core.provider.entities import ProviderRequest
|
||||
from astrbot.core.utils.metrics import Metric
|
||||
from .astrbot_message import AstrBotMessage, Group
|
||||
from .platform_metadata import PlatformMetadata
|
||||
@@ -81,6 +81,9 @@ class AstrMessageEvent(abc.ABC):
|
||||
def get_platform_name(self):
|
||||
return self.platform_meta.name
|
||||
|
||||
def get_platform_id(self):
|
||||
return self.platform_meta.id
|
||||
|
||||
def get_message_str(self) -> str:
|
||||
"""
|
||||
获取消息字符串。
|
||||
@@ -202,6 +205,15 @@ class AstrMessageEvent(abc.ABC):
|
||||
"""
|
||||
return self.role == "admin"
|
||||
|
||||
async def send_streaming(self, generator: AsyncGenerator[MessageChain, None]):
|
||||
"""发送流式消息到消息平台,使用异步生成器。
|
||||
目前仅支持: telegram,qq official 私聊。
|
||||
"""
|
||||
asyncio.create_task(
|
||||
Metric.upload(msg_event_tick=1, adapter_name=self.platform_meta.name)
|
||||
)
|
||||
self._has_send_oper = True
|
||||
|
||||
async def _pre_send(self):
|
||||
"""调度器会在执行 send() 前调用该方法"""
|
||||
|
||||
|
||||
@@ -7,6 +7,8 @@ class PlatformMetadata:
|
||||
"""平台的名称"""
|
||||
description: str
|
||||
"""平台的描述"""
|
||||
id: str = None
|
||||
"""平台的唯一标识符,用于配置中识别特定平台"""
|
||||
|
||||
default_config_tmpl: dict = None
|
||||
"""平台的默认配置模板"""
|
||||
|
||||
@@ -22,6 +22,9 @@ class AiocqhttpMessageEvent(AstrMessageEvent):
|
||||
if isinstance(segment, Plain):
|
||||
d["type"] = "text"
|
||||
d["data"]["text"] = segment.text.strip()
|
||||
# 如果是空文本或者只带换行符的文本,不发送
|
||||
if not d["data"]["text"]:
|
||||
continue
|
||||
elif isinstance(segment, (Image, Record)):
|
||||
# convert to base64
|
||||
bs64 = await segment.convert_to_base64()
|
||||
@@ -38,6 +41,9 @@ class AiocqhttpMessageEvent(AstrMessageEvent):
|
||||
async def send(self, message: MessageChain):
|
||||
ret = await AiocqhttpMessageEvent._parse_onebot_json(message)
|
||||
|
||||
if not ret:
|
||||
return
|
||||
|
||||
send_one_by_one = False
|
||||
for seg in message.chain:
|
||||
if isinstance(seg, (Node, Nodes)):
|
||||
@@ -76,6 +82,19 @@ class AiocqhttpMessageEvent(AstrMessageEvent):
|
||||
|
||||
await super().send(message)
|
||||
|
||||
async def send_streaming(self, generator):
|
||||
buffer = None
|
||||
async for chain in generator:
|
||||
if not buffer:
|
||||
buffer = chain
|
||||
else:
|
||||
buffer.chain.extend(chain.chain)
|
||||
if not buffer:
|
||||
return
|
||||
buffer.squash_plain()
|
||||
await self.send(buffer)
|
||||
return await super().send_streaming(generator)
|
||||
|
||||
async def get_group(self, group_id=None, **kwargs):
|
||||
if isinstance(group_id, str) and group_id.isdigit():
|
||||
group_id = int(group_id)
|
||||
|
||||
@@ -39,8 +39,9 @@ class AiocqhttpAdapter(Platform):
|
||||
self.port = platform_config["ws_reverse_port"]
|
||||
|
||||
self.metadata = PlatformMetadata(
|
||||
"aiocqhttp",
|
||||
"适用于 OneBot 标准的消息平台适配器,支持反向 WebSockets。",
|
||||
name="aiocqhttp",
|
||||
description="适用于 OneBot 标准的消息平台适配器,支持反向 WebSockets。",
|
||||
id=self.config.get("id"),
|
||||
)
|
||||
|
||||
self.bot = CQHttp(
|
||||
@@ -109,7 +110,7 @@ class AiocqhttpAdapter(Platform):
|
||||
"""OneBot V11 请求类事件"""
|
||||
abm = AstrBotMessage()
|
||||
abm.self_id = str(event.self_id)
|
||||
abm.sender = MessageMember(user_id=event.user_id, nickname=event.user_id)
|
||||
abm.sender = MessageMember(user_id=str(event.user_id), nickname=event.user_id)
|
||||
abm.type = MessageType.OTHER_MESSAGE
|
||||
if "group_id" in event and event["group_id"]:
|
||||
abm.type = MessageType.GROUP_MESSAGE
|
||||
@@ -129,7 +130,7 @@ class AiocqhttpAdapter(Platform):
|
||||
"""OneBot V11 通知类事件"""
|
||||
abm = AstrBotMessage()
|
||||
abm.self_id = str(event.self_id)
|
||||
abm.sender = MessageMember(user_id=event.user_id, nickname=event.user_id)
|
||||
abm.sender = MessageMember(user_id=str(event.user_id), nickname=event.user_id)
|
||||
abm.type = MessageType.OTHER_MESSAGE
|
||||
if "group_id" in event and event["group_id"]:
|
||||
abm.group_id = str(event.group_id)
|
||||
|
||||
@@ -73,8 +73,9 @@ class DingtalkPlatformAdapter(Platform):
|
||||
|
||||
def meta(self) -> PlatformMetadata:
|
||||
return PlatformMetadata(
|
||||
"dingtalk",
|
||||
"钉钉机器人官方 API 适配器",
|
||||
name="dingtalk",
|
||||
description="钉钉机器人官方 API 适配器",
|
||||
id=self.config.get("id"),
|
||||
)
|
||||
|
||||
async def convert_msg(
|
||||
|
||||
@@ -24,7 +24,11 @@ class DingtalkMessageEvent(AstrMessageEvent):
|
||||
if isinstance(segment, Comp.Plain):
|
||||
segment.text = segment.text.strip()
|
||||
await asyncio.get_event_loop().run_in_executor(
|
||||
None, client.reply_text, segment.text, self.message_obj.raw_message
|
||||
None,
|
||||
client.reply_markdown,
|
||||
"AstrBot",
|
||||
segment.text,
|
||||
self.message_obj.raw_message,
|
||||
)
|
||||
elif isinstance(segment, Comp.Image):
|
||||
markdown_str = ""
|
||||
@@ -56,3 +60,16 @@ class DingtalkMessageEvent(AstrMessageEvent):
|
||||
async def send(self, message: MessageChain):
|
||||
await self.send_with_client(self.client, message)
|
||||
await super().send(message)
|
||||
|
||||
async def send_streaming(self, generator):
|
||||
buffer = None
|
||||
async for chain in generator:
|
||||
if not buffer:
|
||||
buffer = chain
|
||||
else:
|
||||
buffer.chain.extend(chain.chain)
|
||||
if not buffer:
|
||||
return
|
||||
buffer.squash_plain()
|
||||
await self.send(buffer)
|
||||
return await super().send_streaming(generator)
|
||||
|
||||
@@ -735,3 +735,20 @@ class SimpleGewechatClient:
|
||||
json_blob = await resp.json()
|
||||
logger.debug(f"获取群信息结果: {json_blob}")
|
||||
return json_blob
|
||||
|
||||
async def get_contacts_list(self):
|
||||
"""
|
||||
获取通讯录列表
|
||||
见 https://apifox.com/apidoc/shared/69ba62ca-cb7d-437e-85e4-6f3d3df271b1/api-196794504
|
||||
"""
|
||||
payload = {"appId": self.appid}
|
||||
|
||||
async with aiohttp.ClientSession() as session:
|
||||
async with session.post(
|
||||
f"{self.base_url}/contacts/fetchContactsList",
|
||||
headers=self.headers,
|
||||
json=payload,
|
||||
) as resp:
|
||||
json_blob = await resp.json()
|
||||
logger.debug(f"获取通讯录列表结果: {json_blob}")
|
||||
return json_blob
|
||||
|
||||
@@ -216,3 +216,16 @@ class GewechatPlatformEvent(AstrMessageEvent):
|
||||
group_owner=data.get("chatRoomOwner"),
|
||||
members=members,
|
||||
)
|
||||
|
||||
async def send_streaming(self, generator):
|
||||
buffer = None
|
||||
async for chain in generator:
|
||||
if not buffer:
|
||||
buffer = chain
|
||||
else:
|
||||
buffer.chain.extend(chain.chain)
|
||||
if not buffer:
|
||||
return
|
||||
buffer.squash_plain()
|
||||
await self.send(buffer)
|
||||
return await super().send_streaming(generator)
|
||||
|
||||
@@ -60,8 +60,9 @@ class GewechatPlatformAdapter(Platform):
|
||||
@override
|
||||
def meta(self) -> PlatformMetadata:
|
||||
return PlatformMetadata(
|
||||
"gewechat",
|
||||
"基于 gewechat 的 Wechat 适配器",
|
||||
name="gewechat",
|
||||
description="基于 gewechat 的 Wechat 适配器",
|
||||
id=self.config.get("id"),
|
||||
)
|
||||
|
||||
async def terminate(self):
|
||||
|
||||
@@ -2,6 +2,7 @@ import base64
|
||||
import asyncio
|
||||
import json
|
||||
import re
|
||||
import uuid
|
||||
import astrbot.api.message_components as Comp
|
||||
|
||||
from astrbot.api.platform import (
|
||||
@@ -66,12 +67,47 @@ class LarkPlatformAdapter(Platform):
|
||||
async def send_by_session(
|
||||
self, session: MessageSesion, message_chain: MessageChain
|
||||
):
|
||||
raise NotImplementedError("Lark 适配器不支持 send_by_session")
|
||||
res = await LarkMessageEvent._convert_to_lark(message_chain, self.lark_api)
|
||||
wrapped = {
|
||||
"zh_cn": {
|
||||
"title": "",
|
||||
"content": res,
|
||||
}
|
||||
}
|
||||
|
||||
if session.message_type == MessageType.GROUP_MESSAGE:
|
||||
id_type = "chat_id"
|
||||
if "%" in session.session_id:
|
||||
session.session_id = session.session_id.split("%")[1]
|
||||
else:
|
||||
id_type = "open_id"
|
||||
|
||||
request = (
|
||||
CreateMessageRequest.builder()
|
||||
.receive_id_type(id_type)
|
||||
.request_body(
|
||||
CreateMessageRequestBody.builder()
|
||||
.receive_id(session.session_id)
|
||||
.content(json.dumps(wrapped))
|
||||
.msg_type("post")
|
||||
.uuid(str(uuid.uuid4()))
|
||||
.build()
|
||||
)
|
||||
.build()
|
||||
)
|
||||
|
||||
response = await self.lark_api.im.v1.message.acreate(request)
|
||||
|
||||
if not response.success():
|
||||
logger.error(f"发送飞书消息失败({response.code}): {response.msg}")
|
||||
|
||||
await super().send_by_session(session, message_chain)
|
||||
|
||||
def meta(self) -> PlatformMetadata:
|
||||
return PlatformMetadata(
|
||||
"lark",
|
||||
"飞书机器人官方 API 适配器",
|
||||
name="lark",
|
||||
description="飞书机器人官方 API 适配器",
|
||||
id=self.config.get("id"),
|
||||
)
|
||||
|
||||
async def convert_msg(self, event: lark.im.v1.P2ImMessageReceiveV1):
|
||||
@@ -165,7 +201,10 @@ class LarkPlatformAdapter(Platform):
|
||||
else:
|
||||
abm.session_id = abm.sender.user_id
|
||||
else:
|
||||
abm.session_id = abm.sender.user_id
|
||||
if abm.type == MessageType.GROUP_MESSAGE:
|
||||
abm.session_id = f"{abm.sender.user_id}%{abm.group_id}" # 也保留群组id
|
||||
else:
|
||||
abm.session_id = abm.sender.user_id
|
||||
|
||||
logger.debug(abm)
|
||||
await self.handle_msg(abm)
|
||||
|
||||
@@ -91,3 +91,16 @@ class LarkMessageEvent(AstrMessageEvent):
|
||||
logger.error(f"回复飞书消息失败({response.code}): {response.msg}")
|
||||
|
||||
await super().send(message)
|
||||
|
||||
async def send_streaming(self, generator):
|
||||
buffer = None
|
||||
async for chain in generator:
|
||||
if not buffer:
|
||||
buffer = chain
|
||||
else:
|
||||
buffer.chain.extend(chain.chain)
|
||||
if not buffer:
|
||||
return
|
||||
buffer.squash_plain()
|
||||
await self.send(buffer)
|
||||
return await super().send_streaming(generator)
|
||||
|
||||
@@ -2,6 +2,7 @@ import botpy
|
||||
import botpy.message
|
||||
import botpy.types
|
||||
import botpy.types.message
|
||||
import asyncio
|
||||
from astrbot.core.utils.io import file_to_base64, download_image_by_url
|
||||
from astrbot.api.event import AstrMessageEvent, MessageChain
|
||||
from astrbot.api.platform import AstrBotMessage, PlatformMetadata
|
||||
@@ -9,6 +10,8 @@ from astrbot.api.message_components import Plain, Image
|
||||
from botpy import Client
|
||||
from botpy.http import Route
|
||||
from astrbot.api import logger
|
||||
from botpy.types import message
|
||||
import random
|
||||
|
||||
|
||||
class QQOfficialMessageEvent(AstrMessageEvent):
|
||||
@@ -30,8 +33,45 @@ class QQOfficialMessageEvent(AstrMessageEvent):
|
||||
else:
|
||||
self.send_buffer.chain.extend(message.chain)
|
||||
|
||||
async def _post_send(self):
|
||||
"""QQ 官方 API 仅支持回复一次"""
|
||||
async def send_streaming(self, generator):
|
||||
"""流式输出仅支持消息列表私聊"""
|
||||
stream_payload = {"state": 1, "id": None, "index": 0, "reset": False}
|
||||
last_edit_time = 0 # 上次编辑消息的时间
|
||||
throttle_interval = 1 # 编辑消息的间隔时间 (秒)
|
||||
try:
|
||||
async for chain in generator:
|
||||
source = self.message_obj.raw_message
|
||||
if not self.send_buffer:
|
||||
self.send_buffer = chain
|
||||
else:
|
||||
self.send_buffer.chain.extend(chain.chain)
|
||||
|
||||
if isinstance(source, botpy.message.C2CMessage):
|
||||
# 真流式传输
|
||||
current_time = asyncio.get_event_loop().time()
|
||||
time_since_last_edit = current_time - last_edit_time
|
||||
|
||||
if time_since_last_edit >= throttle_interval:
|
||||
ret = await self._post_send(stream=stream_payload)
|
||||
stream_payload["index"] += 1
|
||||
stream_payload["id"] = ret["id"]
|
||||
last_edit_time = asyncio.get_event_loop().time()
|
||||
|
||||
if isinstance(source, botpy.message.C2CMessage):
|
||||
# 结束流式对话,并且传输 buffer 中剩余的消息
|
||||
stream_payload["state"] = 10
|
||||
ret = await self._post_send(stream=stream_payload)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"发送流式消息时出错: {e}", exc_info=True)
|
||||
self.send_buffer = None
|
||||
|
||||
return await super().send_streaming(generator)
|
||||
|
||||
async def _post_send(self, stream: dict = None):
|
||||
if not self.send_buffer:
|
||||
return
|
||||
|
||||
source = self.message_obj.raw_message
|
||||
assert isinstance(
|
||||
source,
|
||||
@@ -57,6 +97,9 @@ class QQOfficialMessageEvent(AstrMessageEvent):
|
||||
"msg_id": self.message_obj.message_id,
|
||||
}
|
||||
|
||||
if not isinstance(source, (botpy.message.Message,botpy.message.DirectMessage)):
|
||||
payload["msg_seq"] = random.randint(1, 10000)
|
||||
|
||||
match type(source):
|
||||
case botpy.message.GroupMessage:
|
||||
if image_base64:
|
||||
@@ -65,7 +108,7 @@ class QQOfficialMessageEvent(AstrMessageEvent):
|
||||
)
|
||||
payload["media"] = media
|
||||
payload["msg_type"] = 7
|
||||
await self.bot.api.post_group_message(
|
||||
ret = await self.bot.api.post_group_message(
|
||||
group_openid=source.group_openid, **payload
|
||||
)
|
||||
case botpy.message.C2CMessage:
|
||||
@@ -75,22 +118,34 @@ class QQOfficialMessageEvent(AstrMessageEvent):
|
||||
)
|
||||
payload["media"] = media
|
||||
payload["msg_type"] = 7
|
||||
await self.bot.api.post_c2c_message(
|
||||
openid=source.author.user_openid, **payload
|
||||
)
|
||||
if stream:
|
||||
ret = await self.post_c2c_message(
|
||||
openid=source.author.user_openid,
|
||||
**payload,
|
||||
stream=stream,
|
||||
)
|
||||
else:
|
||||
ret = await self.post_c2c_message(
|
||||
openid=source.author.user_openid, **payload
|
||||
)
|
||||
logger.debug(f"Message sent to C2C: {ret}")
|
||||
case botpy.message.Message:
|
||||
if image_path:
|
||||
payload["file_image"] = image_path
|
||||
await self.bot.api.post_message(channel_id=source.channel_id, **payload)
|
||||
ret = await self.bot.api.post_message(
|
||||
channel_id=source.channel_id, **payload
|
||||
)
|
||||
case botpy.message.DirectMessage:
|
||||
if image_path:
|
||||
payload["file_image"] = image_path
|
||||
await self.bot.api.post_dms(guild_id=source.guild_id, **payload)
|
||||
ret = await self.bot.api.post_dms(guild_id=source.guild_id, **payload)
|
||||
|
||||
await super().send(self.send_buffer)
|
||||
|
||||
self.send_buffer = None
|
||||
|
||||
return ret
|
||||
|
||||
async def upload_group_and_c2c_image(
|
||||
self, image_base64: str, file_type: int, **kwargs
|
||||
) -> botpy.types.message.Media:
|
||||
@@ -112,6 +167,27 @@ class QQOfficialMessageEvent(AstrMessageEvent):
|
||||
)
|
||||
return await self.bot.api._http.request(route, json=payload)
|
||||
|
||||
async def post_c2c_message(
|
||||
self,
|
||||
openid: str,
|
||||
msg_type: int = 0,
|
||||
content: str = None,
|
||||
embed: message.Embed = None,
|
||||
ark: message.Ark = None,
|
||||
message_reference: message.Reference = None,
|
||||
media: message.Media = None,
|
||||
msg_id: str = None,
|
||||
msg_seq: str = 1,
|
||||
event_id: str = None,
|
||||
markdown: message.MarkdownPayload = None,
|
||||
keyboard: message.Keyboard = None,
|
||||
stream: dict = None,
|
||||
) -> message.Message:
|
||||
payload = locals()
|
||||
payload.pop("self", None)
|
||||
route = Route("POST", "/v2/users/{openid}/messages", openid=openid)
|
||||
return await self.bot.api._http.request(route, json=payload)
|
||||
|
||||
@staticmethod
|
||||
async def _parse_to_qqofficial(message: MessageChain):
|
||||
plain_text = ""
|
||||
|
||||
@@ -126,8 +126,9 @@ class QQOfficialPlatformAdapter(Platform):
|
||||
|
||||
def meta(self) -> PlatformMetadata:
|
||||
return PlatformMetadata(
|
||||
"qq_official",
|
||||
"QQ 机器人官方 API 适配器",
|
||||
name="qq_official",
|
||||
description="QQ 机器人官方 API 适配器",
|
||||
id=self.config.get("id"),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
|
||||
@@ -99,8 +99,9 @@ class QQOfficialWebhookPlatformAdapter(Platform):
|
||||
|
||||
def meta(self) -> PlatformMetadata:
|
||||
return PlatformMetadata(
|
||||
"qq_official_webhook",
|
||||
"QQ 机器人官方 API 适配器",
|
||||
name="qq_official_webhook",
|
||||
description="QQ 机器人官方 API 适配器",
|
||||
id=self.config.get("id"),
|
||||
)
|
||||
|
||||
async def run(self):
|
||||
@@ -116,5 +117,8 @@ class QQOfficialWebhookPlatformAdapter(Platform):
|
||||
async def terminate(self):
|
||||
self.webhook_helper.shutdown_event.set()
|
||||
await self.client.close()
|
||||
await self.webhook_helper.server.shutdown()
|
||||
try:
|
||||
await self.webhook_helper.server.shutdown()
|
||||
except Exception as _:
|
||||
pass
|
||||
logger.info("QQ 机器人官方 API 适配器已经被优雅地关闭")
|
||||
|
||||
@@ -1,26 +1,31 @@
|
||||
import asyncio
|
||||
import sys
|
||||
import uuid
|
||||
import asyncio
|
||||
import astrbot.api.message_components as Comp
|
||||
|
||||
from apscheduler.schedulers.asyncio import AsyncIOScheduler
|
||||
from telegram import BotCommand, Update
|
||||
from telegram.constants import ChatType
|
||||
from telegram.ext import ApplicationBuilder, ContextTypes, ExtBot, filters
|
||||
from telegram.ext import MessageHandler as TelegramMessageHandler
|
||||
|
||||
import astrbot.api.message_components as Comp
|
||||
from astrbot.api import logger
|
||||
from astrbot.api.event import MessageChain
|
||||
from astrbot.api.platform import (
|
||||
Platform,
|
||||
AstrBotMessage,
|
||||
MessageMember,
|
||||
PlatformMetadata,
|
||||
MessageType,
|
||||
Platform,
|
||||
PlatformMetadata,
|
||||
register_platform_adapter,
|
||||
)
|
||||
from astrbot.api.event import MessageChain
|
||||
from astrbot.core.platform.astr_message_event import MessageSesion
|
||||
from astrbot.api.platform import register_platform_adapter
|
||||
from astrbot.core.star.filter.command import CommandFilter
|
||||
from astrbot.core.star.filter.command_group import CommandGroupFilter
|
||||
from astrbot.core.star.star import star_map
|
||||
from astrbot.core.star.star_handler import star_handlers_registry
|
||||
|
||||
from telegram import Update
|
||||
from telegram.ext import ApplicationBuilder, ContextTypes, filters
|
||||
from telegram.constants import ChatType
|
||||
from telegram.ext import MessageHandler as TelegramMessageHandler
|
||||
from .tg_event import TelegramPlatformEvent
|
||||
from astrbot.api import logger
|
||||
from telegram.ext import ExtBot
|
||||
|
||||
if sys.version_info >= (3, 12):
|
||||
from typing import override
|
||||
@@ -67,6 +72,8 @@ class TelegramPlatformAdapter(Platform):
|
||||
self.client = self.application.bot
|
||||
logger.debug(f"Telegram base url: {self.client.base_url}")
|
||||
|
||||
self.scheduler = AsyncIOScheduler()
|
||||
|
||||
@override
|
||||
async def send_by_session(
|
||||
self, session: MessageSesion, message_chain: MessageChain
|
||||
@@ -80,18 +87,94 @@ class TelegramPlatformAdapter(Platform):
|
||||
@override
|
||||
def meta(self) -> PlatformMetadata:
|
||||
return PlatformMetadata(
|
||||
"telegram",
|
||||
"telegram 适配器",
|
||||
name="telegram", description="telegram 适配器", id=self.config.get("id")
|
||||
)
|
||||
|
||||
@override
|
||||
async def run(self):
|
||||
await self.application.initialize()
|
||||
await self.application.start()
|
||||
await self.register_commands()
|
||||
|
||||
# TODO 使用更优雅的方式重新注册命令
|
||||
self.scheduler.add_job(
|
||||
self.register_commands,
|
||||
"interval",
|
||||
minutes=5,
|
||||
id="telegram_command_register",
|
||||
misfire_grace_time=60,
|
||||
)
|
||||
self.scheduler.start()
|
||||
|
||||
queue = self.application.updater.start_polling()
|
||||
logger.info("Telegram Platform Adapter is running.")
|
||||
await queue
|
||||
|
||||
async def register_commands(self):
|
||||
"""收集所有注册的指令并注册到 Telegram"""
|
||||
try:
|
||||
await self.client.delete_my_commands()
|
||||
commands = self.collect_commands()
|
||||
|
||||
if commands:
|
||||
await self.client.set_my_commands(commands)
|
||||
for cmd in commands:
|
||||
logger.debug(f"已注册指令: /{cmd.command} - {cmd.description}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"向 Telegram 注册指令时发生错误: {e!s}")
|
||||
|
||||
def collect_commands(self) -> list[BotCommand]:
|
||||
"""从注册的处理器中收集所有指令"""
|
||||
command_dict = {}
|
||||
skip_commands = {"start"}
|
||||
|
||||
for handler_md in star_handlers_registry._handlers:
|
||||
handler_metadata = handler_md[1]
|
||||
if not star_map[handler_metadata.handler_module_path].activated:
|
||||
continue
|
||||
for event_filter in handler_metadata.event_filters:
|
||||
cmd_info = self._extract_command_info(
|
||||
event_filter, handler_metadata, skip_commands
|
||||
)
|
||||
if cmd_info:
|
||||
cmd_name, description = cmd_info
|
||||
command_dict.setdefault(cmd_name, description)
|
||||
|
||||
commands_a = sorted(command_dict.keys())
|
||||
return [BotCommand(cmd, command_dict[cmd]) for cmd in commands_a]
|
||||
|
||||
@staticmethod
|
||||
def _extract_command_info(
|
||||
event_filter, handler_metadata, skip_commands: set
|
||||
) -> tuple[str, str] | None:
|
||||
"""从事件过滤器中提取指令信息"""
|
||||
cmd_name = None
|
||||
is_group = False
|
||||
if isinstance(event_filter, CommandFilter) and event_filter.command_name:
|
||||
if (
|
||||
event_filter.parent_command_names
|
||||
and event_filter.parent_command_names != [""]
|
||||
):
|
||||
return None
|
||||
cmd_name = event_filter.command_name
|
||||
elif isinstance(event_filter, CommandGroupFilter):
|
||||
if event_filter.parent_group:
|
||||
return None
|
||||
cmd_name = event_filter.group_name
|
||||
is_group = True
|
||||
|
||||
if not cmd_name or cmd_name in skip_commands:
|
||||
return None
|
||||
|
||||
# Build description.
|
||||
description = handler_metadata.desc or (
|
||||
f"指令组: {cmd_name} (包含多个子指令)" if is_group else f"指令: {cmd_name}"
|
||||
)
|
||||
if len(description) > 30:
|
||||
description = description[:30] + "..."
|
||||
return cmd_name, description
|
||||
|
||||
async def start(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
|
||||
await context.bot.send_message(
|
||||
chat_id=update.effective_chat.id, text=self.config["start_message"]
|
||||
@@ -163,6 +246,16 @@ class TelegramPlatformAdapter(Platform):
|
||||
# 处理文本消息
|
||||
plain_text = update.message.text
|
||||
|
||||
# 群聊场景命令特殊处理
|
||||
if plain_text.startswith("/"):
|
||||
command_parts = plain_text.split(" ", 1)
|
||||
if "@" in command_parts[0]:
|
||||
command, bot_name = command_parts[0].split("@")
|
||||
if bot_name == self.client.username:
|
||||
plain_text = command + (
|
||||
f" {command_parts[1]}" if len(command_parts) > 1 else ""
|
||||
)
|
||||
|
||||
if update.message.entities:
|
||||
for entity in update.message.entities:
|
||||
if entity.type == "mention":
|
||||
@@ -242,7 +335,11 @@ class TelegramPlatformAdapter(Platform):
|
||||
|
||||
async def terminate(self):
|
||||
try:
|
||||
if self.scheduler.running:
|
||||
self.scheduler.shutdown()
|
||||
|
||||
await self.application.stop()
|
||||
await self.client.delete_my_commands()
|
||||
|
||||
# 保险起见先判断是否存在updater对象
|
||||
if self.application.updater is not None:
|
||||
|
||||
@@ -1,8 +1,18 @@
|
||||
import asyncio
|
||||
import telegramify_markdown
|
||||
from astrbot.api.event import AstrMessageEvent, MessageChain
|
||||
from astrbot.api.platform import AstrBotMessage, PlatformMetadata, MessageType
|
||||
from astrbot.api.message_components import Plain, Image, Reply, At, File, Record
|
||||
from astrbot.api.message_components import (
|
||||
Plain,
|
||||
Image,
|
||||
Reply,
|
||||
At,
|
||||
File,
|
||||
Record,
|
||||
)
|
||||
from telegram.ext import ExtBot
|
||||
from astrbot.core.utils.io import download_file
|
||||
from astrbot import logger
|
||||
|
||||
|
||||
class TelegramPlatformEvent(AstrMessageEvent):
|
||||
@@ -49,7 +59,17 @@ class TelegramPlatformEvent(AstrMessageEvent):
|
||||
if at_user_id and not at_flag:
|
||||
i.text = f"@{at_user_id} " + i.text
|
||||
at_flag = True
|
||||
await client.send_message(text=i.text, **payload)
|
||||
text = i.text
|
||||
try:
|
||||
text = telegramify_markdown.markdownify(
|
||||
i.text, max_line_length=None, normalize_whitespace=False
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"MarkdownV2 conversion failed: {e}. Using plain text instead."
|
||||
)
|
||||
return
|
||||
await client.send_message(text=text, parse_mode="MarkdownV2", **payload)
|
||||
elif isinstance(i, Image):
|
||||
image_path = await i.convert_to_file_path()
|
||||
await client.send_photo(photo=image_path, **payload)
|
||||
@@ -70,3 +90,109 @@ class TelegramPlatformEvent(AstrMessageEvent):
|
||||
else:
|
||||
await self.send_with_client(self.client, message, self.get_sender_id())
|
||||
await super().send(message)
|
||||
|
||||
async def send_streaming(self, generator):
|
||||
message_thread_id = None
|
||||
|
||||
if self.get_message_type() == MessageType.GROUP_MESSAGE:
|
||||
user_name = self.message_obj.group_id
|
||||
else:
|
||||
user_name = self.get_sender_id()
|
||||
|
||||
if "#" in user_name:
|
||||
# it's a supergroup chat with message_thread_id
|
||||
user_name, message_thread_id = user_name.split("#")
|
||||
payload = {
|
||||
"chat_id": user_name,
|
||||
}
|
||||
if message_thread_id:
|
||||
payload["reply_to_message_id"] = message_thread_id
|
||||
|
||||
delta = ""
|
||||
current_content = ""
|
||||
message_id = None
|
||||
last_edit_time = 0 # 上次编辑消息的时间
|
||||
throttle_interval = 0.6 # 编辑消息的间隔时间 (秒)
|
||||
|
||||
async for chain in generator:
|
||||
if isinstance(chain, MessageChain):
|
||||
# 处理消息链中的每个组件
|
||||
for i in chain.chain:
|
||||
if isinstance(i, Plain):
|
||||
delta += i.text
|
||||
elif isinstance(i, Image):
|
||||
image_path = await i.convert_to_file_path()
|
||||
await self.client.send_photo(photo=image_path, **payload)
|
||||
continue
|
||||
elif isinstance(i, File):
|
||||
if i.file.startswith("https://"):
|
||||
path = "data/temp/" + i.name
|
||||
await download_file(i.file, path)
|
||||
i.file = path
|
||||
|
||||
await self.client.send_document(
|
||||
document=i.file, filename=i.name, **payload
|
||||
)
|
||||
continue
|
||||
elif isinstance(i, Record):
|
||||
path = await i.convert_to_file_path()
|
||||
await self.client.send_voice(voice=path, **payload)
|
||||
continue
|
||||
else:
|
||||
logger.warning(f"不支持的消息类型: {type(i)}")
|
||||
continue
|
||||
|
||||
# Plain
|
||||
if not message_id:
|
||||
try:
|
||||
msg = await self.client.send_message(text=delta, **payload)
|
||||
current_content = delta
|
||||
except Exception as e:
|
||||
logger.warning(f"发送消息失败(streaming): {e!s}")
|
||||
message_id = msg.message_id
|
||||
last_edit_time = (
|
||||
asyncio.get_event_loop().time()
|
||||
) # 记录初始消息发送时间
|
||||
else:
|
||||
current_time = asyncio.get_event_loop().time()
|
||||
time_since_last_edit = current_time - last_edit_time
|
||||
|
||||
# 如果距离上次编辑的时间 >= 设定的间隔,等待一段时间
|
||||
if time_since_last_edit >= throttle_interval:
|
||||
# 编辑消息
|
||||
try:
|
||||
await self.client.edit_message_text(
|
||||
text=delta,
|
||||
chat_id=payload["chat_id"],
|
||||
message_id=message_id,
|
||||
)
|
||||
current_content = delta
|
||||
except Exception as e:
|
||||
logger.warning(f"编辑消息失败(streaming): {e!s}")
|
||||
last_edit_time = (
|
||||
asyncio.get_event_loop().time()
|
||||
) # 更新上次编辑的时间
|
||||
|
||||
try:
|
||||
if delta and current_content != delta:
|
||||
try:
|
||||
markdown_text = telegramify_markdown.markdownify(
|
||||
delta, max_line_length=None, normalize_whitespace=False
|
||||
)
|
||||
await self.client.edit_message_text(
|
||||
text=markdown_text,
|
||||
chat_id=payload["chat_id"],
|
||||
message_id=message_id,
|
||||
parse_mode="MarkdownV2"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"Markdown转换失败,使用普通文本: {e!s}")
|
||||
await self.client.edit_message_text(
|
||||
text=delta,
|
||||
chat_id=payload["chat_id"],
|
||||
message_id=message_id
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"编辑消息失败(streaming): {e!s}")
|
||||
|
||||
return await super().send_streaming(generator)
|
||||
|
||||
@@ -43,8 +43,7 @@ class WebChatAdapter(Platform):
|
||||
self.imgs_dir = "data/webchat/imgs"
|
||||
|
||||
self.metadata = PlatformMetadata(
|
||||
"webchat",
|
||||
"webchat",
|
||||
name="webchat", description="webchat", id=self.config.get("id")
|
||||
)
|
||||
|
||||
async def send_by_session(
|
||||
|
||||
@@ -3,7 +3,7 @@ import uuid
|
||||
import base64
|
||||
from astrbot.api import logger
|
||||
from astrbot.api.event import AstrMessageEvent, MessageChain
|
||||
from astrbot.api.message_components import Plain, Image
|
||||
from astrbot.api.message_components import Plain, Image, Record
|
||||
from astrbot.core.utils.io import download_image_by_url
|
||||
from astrbot.core import web_chat_back_queue
|
||||
|
||||
@@ -16,16 +16,26 @@ class WebChatMessageEvent(AstrMessageEvent):
|
||||
os.makedirs(imgs_dir, exist_ok=True)
|
||||
|
||||
@staticmethod
|
||||
async def _send(message: MessageChain, session_id: str):
|
||||
async def _send(message: MessageChain, session_id: str, streaming: bool = False):
|
||||
if not message:
|
||||
web_chat_back_queue.put_nowait(None)
|
||||
await web_chat_back_queue.put(
|
||||
{"type": "end", "data": "", "streaming": False}
|
||||
)
|
||||
return
|
||||
|
||||
cid = session_id.split("!")[-1]
|
||||
|
||||
data = ""
|
||||
for comp in message.chain:
|
||||
if isinstance(comp, Plain):
|
||||
web_chat_back_queue.put_nowait((comp.text, cid))
|
||||
data = comp.text
|
||||
await web_chat_back_queue.put(
|
||||
{
|
||||
"type": "plain",
|
||||
"cid": cid,
|
||||
"data": data,
|
||||
"streaming": streaming,
|
||||
}
|
||||
)
|
||||
elif isinstance(comp, Image):
|
||||
# save image to local
|
||||
filename = str(uuid.uuid4()) + ".jpg"
|
||||
@@ -46,11 +56,69 @@ class WebChatMessageEvent(AstrMessageEvent):
|
||||
with open(path, "wb") as f:
|
||||
with open(comp.file, "rb") as f2:
|
||||
f.write(f2.read())
|
||||
web_chat_back_queue.put_nowait((f"[IMAGE]{filename}", cid))
|
||||
data = f"[IMAGE]{filename}"
|
||||
await web_chat_back_queue.put(
|
||||
{
|
||||
"type": "image",
|
||||
"cid": cid,
|
||||
"data": data,
|
||||
"streaming": streaming,
|
||||
}
|
||||
)
|
||||
elif isinstance(comp, Record):
|
||||
# save record to local
|
||||
filename = str(uuid.uuid4()) + ".wav"
|
||||
path = os.path.join(imgs_dir, filename)
|
||||
if comp.file and comp.file.startswith("file:///"):
|
||||
ph = comp.file[8:]
|
||||
with open(path, "wb") as f:
|
||||
with open(ph, "rb") as f2:
|
||||
f.write(f2.read())
|
||||
elif comp.file and comp.file.startswith("http"):
|
||||
await download_image_by_url(comp.file, path=path)
|
||||
else:
|
||||
with open(path, "wb") as f:
|
||||
with open(comp.file, "rb") as f2:
|
||||
f.write(f2.read())
|
||||
data = f"[RECORD]{filename}"
|
||||
await web_chat_back_queue.put(
|
||||
{
|
||||
"type": "record",
|
||||
"cid": cid,
|
||||
"data": data,
|
||||
"streaming": streaming,
|
||||
}
|
||||
)
|
||||
else:
|
||||
logger.debug(f"webchat 忽略: {comp.type}")
|
||||
web_chat_back_queue.put_nowait(None)
|
||||
|
||||
return data
|
||||
|
||||
async def send(self, message: MessageChain):
|
||||
await WebChatMessageEvent._send(message, session_id=self.session_id)
|
||||
await web_chat_back_queue.put(
|
||||
{
|
||||
"type": "end",
|
||||
"data": "",
|
||||
"streaming": False,
|
||||
"cid": self.session_id.split("!")[-1],
|
||||
}
|
||||
)
|
||||
await super().send(message)
|
||||
|
||||
async def send_streaming(self, generator):
|
||||
final_data = ""
|
||||
async for chain in generator:
|
||||
final_data += await WebChatMessageEvent._send(
|
||||
chain, session_id=self.session_id, streaming=True
|
||||
)
|
||||
|
||||
await web_chat_back_queue.put(
|
||||
{
|
||||
"type": "end",
|
||||
"data": final_data,
|
||||
"streaming": True,
|
||||
"cid": self.session_id.split("!")[-1],
|
||||
}
|
||||
)
|
||||
await super().send_streaming(generator)
|
||||
|
||||
@@ -84,3 +84,16 @@ class WecomPlatformEvent(AstrMessageEvent):
|
||||
)
|
||||
|
||||
await super().send(message)
|
||||
|
||||
async def send_streaming(self, generator):
|
||||
buffer = None
|
||||
async for chain in generator:
|
||||
if not buffer:
|
||||
buffer = chain
|
||||
else:
|
||||
buffer.chain.extend(chain.chain)
|
||||
if not buffer:
|
||||
return
|
||||
buffer.squash_plain()
|
||||
await self.send(buffer)
|
||||
return await super().send_streaming(generator)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
from .provider import Provider, Personality, STTProvider
|
||||
|
||||
from .entites import ProviderMetaData
|
||||
from .entities import ProviderMetaData
|
||||
|
||||
__all__ = ["Provider", "Personality", "ProviderMetaData", "STTProvider"]
|
||||
|
||||
@@ -1,269 +1,19 @@
|
||||
import enum
|
||||
import base64
|
||||
import json
|
||||
from astrbot.core.utils.io import download_image_by_url
|
||||
from astrbot import logger
|
||||
from dataclasses import dataclass, field
|
||||
from typing import List, Dict, Type
|
||||
from .func_tool_manager import FuncCall
|
||||
from openai.types.chat.chat_completion import ChatCompletion
|
||||
from openai.types.chat.chat_completion_message_tool_call import (
|
||||
ChatCompletionMessageToolCall,
|
||||
from astrbot.core.provider.entities import (
|
||||
ProviderRequest,
|
||||
ProviderType,
|
||||
ProviderMetaData,
|
||||
ToolCallsResult,
|
||||
AssistantMessageSegment,
|
||||
ToolCallMessageSegment,
|
||||
LLMResponse,
|
||||
)
|
||||
from astrbot.core.db.po import Conversation
|
||||
from astrbot.core.message.message_event_result import MessageChain
|
||||
import astrbot.core.message.components as Comp
|
||||
|
||||
|
||||
class ProviderType(enum.Enum):
|
||||
CHAT_COMPLETION = "chat_completion"
|
||||
SPEECH_TO_TEXT = "speech_to_text"
|
||||
TEXT_TO_SPEECH = "text_to_speech"
|
||||
|
||||
|
||||
@dataclass
|
||||
class ProviderMetaData:
|
||||
type: str
|
||||
"""提供商适配器名称,如 openai, ollama"""
|
||||
desc: str = ""
|
||||
"""提供商适配器描述."""
|
||||
provider_type: ProviderType = ProviderType.CHAT_COMPLETION
|
||||
cls_type: Type = None
|
||||
|
||||
default_config_tmpl: dict = None
|
||||
"""平台的默认配置模板"""
|
||||
provider_display_name: str = None
|
||||
"""显示在 WebUI 配置页中的提供商名称,如空则是 type"""
|
||||
|
||||
|
||||
@dataclass
|
||||
class ToolCallMessageSegment:
|
||||
"""OpenAI 格式的上下文中 role 为 tool 的消息段。参考: https://platform.openai.com/docs/guides/function-calling"""
|
||||
|
||||
tool_call_id: str
|
||||
content: str
|
||||
role: str = "tool"
|
||||
|
||||
def to_dict(self):
|
||||
return {
|
||||
"tool_call_id": self.tool_call_id,
|
||||
"content": self.content,
|
||||
"role": self.role,
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class AssistantMessageSegment:
|
||||
"""OpenAI 格式的上下文中 role 为 assistant 的消息段。参考: https://platform.openai.com/docs/guides/function-calling"""
|
||||
|
||||
content: str = None
|
||||
tool_calls: List[ChatCompletionMessageToolCall | Dict] = None
|
||||
role: str = "assistant"
|
||||
|
||||
def to_dict(self):
|
||||
ret = {
|
||||
"role": self.role,
|
||||
}
|
||||
if self.content:
|
||||
ret["content"] = self.content
|
||||
elif self.tool_calls:
|
||||
ret["tool_calls"] = self.tool_calls
|
||||
return ret
|
||||
|
||||
|
||||
@dataclass
|
||||
class ToolCallsResult:
|
||||
"""工具调用结果"""
|
||||
|
||||
tool_calls_info: AssistantMessageSegment
|
||||
"""函数调用的信息"""
|
||||
tool_calls_result: List[ToolCallMessageSegment]
|
||||
"""函数调用的结果"""
|
||||
|
||||
def to_openai_messages(self) -> List[Dict]:
|
||||
ret = [
|
||||
self.tool_calls_info.to_dict(),
|
||||
*[item.to_dict() for item in self.tool_calls_result],
|
||||
]
|
||||
return ret
|
||||
|
||||
|
||||
@dataclass
|
||||
class ProviderRequest:
|
||||
prompt: str
|
||||
"""提示词"""
|
||||
session_id: str = ""
|
||||
"""会话 ID"""
|
||||
image_urls: List[str] = None
|
||||
"""图片 URL 列表"""
|
||||
func_tool: FuncCall = None
|
||||
"""可用的函数工具"""
|
||||
contexts: List = None
|
||||
"""上下文。格式与 openai 的上下文格式一致:
|
||||
参考 https://platform.openai.com/docs/api-reference/chat/create#chat-create-messages
|
||||
"""
|
||||
system_prompt: str = ""
|
||||
"""系统提示词"""
|
||||
conversation: Conversation = None
|
||||
|
||||
tool_calls_result: ToolCallsResult = None
|
||||
"""附加的上次请求后工具调用的结果。参考: https://platform.openai.com/docs/guides/function-calling#handling-function-calls"""
|
||||
|
||||
def __repr__(self):
|
||||
return f"ProviderRequest(prompt={self.prompt}, session_id={self.session_id}, image_urls={self.image_urls}, func_tool={self.func_tool}, contexts={self._print_friendly_context()}, system_prompt={self.system_prompt.strip()}, tool_calls_result={self.tool_calls_result})"
|
||||
|
||||
def __str__(self):
|
||||
return self.__repr__()
|
||||
|
||||
def _print_friendly_context(self):
|
||||
"""打印友好的消息上下文。将 image_url 的值替换为 <Image>"""
|
||||
if not self.contexts:
|
||||
return f"prompt: {self.prompt}, image_count: {len(self.image_urls or [])}"
|
||||
|
||||
result_parts = []
|
||||
|
||||
for ctx in self.contexts:
|
||||
role = ctx.get("role", "unknown")
|
||||
content = ctx.get("content", "")
|
||||
|
||||
if isinstance(content, str):
|
||||
result_parts.append(f"{role}: {content}")
|
||||
elif isinstance(content, list):
|
||||
msg_parts = []
|
||||
image_count = 0
|
||||
|
||||
for item in content:
|
||||
item_type = item.get("type", "")
|
||||
|
||||
if item_type == "text":
|
||||
msg_parts.append(item.get("text", ""))
|
||||
elif item_type == "image_url":
|
||||
image_count += 1
|
||||
|
||||
if image_count > 0:
|
||||
if msg_parts:
|
||||
msg_parts.append(f"[+{image_count} images]")
|
||||
else:
|
||||
msg_parts.append(f"[{image_count} images]")
|
||||
|
||||
result_parts.append(f"{role}: {''.join(msg_parts)}")
|
||||
|
||||
return result_parts
|
||||
|
||||
async def assemble_context(self) -> Dict:
|
||||
"""将请求(prompt 和 image_urls)包装成 OpenAI 的消息格式。"""
|
||||
if self.image_urls:
|
||||
user_content = {
|
||||
"role": "user",
|
||||
"content": [{"type": "text", "text": self.prompt}],
|
||||
}
|
||||
for image_url in self.image_urls:
|
||||
if image_url.startswith("http"):
|
||||
image_path = await download_image_by_url(image_url)
|
||||
image_data = await self._encode_image_bs64(image_path)
|
||||
elif image_url.startswith("file:///"):
|
||||
image_path = image_url.replace("file:///", "")
|
||||
image_data = await self._encode_image_bs64(image_path)
|
||||
else:
|
||||
image_data = await self._encode_image_bs64(image_url)
|
||||
if not image_data:
|
||||
logger.warning(f"图片 {image_url} 得到的结果为空,将忽略。")
|
||||
continue
|
||||
user_content["content"].append(
|
||||
{"type": "image_url", "image_url": {"url": image_data}}
|
||||
)
|
||||
return user_content
|
||||
else:
|
||||
return {"role": "user", "content": self.prompt}
|
||||
|
||||
async def _encode_image_bs64(self, image_url: str) -> str:
|
||||
"""将图片转换为 base64"""
|
||||
if image_url.startswith("base64://"):
|
||||
return image_url.replace("base64://", "data:image/jpeg;base64,")
|
||||
with open(image_url, "rb") as f:
|
||||
image_bs64 = base64.b64encode(f.read()).decode("utf-8")
|
||||
return "data:image/jpeg;base64," + image_bs64
|
||||
return ""
|
||||
|
||||
|
||||
@dataclass
|
||||
class LLMResponse:
|
||||
role: str
|
||||
"""角色, assistant, tool, err"""
|
||||
result_chain: MessageChain = None
|
||||
"""返回的消息链"""
|
||||
tools_call_args: List[Dict[str, any]] = field(default_factory=list)
|
||||
"""工具调用参数"""
|
||||
tools_call_name: List[str] = field(default_factory=list)
|
||||
"""工具调用名称"""
|
||||
tools_call_ids: List[str] = field(default_factory=list)
|
||||
"""工具调用 ID"""
|
||||
|
||||
raw_completion: ChatCompletion = None
|
||||
_new_record: Dict[str, any] = None
|
||||
|
||||
_completion_text: str = ""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
role: str,
|
||||
completion_text: str = "",
|
||||
result_chain: MessageChain = None,
|
||||
tools_call_args: List[Dict[str, any]] = [],
|
||||
tools_call_name: List[str] = [],
|
||||
tools_call_ids: List[str] = [],
|
||||
raw_completion: ChatCompletion = None,
|
||||
_new_record: Dict[str, any] = None,
|
||||
):
|
||||
"""初始化 LLMResponse
|
||||
|
||||
Args:
|
||||
role (str): 角色, assistant, tool, err
|
||||
completion_text (str, optional): 返回的结果文本,已经过时,推荐使用 result_chain. Defaults to "".
|
||||
result_chain (MessageChain, optional): 返回的消息链. Defaults to None.
|
||||
tools_call_args (List[Dict[str, any]], optional): 工具调用参数. Defaults to None.
|
||||
tools_call_name (List[str], optional): 工具调用名称. Defaults to None.
|
||||
raw_completion (ChatCompletion, optional): 原始响应, OpenAI 格式. Defaults to None.
|
||||
"""
|
||||
self.role = role
|
||||
self.completion_text = completion_text
|
||||
self.result_chain = result_chain
|
||||
self.tools_call_args = tools_call_args
|
||||
self.tools_call_name = tools_call_name
|
||||
self.tools_call_ids = tools_call_ids
|
||||
self.raw_completion = raw_completion
|
||||
self._new_record = _new_record
|
||||
|
||||
@property
|
||||
def completion_text(self):
|
||||
if self.result_chain:
|
||||
return self.result_chain.get_plain_text()
|
||||
return self._completion_text
|
||||
|
||||
@completion_text.setter
|
||||
def completion_text(self, value):
|
||||
if self.result_chain:
|
||||
self.result_chain.chain = [
|
||||
comp
|
||||
for comp in self.result_chain.chain
|
||||
if not isinstance(comp, Comp.Plain)
|
||||
] # 清空 Plain 组件
|
||||
self.result_chain.chain.insert(0, Comp.Plain(value))
|
||||
else:
|
||||
self._completion_text = value
|
||||
|
||||
def to_openai_tool_calls(self) -> List[Dict]:
|
||||
"""将工具调用信息转换为 OpenAI 格式"""
|
||||
ret = []
|
||||
for idx, tool_call_arg in enumerate(self.tools_call_args):
|
||||
ret.append(
|
||||
{
|
||||
"id": self.tools_call_ids[idx],
|
||||
"function": {
|
||||
"name": self.tools_call_name[idx],
|
||||
"arguments": json.dumps(tool_call_arg),
|
||||
},
|
||||
"type": "function",
|
||||
}
|
||||
)
|
||||
return ret
|
||||
__all__ = [
|
||||
"ProviderRequest",
|
||||
"ProviderType",
|
||||
"ProviderMetaData",
|
||||
"ToolCallsResult",
|
||||
"AssistantMessageSegment",
|
||||
"ToolCallMessageSegment",
|
||||
"LLMResponse",
|
||||
]
|
||||
|
||||
@@ -0,0 +1,281 @@
|
||||
import enum
|
||||
import base64
|
||||
import json
|
||||
from astrbot.core.utils.io import download_image_by_url
|
||||
from astrbot import logger
|
||||
from dataclasses import dataclass, field
|
||||
from typing import List, Dict, Type
|
||||
from .func_tool_manager import FuncCall
|
||||
from openai.types.chat.chat_completion import ChatCompletion
|
||||
from openai.types.chat.chat_completion_message_tool_call import (
|
||||
ChatCompletionMessageToolCall,
|
||||
)
|
||||
from astrbot.core.db.po import Conversation
|
||||
from astrbot.core.message.message_event_result import MessageChain
|
||||
import astrbot.core.message.components as Comp
|
||||
|
||||
|
||||
class ProviderType(enum.Enum):
|
||||
CHAT_COMPLETION = "chat_completion"
|
||||
SPEECH_TO_TEXT = "speech_to_text"
|
||||
TEXT_TO_SPEECH = "text_to_speech"
|
||||
|
||||
|
||||
@dataclass
|
||||
class ProviderMetaData:
|
||||
type: str
|
||||
"""提供商适配器名称,如 openai, ollama"""
|
||||
desc: str = ""
|
||||
"""提供商适配器描述."""
|
||||
provider_type: ProviderType = ProviderType.CHAT_COMPLETION
|
||||
cls_type: Type = None
|
||||
|
||||
default_config_tmpl: dict = None
|
||||
"""平台的默认配置模板"""
|
||||
provider_display_name: str = None
|
||||
"""显示在 WebUI 配置页中的提供商名称,如空则是 type"""
|
||||
|
||||
|
||||
@dataclass
|
||||
class ToolCallMessageSegment:
|
||||
"""OpenAI 格式的上下文中 role 为 tool 的消息段。参考: https://platform.openai.com/docs/guides/function-calling"""
|
||||
|
||||
tool_call_id: str
|
||||
content: str
|
||||
role: str = "tool"
|
||||
|
||||
def to_dict(self):
|
||||
return {
|
||||
"tool_call_id": self.tool_call_id,
|
||||
"content": self.content,
|
||||
"role": self.role,
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class AssistantMessageSegment:
|
||||
"""OpenAI 格式的上下文中 role 为 assistant 的消息段。参考: https://platform.openai.com/docs/guides/function-calling"""
|
||||
|
||||
content: str = None
|
||||
tool_calls: List[ChatCompletionMessageToolCall | Dict] = None
|
||||
role: str = "assistant"
|
||||
|
||||
def to_dict(self):
|
||||
ret = {
|
||||
"role": self.role,
|
||||
}
|
||||
if self.content:
|
||||
ret["content"] = self.content
|
||||
elif self.tool_calls:
|
||||
ret["tool_calls"] = self.tool_calls
|
||||
return ret
|
||||
|
||||
|
||||
@dataclass
|
||||
class ToolCallsResult:
|
||||
"""工具调用结果"""
|
||||
|
||||
tool_calls_info: AssistantMessageSegment
|
||||
"""函数调用的信息"""
|
||||
tool_calls_result: List[ToolCallMessageSegment]
|
||||
"""函数调用的结果"""
|
||||
|
||||
def to_openai_messages(self) -> List[Dict]:
|
||||
ret = [
|
||||
self.tool_calls_info.to_dict(),
|
||||
*[item.to_dict() for item in self.tool_calls_result],
|
||||
]
|
||||
return ret
|
||||
|
||||
|
||||
@dataclass
|
||||
class ProviderRequest:
|
||||
prompt: str
|
||||
"""提示词"""
|
||||
session_id: str = ""
|
||||
"""会话 ID"""
|
||||
image_urls: List[str] = None
|
||||
"""图片 URL 列表"""
|
||||
func_tool: FuncCall = None
|
||||
"""可用的函数工具"""
|
||||
contexts: List = None
|
||||
"""上下文。格式与 openai 的上下文格式一致:
|
||||
参考 https://platform.openai.com/docs/api-reference/chat/create#chat-create-messages
|
||||
"""
|
||||
system_prompt: str = ""
|
||||
"""系统提示词"""
|
||||
conversation: Conversation = None
|
||||
|
||||
tool_calls_result: ToolCallsResult = None
|
||||
"""附加的上次请求后工具调用的结果。参考: https://platform.openai.com/docs/guides/function-calling#handling-function-calls"""
|
||||
|
||||
def __repr__(self):
|
||||
return f"ProviderRequest(prompt={self.prompt}, session_id={self.session_id}, image_urls={self.image_urls}, func_tool={self.func_tool}, contexts={self._print_friendly_context()}, system_prompt={self.system_prompt.strip()}, tool_calls_result={self.tool_calls_result})"
|
||||
|
||||
def __str__(self):
|
||||
return self.__repr__()
|
||||
|
||||
def _print_friendly_context(self):
|
||||
"""打印友好的消息上下文。将 image_url 的值替换为 <Image>"""
|
||||
if not self.contexts:
|
||||
return f"prompt: {self.prompt}, image_count: {len(self.image_urls or [])}"
|
||||
|
||||
result_parts = []
|
||||
|
||||
for ctx in self.contexts:
|
||||
role = ctx.get("role", "unknown")
|
||||
content = ctx.get("content", "")
|
||||
|
||||
if isinstance(content, str):
|
||||
result_parts.append(f"{role}: {content}")
|
||||
elif isinstance(content, list):
|
||||
msg_parts = []
|
||||
image_count = 0
|
||||
|
||||
for item in content:
|
||||
item_type = item.get("type", "")
|
||||
|
||||
if item_type == "text":
|
||||
msg_parts.append(item.get("text", ""))
|
||||
elif item_type == "image_url":
|
||||
image_count += 1
|
||||
|
||||
if image_count > 0:
|
||||
if msg_parts:
|
||||
msg_parts.append(f"[+{image_count} images]")
|
||||
else:
|
||||
msg_parts.append(f"[{image_count} images]")
|
||||
|
||||
result_parts.append(f"{role}: {''.join(msg_parts)}")
|
||||
|
||||
return result_parts
|
||||
|
||||
async def assemble_context(self) -> Dict:
|
||||
"""将请求(prompt 和 image_urls)包装成 OpenAI 的消息格式。"""
|
||||
if self.image_urls:
|
||||
user_content = {
|
||||
"role": "user",
|
||||
"content": [{"type": "text", "text": self.prompt}],
|
||||
}
|
||||
for image_url in self.image_urls:
|
||||
if image_url.startswith("http"):
|
||||
image_path = await download_image_by_url(image_url)
|
||||
image_data = await self._encode_image_bs64(image_path)
|
||||
elif image_url.startswith("file:///"):
|
||||
image_path = image_url.replace("file:///", "")
|
||||
image_data = await self._encode_image_bs64(image_path)
|
||||
else:
|
||||
image_data = await self._encode_image_bs64(image_url)
|
||||
if not image_data:
|
||||
logger.warning(f"图片 {image_url} 得到的结果为空,将忽略。")
|
||||
continue
|
||||
user_content["content"].append(
|
||||
{"type": "image_url", "image_url": {"url": image_data}}
|
||||
)
|
||||
return user_content
|
||||
else:
|
||||
return {"role": "user", "content": self.prompt}
|
||||
|
||||
async def _encode_image_bs64(self, image_url: str) -> str:
|
||||
"""将图片转换为 base64"""
|
||||
if image_url.startswith("base64://"):
|
||||
return image_url.replace("base64://", "data:image/jpeg;base64,")
|
||||
with open(image_url, "rb") as f:
|
||||
image_bs64 = base64.b64encode(f.read()).decode("utf-8")
|
||||
return "data:image/jpeg;base64," + image_bs64
|
||||
return ""
|
||||
|
||||
|
||||
@dataclass
|
||||
class LLMResponse:
|
||||
role: str
|
||||
"""角色, assistant, tool, err"""
|
||||
result_chain: MessageChain = None
|
||||
"""返回的消息链"""
|
||||
tools_call_args: List[Dict[str, any]] = field(default_factory=list)
|
||||
"""工具调用参数"""
|
||||
tools_call_name: List[str] = field(default_factory=list)
|
||||
"""工具调用名称"""
|
||||
tools_call_ids: List[str] = field(default_factory=list)
|
||||
"""工具调用 ID"""
|
||||
|
||||
raw_completion: ChatCompletion = None
|
||||
_new_record: Dict[str, any] = None
|
||||
|
||||
_completion_text: str = ""
|
||||
|
||||
is_chunk: bool = False
|
||||
"""是否是流式输出的单个 Chunk"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
role: str,
|
||||
completion_text: str = "",
|
||||
result_chain: MessageChain = None,
|
||||
tools_call_args: List[Dict[str, any]] = None,
|
||||
tools_call_name: List[str] = None,
|
||||
tools_call_ids: List[str] = None,
|
||||
raw_completion: ChatCompletion = None,
|
||||
_new_record: Dict[str, any] = None,
|
||||
is_chunk: bool = False,
|
||||
):
|
||||
"""初始化 LLMResponse
|
||||
|
||||
Args:
|
||||
role (str): 角色, assistant, tool, err
|
||||
completion_text (str, optional): 返回的结果文本,已经过时,推荐使用 result_chain. Defaults to "".
|
||||
result_chain (MessageChain, optional): 返回的消息链. Defaults to None.
|
||||
tools_call_args (List[Dict[str, any]], optional): 工具调用参数. Defaults to None.
|
||||
tools_call_name (List[str], optional): 工具调用名称. Defaults to None.
|
||||
raw_completion (ChatCompletion, optional): 原始响应, OpenAI 格式. Defaults to None.
|
||||
"""
|
||||
if tools_call_args is None:
|
||||
tools_call_args = []
|
||||
if tools_call_name is None:
|
||||
tools_call_name = []
|
||||
if tools_call_ids is None:
|
||||
tools_call_ids = []
|
||||
|
||||
self.role = role
|
||||
self.completion_text = completion_text
|
||||
self.result_chain = result_chain
|
||||
self.tools_call_args = tools_call_args
|
||||
self.tools_call_name = tools_call_name
|
||||
self.tools_call_ids = tools_call_ids
|
||||
self.raw_completion = raw_completion
|
||||
self._new_record = _new_record
|
||||
self.is_chunk = is_chunk
|
||||
|
||||
@property
|
||||
def completion_text(self):
|
||||
if self.result_chain:
|
||||
return self.result_chain.get_plain_text()
|
||||
return self._completion_text
|
||||
|
||||
@completion_text.setter
|
||||
def completion_text(self, value):
|
||||
if self.result_chain:
|
||||
self.result_chain.chain = [
|
||||
comp
|
||||
for comp in self.result_chain.chain
|
||||
if not isinstance(comp, Comp.Plain)
|
||||
] # 清空 Plain 组件
|
||||
self.result_chain.chain.insert(0, Comp.Plain(value))
|
||||
else:
|
||||
self._completion_text = value
|
||||
|
||||
def to_openai_tool_calls(self) -> List[Dict]:
|
||||
"""将工具调用信息转换为 OpenAI 格式"""
|
||||
ret = []
|
||||
for idx, tool_call_arg in enumerate(self.tools_call_args):
|
||||
ret.append(
|
||||
{
|
||||
"id": self.tools_call_ids[idx],
|
||||
"function": {
|
||||
"name": self.tools_call_name[idx],
|
||||
"arguments": json.dumps(tool_call_arg),
|
||||
},
|
||||
"type": "function",
|
||||
}
|
||||
)
|
||||
return ret
|
||||
@@ -339,7 +339,7 @@ class FuncCall:
|
||||
]
|
||||
logger.info(f"已关闭 MCP 服务 {name}")
|
||||
|
||||
def get_func_desc_openai_style(self) -> list:
|
||||
def get_func_desc_openai_style(self, omit_empty_parameter_field = True) -> list:
|
||||
"""
|
||||
获得 OpenAI API 风格的**已经激活**的工具描述
|
||||
"""
|
||||
@@ -348,16 +348,19 @@ class FuncCall:
|
||||
for f in self.func_list:
|
||||
if not f.active:
|
||||
continue
|
||||
_l.append(
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": f.name,
|
||||
"parameters": f.parameters,
|
||||
"description": f.description,
|
||||
},
|
||||
}
|
||||
)
|
||||
func_ = {
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": f.name,
|
||||
# "parameters": f.parameters,
|
||||
"description": f.description,
|
||||
},
|
||||
}
|
||||
func_["function"]["parameters"] = f.parameters
|
||||
if not f.parameters.get("properties") and omit_empty_parameter_field:
|
||||
# 如果 properties 为空,并且 omit_empty_parameter_field 为 True,则删除 parameters 字段
|
||||
del func_["function"]["parameters"]
|
||||
_l.append(func_)
|
||||
return _l
|
||||
|
||||
def get_func_desc_anthropic_style(self) -> list:
|
||||
|
||||
@@ -2,7 +2,7 @@ import traceback
|
||||
import asyncio
|
||||
from astrbot.core.config.astrbot_config import AstrBotConfig
|
||||
from .provider import Provider, STTProvider, TTSProvider, Personality
|
||||
from .entites import ProviderType
|
||||
from .entities import ProviderType
|
||||
from typing import List
|
||||
from astrbot.core.db import BaseDatabase
|
||||
from .register import provider_cls_map, llm_tools
|
||||
@@ -198,6 +198,10 @@ class ProviderManager:
|
||||
from .sources.fishaudio_tts_api_source import (
|
||||
ProviderFishAudioTTSAPI as ProviderFishAudioTTSAPI,
|
||||
)
|
||||
case "dashscope_tts":
|
||||
from .sources.dashscope_tts import (
|
||||
ProviderDashscopeTTSAPI as ProviderDashscopeTTSAPI,
|
||||
)
|
||||
except (ImportError, ModuleNotFoundError) as e:
|
||||
logger.critical(
|
||||
f"加载 {provider_config['type']}({provider_config['id']}) 提供商适配器失败:{e}。可能是因为有未安装的依赖。"
|
||||
@@ -306,10 +310,42 @@ class ProviderManager:
|
||||
|
||||
if len(self.provider_insts) == 0:
|
||||
self.curr_provider_inst = None
|
||||
elif (
|
||||
self.curr_provider_inst is None
|
||||
and len(self.provider_insts) > 0
|
||||
and self.provider_enabled
|
||||
):
|
||||
self.curr_provider_inst = self.provider_insts[0]
|
||||
self.selected_provider_id = self.curr_provider_inst.meta().id
|
||||
logger.info(
|
||||
f"自动选择 {self.curr_provider_inst.meta().id} 作为当前提供商适配器。"
|
||||
)
|
||||
|
||||
if len(self.stt_provider_insts) == 0:
|
||||
self.curr_stt_provider_inst = None
|
||||
elif (
|
||||
self.curr_stt_provider_inst is None
|
||||
and len(self.stt_provider_insts) > 0
|
||||
and self.stt_enabled
|
||||
):
|
||||
self.curr_stt_provider_inst = self.stt_provider_insts[0]
|
||||
self.selected_stt_provider_id = self.curr_stt_provider_inst.meta().id
|
||||
logger.info(
|
||||
f"自动选择 {self.curr_stt_provider_inst.meta().id} 作为当前语音转文本提供商适配器。"
|
||||
)
|
||||
|
||||
if len(self.tts_provider_insts) == 0:
|
||||
self.curr_tts_provider_inst = None
|
||||
elif (
|
||||
self.curr_tts_provider_inst is None
|
||||
and len(self.tts_provider_insts) > 0
|
||||
and self.tts_enabled
|
||||
):
|
||||
self.curr_tts_provider_inst = self.tts_provider_insts[0]
|
||||
self.selected_tts_provider_id = self.curr_tts_provider_inst.meta().id
|
||||
logger.info(
|
||||
f"自动选择 {self.curr_tts_provider_inst.meta().id} 作为当前文本转语音提供商适配器。"
|
||||
)
|
||||
|
||||
def get_insts(self):
|
||||
return self.provider_insts
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import abc
|
||||
from typing import List
|
||||
from astrbot.core.db import BaseDatabase
|
||||
from typing import TypedDict
|
||||
from typing import TypedDict, AsyncGenerator
|
||||
from astrbot.core.provider.func_tool_manager import FuncCall
|
||||
from astrbot.core.provider.entites import LLMResponse, ToolCallsResult
|
||||
from astrbot.core.provider.entities import LLMResponse, ToolCallsResult
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
@@ -108,7 +108,35 @@ class Provider(AbstractProvider):
|
||||
- 如果传入了 image_urls,将会在对话时附上图片。如果模型不支持图片输入,将会抛出错误。
|
||||
- 如果传入了 tools,将会使用 tools 进行 Function-calling。如果模型不支持 Function-calling,将会抛出错误。
|
||||
"""
|
||||
raise NotImplementedError()
|
||||
...
|
||||
|
||||
async def text_chat_stream(
|
||||
self,
|
||||
prompt: str,
|
||||
session_id: str = None,
|
||||
image_urls: List[str] = None,
|
||||
func_tool: FuncCall = None,
|
||||
contexts: List = None,
|
||||
system_prompt: str = None,
|
||||
tool_calls_result: ToolCallsResult = None,
|
||||
**kwargs,
|
||||
) -> AsyncGenerator[LLMResponse, None]:
|
||||
"""获得 LLM 的流式文本对话结果。会使用当前的模型进行对话。在生成的最后会返回一次完整的结果。
|
||||
|
||||
Args:
|
||||
prompt: 提示词
|
||||
session_id: 会话 ID(此属性已经被废弃)
|
||||
image_urls: 图片 URL 列表
|
||||
tools: Function-calling 工具
|
||||
contexts: 上下文
|
||||
tool_calls_result: 回传给 LLM 的工具调用结果。参考: https://platform.openai.com/docs/guides/function-calling
|
||||
kwargs: 其他参数
|
||||
|
||||
Notes:
|
||||
- 如果传入了 image_urls,将会在对话时附上图片。如果模型不支持图片输入,将会抛出错误。
|
||||
- 如果传入了 tools,将会使用 tools 进行 Function-calling。如果模型不支持 Function-calling,将会抛出错误。
|
||||
"""
|
||||
...
|
||||
|
||||
async def pop_record(self, context: List):
|
||||
"""
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
from typing import List, Dict
|
||||
from .entites import ProviderMetaData, ProviderType
|
||||
from .entities import ProviderMetaData, ProviderType
|
||||
from astrbot.core import logger
|
||||
from .func_tool_manager import FuncCall
|
||||
|
||||
|
||||
@@ -10,7 +10,8 @@ from astrbot.api.provider import Provider, Personality
|
||||
from astrbot import logger
|
||||
from astrbot.core.provider.func_tool_manager import FuncCall
|
||||
from ..register import register_provider_adapter
|
||||
from astrbot.core.provider.entites import LLMResponse, ToolCallsResult
|
||||
from astrbot.core.message.message_event_result import MessageChain
|
||||
from astrbot.core.provider.entities import LLMResponse, ToolCallsResult
|
||||
from .openai_source import ProviderOpenAIOfficial
|
||||
|
||||
|
||||
@@ -72,7 +73,8 @@ class ProviderAnthropic(ProviderOpenAIOfficial):
|
||||
if content.type == "text":
|
||||
# text completion
|
||||
completion_text = str(content.text).strip()
|
||||
llm_response.completion_text = completion_text
|
||||
# llm_response.completion_text = completion_text
|
||||
llm_response.result_chain = MessageChain().message(completion_text)
|
||||
|
||||
# Anthropic每次只返回一个函数调用
|
||||
if completion.stop_reason == "tool_use":
|
||||
@@ -145,7 +147,7 @@ class ProviderAnthropic(ProviderOpenAIOfficial):
|
||||
messages=context_query, **model_config
|
||||
)
|
||||
llm_response = LLMResponse("assistant")
|
||||
llm_response.completion_text = response.content[0].text
|
||||
llm_response.result_chain = MessageChain().message(response.content[0].text)
|
||||
llm_response.raw_completion = response
|
||||
return llm_response
|
||||
except Exception as e:
|
||||
@@ -160,6 +162,33 @@ class ProviderAnthropic(ProviderOpenAIOfficial):
|
||||
|
||||
return llm_response
|
||||
|
||||
async def text_chat_stream(
|
||||
self,
|
||||
prompt,
|
||||
session_id=None,
|
||||
image_urls=...,
|
||||
func_tool=None,
|
||||
contexts=...,
|
||||
system_prompt=None,
|
||||
tool_calls_result=None,
|
||||
**kwargs,
|
||||
):
|
||||
# raise NotImplementedError("This method is not implemented yet.")
|
||||
# 调用 text_chat 模拟流式
|
||||
llm_response = await self.text_chat(
|
||||
prompt=prompt,
|
||||
session_id=session_id,
|
||||
image_urls=image_urls,
|
||||
func_tool=func_tool,
|
||||
contexts=contexts,
|
||||
system_prompt=system_prompt,
|
||||
tool_calls_result=tool_calls_result,
|
||||
)
|
||||
llm_response.is_chunk = True
|
||||
yield llm_response
|
||||
llm_response.is_chunk = False
|
||||
yield llm_response
|
||||
|
||||
async def assemble_context(self, text: str, image_urls: List[str] = None):
|
||||
"""组装上下文,支持文本和图片"""
|
||||
if not image_urls:
|
||||
|
||||
@@ -3,10 +3,11 @@ import asyncio
|
||||
import functools
|
||||
from typing import List
|
||||
from .. import Provider, Personality
|
||||
from ..entites import LLMResponse
|
||||
from ..entities import LLMResponse
|
||||
from ..func_tool_manager import FuncCall
|
||||
from astrbot.core.db import BaseDatabase
|
||||
from ..register import register_provider_adapter
|
||||
from astrbot.core.message.message_event_result import MessageChain
|
||||
from .openai_source import ProviderOpenAIOfficial
|
||||
from astrbot.core import logger, sp
|
||||
from dashscope import Application
|
||||
@@ -51,10 +52,14 @@ class ProviderDashscope(ProviderOpenAIOfficial):
|
||||
self.timeout = int(self.timeout)
|
||||
|
||||
def has_rag_options(self):
|
||||
if (
|
||||
self.rag_options
|
||||
and self.rag_options.get("pipeline_ids", None)
|
||||
and self.rag_options.get("file_ids", None)
|
||||
"""判断是否有 RAG 选项
|
||||
|
||||
Returns:
|
||||
bool: 是否有 RAG 选项
|
||||
"""
|
||||
if self.rag_options and (
|
||||
len(self.rag_options.get("pipeline_ids", [])) > 0
|
||||
or len(self.rag_options.get("file_ids", [])) > 0
|
||||
):
|
||||
return True
|
||||
return False
|
||||
@@ -78,7 +83,7 @@ class ProviderDashscope(ProviderOpenAIOfficial):
|
||||
|
||||
if (
|
||||
self.dashscope_app_type in ["agent", "dialog-workflow"]
|
||||
and self.has_rag_options()
|
||||
and not self.has_rag_options()
|
||||
):
|
||||
# 支持多轮对话的
|
||||
new_record = {"role": "user", "content": prompt}
|
||||
@@ -92,12 +97,15 @@ class ProviderDashscope(ProviderOpenAIOfficial):
|
||||
if "_no_save" in part:
|
||||
del part["_no_save"]
|
||||
# 调用阿里云百炼 API
|
||||
payload = {
|
||||
"app_id": self.app_id,
|
||||
"api_key": self.api_key,
|
||||
"messages": context_query,
|
||||
"biz_params": payload_vars or None,
|
||||
}
|
||||
partial = functools.partial(
|
||||
Application.call,
|
||||
app_id=self.app_id,
|
||||
api_key=self.api_key,
|
||||
messages=context_query,
|
||||
biz_params=payload_vars or None,
|
||||
**payload,
|
||||
)
|
||||
response = await asyncio.get_event_loop().run_in_executor(None, partial)
|
||||
else:
|
||||
@@ -125,7 +133,9 @@ class ProviderDashscope(ProviderOpenAIOfficial):
|
||||
)
|
||||
return LLMResponse(
|
||||
role="err",
|
||||
completion_text=f"阿里云百炼请求失败: message={response.message} code={response.status_code}",
|
||||
result_chain=MessageChain().message(
|
||||
f"阿里云百炼请求失败: message={response.message} code={response.status_code}"
|
||||
),
|
||||
)
|
||||
|
||||
output_text = response.output.get("text", "")
|
||||
@@ -134,10 +144,45 @@ class ProviderDashscope(ProviderOpenAIOfficial):
|
||||
if self.output_reference and response.output.get("doc_references", None):
|
||||
ref_str = ""
|
||||
for ref in response.output.get("doc_references", []):
|
||||
ref_str += f"{ref['index_id']}. {ref['title']}\n"
|
||||
ref_title = (
|
||||
ref.get("title", "")
|
||||
if ref.get("title")
|
||||
else ref.get("doc_name", "")
|
||||
)
|
||||
ref_str += f"{ref['index_id']}. {ref_title}\n"
|
||||
output_text += f"\n\n回答来源:\n{ref_str}"
|
||||
|
||||
return LLMResponse(role="assistant", completion_text=output_text)
|
||||
llm_response = LLMResponse("assistant")
|
||||
llm_response.result_chain = MessageChain().message(output_text)
|
||||
|
||||
return llm_response
|
||||
|
||||
async def text_chat_stream(
|
||||
self,
|
||||
prompt,
|
||||
session_id=None,
|
||||
image_urls=...,
|
||||
func_tool=None,
|
||||
contexts=...,
|
||||
system_prompt=None,
|
||||
tool_calls_result=None,
|
||||
**kwargs,
|
||||
):
|
||||
# raise NotImplementedError("This method is not implemented yet.")
|
||||
# 调用 text_chat 模拟流式
|
||||
llm_response = await self.text_chat(
|
||||
prompt=prompt,
|
||||
session_id=session_id,
|
||||
image_urls=image_urls,
|
||||
func_tool=func_tool,
|
||||
contexts=contexts,
|
||||
system_prompt=system_prompt,
|
||||
tool_calls_result=tool_calls_result,
|
||||
)
|
||||
llm_response.is_chunk = True
|
||||
yield llm_response
|
||||
llm_response.is_chunk = False
|
||||
yield llm_response
|
||||
|
||||
async def forget(self, session_id):
|
||||
return True
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import dashscope
|
||||
import uuid
|
||||
import asyncio
|
||||
from dashscope.audio.tts_v2 import *
|
||||
from ..provider import TTSProvider
|
||||
from ..entities import ProviderType
|
||||
from ..register import register_provider_adapter
|
||||
|
||||
|
||||
@register_provider_adapter(
|
||||
"dashscope_tts", "Dashscope TTS API", provider_type=ProviderType.TEXT_TO_SPEECH
|
||||
)
|
||||
class ProviderDashscopeTTSAPI(TTSProvider):
|
||||
def __init__(
|
||||
self,
|
||||
provider_config: dict,
|
||||
provider_settings: dict,
|
||||
) -> None:
|
||||
super().__init__(provider_config, provider_settings)
|
||||
self.chosen_api_key: str = provider_config.get("api_key", "")
|
||||
self.voice: str = provider_config.get("dashscope_tts_voice", "loongstella")
|
||||
self.set_model(provider_config.get("model", None))
|
||||
self.timeout_ms = float(provider_config.get("timeout", 20)) * 1000
|
||||
|
||||
dashscope.api_key = self.chosen_api_key
|
||||
self.synthesizer = SpeechSynthesizer(
|
||||
model=self.get_model(),
|
||||
voice=self.voice,
|
||||
format=AudioFormat.WAV_24000HZ_MONO_16BIT,
|
||||
)
|
||||
|
||||
async def get_audio(self, text: str) -> str:
|
||||
path = f"data/temp/dashscope_tts_{uuid.uuid4()}.wav"
|
||||
audio = await asyncio.get_event_loop().run_in_executor(
|
||||
None, self.synthesizer.call, text, self.timeout_ms
|
||||
)
|
||||
with open(path, "wb") as f:
|
||||
f.write(audio)
|
||||
return path
|
||||
@@ -2,7 +2,7 @@ import astrbot.core.message.components as Comp
|
||||
|
||||
from typing import List
|
||||
from .. import Provider, Personality
|
||||
from ..entites import LLMResponse
|
||||
from ..entities import LLMResponse
|
||||
from ..func_tool_manager import FuncCall
|
||||
from astrbot.core.db import BaseDatabase
|
||||
from ..register import register_provider_adapter
|
||||
@@ -102,7 +102,7 @@ class ProviderDify(Provider):
|
||||
|
||||
try:
|
||||
match self.api_type:
|
||||
case "chat" | "agent":
|
||||
case "chat" | "agent" | "chatflow":
|
||||
if not prompt:
|
||||
prompt = "请描述这张图片。"
|
||||
|
||||
@@ -189,6 +189,33 @@ class ProviderDify(Provider):
|
||||
|
||||
return LLMResponse(role="assistant", result_chain=chain)
|
||||
|
||||
async def text_chat_stream(
|
||||
self,
|
||||
prompt,
|
||||
session_id=None,
|
||||
image_urls=...,
|
||||
func_tool=None,
|
||||
contexts=...,
|
||||
system_prompt=None,
|
||||
tool_calls_result=None,
|
||||
**kwargs,
|
||||
):
|
||||
# raise NotImplementedError("This method is not implemented yet.")
|
||||
# 调用 text_chat 模拟流式
|
||||
llm_response = await self.text_chat(
|
||||
prompt=prompt,
|
||||
session_id=session_id,
|
||||
image_urls=image_urls,
|
||||
func_tool=func_tool,
|
||||
contexts=contexts,
|
||||
system_prompt=system_prompt,
|
||||
tool_calls_result=tool_calls_result,
|
||||
)
|
||||
llm_response.is_chunk = True
|
||||
yield llm_response
|
||||
llm_response.is_chunk = False
|
||||
yield llm_response
|
||||
|
||||
async def parse_dify_result(self, chunk: dict | str) -> MessageChain:
|
||||
if isinstance(chunk, str):
|
||||
# Chat
|
||||
|
||||
@@ -4,7 +4,7 @@ import edge_tts
|
||||
import subprocess
|
||||
import asyncio
|
||||
from ..provider import TTSProvider
|
||||
from ..entites import ProviderType
|
||||
from ..entities import ProviderType
|
||||
from ..register import register_provider_adapter
|
||||
from astrbot.core import logger
|
||||
|
||||
@@ -35,6 +35,8 @@ class ProviderEdgeTTS(TTSProvider):
|
||||
self.pitch = provider_config.get("pitch", None)
|
||||
self.timeout = provider_config.get("timeout", 30)
|
||||
|
||||
self.proxy = os.getenv("https_proxy", None)
|
||||
|
||||
self.set_model("edge_tts")
|
||||
|
||||
async def get_audio(self, text: str) -> str:
|
||||
@@ -42,7 +44,7 @@ class ProviderEdgeTTS(TTSProvider):
|
||||
mp3_path = f"data/temp/edge_tts_temp_{uuid.uuid4()}.mp3"
|
||||
wav_path = f"data/temp/edge_tts_{uuid.uuid4()}.wav"
|
||||
|
||||
# 构建Edge TTS参数
|
||||
# 构建 Edge TTS 参数
|
||||
kwargs = {"text": text, "voice": self.voice}
|
||||
if self.rate:
|
||||
kwargs["rate"] = self.rate
|
||||
@@ -52,35 +54,45 @@ class ProviderEdgeTTS(TTSProvider):
|
||||
kwargs["pitch"] = self.pitch
|
||||
|
||||
try:
|
||||
communicate = edge_tts.Communicate(**kwargs)
|
||||
communicate = edge_tts.Communicate(proxy=self.proxy, **kwargs)
|
||||
await communicate.save(mp3_path)
|
||||
|
||||
# 使用ffmpeg将MP3转换为标准WAV格式
|
||||
_ = await asyncio.create_subprocess_exec(
|
||||
"ffmpeg",
|
||||
"-y", # 覆盖输出文件
|
||||
"-i",
|
||||
mp3_path, # 输入文件
|
||||
"-acodec",
|
||||
"pcm_s16le", # 16位PCM编码
|
||||
"-ar",
|
||||
"24000", # 采样率24kHz (适合微信语音)
|
||||
"-ac",
|
||||
"1", # 单声道
|
||||
"-af",
|
||||
"apad=pad_dur=2", # 确保输出时长准确
|
||||
"-fflags",
|
||||
"+genpts", # 强制生成时间戳
|
||||
"-hide_banner", # 隐藏版本信息
|
||||
wav_path, # 输出文件
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
)
|
||||
# 等待进程完成并获取输出
|
||||
stdout, stderr = await _.communicate()
|
||||
logger.info(f"[EdgeTTS] FFmpeg 标准输出: {stdout.decode().strip()}")
|
||||
logger.debug(f"FFmpeg错误输出: {stderr.decode().strip()}")
|
||||
logger.info(f"[EdgeTTS] 返回值(0代表成功): {_.returncode}")
|
||||
try:
|
||||
from pyffmpeg import FFmpeg
|
||||
|
||||
ff = FFmpeg()
|
||||
ff.convert(input=mp3_path, output=wav_path)
|
||||
except Exception as e:
|
||||
logger.debug(f"pyffmpeg 转换失败: {e}, 尝试使用 ffmpeg 命令行进行转换")
|
||||
# use ffmpeg command line
|
||||
|
||||
# 使用ffmpeg将MP3转换为标准WAV格式
|
||||
p = await asyncio.create_subprocess_exec(
|
||||
"ffmpeg",
|
||||
"-y", # 覆盖输出文件
|
||||
"-i",
|
||||
mp3_path, # 输入文件
|
||||
"-acodec",
|
||||
"pcm_s16le", # 16位PCM编码
|
||||
"-ar",
|
||||
"24000", # 采样率24kHz (适合微信语音)
|
||||
"-ac",
|
||||
"1", # 单声道
|
||||
"-af",
|
||||
"apad=pad_dur=2", # 确保输出时长准确
|
||||
"-fflags",
|
||||
"+genpts", # 强制生成时间戳
|
||||
"-hide_banner", # 隐藏版本信息
|
||||
wav_path, # 输出文件
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
)
|
||||
# 等待进程完成并获取输出
|
||||
stdout, stderr = await p.communicate()
|
||||
logger.info(f"[EdgeTTS] FFmpeg 标准输出: {stdout.decode().strip()}")
|
||||
logger.debug(f"FFmpeg错误输出: {stderr.decode().strip()}")
|
||||
logger.info(f"[EdgeTTS] 返回值(0代表成功): {p.returncode}")
|
||||
|
||||
os.remove(mp3_path)
|
||||
if os.path.exists(wav_path) and os.path.getsize(wav_path) > 0:
|
||||
return wav_path
|
||||
|
||||
@@ -4,7 +4,7 @@ from pydantic import BaseModel, conint
|
||||
from httpx import AsyncClient
|
||||
from typing import Annotated, Literal
|
||||
from ..provider import TTSProvider
|
||||
from ..entites import ProviderType
|
||||
from ..entities import ProviderType
|
||||
from ..register import register_provider_adapter
|
||||
|
||||
|
||||
|
||||
@@ -2,6 +2,9 @@ import base64
|
||||
import aiohttp
|
||||
import json
|
||||
import random
|
||||
import asyncio
|
||||
import astrbot.core.message.components as Comp
|
||||
from astrbot.core.message.message_event_result import MessageChain
|
||||
from astrbot.core.utils.io import download_image_by_url
|
||||
from astrbot.core.db import BaseDatabase
|
||||
from astrbot.api.provider import Provider, Personality
|
||||
@@ -9,7 +12,7 @@ from astrbot import logger
|
||||
from astrbot.core.provider.func_tool_manager import FuncCall
|
||||
from typing import List
|
||||
from ..register import register_provider_adapter
|
||||
from astrbot.core.provider.entites import LLMResponse
|
||||
from astrbot.core.provider.entities import LLMResponse
|
||||
|
||||
|
||||
class SimpleGoogleGenAIClient:
|
||||
@@ -39,6 +42,8 @@ class SimpleGoogleGenAIClient:
|
||||
model: str = "gemini-1.5-flash",
|
||||
system_instruction: str = "",
|
||||
tools: dict = None,
|
||||
modalities: List[str] = ["Text"],
|
||||
safety_settings: List[dict] = [],
|
||||
):
|
||||
payload = {}
|
||||
if system_instruction:
|
||||
@@ -46,6 +51,13 @@ class SimpleGoogleGenAIClient:
|
||||
if tools:
|
||||
payload["tools"] = [tools]
|
||||
payload["contents"] = contents
|
||||
payload["generationConfig"] = {
|
||||
"responseModalities": modalities,
|
||||
}
|
||||
payload["safetySettings"] = [
|
||||
{"category": s["category"], "threshold": s["threshold"]}
|
||||
for s in safety_settings
|
||||
]
|
||||
logger.debug(f"payload: {payload}")
|
||||
request_url = (
|
||||
f"{self.api_base}/v1beta/models/{model}:generateContent?key={self.api_key}"
|
||||
@@ -66,6 +78,39 @@ class SimpleGoogleGenAIClient:
|
||||
logger.error(f"Gemini 返回了非 json 数据: {text}")
|
||||
raise Exception("Gemini 返回了非 json 数据: ")
|
||||
|
||||
async def stream_generate_content(
|
||||
self,
|
||||
contents: List[dict],
|
||||
model: str = "gemini-1.5-flash",
|
||||
system_instruction: str = "",
|
||||
tools: dict = None,
|
||||
modalities: List[str] = ["Text"],
|
||||
safety_settings: List[dict] = [],
|
||||
):
|
||||
payload = {}
|
||||
if system_instruction:
|
||||
payload["system_instruction"] = {"parts": {"text": system_instruction}}
|
||||
if tools:
|
||||
payload["tools"] = [tools]
|
||||
payload["contents"] = contents
|
||||
payload["generationConfig"] = {
|
||||
"responseModalities": modalities,
|
||||
"stream": True,
|
||||
}
|
||||
payload["safetySettings"] = [
|
||||
{"category": s["category"], "threshold": s["threshold"]}
|
||||
for s in safety_settings
|
||||
]
|
||||
logger.debug(f"payload: {payload}")
|
||||
request_url = (
|
||||
f"{self.api_base}/v1beta/models/{model}:streamGenerateContent?key={self.api_key}"
|
||||
)
|
||||
async with self.client.post(
|
||||
request_url, json=payload, timeout=self.timeout
|
||||
) as resp:
|
||||
async for line in resp.content:
|
||||
if line:
|
||||
yield line
|
||||
|
||||
@register_provider_adapter(
|
||||
"googlegenai_chat_completion", "Google Gemini Chat Completion 提供商适配器"
|
||||
@@ -99,6 +144,21 @@ class ProviderGoogleGenAI(Provider):
|
||||
)
|
||||
self.set_model(provider_config["model_config"]["model"])
|
||||
|
||||
safety_mapping = {
|
||||
"harassment": "HARM_CATEGORY_HARASSMENT",
|
||||
"hate_speech": "HARM_CATEGORY_HATE_SPEECH",
|
||||
"sexually_explicit": "HARM_CATEGORY_SEXUALLY_EXPLICIT",
|
||||
"dangerous_content": "HARM_CATEGORY_DANGEROUS_CONTENT",
|
||||
}
|
||||
|
||||
self.safety_settings = []
|
||||
user_safety_config = self.provider_config.get("gm_safety_settings", {})
|
||||
for config_key, harm_category in safety_mapping.items():
|
||||
if threshold := user_safety_config.get(config_key):
|
||||
self.safety_settings.append(
|
||||
{"category": harm_category, "threshold": threshold}
|
||||
)
|
||||
|
||||
async def get_models(self):
|
||||
return await self.client.models_list()
|
||||
|
||||
@@ -120,7 +180,7 @@ class ProviderGoogleGenAI(Provider):
|
||||
if message["role"] == "user":
|
||||
if isinstance(message["content"], str):
|
||||
if not message["content"]:
|
||||
message["content"] = "<empty_content>"
|
||||
message["content"] = " "
|
||||
|
||||
google_genai_conversation.append(
|
||||
{"role": "user", "parts": [{"text": message["content"]}]}
|
||||
@@ -131,7 +191,7 @@ class ProviderGoogleGenAI(Provider):
|
||||
for part in message["content"]:
|
||||
if part["type"] == "text":
|
||||
if not part["text"]:
|
||||
part["text"] = "<empty_content>"
|
||||
part["text"] = ""
|
||||
parts.append({"text": part["text"]})
|
||||
elif part["type"] == "image_url":
|
||||
parts.append(
|
||||
@@ -149,7 +209,7 @@ class ProviderGoogleGenAI(Provider):
|
||||
elif message["role"] == "assistant":
|
||||
if "content" in message:
|
||||
if not message["content"]:
|
||||
message["content"] = "<empty_content>"
|
||||
message["content"] = " "
|
||||
google_genai_conversation.append(
|
||||
{"role": "model", "parts": [{"text": message["content"]}]}
|
||||
)
|
||||
@@ -185,22 +245,54 @@ class ProviderGoogleGenAI(Provider):
|
||||
|
||||
logger.debug(f"google_genai_conversation: {google_genai_conversation}")
|
||||
|
||||
result = await self.client.generate_content(
|
||||
contents=google_genai_conversation,
|
||||
model=self.get_model(),
|
||||
system_instruction=system_instruction,
|
||||
tools=tool,
|
||||
)
|
||||
logger.debug(f"result: {result}")
|
||||
modalites = ["Text"]
|
||||
if self.provider_config.get("gm_resp_image_modal", False):
|
||||
modalites.append("Image")
|
||||
|
||||
if "candidates" not in result:
|
||||
raise Exception("Gemini 返回异常结果: " + str(result))
|
||||
loop = True
|
||||
while loop:
|
||||
loop = False
|
||||
result = await self.client.generate_content(
|
||||
contents=google_genai_conversation,
|
||||
model=self.get_model(),
|
||||
system_instruction=system_instruction,
|
||||
tools=tool,
|
||||
modalities=modalites,
|
||||
safety_settings=self.safety_settings,
|
||||
)
|
||||
logger.debug(f"result: {result}")
|
||||
|
||||
# Developer instruction is not enabled for models/gemini-2.0-flash-exp
|
||||
if "Developer instruction is not enabled" in str(result):
|
||||
logger.warning(
|
||||
f"{self.get_model()} 不支持 system prompt, 已自动去除, 将会影响人格设置。"
|
||||
)
|
||||
system_instruction = ""
|
||||
loop = True
|
||||
|
||||
elif "Function calling is not enabled" in str(result):
|
||||
logger.warning(
|
||||
f"{self.get_model()} 不支持函数调用,已自动去除,不影响使用。"
|
||||
)
|
||||
tool = None
|
||||
loop = True
|
||||
|
||||
elif "Multi-modal output is not supported" in str(result):
|
||||
logger.warning(
|
||||
f"{self.get_model()} 不支持多模态输出,降级为文本模态重新请求。"
|
||||
)
|
||||
modalites = ["Text"]
|
||||
loop = True
|
||||
|
||||
elif "candidates" not in result:
|
||||
raise Exception("Gemini 返回异常结果: " + str(result))
|
||||
|
||||
candidates = result["candidates"][0]["content"]["parts"]
|
||||
llm_response = LLMResponse("assistant")
|
||||
chain = []
|
||||
for candidate in candidates:
|
||||
if "text" in candidate:
|
||||
llm_response.completion_text += candidate["text"]
|
||||
chain.append(Comp.Plain(candidate["text"]))
|
||||
elif "functionCall" in candidate:
|
||||
llm_response.role = "tool"
|
||||
llm_response.tools_call_args.append(candidate["functionCall"]["args"])
|
||||
@@ -208,8 +300,12 @@ class ProviderGoogleGenAI(Provider):
|
||||
llm_response.tools_call_ids.append(
|
||||
candidate["functionCall"]["name"]
|
||||
) # 没有 tool id
|
||||
elif "inlineData" in candidate:
|
||||
mime_type: str = candidate["inlineData"]["mimeType"]
|
||||
if mime_type.startswith("image/"):
|
||||
chain.append(Comp.Image.fromBase64(candidate["inlineData"]["data"]))
|
||||
|
||||
llm_response.completion_text = llm_response.completion_text.strip()
|
||||
llm_response.result_chain = MessageChain(chain=chain)
|
||||
return llm_response
|
||||
|
||||
async def text_chat(
|
||||
@@ -253,46 +349,20 @@ class ProviderGoogleGenAI(Provider):
|
||||
llm_response = await self._query(payloads, func_tool)
|
||||
break
|
||||
except Exception as e:
|
||||
if "maximum context length" in str(e):
|
||||
retry_cnt = 20
|
||||
while retry_cnt > 0:
|
||||
logger.warning(
|
||||
f"请求失败:{e}。上下文长度超过限制。尝试弹出最早的记录然后重试。当前记录条数: {len(context_query)}"
|
||||
)
|
||||
try:
|
||||
await self.pop_record(context_query)
|
||||
llm_response = await self._query(payloads, func_tool)
|
||||
break
|
||||
except Exception as e:
|
||||
if "maximum context length" in str(e):
|
||||
retry_cnt -= 1
|
||||
else:
|
||||
raise e
|
||||
if retry_cnt == 0:
|
||||
llm_response = LLMResponse(
|
||||
"err", "err: 请尝试 /reset 重置会话"
|
||||
)
|
||||
elif "Function calling is not enabled" in str(e):
|
||||
logger.info(
|
||||
f"{self.get_model()} 不支持函数工具调用,已自动去除,不影响使用。"
|
||||
)
|
||||
if "tools" in payloads:
|
||||
del payloads["tools"]
|
||||
llm_response = await self._query(payloads, None)
|
||||
break
|
||||
elif "429" in str(e) or "API key not valid" in str(e):
|
||||
if "429" in str(e) or "API key not valid" in str(e):
|
||||
keys.remove(chosen_key)
|
||||
if len(keys) > 0:
|
||||
chosen_key = random.choice(keys)
|
||||
logger.info(
|
||||
f"检测到 Key 异常({str(e)}),正在尝试更换 API Key 重试... 当前 Key: {chosen_key[:12]}..."
|
||||
)
|
||||
await asyncio.sleep(1)
|
||||
continue
|
||||
else:
|
||||
logger.error(
|
||||
f"检测到 Key 异常({str(e)}),且已没有可用的 Key。 当前 Key: {chosen_key[:12]}..."
|
||||
)
|
||||
raise Exception("API 资源已耗尽,且没有可用的 Key 重试...")
|
||||
raise Exception("达到了 Gemini 速率限制, 请稍后再试...")
|
||||
else:
|
||||
logger.error(
|
||||
f"发生了错误(gemini_source)。Provider 配置如下: {self.provider_config}"
|
||||
@@ -301,6 +371,33 @@ class ProviderGoogleGenAI(Provider):
|
||||
|
||||
return llm_response
|
||||
|
||||
async def text_chat_stream(
|
||||
self,
|
||||
prompt,
|
||||
session_id=None,
|
||||
image_urls=...,
|
||||
func_tool=None,
|
||||
contexts=...,
|
||||
system_prompt=None,
|
||||
tool_calls_result=None,
|
||||
**kwargs,
|
||||
):
|
||||
# raise NotImplementedError("This method is not implemented yet.")
|
||||
# 调用 text_chat 模拟流式
|
||||
llm_response = await self.text_chat(
|
||||
prompt=prompt,
|
||||
session_id=session_id,
|
||||
image_urls=image_urls,
|
||||
func_tool=func_tool,
|
||||
contexts=contexts,
|
||||
system_prompt=system_prompt,
|
||||
tool_calls_result=tool_calls_result,
|
||||
)
|
||||
llm_response.is_chunk = True
|
||||
yield llm_response
|
||||
llm_response.is_chunk = False
|
||||
yield llm_response
|
||||
|
||||
def get_current_key(self) -> str:
|
||||
return self.client.api_key
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import uuid
|
||||
import aiohttp
|
||||
import urllib.parse
|
||||
from ..provider import TTSProvider
|
||||
from ..entites import ProviderType
|
||||
from ..entities import ProviderType
|
||||
from ..register import register_provider_adapter
|
||||
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import os
|
||||
from llmtuner.chat import ChatModel
|
||||
from typing import List
|
||||
from .. import Provider
|
||||
from ..entites import LLMResponse
|
||||
from ..entities import LLMResponse
|
||||
from ..func_tool_manager import FuncCall
|
||||
from astrbot.core.db import BaseDatabase
|
||||
from ..register import register_provider_adapter
|
||||
@@ -95,6 +95,33 @@ class LLMTunerModelLoader(Provider):
|
||||
|
||||
return llm_response
|
||||
|
||||
async def text_chat_stream(
|
||||
self,
|
||||
prompt,
|
||||
session_id=None,
|
||||
image_urls=...,
|
||||
func_tool=None,
|
||||
contexts=...,
|
||||
system_prompt=None,
|
||||
tool_calls_result=None,
|
||||
**kwargs,
|
||||
):
|
||||
# raise NotImplementedError("This method is not implemented yet.")
|
||||
# 调用 text_chat 模拟流式
|
||||
llm_response = await self.text_chat(
|
||||
prompt=prompt,
|
||||
session_id=session_id,
|
||||
image_urls=image_urls,
|
||||
func_tool=func_tool,
|
||||
contexts=contexts,
|
||||
system_prompt=system_prompt,
|
||||
tool_calls_result=tool_calls_result,
|
||||
)
|
||||
llm_response.is_chunk = True
|
||||
yield llm_response
|
||||
llm_response.is_chunk = False
|
||||
yield llm_response
|
||||
|
||||
async def get_current_key(self):
|
||||
return "none"
|
||||
|
||||
|
||||
@@ -2,19 +2,26 @@ import base64
|
||||
import json
|
||||
import os
|
||||
import inspect
|
||||
import random
|
||||
import asyncio
|
||||
import astrbot.core.message.components as Comp
|
||||
|
||||
from openai import AsyncOpenAI, AsyncAzureOpenAI
|
||||
from openai.types.chat.chat_completion import ChatCompletion
|
||||
|
||||
# from openai.types.chat.chat_completion_chunk import ChatCompletionChunk
|
||||
from openai._exceptions import NotFoundError, UnprocessableEntityError
|
||||
from openai.lib.streaming.chat._completions import ChatCompletionStreamState
|
||||
from astrbot.core.utils.io import download_image_by_url
|
||||
from astrbot.core.message.message_event_result import MessageChain
|
||||
|
||||
from astrbot.core.db import BaseDatabase
|
||||
from astrbot.api.provider import Provider, Personality
|
||||
from astrbot import logger
|
||||
from astrbot.core.provider.func_tool_manager import FuncCall
|
||||
from typing import List
|
||||
from typing import List, AsyncGenerator
|
||||
from ..register import register_provider_adapter
|
||||
from astrbot.core.provider.entites import LLMResponse
|
||||
from astrbot.core.provider.entities import LLMResponse
|
||||
|
||||
|
||||
@register_provider_adapter(
|
||||
@@ -80,7 +87,10 @@ class ProviderOpenAIOfficial(Provider):
|
||||
|
||||
async def _query(self, payloads: dict, tools: FuncCall) -> LLMResponse:
|
||||
if tools:
|
||||
tool_list = tools.get_func_desc_openai_style()
|
||||
omit_empty_param_field = "grok" not in payloads.get("model", "").lower()
|
||||
tool_list = tools.get_func_desc_openai_style(
|
||||
omit_empty_parameter_field=omit_empty_param_field
|
||||
)
|
||||
if tool_list:
|
||||
payloads["tools"] = tool_list
|
||||
|
||||
@@ -105,16 +115,75 @@ class ProviderOpenAIOfficial(Provider):
|
||||
|
||||
logger.debug(f"completion: {completion}")
|
||||
|
||||
llm_response = await self.parse_openai_completion(completion, tools)
|
||||
|
||||
return llm_response
|
||||
|
||||
async def _query_stream(
|
||||
self, payloads: dict, tools: FuncCall
|
||||
) -> AsyncGenerator[LLMResponse, None]:
|
||||
"""流式查询API,逐步返回结果"""
|
||||
if tools:
|
||||
omit_empty_param_field = "grok" not in payloads.get("model", "").lower()
|
||||
tool_list = tools.get_func_desc_openai_style(
|
||||
omit_empty_parameter_field=omit_empty_param_field
|
||||
)
|
||||
if tool_list:
|
||||
payloads["tools"] = tool_list
|
||||
|
||||
# 不在默认参数中的参数放在 extra_body 中
|
||||
extra_body = {}
|
||||
to_del = []
|
||||
for key in payloads.keys():
|
||||
if key not in self.default_params:
|
||||
extra_body[key] = payloads[key]
|
||||
to_del.append(key)
|
||||
for key in to_del:
|
||||
del payloads[key]
|
||||
|
||||
stream = await self.client.chat.completions.create(
|
||||
**payloads, stream=True, extra_body=extra_body
|
||||
)
|
||||
|
||||
llm_response = LLMResponse("assistant", is_chunk=True)
|
||||
|
||||
state = ChatCompletionStreamState()
|
||||
|
||||
async for chunk in stream:
|
||||
try:
|
||||
state.handle_chunk(chunk)
|
||||
except Exception as e:
|
||||
logger.warning("Saving chunk state error: " + str(e))
|
||||
if len(chunk.choices) == 0:
|
||||
continue
|
||||
delta = chunk.choices[0].delta
|
||||
# 处理文本内容
|
||||
if delta.content:
|
||||
completion_text = delta.content
|
||||
llm_response.result_chain = MessageChain(
|
||||
chain=[Comp.Plain(completion_text)]
|
||||
)
|
||||
yield llm_response
|
||||
|
||||
final_completion = state.get_final_completion()
|
||||
llm_response = await self.parse_openai_completion(final_completion, tools)
|
||||
|
||||
yield llm_response
|
||||
|
||||
async def parse_openai_completion(
|
||||
self, completion: ChatCompletion, tools: FuncCall
|
||||
):
|
||||
"""解析 OpenAI 的 ChatCompletion 响应"""
|
||||
llm_response = LLMResponse("assistant")
|
||||
|
||||
if len(completion.choices) == 0:
|
||||
raise Exception("API 返回的 completion 为空。")
|
||||
choice = completion.choices[0]
|
||||
|
||||
llm_response = LLMResponse("assistant")
|
||||
|
||||
if choice.message.content:
|
||||
# text completion
|
||||
completion_text = str(choice.message.content).strip()
|
||||
llm_response.completion_text = completion_text
|
||||
llm_response.result_chain = MessageChain().message(completion_text)
|
||||
|
||||
if choice.message.tool_calls:
|
||||
# tools call (function calling)
|
||||
@@ -146,7 +215,7 @@ class ProviderOpenAIOfficial(Provider):
|
||||
|
||||
return llm_response
|
||||
|
||||
async def text_chat(
|
||||
async def _prepare_chat_payload(
|
||||
self,
|
||||
prompt: str,
|
||||
session_id: str = None,
|
||||
@@ -156,7 +225,8 @@ class ProviderOpenAIOfficial(Provider):
|
||||
system_prompt=None,
|
||||
tool_calls_result=None,
|
||||
**kwargs,
|
||||
) -> LLMResponse:
|
||||
) -> tuple:
|
||||
"""准备聊天所需的有效载荷和上下文"""
|
||||
new_record = await self.assemble_context(prompt, image_urls)
|
||||
context_query = [*contexts, new_record]
|
||||
if system_prompt:
|
||||
@@ -175,80 +245,226 @@ class ProviderOpenAIOfficial(Provider):
|
||||
|
||||
payloads = {"messages": context_query, **model_config}
|
||||
|
||||
llm_response = None
|
||||
try:
|
||||
llm_response = await self._query(payloads, func_tool)
|
||||
except UnprocessableEntityError as e:
|
||||
logger.warning(f"不可处理的实体错误:{e},尝试删除图片。")
|
||||
return payloads, context_query, func_tool
|
||||
|
||||
async def _handle_api_error(
|
||||
self,
|
||||
e: Exception,
|
||||
payloads: dict,
|
||||
context_query: list,
|
||||
func_tool: FuncCall,
|
||||
chosen_key: str,
|
||||
available_api_keys: List[str],
|
||||
retry_cnt: int,
|
||||
max_retries: int,
|
||||
) -> tuple:
|
||||
"""处理API错误并尝试恢复"""
|
||||
if "429" in str(e):
|
||||
logger.warning(
|
||||
f"API 调用过于频繁,尝试使用其他 Key 重试。当前 Key: {chosen_key[:12]}"
|
||||
)
|
||||
# 最后一次不等待
|
||||
if retry_cnt < max_retries - 1:
|
||||
await asyncio.sleep(1)
|
||||
available_api_keys.remove(chosen_key)
|
||||
if len(available_api_keys) > 0:
|
||||
chosen_key = random.choice(available_api_keys)
|
||||
return (
|
||||
False,
|
||||
chosen_key,
|
||||
available_api_keys,
|
||||
payloads,
|
||||
context_query,
|
||||
func_tool,
|
||||
)
|
||||
else:
|
||||
raise e
|
||||
elif "maximum context length" in str(e):
|
||||
logger.warning(
|
||||
f"上下文长度超过限制。尝试弹出最早的记录然后重试。当前记录条数: {len(context_query)}"
|
||||
)
|
||||
await self.pop_record(context_query)
|
||||
payloads["messages"] = context_query
|
||||
return (
|
||||
False,
|
||||
chosen_key,
|
||||
available_api_keys,
|
||||
payloads,
|
||||
context_query,
|
||||
func_tool,
|
||||
)
|
||||
elif "The model is not a VLM" in str(e): # siliconcloud
|
||||
# 尝试删除所有 image
|
||||
new_contexts = await self._remove_image_from_context(context_query)
|
||||
payloads["messages"] = new_contexts
|
||||
context_query = new_contexts
|
||||
llm_response = await self._query(payloads, func_tool)
|
||||
except Exception as e:
|
||||
if "maximum context length" in str(e):
|
||||
# 重试 10 次
|
||||
retry_cnt = 20
|
||||
while retry_cnt > 0:
|
||||
logger.warning(
|
||||
f"上下文长度超过限制。尝试弹出最早的记录然后重试。当前记录条数: {len(context_query)}"
|
||||
return (
|
||||
False,
|
||||
chosen_key,
|
||||
available_api_keys,
|
||||
payloads,
|
||||
context_query,
|
||||
func_tool,
|
||||
)
|
||||
elif (
|
||||
"Function calling is not enabled" in str(e)
|
||||
or ("tool" in str(e).lower() and "support" in str(e).lower())
|
||||
or ("function" in str(e).lower() and "support" in str(e).lower())
|
||||
):
|
||||
# openai, ollama, gemini openai, siliconcloud 的错误提示与 code 不统一,只能通过字符串匹配
|
||||
logger.info(
|
||||
f"{self.get_model()} 不支持函数工具调用,已自动去除,不影响使用。"
|
||||
)
|
||||
if "tools" in payloads:
|
||||
del payloads["tools"]
|
||||
return False, chosen_key, available_api_keys, payloads, context_query, None
|
||||
else:
|
||||
logger.error(f"发生了错误。Provider 配置如下: {self.provider_config}")
|
||||
|
||||
if "tool" in str(e).lower() and "support" in str(e).lower():
|
||||
logger.error("疑似该模型不支持函数调用工具调用。请输入 /tool off_all")
|
||||
|
||||
if "Connection error." in str(e):
|
||||
proxy = os.environ.get("http_proxy", None)
|
||||
if proxy:
|
||||
logger.error(
|
||||
f"可能为代理原因,请检查代理是否正常。当前代理: {proxy}"
|
||||
)
|
||||
try:
|
||||
await self.pop_record(context_query)
|
||||
llm_response = await self._query(payloads, func_tool)
|
||||
break
|
||||
except Exception as e:
|
||||
if "maximum context length" in str(e):
|
||||
retry_cnt -= 1
|
||||
else:
|
||||
raise e
|
||||
if retry_cnt == 0:
|
||||
llm_response = LLMResponse(
|
||||
"err", "err: 请尝试 /reset 清除会话记录。"
|
||||
)
|
||||
elif "The model is not a VLM" in str(e): # siliconcloud
|
||||
|
||||
raise e
|
||||
|
||||
async def text_chat(
|
||||
self,
|
||||
prompt: str,
|
||||
session_id: str = None,
|
||||
image_urls: List[str] = [],
|
||||
func_tool: FuncCall = None,
|
||||
contexts=[],
|
||||
system_prompt=None,
|
||||
tool_calls_result=None,
|
||||
**kwargs,
|
||||
) -> LLMResponse:
|
||||
payloads, context_query, func_tool = await self._prepare_chat_payload(
|
||||
prompt,
|
||||
session_id,
|
||||
image_urls,
|
||||
func_tool,
|
||||
contexts,
|
||||
system_prompt,
|
||||
tool_calls_result,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
llm_response = None
|
||||
max_retries = 10
|
||||
available_api_keys = self.api_keys.copy()
|
||||
chosen_key = random.choice(available_api_keys)
|
||||
|
||||
e = None
|
||||
retry_cnt = 0
|
||||
for retry_cnt in range(max_retries):
|
||||
try:
|
||||
self.client.api_key = chosen_key
|
||||
llm_response = await self._query(payloads, func_tool)
|
||||
break
|
||||
except UnprocessableEntityError as e:
|
||||
logger.warning(f"不可处理的实体错误:{e},尝试删除图片。")
|
||||
# 尝试删除所有 image
|
||||
new_contexts = await self._remove_image_from_context(context_query)
|
||||
payloads["messages"] = new_contexts
|
||||
llm_response = await self._query(payloads, func_tool)
|
||||
|
||||
# openai, ollama, gemini openai, siliconcloud 的错误提示与 code 不统一,只能通过字符串匹配
|
||||
elif (
|
||||
"does not support Function Calling" in str(e)
|
||||
or "does not support tools" in str(e)
|
||||
or "Function call is not supported" in str(e)
|
||||
or "Function calling is not enabled" in str(e)
|
||||
or "Tool calling is not supported" in str(e)
|
||||
or "No endpoints found that support tool use" in str(e)
|
||||
or "model does not support function calling" in str(e)
|
||||
or ("tool" in str(e) and "support" in str(e).lower())
|
||||
or ("function" in str(e) and "support" in str(e).lower())
|
||||
):
|
||||
logger.info(
|
||||
f"{self.get_model()} 不支持函数工具调用,已自动去除,不影响使用。"
|
||||
context_query = new_contexts
|
||||
except Exception as e:
|
||||
(
|
||||
success,
|
||||
chosen_key,
|
||||
available_api_keys,
|
||||
payloads,
|
||||
context_query,
|
||||
func_tool,
|
||||
) = await self._handle_api_error(
|
||||
e,
|
||||
payloads,
|
||||
context_query,
|
||||
func_tool,
|
||||
chosen_key,
|
||||
available_api_keys,
|
||||
retry_cnt,
|
||||
max_retries,
|
||||
)
|
||||
if "tools" in payloads:
|
||||
del payloads["tools"]
|
||||
llm_response = await self._query(payloads, None)
|
||||
else:
|
||||
logger.error(f"发生了错误。Provider 配置如下: {self.provider_config}")
|
||||
|
||||
if "tool" in str(e).lower() and "support" in str(e).lower():
|
||||
logger.error(
|
||||
"疑似该模型不支持函数调用工具调用。请输入 /tool off_all"
|
||||
)
|
||||
|
||||
if "Connection error." in str(e):
|
||||
proxy = os.environ.get("http_proxy", None)
|
||||
if proxy:
|
||||
logger.error(
|
||||
f"可能为代理原因,请检查代理是否正常。当前代理: {proxy}"
|
||||
)
|
||||
|
||||
raise e
|
||||
if success:
|
||||
break
|
||||
|
||||
if retry_cnt == max_retries - 1:
|
||||
logger.error(f"API 调用失败,重试 {max_retries} 次仍然失败。")
|
||||
raise e
|
||||
return llm_response
|
||||
|
||||
async def text_chat_stream(
|
||||
self,
|
||||
prompt: str,
|
||||
session_id: str = None,
|
||||
image_urls: List[str] = [],
|
||||
func_tool: FuncCall = None,
|
||||
contexts=[],
|
||||
system_prompt=None,
|
||||
tool_calls_result=None,
|
||||
**kwargs,
|
||||
) -> AsyncGenerator[LLMResponse, None]:
|
||||
"""流式对话,与服务商交互并逐步返回结果"""
|
||||
payloads, context_query, func_tool = await self._prepare_chat_payload(
|
||||
prompt,
|
||||
session_id,
|
||||
image_urls,
|
||||
func_tool,
|
||||
contexts,
|
||||
system_prompt,
|
||||
tool_calls_result,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
max_retries = 10
|
||||
available_api_keys = self.api_keys.copy()
|
||||
chosen_key = random.choice(available_api_keys)
|
||||
|
||||
e = None
|
||||
retry_cnt = 0
|
||||
for retry_cnt in range(max_retries):
|
||||
try:
|
||||
self.client.api_key = chosen_key
|
||||
async for response in self._query_stream(payloads, func_tool):
|
||||
yield response
|
||||
break
|
||||
except UnprocessableEntityError as e:
|
||||
logger.warning(f"不可处理的实体错误:{e},尝试删除图片。")
|
||||
# 尝试删除所有 image
|
||||
new_contexts = await self._remove_image_from_context(context_query)
|
||||
payloads["messages"] = new_contexts
|
||||
context_query = new_contexts
|
||||
except Exception as e:
|
||||
(
|
||||
success,
|
||||
chosen_key,
|
||||
available_api_keys,
|
||||
payloads,
|
||||
context_query,
|
||||
func_tool,
|
||||
) = await self._handle_api_error(
|
||||
e,
|
||||
payloads,
|
||||
context_query,
|
||||
func_tool,
|
||||
chosen_key,
|
||||
available_api_keys,
|
||||
retry_cnt,
|
||||
max_retries,
|
||||
)
|
||||
if success:
|
||||
break
|
||||
|
||||
if retry_cnt == max_retries - 1:
|
||||
logger.error(f"API 调用失败,重试 {max_retries} 次仍然失败。")
|
||||
raise e
|
||||
|
||||
async def _remove_image_from_context(self, contexts: List):
|
||||
"""
|
||||
从上下文中删除所有带有 image 的记录
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import uuid
|
||||
from openai import AsyncOpenAI, NOT_GIVEN
|
||||
from ..provider import TTSProvider
|
||||
from ..entites import ProviderType
|
||||
from ..entities import ProviderType
|
||||
from ..register import register_provider_adapter
|
||||
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ import re
|
||||
from funasr_onnx import SenseVoiceSmall
|
||||
from funasr_onnx.utils.postprocess_utils import rich_transcription_postprocess
|
||||
from ..provider import STTProvider
|
||||
from ..entites import ProviderType
|
||||
from ..entities import ProviderType
|
||||
from astrbot.core.utils.io import download_file
|
||||
from ..register import register_provider_adapter
|
||||
from astrbot.core import logger
|
||||
@@ -48,14 +48,6 @@ class ProviderSenseVoiceSTTSelfHost(STTProvider):
|
||||
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
return os.path.join("data", "temp", f"{timestamp}")
|
||||
|
||||
async def _convert_audio(self, path: str) -> str:
|
||||
from pyffmpeg import FFmpeg
|
||||
|
||||
filename = await self.get_timestamped_path() + ".mp3"
|
||||
ff = FFmpeg()
|
||||
output_path = ff.convert(path, os.path.join('data","temp', filename))
|
||||
return output_path
|
||||
|
||||
async def _is_silk_file(self, file_path):
|
||||
silk_header = b"SILK"
|
||||
with open(file_path, "rb") as f:
|
||||
|
||||
@@ -2,7 +2,7 @@ import uuid
|
||||
import os
|
||||
from openai import AsyncOpenAI, NOT_GIVEN
|
||||
from ..provider import STTProvider
|
||||
from ..entites import ProviderType
|
||||
from ..entities import ProviderType
|
||||
from astrbot.core.utils.io import download_file
|
||||
from ..register import register_provider_adapter
|
||||
from astrbot.core import logger
|
||||
@@ -31,14 +31,6 @@ class ProviderOpenAIWhisperAPI(STTProvider):
|
||||
|
||||
self.set_model(provider_config.get("model", None))
|
||||
|
||||
async def _convert_audio(self, path: str) -> str:
|
||||
from pyffmpeg import FFmpeg
|
||||
|
||||
filename = str(uuid.uuid4()) + ".mp3"
|
||||
ff = FFmpeg()
|
||||
output_path = ff.convert(path, os.path.join("data/temp", filename))
|
||||
return output_path
|
||||
|
||||
async def _is_silk_file(self, file_path):
|
||||
silk_header = b"SILK"
|
||||
with open(file_path, "rb") as f:
|
||||
|
||||
@@ -3,7 +3,7 @@ import os
|
||||
import asyncio
|
||||
import whisper
|
||||
from ..provider import STTProvider
|
||||
from ..entites import ProviderType
|
||||
from ..entities import ProviderType
|
||||
from astrbot.core.utils.io import download_file
|
||||
from ..register import register_provider_adapter
|
||||
from astrbot.core import logger
|
||||
@@ -33,14 +33,6 @@ class ProviderOpenAIWhisperSelfHost(STTProvider):
|
||||
)
|
||||
logger.info("Whisper 模型加载完成。")
|
||||
|
||||
async def _convert_audio(self, path: str) -> str:
|
||||
from pyffmpeg import FFmpeg
|
||||
|
||||
filename = str(uuid.uuid4()) + ".mp3"
|
||||
ff = FFmpeg()
|
||||
output_path = ff.convert(path, os.path.join("data/temp", filename))
|
||||
return output_path
|
||||
|
||||
async def _is_silk_file(self, file_path):
|
||||
silk_header = b"SILK"
|
||||
with open(file_path, "rb") as f:
|
||||
|
||||
@@ -3,7 +3,7 @@ from astrbot import logger
|
||||
from astrbot.core.provider.func_tool_manager import FuncCall
|
||||
from typing import List
|
||||
from ..register import register_provider_adapter
|
||||
from astrbot.core.provider.entites import LLMResponse
|
||||
from astrbot.core.provider.entities import LLMResponse
|
||||
from .openai_source import ProviderOpenAIOfficial
|
||||
|
||||
|
||||
|
||||
@@ -4,12 +4,14 @@ from .context import Context
|
||||
from astrbot.core.provider import Provider
|
||||
from astrbot.core.utils.command_parser import CommandParserMixin
|
||||
from astrbot.core import html_renderer
|
||||
from astrbot.core.star.star_tools import StarTools
|
||||
|
||||
|
||||
class Star(CommandParserMixin):
|
||||
"""所有插件(Star)的父类,所有插件都应该继承于这个类"""
|
||||
|
||||
def __init__(self, context: Context):
|
||||
StarTools.initialize(context)
|
||||
self.context = context
|
||||
|
||||
async def text_to_image(self, text: str, return_url=True) -> str:
|
||||
@@ -27,4 +29,4 @@ class Star(CommandParserMixin):
|
||||
pass
|
||||
|
||||
|
||||
__all__ = ["Star", "StarMetadata", "PluginManager", "Context", "Provider"]
|
||||
__all__ = ["Star", "StarMetadata", "PluginManager", "Context", "Provider", "StarTools"]
|
||||
|
||||
Regular → Executable
Regular → Executable
@@ -47,5 +47,29 @@ class StarMetadata:
|
||||
star_handler_full_names: List[str] = field(default_factory=list)
|
||||
"""注册的 Handler 的全名列表"""
|
||||
|
||||
supported_platforms: Dict[str, bool] = field(default_factory=dict)
|
||||
"""插件支持的平台ID字典,key为平台ID,value为是否支持"""
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"StarMetadata({self.name}, {self.desc}, {self.version}, {self.repo})"
|
||||
|
||||
def update_platform_compatibility(self, plugin_enable_config: dict) -> None:
|
||||
"""更新插件支持的平台列表
|
||||
|
||||
Args:
|
||||
plugin_enable_config: 平台插件启用配置,即platform_settings.plugin_enable配置项
|
||||
"""
|
||||
if not plugin_enable_config:
|
||||
return
|
||||
|
||||
# 清空之前的配置
|
||||
self.supported_platforms.clear()
|
||||
|
||||
# 遍历所有平台配置
|
||||
for platform_id, plugins in plugin_enable_config.items():
|
||||
# 检查该插件在当前平台的配置
|
||||
if self.name in plugins:
|
||||
self.supported_platforms[platform_id] = plugins[self.name]
|
||||
else:
|
||||
# 如果没有明确配置,默认为启用
|
||||
self.supported_platforms[platform_id] = True
|
||||
|
||||
@@ -30,21 +30,36 @@ class StarHandlerRegistry(Generic[T]):
|
||||
print(handler.handler_full_name)
|
||||
|
||||
def get_handlers_by_event_type(
|
||||
self, event_type: EventType, only_activated=True
|
||||
self, event_type: EventType, only_activated=True, platform_id=None
|
||||
) -> List[StarHandlerMetadata]:
|
||||
"""通过事件类型获取 Handler"""
|
||||
handlers = [
|
||||
handler
|
||||
for _, handler in self._handlers
|
||||
if handler.event_type == event_type
|
||||
and (
|
||||
not only_activated
|
||||
or (
|
||||
star_map[handler.handler_module_path]
|
||||
and star_map[handler.handler_module_path].activated
|
||||
)
|
||||
)
|
||||
]
|
||||
"""通过事件类型获取 Handler
|
||||
|
||||
Args:
|
||||
event_type: 事件类型
|
||||
only_activated: 是否只返回已激活的插件的处理器
|
||||
platform_id: 平台ID,如果提供此参数,将过滤掉在此平台不兼容的处理器
|
||||
|
||||
Returns:
|
||||
List[StarHandlerMetadata]: 处理器列表
|
||||
"""
|
||||
handlers = []
|
||||
for _, handler in self._handlers:
|
||||
if handler.event_type != event_type:
|
||||
continue
|
||||
|
||||
# 只激活的插件处理器
|
||||
if only_activated:
|
||||
plugin = star_map.get(handler.handler_module_path)
|
||||
if not (plugin and plugin.activated):
|
||||
continue
|
||||
|
||||
# 平台兼容性过滤
|
||||
if platform_id and event_type != EventType.OnAstrBotLoadedEvent:
|
||||
if not handler.is_enabled_for_platform(platform_id):
|
||||
continue
|
||||
|
||||
handlers.append(handler)
|
||||
|
||||
return handlers
|
||||
|
||||
def get_handler_by_full_name(self, full_name: str) -> StarHandlerMetadata:
|
||||
@@ -139,3 +154,32 @@ class StarHandlerMetadata:
|
||||
return self.extras_configs.get("priority", 0) < other.extras_configs.get(
|
||||
"priority", 0
|
||||
)
|
||||
|
||||
def is_enabled_for_platform(self, platform_id: str) -> bool:
|
||||
"""检查插件是否在指定平台启用
|
||||
|
||||
Args:
|
||||
platform_id: 平台ID,这是从event.get_platform_id()获取的,用于唯一标识平台实例
|
||||
|
||||
Returns:
|
||||
bool: 是否启用,True表示启用,False表示禁用
|
||||
"""
|
||||
plugin = star_map.get(self.handler_module_path)
|
||||
|
||||
# 如果插件元数据不存在,默认允许执行
|
||||
if not plugin or not plugin.name:
|
||||
return True
|
||||
|
||||
# 先检查插件是否被激活
|
||||
if not plugin.activated:
|
||||
return False
|
||||
|
||||
# 直接使用StarMetadata中缓存的supported_platforms判断平台兼容性
|
||||
if (
|
||||
hasattr(plugin, "supported_platforms")
|
||||
and platform_id in plugin.supported_platforms
|
||||
):
|
||||
return plugin.supported_platforms[platform_id]
|
||||
|
||||
# 如果没有缓存数据,默认允许执行
|
||||
return True
|
||||
|
||||
@@ -166,8 +166,71 @@ class PluginManager:
|
||||
|
||||
return metadata
|
||||
|
||||
def _get_plugin_related_modules(
|
||||
self, plugin_root_dir: str, is_reserved: bool = False
|
||||
) -> list[str]:
|
||||
"""获取与指定插件相关的所有已加载模块名
|
||||
|
||||
根据插件根目录名和是否为保留插件,从 sys.modules 中筛选出相关的模块名
|
||||
|
||||
Args:
|
||||
plugin_root_dir: 插件根目录名
|
||||
is_reserved: 是否是保留插件,影响模块路径前缀
|
||||
|
||||
Returns:
|
||||
list[str]: 与该插件相关的模块名列表
|
||||
"""
|
||||
prefix = "packages." if is_reserved else "data.plugins."
|
||||
return [
|
||||
key
|
||||
for key in list(sys.modules.keys())
|
||||
if key.startswith(f"{prefix}{plugin_root_dir}")
|
||||
]
|
||||
|
||||
def _purge_modules(
|
||||
self,
|
||||
module_patterns: list[str] = None,
|
||||
root_dir_name: str = None,
|
||||
is_reserved: bool = False,
|
||||
):
|
||||
"""从 sys.modules 中移除指定的模块
|
||||
|
||||
可以基于模块名模式或插件目录名移除模块,用于清理插件相关的模块缓存
|
||||
|
||||
Args:
|
||||
module_patterns: 要移除的模块名模式列表(例如 ["data.plugins", "packages"])
|
||||
root_dir_name: 插件根目录名,用于移除与该插件相关的所有模块
|
||||
is_reserved: 插件是否为保留插件(影响模块路径前缀)
|
||||
"""
|
||||
if module_patterns:
|
||||
for pattern in module_patterns:
|
||||
for key in list(sys.modules.keys()):
|
||||
if key.startswith(pattern):
|
||||
del sys.modules[key]
|
||||
logger.debug(f"删除模块 {key}")
|
||||
|
||||
if root_dir_name:
|
||||
for module_name in self._get_plugin_related_modules(
|
||||
root_dir_name, is_reserved
|
||||
):
|
||||
try:
|
||||
del sys.modules[module_name]
|
||||
logger.debug(f"删除模块 {module_name}")
|
||||
except KeyError:
|
||||
logger.warning(f"模块 {module_name} 未载入")
|
||||
|
||||
async def reload(self, specified_plugin_name=None):
|
||||
"""扫描并加载所有的插件 当 specified_module_path 指定时,重载指定插件"""
|
||||
"""重新加载插件
|
||||
|
||||
Args:
|
||||
specified_plugin_name (str, optional): 要重载的特定插件名称。
|
||||
如果为 None,则重载所有插件。
|
||||
|
||||
Returns:
|
||||
tuple: 返回 load() 方法的结果,包含 (success, error_message)
|
||||
- success (bool): 重载是否成功
|
||||
- error_message (str|None): 错误信息,成功时为 None
|
||||
"""
|
||||
specified_module_path = None
|
||||
if specified_plugin_name:
|
||||
for smd in star_registry:
|
||||
@@ -192,9 +255,6 @@ class PluginManager:
|
||||
star_handlers_registry.clear()
|
||||
star_map.clear()
|
||||
star_registry.clear()
|
||||
for key in list(sys.modules.keys()):
|
||||
if key.startswith("data.plugins") or key.startswith("packages"):
|
||||
del sys.modules[key]
|
||||
else:
|
||||
# 只重载指定插件
|
||||
smd = star_map.get(specified_module_path)
|
||||
@@ -209,11 +269,44 @@ class PluginManager:
|
||||
|
||||
await self._unbind_plugin(smd.name, specified_module_path)
|
||||
|
||||
return await self.load(specified_module_path)
|
||||
result = await self.load(specified_module_path)
|
||||
|
||||
# 更新所有插件的平台兼容性
|
||||
await self.update_all_platform_compatibility()
|
||||
|
||||
return result
|
||||
|
||||
async def update_all_platform_compatibility(self):
|
||||
"""更新所有插件的平台兼容性设置"""
|
||||
# 获取最新的平台插件启用配置
|
||||
plugin_enable_config = self.config.get("platform_settings", {}).get(
|
||||
"plugin_enable", {}
|
||||
)
|
||||
logger.debug(
|
||||
f"更新所有插件的平台兼容性设置,平台数量: {len(plugin_enable_config)}"
|
||||
)
|
||||
|
||||
# 遍历所有插件,更新平台兼容性
|
||||
for plugin in self.context.get_all_stars():
|
||||
plugin.update_platform_compatibility(plugin_enable_config)
|
||||
logger.debug(
|
||||
f"插件 {plugin.name} 支持的平台: {list(plugin.supported_platforms.keys())}"
|
||||
)
|
||||
|
||||
return True
|
||||
|
||||
async def load(self, specified_module_path=None, specified_dir_name=None):
|
||||
"""载入插件。
|
||||
当 specified_module_path 或者 specified_dir_name 不为 None 时,只载入指定的插件。
|
||||
|
||||
Args:
|
||||
specified_module_path (str, optional): 指定要加载的插件模块路径。例如: "data.plugins.my_plugin.main"
|
||||
specified_dir_name (str, optional): 指定要加载的插件目录名。例如: "my_plugin"
|
||||
|
||||
Returns:
|
||||
tuple: (success, error_message)
|
||||
- success (bool): 是否全部加载成功
|
||||
- error_message (str|None): 错误信息,成功时为 None
|
||||
"""
|
||||
inactivated_plugins: list = sp.get("inactivated_plugins", [])
|
||||
inactivated_llm_tools: list = sp.get("inactivated_llm_tools", [])
|
||||
@@ -320,6 +413,12 @@ class PluginManager:
|
||||
metadata.root_dir_name = root_dir_name
|
||||
metadata.reserved = reserved
|
||||
|
||||
# 更新插件的平台兼容性
|
||||
plugin_enable_config = self.config.get("platform_settings", {}).get(
|
||||
"plugin_enable", {}
|
||||
)
|
||||
metadata.update_platform_compatibility(plugin_enable_config)
|
||||
|
||||
# 绑定 handler
|
||||
related_handlers = (
|
||||
star_handlers_registry.get_handlers_by_module_name(
|
||||
@@ -447,13 +546,62 @@ class PluginManager:
|
||||
return False, fail_rec
|
||||
|
||||
async def install_plugin(self, repo_url: str, proxy=""):
|
||||
"""从仓库 URL 安装插件
|
||||
|
||||
从指定的仓库 URL 下载并安装插件,然后加载该插件到系统中
|
||||
|
||||
Args:
|
||||
repo_url (str): 要安装的插件仓库 URL
|
||||
proxy (str, optional): 用于下载的代理服务器。默认为空字符串。
|
||||
|
||||
Returns:
|
||||
dict | None: 安装成功时返回包含插件信息的字典:
|
||||
- repo: 插件的仓库 URL
|
||||
- readme: README.md 文件的内容(如果存在)
|
||||
如果找不到插件元数据则返回 None。
|
||||
"""
|
||||
plugin_path = await self.updator.install(repo_url, proxy)
|
||||
# reload the plugin
|
||||
dir_name = os.path.basename(plugin_path)
|
||||
await self.load(specified_dir_name=dir_name)
|
||||
return plugin_path
|
||||
|
||||
# Get the plugin metadata to return repo info
|
||||
plugin = self.context.get_registered_star(dir_name)
|
||||
if not plugin:
|
||||
# Try to find by other name if directory name doesn't match plugin name
|
||||
for star in self.context.get_all_stars():
|
||||
if star.root_dir_name == dir_name:
|
||||
plugin = star
|
||||
break
|
||||
|
||||
# Extract README.md content if exists
|
||||
readme_content = None
|
||||
readme_path = os.path.join(plugin_path, "README.md")
|
||||
if not os.path.exists(readme_path):
|
||||
readme_path = os.path.join(plugin_path, "readme.md")
|
||||
|
||||
if os.path.exists(readme_path):
|
||||
try:
|
||||
with open(readme_path, "r", encoding="utf-8") as f:
|
||||
readme_content = f.read()
|
||||
except Exception as e:
|
||||
logger.warning(f"读取插件 {dir_name} 的 README.md 文件失败: {str(e)}")
|
||||
|
||||
plugin_info = None
|
||||
if plugin:
|
||||
plugin_info = {"repo": plugin.repo, "readme": readme_content}
|
||||
|
||||
return plugin_info
|
||||
|
||||
async def uninstall_plugin(self, plugin_name: str):
|
||||
"""卸载指定的插件。
|
||||
|
||||
Args:
|
||||
plugin_name (str): 要卸载的插件名称
|
||||
|
||||
Raises:
|
||||
Exception: 当插件不存在、是保留插件时,或删除插件文件夹失败时抛出异常
|
||||
"""
|
||||
plugin = self.context.get_registered_star(plugin_name)
|
||||
if not plugin:
|
||||
raise Exception("插件不存在。")
|
||||
@@ -482,9 +630,17 @@ class PluginManager:
|
||||
)
|
||||
|
||||
async def _unbind_plugin(self, plugin_name: str, plugin_module_path: str):
|
||||
"""解绑并移除一个插件。
|
||||
|
||||
Args:
|
||||
plugin_name: 要解绑的插件名称
|
||||
plugin_module_path: 插件的完整模块路径
|
||||
"""
|
||||
plugin = None
|
||||
del star_map[plugin_module_path]
|
||||
for i, p in enumerate(star_registry):
|
||||
if p.name == plugin_name:
|
||||
plugin = p
|
||||
del star_registry[i]
|
||||
break
|
||||
for handler in star_handlers_registry.get_handlers_by_module_name(
|
||||
@@ -494,21 +650,17 @@ class PluginManager:
|
||||
f"移除了插件 {plugin_name} 的处理函数 {handler.handler_name} ({len(star_handlers_registry)})"
|
||||
)
|
||||
star_handlers_registry.remove(handler)
|
||||
keys_to_delete = [
|
||||
k
|
||||
for k, v in star_handlers_registry.star_handlers_map.items()
|
||||
if k.startswith(plugin_module_path)
|
||||
]
|
||||
for k in keys_to_delete:
|
||||
try:
|
||||
del star_handlers_registry.star_handlers_map[k]
|
||||
except KeyError:
|
||||
pass
|
||||
|
||||
try:
|
||||
del sys.modules[plugin_module_path]
|
||||
except KeyError:
|
||||
logger.warning(f"模块 {plugin_module_path} 未载入")
|
||||
for k in [
|
||||
k
|
||||
for k in star_handlers_registry.star_handlers_map
|
||||
if k.startswith(plugin_module_path)
|
||||
]:
|
||||
del star_handlers_registry.star_handlers_map[k]
|
||||
|
||||
self._purge_modules(
|
||||
root_dir_name=plugin.root_dir_name, is_reserved=plugin.reserved
|
||||
)
|
||||
|
||||
async def update_plugin(self, plugin_name: str, proxy=""):
|
||||
"""升级一个插件"""
|
||||
@@ -558,7 +710,7 @@ class PluginManager:
|
||||
|
||||
async def _terminate_plugin(self, star_metadata: StarMetadata):
|
||||
"""终止插件,调用插件的 terminate() 和 __del__() 方法"""
|
||||
logging.info(f"正在终止插件 {star_metadata.name} ...")
|
||||
logger.info(f"正在终止插件 {star_metadata.name} ...")
|
||||
|
||||
if not star_metadata.activated:
|
||||
# 说明之前已经被禁用了
|
||||
@@ -569,7 +721,7 @@ class PluginManager:
|
||||
asyncio.get_event_loop().run_in_executor(
|
||||
None, star_metadata.star_cls.__del__
|
||||
)
|
||||
else:
|
||||
elif hasattr(star_metadata.star_cls, "terminate"):
|
||||
await star_metadata.star_cls.terminate()
|
||||
|
||||
async def turn_on_plugin(self, plugin_name: str):
|
||||
@@ -607,3 +759,31 @@ class PluginManager:
|
||||
logger.warning(f"删除插件压缩包失败: {str(e)}")
|
||||
# await self.reload()
|
||||
await self.load(specified_dir_name=dir_name)
|
||||
|
||||
# Get the plugin metadata to return repo info
|
||||
plugin = self.context.get_registered_star(dir_name)
|
||||
if not plugin:
|
||||
# Try to find by other name if directory name doesn't match plugin name
|
||||
for star in self.context.get_all_stars():
|
||||
if star.root_dir_name == dir_name:
|
||||
plugin = star
|
||||
break
|
||||
|
||||
# Extract README.md content if exists
|
||||
readme_content = None
|
||||
readme_path = os.path.join(desti_dir, "README.md")
|
||||
if not os.path.exists(readme_path):
|
||||
readme_path = os.path.join(desti_dir, "readme.md")
|
||||
|
||||
if os.path.exists(readme_path):
|
||||
try:
|
||||
with open(readme_path, "r", encoding="utf-8") as f:
|
||||
readme_content = f.read()
|
||||
except Exception as e:
|
||||
logger.warning(f"读取插件 {dir_name} 的 README.md 文件失败: {str(e)}")
|
||||
|
||||
plugin_info = None
|
||||
if plugin:
|
||||
plugin_info = {"repo": plugin.repo, "readme": readme_content}
|
||||
|
||||
return plugin_info
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
import inspect
|
||||
from typing import Union, Awaitable, List, Optional, ClassVar
|
||||
from astrbot.core.message.components import BaseMessageComponent
|
||||
from astrbot.core.message.message_event_result import MessageChain
|
||||
from astrbot.api.platform import MessageMember, AstrBotMessage
|
||||
from astrbot.core.platform.astr_message_event import MessageSesion
|
||||
from astrbot.core.star.context import Context
|
||||
from astrbot.core.star.star import star_map
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
class StarTools:
|
||||
"""
|
||||
提供给插件使用的便捷工具函数集合
|
||||
这些方法封装了一些常用操作,使插件开发更加简单便捷!
|
||||
"""
|
||||
|
||||
_context: ClassVar[Optional[Context]] = None
|
||||
|
||||
@classmethod
|
||||
def initialize(cls, context: Context) -> None:
|
||||
"""
|
||||
初始化StarTools,设置context引用
|
||||
|
||||
Args:
|
||||
context: 暴露给插件的上下文
|
||||
"""
|
||||
cls._context = context
|
||||
|
||||
@classmethod
|
||||
async def send_message(
|
||||
cls, session: Union[str, MessageSesion], message_chain: MessageChain
|
||||
) -> bool:
|
||||
"""
|
||||
根据session(unified_msg_origin)主动发送消息
|
||||
|
||||
Args:
|
||||
session: 消息会话。通过event.session或者event.unified_msg_origin获取
|
||||
message_chain: 消息链
|
||||
|
||||
Returns:
|
||||
bool: 是否找到匹配的平台
|
||||
|
||||
Raises:
|
||||
ValueError: 当session为字符串且解析失败时抛出
|
||||
|
||||
Note:
|
||||
qq_official(QQ官方API平台)不支持此方法
|
||||
"""
|
||||
return await cls._context.send_message(session, message_chain)
|
||||
|
||||
@classmethod
|
||||
async def create_message(
|
||||
cls,
|
||||
type: str,
|
||||
self_id: str,
|
||||
session_id: str,
|
||||
message_id: str,
|
||||
sender: MessageMember,
|
||||
message: List[BaseMessageComponent],
|
||||
message_str: str,
|
||||
raw_message: object,
|
||||
group_id: str = "",
|
||||
):
|
||||
"""
|
||||
创建一个AstrBot消息对象
|
||||
|
||||
Args:
|
||||
type (str): 消息类型
|
||||
self_id (str): 机器人自身ID
|
||||
session_id (str): 会话ID(通常为用户ID)(QQ号, 群号等)
|
||||
message_id (str): 消息ID
|
||||
sender (MessageMember): 发送者信息
|
||||
message (List[BaseMessageComponent]): 消息组件列表
|
||||
message_str (str): 消息字符串
|
||||
raw_message (object): 原始消息对象
|
||||
group_id (str, optional): 群组ID, 如果为私聊则为空. Defaults to "".
|
||||
|
||||
Returns:
|
||||
AstrBotMessage: 创建的消息对象
|
||||
"""
|
||||
abm = AstrBotMessage()
|
||||
abm.type = type
|
||||
abm.self_id = self_id
|
||||
abm.session_id = session_id
|
||||
abm.message_id = message_id
|
||||
abm.sender = sender
|
||||
abm.message = message
|
||||
abm.message_str = message_str
|
||||
abm.raw_message = raw_message
|
||||
abm.group_id = group_id
|
||||
return abm
|
||||
|
||||
# todo: 添加构造事件的方法
|
||||
# async def create_event(
|
||||
# self, platform: str, umo: str, sender_id: str, session_id: str
|
||||
# ):
|
||||
# platform = self._context.get_platform(platform)
|
||||
|
||||
# todo: 添加找到对应平台并提交对应事件的方法
|
||||
|
||||
@classmethod
|
||||
def activate_llm_tool(cls, name: str) -> bool:
|
||||
"""
|
||||
激活一个已经注册的函数调用工具
|
||||
注册的工具默认是激活状态
|
||||
|
||||
Args:
|
||||
name (str): 工具名称
|
||||
"""
|
||||
return cls._context.activate_llm_tool(name)
|
||||
|
||||
@classmethod
|
||||
def deactivate_llm_tool(cls, name: str) -> bool:
|
||||
"""
|
||||
停用一个已经注册的函数调用工具
|
||||
|
||||
Args:
|
||||
name (str): 工具名称
|
||||
"""
|
||||
return cls._context.deactivate_llm_tool(name)
|
||||
|
||||
@classmethod
|
||||
def register_llm_tool(
|
||||
cls, name: str, func_args: list, desc: str, func_obj: Awaitable
|
||||
) -> None:
|
||||
"""
|
||||
为函数调用(function-calling/tools-use)添加工具
|
||||
|
||||
Args:
|
||||
name (str): 工具名称
|
||||
func_args (list): 函数参数列表
|
||||
desc (str): 工具描述
|
||||
func_obj (Awaitable): 函数对象,必须是异步函数
|
||||
"""
|
||||
cls._context.register_llm_tool(name, func_args, desc, func_obj)
|
||||
|
||||
@classmethod
|
||||
def unregister_llm_tool(cls, name: str) -> None:
|
||||
"""
|
||||
删除一个函数调用工具
|
||||
如果再要启用,需要重新注册
|
||||
|
||||
Args:
|
||||
name (str): 工具名称
|
||||
"""
|
||||
cls._context.unregister_llm_tool(name)
|
||||
|
||||
@classmethod
|
||||
def get_data_dir(cls, plugin_name: Optional[str] = None) -> Path:
|
||||
"""
|
||||
返回插件数据目录的绝对路径。
|
||||
|
||||
此方法会在 data/plugin_data 目录下为插件创建一个专属的数据目录。如果未提供插件名称,
|
||||
会自动从调用栈中获取插件信息。
|
||||
|
||||
Args:
|
||||
plugin_name: 可选的插件名称。如果为None,将自动检测调用者的插件名称。
|
||||
|
||||
Returns:
|
||||
Path (Path): 插件数据目录的绝对路径,位于 data/plugin_data/{plugin_name}。
|
||||
|
||||
Raises:
|
||||
RuntimeError: 当出现以下情况时抛出:
|
||||
- 无法获取调用者模块信息
|
||||
- 无法获取模块的元数据信息
|
||||
- 创建目录失败(权限不足或其他IO错误)
|
||||
"""
|
||||
if not plugin_name:
|
||||
frame = inspect.currentframe().f_back
|
||||
module = inspect.getmodule(frame)
|
||||
|
||||
if not module:
|
||||
raise RuntimeError("无法获取调用者模块信息")
|
||||
|
||||
metadata = star_map.get(module.__name__, None)
|
||||
|
||||
if not metadata:
|
||||
raise RuntimeError(f"无法获取模块 {module.__name__} 的元数据信息")
|
||||
|
||||
plugin_name = metadata.name
|
||||
|
||||
data_dir = Path("data/plugin_data") / plugin_name
|
||||
|
||||
try:
|
||||
data_dir.mkdir(parents=True, exist_ok=True)
|
||||
except OSError as e:
|
||||
if isinstance(e, PermissionError):
|
||||
raise RuntimeError(f"无法创建目录 {data_dir}:权限不足") from e
|
||||
raise RuntimeError(f"无法创建目录 {data_dir}:{e!s}") from e
|
||||
|
||||
return data_dir.resolve()
|
||||
@@ -41,7 +41,7 @@ class PluginUpdator(RepoZipUpdator):
|
||||
plugin_path = os.path.join(self.plugin_store_path, plugin.root_dir_name)
|
||||
|
||||
logger.info(f"正在更新插件,路径: {plugin_path},仓库地址: {repo_url}")
|
||||
await self.download_from_repo_url(plugin_path, repo_url)
|
||||
await self.download_from_repo_url(plugin_path, repo_url, proxy=proxy)
|
||||
|
||||
try:
|
||||
remove_dir(plugin_path)
|
||||
|
||||
@@ -9,6 +9,11 @@ from astrbot.core.utils.io import download_file
|
||||
|
||||
|
||||
class AstrBotUpdator(RepoZipUpdator):
|
||||
"""AstrBot 更新器,继承自 RepoZipUpdator 类
|
||||
该类用于处理 AstrBot 的更新操作
|
||||
功能包括检查更新、下载更新文件、解压缩更新文件等
|
||||
"""
|
||||
|
||||
def __init__(self, repo_mirror: str = "") -> None:
|
||||
super().__init__(repo_mirror)
|
||||
self.MAIN_PATH = os.path.abspath(
|
||||
@@ -17,6 +22,9 @@ class AstrBotUpdator(RepoZipUpdator):
|
||||
self.ASTRBOT_RELEASE_API = "https://api.soulter.top/releases"
|
||||
|
||||
def terminate_child_processes(self):
|
||||
"""终止当前进程的所有子进程
|
||||
使用 psutil 库获取当前进程的所有子进程,并尝试终止它们
|
||||
"""
|
||||
try:
|
||||
parent = psutil.Process(os.getpid())
|
||||
children = parent.children(recursive=True)
|
||||
@@ -35,6 +43,9 @@ class AstrBotUpdator(RepoZipUpdator):
|
||||
pass
|
||||
|
||||
def _reboot(self, delay: int = 3):
|
||||
"""重启当前程序
|
||||
在指定的延迟后,终止所有子进程并重新启动程序
|
||||
"""
|
||||
py = sys.executable
|
||||
time.sleep(delay)
|
||||
self.terminate_child_processes()
|
||||
@@ -46,6 +57,7 @@ class AstrBotUpdator(RepoZipUpdator):
|
||||
raise e
|
||||
|
||||
async def check_update(self, url: str, current_version: str) -> ReleaseInfo:
|
||||
"""检查更新"""
|
||||
return await super().check_update(self.ASTRBOT_RELEASE_API, VERSION)
|
||||
|
||||
async def get_releases(self) -> list:
|
||||
|
||||
@@ -103,7 +103,7 @@ async def download_image_by_url(
|
||||
with open(path, "wb") as f:
|
||||
f.write(await resp.read())
|
||||
return path
|
||||
except aiohttp.client.ClientConnectorSSLError:
|
||||
except (aiohttp.ClientConnectorSSLError, aiohttp.ClientConnectorCertificateError):
|
||||
# 关闭SSL验证
|
||||
ssl_context = ssl.create_default_context()
|
||||
ssl_context.set_ciphers("DEFAULT")
|
||||
@@ -152,7 +152,7 @@ async def download_file(url: str, path: str, show_progress: bool = False):
|
||||
f"\r下载进度: {downloaded_size / total_size:.2%} 速度: {speed:.2f} KB/s",
|
||||
end="",
|
||||
)
|
||||
except aiohttp.client.ClientConnectorSSLError:
|
||||
except (aiohttp.ClientConnectorSSLError, aiohttp.ClientConnectorCertificateError):
|
||||
# 关闭SSL验证
|
||||
ssl_context = ssl.create_default_context()
|
||||
ssl_context.set_ciphers("DEFAULT")
|
||||
|
||||
@@ -15,7 +15,8 @@ class SharedPreferences:
|
||||
|
||||
def _save_preferences(self):
|
||||
with open(self.path, "w") as f:
|
||||
json.dump(self._data, f, indent=4)
|
||||
json.dump(self._data, f, indent=4, ensure_ascii=False)
|
||||
f.flush()
|
||||
|
||||
def get(self, key, default=None):
|
||||
return self._data.get(key, default)
|
||||
|
||||
@@ -105,16 +105,24 @@ class RepoZipUpdator:
|
||||
"""
|
||||
比较两个版本号的大小。
|
||||
返回 1 表示 v1 > v2,返回 -1 表示 v1 < v2,返回 0 表示 v1 = v2。
|
||||
支持任意长度的版本号,如v1.2.3或v3.5.3.1。
|
||||
"""
|
||||
v1 = v1.replace("v", "")
|
||||
v2 = v2.replace("v", "")
|
||||
v1 = v1.split(".")
|
||||
v2 = v2.split(".")
|
||||
v1_parts = v1.split(".")
|
||||
v2_parts = v2.split(".")
|
||||
|
||||
for i in range(3):
|
||||
if int(v1[i]) > int(v2[i]):
|
||||
# 获取最长的版本号长度
|
||||
length = max(len(v1_parts), len(v2_parts))
|
||||
|
||||
# 将短版本号补0以便比较
|
||||
v1_parts.extend(["0"] * (length - len(v1_parts)))
|
||||
v2_parts.extend(["0"] * (length - len(v2_parts)))
|
||||
|
||||
for i in range(length):
|
||||
if int(v1_parts[i]) > int(v2_parts[i]):
|
||||
return 1
|
||||
elif int(v1[i]) < int(v2[i]):
|
||||
elif int(v1_parts[i]) < int(v2_parts[i]):
|
||||
return -1
|
||||
return 0
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import jwt
|
||||
import datetime
|
||||
from .route import Route, Response, RouteContext
|
||||
from quart import request
|
||||
from astrbot.core import WEBUI_SK
|
||||
from astrbot.core import WEBUI_SK, DEMO_MODE
|
||||
from astrbot import logger
|
||||
|
||||
|
||||
@@ -40,6 +40,13 @@ class AuthRoute(Route):
|
||||
return Response().error("用户名或密码错误").__dict__
|
||||
|
||||
async def edit_account(self):
|
||||
if DEMO_MODE:
|
||||
return (
|
||||
Response()
|
||||
.error("You are not permitted to do this operation in demo mode")
|
||||
.__dict__
|
||||
)
|
||||
|
||||
password = self.config["dashboard"]["password"]
|
||||
post_data = await request.json
|
||||
|
||||
|
||||
@@ -161,42 +161,53 @@ class ChatRoute(Route):
|
||||
username = g.get("username", "guest")
|
||||
|
||||
if username in self.curr_chat_sse:
|
||||
return "[ERROR]\n"
|
||||
return Response().error("Already connected").__dict__
|
||||
|
||||
self.curr_chat_sse[username] = None
|
||||
|
||||
heartbeat = json.dumps({"type": "heartbeat", "data": "ping"})
|
||||
|
||||
async def stream():
|
||||
try:
|
||||
yield "[HB]\n"
|
||||
yield f"data: {heartbeat}\n\n" # 心跳包
|
||||
while True:
|
||||
try:
|
||||
result = await asyncio.wait_for(
|
||||
web_chat_back_queue.get(), timeout=10
|
||||
) # 设置超时时间为5秒
|
||||
except asyncio.TimeoutError:
|
||||
yield "[HB]\n" # 心跳包
|
||||
yield f"data: {heartbeat}\n\n" # 心跳包
|
||||
continue
|
||||
|
||||
if not result:
|
||||
continue
|
||||
result_text, cid = result
|
||||
|
||||
result_text = result["data"]
|
||||
type = result.get("type")
|
||||
cid = result.get("cid")
|
||||
streaming = result.get("streaming", False)
|
||||
if cid != self.curr_user_cid.get(username):
|
||||
# 丢弃
|
||||
continue
|
||||
yield result_text + "\n"
|
||||
yield f"data: {json.dumps(result, ensure_ascii=False)}\n\n"
|
||||
await asyncio.sleep(0.05)
|
||||
|
||||
conversation = self.db.get_conversation_by_user_id(username, cid)
|
||||
try:
|
||||
history = json.loads(conversation.history)
|
||||
except BaseException as e:
|
||||
print(e)
|
||||
history = []
|
||||
history.append({"type": "bot", "message": result_text})
|
||||
self.db.update_conversation(
|
||||
username, cid, history=json.dumps(history)
|
||||
)
|
||||
if streaming and type != "end":
|
||||
continue
|
||||
|
||||
await asyncio.sleep(0.5)
|
||||
if result_text:
|
||||
conversation = self.db.get_conversation_by_user_id(
|
||||
username, cid
|
||||
)
|
||||
try:
|
||||
history = json.loads(conversation.history)
|
||||
except BaseException as e:
|
||||
print(e)
|
||||
history = []
|
||||
history.append({"type": "bot", "message": result_text})
|
||||
self.db.update_conversation(
|
||||
username, cid, history=json.dumps(history)
|
||||
)
|
||||
except BaseException as _:
|
||||
logger.debug(f"用户 {username} 断开聊天长连接。")
|
||||
self.curr_chat_sse.pop(username)
|
||||
|
||||
@@ -12,8 +12,11 @@ from astrbot.core import logger
|
||||
|
||||
|
||||
def try_cast(value: str, type_: str):
|
||||
if type_ == "int" and value.isdigit():
|
||||
return int(value)
|
||||
if type_ == "int":
|
||||
try:
|
||||
return int(value)
|
||||
except (ValueError, TypeError):
|
||||
return None
|
||||
elif (
|
||||
type_ == "float"
|
||||
and isinstance(value, str)
|
||||
@@ -22,6 +25,11 @@ def try_cast(value: str, type_: str):
|
||||
return float(value)
|
||||
elif type_ == "float" and isinstance(value, int):
|
||||
return float(value)
|
||||
elif type_ == "float":
|
||||
try:
|
||||
return float(value)
|
||||
except (ValueError, TypeError):
|
||||
return None
|
||||
|
||||
|
||||
def validate_config(
|
||||
@@ -34,21 +42,31 @@ def validate_config(
|
||||
if key not in metadata:
|
||||
# 无 schema 的配置项,执行类型猜测
|
||||
if isinstance(value, str):
|
||||
if value.isdigit():
|
||||
try:
|
||||
data[key] = int(value)
|
||||
elif value.replace(".", "", 1).isdigit():
|
||||
continue
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
try:
|
||||
data[key] = float(value)
|
||||
elif value == "true":
|
||||
continue
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
if value.lower() == "true":
|
||||
data[key] = True
|
||||
elif value == "false":
|
||||
elif value.lower() == "false":
|
||||
data[key] = False
|
||||
continue
|
||||
meta = metadata[key]
|
||||
if "type" not in meta:
|
||||
logger.debug(f"配置项 {path}{key} 没有类型定义, 跳过校验")
|
||||
continue
|
||||
# null 转换
|
||||
if value is None:
|
||||
data[key] = DEFAULT_VALUE_MAP[meta["type"]]
|
||||
continue
|
||||
# 递归验证
|
||||
if meta["type"] == "list" and not isinstance(value, list):
|
||||
errors.append(
|
||||
f"错误的类型 {path}{key}: 期望是 list, 得到了 {type(value).__name__}"
|
||||
@@ -163,7 +181,7 @@ class ConfigRoute(Route):
|
||||
await self._save_astrbot_configs(post_configs)
|
||||
return Response().ok(None, "保存成功~ 机器人正在重载配置。").__dict__
|
||||
except Exception as e:
|
||||
logger.error(e)
|
||||
logger.error(traceback.format_exc())
|
||||
return Response().error(str(e)).__dict__
|
||||
|
||||
async def post_plugin_configs(self):
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import asyncio
|
||||
from quart import websocket
|
||||
import json
|
||||
from quart import make_response
|
||||
from astrbot.core import logger, LogBroker
|
||||
from .route import Route, RouteContext
|
||||
|
||||
@@ -8,21 +9,36 @@ class LogRoute(Route):
|
||||
def __init__(self, context: RouteContext, log_broker: LogBroker) -> None:
|
||||
super().__init__(context)
|
||||
self.log_broker = log_broker
|
||||
self.app.add_url_rule(
|
||||
"/api/live-log", view_func=self.log, methods=["GET"], websocket=True
|
||||
)
|
||||
self.app.add_url_rule("/api/live-log", view_func=self.log, methods=["GET"])
|
||||
|
||||
async def log(self):
|
||||
queue = None
|
||||
try:
|
||||
queue = self.log_broker.register()
|
||||
while True:
|
||||
message = await queue.get()
|
||||
await websocket.send(message)
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
except BaseException as e:
|
||||
logger.error(f"WebSocket 连接错误: {e}")
|
||||
finally:
|
||||
if queue:
|
||||
self.log_broker.unregister(queue)
|
||||
async def stream():
|
||||
queue = None
|
||||
try:
|
||||
queue = self.log_broker.register()
|
||||
while True:
|
||||
message = await queue.get()
|
||||
payload = {
|
||||
"type": "log",
|
||||
**message, # see astrbot/core/log.py
|
||||
}
|
||||
yield f"data: {json.dumps(payload, ensure_ascii=False)}\n\n"
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
except BaseException as e:
|
||||
logger.error(f"Log SSE 连接错误: {e}")
|
||||
finally:
|
||||
if queue:
|
||||
self.log_broker.unregister(queue)
|
||||
|
||||
response = await make_response(
|
||||
stream(),
|
||||
{
|
||||
"Content-Type": "text/event-stream",
|
||||
"Cache-Control": "no-cache",
|
||||
"Connection": "keep-alive",
|
||||
"Transfer-Encoding": "chunked",
|
||||
},
|
||||
)
|
||||
response.timeout = None
|
||||
return response
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import traceback
|
||||
import aiohttp
|
||||
import os
|
||||
|
||||
import ssl
|
||||
import certifi
|
||||
@@ -15,6 +16,7 @@ from astrbot.core.star.filter.command_group import CommandGroupFilter
|
||||
from astrbot.core.star.filter.permission import PermissionTypeFilter
|
||||
from astrbot.core.star.filter.regex import RegexFilter
|
||||
from astrbot.core.star.star_handler import EventType
|
||||
from astrbot.core import DEMO_MODE
|
||||
|
||||
|
||||
class PluginRoute(Route):
|
||||
@@ -35,6 +37,9 @@ class PluginRoute(Route):
|
||||
"/plugin/off": ("POST", self.off_plugin),
|
||||
"/plugin/on": ("POST", self.on_plugin),
|
||||
"/plugin/reload": ("POST", self.reload_plugins),
|
||||
"/plugin/readme": ("GET", self.get_plugin_readme),
|
||||
"/plugin/platform_enable/get": ("GET", self.get_plugin_platform_enable),
|
||||
"/plugin/platform_enable/set": ("POST", self.set_plugin_platform_enable),
|
||||
}
|
||||
self.core_lifecycle = core_lifecycle
|
||||
self.plugin_manager = plugin_manager
|
||||
@@ -50,6 +55,13 @@ class PluginRoute(Route):
|
||||
}
|
||||
|
||||
async def reload_plugins(self):
|
||||
if DEMO_MODE:
|
||||
return (
|
||||
Response()
|
||||
.error("You are not permitted to do this operation in demo mode")
|
||||
.__dict__
|
||||
)
|
||||
|
||||
data = await request.json
|
||||
plugin_name = data.get("name", None)
|
||||
try:
|
||||
@@ -187,6 +199,13 @@ class PluginRoute(Route):
|
||||
return handlers
|
||||
|
||||
async def install_plugin(self):
|
||||
if DEMO_MODE:
|
||||
return (
|
||||
Response()
|
||||
.error("You are not permitted to do this operation in demo mode")
|
||||
.__dict__
|
||||
)
|
||||
|
||||
post_data = await request.json
|
||||
repo_url = post_data["url"]
|
||||
|
||||
@@ -196,30 +215,44 @@ class PluginRoute(Route):
|
||||
|
||||
try:
|
||||
logger.info(f"正在安装插件 {repo_url}")
|
||||
await self.plugin_manager.install_plugin(repo_url, proxy)
|
||||
plugin_info = await self.plugin_manager.install_plugin(repo_url, proxy)
|
||||
# self.core_lifecycle.restart()
|
||||
logger.info(f"安装插件 {repo_url} 成功。")
|
||||
return Response().ok(None, "安装成功。").__dict__
|
||||
return Response().ok(plugin_info, "安装成功。").__dict__
|
||||
except Exception as e:
|
||||
logger.error(traceback.format_exc())
|
||||
return Response().error(str(e)).__dict__
|
||||
|
||||
async def install_plugin_upload(self):
|
||||
if DEMO_MODE:
|
||||
return (
|
||||
Response()
|
||||
.error("You are not permitted to do this operation in demo mode")
|
||||
.__dict__
|
||||
)
|
||||
|
||||
try:
|
||||
file = await request.files
|
||||
file = file["file"]
|
||||
logger.info(f"正在安装用户上传的插件 {file.filename}")
|
||||
file_path = f"data/temp/{file.filename}"
|
||||
await file.save(file_path)
|
||||
await self.plugin_manager.install_plugin_from_file(file_path)
|
||||
plugin_info = await self.plugin_manager.install_plugin_from_file(file_path)
|
||||
# self.core_lifecycle.restart()
|
||||
logger.info(f"安装插件 {file.filename} 成功")
|
||||
return Response().ok(None, "安装成功。").__dict__
|
||||
return Response().ok(plugin_info, "安装成功。").__dict__
|
||||
except Exception as e:
|
||||
logger.error(traceback.format_exc())
|
||||
return Response().error(str(e)).__dict__
|
||||
|
||||
async def uninstall_plugin(self):
|
||||
if DEMO_MODE:
|
||||
return (
|
||||
Response()
|
||||
.error("You are not permitted to do this operation in demo mode")
|
||||
.__dict__
|
||||
)
|
||||
|
||||
post_data = await request.json
|
||||
plugin_name = post_data["name"]
|
||||
try:
|
||||
@@ -232,6 +265,13 @@ class PluginRoute(Route):
|
||||
return Response().error(str(e)).__dict__
|
||||
|
||||
async def update_plugin(self):
|
||||
if DEMO_MODE:
|
||||
return (
|
||||
Response()
|
||||
.error("You are not permitted to do this operation in demo mode")
|
||||
.__dict__
|
||||
)
|
||||
|
||||
post_data = await request.json
|
||||
plugin_name = post_data["name"]
|
||||
proxy: str = post_data.get("proxy", None)
|
||||
@@ -247,6 +287,13 @@ class PluginRoute(Route):
|
||||
return Response().error(str(e)).__dict__
|
||||
|
||||
async def off_plugin(self):
|
||||
if DEMO_MODE:
|
||||
return (
|
||||
Response()
|
||||
.error("You are not permitted to do this operation in demo mode")
|
||||
.__dict__
|
||||
)
|
||||
|
||||
post_data = await request.json
|
||||
plugin_name = post_data["name"]
|
||||
try:
|
||||
@@ -258,6 +305,13 @@ class PluginRoute(Route):
|
||||
return Response().error(str(e)).__dict__
|
||||
|
||||
async def on_plugin(self):
|
||||
if DEMO_MODE:
|
||||
return (
|
||||
Response()
|
||||
.error("You are not permitted to do this operation in demo mode")
|
||||
.__dict__
|
||||
)
|
||||
|
||||
post_data = await request.json
|
||||
plugin_name = post_data["name"]
|
||||
try:
|
||||
@@ -267,3 +321,135 @@ class PluginRoute(Route):
|
||||
except Exception as e:
|
||||
logger.error(f"/api/plugin/on: {traceback.format_exc()}")
|
||||
return Response().error(str(e)).__dict__
|
||||
|
||||
async def get_plugin_readme(self):
|
||||
plugin_name = request.args.get("name")
|
||||
logger.debug(f"正在获取插件 {plugin_name} 的README文件内容")
|
||||
|
||||
if not plugin_name:
|
||||
logger.warning("插件名称为空")
|
||||
return Response().error("插件名称不能为空").__dict__
|
||||
|
||||
plugin_obj = None
|
||||
for plugin in self.plugin_manager.context.get_all_stars():
|
||||
if plugin.name == plugin_name:
|
||||
plugin_obj = plugin
|
||||
break
|
||||
|
||||
if not plugin_obj:
|
||||
logger.warning(f"插件 {plugin_name} 不存在")
|
||||
return Response().error(f"插件 {plugin_name} 不存在").__dict__
|
||||
|
||||
plugin_dir = os.path.join(
|
||||
self.plugin_manager.plugin_store_path, plugin_obj.root_dir_name
|
||||
)
|
||||
|
||||
if not os.path.isdir(plugin_dir):
|
||||
logger.warning(f"无法找到插件目录: {plugin_dir}")
|
||||
return Response().error(f"无法找到插件 {plugin_name} 的目录").__dict__
|
||||
|
||||
readme_path = os.path.join(plugin_dir, "README.md")
|
||||
|
||||
if not os.path.isfile(readme_path):
|
||||
logger.warning(f"插件 {plugin_name} 没有README文件")
|
||||
return Response().error(f"插件 {plugin_name} 没有README文件").__dict__
|
||||
|
||||
try:
|
||||
with open(readme_path, "r", encoding="utf-8") as f:
|
||||
readme_content = f.read()
|
||||
|
||||
return (
|
||||
Response()
|
||||
.ok({"content": readme_content}, "成功获取README内容")
|
||||
.__dict__
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"/api/plugin/readme: {traceback.format_exc()}")
|
||||
return Response().error(f"读取README文件失败: {str(e)}").__dict__
|
||||
|
||||
async def get_plugin_platform_enable(self):
|
||||
"""获取插件在各平台的可用性配置"""
|
||||
try:
|
||||
platform_enable = self.core_lifecycle.astrbot_config.get(
|
||||
"platform_settings", {}
|
||||
).get("plugin_enable", {})
|
||||
|
||||
# 获取所有可用平台
|
||||
platforms = []
|
||||
|
||||
for platform in self.core_lifecycle.astrbot_config.get("platform", []):
|
||||
platform_type = platform.get("type", "")
|
||||
platform_id = platform.get("id", "")
|
||||
|
||||
platforms.append(
|
||||
{
|
||||
"name": platform_id, # 使用type作为name,这是系统内部使用的平台名称
|
||||
"id": platform_id, # 保留id字段以便前端可以显示
|
||||
"type": platform_type,
|
||||
"display_name": f"{platform_type}({platform_id})",
|
||||
}
|
||||
)
|
||||
|
||||
adjusted_platform_enable = {}
|
||||
for platform_id, plugins in platform_enable.items():
|
||||
adjusted_platform_enable[platform_id] = plugins
|
||||
|
||||
# 获取所有插件,包括系统内部插件
|
||||
plugins = []
|
||||
for plugin in self.plugin_manager.context.get_all_stars():
|
||||
plugins.append(
|
||||
{
|
||||
"name": plugin.name,
|
||||
"desc": plugin.desc,
|
||||
"reserved": plugin.reserved, # 添加reserved标志
|
||||
}
|
||||
)
|
||||
|
||||
logger.debug(
|
||||
f"获取插件平台配置: 原始配置={platform_enable}, 调整后={adjusted_platform_enable}"
|
||||
)
|
||||
|
||||
return (
|
||||
Response()
|
||||
.ok(
|
||||
{
|
||||
"platforms": platforms,
|
||||
"plugins": plugins,
|
||||
"platform_enable": adjusted_platform_enable,
|
||||
}
|
||||
)
|
||||
.__dict__
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"/api/plugin/platform_enable/get: {traceback.format_exc()}")
|
||||
return Response().error(str(e)).__dict__
|
||||
|
||||
async def set_plugin_platform_enable(self):
|
||||
"""设置插件在各平台的可用性配置"""
|
||||
if DEMO_MODE:
|
||||
return (
|
||||
Response()
|
||||
.error("You are not permitted to do this operation in demo mode")
|
||||
.__dict__
|
||||
)
|
||||
|
||||
try:
|
||||
data = await request.json
|
||||
platform_enable = data.get("platform_enable", {})
|
||||
|
||||
# 更新配置
|
||||
config = self.core_lifecycle.astrbot_config
|
||||
platform_settings = config.get("platform_settings", {})
|
||||
platform_settings["plugin_enable"] = platform_enable
|
||||
config["platform_settings"] = platform_settings
|
||||
config.save_config()
|
||||
|
||||
# 更新插件的平台兼容性缓存
|
||||
await self.plugin_manager.update_all_platform_compatibility()
|
||||
|
||||
logger.info(f"插件平台可用性配置已更新: {platform_enable}")
|
||||
|
||||
return Response().ok(None, "插件平台可用性配置已更新").__dict__
|
||||
except Exception as e:
|
||||
logger.error(f"/api/plugin/platform_enable/set: {traceback.format_exc()}")
|
||||
return Response().error(str(e)).__dict__
|
||||
|
||||
@@ -8,6 +8,7 @@ from quart import request
|
||||
from astrbot.core.core_lifecycle import AstrBotCoreLifecycle
|
||||
from astrbot.core.db import BaseDatabase
|
||||
from astrbot.core.config import VERSION
|
||||
from astrbot.core import DEMO_MODE
|
||||
|
||||
|
||||
class StatRoute(Route):
|
||||
@@ -29,6 +30,13 @@ class StatRoute(Route):
|
||||
self.core_lifecycle = core_lifecycle
|
||||
|
||||
async def restart_core(self):
|
||||
if DEMO_MODE:
|
||||
return (
|
||||
Response()
|
||||
.error("You are not permitted to do this operation in demo mode")
|
||||
.__dict__
|
||||
)
|
||||
|
||||
await self.core_lifecycle.restart()
|
||||
return Response().ok().__dict__
|
||||
|
||||
|
||||
@@ -20,6 +20,8 @@ class StaticFileRoute(Route):
|
||||
"/providers",
|
||||
"/about",
|
||||
"/extension-marketplace",
|
||||
"/conversation",
|
||||
"/tool-use",
|
||||
]
|
||||
for i in index_:
|
||||
self.app.add_url_rule(i, view_func=self.index)
|
||||
|
||||
@@ -6,6 +6,7 @@ from astrbot.core.updator import AstrBotUpdator
|
||||
from astrbot.core import logger, pip_installer
|
||||
from astrbot.core.utils.io import download_dashboard, get_dashboard_version
|
||||
from astrbot.core.config.default import VERSION
|
||||
from astrbot.core import DEMO_MODE
|
||||
|
||||
|
||||
class UpdateRoute(Route):
|
||||
@@ -126,6 +127,13 @@ class UpdateRoute(Route):
|
||||
return Response().error(e.__str__()).__dict__
|
||||
|
||||
async def install_pip_package(self):
|
||||
if DEMO_MODE:
|
||||
return (
|
||||
Response()
|
||||
.error("You are not permitted to do this operation in demo mode")
|
||||
.__dict__
|
||||
)
|
||||
|
||||
data = await request.json
|
||||
package = data.get("package", "")
|
||||
if not package:
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
# What's Changed
|
||||
|
||||
> 📢 在升级前,请完整阅读本次更新日志。
|
||||
|
||||
## ✨ 新增的功能
|
||||
|
||||
1. 适配 `gemini-2.0-flash-exp-image-generation` 对图片模态的输入 [#1017](https://github.com/Soulter/AstrBot/issues/1017)
|
||||
2. 在 MessageChain 类中添加 at 和 at_all 方法,用于快速添加 At 消息 @left666
|
||||
3. Gewechat Client 增加获取通讯录列表接口
|
||||
4. 支持 /llm 指令快捷启停 LLM 功能 [#296](https://github.com/Soulter/AstrBot/issues/296)
|
||||
|
||||
## 🎈 功能性优化
|
||||
|
||||
1. Edge TTS 支持使用代理
|
||||
2. 在 Lifecycle 新增插件资源清理逻辑 @Raven95676
|
||||
3. Docker 镜像提供内置 FFmpeg [#979](https://github.com/Soulter/AstrBot/issues/979)
|
||||
4. 优化无对话情况下设置人格的反馈 @Raven95676
|
||||
5. 若禁用提供商,自动切换到另一个可用的提供商 @Raven95676
|
||||
6. openai_source 同步支持随机请求均衡,同时优化 LLM 请求逻辑的异常处理
|
||||
7. 保存 shared_preferences 时强制刷新文件缓冲区
|
||||
8. 优化空 At 回复 @advent259141
|
||||
|
||||
## 🐛 修复的 Bug
|
||||
|
||||
1. 插件更新时没有正确应用加速地址
|
||||
2. newgroup 指令名显示错误
|
||||
|
||||
## 🧩 新增的插件
|
||||
|
||||
待补充
|
||||
@@ -0,0 +1,31 @@
|
||||
# What's Changed
|
||||
|
||||
> 📢 在升级前,请完整阅读本次更新日志。
|
||||
|
||||
## ✨ 新增的功能
|
||||
|
||||
1. 安装完插件后自动弹出插件仓库 README 对话框 @zhx8702
|
||||
4. 支持阿里云百炼 TTS@Soulter
|
||||
5. 支持 Telegram MarkdownV2 渲染 @Soulter
|
||||
6. 支持 钉钉 Markdown 渲染 @Soulter
|
||||
6. 增加对 Gemini 系列模型的输入安全设置参数支持 @AliveGh0st
|
||||
7. 支持手动设置时区以应对容器、国外用户的时区问题 @anka-afk @Raven95676 @Soulter
|
||||
8. 插件市场显示帮助按钮 @Soulter
|
||||
|
||||
## 🎈 功能性优化
|
||||
|
||||
1. WebUI 的日志通信使用 SSE 替代 Websockets @Soulter
|
||||
2. 在发送消息之前统一检查消息内容是否为空, 不允许发送空消息, 以解决该消息内容不支持查看以及 Gemini 返回 `<empty content>` 问题 @anka-afk
|
||||
3. 更新 Dify 平台链接为官方域名 by @Captain-Slacker-OwO
|
||||
4. 人格 prompt 输入框支持调节高度 @Soulter
|
||||
|
||||
## 🐛 修复的 Bug
|
||||
|
||||
1. 将最多携带对话数量修改回 `-1` 时出现报错 #1074 @anka-afk
|
||||
2. 修复无法识别到函数调用异常的问题 by @Soulter
|
||||
3. 修复 aiocqhttp 适配器下空白 plain 导致的 `the object is not a proper segment chain` 报错问题 @Soulter
|
||||
4. 修复阿里百炼应用无法多轮会话的问题 @Soulter
|
||||
|
||||
## 🧩 新增的插件
|
||||
|
||||
待补充
|
||||
@@ -0,0 +1,35 @@
|
||||
# What's Changed
|
||||
|
||||
> 📢 在升级前,请完整阅读本次更新日志。
|
||||
> 此版本为针对 `v3.5.3` 的紧急修复版本
|
||||
|
||||
## ✨ 新增的功能
|
||||
|
||||
1. Telegram、Webchat、QQ官方机器人平台(私聊)支持流式输出(实验性)。@Soulter @Raven95676 @anka-afk
|
||||
2. 支持针对不同消息平台开启/关闭插件 @zhx8702 @Raven95676 @Soulter
|
||||
3. 插件市场支持显示 Star 个数、插件管理支持插件帮助对话框 @kterna
|
||||
4. 飞书平台支持主动消息发送 @Soulter
|
||||
5. Telegram 平台适配显示指令列表,支持自动补全 @Raven95676
|
||||
6. 新增配置项允许配置当超出最多携带对话数量时,一次性丢弃多少条旧消息 @Rail1bc
|
||||
7. StarTool 新增获取插件数据目录接口 @Raven95676
|
||||
|
||||
## 🎈 功能性优化
|
||||
|
||||
1. 优化 /his 指令对函数调用的显示 @anka-afk
|
||||
2. QQ 官方机器人支持对同一条消息多次回复 @kuangfeng
|
||||
|
||||
## 🐛 修复的 Bug
|
||||
|
||||
1. ‼️ 修复使用 gemini 时,函数数工具调用会重复调用已经在过去会话中调用过的工具 @Soulter
|
||||
2. 修复使用 Gemini 模型时出现 <empty_content> 的问题 @anka-afk
|
||||
4. 修复使用 OneAPI + Gemini(openai) 传递空参数函数工具时可能报错的问题 @Soulter
|
||||
5. 修复 permission 过滤算子的 raise_error 参数失效的问题 @Soulter
|
||||
6. 修复函数调用时可能出现 `messages with role 'tool' must be a response to a preceeding message with 'tool_calls'` 报错的问题 @anka-afk
|
||||
7. 修复 dify 下删除对话的报错问题 @Soulter
|
||||
8. 修复人格预设对话多次插入上下文的问题 @Rail1bc
|
||||
9. 修复了 event.get_sender_id() 返回值与函数注释不一致的问题 @zsbai
|
||||
|
||||
|
||||
## 🧩 新增的插件
|
||||
|
||||
待补充
|
||||
@@ -0,0 +1,34 @@
|
||||
# What's Changed
|
||||
|
||||
> 📢 在升级前,请完整阅读本次更新日志。
|
||||
|
||||
## ✨ 新增的功能
|
||||
|
||||
1. Telegram、Webchat、QQ官方机器人平台(私聊)支持流式输出(实验性)。@Soulter @Raven95676 @anka-afk
|
||||
2. 支持针对不同消息平台开启/关闭插件 @zhx8702 @Raven95676 @Soulter
|
||||
3. 插件市场支持显示 Star 个数、插件管理支持插件帮助对话框 @kterna
|
||||
4. 飞书平台支持主动消息发送 @Soulter
|
||||
5. Telegram 平台适配显示指令列表,支持自动补全 @Raven95676
|
||||
6. 新增配置项允许配置当超出最多携带对话数量时,一次性丢弃多少条旧消息 @Rail1bc
|
||||
7. StarTool 新增获取插件数据目录接口 @Raven95676
|
||||
|
||||
## 🎈 功能性优化
|
||||
|
||||
1. 优化 /his 指令对函数调用的显示 @anka-afk
|
||||
2. QQ 官方机器人支持对同一条消息多次回复 @kuangfeng
|
||||
|
||||
## 🐛 修复的 Bug
|
||||
|
||||
1. ‼️ 修复使用 gemini 时,函数数工具调用会重复调用已经在过去会话中调用过的工具 @Soulter
|
||||
2. 修复使用 Gemini 模型时出现 <empty_content> 的问题 @anka-afk
|
||||
4. 修复使用 OneAPI + Gemini(openai) 传递空参数函数工具时可能报错的问题 @Soulter
|
||||
5. 修复 permission 过滤算子的 raise_error 参数失效的问题 @Soulter
|
||||
6. 修复函数调用时可能出现 `messages with role 'tool' must be a response to a preceeding message with 'tool_calls'` 报错的问题 @anka-afk
|
||||
7. 修复 dify 下删除对话的报错问题 @Soulter
|
||||
8. 修复人格预设对话多次插入上下文的问题 @Rail1bc
|
||||
9. 修复了 event.get_sender_id() 返回值与函数注释不一致的问题 @zsbai
|
||||
|
||||
|
||||
## 🧩 新增的插件
|
||||
|
||||
待补充
|
||||
@@ -21,9 +21,10 @@
|
||||
"axios-mock-adapter": "^1.22.0",
|
||||
"chance": "1.1.11",
|
||||
"date-fns": "2.30.0",
|
||||
"highlight.js": "^11.11.1",
|
||||
"js-md5": "^0.8.3",
|
||||
"lodash": "4.17.21",
|
||||
"marked": "^15.0.6",
|
||||
"marked": "^15.0.7",
|
||||
"pinia": "2.1.6",
|
||||
"remixicon": "3.5.0",
|
||||
"vee-validate": "4.11.3",
|
||||
|
||||
@@ -94,7 +94,6 @@
|
||||
v-else-if="metadata[metadataKey].items[key]?.type === 'text' && !metadata[metadataKey].items[key]?.invisible"
|
||||
v-model="iterable[key]"
|
||||
variant="outlined"
|
||||
auto-grow
|
||||
rows="3"
|
||||
class="config-field"
|
||||
hide-details
|
||||
|
||||
@@ -3,9 +3,20 @@ import { useCommonStore } from '@/stores/common';
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div id="term"
|
||||
style="background-color: #1e1e1e; padding: 16px; border-radius: 8px; overflow-y:auto">
|
||||
<div>
|
||||
<!-- 添加筛选级别控件 -->
|
||||
<div class="filter-controls mb-2">
|
||||
<v-chip-group v-model="selectedLevels" column multiple>
|
||||
<v-chip v-for="level in logLevels" :key="level" :color="getLevelColor(level)" filter
|
||||
:text-color="level === 'DEBUG' || level === 'INFO' ? 'black' : 'white'">
|
||||
{{ level }}
|
||||
</v-chip>
|
||||
</v-chip-group>
|
||||
</div>
|
||||
|
||||
<div id="term" style="background-color: #1e1e1e; padding: 16px; border-radius: 8px; overflow-y:auto; height: 100%">
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
@@ -25,7 +36,16 @@ export default {
|
||||
'default': 'color: #FFFFFF;'
|
||||
},
|
||||
logCache: useCommonStore().getLogCache(),
|
||||
historyNum_: -1
|
||||
historyNum_: -1,
|
||||
logLevels: ['DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL'],
|
||||
selectedLevels: [0, 1, 2, 3, 4], // 默认选中所有级别
|
||||
levelColors: {
|
||||
'DEBUG': 'grey',
|
||||
'INFO': 'blue-lighten-3',
|
||||
'WARNING': 'amber',
|
||||
'ERROR': 'red',
|
||||
'CRITICAL': 'purple'
|
||||
}
|
||||
}
|
||||
},
|
||||
props: {
|
||||
@@ -37,27 +57,82 @@ export default {
|
||||
watch: {
|
||||
logCache: {
|
||||
handler(val) {
|
||||
this.printLog(val[this.logCache.length - 1])
|
||||
const lastLog = val[this.logCache.length - 1];
|
||||
if (lastLog && this.isLevelSelected(lastLog.level)) {
|
||||
this.printLog(lastLog.data);
|
||||
}
|
||||
},
|
||||
deep: true
|
||||
},
|
||||
selectedLevels: {
|
||||
handler() {
|
||||
this.refreshDisplay();
|
||||
},
|
||||
deep: true
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.historyNum_ = parseInt(this.historyNum)
|
||||
let i = 0
|
||||
for (let log of this.logCache) {
|
||||
if (this.historyNum_ != -1 && i >= this.logCache.length - this.historyNum_) {
|
||||
this.printLog(log)
|
||||
++i
|
||||
} else if (this.historyNum_ == -1) {
|
||||
this.printLog(log)
|
||||
}
|
||||
if (this.logCache.length === 0) {
|
||||
this.delayInit()
|
||||
} else {
|
||||
this.init()
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
getLevelColor(level) {
|
||||
return this.levelColors[level] || 'grey';
|
||||
},
|
||||
|
||||
isLevelSelected(level) {
|
||||
for (let i = 0; i < this.selectedLevels.length; ++i) {
|
||||
let level_ = this.logLevels[this.selectedLevels[i]]
|
||||
if (level_ === level) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
},
|
||||
|
||||
refreshDisplay() {
|
||||
// 清空现有的显示
|
||||
const termElement = document.getElementById('term');
|
||||
if (termElement) {
|
||||
termElement.innerHTML = '';
|
||||
}
|
||||
|
||||
// 重新显示符合筛选条件的日志
|
||||
this.init();
|
||||
},
|
||||
|
||||
delayInit() {
|
||||
if (this.logCache.length === 0) {
|
||||
setTimeout(() => {
|
||||
this.delayInit()
|
||||
}, 500)
|
||||
} else {
|
||||
this.init()
|
||||
}
|
||||
},
|
||||
|
||||
init() {
|
||||
this.historyNum_ = parseInt(this.historyNum)
|
||||
let i = 0
|
||||
for (let log of this.logCache) {
|
||||
if (this.isLevelSelected(log.level)) { // 只显示选中级别的日志
|
||||
if (this.historyNum_ != -1 && i >= this.logCache.length - this.historyNum_) {
|
||||
this.printLog(log.data)
|
||||
++i
|
||||
} else if (this.historyNum_ == -1) {
|
||||
this.printLog(log.data)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
toggleAutoScroll() {
|
||||
this.autoScroll = !this.autoScroll;
|
||||
},
|
||||
|
||||
printLog(log) {
|
||||
// append 一个 span 标签到 term,block 的方式
|
||||
let ele = document.getElementById('term')
|
||||
@@ -70,14 +145,38 @@ export default {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
span.style = style + 'display: block; font-size: 12px; font-family: Consolas, monospace; white-space: pre-wrap;'
|
||||
span.classList.add('fade-in')
|
||||
span.innerText = log
|
||||
span.innerText = `${log}`;
|
||||
ele.appendChild(span)
|
||||
if (this.autoScroll) {
|
||||
if (this.autoScroll ) {
|
||||
ele.scrollTop = ele.scrollHeight
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
</script>
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.filter-controls {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.fade-in {
|
||||
animation: fadeIn 0.3s;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -24,13 +24,10 @@ const emit = defineEmits([
|
||||
'install',
|
||||
'uninstall',
|
||||
'toggle-activation',
|
||||
'view-handlers'
|
||||
'view-handlers',
|
||||
'view-readme'
|
||||
]);
|
||||
|
||||
const open = (link: string | undefined) => {
|
||||
window.open(link, '_blank');
|
||||
};
|
||||
|
||||
const reveal = ref(false);
|
||||
|
||||
// 操作函数
|
||||
@@ -70,6 +67,10 @@ const toggleActivation = () => {
|
||||
const viewHandlers = () => {
|
||||
emit('view-handlers', props.extension);
|
||||
};
|
||||
|
||||
const viewReadme = () => {
|
||||
emit('view-readme', props.extension);
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -128,7 +129,7 @@ const viewHandlers = () => {
|
||||
</v-card-text>
|
||||
|
||||
<v-card-actions style="padding: 0px; margin-top: auto;">
|
||||
<v-btn color="teal-accent-4" text="帮助" variant="text" @click="open(extension.repo)"></v-btn>
|
||||
<v-btn color="teal-accent-4" text="查看文档" variant="text" @click="viewReadme"></v-btn>
|
||||
<v-btn v-if="!marketMode" color="teal-accent-4" text="操作" variant="text" @click="reveal = true"></v-btn>
|
||||
<v-btn v-if="marketMode && !extension?.installed" color="teal-accent-4" text="安装" variant="text"
|
||||
@click="emit('install', extension)"></v-btn>
|
||||
|
||||
@@ -3,12 +3,36 @@
|
||||
<v-list dense style="background-color: transparent;max-height: 300px; overflow-y: auto;">
|
||||
<v-list-item v-for="(item, index) in items" :key="index">
|
||||
<v-list-item-content style="display: flex; justify-content: space-between;">
|
||||
<v-list-item-title>
|
||||
<v-list-item-title v-if="editIndex !== index">
|
||||
<v-chip size="small" label color="primary">{{ item }}</v-chip>
|
||||
</v-list-item-title>
|
||||
<v-btn @click="removeItem(index)" variant="plain">
|
||||
<v-icon>mdi-close</v-icon>
|
||||
</v-btn>
|
||||
<v-text-field
|
||||
v-else
|
||||
v-model="editItem"
|
||||
dense
|
||||
hide-details
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
@keyup.enter="saveEdit"
|
||||
@keyup.esc="cancelEdit"
|
||||
autofocus
|
||||
></v-text-field>
|
||||
<div v-if="editIndex !== index">
|
||||
<v-btn @click="startEdit(index, item)" variant="plain" class="edit-btn" icon size="small">
|
||||
<v-icon>mdi-pencil</v-icon>
|
||||
</v-btn>
|
||||
<v-btn @click="removeItem(index)" variant="plain" icon size="small">
|
||||
<v-icon>mdi-close</v-icon>
|
||||
</v-btn>
|
||||
</div>
|
||||
<div v-else>
|
||||
<v-btn @click="saveEdit" variant="plain" color="success" icon size="small">
|
||||
<v-icon>mdi-check</v-icon>
|
||||
</v-btn>
|
||||
<v-btn @click="cancelEdit" variant="plain" color="error" icon size="small">
|
||||
<v-icon>mdi-close</v-icon>
|
||||
</v-btn>
|
||||
</div>
|
||||
</v-list-item-content>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
@@ -41,6 +65,8 @@ export default {
|
||||
return {
|
||||
newItem: '',
|
||||
items: this.value,
|
||||
editIndex: -1,
|
||||
editItem: '',
|
||||
};
|
||||
},
|
||||
watch: {
|
||||
@@ -58,6 +84,20 @@ export default {
|
||||
removeItem(index) {
|
||||
this.items.splice(index, 1);
|
||||
},
|
||||
startEdit(index, item) {
|
||||
this.editIndex = index;
|
||||
this.editItem = item;
|
||||
},
|
||||
saveEdit() {
|
||||
if (this.editItem.trim() !== '') {
|
||||
this.items[this.editIndex] = this.editItem.trim();
|
||||
this.cancelEdit();
|
||||
}
|
||||
},
|
||||
cancelEdit() {
|
||||
this.editIndex = -1;
|
||||
this.editItem = '';
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
@@ -82,4 +122,8 @@ export default {
|
||||
.v-btn {
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
.edit-btn {
|
||||
margin-right: -8px;
|
||||
}
|
||||
</style>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user