Compare commits

...

31 Commits

Author SHA1 Message Date
Junyan Qin
fdc79b8d77 chore: release v4.0.9 2025-07-16 11:39:15 +08:00
Junyan Qin
f244795e57 fix: rename to '302.AI' 2025-07-16 11:36:57 +08:00
Junyan Qin
5a2aa19d0f feat(aiocqhttp): no longer download files for now 2025-07-16 11:36:01 +08:00
Junyan Qin
81eb92646f doc: perf README_JP 2025-07-14 11:22:59 +08:00
Junyan Qin
019a9317e9 doc: perf README 2025-07-14 11:17:58 +08:00
TwperBody
858cfd8d5a Update package.json (#1570)
Compatible with the creation of environment variables in the Windows environment
2025-07-12 22:31:30 +08:00
Junyan Qin
bfdf238db5 chore: use new social image 2025-07-12 11:44:08 +08:00
Junyan Qin
c6e77e42be chore: switch some comments to en 2025-07-10 11:09:33 +08:00
Junyan Qin
4d0a39eb65 chore: switch comments to en 2025-07-10 11:01:16 +08:00
Junyan Qin
56248c350f chore: repo transferred 2025-07-07 19:00:55 +08:00
gaord
244aaf6e20 feat: 聊天的@用户id内容需要保留 (#1564)
* converters could use the application logger

* keep @targets in message for some plugins may need it to their functionality

* fix:form wxid in config

fix:传参问题,可以直接从config中拿到wxid

---------

Co-authored-by: fdc310 <82008029+fdc310@users.noreply.github.com>
2025-07-07 10:28:12 +08:00
Junyan Qin
a0b7d759ac chore: release v4.0.8.1 2025-07-06 10:46:32 +08:00
Junyan Qin
09884d3152 revert: 0203faa 2025-07-06 10:34:24 +08:00
Matthew_Astral
01f2ef5694 feat: new discord adapter (#1563) 2025-07-05 20:51:04 +08:00
Junyan Qin (Chin)
a01706d163 Feat/reset password (#1566)
* feat: reset password with recovery key

* perf: formatting and multi language
2025-07-05 17:36:35 +08:00
Junyan Qin
a8d03c98dc doc: replace comshare link 2025-07-04 11:37:31 +08:00
Junyan Qin
3f0153ea4d doc: fix incorrect 302.AI name 2025-07-03 17:26:17 +08:00
Junyan Qin
60b50a35f1 chore: release v4.0.8 2025-07-03 15:07:19 +08:00
Junyan Qin (Chin)
abd02f04af Feat/compshare requester (#1561)
* feat: add compshare requester

* doc: add compshare to README
2025-07-03 15:04:02 +08:00
Matthew_Astral
14411a8af6 Add Discord platform adapter implementation (#1560)
- Implement DiscordMessageConverter for message conversion
- Support image handling from base64, URL, and file paths
- Add DiscordEventConverter for event conversion
- Implement DiscordAdapter for Discord bot integration
- Support DM and TextChannel message handling
2025-07-02 09:48:49 +08:00
Junyan Qin
896fef8cce perf: make launch notes show async 2025-06-30 21:34:02 +08:00
Junyan Qin
89c1972abe perf: skip broken models and bots in bootstrap 2025-06-30 21:29:38 +08:00
Junyan Qin
1627d04958 fix: bad import 2025-06-30 21:13:14 +08:00
Junyan Qin (Chin)
c959c99e45 Feat/302 ai (#1558)
* feat: add 302.AI requester

* doc: add 302.AI to README
2025-06-30 21:05:32 +08:00
Junyan Qin
0203faa8c1 fix: dingtalk adapter initializer blocks boot (#1544) 2025-06-28 22:06:12 +08:00
Junyan Qin (Chin)
35f76cb7ae Perf/combine entity dialogs (#1555)
* feat: combine bot settings and bot log dialogs

* perf: dialog style when creating bot

* perf: bot creation dialog

* feat: combine pipeline dialogs

* perf: ui

* perf: move buttons

* perf: ui layout in pipeline detail dialog

* perf: remove debug button from pipeline card

* perf: open pipeline dialog after creating

* perf: placeholder in send input

* perf: no close dialog when save done

* fix: linter errors
2025-06-28 21:50:51 +08:00
fdc310
c34232a26c fix: add wechatpad image (#1551)
* add wechatpad image

* add wechatpad image

---------

Co-authored-by: fdc <you@example.com>
2025-06-27 15:41:21 +08:00
简律纯
b43dd95dc6 chore(python): Delete .python-version (#1549) 2025-06-25 22:47:02 +08:00
Junyan Qin
5331ba83d7 chore: update description of lark bot name field 2025-06-25 10:57:44 +08:00
fdc310
a2038b86f1 feat:add onebotv11 face send and accept but some face no name. (#1543)
* feat:add onebotv11 face send and accept but some face no name.

* add face annotation

* add face_code_dict

* add some face in image can't download,so pass on face

* fix:Pass the face_id to face
2025-06-19 10:38:02 +08:00
Junyan Qin
eb066f3485 revert: 3cbc823 2025-06-18 15:16:55 +08:00
116 changed files with 4463 additions and 1413 deletions

View File

@@ -9,7 +9,7 @@
*请在方括号间写`x`以打勾 / Please tick the box with `x`* *请在方括号间写`x`以打勾 / Please tick the box with `x`*
- [ ] 阅读仓库[贡献指引](https://github.com/RockChinQ/LangBot/blob/master/CONTRIBUTING.md)了吗? / Have you read the [contribution guide](https://github.com/RockChinQ/LangBot/blob/master/CONTRIBUTING.md)? - [ ] 阅读仓库[贡献指引](https://github.com/langbot-app/LangBot/blob/master/CONTRIBUTING.md)了吗? / Have you read the [contribution guide](https://github.com/langbot-app/LangBot/blob/master/CONTRIBUTING.md)?
- [ ] 与项目所有者沟通过了吗? / Have you communicated with the project maintainer? - [ ] 与项目所有者沟通过了吗? / Have you communicated with the project maintainer?
- [ ] 我确定已自行测试所作的更改,确保功能符合预期。 / I have tested the changes and ensured they work as expected. - [ ] 我确定已自行测试所作的更改,确保功能符合预期。 / I have tested the changes and ensured they work as expected.

View File

@@ -1 +0,0 @@
3.12

160
README.md
View File

@@ -1,143 +1,155 @@
<p align="center"> <p align="center">
<a href="https://langbot.app"> <a href="https://langbot.app">
<img src="https://docs.langbot.app/social.png" alt="LangBot"/> <img src="https://docs.langbot.app/social_zh.png" alt="LangBot"/>
</a> </a>
<div align="center"> <div align="center">
<a href="https://trendshift.io/repositories/12901" target="_blank"><img src="https://trendshift.io/api/badge/repositories/12901" alt="RockChinQ%2FLangBot | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a> 简体中文 / [English](README_EN.md) / [日本語](README_JP.md) / (PR for your language)
<a href="https://langbot.app">Home</a>
<a href="https://docs.langbot.app/en/insight/guide.html">Deployment</a>
<a href="https://docs.langbot.app/en/plugin/plugin-intro.html">Plugin</a>
<a href="https://github.com/RockChinQ/LangBot/issues/new?assignees=&labels=%E7%8B%AC%E7%AB%8B%E6%8F%92%E4%BB%B6&projects=&template=submit-plugin.yml&title=%5BPlugin%5D%3A+%E8%AF%B7%E6%B1%82%E7%99%BB%E8%AE%B0%E6%96%B0%E6%8F%92%E4%BB%B6">Submit Plugin</a>
<div align="center">
😎High Stability, 🧩Extension Supported, 🦄Multi-modal - LLM Native Instant Messaging Bot Platform🤖
</div>
<br/>
[![Discord](https://img.shields.io/discord/1335141740050649118?logo=discord&labelColor=%20%235462eb&logoColor=%20%23f5f5f5&color=%20%235462eb)](https://discord.gg/wdNEHETs87) [![Discord](https://img.shields.io/discord/1335141740050649118?logo=discord&labelColor=%20%235462eb&logoColor=%20%23f5f5f5&color=%20%235462eb)](https://discord.gg/wdNEHETs87)
[![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/RockChinQ/LangBot) [![QQ Group](https://img.shields.io/badge/%E7%A4%BE%E5%8C%BAQQ%E7%BE%A4-966235608-blue)](https://qm.qq.com/q/JLi38whHum)
[![GitHub release (latest by date)](https://img.shields.io/github/v/release/RockChinQ/LangBot)](https://github.com/RockChinQ/LangBot/releases/latest) [![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/langbot-app/LangBot)
[![GitHub release (latest by date)](https://img.shields.io/github/v/release/langbot-app/LangBot)](https://github.com/langbot-app/LangBot/releases/latest)
<img src="https://img.shields.io/badge/python-3.10 ~ 3.13 -blue.svg" alt="python"> <img src="https://img.shields.io/badge/python-3.10 ~ 3.13 -blue.svg" alt="python">
[![star](https://gitcode.com/langbot-app/LangBot/star/badge.svg)](https://gitcode.com/langbot-app/LangBot)
<a href="https://langbot.app">项目主页</a>
<a href="https://docs.langbot.app/zh/insight/guide.html">部署文档</a>
<a href="https://docs.langbot.app/zh/plugin/plugin-intro.html">插件介绍</a>
<a href="https://github.com/langbot-app/LangBot/issues/new?assignees=&labels=%E7%8B%AC%E7%AB%8B%E6%8F%92%E4%BB%B6&projects=&template=submit-plugin.yml&title=%5BPlugin%5D%3A+%E8%AF%B7%E6%B1%82%E7%99%BB%E8%AE%B0%E6%96%B0%E6%8F%92%E4%BB%B6">提交插件</a>
[简体中文](README_CN.md) / [English](README.md) / [日本語](README_JP.md) / (PR for your language)
</div> </div>
</p> </p>
## ✨ Features ## ✨ 特性
- 💬 Chat with LLM / Agent: Supports multiple LLMs, adapt to group chats and private chats; Supports multi-round conversations, tool calls, and multi-modal capabilities. Deeply integrates with [Dify](https://dify.ai). Currently supports QQ, QQ Channel, WeCom, personal WeChat, Lark, DingTalk, Discord, Telegram, etc. - 💬 大模型对话、Agent支持多种大模型适配群聊和私聊具有多轮对话、工具调用、多模态能力并深度适配 [Dify](https://dify.ai)。目前支持 QQ、QQ频道、企业微信、个人微信、飞书、DiscordTelegram 等平台。
- 🛠️ High Stability, Feature-rich: Native access control, rate limiting, sensitive word filtering, etc. mechanisms; Easy to use, supports multiple deployment methods. Supports multiple pipeline configurations, different bots can be used for different scenarios. - 🛠️ 高稳定性、功能完备:原生支持访问控制、限速、敏感词过滤等机制;配置简单,支持多种部署方式。支持多流水线配置,不同机器人用于不同应用场景。
- 🧩 Plugin Extension, Active Community: Support event-driven, component extension, etc. plugin mechanisms; Integrate Anthropic [MCP protocol](https://modelcontextprotocol.io/); Currently has hundreds of plugins. - 🧩 插件扩展、活跃社区:支持事件驱动、组件扩展等插件机制;适配 Anthropic [MCP 协议](https://modelcontextprotocol.io/);目前已有数百个插件。
- 😻 [New] Web UI: Support management LangBot instance through the browser. No need to manually write configuration files. - 😻 Web 管理面板:支持通过浏览器管理 LangBot 实例,不再需要手动编写配置文件。
## 📦 Getting Started ## 📦 开始使用
#### Docker Compose Deployment #### Docker Compose 部署
```bash ```bash
git clone https://github.com/RockChinQ/LangBot git clone https://github.com/langbot-app/LangBot
cd LangBot cd LangBot
docker compose up -d docker compose up -d
``` ```
Visit http://localhost:5300 to start using it. 访问 http://localhost:5300 即可开始使用。
Detailed documentation [Docker Deployment](https://docs.langbot.app/en/deploy/langbot/docker.html). 详细文档[Docker 部署](https://docs.langbot.app/zh/deploy/langbot/docker.html)
#### One-click Deployment on BTPanel #### 宝塔面板部署
LangBot has been listed on the BTPanel, if you have installed the BTPanel, you can use the [document](https://docs.langbot.app/en/deploy/langbot/one-click/bt.html) to use it. 已上架宝塔面板,若您已安装宝塔面板,可以根据[文档](https://docs.langbot.app/zh/deploy/langbot/one-click/bt.html)使用。
#### Zeabur Cloud Deployment #### Zeabur 云部署
Community contributed Zeabur template. 社区贡献的 Zeabur 模板。
[![Deploy on Zeabur](https://zeabur.com/button.svg)](https://zeabur.com/en-US/templates/ZKTBDH) [![Deploy on Zeabur](https://zeabur.com/button.svg)](https://zeabur.com/zh-CN/templates/ZKTBDH)
#### Railway Cloud Deployment #### Railway 云部署
[![Deploy on Railway](https://railway.com/button.svg)](https://railway.app/template/yRrAyL?referralCode=vogKPF) [![Deploy on Railway](https://railway.com/button.svg)](https://railway.app/template/yRrAyL?referralCode=vogKPF)
#### Other Deployment Methods #### 手动部署
Directly use the released version to run, see the [Manual Deployment](https://docs.langbot.app/en/deploy/langbot/manual.html) documentation. 直接使用发行版运行,查看文档[手动部署](https://docs.langbot.app/zh/deploy/langbot/manual.html)
## 📸 Demo ## 📸 效果展示
<img alt="bots" src="https://docs.langbot.app/webui/bot-page.png" width="400px"/> <img alt="bots" src="https://docs.langbot.app/webui/bot-page.png" width="450px"/>
<img alt="bots" src="https://docs.langbot.app/webui/create-model.png" width="400px"/> <img alt="bots" src="https://docs.langbot.app/webui/create-model.png" width="450px"/>
<img alt="bots" src="https://docs.langbot.app/webui/edit-pipeline.png" width="400px"/> <img alt="bots" src="https://docs.langbot.app/webui/edit-pipeline.png" width="450px"/>
<img alt="bots" src="https://docs.langbot.app/webui/plugin-market.png" width="400px"/> <img alt="bots" src="https://docs.langbot.app/webui/plugin-market.png" width="450px"/>
<img alt="Reply Effect (with Internet Plugin)" src="https://docs.langbot.app/QChatGPT-0516.png" width="500px"/> <img alt="回复效果(带有联网插件)" src="https://docs.langbot.app/QChatGPT-0516.png" width="500px"/>
- WebUI Demo: https://demo.langbot.dev/ - WebUI Demo: https://demo.langbot.dev/
- Login information: Email: `demo@langbot.app` Password: `langbot123456` - 登录信息:邮箱:`demo@langbot.app` 密码:`langbot123456`
- Note: Only the WebUI effect is shown, please do not fill in any sensitive information in the public environment. - 注意仅展示webui效果公开环境请不要在其中填入您的任何敏感信息。
## 🔌 Component Compatibility ## 🔌 组件兼容性
### Message Platform ### 消息平台
| Platform | Status | Remarks | | 平台 | 状态 | 备注 |
| --- | --- | --- | | --- | --- | --- |
| Personal QQ | ✅ | | | QQ 个人号 | ✅ | QQ 个人号私聊、群聊 |
| QQ Official API | ✅ | | | QQ 官方机器人 | ✅ | QQ 官方机器人,支持频道、私聊、群聊 |
| WeCom | ✅ | | | 企业微信 | ✅ | |
| WeComCS | ✅ | | | 企微对外客服 | ✅ | |
| Personal WeChat | ✅ | | | 个人微信 | ✅ | |
| Lark | ✅ | | | 微信公众号 | ✅ | |
| DingTalk | ✅ | | | 飞书 | ✅ | |
| 钉钉 | ✅ | |
| Discord | ✅ | | | Discord | ✅ | |
| Telegram | ✅ | | | Telegram | ✅ | |
| Slack | ✅ | | | Slack | ✅ | |
| LINE | 🚧 | | | LINE | 🚧 | |
| WhatsApp | 🚧 | | | WhatsApp | 🚧 | |
🚧: In development 🚧: 正在开发中
### LLMs ### 大模型能力
| LLM | Status | Remarks | | 模型 | 状态 | 备注 |
| --- | --- | --- | | --- | --- | --- |
| [OpenAI](https://platform.openai.com/) | ✅ | Available for any OpenAI interface format model | | [OpenAI](https://platform.openai.com/) | ✅ | 可接入任何 OpenAI 接口格式模型 |
| [DeepSeek](https://www.deepseek.com/) | ✅ | | | [DeepSeek](https://www.deepseek.com/) | ✅ | |
| [Moonshot](https://www.moonshot.cn/) | ✅ | | | [Moonshot](https://www.moonshot.cn/) | ✅ | |
| [Anthropic](https://www.anthropic.com/) | ✅ | | | [Anthropic](https://www.anthropic.com/) | ✅ | |
| [xAI](https://x.ai/) | ✅ | | | [xAI](https://x.ai/) | ✅ | |
| [Zhipu AI](https://open.bigmodel.cn/) | ✅ | | | [智谱AI](https://open.bigmodel.cn/) | ✅ | |
| [Dify](https://dify.ai) | ✅ | LLMOps platform | | [优云智算](https://www.compshare.cn/?ytag=GPU_YY-gh_langbot) | ✅ | 大模型和 GPU 资源平台 |
| [PPIO](https://ppinfra.com/user/register?invited_by=QJKFYD&utm_source=github_langbot) | ✅ | LLM and GPU resource platform | | [PPIO](https://ppinfra.com/user/register?invited_by=QJKFYD&utm_source=github_langbot) | ✅ | 大模型和 GPU 资源平台 |
| [302.AI](https://share.302.ai/SuTG99) | ✅ | 大模型聚合平台 |
| [Google Gemini](https://aistudio.google.com/prompts/new_chat) | ✅ | | | [Google Gemini](https://aistudio.google.com/prompts/new_chat) | ✅ | |
| [Ollama](https://ollama.com/) | ✅ | Local LLM running platform | | [Dify](https://dify.ai) | ✅ | LLMOps 平台 |
| [LMStudio](https://lmstudio.ai/) | ✅ | Local LLM running platform | | [Ollama](https://ollama.com/) | ✅ | 本地大模型运行平台 |
| [GiteeAI](https://ai.gitee.com/) | ✅ | LLM interface gateway(MaaS) | | [LMStudio](https://lmstudio.ai/) | ✅ | 本地大模型运行平台 |
| [SiliconFlow](https://siliconflow.cn/) | ✅ | LLM gateway(MaaS) | | [GiteeAI](https://ai.gitee.com/) | ✅ | 大模型接口聚合平台 |
| [Aliyun Bailian](https://bailian.console.aliyun.com/) | ✅ | LLM gateway(MaaS), LLMOps platform | | [SiliconFlow](https://siliconflow.cn/) | ✅ | 大模型聚合平台 |
| [Volc Engine Ark](https://console.volcengine.com/ark/region:ark+cn-beijing/model?vendor=Bytedance&view=LIST_VIEW) | ✅ | LLM gateway(MaaS), LLMOps platform | | [阿里云百炼](https://bailian.console.aliyun.com/) | ✅ | 大模型聚合平台, LLMOps 平台 |
| [ModelScope](https://modelscope.cn/docs/model-service/API-Inference/intro) | ✅ | LLM gateway(MaaS) | | [火山方舟](https://console.volcengine.com/ark/region:ark+cn-beijing/model?vendor=Bytedance&view=LIST_VIEW) | ✅ | 大模型聚合平台, LLMOps 平台 |
| [MCP](https://modelcontextprotocol.io/) | ✅ | Support tool access through MCP protocol | | [ModelScope](https://modelscope.cn/docs/model-service/API-Inference/intro) | ✅ | 大模型聚合平台 |
| [MCP](https://modelcontextprotocol.io/) | ✅ | 支持通过 MCP 协议获取工具 |
## 🤝 Community Contribution ### TTS
Thank you for the following [code contributors](https://github.com/RockChinQ/LangBot/graphs/contributors) and other members in the community for their contributions to LangBot: | 平台/模型 | 备注 |
| --- | --- |
| [FishAudio](https://fish.audio/zh-CN/discovery/) | [插件](https://github.com/the-lazy-me/NewChatVoice) |
| [海豚 AI](https://www.ttson.cn/?source=thelazy) | [插件](https://github.com/the-lazy-me/NewChatVoice) |
| [AzureTTS](https://portal.azure.com/) | [插件](https://github.com/Ingnaryk/LangBot_AzureTTS) |
<a href="https://github.com/RockChinQ/LangBot/graphs/contributors"> ### 文生图
<img src="https://contrib.rocks/image?repo=RockChinQ/LangBot" />
| 平台/模型 | 备注 |
| --- | --- |
| 阿里云百炼 | [插件](https://github.com/Thetail001/LangBot_BailianTextToImagePlugin)
## 😘 社区贡献
感谢以下[代码贡献者](https://github.com/langbot-app/LangBot/graphs/contributors)和社区里其他成员对 LangBot 的贡献:
<a href="https://github.com/langbot-app/LangBot/graphs/contributors">
<img src="https://contrib.rocks/image?repo=langbot-app/LangBot" />
</a> </a>
## 😎 Stay Ahead ## 😎 保持更新
Click the Star and Watch button in the upper right corner of the repository to get the latest updates. 点击仓库右上角 Star Watch 按钮,获取最新动态。
![star gif](https://docs.langbot.app/star.gif) ![star gif](https://docs.langbot.app/star.gif)

View File

@@ -1,160 +0,0 @@
<p align="center">
<a href="https://langbot.app">
<img src="https://docs.langbot.app/social.png" alt="LangBot"/>
</a>
<div align="center">
<a href="https://trendshift.io/repositories/12901" target="_blank"><img src="https://trendshift.io/api/badge/repositories/12901" alt="RockChinQ%2FLangBot | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
<a href="https://langbot.app">项目主页</a>
<a href="https://docs.langbot.app/zh/insight/guide.html">部署文档</a>
<a href="https://docs.langbot.app/zh/plugin/plugin-intro.html">插件介绍</a>
<a href="https://github.com/RockChinQ/LangBot/issues/new?assignees=&labels=%E7%8B%AC%E7%AB%8B%E6%8F%92%E4%BB%B6&projects=&template=submit-plugin.yml&title=%5BPlugin%5D%3A+%E8%AF%B7%E6%B1%82%E7%99%BB%E8%AE%B0%E6%96%B0%E6%8F%92%E4%BB%B6">提交插件</a>
<div align="center">
😎高稳定、🧩支持扩展、🦄多模态 - 大模型原生即时通信机器人平台🤖
</div>
<br/>
[![Discord](https://img.shields.io/discord/1335141740050649118?logo=discord&labelColor=%20%235462eb&logoColor=%20%23f5f5f5&color=%20%235462eb)](https://discord.gg/wdNEHETs87)
[![QQ Group](https://img.shields.io/badge/%E7%A4%BE%E5%8C%BAQQ%E7%BE%A4-966235608-blue)](https://qm.qq.com/q/JLi38whHum)
[![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/RockChinQ/LangBot)
[![GitHub release (latest by date)](https://img.shields.io/github/v/release/RockChinQ/LangBot)](https://github.com/RockChinQ/LangBot/releases/latest)
<img src="https://img.shields.io/badge/python-3.10 ~ 3.13 -blue.svg" alt="python">
[![star](https://gitcode.com/RockChinQ/LangBot/star/badge.svg)](https://gitcode.com/RockChinQ/LangBot)
[简体中文](README_CN.md) / [English](README.md) / [日本語](README_JP.md) / (PR for your language)
</div>
</p>
## ✨ 特性
- 💬 大模型对话、Agent支持多种大模型适配群聊和私聊具有多轮对话、工具调用、多模态能力并深度适配 [Dify](https://dify.ai)。目前支持 QQ、QQ频道、企业微信、个人微信、飞书、Discord、Telegram 等平台。
- 🛠️ 高稳定性、功能完备:原生支持访问控制、限速、敏感词过滤等机制;配置简单,支持多种部署方式。支持多流水线配置,不同机器人用于不同应用场景。
- 🧩 插件扩展、活跃社区:支持事件驱动、组件扩展等插件机制;适配 Anthropic [MCP 协议](https://modelcontextprotocol.io/);目前已有数百个插件。
- 😻 Web 管理面板:支持通过浏览器管理 LangBot 实例,不再需要手动编写配置文件。
## 📦 开始使用
#### Docker Compose 部署
```bash
git clone https://github.com/RockChinQ/LangBot
cd LangBot
docker compose up -d
```
访问 http://localhost:5300 即可开始使用。
详细文档[Docker 部署](https://docs.langbot.app/zh/deploy/langbot/docker.html)。
#### 宝塔面板部署
已上架宝塔面板,若您已安装宝塔面板,可以根据[文档](https://docs.langbot.app/zh/deploy/langbot/one-click/bt.html)使用。
#### Zeabur 云部署
社区贡献的 Zeabur 模板。
[![Deploy on Zeabur](https://zeabur.com/button.svg)](https://zeabur.com/zh-CN/templates/ZKTBDH)
#### Railway 云部署
[![Deploy on Railway](https://railway.com/button.svg)](https://railway.app/template/yRrAyL?referralCode=vogKPF)
#### 手动部署
直接使用发行版运行,查看文档[手动部署](https://docs.langbot.app/zh/deploy/langbot/manual.html)。
## 📸 效果展示
<img alt="bots" src="https://docs.langbot.app/webui/bot-page.png" width="450px"/>
<img alt="bots" src="https://docs.langbot.app/webui/create-model.png" width="450px"/>
<img alt="bots" src="https://docs.langbot.app/webui/edit-pipeline.png" width="450px"/>
<img alt="bots" src="https://docs.langbot.app/webui/plugin-market.png" width="450px"/>
<img alt="回复效果(带有联网插件)" src="https://docs.langbot.app/QChatGPT-0516.png" width="500px"/>
- WebUI Demo: https://demo.langbot.dev/
- 登录信息:邮箱:`demo@langbot.app` 密码:`langbot123456`
- 注意仅展示webui效果公开环境请不要在其中填入您的任何敏感信息。
## 🔌 组件兼容性
### 消息平台
| 平台 | 状态 | 备注 |
| --- | --- | --- |
| QQ 个人号 | ✅ | QQ 个人号私聊、群聊 |
| QQ 官方机器人 | ✅ | QQ 官方机器人,支持频道、私聊、群聊 |
| 企业微信 | ✅ | |
| 企微对外客服 | ✅ | |
| 个人微信 | ✅ | |
| 微信公众号 | ✅ | |
| 飞书 | ✅ | |
| 钉钉 | ✅ | |
| Discord | ✅ | |
| Telegram | ✅ | |
| Slack | ✅ | |
| LINE | 🚧 | |
| WhatsApp | 🚧 | |
🚧: 正在开发中
### 大模型能力
| 模型 | 状态 | 备注 |
| --- | --- | --- |
| [OpenAI](https://platform.openai.com/) | ✅ | 可接入任何 OpenAI 接口格式模型 |
| [DeepSeek](https://www.deepseek.com/) | ✅ | |
| [Moonshot](https://www.moonshot.cn/) | ✅ | |
| [Anthropic](https://www.anthropic.com/) | ✅ | |
| [xAI](https://x.ai/) | ✅ | |
| [智谱AI](https://open.bigmodel.cn/) | ✅ | |
| [PPIO](https://ppinfra.com/user/register?invited_by=QJKFYD&utm_source=github_langbot) | ✅ | 大模型和 GPU 资源平台 |
| [Google Gemini](https://aistudio.google.com/prompts/new_chat) | ✅ | |
| [Dify](https://dify.ai) | ✅ | LLMOps 平台 |
| [Ollama](https://ollama.com/) | ✅ | 本地大模型运行平台 |
| [LMStudio](https://lmstudio.ai/) | ✅ | 本地大模型运行平台 |
| [GiteeAI](https://ai.gitee.com/) | ✅ | 大模型接口聚合平台 |
| [SiliconFlow](https://siliconflow.cn/) | ✅ | 大模型聚合平台 |
| [阿里云百炼](https://bailian.console.aliyun.com/) | ✅ | 大模型聚合平台, LLMOps 平台 |
| [火山方舟](https://console.volcengine.com/ark/region:ark+cn-beijing/model?vendor=Bytedance&view=LIST_VIEW) | ✅ | 大模型聚合平台, LLMOps 平台 |
| [ModelScope](https://modelscope.cn/docs/model-service/API-Inference/intro) | ✅ | 大模型聚合平台 |
| [MCP](https://modelcontextprotocol.io/) | ✅ | 支持通过 MCP 协议获取工具 |
### TTS
| 平台/模型 | 备注 |
| --- | --- |
| [FishAudio](https://fish.audio/zh-CN/discovery/) | [插件](https://github.com/the-lazy-me/NewChatVoice) |
| [海豚 AI](https://www.ttson.cn/?source=thelazy) | [插件](https://github.com/the-lazy-me/NewChatVoice) |
| [AzureTTS](https://portal.azure.com/) | [插件](https://github.com/Ingnaryk/LangBot_AzureTTS) |
### 文生图
| 平台/模型 | 备注 |
| --- | --- |
| 阿里云百炼 | [插件](https://github.com/Thetail001/LangBot_BailianTextToImagePlugin)
## 😘 社区贡献
感谢以下[代码贡献者](https://github.com/RockChinQ/LangBot/graphs/contributors)和社区里其他成员对 LangBot 的贡献:
<a href="https://github.com/RockChinQ/LangBot/graphs/contributors">
<img src="https://contrib.rocks/image?repo=RockChinQ/LangBot" />
</a>
## 😎 保持更新
点击仓库右上角 Star 和 Watch 按钮,获取最新动态。
![star gif](https://docs.langbot.app/star.gif)

136
README_EN.md Normal file
View File

@@ -0,0 +1,136 @@
<p align="center">
<a href="https://langbot.app">
<img src="https://docs.langbot.app/social_en.png" alt="LangBot"/>
</a>
<div align="center">
[简体中文](README.md) / English / [日本語](README_JP.md) / (PR for your language)
[![Discord](https://img.shields.io/discord/1335141740050649118?logo=discord&labelColor=%20%235462eb&logoColor=%20%23f5f5f5&color=%20%235462eb)](https://discord.gg/wdNEHETs87)
[![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/langbot-app/LangBot)
[![GitHub release (latest by date)](https://img.shields.io/github/v/release/langbot-app/LangBot)](https://github.com/langbot-app/LangBot/releases/latest)
<img src="https://img.shields.io/badge/python-3.10 ~ 3.13 -blue.svg" alt="python">
<a href="https://langbot.app">Home</a>
<a href="https://docs.langbot.app/en/insight/guide.html">Deployment</a>
<a href="https://docs.langbot.app/en/plugin/plugin-intro.html">Plugin</a>
<a href="https://github.com/langbot-app/LangBot/issues/new?assignees=&labels=%E7%8B%AC%E7%AB%8B%E6%8F%92%E4%BB%B6&projects=&template=submit-plugin.yml&title=%5BPlugin%5D%3A+%E8%AF%B7%E6%B1%82%E7%99%BB%E8%AE%B0%E6%96%B0%E6%8F%92%E4%BB%B6">Submit Plugin</a>
</div>
</p>
## ✨ Features
- 💬 Chat with LLM / Agent: Supports multiple LLMs, adapt to group chats and private chats; Supports multi-round conversations, tool calls, and multi-modal capabilities. Deeply integrates with [Dify](https://dify.ai). Currently supports QQ, QQ Channel, WeCom, personal WeChat, Lark, DingTalk, Discord, Telegram, etc.
- 🛠️ High Stability, Feature-rich: Native access control, rate limiting, sensitive word filtering, etc. mechanisms; Easy to use, supports multiple deployment methods. Supports multiple pipeline configurations, different bots can be used for different scenarios.
- 🧩 Plugin Extension, Active Community: Support event-driven, component extension, etc. plugin mechanisms; Integrate Anthropic [MCP protocol](https://modelcontextprotocol.io/); Currently has hundreds of plugins.
- 😻 [New] Web UI: Support management LangBot instance through the browser. No need to manually write configuration files.
## 📦 Getting Started
#### Docker Compose Deployment
```bash
git clone https://github.com/langbot-app/LangBot
cd LangBot
docker compose up -d
```
Visit http://localhost:5300 to start using it.
Detailed documentation [Docker Deployment](https://docs.langbot.app/en/deploy/langbot/docker.html).
#### One-click Deployment on BTPanel
LangBot has been listed on the BTPanel, if you have installed the BTPanel, you can use the [document](https://docs.langbot.app/en/deploy/langbot/one-click/bt.html) to use it.
#### Zeabur Cloud Deployment
Community contributed Zeabur template.
[![Deploy on Zeabur](https://zeabur.com/button.svg)](https://zeabur.com/en-US/templates/ZKTBDH)
#### Railway Cloud Deployment
[![Deploy on Railway](https://railway.com/button.svg)](https://railway.app/template/yRrAyL?referralCode=vogKPF)
#### Other Deployment Methods
Directly use the released version to run, see the [Manual Deployment](https://docs.langbot.app/en/deploy/langbot/manual.html) documentation.
## 📸 Demo
<img alt="bots" src="https://docs.langbot.app/webui/bot-page.png" width="400px"/>
<img alt="bots" src="https://docs.langbot.app/webui/create-model.png" width="400px"/>
<img alt="bots" src="https://docs.langbot.app/webui/edit-pipeline.png" width="400px"/>
<img alt="bots" src="https://docs.langbot.app/webui/plugin-market.png" width="400px"/>
<img alt="Reply Effect (with Internet Plugin)" src="https://docs.langbot.app/QChatGPT-0516.png" width="500px"/>
- WebUI Demo: https://demo.langbot.dev/
- Login information: Email: `demo@langbot.app` Password: `langbot123456`
- Note: Only the WebUI effect is shown, please do not fill in any sensitive information in the public environment.
## 🔌 Component Compatibility
### Message Platform
| Platform | Status | Remarks |
| --- | --- | --- |
| Personal QQ | ✅ | |
| QQ Official API | ✅ | |
| WeCom | ✅ | |
| WeComCS | ✅ | |
| Personal WeChat | ✅ | |
| Lark | ✅ | |
| DingTalk | ✅ | |
| Discord | ✅ | |
| Telegram | ✅ | |
| Slack | ✅ | |
| LINE | 🚧 | |
| WhatsApp | 🚧 | |
🚧: In development
### LLMs
| LLM | Status | Remarks |
| --- | --- | --- |
| [OpenAI](https://platform.openai.com/) | ✅ | Available for any OpenAI interface format model |
| [DeepSeek](https://www.deepseek.com/) | ✅ | |
| [Moonshot](https://www.moonshot.cn/) | ✅ | |
| [Anthropic](https://www.anthropic.com/) | ✅ | |
| [xAI](https://x.ai/) | ✅ | |
| [Zhipu AI](https://open.bigmodel.cn/) | ✅ | |
| [CompShare](https://www.compshare.cn/?ytag=GPU_YY-gh_langbot) | ✅ | LLM and GPU resource platform |
| [Dify](https://dify.ai) | ✅ | LLMOps platform |
| [PPIO](https://ppinfra.com/user/register?invited_by=QJKFYD&utm_source=github_langbot) | ✅ | LLM and GPU resource platform |
| [302.AI](https://share.302.ai/SuTG99) | ✅ | LLM gateway(MaaS) |
| [Google Gemini](https://aistudio.google.com/prompts/new_chat) | ✅ | |
| [Ollama](https://ollama.com/) | ✅ | Local LLM running platform |
| [LMStudio](https://lmstudio.ai/) | ✅ | Local LLM running platform |
| [GiteeAI](https://ai.gitee.com/) | ✅ | LLM interface gateway(MaaS) |
| [SiliconFlow](https://siliconflow.cn/) | ✅ | LLM gateway(MaaS) |
| [Aliyun Bailian](https://bailian.console.aliyun.com/) | ✅ | LLM gateway(MaaS), LLMOps platform |
| [Volc Engine Ark](https://console.volcengine.com/ark/region:ark+cn-beijing/model?vendor=Bytedance&view=LIST_VIEW) | ✅ | LLM gateway(MaaS), LLMOps platform |
| [ModelScope](https://modelscope.cn/docs/model-service/API-Inference/intro) | ✅ | LLM gateway(MaaS) |
| [MCP](https://modelcontextprotocol.io/) | ✅ | Support tool access through MCP protocol |
## 🤝 Community Contribution
Thank you for the following [code contributors](https://github.com/langbot-app/LangBot/graphs/contributors) and other members in the community for their contributions to LangBot:
<a href="https://github.com/langbot-app/LangBot/graphs/contributors">
<img src="https://contrib.rocks/image?repo=langbot-app/LangBot" />
</a>
## 😎 Stay Ahead
Click the Star and Watch button in the upper right corner of the repository to get the latest updates.
![star gif](https://docs.langbot.app/star.gif)

View File

@@ -1,29 +1,21 @@
<p align="center"> <p align="center">
<a href="https://langbot.app"> <a href="https://langbot.app">
<img src="https://docs.langbot.app/social.png" alt="LangBot"/> <img src="https://docs.langbot.app/social_en.png" alt="LangBot"/>
</a> </a>
<div align="center"> <div align="center">
<a href="https://trendshift.io/repositories/12901" target="_blank"><img src="https://trendshift.io/api/badge/repositories/12901" alt="RockChinQ%2FLangBot | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a> [简体中文](README.md) / [English](README_EN.md) / 日本語 / (PR for your language)
[![Discord](https://img.shields.io/discord/1335141740050649118?logo=discord&labelColor=%20%235462eb&logoColor=%20%23f5f5f5&color=%20%235462eb)](https://discord.gg/wdNEHETs87)
[![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/langbot-app/LangBot)
[![GitHub release (latest by date)](https://img.shields.io/github/v/release/langbot-app/LangBot)](https://github.com/langbot-app/LangBot/releases/latest)
<img src="https://img.shields.io/badge/python-3.10 ~ 3.13 -blue.svg" alt="python">
<a href="https://langbot.app">ホーム</a> <a href="https://langbot.app">ホーム</a>
<a href="https://docs.langbot.app/en/insight/guide.html">デプロイ</a> <a href="https://docs.langbot.app/en/insight/guide.html">デプロイ</a>
<a href="https://docs.langbot.app/en/plugin/plugin-intro.html">プラグイン</a> <a href="https://docs.langbot.app/en/plugin/plugin-intro.html">プラグイン</a>
<a href="https://github.com/RockChinQ/LangBot/issues/new?assignees=&labels=%E7%8B%AC%E7%AB%8B%E6%8F%92%E4%BB%B6&projects=&template=submit-plugin.yml&title=%5BPlugin%5D%3A+%E8%AF%B7%E6%B1%82%E7%99%BB%E8%AE%B0%E6%96%B0%E6%8F%92%E4%BB%B6">プラグインの提出</a> <a href="https://github.com/langbot-app/LangBot/issues/new?assignees=&labels=%E7%8B%AC%E7%AB%8B%E6%8F%92%E4%BB%B6&projects=&template=submit-plugin.yml&title=%5BPlugin%5D%3A+%E8%AF%B7%E6%B1%82%E7%99%BB%E8%AE%B0%E6%96%B0%E6%8F%92%E4%BB%B6">プラグインの提出</a>
<div align="center">
😎高い安定性、🧩拡張サポート、🦄マルチモーダル - LLMネイティブインスタントメッセージングボットプラットフォーム🤖
</div>
<br/>
[![Discord](https://img.shields.io/discord/1335141740050649118?logo=discord&labelColor=%20%235462eb&logoColor=%20%23f5f5f5&color=%20%235462eb)](https://discord.gg/wdNEHETs87)
[![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/RockChinQ/LangBot)
[![GitHub release (latest by date)](https://img.shields.io/github/v/release/RockChinQ/LangBot)](https://github.com/RockChinQ/LangBot/releases/latest)
<img src="https://img.shields.io/badge/python-3.10 ~ 3.13 -blue.svg" alt="python">
[简体中文](README_CN.md) / [English](README.md) / [日本語](README_JP.md) / (PR for your language)
</div> </div>
@@ -41,7 +33,7 @@
#### Docker Compose デプロイ #### Docker Compose デプロイ
```bash ```bash
git clone https://github.com/RockChinQ/LangBot git clone https://github.com/langbot-app/LangBot
cd LangBot cd LangBot
docker compose up -d docker compose up -d
``` ```
@@ -115,7 +107,9 @@ LangBotはBTPanelにリストされています。BTPanelをインストール
| [Anthropic](https://www.anthropic.com/) | ✅ | | | [Anthropic](https://www.anthropic.com/) | ✅ | |
| [xAI](https://x.ai/) | ✅ | | | [xAI](https://x.ai/) | ✅ | |
| [Zhipu AI](https://open.bigmodel.cn/) | ✅ | | | [Zhipu AI](https://open.bigmodel.cn/) | ✅ | |
| [CompShare](https://www.compshare.cn/?ytag=GPU_YY-gh_langbot) | ✅ | 大模型とGPUリソースプラットフォーム |
| [PPIO](https://ppinfra.com/user/register?invited_by=QJKFYD&utm_source=github_langbot) | ✅ | 大模型とGPUリソースプラットフォーム | | [PPIO](https://ppinfra.com/user/register?invited_by=QJKFYD&utm_source=github_langbot) | ✅ | 大模型とGPUリソースプラットフォーム |
| [302.AI](https://share.302.ai/SuTG99) | ✅ | LLMゲートウェイ(MaaS) |
| [Google Gemini](https://aistudio.google.com/prompts/new_chat) | ✅ | | | [Google Gemini](https://aistudio.google.com/prompts/new_chat) | ✅ | |
| [Dify](https://dify.ai) | ✅ | LLMOpsプラットフォーム | | [Dify](https://dify.ai) | ✅ | LLMOpsプラットフォーム |
| [Ollama](https://ollama.com/) | ✅ | ローカルLLM実行プラットフォーム | | [Ollama](https://ollama.com/) | ✅ | ローカルLLM実行プラットフォーム |
@@ -129,10 +123,10 @@ LangBotはBTPanelにリストされています。BTPanelをインストール
## 🤝 コミュニティ貢献 ## 🤝 コミュニティ貢献
LangBot への貢献に対して、以下の [コード貢献者](https://github.com/RockChinQ/LangBot/graphs/contributors) とコミュニティの他のメンバーに感謝します。 LangBot への貢献に対して、以下の [コード貢献者](https://github.com/langbot-app/LangBot/graphs/contributors) とコミュニティの他のメンバーに感謝します。
<a href="https://github.com/RockChinQ/LangBot/graphs/contributors"> <a href="https://github.com/langbot-app/LangBot/graphs/contributors">
<img src="https://contrib.rocks/image?repo=RockChinQ/LangBot" /> <img src="https://contrib.rocks/image?repo=langbot-app/LangBot" />
</a> </a>
## 😎 最新情報を入手 ## 😎 最新情報を入手

View File

@@ -11,7 +11,7 @@ asciiart = r"""
|____\__,_|_||_\__, |___/\___/\__| |____\__,_|_||_\__, |___/\___/\__|
|___/ |___/
⭐️ Open Source 开源地址: https://github.com/RockChinQ/LangBot ⭐️ Open Source 开源地址: https://github.com/langbot-app/LangBot
📖 Documentation 文档地址: https://docs.langbot.app 📖 Documentation 文档地址: https://docs.langbot.app
""" """

View File

@@ -11,11 +11,11 @@ from ....core import app
preregistered_groups: list[type[RouterGroup]] = [] preregistered_groups: list[type[RouterGroup]] = []
"""RouterGroup 的预注册列表""" """Pre-registered list of RouterGroup"""
def group_class(name: str, path: str) -> None: def group_class(name: str, path: str) -> None:
"""注册一个 RouterGroup""" """Register a RouterGroup"""
def decorator(cls: typing.Type[RouterGroup]) -> typing.Type[RouterGroup]: def decorator(cls: typing.Type[RouterGroup]) -> typing.Type[RouterGroup]:
cls.name = name cls.name = name
@@ -27,7 +27,7 @@ def group_class(name: str, path: str) -> None:
class AuthType(enum.Enum): class AuthType(enum.Enum):
"""认证类型""" """Authentication type"""
NONE = 'none' NONE = 'none'
USER_TOKEN = 'user-token' USER_TOKEN = 'user-token'
@@ -56,7 +56,7 @@ class RouterGroup(abc.ABC):
auth_type: AuthType = AuthType.USER_TOKEN, auth_type: AuthType = AuthType.USER_TOKEN,
**options: typing.Any, **options: typing.Any,
) -> typing.Callable[[RouteCallable], RouteCallable]: # decorator ) -> typing.Callable[[RouteCallable], RouteCallable]: # decorator
"""注册一个路由""" """Register a route"""
def decorator(f: RouteCallable) -> RouteCallable: def decorator(f: RouteCallable) -> RouteCallable:
nonlocal rule nonlocal rule
@@ -64,11 +64,11 @@ class RouterGroup(abc.ABC):
async def handler_error(*args, **kwargs): async def handler_error(*args, **kwargs):
if auth_type == AuthType.USER_TOKEN: if auth_type == AuthType.USER_TOKEN:
# Authorization头中获取token # get token from Authorization header
token = quart.request.headers.get('Authorization', '').replace('Bearer ', '') token = quart.request.headers.get('Authorization', '').replace('Bearer ', '')
if not token: if not token:
return self.http_status(401, -1, '未提供有效的用户令牌') return self.http_status(401, -1, 'No valid user token provided')
try: try:
user_email = await self.ap.user_service.verify_jwt_token(token) user_email = await self.ap.user_service.verify_jwt_token(token)
@@ -76,9 +76,9 @@ class RouterGroup(abc.ABC):
# check if this account exists # check if this account exists
user = await self.ap.user_service.get_user_by_email(user_email) user = await self.ap.user_service.get_user_by_email(user_email)
if not user: if not user:
return self.http_status(401, -1, '用户不存在') return self.http_status(401, -1, 'User not found')
# 检查f是否接受user_email参数 # check if f accepts user_email parameter
if 'user_email' in f.__code__.co_varnames: if 'user_email' in f.__code__.co_varnames:
kwargs['user_email'] = user_email kwargs['user_email'] = user_email
except Exception as e: except Exception as e:
@@ -86,7 +86,7 @@ class RouterGroup(abc.ABC):
try: try:
return await f(*args, **kwargs) return await f(*args, **kwargs)
except Exception: # 自动 500 except Exception: # auto 500
traceback.print_exc() traceback.print_exc()
# return self.http_status(500, -2, str(e)) # return self.http_status(500, -2, str(e))
return self.http_status(500, -2, 'internal server error') return self.http_status(500, -2, 'internal server error')
@@ -101,7 +101,7 @@ class RouterGroup(abc.ABC):
return decorator return decorator
def success(self, data: typing.Any = None) -> quart.Response: def success(self, data: typing.Any = None) -> quart.Response:
"""返回一个 200 响应""" """Return a 200 response"""
return quart.jsonify( return quart.jsonify(
{ {
'code': 0, 'code': 0,
@@ -111,7 +111,7 @@ class RouterGroup(abc.ABC):
) )
def fail(self, code: int, msg: str) -> quart.Response: def fail(self, code: int, msg: str) -> quart.Response:
"""返回一个异常响应""" """Return an error response"""
return quart.jsonify( return quart.jsonify(
{ {
@@ -121,5 +121,5 @@ class RouterGroup(abc.ABC):
) )
def http_status(self, status: int, code: int, msg: str) -> quart.Response: def http_status(self, status: int, code: int, msg: str) -> quart.Response:
"""返回一个指定状态码的响应""" """Return a response with a specified status code"""
return self.fail(code, msg), status return self.fail(code, msg), status

View File

@@ -8,7 +8,7 @@ class WebChatDebugRouterGroup(group.RouterGroup):
async def initialize(self) -> None: async def initialize(self) -> None:
@self.route('/send', methods=['POST']) @self.route('/send', methods=['POST'])
async def send_message(pipeline_uuid: str) -> str: async def send_message(pipeline_uuid: str) -> str:
"""发送调试消息到流水线""" """Send a message to the pipeline for debugging"""
try: try:
data = await quart.request.get_json() data = await quart.request.get_json()
session_type = data.get('session_type', 'person') session_type = data.get('session_type', 'person')
@@ -38,7 +38,7 @@ class WebChatDebugRouterGroup(group.RouterGroup):
@self.route('/messages/<session_type>', methods=['GET']) @self.route('/messages/<session_type>', methods=['GET'])
async def get_messages(pipeline_uuid: str, session_type: str) -> str: async def get_messages(pipeline_uuid: str, session_type: str) -> str:
"""获取调试消息历史""" """Get the message history of the pipeline for debugging"""
try: try:
if session_type not in ['person', 'group']: if session_type not in ['person', 'group']:
return self.http_status(400, -1, 'session_type must be person or group') return self.http_status(400, -1, 'session_type must be person or group')
@@ -57,7 +57,7 @@ class WebChatDebugRouterGroup(group.RouterGroup):
@self.route('/reset/<session_type>', methods=['POST']) @self.route('/reset/<session_type>', methods=['POST'])
async def reset_session(session_type: str) -> str: async def reset_session(session_type: str) -> str:
"""重置调试会话""" """Reset the debug session"""
try: try:
if session_type not in ['person', 'group']: if session_type not in ['person', 'group']:
return self.http_status(400, -1, 'session_type must be person or group') return self.http_status(400, -1, 'session_type must be person or group')

View File

@@ -40,7 +40,7 @@ class PluginsRouterGroup(group.RouterGroup):
self.ap.plugin_mgr.update_plugin(plugin_name, task_context=ctx), self.ap.plugin_mgr.update_plugin(plugin_name, task_context=ctx),
kind='plugin-operation', kind='plugin-operation',
name=f'plugin-update-{plugin_name}', name=f'plugin-update-{plugin_name}',
label=f'更新插件 {plugin_name}', label=f'Updating plugin {plugin_name}',
context=ctx, context=ctx,
) )
return self.success(data={'task_id': wrapper.id}) return self.success(data={'task_id': wrapper.id})
@@ -62,7 +62,7 @@ class PluginsRouterGroup(group.RouterGroup):
self.ap.plugin_mgr.uninstall_plugin(plugin_name, task_context=ctx), self.ap.plugin_mgr.uninstall_plugin(plugin_name, task_context=ctx),
kind='plugin-operation', kind='plugin-operation',
name=f'plugin-remove-{plugin_name}', name=f'plugin-remove-{plugin_name}',
label=f'删除插件 {plugin_name}', label=f'Removing plugin {plugin_name}',
context=ctx, context=ctx,
) )
@@ -102,7 +102,7 @@ class PluginsRouterGroup(group.RouterGroup):
self.ap.plugin_mgr.install_plugin(data['source'], task_context=ctx), self.ap.plugin_mgr.install_plugin(data['source'], task_context=ctx),
kind='plugin-operation', kind='plugin-operation',
name='plugin-install-github', name='plugin-install-github',
label=f'安装插件 ...{short_source_str}', label=f'Installing plugin ...{short_source_str}',
context=ctx, context=ctx,
) )

View File

@@ -1,5 +1,6 @@
import quart import quart
import argon2 import argon2
import asyncio
from .. import group from .. import group
@@ -13,7 +14,7 @@ class UserRouterGroup(group.RouterGroup):
return self.success(data={'initialized': await self.ap.user_service.is_initialized()}) return self.success(data={'initialized': await self.ap.user_service.is_initialized()})
if await self.ap.user_service.is_initialized(): if await self.ap.user_service.is_initialized():
return self.fail(1, '系统已初始化') return self.fail(1, 'System already initialized')
json_data = await quart.request.json json_data = await quart.request.json
@@ -31,7 +32,7 @@ class UserRouterGroup(group.RouterGroup):
try: try:
token = await self.ap.user_service.authenticate(json_data['user'], json_data['password']) token = await self.ap.user_service.authenticate(json_data['user'], json_data['password'])
except argon2.exceptions.VerifyMismatchError: except argon2.exceptions.VerifyMismatchError:
return self.fail(1, '用户名或密码错误') return self.fail(1, 'Invalid username or password')
return self.success(data={'token': token}) return self.success(data={'token': token})
@@ -40,3 +41,29 @@ class UserRouterGroup(group.RouterGroup):
token = await self.ap.user_service.generate_jwt_token(user_email) token = await self.ap.user_service.generate_jwt_token(user_email)
return self.success(data={'token': token}) return self.success(data={'token': token})
@self.route('/reset-password', methods=['POST'], auth_type=group.AuthType.NONE)
async def _() -> str:
json_data = await quart.request.json
user_email = json_data['user']
recovery_key = json_data['recovery_key']
new_password = json_data['new_password']
# hard sleep 3s for security
await asyncio.sleep(3)
if not await self.ap.user_service.is_initialized():
return self.http_status(400, -1, 'System not initialized')
user_obj = await self.ap.user_service.get_user_by_email(user_email)
if user_obj is None:
return self.http_status(400, -1, 'User not found')
if recovery_key != self.ap.instance_config.data['system']['recovery_key']:
return self.http_status(403, -1, 'Invalid recovery key')
await self.ap.user_service.reset_password(user_email, new_password)
return self.success(data={'user': user_email})

View File

@@ -45,7 +45,7 @@ class HTTPController:
try: try:
await self.quart_app.run_task(*args, **kwargs) await self.quart_app.run_task(*args, **kwargs)
except Exception as e: except Exception as e:
self.ap.logger.error(f'启动 HTTP 服务失败: {e}') self.ap.logger.error(f'Failed to start HTTP service: {e}')
self.ap.task_mgr.create_task( self.ap.task_mgr.create_task(
exception_handler( exception_handler(

View File

@@ -10,7 +10,7 @@ from ....entity.persistence import pipeline as persistence_pipeline
class BotService: class BotService:
"""机器人服务""" """Bot service"""
ap: app.Application ap: app.Application
@@ -18,7 +18,7 @@ class BotService:
self.ap = ap self.ap = ap
async def get_bots(self) -> list[dict]: async def get_bots(self) -> list[dict]:
"""获取所有机器人""" """Get all bots"""
result = await self.ap.persistence_mgr.execute_async(sqlalchemy.select(persistence_bot.Bot)) result = await self.ap.persistence_mgr.execute_async(sqlalchemy.select(persistence_bot.Bot))
bots = result.all() bots = result.all()
@@ -26,7 +26,7 @@ class BotService:
return [self.ap.persistence_mgr.serialize_model(persistence_bot.Bot, bot) for bot in bots] return [self.ap.persistence_mgr.serialize_model(persistence_bot.Bot, bot) for bot in bots]
async def get_bot(self, bot_uuid: str) -> dict | None: async def get_bot(self, bot_uuid: str) -> dict | None:
"""获取机器人""" """Get bot"""
result = await self.ap.persistence_mgr.execute_async( result = await self.ap.persistence_mgr.execute_async(
sqlalchemy.select(persistence_bot.Bot).where(persistence_bot.Bot.uuid == bot_uuid) sqlalchemy.select(persistence_bot.Bot).where(persistence_bot.Bot.uuid == bot_uuid)
) )
@@ -39,7 +39,7 @@ class BotService:
return self.ap.persistence_mgr.serialize_model(persistence_bot.Bot, bot) return self.ap.persistence_mgr.serialize_model(persistence_bot.Bot, bot)
async def create_bot(self, bot_data: dict) -> str: async def create_bot(self, bot_data: dict) -> str:
"""创建机器人""" """Create bot"""
# TODO: 检查配置信息格式 # TODO: 检查配置信息格式
bot_data['uuid'] = str(uuid.uuid4()) bot_data['uuid'] = str(uuid.uuid4())
@@ -63,7 +63,7 @@ class BotService:
return bot_data['uuid'] return bot_data['uuid']
async def update_bot(self, bot_uuid: str, bot_data: dict) -> None: async def update_bot(self, bot_uuid: str, bot_data: dict) -> None:
"""更新机器人""" """Update bot"""
if 'uuid' in bot_data: if 'uuid' in bot_data:
del bot_data['uuid'] del bot_data['uuid']
@@ -99,7 +99,7 @@ class BotService:
session.using_conversation = None session.using_conversation = None
async def delete_bot(self, bot_uuid: str) -> None: async def delete_bot(self, bot_uuid: str) -> None:
"""删除机器人""" """Delete bot"""
await self.ap.platform_mgr.remove_bot(bot_uuid) await self.ap.platform_mgr.remove_bot(bot_uuid)
await self.ap.persistence_mgr.execute_async( await self.ap.persistence_mgr.execute_async(
sqlalchemy.delete(persistence_bot.Bot).where(persistence_bot.Bot.uuid == bot_uuid) sqlalchemy.delete(persistence_bot.Bot).where(persistence_bot.Bot.uuid == bot_uuid)

View File

@@ -73,3 +73,12 @@ class UserService:
jwt_secret = self.ap.instance_config.data['system']['jwt']['secret'] jwt_secret = self.ap.instance_config.data['system']['jwt']['secret']
return jwt.decode(token, jwt_secret, algorithms=['HS256'])['user'] return jwt.decode(token, jwt_secret, algorithms=['HS256'])['user']
async def reset_password(self, user_email: str, new_password: str) -> None:
ph = argon2.PasswordHasher()
hashed_password = ph.hash(new_password)
await self.ap.persistence_mgr.execute_async(
sqlalchemy.update(user.User).where(user.User.user == user_email).values(password=hashed_password)
)

View File

@@ -6,7 +6,7 @@ from .. import model as file_model
class JSONConfigFile(file_model.ConfigFile): class JSONConfigFile(file_model.ConfigFile):
"""JSON配置文件""" """JSON config file"""
def __init__( def __init__(
self, self,
@@ -42,7 +42,7 @@ class JSONConfigFile(file_model.ConfigFile):
try: try:
cfg = json.load(f) cfg = json.load(f)
except json.JSONDecodeError as e: except json.JSONDecodeError as e:
raise Exception(f'配置文件 {self.config_file_name} 语法错误: {e}') raise Exception(f'Syntax error in config file {self.config_file_name}: {e}')
if completion: if completion:
for key in self.template_data: for key in self.template_data:

View File

@@ -7,13 +7,13 @@ from .. import model as file_model
class PythonModuleConfigFile(file_model.ConfigFile): class PythonModuleConfigFile(file_model.ConfigFile):
"""Python模块配置文件""" """Python module config file"""
config_file_name: str = None config_file_name: str = None
"""配置文件名""" """Config file name"""
template_file_name: str = None template_file_name: str = None
"""模板文件名""" """Template file name"""
def __init__(self, config_file_name: str, template_file_name: str) -> None: def __init__(self, config_file_name: str, template_file_name: str) -> None:
self.config_file_name = config_file_name self.config_file_name = config_file_name
@@ -42,7 +42,7 @@ class PythonModuleConfigFile(file_model.ConfigFile):
cfg[key] = getattr(module, key) cfg[key] = getattr(module, key)
# 从模板模块文件中进行补全 # complete from template module file
if completion: if completion:
module_name = os.path.splitext(os.path.basename(self.template_file_name))[0] module_name = os.path.splitext(os.path.basename(self.template_file_name))[0]
module = importlib.import_module(module_name) module = importlib.import_module(module_name)
@@ -60,7 +60,7 @@ class PythonModuleConfigFile(file_model.ConfigFile):
return cfg return cfg
async def save(self, data: dict): async def save(self, data: dict):
logging.warning('Python模块配置文件不支持保存') logging.warning('Python module config file does not support saving')
def save_sync(self, data: dict): def save_sync(self, data: dict):
logging.warning('Python模块配置文件不支持保存') logging.warning('Python module config file does not support saving')

View File

@@ -6,7 +6,7 @@ from .. import model as file_model
class YAMLConfigFile(file_model.ConfigFile): class YAMLConfigFile(file_model.ConfigFile):
"""YAML配置文件""" """YAML config file"""
def __init__( def __init__(
self, self,
@@ -42,7 +42,7 @@ class YAMLConfigFile(file_model.ConfigFile):
try: try:
cfg = yaml.load(f, Loader=yaml.FullLoader) cfg = yaml.load(f, Loader=yaml.FullLoader)
except yaml.YAMLError as e: except yaml.YAMLError as e:
raise Exception(f'配置文件 {self.config_file_name} 语法错误: {e}') raise Exception(f'Syntax error in config file {self.config_file_name}: {e}')
if completion: if completion:
for key in self.template_data: for key in self.template_data:

View File

@@ -5,27 +5,27 @@ from .impls import pymodule, json as json_file, yaml as yaml_file
class ConfigManager: class ConfigManager:
"""配置文件管理器""" """Config file manager"""
name: str = None name: str = None
"""配置管理器名""" """Config manager name"""
description: str = None description: str = None
"""配置管理器描述""" """Config manager description"""
schema: dict = None schema: dict = None
"""配置文件 schema """Config file schema
需要符合 JSON Schema Draft 7 规范 Must conform to JSON Schema Draft 7 specification
""" """
file: file_model.ConfigFile = None file: file_model.ConfigFile = None
"""配置文件实例""" """Config file instance"""
data: dict = None data: dict = None
"""配置数据""" """Config data"""
doc_link: str = None doc_link: str = None
"""配置文件文档链接""" """Config file documentation link"""
def __init__(self, cfg_file: file_model.ConfigFile) -> None: def __init__(self, cfg_file: file_model.ConfigFile) -> None:
self.file = cfg_file self.file = cfg_file
@@ -42,15 +42,15 @@ class ConfigManager:
async def load_python_module_config(config_name: str, template_name: str, completion: bool = True) -> ConfigManager: async def load_python_module_config(config_name: str, template_name: str, completion: bool = True) -> ConfigManager:
"""加载Python模块配置文件 """Load Python module config file
Args: Args:
config_name (str): 配置文件名 config_name (str): Config file name
template_name (str): 模板文件名 template_name (str): Template file name
completion (bool): 是否自动补全内存中的配置文件 completion (bool): Whether to automatically complete the config file in memory
Returns: Returns:
ConfigManager: 配置文件管理器 ConfigManager: Config file manager
""" """
cfg_inst = pymodule.PythonModuleConfigFile(config_name, template_name) cfg_inst = pymodule.PythonModuleConfigFile(config_name, template_name)
@@ -66,13 +66,13 @@ async def load_json_config(
template_data: dict = None, template_data: dict = None,
completion: bool = True, completion: bool = True,
) -> ConfigManager: ) -> ConfigManager:
"""加载JSON配置文件 """Load JSON config file
Args: Args:
config_name (str): 配置文件名 config_name (str): Config file name
template_name (str): 模板文件名 template_name (str): Template file name
template_data (dict): 模板数据 template_data (dict): Template data
completion (bool): 是否自动补全内存中的配置文件 completion (bool): Whether to automatically complete the config file in memory
""" """
cfg_inst = json_file.JSONConfigFile(config_name, template_name, template_data) cfg_inst = json_file.JSONConfigFile(config_name, template_name, template_data)
@@ -88,16 +88,16 @@ async def load_yaml_config(
template_data: dict = None, template_data: dict = None,
completion: bool = True, completion: bool = True,
) -> ConfigManager: ) -> ConfigManager:
"""加载YAML配置文件 """Load YAML config file
Args: Args:
config_name (str): 配置文件名 config_name (str): Config file name
template_name (str): 模板文件名 template_name (str): Template file name
template_data (dict): 模板数据 template_data (dict): Template data
completion (bool): 是否自动补全内存中的配置文件 completion (bool): Whether to automatically complete the config file in memory
Returns: Returns:
ConfigManager: 配置文件管理器 ConfigManager: Config file manager
""" """
cfg_inst = yaml_file.YAMLConfigFile(config_name, template_name, template_data) cfg_inst = yaml_file.YAMLConfigFile(config_name, template_name, template_data)

View File

@@ -2,16 +2,16 @@ import abc
class ConfigFile(metaclass=abc.ABCMeta): class ConfigFile(metaclass=abc.ABCMeta):
"""配置文件抽象类""" """Config file abstract class"""
config_file_name: str = None config_file_name: str = None
"""配置文件名""" """Config file name"""
template_file_name: str = None template_file_name: str = None
"""模板文件名""" """Template file name"""
template_data: dict = None template_data: dict = None
"""模板数据""" """Template data"""
@abc.abstractmethod @abc.abstractmethod
def exists(self) -> bool: def exists(self) -> bool:

View File

@@ -30,7 +30,7 @@ from . import entities as core_entities
class Application: class Application:
"""运行时应用对象和上下文""" """Runtime application object and context"""
event_loop: asyncio.AbstractEventLoop = None event_loop: asyncio.AbstractEventLoop = None
@@ -47,10 +47,10 @@ class Application:
model_mgr: llm_model_mgr.ModelManager = None model_mgr: llm_model_mgr.ModelManager = None
# TODO 移动到 pipeline # TODO move to pipeline
tool_mgr: llm_tool_mgr.ToolManager = None tool_mgr: llm_tool_mgr.ToolManager = None
# ======= 配置管理器 ======= # ======= Config manager =======
command_cfg: config_mgr.ConfigManager = None # deprecated command_cfg: config_mgr.ConfigManager = None # deprecated
@@ -64,7 +64,7 @@ class Application:
instance_config: config_mgr.ConfigManager = None instance_config: config_mgr.ConfigManager = None
# ======= 元数据配置管理器 ======= # ======= Metadata config manager =======
sensitive_meta: config_mgr.ConfigManager = None sensitive_meta: config_mgr.ConfigManager = None
@@ -154,11 +154,11 @@ class Application:
except asyncio.CancelledError: except asyncio.CancelledError:
pass pass
except Exception as e: except Exception as e:
self.logger.error(f'应用运行致命异常: {e}') self.logger.error(f'Application runtime fatal exception: {e}')
self.logger.debug(f'Traceback: {traceback.format_exc()}') self.logger.debug(f'Traceback: {traceback.format_exc()}')
async def print_web_access_info(self): async def print_web_access_info(self):
"""打印访问 webui 的提示""" """Print access webui tips"""
if not os.path.exists(os.path.join('.', 'web/out')): if not os.path.exists(os.path.join('.', 'web/out')):
self.logger.warning('WebUI 文件缺失请根据文档部署https://docs.langbot.app/zh') self.logger.warning('WebUI 文件缺失请根据文档部署https://docs.langbot.app/zh')
@@ -190,7 +190,7 @@ class Application:
): ):
match scope: match scope:
case core_entities.LifecycleControlScope.PLATFORM.value: case core_entities.LifecycleControlScope.PLATFORM.value:
self.logger.info('执行热重载 scope=' + scope) self.logger.info('Hot reload scope=' + scope)
await self.platform_mgr.shutdown() await self.platform_mgr.shutdown()
self.platform_mgr = im_mgr.PlatformManager(self) self.platform_mgr = im_mgr.PlatformManager(self)
@@ -206,7 +206,7 @@ class Application:
], ],
) )
case core_entities.LifecycleControlScope.PLUGIN.value: case core_entities.LifecycleControlScope.PLUGIN.value:
self.logger.info('执行热重载 scope=' + scope) self.logger.info('Hot reload scope=' + scope)
await self.plugin_mgr.destroy_plugins() await self.plugin_mgr.destroy_plugins()
# 删除 sys.module 中所有的 plugins/* 下的模块 # 删除 sys.module 中所有的 plugins/* 下的模块
@@ -222,7 +222,7 @@ class Application:
await self.plugin_mgr.load_plugins() await self.plugin_mgr.load_plugins()
await self.plugin_mgr.initialize_plugins() await self.plugin_mgr.initialize_plugins()
case core_entities.LifecycleControlScope.PROVIDER.value: case core_entities.LifecycleControlScope.PROVIDER.value:
self.logger.info('执行热重载 scope=' + scope) self.logger.info('Hot reload scope=' + scope)
await self.tool_mgr.shutdown() await self.tool_mgr.shutdown()

View File

@@ -1,4 +1,4 @@
from __future__ import print_function from __future__ import annotations
import traceback import traceback
import asyncio import asyncio
@@ -8,7 +8,7 @@ from . import app
from . import stage from . import stage
from ..utils import constants, importutil from ..utils import constants, importutil
# 引入启动阶段实现以便注册 # Import startup stage implementation to register
from . import stages from . import stages
importutil.import_modules_in_pkg(stages) importutil.import_modules_in_pkg(stages)
@@ -25,7 +25,7 @@ stage_order = [
async def make_app(loop: asyncio.AbstractEventLoop) -> app.Application: async def make_app(loop: asyncio.AbstractEventLoop) -> app.Application:
# 确定是否为调试模式 # Determine if it is debug mode
if 'DEBUG' in os.environ and os.environ['DEBUG'] in ['true', '1']: if 'DEBUG' in os.environ and os.environ['DEBUG'] in ['true', '1']:
constants.debug_mode = True constants.debug_mode = True
@@ -33,7 +33,7 @@ async def make_app(loop: asyncio.AbstractEventLoop) -> app.Application:
ap.event_loop = loop ap.event_loop = loop
# 执行启动阶段 # Execute startup stage
for stage_name in stage_order: for stage_name in stage_order:
stage_cls = stage.preregistered_stages[stage_name] stage_cls = stage.preregistered_stages[stage_name]
stage_inst = stage_cls() stage_inst = stage_cls()
@@ -47,11 +47,11 @@ async def make_app(loop: asyncio.AbstractEventLoop) -> app.Application:
async def main(loop: asyncio.AbstractEventLoop): async def main(loop: asyncio.AbstractEventLoop):
try: try:
# 挂系统信号处理 # Hang system signal processing
import signal import signal
def signal_handler(sig, frame): def signal_handler(sig, frame):
print('[Signal] 程序退出.') print('[Signal] Program exit.')
# ap.shutdown() # ap.shutdown()
os._exit(0) os._exit(0)

View File

@@ -2,8 +2,8 @@ import pip
import os import os
from ...utils import pkgmgr from ...utils import pkgmgr
# 检查依赖,防止用户未安装 # Check dependencies to prevent users from not installing
# 左边为引入名称,右边为依赖名称 # Left is the import name, right is the dependency name
required_deps = { required_deps = {
'requests': 'requests', 'requests': 'requests',
'openai': 'openai', 'openai': 'openai',
@@ -65,7 +65,7 @@ async def install_deps(deps: list[str]):
async def precheck_plugin_deps(): async def precheck_plugin_deps():
print('[Startup] Prechecking plugin dependencies...') print('[Startup] Prechecking plugin dependencies...')
# 只有在plugins目录存在时才执行插件依赖安装 # Only execute plugin dependency installation when the plugins directory exists
if os.path.exists('plugins'): if os.path.exists('plugins'):
for dir in os.listdir('plugins'): for dir in os.listdir('plugins'):
subdir = os.path.join('plugins', dir) subdir = os.path.join('plugins', dir)

View File

@@ -17,7 +17,7 @@ log_colors_config = {
async def init_logging(extra_handlers: list[logging.Handler] = None) -> logging.Logger: async def init_logging(extra_handlers: list[logging.Handler] = None) -> logging.Logger:
# 删除所有现有的logger # Remove all existing loggers
for handler in logging.root.handlers[:]: for handler in logging.root.handlers[:]:
logging.root.removeHandler(handler) logging.root.removeHandler(handler)
@@ -54,13 +54,13 @@ async def init_logging(extra_handlers: list[logging.Handler] = None) -> logging.
handler.setFormatter(color_formatter) handler.setFormatter(color_formatter)
qcg_logger.addHandler(handler) qcg_logger.addHandler(handler)
qcg_logger.debug('日志初始化完成,日志级别:%s' % level) qcg_logger.debug('Logging initialized, log level: %s' % level)
logging.basicConfig( logging.basicConfig(
level=logging.CRITICAL, # 设置日志输出格式 level=logging.CRITICAL, # Set log output format
format='[DEPR][%(asctime)s.%(msecs)03d] %(pathname)s (%(lineno)d) - [%(levelname)s] :\n%(message)s', format='[DEPR][%(asctime)s.%(msecs)03d] %(pathname)s (%(lineno)d) - [%(levelname)s] :\n%(message)s',
# 日志输出的格式 # Log output format
# -8表示占位符让输出左对齐输出长度都为8位 # -8 is a placeholder, left-align the output, and output length is 8
datefmt='%Y-%m-%d %H:%M:%S', # 时间输出的格式 datefmt='%Y-%m-%d %H:%M:%S', # Time output format
handlers=[logging.NullHandler()], handlers=[logging.NullHandler()],
) )

View File

@@ -7,11 +7,11 @@ from . import app
preregistered_migrations: list[typing.Type[Migration]] = [] preregistered_migrations: list[typing.Type[Migration]] = []
"""当前阶段暂不支持扩展""" """Currently not supported for extension"""
def migration_class(name: str, number: int): def migration_class(name: str, number: int):
"""注册一个迁移""" """Register a migration"""
def decorator(cls: typing.Type[Migration]) -> typing.Type[Migration]: def decorator(cls: typing.Type[Migration]) -> typing.Type[Migration]:
cls.name = name cls.name = name
@@ -23,7 +23,7 @@ def migration_class(name: str, number: int):
class Migration(abc.ABC): class Migration(abc.ABC):
"""一个版本的迁移""" """A version migration"""
name: str name: str
@@ -36,10 +36,10 @@ class Migration(abc.ABC):
@abc.abstractmethod @abc.abstractmethod
async def need_migrate(self) -> bool: async def need_migrate(self) -> bool:
"""判断当前环境是否需要运行此迁移""" """Determine if the current environment needs to run this migration"""
pass pass
@abc.abstractmethod @abc.abstractmethod
async def run(self): async def run(self):
"""执行迁移""" """Run migration"""
pass pass

View File

@@ -9,7 +9,7 @@ preregistered_notes: list[typing.Type[LaunchNote]] = []
def note_class(name: str, number: int): def note_class(name: str, number: int):
"""注册一个启动信息""" """Register a launch information"""
def decorator(cls: typing.Type[LaunchNote]) -> typing.Type[LaunchNote]: def decorator(cls: typing.Type[LaunchNote]) -> typing.Type[LaunchNote]:
cls.name = name cls.name = name
@@ -21,7 +21,7 @@ def note_class(name: str, number: int):
class LaunchNote(abc.ABC): class LaunchNote(abc.ABC):
"""启动信息""" """Launch information"""
name: str name: str
@@ -34,10 +34,10 @@ class LaunchNote(abc.ABC):
@abc.abstractmethod @abc.abstractmethod
async def need_show(self) -> bool: async def need_show(self) -> bool:
"""判断当前环境是否需要显示此启动信息""" """Determine if the current environment needs to display this launch information"""
pass pass
@abc.abstractmethod @abc.abstractmethod
async def yield_note(self) -> typing.AsyncGenerator[typing.Tuple[str, int], None]: async def yield_note(self) -> typing.AsyncGenerator[typing.Tuple[str, int], None]:
"""生成启动信息""" """Generate launch information"""
pass pass

View File

@@ -7,7 +7,7 @@ from .. import note
@note.note_class('ClassicNotes', 1) @note.note_class('ClassicNotes', 1)
class ClassicNotes(note.LaunchNote): class ClassicNotes(note.LaunchNote):
"""经典启动信息""" """Classic launch information"""
async def need_show(self) -> bool: async def need_show(self) -> bool:
return True return True

View File

@@ -9,7 +9,7 @@ from .. import note
@note.note_class('SelectionModeOnWindows', 2) @note.note_class('SelectionModeOnWindows', 2)
class SelectionModeOnWindows(note.LaunchNote): class SelectionModeOnWindows(note.LaunchNote):
"""Windows 上的选择模式提示信息""" """Selection mode prompt information on Windows"""
async def need_show(self) -> bool: async def need_show(self) -> bool:
return os.name == 'nt' return os.name == 'nt'
@@ -19,3 +19,8 @@ class SelectionModeOnWindows(note.LaunchNote):
"""您正在使用 Windows 系统,若窗口左上角显示处于”选择“模式,程序将被暂停运行,此时请右键窗口中空白区域退出选择模式。""", """您正在使用 Windows 系统,若窗口左上角显示处于”选择“模式,程序将被暂停运行,此时请右键窗口中空白区域退出选择模式。""",
logging.INFO, logging.INFO,
) )
yield (
"""You are using Windows system, if the top left corner of the window displays "Selection" mode, the program will be paused running, please right-click on the blank area in the window to exit the selection mode.""",
logging.INFO,
)

View File

@@ -7,9 +7,9 @@ from . import app
preregistered_stages: dict[str, typing.Type[BootingStage]] = {} preregistered_stages: dict[str, typing.Type[BootingStage]] = {}
"""预注册的请求处理阶段。在初始化时,所有请求处理阶段类会被注册到此字典中。 """Pre-registered request processing stages. All request processing stage classes are registered in this dictionary during initialization.
当前阶段暂不支持扩展 Currently not supported for extension
""" """
@@ -22,11 +22,11 @@ def stage_class(name: str):
class BootingStage(abc.ABC): class BootingStage(abc.ABC):
"""启动阶段""" """Booting stage"""
name: str = None name: str = None
@abc.abstractmethod @abc.abstractmethod
async def run(self, ap: app.Application): async def run(self, ap: app.Application):
"""启动""" """Run"""
pass pass

View File

@@ -24,10 +24,10 @@ from .. import taskmgr
@stage.stage_class('BuildAppStage') @stage.stage_class('BuildAppStage')
class BuildAppStage(stage.BootingStage): class BuildAppStage(stage.BootingStage):
"""构建应用阶段""" """Build LangBot application"""
async def run(self, ap: app.Application): async def run(self, ap: app.Application):
"""构建app对象的各个组件对象并初始化""" """Build LangBot application"""
ap.task_mgr = taskmgr.AsyncTaskManager(ap) ap.task_mgr = taskmgr.AsyncTaskManager(ap)
discover = discover_engine.ComponentDiscoveryEngine(ap) discover = discover_engine.ComponentDiscoveryEngine(ap)
@@ -42,7 +42,7 @@ class BuildAppStage(stage.BootingStage):
await ver_mgr.initialize() await ver_mgr.initialize()
ap.ver_mgr = ver_mgr ap.ver_mgr = ver_mgr
# 发送公告 # Send announcement
ann_mgr = announce.AnnouncementManager(ap) ann_mgr = announce.AnnouncementManager(ap)
ap.ann_mgr = ann_mgr ap.ann_mgr = ann_mgr

View File

@@ -7,11 +7,18 @@ from .. import stage, app
@stage.stage_class('GenKeysStage') @stage.stage_class('GenKeysStage')
class GenKeysStage(stage.BootingStage): class GenKeysStage(stage.BootingStage):
"""生成密钥阶段""" """Generate keys stage"""
async def run(self, ap: app.Application): async def run(self, ap: app.Application):
"""启动""" """Generate keys"""
if not ap.instance_config.data['system']['jwt']['secret']: if not ap.instance_config.data['system']['jwt']['secret']:
ap.instance_config.data['system']['jwt']['secret'] = secrets.token_hex(16) ap.instance_config.data['system']['jwt']['secret'] = secrets.token_hex(16)
await ap.instance_config.dump_config() await ap.instance_config.dump_config()
if 'recovery_key' not in ap.instance_config.data['system']:
ap.instance_config.data['system']['recovery_key'] = ''
if not ap.instance_config.data['system']['recovery_key']:
ap.instance_config.data['system']['recovery_key'] = secrets.token_hex(3).upper()
await ap.instance_config.dump_config()

View File

@@ -8,10 +8,10 @@ from ..bootutils import config
@stage.stage_class('LoadConfigStage') @stage.stage_class('LoadConfigStage')
class LoadConfigStage(stage.BootingStage): class LoadConfigStage(stage.BootingStage):
"""加载配置文件阶段""" """Load config file stage"""
async def run(self, ap: app.Application): async def run(self, ap: app.Application):
"""启动""" """Load config file"""
# ======= deprecated ======= # ======= deprecated =======
if os.path.exists('data/config/command.json'): if os.path.exists('data/config/command.json'):

View File

@@ -11,10 +11,13 @@ importutil.import_modules_in_pkg(migrations)
@stage.stage_class('MigrationStage') @stage.stage_class('MigrationStage')
class MigrationStage(stage.BootingStage): class MigrationStage(stage.BootingStage):
"""迁移阶段""" """Migration stage
These migrations are legacy, only performed in version 3.x
"""
async def run(self, ap: app.Application): async def run(self, ap: app.Application):
"""启动""" """Run migration"""
if any( if any(
[ [
@@ -29,7 +32,7 @@ class MigrationStage(stage.BootingStage):
migrations = migration.preregistered_migrations migrations = migration.preregistered_migrations
# 按照迁移号排序 # Sort by migration number
migrations.sort(key=lambda x: x.number) migrations.sort(key=lambda x: x.number)
for migration_cls in migrations: for migration_cls in migrations:
@@ -37,4 +40,4 @@ class MigrationStage(stage.BootingStage):
if await migration_instance.need_migrate(): if await migration_instance.need_migrate():
await migration_instance.run() await migration_instance.run()
print(f'已执行迁移 {migration_instance.name}') print(f'Migration {migration_instance.name} executed')

View File

@@ -8,7 +8,7 @@ from ..bootutils import log
class PersistenceHandler(logging.Handler, object): class PersistenceHandler(logging.Handler, object):
""" """
保存日志到数据库 Save logs to database
""" """
ap: app.Application ap: app.Application
@@ -19,9 +19,9 @@ class PersistenceHandler(logging.Handler, object):
def emit(self, record): def emit(self, record):
""" """
emit函数为自定义handler类时必重写的函数这里可以根据需要对日志消息做一些处理比如发送日志到服务器 emit function is a required function for custom handler classes, here you can process the log messages as needed, such as sending logs to the server
发出记录(Emit a record) Emit a record
""" """
try: try:
msg = self.format(record) msg = self.format(record)
@@ -34,10 +34,10 @@ class PersistenceHandler(logging.Handler, object):
@stage.stage_class('SetupLoggerStage') @stage.stage_class('SetupLoggerStage')
class SetupLoggerStage(stage.BootingStage): class SetupLoggerStage(stage.BootingStage):
"""设置日志器阶段""" """Setup logger stage"""
async def run(self, ap: app.Application): async def run(self, ap: app.Application):
"""启动""" """Setup logger"""
persistence_handler = PersistenceHandler('LoggerHandler', ap) persistence_handler = PersistenceHandler('LoggerHandler', ap)
extra_handlers = [] extra_handlers = []

View File

@@ -1,5 +1,7 @@
from __future__ import annotations from __future__ import annotations
import asyncio
from .. import stage, app, note from .. import stage, app, note
from ...utils import importutil from ...utils import importutil
@@ -10,21 +12,25 @@ importutil.import_modules_in_pkg(notes)
@stage.stage_class('ShowNotesStage') @stage.stage_class('ShowNotesStage')
class ShowNotesStage(stage.BootingStage): class ShowNotesStage(stage.BootingStage):
"""显示启动信息阶段""" """Show notes stage"""
async def run(self, ap: app.Application): async def run(self, ap: app.Application):
# 排序 # Sort
note.preregistered_notes.sort(key=lambda x: x.number) note.preregistered_notes.sort(key=lambda x: x.number)
for note_cls in note.preregistered_notes: for note_cls in note.preregistered_notes:
try: try:
note_inst = note_cls(ap) note_inst = note_cls(ap)
if await note_inst.need_show(): if await note_inst.need_show():
async for ret in note_inst.yield_note():
if not ret: async def ayield_note(note_inst: note.LaunchNote):
continue async for ret in note_inst.yield_note():
msg, level = ret if not ret:
if msg: continue
ap.logger.log(level, msg) msg, level = ret
if msg:
ap.logger.log(level, msg)
asyncio.create_task(ayield_note(note_inst))
except Exception: except Exception:
continue continue

View File

@@ -9,13 +9,13 @@ from . import entities as core_entities
class TaskContext: class TaskContext:
"""任务跟踪上下文""" """Task tracking context"""
current_action: str current_action: str
"""当前正在执行的动作""" """Current action being executed"""
log: str log: str
"""记录日志""" """Log"""
def __init__(self): def __init__(self):
self.current_action = 'default' self.current_action = 'default'
@@ -58,40 +58,40 @@ placeholder_context: TaskContext | None = None
class TaskWrapper: class TaskWrapper:
"""任务包装器""" """Task wrapper"""
_id_index: int = 0 _id_index: int = 0
"""任务ID索引""" """Task ID index"""
id: int id: int
"""任务ID""" """Task ID"""
task_type: str = 'system' # 任务类型: system user task_type: str = 'system' # Task type: system or user
"""任务类型""" """Task type"""
kind: str = 'system_task' # 由发起者确定任务种类,通常同质化的任务种类相同 kind: str = 'system_task' # Task type determined by the initiator, usually the same task type
"""任务种类""" """Task type"""
name: str = '' name: str = ''
"""任务唯一名称""" """Task unique name"""
label: str = '' label: str = ''
"""任务显示名称""" """Task display name"""
task_context: TaskContext task_context: TaskContext
"""任务上下文""" """Task context"""
task: asyncio.Task task: asyncio.Task
"""任务""" """Task"""
task_stack: list = None task_stack: list = None
"""任务堆栈""" """Task stack"""
ap: app.Application ap: app.Application
"""应用实例""" """Application instance"""
scopes: list[core_entities.LifecycleControlScope] scopes: list[core_entities.LifecycleControlScope]
"""任务所属生命周期控制范围""" """Task scope"""
def __init__( def __init__(
self, self,
@@ -165,13 +165,13 @@ class TaskWrapper:
class AsyncTaskManager: class AsyncTaskManager:
"""保存app中的所有异步任务 """Save all asynchronous tasks in the app
包含系统级的和用户级(插件安装、更新等由用户直接发起的)的""" Include system-level and user-level (plugin installation, update, etc. initiated by users directly)"""
ap: app.Application ap: app.Application
tasks: list[TaskWrapper] tasks: list[TaskWrapper]
"""所有任务""" """All tasks"""
def __init__(self, ap: app.Application): def __init__(self, ap: app.Application):
self.ap = ap self.ap = ap

View File

@@ -0,0 +1,9 @@
from __future__ import annotations
class AdapterNotFoundError(Exception):
def __init__(self, adapter_name: str):
self.adapter_name = adapter_name
def __str__(self):
return f'Adapter {self.adapter_name} not found'

View File

@@ -0,0 +1,9 @@
from __future__ import annotations
class RequesterNotFoundError(Exception):
def __init__(self, requester_name: str):
self.requester_name = requester_name
def __str__(self):
return f'Requester {self.requester_name} not found'

View File

@@ -4,7 +4,7 @@ from .base import Base
class Bot(Base): class Bot(Base):
"""机器人""" """Bot"""
__tablename__ = 'bots' __tablename__ = 'bots'

View File

@@ -12,7 +12,7 @@ initial_metadata = [
class Metadata(Base): class Metadata(Base):
"""数据库元数据""" """Database metadata"""
__tablename__ = 'metadata' __tablename__ = 'metadata'

View File

@@ -4,7 +4,7 @@ from .base import Base
class LLMModel(Base): class LLMModel(Base):
"""LLM 模型""" """LLM model"""
__tablename__ = 'llm_models' __tablename__ = 'llm_models'

View File

@@ -4,7 +4,7 @@ from .base import Base
class LegacyPipeline(Base): class LegacyPipeline(Base):
"""旧版流水线""" """Legacy pipeline"""
__tablename__ = 'legacy_pipelines' __tablename__ = 'legacy_pipelines'
@@ -26,7 +26,7 @@ class LegacyPipeline(Base):
class PipelineRunRecord(Base): class PipelineRunRecord(Base):
"""流水线运行记录""" """Pipeline run record"""
__tablename__ = 'pipeline_run_records' __tablename__ = 'pipeline_run_records'

View File

@@ -4,7 +4,7 @@ from .base import Base
class PluginSetting(Base): class PluginSetting(Base):
"""插件配置""" """Plugin setting"""
__tablename__ = 'plugin_settings' __tablename__ = 'plugin_settings'

View File

@@ -11,7 +11,7 @@ preregistered_managers: list[type[BaseDatabaseManager]] = []
def manager_class(name: str) -> None: def manager_class(name: str) -> None:
"""注册一个数据库管理类""" """Register a database manager class"""
def decorator(cls: type[BaseDatabaseManager]) -> type[BaseDatabaseManager]: def decorator(cls: type[BaseDatabaseManager]) -> type[BaseDatabaseManager]:
cls.name = name cls.name = name
@@ -22,7 +22,7 @@ def manager_class(name: str) -> None:
class BaseDatabaseManager(abc.ABC): class BaseDatabaseManager(abc.ABC):
"""基础数据库管理类""" """Base database manager class"""
name: str name: str

View File

@@ -7,7 +7,7 @@ from .. import database
@database.manager_class('sqlite') @database.manager_class('sqlite')
class SQLiteDatabaseManager(database.BaseDatabaseManager): class SQLiteDatabaseManager(database.BaseDatabaseManager):
"""SQLite 数据库管理类""" """SQLite database manager"""
async def initialize(self) -> None: async def initialize(self) -> None:
sqlite_path = 'data/langbot.db' sqlite_path = 'data/langbot.db'

View File

@@ -22,12 +22,12 @@ importutil.import_modules_in_pkg(persistence)
class PersistenceManager: class PersistenceManager:
"""持久化模块管理器""" """Persistence module manager"""
ap: app.Application ap: app.Application
db: database.BaseDatabaseManager db: database.BaseDatabaseManager
"""数据库管理器""" """Database manager"""
meta: sqlalchemy.MetaData meta: sqlalchemy.MetaData
@@ -79,7 +79,7 @@ class PersistenceManager:
'stages': pipeline_service.default_stage_order, 'stages': pipeline_service.default_stage_order,
'is_default': True, 'is_default': True,
'name': 'ChatPipeline', 'name': 'ChatPipeline',
'description': '默认提供的流水线,您配置的机器人、第一个模型将自动绑定到此流水线', 'description': 'Default pipeline provided, your new bots will be automatically bound to this pipeline | 默认提供的流水线,您配置的机器人将自动绑定到此流水线',
'config': pipeline_config, 'config': pipeline_config,
} }

View File

@@ -10,7 +10,7 @@ preregistered_db_migrations: list[typing.Type[DBMigration]] = []
def migration_class(number: int): def migration_class(number: int):
"""迁移类装饰器""" """Migration class decorator"""
def wrapper(cls: typing.Type[DBMigration]) -> typing.Type[DBMigration]: def wrapper(cls: typing.Type[DBMigration]) -> typing.Type[DBMigration]:
cls.number = number cls.number = number
@@ -21,20 +21,20 @@ def migration_class(number: int):
class DBMigration(abc.ABC): class DBMigration(abc.ABC):
"""数据库迁移""" """Database migration"""
number: int number: int
"""迁移号""" """Migration number"""
def __init__(self, ap: app.Application): def __init__(self, ap: app.Application):
self.ap = ap self.ap = ap
@abc.abstractmethod @abc.abstractmethod
async def upgrade(self): async def upgrade(self):
"""升级""" """Upgrade"""
pass pass
@abc.abstractmethod @abc.abstractmethod
async def downgrade(self): async def downgrade(self):
"""降级""" """Downgrade"""
pass pass

View File

@@ -15,21 +15,21 @@ from ...entity.persistence import (
@migration.migration_class(1) @migration.migration_class(1)
class DBMigrateV3Config(migration.DBMigration): class DBMigrateV3Config(migration.DBMigration):
"""从 v3 的配置迁移到 v4 的数据库""" """Migrate v3 config to v4 database"""
async def upgrade(self): async def upgrade(self):
"""升级""" """Upgrade"""
""" """
将 data/config 下的所有配置文件进行迁移。 Migrate all config files under data/config.
迁移后,之前的配置文件都保存到 data/legacy/config 下。 After migration, all previous config files are saved under data/legacy/config.
迁移后data/metadata/ 下的所有配置文件都保存到 data/legacy/metadata 下。 After migration, all config files under data/metadata/ are saved under data/legacy/metadata.
""" """
if self.ap.provider_cfg is None: if self.ap.provider_cfg is None:
return return
# ======= 迁移模型 ======= # ======= Migrate model =======
# 只迁移当前选中的模型 # Only migrate the currently selected model
model_name = self.ap.provider_cfg.data.get('model', 'gpt-4o') model_name = self.ap.provider_cfg.data.get('model', 'gpt-4o')
model_requester = 'openai-chat-completions' model_requester = 'openai-chat-completions'
@@ -91,8 +91,8 @@ class DBMigrateV3Config(migration.DBMigration):
sqlalchemy.insert(persistence_model.LLMModel).values(**llm_model_data) sqlalchemy.insert(persistence_model.LLMModel).values(**llm_model_data)
) )
# ======= 迁移流水线配置 ======= # ======= Migrate pipeline config =======
# 修改到默认流水线 # Modify to default pipeline
default_pipeline = [ default_pipeline = [
self.ap.persistence_mgr.serialize_model(persistence_pipeline.LegacyPipeline, pipeline) self.ap.persistence_mgr.serialize_model(persistence_pipeline.LegacyPipeline, pipeline)
for pipeline in ( for pipeline in (
@@ -184,8 +184,8 @@ class DBMigrateV3Config(migration.DBMigration):
.where(persistence_pipeline.LegacyPipeline.uuid == default_pipeline['uuid']) .where(persistence_pipeline.LegacyPipeline.uuid == default_pipeline['uuid'])
) )
# ======= 迁移机器人 ======= # ======= Migrate bot =======
# 只迁移启用的机器人 # Only migrate enabled bots
for adapter in self.ap.platform_cfg.data.get('platform-adapters', []): for adapter in self.ap.platform_cfg.data.get('platform-adapters', []):
if not adapter.get('enable'): if not adapter.get('enable'):
continue continue
@@ -207,7 +207,7 @@ class DBMigrateV3Config(migration.DBMigration):
await self.ap.persistence_mgr.execute_async(sqlalchemy.insert(persistence_bot.Bot).values(**bot_data)) await self.ap.persistence_mgr.execute_async(sqlalchemy.insert(persistence_bot.Bot).values(**bot_data))
# ======= 迁移系统设置 ======= # ======= Migrate system settings =======
self.ap.instance_config.data['admins'] = self.ap.system_cfg.data['admin-sessions'] self.ap.instance_config.data['admins'] = self.ap.system_cfg.data['admin-sessions']
self.ap.instance_config.data['api']['port'] = self.ap.system_cfg.data['http-api']['port'] self.ap.instance_config.data['api']['port'] = self.ap.system_cfg.data['http-api']['port']
self.ap.instance_config.data['command'] = { self.ap.instance_config.data['command'] = {
@@ -223,7 +223,7 @@ class DBMigrateV3Config(migration.DBMigration):
await self.ap.instance_config.dump_config() await self.ap.instance_config.dump_config()
# ======= move files ======= # ======= move files =======
# 迁移 data/config 下的所有配置文件 # Migrate all config files under data/config
all_legacy_dir_name = [ all_legacy_dir_name = [
'config', 'config',
# 'metadata', # 'metadata',
@@ -246,4 +246,4 @@ class DBMigrateV3Config(migration.DBMigration):
move_legacy_files(dir_name) move_legacy_files(dir_name)
async def downgrade(self): async def downgrade(self):
"""降级""" """Downgrade"""

View File

@@ -7,10 +7,10 @@ from ...entity.persistence import pipeline as persistence_pipeline
@migration.migration_class(2) @migration.migration_class(2)
class DBMigrateCombineQuoteMsgConfig(migration.DBMigration): class DBMigrateCombineQuoteMsgConfig(migration.DBMigration):
"""引用消息合并配置""" """Combine quote message config"""
async def upgrade(self): async def upgrade(self):
"""升级""" """Upgrade"""
# read all pipelines # read all pipelines
pipelines = await self.ap.persistence_mgr.execute_async(sqlalchemy.select(persistence_pipeline.LegacyPipeline)) pipelines = await self.ap.persistence_mgr.execute_async(sqlalchemy.select(persistence_pipeline.LegacyPipeline))
@@ -37,5 +37,5 @@ class DBMigrateCombineQuoteMsgConfig(migration.DBMigration):
) )
async def downgrade(self): async def downgrade(self):
"""降级""" """Downgrade"""
pass pass

View File

@@ -7,10 +7,10 @@ from ...entity.persistence import pipeline as persistence_pipeline
@migration.migration_class(3) @migration.migration_class(3)
class DBMigrateN8nConfig(migration.DBMigration): class DBMigrateN8nConfig(migration.DBMigration):
"""N8n配置""" """N8n config"""
async def upgrade(self): async def upgrade(self):
"""升级""" """Upgrade"""
# read all pipelines # read all pipelines
pipelines = await self.ap.persistence_mgr.execute_async(sqlalchemy.select(persistence_pipeline.LegacyPipeline)) pipelines = await self.ap.persistence_mgr.execute_async(sqlalchemy.select(persistence_pipeline.LegacyPipeline))
@@ -45,5 +45,5 @@ class DBMigrateN8nConfig(migration.DBMigration):
) )
async def downgrade(self): async def downgrade(self):
"""降级""" """Downgrade"""
pass pass

View File

@@ -6,9 +6,9 @@ from ...core import entities as core_entities
@stage.stage_class('BanSessionCheckStage') @stage.stage_class('BanSessionCheckStage')
class BanSessionCheckStage(stage.PipelineStage): class BanSessionCheckStage(stage.PipelineStage):
"""访问控制处理阶段 """Access control processing stage
仅检查query中群号或个人号是否在访问控制列表中。 Only check if the group or personal number in the query is in the access control list.
""" """
async def initialize(self, pipeline_config: dict): async def initialize(self, pipeline_config: dict):
@@ -41,5 +41,7 @@ class BanSessionCheckStage(stage.PipelineStage):
return entities.StageProcessResult( return entities.StageProcessResult(
result_type=entities.ResultType.CONTINUE if ctn else entities.ResultType.INTERRUPT, result_type=entities.ResultType.CONTINUE if ctn else entities.ResultType.INTERRUPT,
new_query=query, new_query=query,
console_notice=f'根据访问控制忽略消息: {query.launcher_type.value}_{query.launcher_id}' if not ctn else '', console_notice=f'Ignore message according to access control: {query.launcher_type.value}_{query.launcher_id}'
if not ctn
else '',
) )

View File

@@ -13,13 +13,13 @@ preregistered_filters: list[typing.Type[ContentFilter]] = []
def filter_class( def filter_class(
name: str, name: str,
) -> typing.Callable[[typing.Type[ContentFilter]], typing.Type[ContentFilter]]: ) -> typing.Callable[[typing.Type[ContentFilter]], typing.Type[ContentFilter]]:
"""内容过滤器类装饰器 """Content filter class decorator
Args: Args:
name (str): 过滤器名称 name (str): Filter name
Returns: Returns:
typing.Callable[[typing.Type[ContentFilter]], typing.Type[ContentFilter]]: 装饰器 typing.Callable[[typing.Type[ContentFilter]], typing.Type[ContentFilter]]: Decorator
""" """
def decorator(cls: typing.Type[ContentFilter]) -> typing.Type[ContentFilter]: def decorator(cls: typing.Type[ContentFilter]) -> typing.Type[ContentFilter]:
@@ -35,7 +35,7 @@ def filter_class(
class ContentFilter(metaclass=abc.ABCMeta): class ContentFilter(metaclass=abc.ABCMeta):
"""内容过滤器抽象类""" """Content filter abstract class"""
name: str name: str
@@ -46,31 +46,31 @@ class ContentFilter(metaclass=abc.ABCMeta):
@property @property
def enable_stages(self): def enable_stages(self):
"""启用的阶段 """Enabled stages
默认为消息请求AI前后的两个阶段。 Default is the two stages before and after the message request to AI.
entity.EnableStage.PRE: 消息请求AI前此时需要检查的内容是用户的输入消息。 entity.EnableStage.PRE: Before message request to AI, the content to check is the user's input message.
entity.EnableStage.POST: 消息请求AI后此时需要检查的内容是AI的回复消息。 entity.EnableStage.POST: After message request to AI, the content to check is the AI's reply message.
""" """
return [entities.EnableStage.PRE, entities.EnableStage.POST] return [entities.EnableStage.PRE, entities.EnableStage.POST]
async def initialize(self): async def initialize(self):
"""初始化过滤器""" """Initialize filter"""
pass pass
@abc.abstractmethod @abc.abstractmethod
async def process(self, query: core_entities.Query, message: str = None, image_url=None) -> entities.FilterResult: async def process(self, query: core_entities.Query, message: str = None, image_url=None) -> entities.FilterResult:
"""处理消息 """Process message
分为前后阶段,具体取决于 enable_stages 的值。 It is divided into two stages, depending on the value of enable_stages.
对于内容过滤器来说,不需要考虑消息所处的阶段,只需要检查消息内容即可。 For content filters, you do not need to consider the stage of the message, you only need to check the message content.
Args: Args:
message (str): 需要检查的内容 message (str): Content to check
image_url (str): 要检查的图片的 URL image_url (str): URL of the image to check
Returns: Returns:
entities.FilterResult: 过滤结果,具体内容请查看 entities.FilterResult 类的文档 entities.FilterResult: Filter result, please refer to the documentation of entities.FilterResult class
""" """
raise NotImplementedError raise NotImplementedError

View File

@@ -8,7 +8,7 @@ from ....core import entities as core_entities
@filter_model.filter_class('ban-word-filter') @filter_model.filter_class('ban-word-filter')
class BanWordFilter(filter_model.ContentFilter): class BanWordFilter(filter_model.ContentFilter):
"""根据内容过滤""" """Filter content"""
async def initialize(self): async def initialize(self):
pass pass

View File

@@ -8,7 +8,7 @@ from ....core import entities as core_entities
@filter_model.filter_class('content-ignore') @filter_model.filter_class('content-ignore')
class ContentIgnore(filter_model.ContentFilter): class ContentIgnore(filter_model.ContentFilter):
"""根据内容忽略消息""" """Ignore message according to content"""
@property @property
def enable_stages(self): def enable_stages(self):
@@ -24,7 +24,7 @@ class ContentIgnore(filter_model.ContentFilter):
level=entities.ResultLevel.BLOCK, level=entities.ResultLevel.BLOCK,
replacement='', replacement='',
user_notice='', user_notice='',
console_notice='根据 ignore_rules 中的 prefix 规则,忽略消息', console_notice='Ignore message according to prefix rule in ignore_rules',
) )
if 'regexp' in query.pipeline_config['trigger']['ignore-rules']: if 'regexp' in query.pipeline_config['trigger']['ignore-rules']:
@@ -34,7 +34,7 @@ class ContentIgnore(filter_model.ContentFilter):
level=entities.ResultLevel.BLOCK, level=entities.ResultLevel.BLOCK,
replacement='', replacement='',
user_notice='', user_notice='',
console_notice='根据 ignore_rules 中的 regexp 规则,忽略消息', console_notice='Ignore message according to regexp rule in ignore_rules',
) )
return entities.FilterResult( return entities.FilterResult(

View File

@@ -16,9 +16,9 @@ importutil.import_modules_in_pkg(strategies)
@stage.stage_class('LongTextProcessStage') @stage.stage_class('LongTextProcessStage')
class LongTextProcessStage(stage.PipelineStage): class LongTextProcessStage(stage.PipelineStage):
"""长消息处理阶段 """Long message processing stage
改写: Rewrite:
- resp_message_chain - resp_message_chain
""" """
@@ -36,22 +36,22 @@ class LongTextProcessStage(stage.PipelineStage):
use_font = 'C:/Windows/Fonts/msyh.ttc' use_font = 'C:/Windows/Fonts/msyh.ttc'
if not os.path.exists(use_font): if not os.path.exists(use_font):
self.ap.logger.warn( self.ap.logger.warn(
'未找到字体文件且无法使用Windows自带字体更换为转发消息组件以发送长消息您可以在配置文件中调整相关设置。' 'Font file not found, and Windows system font cannot be used, switch to forward message component to send long messages, you can adjust the related settings in the configuration file.'
) )
config['blob_message_strategy'] = 'forward' config['blob_message_strategy'] = 'forward'
else: else:
self.ap.logger.info('使用Windows自带字体:' + use_font) self.ap.logger.info('Using Windows system font: ' + use_font)
config['font-path'] = use_font config['font-path'] = use_font
else: else:
self.ap.logger.warn( self.ap.logger.warn(
'未找到字体文件,且无法使用系统自带字体,更换为转发消息组件以发送长消息,您可以在配置文件中调整相关设置。' 'Font file not found, and system font cannot be used, switch to forward message component to send long messages, you can adjust the related settings in the configuration file.'
) )
pipeline_config['output']['long-text-processing']['strategy'] = 'forward' pipeline_config['output']['long-text-processing']['strategy'] = 'forward'
except Exception: except Exception:
traceback.print_exc() traceback.print_exc()
self.ap.logger.error( self.ap.logger.error(
'加载字体文件失败({}),更换为转发消息组件以发送长消息,您可以在配置文件中调整相关设置。'.format( 'Failed to load font file ({}), switch to forward message component to send long messages, you can adjust the related settings in the configuration file.'.format(
use_font use_font
) )
) )
@@ -63,12 +63,12 @@ class LongTextProcessStage(stage.PipelineStage):
self.strategy_impl = strategy_cls(self.ap) self.strategy_impl = strategy_cls(self.ap)
break break
else: else:
raise ValueError(f'未找到名为 {config["strategy"]} 的长消息处理策略') raise ValueError(f'Long message processing strategy not found: {config["strategy"]}')
await self.strategy_impl.initialize() await self.strategy_impl.initialize()
async def process(self, query: core_entities.Query, stage_inst_name: str) -> entities.StageProcessResult: async def process(self, query: core_entities.Query, stage_inst_name: str) -> entities.StageProcessResult:
# 检查是否包含非 Plain 组件 # Check if it contains non-Plain components
contains_non_plain = False contains_non_plain = False
for msg in query.resp_message_chain[-1]: for msg in query.resp_message_chain[-1]:
@@ -77,7 +77,7 @@ class LongTextProcessStage(stage.PipelineStage):
break break
if contains_non_plain: if contains_non_plain:
self.ap.logger.debug('消息中包含非 Plain 组件,跳过长消息处理。') self.ap.logger.debug('Message contains non-Plain components, skip long message processing.')
elif ( elif (
len(str(query.resp_message_chain[-1])) len(str(query.resp_message_chain[-1]))
> query.pipeline_config['output']['long-text-processing']['threshold'] > query.pipeline_config['output']['long-text-processing']['threshold']

View File

@@ -15,17 +15,17 @@ Forward = platform_message.Forward
class ForwardComponentStrategy(strategy_model.LongTextStrategy): class ForwardComponentStrategy(strategy_model.LongTextStrategy):
async def process(self, message: str, query: core_entities.Query) -> list[platform_message.MessageComponent]: async def process(self, message: str, query: core_entities.Query) -> list[platform_message.MessageComponent]:
display = ForwardMessageDiaplay( display = ForwardMessageDiaplay(
title='群聊的聊天记录', title='Group chat history',
brief='[聊天记录]', brief='[Chat history]',
source='聊天记录', source='Chat history',
preview=['QQ用户: ' + message], preview=['User: ' + message],
summary='查看1条转发消息', summary='View 1 forwarded message',
) )
node_list = [ node_list = [
platform_message.ForwardMessageNode( platform_message.ForwardMessageNode(
sender_id=query.adapter.bot_account_id, sender_id=query.adapter.bot_account_id,
sender_name='QQ用户', sender_name='User',
message_chain=platform_message.MessageChain([message]), message_chain=platform_message.MessageChain([message]),
) )
] ]

View File

@@ -14,13 +14,13 @@ preregistered_strategies: list[typing.Type[LongTextStrategy]] = []
def strategy_class( def strategy_class(
name: str, name: str,
) -> typing.Callable[[typing.Type[LongTextStrategy]], typing.Type[LongTextStrategy]]: ) -> typing.Callable[[typing.Type[LongTextStrategy]], typing.Type[LongTextStrategy]]:
"""长文本处理策略类装饰器 """Long text processing strategy class decorator
Args: Args:
name (str): 策略名称 name (str): Strategy name
Returns: Returns:
typing.Callable[[typing.Type[LongTextStrategy]], typing.Type[LongTextStrategy]]: 装饰器 typing.Callable[[typing.Type[LongTextStrategy]], typing.Type[LongTextStrategy]]: Decorator
""" """
def decorator(cls: typing.Type[LongTextStrategy]) -> typing.Type[LongTextStrategy]: def decorator(cls: typing.Type[LongTextStrategy]) -> typing.Type[LongTextStrategy]:
@@ -36,7 +36,7 @@ def strategy_class(
class LongTextStrategy(metaclass=abc.ABCMeta): class LongTextStrategy(metaclass=abc.ABCMeta):
"""长文本处理策略抽象类""" """Long text processing strategy abstract class"""
name: str name: str
@@ -50,15 +50,15 @@ class LongTextStrategy(metaclass=abc.ABCMeta):
@abc.abstractmethod @abc.abstractmethod
async def process(self, message: str, query: core_entities.Query) -> list[platform_message.MessageComponent]: async def process(self, message: str, query: core_entities.Query) -> list[platform_message.MessageComponent]:
"""处理长文本 """Process long text
在 platform.json 中配置 long-text-process 字段,只要 文本长度超过了 threshold 就会调用此方法 If the text length exceeds the threshold, this method will be called.
Args: Args:
message (str): 消息 message (str): Message
query (core_entities.Query): 此次请求的上下文对象 query (core_entities.Query): Query object
Returns: Returns:
list[platform_message.MessageComponent]: 转换后的 平台 消息组件列表 list[platform_message.MessageComponent]: Converted platform message components
""" """
return [] return []

View File

@@ -12,9 +12,9 @@ importutil.import_modules_in_pkg(truncators)
@stage.stage_class('ConversationMessageTruncator') @stage.stage_class('ConversationMessageTruncator')
class ConversationMessageTruncator(stage.PipelineStage): class ConversationMessageTruncator(stage.PipelineStage):
"""会话消息截断器 """Conversation message truncator
用于截断会话消息链,以适应平台消息长度限制。 Used to truncate the conversation message chain to adapt to the LLM message length limit.
""" """
trun: truncator.Truncator trun: truncator.Truncator
@@ -27,10 +27,10 @@ class ConversationMessageTruncator(stage.PipelineStage):
self.trun = trun(self.ap) self.trun = trun(self.ap)
break break
else: else:
raise ValueError(f'未知的截断器: {use_method}') raise ValueError(f'Unknown truncator: {use_method}')
async def process(self, query: core_entities.Query, stage_inst_name: str) -> entities.StageProcessResult: async def process(self, query: core_entities.Query, stage_inst_name: str) -> entities.StageProcessResult:
"""处理""" """Process"""
query = await self.trun.truncate(query) query = await self.trun.truncate(query)
return entities.StageProcessResult(result_type=entities.ResultType.CONTINUE, new_query=query) return entities.StageProcessResult(result_type=entities.ResultType.CONTINUE, new_query=query)

View File

@@ -6,17 +6,17 @@ from ....core import entities as core_entities
@truncator.truncator_class('round') @truncator.truncator_class('round')
class RoundTruncator(truncator.Truncator): class RoundTruncator(truncator.Truncator):
"""前文回合数阶段器""" """Truncate the conversation message chain to adapt to the LLM message length limit."""
async def truncate(self, query: core_entities.Query) -> core_entities.Query: async def truncate(self, query: core_entities.Query) -> core_entities.Query:
"""截断""" """Truncate"""
max_round = query.pipeline_config['ai']['local-agent']['max-round'] max_round = query.pipeline_config['ai']['local-agent']['max-round']
temp_messages = [] temp_messages = []
current_round = 0 current_round = 0
# 从后往前遍历 # Traverse from back to front
for msg in query.messages[::-1]: for msg in query.messages[::-1]:
if current_round < max_round: if current_round < max_round:
temp_messages.append(msg) temp_messages.append(msg)

View File

@@ -11,11 +11,11 @@ from ...platform.types import message as platform_message
@stage.stage_class('PreProcessor') @stage.stage_class('PreProcessor')
class PreProcessor(stage.PipelineStage): class PreProcessor(stage.PipelineStage):
"""请求预处理阶段 """Request pre-processing stage
签出会话、prompt、上文、模型、内容函数。 Check out session, prompt, context, model, and content functions.
改写: Rewrite:
- session - session
- prompt - prompt
- messages - messages
@@ -29,12 +29,12 @@ class PreProcessor(stage.PipelineStage):
query: core_entities.Query, query: core_entities.Query,
stage_inst_name: str, stage_inst_name: str,
) -> entities.StageProcessResult: ) -> entities.StageProcessResult:
"""处理""" """Process"""
selected_runner = query.pipeline_config['ai']['runner']['runner'] selected_runner = query.pipeline_config['ai']['runner']['runner']
session = await self.ap.sess_mgr.get_session(query) session = await self.ap.sess_mgr.get_session(query)
# local-agent 时,llm_model None # When not local-agent, llm_model is None
llm_model = ( llm_model = (
await self.ap.model_mgr.get_model_by_uuid(query.pipeline_config['ai']['local-agent']['model']) await self.ap.model_mgr.get_model_by_uuid(query.pipeline_config['ai']['local-agent']['model'])
if selected_runner == 'local-agent' if selected_runner == 'local-agent'
@@ -51,7 +51,7 @@ class PreProcessor(stage.PipelineStage):
conversation.use_llm_model = llm_model conversation.use_llm_model = llm_model
# 设置query # Set query
query.session = session query.session = session
query.prompt = conversation.prompt.copy() query.prompt = conversation.prompt.copy()
query.messages = conversation.messages.copy() query.messages = conversation.messages.copy()
@@ -109,7 +109,7 @@ class PreProcessor(stage.PipelineStage):
query.variables['user_message_text'] = plain_text query.variables['user_message_text'] = plain_text
query.user_message = llm_entities.Message(role='user', content=content_list) query.user_message = llm_entities.Message(role='user', content=content_list)
# =========== 触发事件 PromptPreProcessing # =========== Trigger event PromptPreProcessing
event_ctx = await self.ap.plugin_mgr.emit_event( event_ctx = await self.ap.plugin_mgr.emit_event(
event=events.PromptPreProcessing( event=events.PromptPreProcessing(

View File

@@ -25,7 +25,7 @@ class MessageHandler(metaclass=abc.ABCMeta):
def cut_str(self, s: str) -> str: def cut_str(self, s: str) -> str:
""" """
取字符串第一行最多20个字符若有多行或超过20个字符则加省略号 Take the first line of the string, up to 20 characters, if there are multiple lines, or more than 20 characters, add an ellipsis
""" """
s0 = s.split('\n')[0] s0 = s.split('\n')[0]
if len(s0) > 20 or '\n' in s: if len(s0) > 20 or '\n' in s:

View File

@@ -22,11 +22,11 @@ class ChatMessageHandler(handler.MessageHandler):
self, self,
query: core_entities.Query, query: core_entities.Query,
) -> typing.AsyncGenerator[entities.StageProcessResult, None]: ) -> typing.AsyncGenerator[entities.StageProcessResult, None]:
"""处理""" """Process"""
# API # Call API
# 生成器 # generator
# 触发插件事件 # Trigger plugin event
event_class = ( event_class = (
events.PersonNormalMessageReceived events.PersonNormalMessageReceived
if query.launcher_type == core_entities.LauncherTypes.PERSON if query.launcher_type == core_entities.LauncherTypes.PERSON
@@ -54,7 +54,7 @@ class ChatMessageHandler(handler.MessageHandler):
yield entities.StageProcessResult(result_type=entities.ResultType.INTERRUPT, new_query=query) yield entities.StageProcessResult(result_type=entities.ResultType.INTERRUPT, new_query=query)
else: else:
if event_ctx.event.alter is not None: if event_ctx.event.alter is not None:
# if isinstance(event_ctx.event, str): # 现在暂时不考虑多模态alter # if isinstance(event_ctx.event, str): # Currently not considering multi-modal alter
query.user_message.content = event_ctx.event.alter query.user_message.content = event_ctx.event.alter
text_length = 0 text_length = 0
@@ -65,12 +65,12 @@ class ChatMessageHandler(handler.MessageHandler):
runner = r(self.ap, query.pipeline_config) runner = r(self.ap, query.pipeline_config)
break break
else: else:
raise ValueError(f'未找到请求运行器: {query.pipeline_config["ai"]["runner"]["runner"]}') raise ValueError(f'Request runner not found: {query.pipeline_config["ai"]["runner"]["runner"]}')
async for result in runner.run(query): async for result in runner.run(query):
query.resp_messages.append(result) query.resp_messages.append(result)
self.ap.logger.info(f'对话({query.query_id})响应: {self.cut_str(result.readable_str())}') self.ap.logger.info(f'Response({query.query_id}): {self.cut_str(result.readable_str())}')
if result.content is not None: if result.content is not None:
text_length += len(result.content) text_length += len(result.content)
@@ -80,7 +80,7 @@ class ChatMessageHandler(handler.MessageHandler):
query.session.using_conversation.messages.append(query.user_message) query.session.using_conversation.messages.append(query.user_message)
query.session.using_conversation.messages.extend(query.resp_messages) query.session.using_conversation.messages.extend(query.resp_messages)
except Exception as e: except Exception as e:
self.ap.logger.error(f'对话({query.query_id})请求失败: {type(e).__name__} {str(e)}') self.ap.logger.error(f'Request failed({query.query_id}): {type(e).__name__} {str(e)}')
hide_exception_info = query.pipeline_config['output']['misc']['hide-exception'] hide_exception_info = query.pipeline_config['output']['misc']['hide-exception']

View File

@@ -15,7 +15,7 @@ class CommandHandler(handler.MessageHandler):
self, self,
query: core_entities.Query, query: core_entities.Query,
) -> typing.AsyncGenerator[entities.StageProcessResult, None]: ) -> typing.AsyncGenerator[entities.StageProcessResult, None]:
"""处理""" """Process"""
command_text = str(query.message_chain).strip()[1:] command_text = str(query.message_chain).strip()[1:]
@@ -70,7 +70,7 @@ class CommandHandler(handler.MessageHandler):
) )
) )
self.ap.logger.info(f'命令({query.query_id})报错: {self.cut_str(str(ret.error))}') self.ap.logger.info(f'Command({query.query_id}) error: {self.cut_str(str(ret.error))}')
yield entities.StageProcessResult(result_type=entities.ResultType.CONTINUE, new_query=query) yield entities.StageProcessResult(result_type=entities.ResultType.CONTINUE, new_query=query)
elif ret.text is not None or ret.image_url is not None: elif ret.text is not None or ret.image_url is not None:
@@ -89,7 +89,7 @@ class CommandHandler(handler.MessageHandler):
) )
) )
self.ap.logger.info(f'命令返回: {self.cut_str(str(content[0]))}') self.ap.logger.info(f'Command returned: {self.cut_str(str(content[0]))}')
yield entities.StageProcessResult(result_type=entities.ResultType.CONTINUE, new_query=query) yield entities.StageProcessResult(result_type=entities.ResultType.CONTINUE, new_query=query)
else: else:

View File

@@ -33,11 +33,11 @@ class Processor(stage.PipelineStage):
query: core_entities.Query, query: core_entities.Query,
stage_inst_name: str, stage_inst_name: str,
) -> entities.StageProcessResult: ) -> entities.StageProcessResult:
"""处理""" """Process"""
message_text = str(query.message_chain).strip() message_text = str(query.message_chain).strip()
self.ap.logger.info( self.ap.logger.info(
f'处理 {query.launcher_type.value}_{query.launcher_id} 的请求({query.query_id}): {message_text}' f'Processing request from {query.launcher_type.value}_{query.launcher_id} ({query.query_id}): {message_text}'
) )
async def generator(): async def generator():

View File

@@ -15,6 +15,8 @@ from ..discover import engine
from ..entity.persistence import bot as persistence_bot from ..entity.persistence import bot as persistence_bot
from ..entity.errors import platform as platform_errors
from .logger import EventLogger from .logger import EventLogger
# 处理 3.4 移除了 YiriMirai 之后,插件的兼容性问题 # 处理 3.4 移除了 YiriMirai 之后,插件的兼容性问题
@@ -205,7 +207,12 @@ class PlatformManager:
for bot in bots: for bot in bots:
# load all bots here, enable or disable will be handled in runtime # load all bots here, enable or disable will be handled in runtime
await self.load_bot(bot) try:
await self.load_bot(bot)
except platform_errors.AdapterNotFoundError as e:
self.ap.logger.warning(f'Adapter {e.adapter_name} not found, skipping bot {bot.uuid}')
except Exception as e:
self.ap.logger.error(f'Failed to load bot {bot.uuid}: {e}\n{traceback.format_exc()}')
async def load_bot( async def load_bot(
self, self,
@@ -219,6 +226,9 @@ class PlatformManager:
logger = EventLogger(name=f'platform-adapter-{bot_entity.name}', ap=self.ap) logger = EventLogger(name=f'platform-adapter-{bot_entity.name}', ap=self.ap)
if bot_entity.adapter not in self.adapter_dict:
raise platform_errors.AdapterNotFoundError(bot_entity.adapter)
adapter_inst = self.adapter_dict[bot_entity.adapter]( adapter_inst = self.adapter_dict[bot_entity.adapter](
bot_entity.adapter_config, bot_entity.adapter_config,
self.ap, self.ap,

View File

@@ -16,7 +16,6 @@ from ..logger import EventLogger
class AiocqhttpMessageConverter(adapter.MessageConverter): class AiocqhttpMessageConverter(adapter.MessageConverter):
@staticmethod @staticmethod
async def yiri2target( async def yiri2target(
message_chain: platform_message.MessageChain, message_chain: platform_message.MessageChain,
@@ -62,8 +61,14 @@ class AiocqhttpMessageConverter(adapter.MessageConverter):
for node in msg.node_list: for node in msg.node_list:
msg_list.extend((await AiocqhttpMessageConverter.yiri2target(node.message_chain))[0]) msg_list.extend((await AiocqhttpMessageConverter.yiri2target(node.message_chain))[0])
elif isinstance(msg, platform_message.File): elif isinstance(msg, platform_message.File):
msg_list.append({"type":"file", "data":{'file': msg.url, "name": msg.name}}) msg_list.append({'type': 'file', 'data': {'file': msg.url, 'name': msg.name}})
elif isinstance(msg, platform_message.Face):
if msg.face_type == 'face':
msg_list.append(aiocqhttp.MessageSegment.face(msg.face_id))
elif msg.face_type == 'rps':
msg_list.append(aiocqhttp.MessageSegment.rps())
elif msg.face_type == 'dice':
msg_list.append(aiocqhttp.MessageSegment.dice())
else: else:
msg_list.append(aiocqhttp.MessageSegment.text(str(msg))) msg_list.append(aiocqhttp.MessageSegment.text(str(msg)))
@@ -71,35 +76,154 @@ class AiocqhttpMessageConverter(adapter.MessageConverter):
return msg_list, msg_id, msg_time return msg_list, msg_id, msg_time
@staticmethod @staticmethod
async def target2yiri(message: str, message_id: int = -1,bot=None): async def target2yiri(message: str, message_id: int = -1, bot: aiocqhttp.CQHttp = None):
# print(message)
message = aiocqhttp.Message(message) message = aiocqhttp.Message(message)
def get_face_name(face_id):
face_code_dict = {
'2': '好色',
'4': '得意',
'5': '流泪',
'8': '',
'9': '大哭',
'10': '尴尬',
'12': '调皮',
'14': '微笑',
'16': '',
'21': '可爱',
'23': '傲慢',
'24': '饥饿',
'25': '',
'26': '惊恐',
'27': '流汗',
'28': '憨笑',
'29': '悠闲',
'30': '奋斗',
'32': '疑问',
'33': '',
'34': '',
'38': '敲打',
'39': '再见',
'41': '发抖',
'42': '爱情',
'43': '跳跳',
'49': '拥抱',
'53': '蛋糕',
'60': '咖啡',
'63': '玫瑰',
'66': '爱心',
'74': '太阳',
'75': '月亮',
'76': '',
'78': '握手',
'79': '胜利',
'85': '飞吻',
'89': '西瓜',
'96': '冷汗',
'97': '擦汗',
'98': '抠鼻',
'99': '鼓掌',
'100': '糗大了',
'101': '坏笑',
'102': '左哼哼',
'103': '右哼哼',
'104': '哈欠',
'106': '委屈',
'109': '左亲亲',
'111': '可怜',
'116': '示爱',
'118': '抱拳',
'120': '拳头',
'122': '爱你',
'123': 'NO',
'124': 'OK',
'125': '转圈',
'129': '挥手',
'144': '喝彩',
'147': '棒棒糖',
'171': '',
'173': '泪奔',
'174': '无奈',
'175': '卖萌',
'176': '小纠结',
'179': 'doge',
'180': '惊喜',
'181': '骚扰',
'182': '笑哭',
'183': '我最美',
'201': '点赞',
'203': '托脸',
'212': '托腮',
'214': '啵啵',
'219': '蹭一蹭',
'222': '抱抱',
'227': '拍手',
'232': '佛系',
'240': '喷脸',
'243': '甩头',
'246': '加油抱抱',
'262': '脑阔疼',
'264': '捂脸',
'265': '辣眼睛',
'266': '哦哟',
'267': '头秃',
'268': '问号脸',
'269': '暗中观察',
'270': 'emm',
'271': '吃瓜',
'272': '呵呵哒',
'273': '我酸了',
'277': '汪汪',
'278': '',
'281': '无眼笑',
'282': '敬礼',
'284': '面无表情',
'285': '摸鱼',
'287': '',
'289': '睁眼',
'290': '敲开心',
'293': '摸锦鲤',
'294': '期待',
'297': '拜谢',
'298': '元宝',
'299': '牛啊',
'305': '右亲亲',
'306': '牛气冲天',
'307': '喵喵',
'314': '仔细分析',
'315': '加油',
'318': '崇拜',
'319': '比心',
'320': '庆祝',
'322': '拒绝',
'324': '吃糖',
'326': '生气',
}
return face_code_dict.get(face_id, '')
async def process_message_data(msg_data, reply_list): async def process_message_data(msg_data, reply_list):
if msg_data["type"] == "image": if msg_data['type'] == 'image':
image_base64, image_format = await image.qq_image_url_to_base64(msg_data["data"]['url']) image_base64, image_format = await image.qq_image_url_to_base64(msg_data['data']['url'])
reply_list.append( reply_list.append(platform_message.Image(base64=f'data:image/{image_format};base64,{image_base64}'))
platform_message.Image(base64=f'data:image/{image_format};base64,{image_base64}'))
elif msg_data["type"] == "text": elif msg_data['type'] == 'text':
reply_list.append(platform_message.Plain(text=msg_data["data"]["text"])) reply_list.append(platform_message.Plain(text=msg_data['data']['text']))
elif msg_data["type"] == "forward": # 这里来应该传入转发消息组暂时传入qoute elif msg_data['type'] == 'forward': # 这里来应该传入转发消息组暂时传入qoute
for forward_msg_datas in msg_data["data"]["content"]: for forward_msg_datas in msg_data['data']['content']:
for forward_msg_data in forward_msg_datas["message"]: for forward_msg_data in forward_msg_datas['message']:
await process_message_data(forward_msg_data, reply_list) await process_message_data(forward_msg_data, reply_list)
elif msg_data["type"] == "at": elif msg_data['type'] == 'at':
if msg_data["data"]['qq'] == 'all': if msg_data['data']['qq'] == 'all':
reply_list.append(platform_message.AtAll()) reply_list.append(platform_message.AtAll())
else: else:
reply_list.append( reply_list.append(
platform_message.At( platform_message.At(
target=msg_data["data"]['qq'], target=msg_data['data']['qq'],
) )
) )
yiri_msg_list = [] yiri_msg_list = []
yiri_msg_list.append(platform_message.Source(id=message_id, time=datetime.datetime.now())) yiri_msg_list.append(platform_message.Source(id=message_id, time=datetime.datetime.now()))
@@ -118,8 +242,15 @@ class AiocqhttpMessageConverter(adapter.MessageConverter):
elif msg.type == 'text': elif msg.type == 'text':
yiri_msg_list.append(platform_message.Plain(text=msg.data['text'])) yiri_msg_list.append(platform_message.Plain(text=msg.data['text']))
elif msg.type == 'image': elif msg.type == 'image':
image_base64, image_format = await image.qq_image_url_to_base64(msg.data['url']) emoji_id = msg.data.get('emoji_package_id', None)
yiri_msg_list.append(platform_message.Image(base64=f'data:image/{image_format};base64,{image_base64}')) if emoji_id:
face_id = emoji_id
face_name = msg.data.get('summary', '')
image_msg = platform_message.Face(face_id=face_id, face_name=face_name)
else:
image_base64, image_format = await image.qq_image_url_to_base64(msg.data['url'])
image_msg = platform_message.Image(base64=f'data:image/{image_format};base64,{image_base64}')
yiri_msg_list.append(image_msg)
elif msg.type == 'forward': elif msg.type == 'forward':
# 暂时不太合理 # 暂时不太合理
# msg_datas = await bot.get_msg(message_id=message_id) # msg_datas = await bot.get_msg(message_id=message_id)
@@ -128,52 +259,53 @@ class AiocqhttpMessageConverter(adapter.MessageConverter):
# await process_message_data(msg_data, yiri_msg_list) # await process_message_data(msg_data, yiri_msg_list)
pass pass
elif msg.type == 'reply': # 此处处理引用消息传入Qoute elif msg.type == 'reply': # 此处处理引用消息传入Qoute
msg_datas = await bot.get_msg(message_id=msg.data["id"]) msg_datas = await bot.get_msg(message_id=msg.data['id'])
for msg_data in msg_datas["message"]: for msg_data in msg_datas['message']:
await process_message_data(msg_data, reply_list) await process_message_data(msg_data, reply_list)
reply_msg = platform_message.Quote(message_id=msg.data["id"],sender_id=msg_datas["user_id"],origin=reply_list) reply_msg = platform_message.Quote(
message_id=msg.data['id'], sender_id=msg_datas['user_id'], origin=reply_list
)
yiri_msg_list.append(reply_msg) yiri_msg_list.append(reply_msg)
elif msg.type == 'file': # 这里下载所有文件会导致下载文件过多,暂时不下载
# file_name = msg.data['file'] # elif msg.type == 'file':
file_id = msg.data['file_id'] # # file_name = msg.data['file']
file_data = await bot.get_file(file_id=file_id) # file_id = msg.data['file_id']
file_name = file_data.get('file_name') # file_data = await bot.get_file(file_id=file_id)
file_path = file_data.get('file') # file_name = file_data.get('file_name')
file_url = file_data.get('file_url') # file_path = file_data.get('file')
file_size = file_data.get('file_size') # file_url = file_data.get('file_url')
yiri_msg_list.append(platform_message.File(id=file_id, name=file_name,url=file_url,size=file_size)) # file_size = file_data.get('file_size')
# yiri_msg_list.append(platform_message.File(id=file_id, name=file_name,url=file_url,size=file_size))
elif msg.type == 'face':
face_id = msg.data['id']
face_name = msg.data['raw']['faceText']
if not face_name:
face_name = get_face_name(face_id)
yiri_msg_list.append(platform_message.Face(face_id=int(face_id), face_name=face_name.replace('/', '')))
elif msg.type == 'rps':
face_id = msg.data['result']
yiri_msg_list.append(platform_message.Face(face_type='rps', face_id=int(face_id), face_name='猜拳'))
elif msg.type == 'dice':
face_id = msg.data['result']
yiri_msg_list.append(platform_message.Face(face_type='dice', face_id=int(face_id), face_name='骰子'))
chain = platform_message.MessageChain(yiri_msg_list) chain = platform_message.MessageChain(yiri_msg_list)
return chain return chain
class AiocqhttpEventConverter(adapter.EventConverter): class AiocqhttpEventConverter(adapter.EventConverter):
@staticmethod @staticmethod
async def yiri2target(event: platform_events.MessageEvent, bot_account_id: int): async def yiri2target(event: platform_events.MessageEvent, bot_account_id: int):
return event.source_platform_object return event.source_platform_object
@staticmethod @staticmethod
async def target2yiri(event: aiocqhttp.Event,bot=None): async def target2yiri(event: aiocqhttp.Event, bot=None):
yiri_chain = await AiocqhttpMessageConverter.target2yiri(event.message, event.message_id, bot)
yiri_chain = await AiocqhttpMessageConverter.target2yiri(event.message, event.message_id,bot)
if event.message_type == 'group': if event.message_type == 'group':
permission = 'MEMBER' permission = 'MEMBER'
@@ -253,7 +385,6 @@ class AiocqhttpAdapter(adapter.MessagePlatformAdapter):
aiocq_msg = (await AiocqhttpMessageConverter.yiri2target(message))[0] aiocq_msg = (await AiocqhttpMessageConverter.yiri2target(message))[0]
if target_type == 'group': if target_type == 'group':
await self.bot.send_group_msg(group_id=int(target_id), message=aiocq_msg) await self.bot.send_group_msg(group_id=int(target_id), message=aiocq_msg)
elif target_type == 'person': elif target_type == 'person':
await self.bot.send_private_msg(user_id=int(target_id), message=aiocq_msg) await self.bot.send_private_msg(user_id=int(target_id), message=aiocq_msg)
@@ -282,15 +413,18 @@ class AiocqhttpAdapter(adapter.MessagePlatformAdapter):
async def on_message(event: aiocqhttp.Event): async def on_message(event: aiocqhttp.Event):
self.bot_account_id = event.self_id self.bot_account_id = event.self_id
try: try:
return await callback(await self.event_converter.target2yiri(event,self.bot), self) return await callback(await self.event_converter.target2yiri(event, self.bot), self)
except Exception: except Exception:
await self.logger.error(f'Error in on_message: {traceback.format_exc()}') await self.logger.error(f'Error in on_message: {traceback.format_exc()}')
traceback.print_exc() traceback.print_exc()
if event_type == platform_events.GroupMessage: if event_type == platform_events.GroupMessage:
self.bot.on_message('group')(on_message) self.bot.on_message('group')(on_message)
# self.bot.on_notice()(on_message)
elif event_type == platform_events.FriendMessage: elif event_type == platform_events.FriendMessage:
self.bot.on_message('private')(on_message) self.bot.on_message('private')(on_message)
# self.bot.on_notice()(on_message)
# print(event_type)
async def on_websocket_connection(event: aiocqhttp.Event): async def on_websocket_connection(event: aiocqhttp.Event):
for event in self.on_websocket_connection_event_cache: for event in self.on_websocket_connection_event_cache:

View File

@@ -22,7 +22,7 @@ class DingTalkMessageConverter(adapter.MessageConverter):
at = True at = True
if type(msg) is platform_message.Plain: if type(msg) is platform_message.Plain:
content += msg.text content += msg.text
return content,at return content, at
@staticmethod @staticmethod
async def target2yiri(event: DingTalkEvent, bot_name: str): async def target2yiri(event: DingTalkEvent, bot_name: str):
@@ -136,8 +136,8 @@ class DingTalkAdapter(adapter.MessagePlatformAdapter):
) )
incoming_message = event.incoming_message incoming_message = event.incoming_message
content,at = await DingTalkMessageConverter.yiri2target(message) content, at = await DingTalkMessageConverter.yiri2target(message)
await self.bot.send_message(content, incoming_message,at) await self.bot.send_message(content, incoming_message, at)
async def send_message(self, target_type: str, target_id: str, message: platform_message.MessageChain): async def send_message(self, target_type: str, target_id: str, message: platform_message.MessageChain):
content = await DingTalkMessageConverter.yiri2target(message) content = await DingTalkMessageConverter.yiri2target(message)
@@ -157,8 +157,8 @@ class DingTalkAdapter(adapter.MessagePlatformAdapter):
await self.event_converter.target2yiri(event, self.config['robot_name']), await self.event_converter.target2yiri(event, self.config['robot_name']),
self, self,
) )
except Exception as e: except Exception:
await self.logger.error(f"Error in dingtalk callback: {traceback.format_exc()}") await self.logger.error(f'Error in dingtalk callback: {traceback.format_exc()}')
if event_type == platform_events.FriendMessage: if event_type == platform_events.FriendMessage:
self.bot.on_message('FriendMessage')(on_message) self.bot.on_message('FriendMessage')(on_message)

View File

@@ -8,15 +8,592 @@ import base64
import uuid import uuid
import os import os
import datetime import datetime
import io
import asyncio
from enum import Enum
import aiohttp import aiohttp
from .. import adapter from .. import adapter
from ...core import app from ...core import app
from ..logger import EventLogger
from ..types import message as platform_message from ..types import message as platform_message
from ..types import events as platform_events from ..types import events as platform_events
from ..types import entities as platform_entities from ..types import entities as platform_entities
from ..logger import EventLogger
# 语音功能相关异常定义
class VoiceConnectionError(Exception):
"""语音连接基础异常"""
def __init__(self, message: str, error_code: str = None, guild_id: int = None):
super().__init__(message)
self.error_code = error_code
self.guild_id = guild_id
self.timestamp = datetime.datetime.now()
class VoicePermissionError(VoiceConnectionError):
"""语音权限异常"""
def __init__(self, message: str, missing_permissions: list = None, user_id: int = None, channel_id: int = None):
super().__init__(message, "PERMISSION_ERROR")
self.missing_permissions = missing_permissions or []
self.user_id = user_id
self.channel_id = channel_id
class VoiceNetworkError(VoiceConnectionError):
"""语音网络异常"""
def __init__(self, message: str, retry_count: int = 0):
super().__init__(message, "NETWORK_ERROR")
self.retry_count = retry_count
self.last_attempt = datetime.datetime.now()
class VoiceConnectionStatus(Enum):
"""语音连接状态枚举"""
IDLE = "idle"
CONNECTING = "connecting"
CONNECTED = "connected"
PLAYING = "playing"
RECONNECTING = "reconnecting"
FAILED = "failed"
class VoiceConnectionInfo:
"""
语音连接信息类
用于存储和管理单个语音连接的详细信息,包括连接状态、时间戳、
频道信息等。提供连接信息的标准化数据结构。
@author: @ydzat
@version: 1.0
@since: 2025-07-04
"""
def __init__(self, guild_id: int, channel_id: int, channel_name: str = None):
"""
初始化语音连接信息
@author: @ydzat
Args:
guild_id (int): 服务器ID
channel_id (int): 语音频道ID
channel_name (str, optional): 语音频道名称
"""
self.guild_id = guild_id
self.channel_id = channel_id
self.channel_name = channel_name or f"Channel-{channel_id}"
self.connected = False
self.connection_time: datetime.datetime = None
self.last_activity = datetime.datetime.now()
self.status = VoiceConnectionStatus.IDLE
self.user_count = 0
self.latency = 0.0
self.connection_health = "unknown"
self.voice_client = None
def update_status(self, status: VoiceConnectionStatus):
"""
更新连接状态
@author: @ydzat
Args:
status (VoiceConnectionStatus): 新的连接状态
"""
self.status = status
self.last_activity = datetime.datetime.now()
if status == VoiceConnectionStatus.CONNECTED:
self.connected = True
if self.connection_time is None:
self.connection_time = datetime.datetime.now()
elif status in [VoiceConnectionStatus.IDLE, VoiceConnectionStatus.FAILED]:
self.connected = False
self.connection_time = None
self.voice_client = None
def to_dict(self) -> dict:
"""
转换为字典格式
@author: @ydzat
Returns:
dict: 连接信息的字典表示
"""
return {
"guild_id": self.guild_id,
"channel_id": self.channel_id,
"channel_name": self.channel_name,
"connected": self.connected,
"connection_time": self.connection_time.isoformat() if self.connection_time else None,
"last_activity": self.last_activity.isoformat(),
"status": self.status.value,
"user_count": self.user_count,
"latency": self.latency,
"connection_health": self.connection_health
}
class VoiceConnectionManager:
"""
语音连接管理器
负责管理多个服务器的语音连接,提供连接建立、断开、状态查询等功能。
采用单例模式确保全局只有一个连接管理器实例。
@author: @ydzat
@version: 1.0
@since: 2025-07-04
"""
def __init__(self, bot: discord.Client, logger: EventLogger):
"""
初始化语音连接管理器
@author: @ydzat
Args:
bot (discord.Client): Discord 客户端实例
logger (EventLogger): 事件日志记录器
"""
self.bot = bot
self.logger = logger
self.connections: typing.Dict[int, VoiceConnectionInfo] = {}
self._connection_lock = asyncio.Lock()
self._cleanup_task = None
self._monitoring_enabled = True
async def join_voice_channel(self, guild_id: int, channel_id: int,
user_id: int = None) -> discord.VoiceClient:
"""
加入语音频道
验证用户权限和频道状态后,建立到指定语音频道的连接。
支持连接复用和自动重连机制。
@author: @ydzat
Args:
guild_id (int): 服务器ID
channel_id (int): 语音频道ID
user_id (int, optional): 请求用户ID用于权限验证
Returns:
discord.VoiceClient: 语音客户端实例
Raises:
VoicePermissionError: 权限不足时抛出
VoiceNetworkError: 网络连接失败时抛出
VoiceConnectionError: 其他连接错误时抛出
"""
async with self._connection_lock:
try:
# 获取服务器和频道对象
guild = self.bot.get_guild(guild_id)
if not guild:
raise VoiceConnectionError(
f"无法找到服务器 {guild_id}",
"GUILD_NOT_FOUND",
guild_id
)
channel = guild.get_channel(channel_id)
if not channel or not isinstance(channel, discord.VoiceChannel):
raise VoiceConnectionError(
f"无法找到语音频道 {channel_id}",
"CHANNEL_NOT_FOUND",
guild_id
)
# 验证用户是否在语音频道中如果提供了用户ID
if user_id:
await self._validate_user_in_channel(guild, channel, user_id)
# 验证机器人权限
await self._validate_bot_permissions(channel)
# 检查是否已有连接
if guild_id in self.connections:
existing_conn = self.connections[guild_id]
if existing_conn.connected and existing_conn.voice_client:
if existing_conn.channel_id == channel_id:
# 已连接到相同频道,返回现有连接
await self.logger.info(f"复用现有语音连接: {guild.name} -> {channel.name}")
return existing_conn.voice_client
else:
# 连接到不同频道,先断开旧连接
await self._disconnect_internal(guild_id)
# 建立新连接
voice_client = await channel.connect()
# 更新连接信息
conn_info = VoiceConnectionInfo(guild_id, channel_id, channel.name)
conn_info.voice_client = voice_client
conn_info.update_status(VoiceConnectionStatus.CONNECTED)
conn_info.user_count = len(channel.members)
self.connections[guild_id] = conn_info
await self.logger.info(f"成功连接到语音频道: {guild.name} -> {channel.name}")
return voice_client
except discord.ClientException as e:
raise VoiceNetworkError(f"Discord 客户端错误: {str(e)}")
except discord.opus.OpusNotLoaded as e:
raise VoiceConnectionError(f"Opus 编码器未加载: {str(e)}", "OPUS_NOT_LOADED", guild_id)
except Exception as e:
await self.logger.error(f"连接语音频道时发生未知错误: {str(e)}")
raise VoiceConnectionError(f"连接失败: {str(e)}", "UNKNOWN_ERROR", guild_id)
async def leave_voice_channel(self, guild_id: int) -> bool:
"""
离开语音频道
断开指定服务器的语音连接,清理相关资源和状态信息。
确保音频播放停止后再断开连接。
@author: @ydzat
Args:
guild_id (int): 服务器ID
Returns:
bool: 断开是否成功
"""
async with self._connection_lock:
return await self._disconnect_internal(guild_id)
async def _disconnect_internal(self, guild_id: int) -> bool:
"""
内部断开连接方法
@author: @ydzat
Args:
guild_id (int): 服务器ID
Returns:
bool: 断开是否成功
"""
if guild_id not in self.connections:
return True
conn_info = self.connections[guild_id]
try:
if conn_info.voice_client and conn_info.voice_client.is_connected():
# 停止当前播放
if conn_info.voice_client.is_playing():
conn_info.voice_client.stop()
# 等待播放完全停止
await asyncio.sleep(0.1)
# 断开连接
await conn_info.voice_client.disconnect()
conn_info.update_status(VoiceConnectionStatus.IDLE)
del self.connections[guild_id]
await self.logger.info(f"已断开语音连接: Guild {guild_id}")
return True
except Exception as e:
await self.logger.error(f"断开语音连接时发生错误: {str(e)}")
# 即使出错也要清理连接记录
conn_info.update_status(VoiceConnectionStatus.FAILED)
if guild_id in self.connections:
del self.connections[guild_id]
return False
async def get_voice_client(self, guild_id: int) -> typing.Optional[discord.VoiceClient]:
"""
获取语音客户端
返回指定服务器的语音客户端实例,如果未连接则返回 None。
会验证连接的有效性,自动清理无效连接。
@author: @ydzat
Args:
guild_id (int): 服务器ID
Returns:
Optional[discord.VoiceClient]: 语音客户端实例或 None
"""
if guild_id not in self.connections:
return None
conn_info = self.connections[guild_id]
# 验证连接是否仍然有效
if conn_info.voice_client and not conn_info.voice_client.is_connected():
# 连接已失效,清理状态
await self._disconnect_internal(guild_id)
return None
return conn_info.voice_client if conn_info.connected else None
async def is_connected_to_voice(self, guild_id: int) -> bool:
"""
检查是否连接到语音频道
@author: @ydzat
Args:
guild_id (int): 服务器ID
Returns:
bool: 是否已连接
"""
if guild_id not in self.connections:
return False
conn_info = self.connections[guild_id]
# 检查实际连接状态
if conn_info.voice_client and not conn_info.voice_client.is_connected():
# 连接已失效,清理状态
await self._disconnect_internal(guild_id)
return False
return conn_info.connected
async def get_connection_status(self, guild_id: int) -> typing.Optional[dict]:
"""
获取连接状态信息
@author: @ydzat
Args:
guild_id (int): 服务器ID
Returns:
Optional[dict]: 连接状态信息字典或 None
"""
if guild_id not in self.connections:
return None
conn_info = self.connections[guild_id]
# 更新实时信息
if conn_info.voice_client and conn_info.voice_client.is_connected():
conn_info.latency = conn_info.voice_client.latency * 1000 # 转换为毫秒
conn_info.connection_health = "good" if conn_info.latency < 100 else "poor"
# 更新频道用户数
guild = self.bot.get_guild(guild_id)
if guild:
channel = guild.get_channel(conn_info.channel_id)
if channel and isinstance(channel, discord.VoiceChannel):
conn_info.user_count = len(channel.members)
return conn_info.to_dict()
async def list_active_connections(self) -> typing.List[dict]:
"""
列出所有活跃连接
@author: @ydzat
Returns:
List[dict]: 活跃连接列表
"""
active_connections = []
for guild_id, conn_info in self.connections.items():
if conn_info.connected:
status = await self.get_connection_status(guild_id)
if status:
active_connections.append(status)
return active_connections
async def get_voice_channel_info(self, guild_id: int, channel_id: int) -> typing.Optional[dict]:
"""
获取语音频道信息
@author: @ydzat
Args:
guild_id (int): 服务器ID
channel_id (int): 频道ID
Returns:
Optional[dict]: 频道信息字典或 None
"""
guild = self.bot.get_guild(guild_id)
if not guild:
return None
channel = guild.get_channel(channel_id)
if not channel or not isinstance(channel, discord.VoiceChannel):
return None
# 获取用户信息
users = []
for member in channel.members:
users.append({
"id": member.id,
"name": member.display_name,
"status": str(member.status),
"is_bot": member.bot
})
# 获取权限信息
bot_member = guild.me
permissions = channel.permissions_for(bot_member)
return {
"channel_id": channel_id,
"channel_name": channel.name,
"guild_id": guild_id,
"guild_name": guild.name,
"user_limit": channel.user_limit,
"current_users": users,
"user_count": len(users),
"bitrate": channel.bitrate,
"permissions": {
"connect": permissions.connect,
"speak": permissions.speak,
"use_voice_activation": permissions.use_voice_activation,
"priority_speaker": permissions.priority_speaker
}
}
async def _validate_user_in_channel(self, guild: discord.Guild,
channel: discord.VoiceChannel, user_id: int):
"""
验证用户是否在语音频道中
@author: @ydzat
Args:
guild: Discord 服务器对象
channel: 语音频道对象
user_id: 用户ID
Raises:
VoicePermissionError: 用户不在频道中时抛出
"""
member = guild.get_member(user_id)
if not member:
raise VoicePermissionError(
f"无法找到用户 {user_id}",
["member_not_found"],
user_id,
channel.id
)
if not member.voice or member.voice.channel != channel:
raise VoicePermissionError(
f"用户 {member.display_name} 不在语音频道 {channel.name}",
["user_not_in_channel"],
user_id,
channel.id
)
async def _validate_bot_permissions(self, channel: discord.VoiceChannel):
"""
验证机器人权限
@author: @ydzat
Args:
channel: 语音频道对象
Raises:
VoicePermissionError: 权限不足时抛出
"""
bot_member = channel.guild.me
permissions = channel.permissions_for(bot_member)
missing_permissions = []
if not permissions.connect:
missing_permissions.append("connect")
if not permissions.speak:
missing_permissions.append("speak")
if missing_permissions:
raise VoicePermissionError(
f"机器人在频道 {channel.name} 中缺少权限: {', '.join(missing_permissions)}",
missing_permissions,
channel_id=channel.id
)
async def cleanup_inactive_connections(self):
"""
清理无效连接
定期检查并清理已断开或无效的语音连接,释放资源。
@author: @ydzat
"""
cleanup_guilds = []
for guild_id, conn_info in self.connections.items():
if not conn_info.voice_client or not conn_info.voice_client.is_connected():
cleanup_guilds.append(guild_id)
for guild_id in cleanup_guilds:
await self._disconnect_internal(guild_id)
if cleanup_guilds:
await self.logger.info(f"清理了 {len(cleanup_guilds)} 个无效的语音连接")
async def start_monitoring(self):
"""
开始连接监控
@author: @ydzat
"""
if self._cleanup_task is None and self._monitoring_enabled:
self._cleanup_task = asyncio.create_task(self._monitoring_loop())
async def stop_monitoring(self):
"""
停止连接监控
@author: @ydzat
"""
self._monitoring_enabled = False
if self._cleanup_task:
self._cleanup_task.cancel()
try:
await self._cleanup_task
except asyncio.CancelledError:
pass
self._cleanup_task = None
async def _monitoring_loop(self):
"""
监控循环
@author: @ydzat
"""
try:
while self._monitoring_enabled:
await asyncio.sleep(60) # 每分钟检查一次
await self.cleanup_inactive_connections()
except asyncio.CancelledError:
pass
async def disconnect_all(self):
"""
断开所有连接
@author: @ydzat
"""
async with self._connection_lock:
guild_ids = list(self.connections.keys())
for guild_id in guild_ids:
await self._disconnect_internal(guild_id)
await self.stop_monitoring()
class DiscordMessageConverter(adapter.MessageConverter): class DiscordMessageConverter(adapter.MessageConverter):
@@ -35,28 +612,88 @@ class DiscordMessageConverter(adapter.MessageConverter):
for ele in message_chain: for ele in message_chain:
if isinstance(ele, platform_message.Image): if isinstance(ele, platform_message.Image):
image_bytes = None image_bytes = None
filename = f'{uuid.uuid4()}.png' # 默认文件名
if ele.base64: if ele.base64:
image_bytes = base64.b64decode(ele.base64) # 处理base64编码的图片
if ele.base64.startswith('data:'):
# 从data URL中提取文件类型
data_header = ele.base64.split(',')[0]
if 'jpeg' in data_header or 'jpg' in data_header:
filename = f'{uuid.uuid4()}.jpg'
elif 'gif' in data_header:
filename = f'{uuid.uuid4()}.gif'
elif 'webp' in data_header:
filename = f'{uuid.uuid4()}.webp'
# 去掉data:image/xxx;base64,前缀
base64_data = ele.base64.split(',')[1]
else:
base64_data = ele.base64
image_bytes = base64.b64decode(base64_data)
elif ele.url: elif ele.url:
# 从URL下载图片
async with aiohttp.ClientSession() as session: async with aiohttp.ClientSession() as session:
async with session.get(ele.url) as response: async with session.get(ele.url) as response:
image_bytes = await response.read() image_bytes = await response.read()
# 从URL或Content-Type推断文件类型
content_type = response.headers.get('Content-Type', '')
if 'jpeg' in content_type or 'jpg' in content_type:
filename = f'{uuid.uuid4()}.jpg'
elif 'gif' in content_type:
filename = f'{uuid.uuid4()}.gif'
elif 'webp' in content_type:
filename = f'{uuid.uuid4()}.webp'
elif ele.url.lower().endswith(('.jpg', '.jpeg')):
filename = f'{uuid.uuid4()}.jpg'
elif ele.url.lower().endswith('.gif'):
filename = f'{uuid.uuid4()}.gif'
elif ele.url.lower().endswith('.webp'):
filename = f'{uuid.uuid4()}.webp'
elif ele.path: elif ele.path:
with open(ele.path, 'rb') as f: # 从文件路径读取图片
image_bytes = f.read() # 确保路径没有空字节
clean_path = ele.path.replace('\x00', '')
clean_path = os.path.abspath(clean_path)
image_files.append(discord.File(fp=image_bytes, filename=f'{uuid.uuid4()}.png')) if not os.path.exists(clean_path):
continue # 跳过不存在的文件
try:
with open(clean_path, 'rb') as f:
image_bytes = f.read()
# 从文件路径获取文件名,保持原始扩展名
original_filename = os.path.basename(clean_path)
if original_filename and '.' in original_filename:
# 保持原始文件名的扩展名
ext = original_filename.split('.')[-1].lower()
filename = f'{uuid.uuid4()}.{ext}'
else:
# 如果没有扩展名,尝试从文件内容检测
if image_bytes.startswith(b'\xff\xd8\xff'):
filename = f'{uuid.uuid4()}.jpg'
elif image_bytes.startswith(b'GIF'):
filename = f'{uuid.uuid4()}.gif'
elif image_bytes.startswith(b'RIFF') and b'WEBP' in image_bytes[:20]:
filename = f'{uuid.uuid4()}.webp'
# 默认保持PNG
except Exception as e:
print(f"Error reading image file {clean_path}: {e}")
continue # 跳过读取失败的文件
if image_bytes:
# 使用BytesIO创建文件对象避免路径问题
import io
image_files.append(discord.File(fp=io.BytesIO(image_bytes), filename=filename))
elif isinstance(ele, platform_message.Plain): elif isinstance(ele, platform_message.Plain):
text_string += ele.text text_string += ele.text
elif isinstance(ele, platform_message.Forward): elif isinstance(ele, platform_message.Forward):
for node in ele.node_list: for node in ele.node_list:
( (
text_string, node_text,
image_files, node_images,
) = await DiscordMessageConverter.yiri2target(node.message_chain) ) = await DiscordMessageConverter.yiri2target(node.message_chain)
text_string += text_string text_string += node_text
image_files.extend(image_files) image_files.extend(node_images)
return text_string, image_files return text_string, image_files
@@ -178,6 +815,9 @@ class DiscordAdapter(adapter.MessagePlatformAdapter):
self.bot_account_id = self.config['client_id'] self.bot_account_id = self.config['client_id']
# 初始化语音连接管理器
self.voice_manager: VoiceConnectionManager = None
adapter_self = self adapter_self = self
class MyClient(discord.Client): class MyClient(discord.Client):
@@ -198,8 +838,191 @@ class DiscordAdapter(adapter.MessagePlatformAdapter):
self.bot = MyClient(intents=intents, **args) self.bot = MyClient(intents=intents, **args)
# Voice functionality methods
async def join_voice_channel(self, guild_id: int, channel_id: int,
user_id: int = None) -> discord.VoiceClient:
"""
加入语音频道
为指定服务器的语音频道建立连接,支持用户权限验证和连接复用。
@author: @ydzat
@version: 1.0
@since: 2025-07-04
Args:
guild_id (int): Discord 服务器ID
channel_id (int): 语音频道ID
user_id (int, optional): 请求用户ID用于权限验证
Returns:
discord.VoiceClient: 语音客户端实例
Raises:
VoicePermissionError: 权限不足
VoiceNetworkError: 网络连接失败
VoiceConnectionError: 其他连接错误
"""
if not self.voice_manager:
raise VoiceConnectionError("语音管理器未初始化", "MANAGER_NOT_READY")
return await self.voice_manager.join_voice_channel(guild_id, channel_id, user_id)
async def leave_voice_channel(self, guild_id: int) -> bool:
"""
离开语音频道
断开指定服务器的语音连接,清理相关资源。
@author: @ydzat
@version: 1.0
@since: 2025-07-04
Args:
guild_id (int): Discord 服务器ID
Returns:
bool: 是否成功断开连接
"""
if not self.voice_manager:
return False
return await self.voice_manager.leave_voice_channel(guild_id)
async def get_voice_client(self, guild_id: int) -> typing.Optional[discord.VoiceClient]:
"""
获取语音客户端
返回指定服务器的语音客户端实例,用于音频播放控制。
@author: @ydzat
@version: 1.0
@since: 2025-07-04
Args:
guild_id (int): Discord 服务器ID
Returns:
Optional[discord.VoiceClient]: 语音客户端实例或 None
"""
if not self.voice_manager:
return None
return await self.voice_manager.get_voice_client(guild_id)
async def is_connected_to_voice(self, guild_id: int) -> bool:
"""
检查语音连接状态
@author: @ydzat
@version: 1.0
@since: 2025-07-04
Args:
guild_id (int): Discord 服务器ID
Returns:
bool: 是否已连接到语音频道
"""
if not self.voice_manager:
return False
return await self.voice_manager.is_connected_to_voice(guild_id)
async def get_voice_connection_status(self, guild_id: int) -> typing.Optional[dict]:
"""
获取语音连接详细状态
返回包含连接时间、延迟、用户数等详细信息的状态字典。
@author: @ydzat
@version: 1.0
@since: 2025-07-04
Args:
guild_id (int): Discord 服务器ID
Returns:
Optional[dict]: 连接状态信息或 None
"""
if not self.voice_manager:
return None
return await self.voice_manager.get_connection_status(guild_id)
async def list_active_voice_connections(self) -> typing.List[dict]:
"""
列出所有活跃的语音连接
@author: @ydzat
@version: 1.0
@since: 2025-07-04
Returns:
List[dict]: 活跃语音连接列表
"""
if not self.voice_manager:
return []
return await self.voice_manager.list_active_connections()
async def get_voice_channel_info(self, guild_id: int, channel_id: int) -> typing.Optional[dict]:
"""
获取语音频道详细信息
包括频道名称、用户列表、权限信息等。
@author: @ydzat
@version: 1.0
@since: 2025-07-04
Args:
guild_id (int): Discord 服务器ID
channel_id (int): 语音频道ID
Returns:
Optional[dict]: 频道信息字典或 None
"""
if not self.voice_manager:
return None
return await self.voice_manager.get_voice_channel_info(guild_id, channel_id)
async def cleanup_voice_connections(self):
"""
清理无效的语音连接
手动触发语音连接清理,移除已断开或无效的连接。
@author: @ydzat
@version: 1.0
@since: 2025-07-04
"""
if self.voice_manager:
await self.voice_manager.cleanup_inactive_connections()
async def send_message(self, target_type: str, target_id: str, message: platform_message.MessageChain): async def send_message(self, target_type: str, target_id: str, message: platform_message.MessageChain):
pass msg_to_send, image_files = await self.message_converter.yiri2target(message)
try:
# 获取频道对象
channel = self.bot.get_channel(int(target_id))
if channel is None:
# 如果本地缓存中没有尝试从API获取
channel = await self.bot.fetch_channel(int(target_id))
args = {
'content': msg_to_send,
}
if len(image_files) > 0:
args['files'] = image_files
await channel.send(**args)
except Exception as e:
await self.logger.error(f"Discord send_message failed: {e}")
raise e
async def reply_message( async def reply_message(
self, self,
@@ -243,9 +1066,32 @@ class DiscordAdapter(adapter.MessagePlatformAdapter):
self.listeners.pop(event_type) self.listeners.pop(event_type)
async def run_async(self): async def run_async(self):
"""
启动 Discord 适配器
初始化语音管理器并启动 Discord 客户端连接。
@author: @ydzat (修改)
"""
async with self.bot: async with self.bot:
# 初始化语音管理器
self.voice_manager = VoiceConnectionManager(self.bot, self.logger)
await self.voice_manager.start_monitoring()
await self.logger.info("Discord 适配器语音功能已启用")
await self.bot.start(self.config['token'], reconnect=True) await self.bot.start(self.config['token'], reconnect=True)
async def kill(self) -> bool: async def kill(self) -> bool:
"""
关闭 Discord 适配器
清理语音连接并关闭 Discord 客户端。
@author: @ydzat (修改)
"""
if self.voice_manager:
await self.voice_manager.disconnect_all()
await self.bot.close() await self.bot.close()
return True return True

View File

@@ -29,6 +29,9 @@ spec:
label: label:
en_US: Bot Name en_US: Bot Name
zh_Hans: 机器人名称 zh_Hans: 机器人名称
description:
en_US: Must be the same as the name of the bot in Lark, otherwise the bot will not be able to receive messages in the group
zh_Hans: 必须与飞书机器人名称一致,否则机器人将无法在群内正常接收消息
type: string type: string
required: true required: true
default: "" default: ""

Binary file not shown.

After

Width:  |  Height:  |  Size: 466 KiB

View File

@@ -38,10 +38,10 @@ import logging
class WeChatPadMessageConverter(adapter.MessageConverter): class WeChatPadMessageConverter(adapter.MessageConverter):
def __init__(self, config: dict): def __init__(self, config: dict, logger: logging.Logger):
self.config = config self.config = config
self.bot = WeChatPadClient(self.config["wechatpad_url"],self.config["token"]) self.bot = WeChatPadClient(self.config["wechatpad_url"],self.config["token"])
self.logger = logging.getLogger("WeChatPadMessageConverter") self.logger = logger
@staticmethod @staticmethod
async def yiri2target( async def yiri2target(
@@ -90,21 +90,30 @@ class WeChatPadMessageConverter(adapter.MessageConverter):
async def target2yiri( async def target2yiri(
self, self,
message: dict, message: dict,
bot_account_id: str bot_account_id: str,
) -> platform_message.MessageChain: ) -> platform_message.MessageChain:
"""外部消息转平台消息""" """外部消息转平台消息"""
# 数据预处理 # 数据预处理
message_list = [] message_list = []
bot_wxid = self.config['wxid']
ats_bot = False # 是否被@ ats_bot = False # 是否被@
content = message["content"]["str"] content = message["content"]["str"]
content_no_preifx = content # 群消息则去掉前缀 content_no_preifx = content # 群消息则去掉前缀
is_group_message = self._is_group_message(message) is_group_message = self._is_group_message(message)
if is_group_message: if is_group_message:
ats_bot = self._ats_bot(message, bot_account_id) ats_bot = self._ats_bot(message, bot_account_id)
self.logger.info(f"ats_bot: {ats_bot}; bot_account_id: {bot_account_id}; bot_wxid: {bot_wxid}")
if "@所有人" in content: if "@所有人" in content:
message_list.append(platform_message.AtAll()) message_list.append(platform_message.AtAll())
elif ats_bot: elif ats_bot:
message_list.append(platform_message.At(target=bot_account_id)) message_list.append(platform_message.At(target=bot_account_id))
# 解析@信息并生成At组件
at_targets = self._extract_at_targets(message)
for target_id in at_targets:
if target_id != bot_wxid: # 避免重复添加机器人的At
message_list.append(platform_message.At(target=target_id))
content_no_preifx, _ = self._extract_content_and_sender(content) content_no_preifx, _ = self._extract_content_and_sender(content)
msg_type = message["msg_type"] msg_type = message["msg_type"]
@@ -458,6 +467,23 @@ class WeChatPadMessageConverter(adapter.MessageConverter):
finally: finally:
return ats_bot return ats_bot
# 提取一下at的wxid列表
def _extract_at_targets(self, message: dict) -> list[str]:
"""从消息中提取被@用户的ID列表"""
at_targets = []
try:
# 从msg_source中解析atuserlist
msg_source = message.get('msg_source', '') or ''
if len(msg_source) > 0:
msg_source_data = ET.fromstring(msg_source)
at_user_list = msg_source_data.findtext("atuserlist") or ""
if at_user_list:
# atuserlist格式通常是逗号分隔的用户ID列表
at_targets = [user_id.strip() for user_id in at_user_list.split(',') if user_id.strip()]
except Exception as e:
self.logger.error(f"_extract_at_targets got except: {e}")
return at_targets
# 提取一下content前面的sender_id, 和去掉前缀的内容 # 提取一下content前面的sender_id, 和去掉前缀的内容
def _extract_content_and_sender(self, raw_content: str) -> Tuple[str, Optional[str]]: def _extract_content_and_sender(self, raw_content: str) -> Tuple[str, Optional[str]]:
try: try:
@@ -482,10 +508,10 @@ class WeChatPadMessageConverter(adapter.MessageConverter):
class WeChatPadEventConverter(adapter.EventConverter): class WeChatPadEventConverter(adapter.EventConverter):
def __init__(self, config: dict): def __init__(self, config: dict, logger: logging.Logger):
self.config = config self.config = config
self.message_converter = WeChatPadMessageConverter(config) self.message_converter = WeChatPadMessageConverter(config, logger)
self.logger = logging.getLogger("WeChatPadEventConverter") self.logger = logger
@staticmethod @staticmethod
async def yiri2target( async def yiri2target(
@@ -496,7 +522,7 @@ class WeChatPadEventConverter(adapter.EventConverter):
async def target2yiri( async def target2yiri(
self, self,
event: dict, event: dict,
bot_account_id: str bot_account_id: str,
) -> platform_events.MessageEvent: ) -> platform_events.MessageEvent:
# 排除公众号以及微信团队消息 # 排除公众号以及微信团队消息
@@ -572,8 +598,8 @@ class WeChatPadAdapter(adapter.MessagePlatformAdapter):
self.logger = logger self.logger = logger
self.quart_app = quart.Quart(__name__) self.quart_app = quart.Quart(__name__)
self.message_converter = WeChatPadMessageConverter(config) self.message_converter = WeChatPadMessageConverter(config, ap.logger)
self.event_converter = WeChatPadEventConverter(config) self.event_converter = WeChatPadEventConverter(config, ap.logger)
async def ws_message(self, data): async def ws_message(self, data):
"""处理接收到的消息""" """处理接收到的消息"""

View File

@@ -8,6 +8,7 @@ metadata:
description: description:
en_US: WeChatPad Adapter en_US: WeChatPad Adapter
zh_CN: WeChatPad 适配器 zh_CN: WeChatPad 适配器
icon: wechatpad.png
spec: spec:
config: config:
- name: wechatpad_url - name: wechatpad_url

View File

@@ -804,7 +804,7 @@ class File(MessageComponent):
"""文件识别 ID。""" """文件识别 ID。"""
name: str name: str
"""文件名称。""" """文件名称。"""
size: int = '' size: int = 0
"""文件大小。""" """文件大小。"""
url: str url: str
"""文件路径""" """文件路径"""
@@ -812,6 +812,36 @@ class File(MessageComponent):
def __str__(self): def __str__(self):
return f'[文件]{self.name}' return f'[文件]{self.name}'
class Face(MessageComponent):
"""系统表情
此处将超级表情骰子/划拳一同归类于face
当face_type为rps(划拳)时 face_id 对应的是手势
当face_type为dice(骰子)时 face_id 对应的是点数
"""
type: str = 'Face'
"""表情类型"""
face_type: str = 'face'
"""表情id"""
face_id: int = 0
"""表情名"""
face_name: str = ''
def __str__(self):
if self.face_type == 'face':
return f'[表情]{self.face_name}'
elif self.face_type == 'dice':
return f'[表情]{self.face_id}点的{self.face_name}'
elif self.face_type == 'rps':
return f'[表情]{self.face_name}({self.rps_data(self.face_id)})'
def rps_data(self,face_id):
rps_dict ={
1 : "",
2 : "剪刀",
3 : "石头",
}
return rps_dict[face_id]
# ================ 个人微信专用组件 ================ # ================ 个人微信专用组件 ================
@@ -935,7 +965,7 @@ class WeChatFile(MessageComponent):
"""文件识别 ID。""" """文件识别 ID。"""
file_name: str = '' file_name: str = ''
"""文件名称。""" """文件名称。"""
file_size: int = '' file_size: int = 0
"""文件大小。""" """文件大小。"""
file_path: str = '' file_path: str = ''
"""文件地址""" """文件地址"""

View File

@@ -1,12 +1,14 @@
from __future__ import annotations from __future__ import annotations
import sqlalchemy import sqlalchemy
import traceback
from . import entities, requester from . import entities, requester
from ...core import app from ...core import app
from ...discover import engine from ...discover import engine
from . import token from . import token
from ...entity.persistence import model as persistence_model from ...entity.persistence import model as persistence_model
from ...entity.errors import provider as provider_errors
FETCH_MODEL_LIST_URL = 'https://api.qchatgpt.rockchin.top/api/v2/fetch/model_list' FETCH_MODEL_LIST_URL = 'https://api.qchatgpt.rockchin.top/api/v2/fetch/model_list'
@@ -64,7 +66,12 @@ class ModelManager:
# load models # load models
for llm_model in llm_models: for llm_model in llm_models:
await self.load_llm_model(llm_model) try:
await self.load_llm_model(llm_model)
except provider_errors.RequesterNotFoundError as e:
self.ap.logger.warning(f'Requester {e.requester_name} not found, skipping model {llm_model.uuid}')
except Exception as e:
self.ap.logger.error(f'Failed to load model {llm_model.uuid}: {e}\n{traceback.format_exc()}')
async def init_runtime_llm_model( async def init_runtime_llm_model(
self, self,
@@ -76,6 +83,9 @@ class ModelManager:
elif isinstance(model_info, dict): elif isinstance(model_info, dict):
model_info = persistence_model.LLMModel(**model_info) model_info = persistence_model.LLMModel(**model_info)
if model_info.requester not in self.requester_dict:
raise provider_errors.RequesterNotFoundError(model_info.requester)
requester_inst = self.requester_dict[model_info.requester](ap=self.ap, config=model_info.requester_config) requester_inst = self.requester_dict[model_info.requester](ap=self.ap, config=model_info.requester_config)
await requester_inst.initialize() await requester_inst.initialize()

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.3 KiB

View File

@@ -0,0 +1,17 @@
from __future__ import annotations
import typing
import openai
from . import chatcmpl
class AI302ChatCompletions(chatcmpl.OpenAIChatCompletions):
"""302.AI ChatCompletion API 请求器"""
client: openai.AsyncClient
default_config: dict[str, typing.Any] = {
'base_url': 'https://api.302.ai/v1',
'timeout': 120,
}

View File

@@ -0,0 +1,28 @@
apiVersion: v1
kind: LLMAPIRequester
metadata:
name: 302-ai-chat-completions
label:
en_US: 302.AI
zh_Hans: 302.AI
icon: 302ai.png
spec:
config:
- name: base_url
label:
en_US: Base URL
zh_Hans: 基础 URL
type: string
required: true
default: "https://api.302.ai/v1"
- name: timeout
label:
en_US: Timeout
zh_Hans: 超时时间
type: integer
required: true
default: 120
execution:
python:
path: ./302aichatcmpl.py
attr: AI302ChatCompletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

View File

@@ -0,0 +1,17 @@
from __future__ import annotations
import typing
import openai
from . import chatcmpl
class CompShareChatCompletions(chatcmpl.OpenAIChatCompletions):
"""CompShare ChatCompletion API 请求器"""
client: openai.AsyncClient
default_config: dict[str, typing.Any] = {
'base_url': 'https://api.modelverse.cn/v1',
'timeout': 120,
}

View File

@@ -0,0 +1,28 @@
apiVersion: v1
kind: LLMAPIRequester
metadata:
name: compshare-chat-completions
label:
en_US: CompShare
zh_Hans: 优云智算
icon: compshare.png
spec:
config:
- name: base_url
label:
en_US: Base URL
zh_Hans: 基础 URL
type: string
required: true
default: "https://api.modelverse.cn/v1"
- name: timeout
label:
en_US: Timeout
zh_Hans: 超时时间
type: integer
required: true
default: 120
execution:
python:
path: ./compsharechatcmpl.py
attr: CompShareChatCompletions

View File

@@ -46,7 +46,7 @@ class AnnouncementManager:
async def fetch_all(self) -> list[Announcement]: async def fetch_all(self) -> list[Announcement]:
"""获取所有公告""" """获取所有公告"""
resp = requests.get( resp = requests.get(
url='https://api.github.com/repos/RockChinQ/LangBot/contents/res/announcement.json', url='https://api.github.com/repos/langbot-app/LangBot/contents/res/announcement.json',
proxies=self.ap.proxy_mgr.get_forward_proxies(), proxies=self.ap.proxy_mgr.get_forward_proxies(),
timeout=5, timeout=5,
) )

View File

@@ -1,4 +1,4 @@
semantic_version = 'v4.0.7' semantic_version = 'v4.0.9'
required_database_version = 3 required_database_version = 3
"""标记本版本所需要的数据库结构版本,用于判断数据库迁移""" """标记本版本所需要的数据库结构版本,用于判断数据库迁移"""

View File

@@ -29,7 +29,7 @@ class VersionManager:
async def get_release_list(self) -> list: async def get_release_list(self) -> list:
"""获取发行列表""" """获取发行列表"""
rls_list_resp = requests.get( rls_list_resp = requests.get(
url='https://api.github.com/repos/RockChinQ/LangBot/releases', url='https://api.github.com/repos/langbot-app/LangBot/releases',
proxies=self.ap.proxy_mgr.get_forward_proxies(), proxies=self.ap.proxy_mgr.get_forward_proxies(),
timeout=5, timeout=5,
) )

View File

@@ -1,6 +1,6 @@
[project] [project]
name = "langbot" name = "langbot"
version = "4.0.7" version = "4.0.9"
description = "高稳定、支持扩展、多模态 - 大模型原生即时通信机器人平台" description = "高稳定、支持扩展、多模态 - 大模型原生即时通信机器人平台"
readme = "README.md" readme = "README.md"
requires-python = ">=3.10.1" requires-python = ">=3.10.1"
@@ -19,6 +19,7 @@ dependencies = [
"dashscope>=1.23.2", "dashscope>=1.23.2",
"dingtalk-stream>=0.24.0", "dingtalk-stream>=0.24.0",
"discord-py>=2.5.2", "discord-py>=2.5.2",
"pynacl>=1.5.0", # Required for Discord voice support
"gewechat-client>=0.1.5", "gewechat-client>=0.1.5",
"lark-oapi>=1.4.15", "lark-oapi>=1.4.15",
"mcp>=1.8.1", "mcp>=1.8.1",
@@ -79,11 +80,13 @@ classifiers = [
[project.urls] [project.urls]
Homepage = "https://langbot.app" Homepage = "https://langbot.app"
Documentation = "https://docs.langbot.app" Documentation = "https://docs.langbot.app"
Repository = "https://github.com/RockChinQ/langbot" Repository = "https://github.com/langbot-app/LangBot"
[dependency-groups] [dependency-groups]
dev = [ dev = [
"pre-commit>=4.2.0", "pre-commit>=4.2.0",
"pytest>=8.4.1",
"pytest-asyncio>=1.0.0",
"ruff>=0.11.9", "ruff>=0.11.9",
] ]

View File

@@ -15,6 +15,7 @@ proxy:
http: '' http: ''
https: '' https: ''
system: system:
recovery_key: ''
jwt: jwt:
expire: 604800 expire: 604800
secret: '' secret: ''

View File

@@ -5,6 +5,7 @@
"scripts": { "scripts": {
"dev": "next dev --turbopack", "dev": "next dev --turbopack",
"dev:local": "NEXT_PUBLIC_API_BASE_URL=http://localhost:5300 next dev --turbopack", "dev:local": "NEXT_PUBLIC_API_BASE_URL=http://localhost:5300 next dev --turbopack",
"dev:local:win": "set NEXT_PUBLIC_API_BASE_URL=http://localhost:5300 && next dev --turbopack",
"build": "next build", "build": "next build",
"start": "next start", "start": "next start",
"lint": "next lint", "lint": "next lint",
@@ -21,23 +22,26 @@
"@dnd-kit/sortable": "^10.0.0", "@dnd-kit/sortable": "^10.0.0",
"@hookform/resolvers": "^5.0.1", "@hookform/resolvers": "^5.0.1",
"@radix-ui/react-checkbox": "^1.3.1", "@radix-ui/react-checkbox": "^1.3.1",
"@radix-ui/react-dialog": "^1.1.13", "@radix-ui/react-dialog": "^1.1.14",
"@radix-ui/react-hover-card": "^1.1.13", "@radix-ui/react-hover-card": "^1.1.13",
"@radix-ui/react-label": "^2.1.6", "@radix-ui/react-label": "^2.1.6",
"@radix-ui/react-popover": "^1.1.14", "@radix-ui/react-popover": "^1.1.14",
"@radix-ui/react-scroll-area": "^1.2.9", "@radix-ui/react-scroll-area": "^1.2.9",
"@radix-ui/react-select": "^2.2.4", "@radix-ui/react-select": "^2.2.4",
"@radix-ui/react-slot": "^1.2.2", "@radix-ui/react-separator": "^1.1.7",
"@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-switch": "^1.2.4", "@radix-ui/react-switch": "^1.2.4",
"@radix-ui/react-tabs": "^1.1.11", "@radix-ui/react-tabs": "^1.1.11",
"@radix-ui/react-toggle": "^1.1.8", "@radix-ui/react-toggle": "^1.1.8",
"@radix-ui/react-toggle-group": "^1.1.9", "@radix-ui/react-toggle-group": "^1.1.9",
"@radix-ui/react-tooltip": "^1.2.7",
"@tailwindcss/postcss": "^4.1.5", "@tailwindcss/postcss": "^4.1.5",
"axios": "^1.8.4", "axios": "^1.8.4",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"i18next": "^25.1.2", "i18next": "^25.1.2",
"i18next-browser-languagedetector": "^8.1.0", "i18next-browser-languagedetector": "^8.1.0",
"input-otp": "^1.4.2",
"lodash": "^4.17.21", "lodash": "^4.17.21",
"lucide-react": "^0.507.0", "lucide-react": "^0.507.0",
"next": "15.2.4", "next": "15.2.4",

View File

@@ -0,0 +1,262 @@
'use client';
import { useState, useEffect } from 'react';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from '@/components/ui/dialog';
import {
Sidebar,
SidebarContent,
SidebarGroup,
SidebarGroupContent,
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
SidebarProvider,
} from '@/components/ui/sidebar';
import { Button } from '@/components/ui/button';
import BotForm from '@/app/home/bots/components/bot-form/BotForm';
import { BotLogListComponent } from '@/app/home/bots/components/bot-log/view/BotLogListComponent';
import { useTranslation } from 'react-i18next';
import { z } from 'zod';
import { httpClient } from '@/app/infra/http/HttpClient';
interface BotDetailDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
botId?: string;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
onFormSubmit: (value: z.infer<any>) => void;
onFormCancel: () => void;
onBotDeleted: () => void;
onNewBotCreated: (botId: string) => void;
}
export default function BotDetailDialog({
open,
onOpenChange,
botId: propBotId,
onFormSubmit,
onFormCancel,
onBotDeleted,
onNewBotCreated,
}: BotDetailDialogProps) {
const { t } = useTranslation();
const [botId, setBotId] = useState<string | undefined>(propBotId);
const [activeMenu, setActiveMenu] = useState('config');
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
useEffect(() => {
setBotId(propBotId);
setActiveMenu('config');
}, [propBotId, open]);
const menu = [
{
key: 'config',
label: t('bots.configuration'),
icon: (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
>
<path d="M5 7C5 6.17157 5.67157 5.5 6.5 5.5C7.32843 5.5 8 6.17157 8 7C8 7.82843 7.32843 8.5 6.5 8.5C5.67157 8.5 5 7.82843 5 7ZM6.5 3.5C4.567 3.5 3 5.067 3 7C3 8.933 4.567 10.5 6.5 10.5C8.433 10.5 10 8.933 10 7C10 5.067 8.433 3.5 6.5 3.5ZM12 8H20V6H12V8ZM16 17C16 16.1716 16.6716 15.5 17.5 15.5C18.3284 15.5 19 16.1716 19 17C19 17.8284 18.3284 18.5 17.5 18.5C16.6716 18.5 16 17.8284 16 17ZM17.5 13.5C15.567 13.5 14 15.067 14 17C14 18.933 15.567 20.5 17.5 20.5C19.433 20.5 21 18.933 21 17C21 15.067 19.433 13.5 17.5 13.5ZM4 16V18H12V16H4Z"></path>
</svg>
),
},
{
key: 'logs',
label: t('bots.logs'),
icon: (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
>
<path d="M21 8V20.9932C21 21.5501 20.5552 22 20.0066 22H3.9934C3.44495 22 3 21.556 3 21.0082V2.9918C3 2.45531 3.4487 2 4.00221 2H14.9968L21 8ZM19 9H14V4H5V20H19V9ZM8 7H11V9H8V7ZM8 11H16V13H8V11ZM8 15H16V17H8V15Z"></path>
</svg>
),
},
];
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const handleFormSubmit = (value: any) => {
onFormSubmit(value);
};
const handleFormCancel = () => {
onFormCancel();
};
const handleBotDeleted = () => {
httpClient.deleteBot(botId ?? '').then(() => {
onBotDeleted();
});
};
const handleNewBotCreated = (newBotId: string) => {
setBotId(newBotId);
setActiveMenu('config');
onNewBotCreated(newBotId);
};
const handleDelete = () => {
setShowDeleteConfirm(true);
};
const confirmDelete = () => {
handleBotDeleted();
setShowDeleteConfirm(false);
};
if (!botId) {
return (
<>
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="overflow-hidden p-0 !max-w-[40vw] max-h-[70vh] flex">
<main className="flex flex-1 flex-col h-[70vh]">
<DialogHeader className="px-6 pt-6 pb-4 shrink-0">
<DialogTitle>{t('bots.createBot')}</DialogTitle>
</DialogHeader>
<div className="flex-1 overflow-y-auto px-6 pb-6">
<BotForm
initBotId={undefined}
onFormSubmit={handleFormSubmit}
onFormCancel={handleFormCancel}
onBotDeleted={handleBotDeleted}
onNewBotCreated={handleNewBotCreated}
hideButtons={true}
/>
</div>
<DialogFooter className="px-6 py-4 border-t shrink-0">
<div className="flex justify-end gap-2">
<Button type="submit" form="bot-form">
{t('common.submit')}
</Button>
<Button
type="button"
variant="outline"
onClick={handleFormCancel}
>
{t('common.cancel')}
</Button>
</div>
</DialogFooter>
</main>
</DialogContent>
</Dialog>
</>
);
}
return (
<>
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="overflow-hidden p-0 !max-w-[50rem] max-h-[75vh] flex">
<SidebarProvider className="items-start w-full flex">
<Sidebar
collapsible="none"
className="hidden md:flex h-[80vh] w-40 min-w-[120px] border-r bg-white"
>
<SidebarContent>
<SidebarGroup>
<SidebarGroupContent>
<SidebarMenu>
{menu.map((item) => (
<SidebarMenuItem key={item.key}>
<SidebarMenuButton
asChild
isActive={activeMenu === item.key}
onClick={() => setActiveMenu(item.key)}
>
<a href="#">
{item.icon}
<span>{item.label}</span>
</a>
</SidebarMenuButton>
</SidebarMenuItem>
))}
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
</SidebarContent>
</Sidebar>
<main className="flex flex-1 flex-col h-[75vh]">
<DialogHeader className="px-6 pt-6 pb-4 shrink-0">
<DialogTitle>
{activeMenu === 'config'
? t('bots.editBot')
: t('bots.botLogTitle')}
</DialogTitle>
</DialogHeader>
<div className="flex-1 overflow-y-auto px-6 pb-6">
{activeMenu === 'config' && (
<BotForm
initBotId={botId}
onFormSubmit={handleFormSubmit}
onFormCancel={handleFormCancel}
onBotDeleted={handleBotDeleted}
onNewBotCreated={handleNewBotCreated}
hideButtons={true}
/>
)}
{activeMenu === 'logs' && botId && (
<BotLogListComponent botId={botId} />
)}
</div>
{activeMenu === 'config' && (
<DialogFooter className="px-6 py-4 border-t shrink-0">
<div className="flex justify-end gap-2">
<Button
type="button"
variant="destructive"
onClick={handleDelete}
>
{t('common.delete')}
</Button>
<Button type="submit" form="bot-form">
{t('common.save')}
</Button>
<Button
type="button"
variant="outline"
onClick={handleFormCancel}
>
{t('common.cancel')}
</Button>
</div>
</DialogFooter>
)}
</main>
</SidebarProvider>
</DialogContent>
</Dialog>
{/* 删除确认对话框 */}
<Dialog open={showDeleteConfirm} onOpenChange={setShowDeleteConfirm}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t('common.confirmDelete')}</DialogTitle>
</DialogHeader>
<div className="py-4">{t('bots.deleteConfirmation')}</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => setShowDeleteConfirm(false)}
>
{t('common.cancel')}
</Button>
<Button variant="destructive" onClick={confirmDelete}>
{t('common.confirmDelete')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}

View File

@@ -4,21 +4,15 @@ import { httpClient } from '@/app/infra/http/HttpClient';
import { Switch } from '@/components/ui/switch'; import { Switch } from '@/components/ui/switch';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { Button } from '@/components/ui/button';
export default function BotCard({ export default function BotCard({
botCardVO, botCardVO,
clickLogIconCallback,
setBotEnableCallback, setBotEnableCallback,
}: { }: {
botCardVO: BotCardVO; botCardVO: BotCardVO;
clickLogIconCallback: (id: string) => void;
setBotEnableCallback: (id: string, enable: boolean) => void; setBotEnableCallback: (id: string, enable: boolean) => void;
}) { }) {
const { t } = useTranslation(); const { t } = useTranslation();
function onClickLogIcon() {
clickLogIconCallback(botCardVO.id);
}
function setBotEnable(enable: boolean) { function setBotEnable(enable: boolean) {
return httpClient.updateBot(botCardVO.id, { return httpClient.updateBot(botCardVO.id, {
@@ -93,25 +87,6 @@ export default function BotCard({
e.stopPropagation(); e.stopPropagation();
}} }}
/> />
<Button
variant="outline"
className="w-auto h-[40px]"
onClick={(e) => {
onClickLogIcon();
e.stopPropagation();
}}
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
width="48"
height="48"
fill="currentColor"
>
<path d="M21 8V20.9932C21 21.5501 20.5552 22 20.0066 22H3.9934C3.44495 22 3 21.556 3 21.0082V2.9918C3 2.45531 3.4487 2 4.00221 2H14.9968L21 8ZM19 9H14V4H5V20H19V9ZM8 7H11V9H8V7ZM8 11H16V13H8V11ZM8 15H16V17H8V15Z"></path>
</svg>
{t('bots.log')}
</Button>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -67,12 +67,14 @@ export default function BotForm({
onFormCancel, onFormCancel,
onBotDeleted, onBotDeleted,
onNewBotCreated, onNewBotCreated,
hideButtons = false,
}: { }: {
initBotId?: string; initBotId?: string;
onFormSubmit: (value: z.infer<ReturnType<typeof getFormSchema>>) => void; onFormSubmit: (value: z.infer<ReturnType<typeof getFormSchema>>) => void;
onFormCancel: () => void; onFormCancel: () => void;
onBotDeleted: () => void; onBotDeleted: () => void;
onNewBotCreated: (botId: string) => void; onNewBotCreated: (botId: string) => void;
hideButtons?: boolean;
}) { }) {
const { t } = useTranslation(); const { t } = useTranslation();
const formSchema = getFormSchema(t); const formSchema = getFormSchema(t);
@@ -282,7 +284,7 @@ export default function BotForm({
}) })
.finally(() => { .finally(() => {
setIsLoading(false); setIsLoading(false);
form.reset(); // form.reset();
// dynamicForm.resetFields(); // dynamicForm.resetFields();
}); });
} else { } else {
@@ -314,8 +316,6 @@ export default function BotForm({
// dynamicForm.resetFields(); // dynamicForm.resetFields();
}); });
} }
setShowDynamicForm(false);
console.log('set loading', false);
} }
function deleteBot() { function deleteBot() {
@@ -365,6 +365,7 @@ export default function BotForm({
<Form {...form}> <Form {...form}>
<form <form
id="bot-form"
onSubmit={form.handleSubmit(onDynamicFormSubmit)} onSubmit={form.handleSubmit(onDynamicFormSubmit)}
className="space-y-8" className="space-y-8"
> >
@@ -527,42 +528,44 @@ export default function BotForm({
)} )}
</div> </div>
<div className="sticky bottom-0 left-0 right-0 bg-background border-t p-4 mt-4"> {!hideButtons && (
<div className="flex justify-end gap-2"> <div className="sticky bottom-0 left-0 right-0 bg-background border-t p-4 mt-4">
{!initBotId && ( <div className="flex justify-end gap-2">
<Button {!initBotId && (
type="submit"
onClick={form.handleSubmit(onDynamicFormSubmit)}
>
{t('common.submit')}
</Button>
)}
{initBotId && (
<>
<Button <Button
type="button" type="submit"
variant="destructive"
onClick={() => setShowDeleteConfirmModal(true)}
>
{t('common.delete')}
</Button>
<Button
type="button"
onClick={form.handleSubmit(onDynamicFormSubmit)} onClick={form.handleSubmit(onDynamicFormSubmit)}
> >
{t('common.save')} {t('common.submit')}
</Button> </Button>
</> )}
)} {initBotId && (
<Button <>
type="button" <Button
variant="outline" type="button"
onClick={() => onFormCancel()} variant="destructive"
> onClick={() => setShowDeleteConfirmModal(true)}
{t('common.cancel')} >
</Button> {t('common.delete')}
</Button>
<Button
type="button"
onClick={form.handleSubmit(onDynamicFormSubmit)}
>
{t('common.save')}
</Button>
</>
)}
<Button
type="button"
variant="outline"
onClick={() => onFormCancel()}
>
{t('common.cancel')}
</Button>
</div>
</div> </div>
</div> )}
</form> </form>
</Form> </Form>
</div> </div>

View File

@@ -1,9 +1,9 @@
'use client'; 'use client';
import { BotLogManager } from '@/app/home/bots/bot-log/BotLogManager'; import { BotLogManager } from '@/app/home/bots/components/bot-log/BotLogManager';
import { useCallback, useEffect, useRef, useState } from 'react'; import { useCallback, useEffect, useRef, useState } from 'react';
import { BotLog } from '@/app/infra/http/requestParam/bots/GetBotLogsResponse'; import { BotLog } from '@/app/infra/http/requestParam/bots/GetBotLogsResponse';
import { BotLogCard } from '@/app/home/bots/bot-log/view/BotLogCard'; import { BotLogCard } from '@/app/home/bots/components/bot-log/view/BotLogCard';
import styles from './botLog.module.css'; import styles from './botLog.module.css';
import { Switch } from '@/components/ui/switch'; import { Switch } from '@/components/ui/switch';
import { debounce } from 'lodash'; import { debounce } from 'lodash';
@@ -112,10 +112,7 @@ export function BotLogListComponent({ botId }: { botId: string }) {
); );
return ( return (
<div <div className={`${styles.botLogListContainer}`} ref={listContainerRef}>
className={`${styles.botLogListContainer} px-6`}
ref={listContainerRef}
>
<div className={`${styles.listHeader}`}> <div className={`${styles.listHeader}`}>
<div className={'mr-2'}>{t('bots.enableAutoRefresh')}</div> <div className={'mr-2'}>{t('bots.enableAutoRefresh')}</div>
<Switch checked={autoFlush} onCheckedChange={(e) => setAutoFlush(e)} /> <Switch checked={autoFlush} onCheckedChange={(e) => setAutoFlush(e)} />

View File

@@ -3,32 +3,21 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import styles from './botConfig.module.css'; import styles from './botConfig.module.css';
import { BotCardVO } from '@/app/home/bots/components/bot-card/BotCardVO'; import { BotCardVO } from '@/app/home/bots/components/bot-card/BotCardVO';
import BotForm from '@/app/home/bots/components/bot-form/BotForm';
import BotCard from '@/app/home/bots/components/bot-card/BotCard'; import BotCard from '@/app/home/bots/components/bot-card/BotCard';
import CreateCardComponent from '@/app/infra/basic-component/create-card-component/CreateCardComponent'; import CreateCardComponent from '@/app/infra/basic-component/create-card-component/CreateCardComponent';
import { httpClient } from '@/app/infra/http/HttpClient'; import { httpClient } from '@/app/infra/http/HttpClient';
import { Bot, Adapter } from '@/app/infra/entities/api'; import { Bot, Adapter } from '@/app/infra/entities/api';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { i18nObj } from '@/i18n/I18nProvider'; import { i18nObj } from '@/i18n/I18nProvider';
import { BotLogListComponent } from '@/app/home/bots/bot-log/view/BotLogListComponent'; import BotDetailDialog from '@/app/home/bots/BotDetailDialog';
export default function BotConfigPage() { export default function BotConfigPage() {
const { t } = useTranslation(); const { t } = useTranslation();
// 编辑机器人的modal // 机器人详情dialog
const [modalOpen, setModalOpen] = useState<boolean>(false); const [detailDialogOpen, setDetailDialogOpen] = useState<boolean>(false);
// 机器人日志的modal
const [logModalOpen, setLogModalOpen] = useState<boolean>(false);
const [botList, setBotList] = useState<BotCardVO[]>([]); const [botList, setBotList] = useState<BotCardVO[]>([]);
const [isEditForm, setIsEditForm] = useState(false); const [selectedBotId, setSelectedBotId] = useState<string>('');
const [nowSelectedBotUUID, setNowSelectedBotUUID] = useState<string>();
const [nowSelectedBotLog, setNowSelectedBotLog] = useState<string>();
useEffect(() => { useEffect(() => {
getBotList(); getBotList();
@@ -73,61 +62,46 @@ export default function BotConfigPage() {
} }
function handleCreateBotClick() { function handleCreateBotClick() {
setIsEditForm(false); setSelectedBotId('');
setNowSelectedBotUUID(''); setDetailDialogOpen(true);
setModalOpen(true);
} }
function selectBot(botUUID: string) { function selectBot(botUUID: string) {
setNowSelectedBotUUID(botUUID); setSelectedBotId(botUUID);
setIsEditForm(true); setDetailDialogOpen(true);
setModalOpen(true);
} }
function onClickLogIcon(botId: string) { function handleFormSubmit() {
setNowSelectedBotLog(botId); getBotList();
setLogModalOpen(true); // setDetailDialogOpen(false);
}
function handleFormCancel() {
setDetailDialogOpen(false);
}
function handleBotDeleted() {
getBotList();
setDetailDialogOpen(false);
}
function handleNewBotCreated(botId: string) {
console.log('new bot created', botId);
getBotList();
setSelectedBotId(botId);
} }
return ( return (
<div className={styles.configPageContainer}> <div className={styles.configPageContainer}>
<Dialog open={modalOpen} onOpenChange={setModalOpen}> <BotDetailDialog
<DialogContent className="w-[700px] max-h-[80vh] p-0 flex flex-col"> open={detailDialogOpen}
<DialogHeader className="px-6 pt-6 pb-4"> onOpenChange={setDetailDialogOpen}
<DialogTitle> botId={selectedBotId || undefined}
{isEditForm ? t('bots.editBot') : t('bots.createBot')} onFormSubmit={handleFormSubmit}
</DialogTitle> onFormCancel={handleFormCancel}
</DialogHeader> onBotDeleted={handleBotDeleted}
<div className="flex-1 overflow-y-auto px-6"> onNewBotCreated={handleNewBotCreated}
<BotForm />
initBotId={nowSelectedBotUUID}
onFormSubmit={() => {
getBotList();
setModalOpen(false);
}}
onFormCancel={() => setModalOpen(false)}
onBotDeleted={() => {
getBotList();
setModalOpen(false);
}}
onNewBotCreated={(botId) => {
console.log('new bot created', botId);
getBotList();
selectBot(botId);
}}
/>
</div>
</DialogContent>
</Dialog>
<Dialog open={logModalOpen} onOpenChange={setLogModalOpen}>
<DialogContent className="w-[700px] max-h-[80vh] p-0 flex flex-col">
<DialogHeader className="px-6 pt-6 pb-0">
<DialogTitle>{t('bots.botLogTitle')}</DialogTitle>
</DialogHeader>
<BotLogListComponent botId={nowSelectedBotLog || ''} />
</DialogContent>
</Dialog>
{/* 注意其余的返回内容需要保持在Spin组件外部 */} {/* 注意其余的返回内容需要保持在Spin组件外部 */}
<div className={`${styles.botListContainer}`}> <div className={`${styles.botListContainer}`}>
@@ -147,9 +121,6 @@ export default function BotConfigPage() {
> >
<BotCard <BotCard
botCardVO={cardVO} botCardVO={cardVO}
clickLogIconCallback={(id) => {
onClickLogIcon(id);
}}
setBotEnableCallback={(id, enable) => { setBotEnableCallback={(id, enable) => {
setBotList( setBotList(
botList.map((bot) => { botList.map((bot) => {

View File

@@ -0,0 +1,214 @@
import React, { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import {
Sidebar,
SidebarContent,
SidebarGroup,
SidebarGroupContent,
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
SidebarProvider,
} from '@/components/ui/sidebar';
import PipelineFormComponent from './components/pipeline-form/PipelineFormComponent';
import DebugDialog from './components/debug-dialog/DebugDialog';
import { PipelineFormEntity } from '@/app/infra/entities/pipeline';
interface PipelineDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
pipelineId?: string;
isEditMode?: boolean;
isDefaultPipeline?: boolean;
initValues?: PipelineFormEntity;
onFinish: () => void;
onNewPipelineCreated?: (pipelineId: string) => void;
onDeletePipeline: () => void;
onCancel: () => void;
}
type DialogMode = 'config' | 'debug';
export default function PipelineDialog({
open,
onOpenChange,
pipelineId: propPipelineId,
isEditMode = false,
isDefaultPipeline = false,
initValues,
onFinish,
onNewPipelineCreated,
onDeletePipeline,
onCancel,
}: PipelineDialogProps) {
const { t } = useTranslation();
const [pipelineId, setPipelineId] = useState<string | undefined>(
propPipelineId,
);
const [currentMode, setCurrentMode] = useState<DialogMode>('config');
useEffect(() => {
setPipelineId(propPipelineId);
setCurrentMode('config');
}, [propPipelineId, open]);
const handleFinish = () => {
onFinish();
};
const handleNewPipelineCreated = (newPipelineId: string) => {
setPipelineId(newPipelineId);
setCurrentMode('config');
if (onNewPipelineCreated) {
onNewPipelineCreated(newPipelineId);
}
};
const menu = [
{
key: 'config',
label: t('pipelines.configuration'),
icon: (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
>
<path d="M5 7C5 6.17157 5.67157 5.5 6.5 5.5C7.32843 5.5 8 6.17157 8 7C8 7.82843 7.32843 8.5 6.5 8.5C5.67157 8.5 5 7.82843 5 7ZM6.5 3.5C4.567 3.5 3 5.067 3 7C3 8.933 4.567 10.5 6.5 10.5C8.433 10.5 10 8.933 10 7C10 5.067 8.433 3.5 6.5 3.5ZM12 8H20V6H12V8ZM16 17C16 16.1716 16.6716 15.5 17.5 15.5C18.3284 15.5 19 16.1716 19 17C19 17.8284 18.3284 18.5 17.5 18.5C16.6716 18.5 16 17.8284 16 17ZM17.5 13.5C15.567 13.5 14 15.067 14 17C14 18.933 15.567 20.5 17.5 20.5C19.433 20.5 21 18.933 21 17C21 15.067 19.433 13.5 17.5 13.5ZM4 16V18H12V16H4Z"></path>
</svg>
),
},
{
key: 'debug',
label: t('pipelines.debugChat'),
icon: (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
>
<path d="M13 19.9C15.2822 19.4367 17 17.419 17 15V12C17 11.299 16.8564 10.6219 16.5846 10H7.41538C7.14358 10.6219 7 11.299 7 12V15C7 17.419 8.71776 19.4367 11 19.9V14H13V19.9ZM5.5358 17.6907C5.19061 16.8623 5 15.9534 5 15H2V13H5V12C5 11.3573 5.08661 10.7348 5.2488 10.1436L3.0359 8.86602L4.0359 7.13397L6.05636 8.30049C6.11995 8.19854 6.18609 8.09835 6.25469 8H17.7453C17.8139 8.09835 17.88 8.19854 17.9436 8.30049L19.9641 7.13397L20.9641 8.86602L18.7512 10.1436C18.9134 10.7348 19 11.3573 19 12V13H22V15H19C19 15.9534 18.8094 16.8623 18.4642 17.6907L20.9641 19.134L19.9641 20.866L17.4383 19.4077C16.1549 20.9893 14.1955 22 12 22C9.80453 22 7.84512 20.9893 6.56171 19.4077L4.0359 20.866L3.0359 19.134L5.5358 17.6907ZM8 6C8 3.79086 9.79086 2 12 2C14.2091 2 16 3.79086 16 6H8Z"></path>
</svg>
),
},
];
const getDialogTitle = () => {
if (currentMode === 'config') {
return isEditMode
? t('pipelines.editPipeline')
: t('pipelines.createPipeline');
}
return t('pipelines.debugDialog.title');
};
// 创建新流水线时的对话框
if (!isEditMode) {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="overflow-hidden p-0 !max-w-[40vw] max-h-[70vh] flex">
<main className="flex flex-1 flex-col h-[70vh]">
<DialogHeader className="px-6 pt-6 pb-4 shrink-0">
<DialogTitle>{t('pipelines.createPipeline')}</DialogTitle>
</DialogHeader>
<div className="flex-1 overflow-y-auto px-6 pb-6">
<PipelineFormComponent
initValues={initValues}
isDefaultPipeline={isDefaultPipeline}
onFinish={handleFinish}
onNewPipelineCreated={handleNewPipelineCreated}
isEditMode={isEditMode}
pipelineId={pipelineId}
disableForm={false}
showButtons={true}
onDeletePipeline={onDeletePipeline}
onCancel={() => {
onCancel();
}}
/>
</div>
</main>
</DialogContent>
</Dialog>
);
}
// 编辑流水线时的对话框
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="overflow-hidden p-0 !max-w-[50rem] h-[75vh] flex">
<SidebarProvider className="items-start w-full flex h-full min-h-0">
<Sidebar
collapsible="none"
className="hidden md:flex h-full min-h-0 w-40 border-r bg-white"
>
<SidebarContent>
<SidebarGroup>
<SidebarGroupContent>
<SidebarMenu>
{menu.map((item) => (
<SidebarMenuItem key={item.key}>
<SidebarMenuButton
asChild
isActive={currentMode === item.key}
onClick={() => setCurrentMode(item.key as DialogMode)}
>
<a href="#">
{item.icon}
<span>{item.label}</span>
</a>
</SidebarMenuButton>
</SidebarMenuItem>
))}
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
</SidebarContent>
</Sidebar>
<main className="flex flex-1 flex-col h-full min-h-0">
<DialogHeader
className="px-6 pt-6 pb-4 shrink-0"
style={{ height: '4rem' }}
>
<DialogTitle>{getDialogTitle()}</DialogTitle>
</DialogHeader>
<div
className="flex-1 auto px-6 pb-4 w-full"
style={{ height: 'calc(100% - 4rem)' }}
>
{currentMode === 'config' && (
<PipelineFormComponent
initValues={initValues}
isDefaultPipeline={isDefaultPipeline}
onFinish={handleFinish}
onNewPipelineCreated={handleNewPipelineCreated}
isEditMode={isEditMode}
pipelineId={pipelineId}
disableForm={false}
showButtons={true}
onDeletePipeline={onDeletePipeline}
onCancel={() => {
onCancel();
}}
/>
)}
{currentMode === 'debug' && pipelineId && (
<DebugDialog
open={true}
pipelineId={pipelineId}
isEmbedded={true}
/>
)}
</div>
</main>
</SidebarProvider>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,376 @@
import React, { useState, useEffect, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import { httpClient } from '@/app/infra/http/HttpClient';
import { DialogContent } from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { ScrollArea } from '@/components/ui/scroll-area';
import { cn } from '@/lib/utils';
import { Message } from '@/app/infra/entities/message';
import { toast } from 'sonner';
import AtBadge from './AtBadge';
interface MessageComponent {
type: 'At' | 'Plain';
target?: string;
text?: string;
}
interface DebugDialogProps {
open: boolean;
pipelineId: string;
isEmbedded?: boolean;
}
export default function DebugDialog({
open,
pipelineId,
isEmbedded = false,
}: DebugDialogProps) {
const { t } = useTranslation();
const [selectedPipelineId, setSelectedPipelineId] = useState(pipelineId);
const [sessionType, setSessionType] = useState<'person' | 'group'>('person');
const [messages, setMessages] = useState<Message[]>([]);
const [inputValue, setInputValue] = useState('');
const [showAtPopover, setShowAtPopover] = useState(false);
const [hasAt, setHasAt] = useState(false);
const [isHovering, setIsHovering] = useState(false);
const messagesEndRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
const popoverRef = useRef<HTMLDivElement>(null);
const scrollToBottom = () => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
};
useEffect(() => {
scrollToBottom();
}, [messages]);
useEffect(() => {
if (open) {
setSelectedPipelineId(pipelineId);
loadMessages(pipelineId);
}
}, [open, pipelineId]);
useEffect(() => {
if (open) {
loadMessages(selectedPipelineId);
}
}, [sessionType, selectedPipelineId]);
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (
popoverRef.current &&
!popoverRef.current.contains(event.target as Node) &&
!inputRef.current?.contains(event.target as Node)
) {
setShowAtPopover(false);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
}, []);
useEffect(() => {
if (showAtPopover) {
setIsHovering(true);
}
}, [showAtPopover]);
const loadMessages = async (pipelineId: string) => {
try {
const response = await httpClient.getWebChatHistoryMessages(
pipelineId,
sessionType,
);
setMessages(response.messages);
} catch (error) {
console.error('Failed to load messages:', error);
}
};
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const value = e.target.value;
if (sessionType === 'group') {
if (value.endsWith('@')) {
setShowAtPopover(true);
} else if (showAtPopover && (!value.includes('@') || value.length > 1)) {
setShowAtPopover(false);
}
}
setInputValue(value);
};
const handleAtSelect = () => {
setHasAt(true);
setShowAtPopover(false);
setInputValue(inputValue.slice(0, -1));
};
const handleAtRemove = () => {
setHasAt(false);
};
const handleKeyPress = (e: React.KeyboardEvent) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
if (showAtPopover) {
handleAtSelect();
} else {
sendMessage();
}
} else if (e.key === 'Backspace' && hasAt && inputValue === '') {
handleAtRemove();
}
};
const sendMessage = async () => {
if (!inputValue.trim() && !hasAt) return;
try {
const messageChain = [];
let text_content = inputValue.trim();
if (hasAt) {
text_content = ' ' + text_content;
}
if (hasAt) {
messageChain.push({
type: 'At',
target: 'webchatbot',
});
}
messageChain.push({
type: 'Plain',
text: text_content,
});
if (hasAt) {
// for showing
text_content = '@webchatbot' + text_content;
}
const userMessage: Message = {
id: -1,
role: 'user',
content: text_content,
timestamp: new Date().toISOString(),
message_chain: messageChain,
};
setMessages((prevMessages) => [...prevMessages, userMessage]);
setInputValue('');
setHasAt(false);
const response = await httpClient.sendWebChatMessage(
sessionType,
messageChain,
selectedPipelineId,
120000,
);
setMessages((prevMessages) => [...prevMessages, response.message]);
} catch (
// eslint-disable-next-line @typescript-eslint/no-explicit-any
error: any
) {
console.log(error, 'type of error', typeof error);
console.error('Failed to send message:', error);
if (!error.message.includes('timeout') && sessionType === 'person') {
toast.error(t('pipelines.debugDialog.sendFailed'));
}
} finally {
inputRef.current?.focus();
}
};
const renderMessageContent = (message: Message) => {
return (
<span className="text-base leading-relaxed align-middle whitespace-pre-wrap">
{(message.message_chain as MessageComponent[]).map(
(component, index) => {
if (component.type === 'At') {
return (
<AtBadge
key={index}
targetName={component.target || ''}
readonly={true}
/>
);
} else if (component.type === 'Plain') {
return <span key={index}>{component.text}</span>;
}
return null;
},
)}
</span>
);
};
const renderContent = () => (
<div className="flex flex-1 h-full min-h-0">
<div className="w-14 bg-white p-2 pl-0 flex-shrink-0 flex flex-col justify-start gap-2">
<Button
variant="ghost"
size="icon"
className={`w-10 h-10 justify-center rounded-md transition-none ${
sessionType === 'person'
? 'bg-[#2288ee] text-white hover:bg-[#2288ee] hover:text-white'
: 'bg-white text-gray-800 hover:bg-gray-100'
} border-0 shadow-none`}
onClick={() => setSessionType('person')}
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
className="w-6 h-6"
>
<path d="M4 22C4 17.5817 7.58172 14 12 14C16.4183 14 20 17.5817 20 22H18C18 18.6863 15.3137 16 12 16C8.68629 16 6 18.6863 6 22H4ZM12 13C8.685 13 6 10.315 6 7C6 3.685 8.685 1 12 1C15.315 1 18 3.685 18 7C18 10.315 15.315 13 12 13ZM12 11C14.21 11 16 9.21 16 7C16 4.79 14.21 3 12 3C9.79 3 8 4.79 8 7C8 9.21 9.79 11 12 11Z"></path>
</svg>
</Button>
<Button
variant="ghost"
size="icon"
className={`w-10 h-10 justify-center rounded-md transition-none ${
sessionType === 'group'
? 'bg-[#2288ee] text-white hover:bg-[#2288ee] hover:text-white'
: 'bg-white text-gray-800 hover:bg-gray-100'
} border-0 shadow-none`}
onClick={() => setSessionType('group')}
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
className="w-6 h-6"
>
<path d="M2 22C2 17.5817 5.58172 14 10 14C14.4183 14 18 17.5817 18 22H16C16 18.6863 13.3137 16 10 16C6.68629 16 4 18.6863 4 22H2ZM10 13C6.685 13 4 10.315 4 7C4 3.685 6.685 1 10 1C13.315 1 16 3.685 16 7C16 10.315 13.315 13 10 13ZM10 11C12.21 11 14 9.21 14 7C14 4.79 12.21 3 10 3C7.79 3 6 4.79 6 7C6 9.21 7.79 11 10 11ZM18.2837 14.7028C21.0644 15.9561 23 18.752 23 22H21C21 19.564 19.5483 17.4671 17.4628 16.5271L18.2837 14.7028ZM17.5962 3.41321C19.5944 4.23703 21 6.20361 21 8.5C21 11.3702 18.8042 13.7252 16 13.9776V11.9646C17.6967 11.7222 19 10.264 19 8.5C19 7.11935 18.2016 5.92603 17.041 5.35635L17.5962 3.41321Z"></path>
</svg>
</Button>
<div className="flex-1" />
</div>
<div className="flex-1 flex flex-col w-[10rem] h-full min-h-0">
<ScrollArea className="flex-1 p-6 overflow-y-auto min-h-0 bg-white">
<div className="space-y-6">
{messages.length === 0 ? (
<div className="text-center text-muted-foreground py-12 text-lg">
{t('pipelines.debugDialog.noMessages')}
</div>
) : (
messages.map((message) => (
<div
key={message.id + message.timestamp}
className={cn(
'flex',
message.role === 'user' ? 'justify-end' : 'justify-start',
)}
>
<div
className={cn(
'max-w-md px-5 py-3 rounded-2xl',
message.role === 'user'
? 'bg-[#2288ee] text-white rounded-br-none'
: 'bg-gray-100 text-gray-900 rounded-bl-none',
)}
>
{renderMessageContent(message)}
<div
className={cn(
'text-xs mt-2',
message.role === 'user'
? 'text-white/70'
: 'text-gray-500',
)}
>
{message.role === 'user'
? t('pipelines.debugDialog.userMessage')
: t('pipelines.debugDialog.botMessage')}
</div>
</div>
</div>
))
)}
<div ref={messagesEndRef} />
</div>
</ScrollArea>
<div className="p-4 pb-0 bg-white flex gap-2">
<div className="flex-1 flex items-center gap-2">
{hasAt && (
<AtBadge targetName="webchatbot" onRemove={handleAtRemove} />
)}
<div className="relative flex-1">
<Input
ref={inputRef}
value={inputValue}
onChange={handleInputChange}
onKeyPress={handleKeyPress}
placeholder={t('pipelines.debugDialog.inputPlaceholder', {
type:
sessionType === 'person'
? t('pipelines.debugDialog.privateChat')
: t('pipelines.debugDialog.groupChat'),
})}
className="flex-1 rounded-md px-3 py-2 border border-gray-300 focus:border-[#2288ee] transition-none text-base"
/>
{showAtPopover && (
<div
ref={popoverRef}
className="absolute bottom-full left-0 mb-2 w-auto rounded-md border bg-white shadow-lg"
>
<div
className={cn(
'flex items-center gap-2 px-4 py-1.5 rounded cursor-pointer',
isHovering ? 'bg-gray-100' : 'bg-white',
)}
onClick={handleAtSelect}
onMouseEnter={() => setIsHovering(true)}
onMouseLeave={() => setIsHovering(false)}
>
<span>
@webchatbot - {t('pipelines.debugDialog.atTips')}
</span>
</div>
</div>
)}
</div>
</div>
<Button
onClick={sendMessage}
disabled={!inputValue.trim() && !hasAt}
className="rounded-md bg-[#2288ee] hover:bg-[#2288ee] w-20 text-white px-6 py-2 text-base font-medium transition-none flex items-center gap-2 shadow-none"
>
<>{t('pipelines.debugDialog.send')}</>
</Button>
</div>
</div>
</div>
);
// 如果是嵌入模式,直接返回内容
if (isEmbedded) {
return (
<div className="flex flex-col h-full min-h-0">
<div className="flex-1 min-h-0 flex flex-col">{renderContent()}</div>
</div>
);
}
// 原有的Dialog包装
return (
<DialogContent className="!max-w-[70vw] max-w-6xl h-[70vh] p-6 flex flex-col rounded-2xl shadow-2xl bg-white">
{renderContent()}
</DialogContent>
);
}

View File

@@ -1,22 +1,10 @@
import styles from './pipelineCard.module.css'; import styles from './pipelineCard.module.css';
import { PipelineCardVO } from '@/app/home/pipelines/components/pipeline-card/PipelineCardVO'; import { PipelineCardVO } from '@/app/home/pipelines/components/pipeline-card/PipelineCardVO';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Button } from '@/components/ui/button';
export default function PipelineCard({ export default function PipelineCard({ cardVO }: { cardVO: PipelineCardVO }) {
cardVO,
onDebug,
}: {
cardVO: PipelineCardVO;
onDebug: (pipelineId: string) => void;
}) {
const { t } = useTranslation(); const { t } = useTranslation();
const handleDebugClick = (e: React.MouseEvent) => {
e.stopPropagation();
onDebug(cardVO.id);
};
return ( return (
<div className={`${styles.cardContainer}`}> <div className={`${styles.cardContainer}`}>
<div className={`${styles.basicInfoContainer}`}> <div className={`${styles.basicInfoContainer}`}>
@@ -61,22 +49,6 @@ export default function PipelineCard({
</div> </div>
</div> </div>
)} )}
<Button
variant="outline"
onClick={handleDebugClick}
title={t('pipelines.chat')}
className="mt-auto"
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
className={styles.debugButtonIcon}
>
<path d="M13 19.9C15.2822 19.4367 17 17.419 17 15V12C17 11.299 16.8564 10.6219 16.5846 10H7.41538C7.14358 10.6219 7 11.299 7 12V15C7 17.419 8.71776 19.4367 11 19.9V14H13V19.9ZM5.5358 17.6907C5.19061 16.8623 5 15.9534 5 15H2V13H5V12C5 11.3573 5.08661 10.7348 5.2488 10.1436L3.0359 8.86602L4.0359 7.13397L6.05636 8.30049C6.11995 8.19854 6.18609 8.09835 6.25469 8H17.7453C17.8139 8.09835 17.88 8.19854 17.9436 8.30049L19.9641 7.13397L20.9641 8.86602L18.7512 10.1436C18.9134 10.7348 19 11.3573 19 12V13H22V15H19C19 15.9534 18.8094 16.8623 18.4642 17.6907L20.9641 19.134L19.9641 20.866L17.4383 19.4077C16.1549 20.9893 14.1955 22 12 22C9.80453 22 7.84512 20.9893 6.56171 19.4077L4.0359 20.866L3.0359 19.134L5.5358 17.6907ZM8 6C8 3.79086 9.79086 2 12 2C14.2091 2 16 3.79086 16 6H8Z"></path>
</svg>
{t('pipelines.chat')}
</Button>
</div> </div>
</div> </div>
); );

View File

@@ -22,15 +22,14 @@ import {
FormLabel, FormLabel,
FormMessage, FormMessage,
} from '@/components/ui/form'; } from '@/components/ui/form';
import { toast } from 'sonner';
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
DialogDescription,
DialogFooter, DialogFooter,
} from '@/components/ui/dialog'; } from '@/components/ui/dialog';
import { toast } from 'sonner';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { i18nObj } from '@/i18n/I18nProvider'; import { i18nObj } from '@/i18n/I18nProvider';
@@ -41,17 +40,25 @@ export default function PipelineFormComponent({
onNewPipelineCreated, onNewPipelineCreated,
isEditMode, isEditMode,
pipelineId, pipelineId,
showButtons = true,
onDeletePipeline,
onCancel,
}: { }: {
pipelineId?: string; pipelineId?: string;
isDefaultPipeline: boolean; isDefaultPipeline: boolean;
isEditMode: boolean; isEditMode: boolean;
disableForm: boolean; disableForm: boolean;
showButtons?: boolean;
// 这里的写法很不安全不规范,未来流水线需要重新整理 // 这里的写法很不安全不规范,未来流水线需要重新整理
initValues?: PipelineFormEntity; initValues?: PipelineFormEntity;
onFinish: () => void; onFinish: () => void;
onNewPipelineCreated: (pipelineId: string) => void; onNewPipelineCreated: (pipelineId: string) => void;
onDeletePipeline: () => void;
onCancel: () => void;
}) { }) {
const { t } = useTranslation(); const { t } = useTranslation();
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const formSchema = isEditMode const formSchema = isEditMode
? z.object({ ? z.object({
basic: z.object({ basic: z.object({
@@ -98,7 +105,6 @@ export default function PipelineFormComponent({
useState<PipelineConfigTab>(); useState<PipelineConfigTab>();
const [outputConfigTabSchema, setOutputConfigTabSchema] = const [outputConfigTabSchema, setOutputConfigTabSchema] =
useState<PipelineConfigTab>(); useState<PipelineConfigTab>();
const [showDeleteConfirmModal, setShowDeleteConfirmModal] = useState(false);
const form = useForm<FormValues>({ const form = useForm<FormValues>({
resolver: zodResolver(formSchema), resolver: zodResolver(formSchema),
@@ -306,187 +312,191 @@ export default function PipelineFormComponent({
); );
} }
function deletePipeline() { const handleDelete = () => {
httpClient setShowDeleteConfirm(true);
.deletePipeline(pipelineId || '') };
.then(() => {
onFinish(); const confirmDelete = () => {
toast.success(t('common.deleteSuccess')); if (pipelineId) {
}) httpClient
.catch((err) => { .deletePipeline(pipelineId)
toast.error(t('common.deleteError') + err.message); .then(() => {
}); onDeletePipeline();
} setShowDeleteConfirm(false);
toast.success(t('pipelines.deleteSuccess'));
})
.catch((err) => {
toast.error(t('pipelines.deleteError') + err.message);
});
}
};
return ( return (
<div style={{ maxHeight: '70vh', overflowY: 'auto' }}> <>
<Dialog <div className="!max-w-[70vw] max-w-6xl h-full p-0 flex flex-col bg-white">
open={showDeleteConfirmModal} <Form {...form}>
onOpenChange={setShowDeleteConfirmModal} <form
> id="pipeline-form"
<DialogContent> onSubmit={form.handleSubmit(handleFormSubmit)}
<DialogHeader> className="h-full flex flex-col flex-1 min-h-0 mb-2"
<DialogTitle>{t('common.confirmDelete')}</DialogTitle> >
</DialogHeader> <div className="flex-1 flex flex-col min-h-0">
<DialogDescription> <Tabs
{t('pipelines.deleteConfirmation')} defaultValue={formLabelList[0].name}
</DialogDescription> className="h-full flex flex-col flex-1 min-h-0"
<DialogFooter>
<Button
variant="outline"
onClick={() => setShowDeleteConfirmModal(false)}
>
{t('common.cancel')}
</Button>
<Button
variant="destructive"
onClick={() => {
deletePipeline();
setShowDeleteConfirmModal(false);
}}
>
{t('common.confirmDelete')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Form {...form}>
<form onSubmit={form.handleSubmit(handleFormSubmit)}>
<Tabs defaultValue={formLabelList[0].name}>
<TabsList>
{formLabelList.map((formLabel) => (
<TabsTrigger key={formLabel.name} value={formLabel.name}>
{formLabel.label}
</TabsTrigger>
))}
</TabsList>
{formLabelList.map((formLabel) => (
<TabsContent
key={formLabel.name}
value={formLabel.name}
className="pr-6"
> >
<h1 className="text-xl font-bold mb-4">{formLabel.label}</h1> <TabsList>
{formLabelList.map((formLabel) => (
<TabsTrigger key={formLabel.name} value={formLabel.name}>
{formLabel.label}
</TabsTrigger>
))}
</TabsList>
{formLabel.name === 'basic' && ( <div
<div className="space-y-6"> id="pipeline-form-content"
<FormField className="flex-1 overflow-y-auto min-h-0"
control={form.control} >
name="basic.name" {formLabelList.map((formLabel) => (
render={({ field }) => ( <TabsContent
<FormItem> key={formLabel.name}
<FormLabel> value={formLabel.name}
{t('common.name')} className="overflow-y-auto max-h-full"
<span className="text-red-500">*</span> >
</FormLabel> {formLabel.name === 'basic' && (
<FormControl> <div className="space-y-6">
<Input {...field} /> <FormField
</FormControl> control={form.control}
<FormMessage /> name="basic.name"
</FormItem> render={({ field }) => (
<FormItem>
<FormLabel>
{t('common.name')}
<span className="text-red-500">*</span>
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="basic.description"
render={({ field }) => (
<FormItem>
<FormLabel>
{t('common.description')}
<span className="text-red-500">*</span>
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
)} )}
/>
<FormField {isEditMode && (
control={form.control} <>
name="basic.description" {formLabel.name === 'ai' && aiConfigTabSchema && (
render={({ field }) => ( <div className="space-y-6">
<FormItem> {aiConfigTabSchema.stages.map((stage) =>
<FormLabel> renderDynamicForms(stage, 'ai'),
{t('common.description')} )}
<span className="text-red-500">*</span> </div>
</FormLabel> )}
<FormControl>
<Input {...field} /> {formLabel.name === 'trigger' &&
</FormControl> triggerConfigTabSchema && (
<FormMessage /> <div className="space-y-6">
</FormItem> {triggerConfigTabSchema.stages.map((stage) =>
renderDynamicForms(stage, 'trigger'),
)}
</div>
)}
{formLabel.name === 'safety' &&
safetyConfigTabSchema && (
<div className="space-y-6">
{safetyConfigTabSchema.stages.map((stage) =>
renderDynamicForms(stage, 'safety'),
)}
</div>
)}
{formLabel.name === 'output' &&
outputConfigTabSchema && (
<div className="space-y-6">
{outputConfigTabSchema.stages.map((stage) =>
renderDynamicForms(stage, 'output'),
)}
</div>
)}
</>
)} )}
/> </TabsContent>
</div> ))}
)} </div>
</Tabs>
{isEditMode && ( </div>
<> </form>
{formLabel.name === 'ai' && aiConfigTabSchema && ( {/* 按钮栏移到 Tabs 外部,始终固定底部 */}
<div className="space-y-6"> {showButtons && (
{aiConfigTabSchema.stages.map((stage) => <div className="flex justify-end gap-2 pt-4 border-t mb-0 bg-white sticky bottom-0 z-10">
renderDynamicForms(stage, 'ai'),
)}
</div>
)}
{formLabel.name === 'trigger' && triggerConfigTabSchema && (
<div className="space-y-6">
{triggerConfigTabSchema.stages.map((stage) =>
renderDynamicForms(stage, 'trigger'),
)}
</div>
)}
{formLabel.name === 'safety' && safetyConfigTabSchema && (
<div className="space-y-6">
{safetyConfigTabSchema.stages.map((stage) =>
renderDynamicForms(stage, 'safety'),
)}
</div>
)}
{formLabel.name === 'output' && outputConfigTabSchema && (
<div className="space-y-6">
{outputConfigTabSchema.stages.map((stage) =>
renderDynamicForms(stage, 'output'),
)}
</div>
)}
</>
)}
</TabsContent>
))}
</Tabs>
<div className="sticky bottom-0 left-0 right-0 bg-background border-t p-4 mt-4">
<div className="flex justify-end items-center gap-2">
{isEditMode && isDefaultPipeline && (
<span className="text-gray-500 text-[0.7rem]">
{t('pipelines.defaultPipelineCannotDelete')}
</span>
)}
{isEditMode && !isDefaultPipeline && ( {isEditMode && !isDefaultPipeline && (
<Button <Button
type="button" type="button"
variant="destructive" variant="destructive"
onClick={() => { onClick={handleDelete}
setShowDeleteConfirmModal(true);
}}
className="cursor-pointer"
> >
{t('common.delete')} {t('common.delete')}
</Button> </Button>
)} )}
<Button type="submit" className="cursor-pointer"> {isEditMode && isDefaultPipeline && (
<div className="text-gray-500 text-sm h-full flex items-center mr-2">
{t('pipelines.defaultPipelineCannotDelete')}
</div>
)}
<Button type="submit" form="pipeline-form">
{isEditMode ? t('common.save') : t('common.submit')} {isEditMode ? t('common.save') : t('common.submit')}
</Button> </Button>
<Button <Button type="button" variant="outline" onClick={onCancel}>
type="button"
variant="outline"
onClick={onFinish}
className="cursor-pointer"
>
{t('common.cancel')} {t('common.cancel')}
</Button> </Button>
</div> </div>
</div> )}
</form> </Form>
</Form> </div>
</div>
{/* 删除确认对话框 */}
<Dialog open={showDeleteConfirm} onOpenChange={setShowDeleteConfirm}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t('common.confirmDelete')}</DialogTitle>
</DialogHeader>
<div className="py-4">{t('pipelines.deleteConfirmation')}</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => setShowDeleteConfirm(false)}
>
{t('common.cancel')}
</Button>
<Button variant="destructive" onClick={confirmDelete}>
{t('common.confirmDelete')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
); );
} }
interface FormLabel { interface FormLabel {
label: string; label: string;
name: string; name: string;

View File

@@ -1,422 +0,0 @@
import React, { useState, useEffect, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import { httpClient } from '@/app/infra/http/HttpClient';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { ScrollArea } from '@/components/ui/scroll-area';
import { cn } from '@/lib/utils';
import { Pipeline } from '@/app/infra/entities/api';
import { Message } from '@/app/infra/entities/message';
import { toast } from 'sonner';
import AtBadge from './AtBadge';
interface MessageComponent {
type: 'At' | 'Plain';
target?: string;
text?: string;
}
interface DebugDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
pipelineId: string;
}
export default function DebugDialog({
open,
onOpenChange,
pipelineId,
}: DebugDialogProps) {
const { t } = useTranslation();
const [selectedPipelineId, setSelectedPipelineId] = useState(pipelineId);
const [sessionType, setSessionType] = useState<'person' | 'group'>('person');
const [messages, setMessages] = useState<Message[]>([]);
const [inputValue, setInputValue] = useState('');
const [pipelines, setPipelines] = useState<Pipeline[]>([]);
const [showAtPopover, setShowAtPopover] = useState(false);
const [hasAt, setHasAt] = useState(false);
const [isHovering, setIsHovering] = useState(false);
const messagesEndRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
const popoverRef = useRef<HTMLDivElement>(null);
const scrollToBottom = () => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
};
useEffect(() => {
scrollToBottom();
}, [messages]);
useEffect(() => {
if (open) {
setSelectedPipelineId(pipelineId);
loadPipelines();
loadMessages(pipelineId);
}
}, [open, pipelineId]);
useEffect(() => {
if (open) {
loadMessages(selectedPipelineId);
}
}, [sessionType, selectedPipelineId]);
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (
popoverRef.current &&
!popoverRef.current.contains(event.target as Node) &&
!inputRef.current?.contains(event.target as Node)
) {
setShowAtPopover(false);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
}, []);
useEffect(() => {
if (showAtPopover) {
setIsHovering(true);
}
}, [showAtPopover]);
const loadPipelines = async () => {
try {
const response = await httpClient.getPipelines();
setPipelines(response.pipelines);
} catch (error) {
console.error('Failed to load pipelines:', error);
}
};
const loadMessages = async (pipelineId: string) => {
try {
const response = await httpClient.getWebChatHistoryMessages(
pipelineId,
sessionType,
);
setMessages(response.messages);
} catch (error) {
console.error('Failed to load messages:', error);
}
};
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const value = e.target.value;
if (sessionType === 'group') {
if (value.endsWith('@')) {
setShowAtPopover(true);
} else if (showAtPopover && (!value.includes('@') || value.length > 1)) {
setShowAtPopover(false);
}
}
setInputValue(value);
};
const handleAtSelect = () => {
setHasAt(true);
setShowAtPopover(false);
setInputValue(inputValue.slice(0, -1));
};
const handleAtRemove = () => {
setHasAt(false);
};
const handleKeyPress = (e: React.KeyboardEvent) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
if (showAtPopover) {
handleAtSelect();
} else {
sendMessage();
}
} else if (e.key === 'Backspace' && hasAt && inputValue === '') {
handleAtRemove();
}
};
const sendMessage = async () => {
if (!inputValue.trim() && !hasAt) return;
try {
const messageChain = [];
let text_content = inputValue.trim();
if (hasAt) {
text_content = ' ' + text_content;
}
if (hasAt) {
messageChain.push({
type: 'At',
target: 'webchatbot',
});
}
messageChain.push({
type: 'Plain',
text: text_content,
});
if (hasAt) {
// for showing
text_content = '@webchatbot' + text_content;
}
const userMessage: Message = {
id: -1,
role: 'user',
content: text_content,
timestamp: new Date().toISOString(),
message_chain: messageChain,
};
setMessages((prevMessages) => [...prevMessages, userMessage]);
setInputValue('');
setHasAt(false);
const response = await httpClient.sendWebChatMessage(
sessionType,
messageChain,
selectedPipelineId,
120000,
);
setMessages((prevMessages) => [...prevMessages, response.message]);
} catch (
// eslint-disable-next-line @typescript-eslint/no-explicit-any
error: any
) {
console.log(error, 'type of error', typeof error);
console.error('Failed to send message:', error);
if (!error.message.includes('timeout') && sessionType === 'person') {
toast.error(t('pipelines.debugDialog.sendFailed'));
}
} finally {
inputRef.current?.focus();
}
};
// const resetSession = async () => {
// try {
// await httpClient.resetWebChatSession(selectedPipelineId, sessionType);
// setMessages([]);
// } catch (error) {
// console.error('Failed to reset session:', error);
// }
// };
const renderMessageContent = (message: Message) => {
return (
<span className="text-base leading-relaxed align-middle whitespace-pre-wrap">
{(message.message_chain as MessageComponent[]).map(
(component, index) => {
if (component.type === 'At') {
return (
<AtBadge
key={index}
targetName={component.target || ''}
readonly={true}
/>
);
} else if (component.type === 'Plain') {
return <span key={index}>{component.text}</span>;
}
return null;
},
)}
</span>
);
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="!max-w-[70vw] max-w-6xl h-[70vh] p-6 flex flex-col rounded-2xl shadow-2xl bg-white">
<DialogHeader className="pl-2">
<div className="flex items-center justify-between">
<DialogTitle className="flex items-center gap-4 font-bold">
{t('pipelines.debugDialog.title')}
<Select
value={selectedPipelineId}
onValueChange={(value) => {
setSelectedPipelineId(value);
loadMessages(value);
}}
>
<SelectTrigger className="bg-white">
<SelectValue />
</SelectTrigger>
<SelectContent className="rounded-xl shadow-lg">
{pipelines.map((pipeline) => (
<SelectItem
key={pipeline.uuid}
value={pipeline.uuid || ''}
className="rounded-lg"
>
{pipeline.name}
</SelectItem>
))}
</SelectContent>
</Select>
</DialogTitle>
</div>
</DialogHeader>
<div className="flex flex-1 h-full min-h-0 border-t">
<div className="w-50 bg-white border-r p-6 pl-0 rounded-l-2xl flex-shrink-0 flex flex-col justify-start gap-4">
<div className="flex flex-col gap-2">
<Button
variant="ghost"
className={`w-full justify-center rounded-md px-4 py-6 text-base font-medium transition-none ${
sessionType === 'person'
? 'bg-[#2288ee] text-white hover:bg-[#2288ee] hover:text-white'
: 'bg-white text-gray-800 hover:bg-gray-100'
} border-0 shadow-none`}
onClick={() => setSessionType('person')}
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
>
<path d="M4 22C4 17.5817 7.58172 14 12 14C16.4183 14 20 17.5817 20 22H18C18 18.6863 15.3137 16 12 16C8.68629 16 6 18.6863 6 22H4ZM12 13C8.685 13 6 10.315 6 7C6 3.685 8.685 1 12 1C15.315 1 18 3.685 18 7C18 10.315 15.315 13 12 13ZM12 11C14.21 11 16 9.21 16 7C16 4.79 14.21 3 12 3C9.79 3 8 4.79 8 7C8 9.21 9.79 11 12 11Z"></path>
</svg>
{t('pipelines.debugDialog.privateChat')}
</Button>
<Button
variant="ghost"
className={`w-full justify-center rounded-md px-4 py-6 text-base font-medium transition-none ${
sessionType === 'group'
? 'bg-[#2288ee] text-white hover:bg-[#2288ee] hover:text-white'
: 'bg-white text-gray-800 hover:bg-gray-100'
} border-0 shadow-none`}
onClick={() => setSessionType('group')}
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
>
<path d="M2 22C2 17.5817 5.58172 14 10 14C14.4183 14 18 17.5817 18 22H16C16 18.6863 13.3137 16 10 16C6.68629 16 4 18.6863 4 22H2ZM10 13C6.685 13 4 10.315 4 7C4 3.685 6.685 1 10 1C13.315 1 16 3.685 16 7C16 10.315 13.315 13 10 13ZM10 11C12.21 11 14 9.21 14 7C14 4.79 12.21 3 10 3C7.79 3 6 4.79 6 7C6 9.21 7.79 11 10 11ZM18.2837 14.7028C21.0644 15.9561 23 18.752 23 22H21C21 19.564 19.5483 17.4671 17.4628 16.5271L18.2837 14.7028ZM17.5962 3.41321C19.5944 4.23703 21 6.20361 21 8.5C21 11.3702 18.8042 13.7252 16 13.9776V11.9646C17.6967 11.7222 19 10.264 19 8.5C19 7.11935 18.2016 5.92603 17.041 5.35635L17.5962 3.41321Z"></path>
</svg>
{t('pipelines.debugDialog.groupChat')}
</Button>
</div>
<div className="flex-1" />
</div>
<div className="flex-1 flex flex-col w-[10rem] h-full min-h-0">
<ScrollArea className="flex-1 p-6 overflow-y-auto min-h-0 bg-white">
<div className="space-y-6">
{messages.length === 0 ? (
<div className="text-center text-muted-foreground py-12 text-lg">
{t('pipelines.debugDialog.noMessages')}
</div>
) : (
messages.map((message) => (
<div
key={message.id + message.timestamp}
className={cn(
'flex',
message.role === 'user'
? 'justify-end'
: 'justify-start',
)}
>
<div
className={cn(
'max-w-md px-5 py-3 rounded-2xl',
message.role === 'user'
? 'bg-[#2288ee] text-white rounded-br-none'
: 'bg-gray-100 text-gray-900 rounded-bl-none',
)}
>
{renderMessageContent(message)}
<div
className={cn(
'text-xs mt-2',
message.role === 'user'
? 'text-white/70'
: 'text-gray-500',
)}
>
{message.role === 'user'
? t('pipelines.debugDialog.userMessage')
: t('pipelines.debugDialog.botMessage')}
</div>
</div>
</div>
))
)}
<div ref={messagesEndRef} />
</div>
</ScrollArea>
<div className="border-t p-4 pb-0 bg-white flex gap-2">
<div className="flex-1 flex items-center gap-2">
{hasAt && (
<AtBadge targetName="webchatbot" onRemove={handleAtRemove} />
)}
<div className="relative flex-1">
<Input
ref={inputRef}
value={inputValue}
onChange={handleInputChange}
onKeyPress={handleKeyPress}
placeholder={t('pipelines.debugDialog.inputPlaceholder')}
className="flex-1 rounded-md px-3 py-2 border border-gray-300 focus:border-[#2288ee] transition-none text-base"
/>
{showAtPopover && (
<div
ref={popoverRef}
className="absolute bottom-full left-0 mb-2 w-auto rounded-md border bg-white shadow-lg"
>
<div
className={cn(
'flex items-center gap-2 px-4 py-1.5 rounded cursor-pointer',
isHovering ? 'bg-gray-100' : 'bg-white',
)}
onClick={handleAtSelect}
onMouseEnter={() => setIsHovering(true)}
onMouseLeave={() => setIsHovering(false)}
>
<span>
@webchatbot - {t('pipelines.debugDialog.atTips')}
</span>
</div>
</div>
)}
</div>
</div>
<Button
onClick={sendMessage}
disabled={!inputValue.trim() && !hasAt}
className="rounded-md bg-[#2288ee] hover:bg-[#2288ee] w-20 text-white px-6 py-2 text-base font-medium transition-none flex items-center gap-2 shadow-none"
>
<>{t('pipelines.debugDialog.send')}</>
</Button>
</div>
</div>
</div>
</DialogContent>
</Dialog>
);
}

Some files were not shown because too many files have changed in this diff Show More