Compare commits

...

21 Commits

Author SHA1 Message Date
Junyan Qin
a610c72067 chore: bump version 4.6.4 2025-12-10 14:22:57 +08:00
Junyan Qin
d210a49fae fix: react cve 2025-12-10 14:21:41 +08:00
Junyan Qin
b015c248ea chore: bump langbot-plugin to 0.2.3 2025-12-10 14:02:23 +08:00
Hadong
4a559ea770 feat: 飞书适配器加入“机器人进群欢迎语”配置 (#1852)
* feat(lark): 支持机器人进群发送欢迎消息

* perf: existence check and indent

---------

Co-authored-by: donghao <donghao@patsnap.com>
Co-authored-by: Junyan Qin <rockchinq@gmail.com>
2025-12-09 16:37:03 +08:00
fdc310
e306751863 feat:add lark ubified_webhook and The configuration for the front-end regarding whether to enable webhooks for Lark is displayed. (#1850) 2025-12-09 13:30:45 +08:00
Junyan Qin
2f51f5f33e docs: apply README changes to all languages 2025-12-06 22:34:48 +08:00
Junyan Qin (Chin)
74a2a61fc1 Update README with new features and headings
Added a new heading and additional features to the README.
2025-12-06 22:21:49 +08:00
Junyan Qin
b6c0345b3e chore: bump version 4.6.3 2025-12-06 21:29:28 +08:00
Junyan Qin (Chin)
6421a6f5cb Feat/complete adapter features (#1849)
* feat: add voice and file supports for wecom

* feat: add   and  in query variables

* feat: supports for lark recv file message

* feat: kook recv voice msg

* feat: supports for Voice and File in discord

* chore: remove debug msg

* perf: remove unnecessary bot logs

* feat: implement bot log filtering and per label color (#1839)

* feat: add sender_name and group_name in query variables
2025-12-06 21:11:01 +08:00
Junyan Qin
daf56e5dc2 fix: test failed 2025-12-05 22:54:13 +08:00
Yaguang.Wang
cb7c9af25c feat: Expanded WeCom message parsing to capture msgtype, inline voice/video… (#1843)
* Expanded WeCom message parsing to capture msgtype, inline voice/video/file/link data, bounded base64 downloads, and richer mixed-message attachments (src/langbot/libs/wecom_ai_bot_api/api.py); added event accessors for new fields (src/langbot/libs/wecom_ai_bot_api/wecombotevent.py).
Converter now maps richer WeCom payloads (text, images, files, voice, video, links) into platform message chain with fallbacks when nothing parsable is present (src/langbot/pkg/platform/sources/wecombot.py).
Preprocessor now turns voice inputs into file URLs for downstream runners (src/langbot/pkg/pipeline/preproc/preproc.py).
Dify runner uploads all incoming files (images/audio/video/docs) after downloading or decoding data URLs, infers MIME types, and passes typed file descriptors into chat/workflow calls (src/langbot/pkg/provider/runners/difysvapi.py).

* Update src/langbot/pkg/platform/sources/wecombot.py

Fixed the issue of duplicate text in the comments.

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Update src/langbot/libs/wecom_ai_bot_api/api.py

Modify the way you approach challenges.

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Update src/langbot/pkg/platform/sources/wecombot.py

Changing the variable names makes more sense.

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* feat: use from_base64 for the voice file converting

---------

Co-authored-by: tabriswang <tabriswang@finecomn.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Junyan Qin <rockchinq@gmail.com>
2025-12-05 22:33:15 +08:00
Junyan Qin
45e61befac fix: test failed 2025-12-05 22:30:44 +08:00
Junyan Qin
ea50ba10e6 perf: add en name in the wecom manifest 2025-12-05 21:28:56 +08:00
Junyan Qin
5c4a727e74 feat: make all db migrations SQL-only 2025-12-05 21:00:04 +08:00
Junyan Qin
867f05c4ad perf: make the timeout of emit_event 180s 2025-12-05 20:59:37 +08:00
Junyan Qin
b06b32306f feat: remove all unnecessary fields in GroupMember and implement MessageEvent field for pipeline events 2025-12-05 17:24:58 +08:00
Junyan Qin
dbfcb70f8d fix: sender_id not presented to Session 2025-12-05 17:13:30 +08:00
Junyan Qin
e64d56c4ac fix: bad protocol of default plugin debug url 2025-12-05 16:06:56 +08:00
Bruce
8f0da7943c Remove plugins volume from docker-compose (#1842) 2025-12-05 11:28:04 +08:00
Junyan Qin
e62ff7e520 fix: deps issues 2025-12-04 23:07:55 +08:00
Junyan Qin (Chin)
86e951916e feat: add milvus and pgvector as vector db (#1840)
* feat: add milvus and pgvector as vector db

* chore: update config.yaml template delete comments
2025-12-04 22:34:49 +08:00
69 changed files with 8253 additions and 10958 deletions

View File

@@ -1,13 +1,15 @@
<p align="center"> <p align="center">
<a href="https://langbot.app"> <a href="https://langbot.app">
<img src="https://docs.langbot.app/social_zh.png" alt="LangBot"/> <img width="130" src="https://docs.langbot.app/langbot-logo.png" alt="LangBot"/>
</a> </a>
<div align="center"> <div align="center">
<a href="https://hellogithub.com/repository/langbot-app/LangBot" target="_blank"><img src="https://abroad.hellogithub.com/v1/widgets/recommend.svg?rid=5ce8ae2aa4f74316bf393b57b952433c&claim_uid=gtmc6YWjMZkT21R" alt="FeaturedHelloGitHub" style="width: 250px; height: 54px;" width="250" height="54" /></a> <a href="https://hellogithub.com/repository/langbot-app/LangBot" target="_blank"><img src="https://abroad.hellogithub.com/v1/widgets/recommend.svg?rid=5ce8ae2aa4f74316bf393b57b952433c&claim_uid=gtmc6YWjMZkT21R" alt="FeaturedHelloGitHub" style="width: 250px; height: 54px;" width="250" height="54" /></a>
<h3>使用 LangBot 快速构建、调试、部署即时通信机器人。</h3>
[English](README_EN.md) / 简体中文 / [繁體中文](README_TW.md) / [日本語](README_JP.md) / [Español](README_ES.md) / [Français](README_FR.md) / [한국어](README_KO.md) / [Русский](README_RU.md) / [Tiếng Việt](README_VI.md) [English](README_EN.md) / 简体中文 / [繁體中文](README_TW.md) / [日本語](README_JP.md) / [Español](README_ES.md) / [Français](README_FR.md) / [한국어](README_KO.md) / [Русский](README_RU.md) / [Tiếng Việt](README_VI.md)
[![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)
@@ -22,12 +24,10 @@
<a href="https://docs.langbot.app/zh/plugin/plugin-intro.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> <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> </div>
</p> </p>
LangBot 是一个开源的大语言模型原生即时通信机器人开发平台,旨在提供开箱即用的 IM 机器人开发体验,具有 Agent、RAG、MCP 等多种 LLM 应用功能,适配全球主流即时通信平台,并提供丰富的 API 接口,支持自定义开发。
## 📦 开始使用 ## 📦 开始使用
@@ -83,6 +83,9 @@ docker compose up -d
## ✨ 特性 ## ✨ 特性
<img width="500" src="https://docs.langbot.app/ui/bot-page-zh-rounded.png" />
- 💬 大模型对话、Agent支持多种大模型适配群聊和私聊具有多轮对话、工具调用、多模态、流式输出能力自带 RAG知识库实现并深度适配 [Dify](https://dify.ai)、[Coze](https://coze.com)、[n8n](https://n8n.io)等 LLMOps 平台。 - 💬 大模型对话、Agent支持多种大模型适配群聊和私聊具有多轮对话、工具调用、多模态、流式输出能力自带 RAG知识库实现并深度适配 [Dify](https://dify.ai)、[Coze](https://coze.com)、[n8n](https://n8n.io)等 LLMOps 平台。
- 🤖 多平台支持:目前支持 QQ、QQ频道、企业微信、个人微信、飞书、Discord、Telegram、KOOK、Slack、LINE 等平台。 - 🤖 多平台支持:目前支持 QQ、QQ频道、企业微信、个人微信、飞书、Discord、Telegram、KOOK、Slack、LINE 等平台。
- 🛠️ 高稳定性、功能完备:原生支持访问控制、限速、敏感词过滤等机制;配置简单,支持多种部署方式。支持多流水线配置,不同机器人用于不同应用场景。 - 🛠️ 高稳定性、功能完备:原生支持访问控制、限速、敏感词过滤等机制;配置简单,支持多种部署方式。支持多流水线配置,不同机器人用于不同应用场景。

View File

@@ -1,12 +1,14 @@
<p align="center"> <p align="center">
<a href="https://langbot.app"> <a href="https://langbot.app">
<img src="https://docs.langbot.app/social_en.png" alt="LangBot"/> <img width="130" src="https://docs.langbot.app/langbot-logo.png" alt="LangBot"/>
</a> </a>
<div align="center"> <div align="center">
<a href="https://www.producthunt.com/products/langbot?utm_source=badge-follow&utm_medium=badge&utm_source=badge-langbot" target="_blank"><img src="https://api.producthunt.com/widgets/embed-image/v1/follow.svg?product_id=1077185&theme=light" alt="LangBot - Production&#0045;grade&#0032;IM&#0032;bot&#0032;made&#0032;easy&#0046; | Product Hunt" style="width: 250px; height: 54px;" width="250" height="54" /></a> <a href="https://www.producthunt.com/products/langbot?utm_source=badge-follow&utm_medium=badge&utm_source=badge-langbot" target="_blank"><img src="https://api.producthunt.com/widgets/embed-image/v1/follow.svg?product_id=1077185&theme=light" alt="LangBot - Production&#0045;grade&#0032;IM&#0032;bot&#0032;made&#0032;easy&#0046; | Product Hunt" style="width: 250px; height: 54px;" width="250" height="54" /></a>
<h3>Quickly build, debug, and ship IM bots with LangBot.</h3>
English / [简体中文](README.md) / [繁體中文](README_TW.md) / [日本語](README_JP.md) / [Español](README_ES.md) / [Français](README_FR.md) / [한국어](README_KO.md) / [Русский](README_RU.md) / [Tiếng Việt](README_VI.md) English / [简体中文](README.md) / [繁體中文](README_TW.md) / [日本語](README_JP.md) / [Español](README_ES.md) / [Français](README_FR.md) / [한국어](README_KO.md) / [Русский](README_RU.md) / [Tiếng Việt](README_VI.md)
[![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)
@@ -23,7 +25,6 @@ English / [简体中文](README.md) / [繁體中文](README_TW.md) / [日本語]
</p> </p>
LangBot is an open-source LLM native instant messaging robot development platform, aiming to provide out-of-the-box IM robot development experience, with Agent, RAG, MCP and other LLM application functions, adapting to global instant messaging platforms, and providing rich API interfaces, supporting custom development.
## 📦 Getting Started ## 📦 Getting Started
@@ -79,6 +80,9 @@ Click the Star and Watch button in the upper right corner of the repository to g
## ✨ Features ## ✨ Features
<img width="500" src="https://docs.langbot.app/ui/bot-page-en-rounded.png" />
- 💬 Chat with LLM / Agent: Supports multiple LLMs, adapt to group chats and private chats; Supports multi-round conversations, tool calls, multi-modal, and streaming output capabilities. Built-in RAG (knowledge base) implementation, and deeply integrates with [Dify](https://dify.ai), [Coze](https://coze.com), [n8n](https://n8n.io) etc. LLMOps platforms. - 💬 Chat with LLM / Agent: Supports multiple LLMs, adapt to group chats and private chats; Supports multi-round conversations, tool calls, multi-modal, and streaming output capabilities. Built-in RAG (knowledge base) implementation, and deeply integrates with [Dify](https://dify.ai), [Coze](https://coze.com), [n8n](https://n8n.io) etc. LLMOps platforms.
- 🤖 Multi-platform Support: Currently supports QQ, QQ Channel, WeCom, personal WeChat, Lark, DingTalk, Discord, Telegram, KOOK, Slack, LINE, etc. - 🤖 Multi-platform Support: Currently supports QQ, QQ Channel, WeCom, personal WeChat, Lark, DingTalk, Discord, Telegram, KOOK, Slack, LINE, 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. - 🛠️ 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.

View File

@@ -1,12 +1,14 @@
<p align="center"> <p align="center">
<a href="https://langbot.app"> <a href="https://langbot.app">
<img src="https://docs.langbot.app/social_en.png" alt="LangBot"/> <img width="130" src="https://docs.langbot.app/langbot-logo.png" alt="LangBot"/>
</a> </a>
<div align="center"> <div align="center">
<a href="https://www.producthunt.com/products/langbot?utm_source=badge-follow&utm_medium=badge&utm_source=badge-langbot" target="_blank"><img src="https://api.producthunt.com/widgets/embed-image/v1/follow.svg?product_id=1077185&theme=light" alt="LangBot - Production&#0045;grade&#0032;IM&#0032;bot&#0032;made&#0032;easy&#0046; | Product Hunt" style="width: 250px; height: 54px;" width="250" height="54" /></a> <a href="https://www.producthunt.com/products/langbot?utm_source=badge-follow&utm_medium=badge&utm_source=badge-langbot" target="_blank"><img src="https://api.producthunt.com/widgets/embed-image/v1/follow.svg?product_id=1077185&theme=light" alt="LangBot - Production&#0045;grade&#0032;IM&#0032;bot&#0032;made&#0032;easy&#0046; | Product Hunt" style="width: 250px; height: 54px;" width="250" height="54" /></a>
<h3>Cree, depure y despliegue bots de mensajería instantánea rápidamente con LangBot.</h3>
[English](README_EN.md) / [简体中文](README.md) / [繁體中文](README_TW.md) / [日本語](README_JP.md) / Español / [Français](README_FR.md) / [한국어](README_KO.md) / [Русский](README_RU.md) / [Tiếng Việt](README_VI.md) [English](README_EN.md) / [简体中文](README.md) / [繁體中文](README_TW.md) / [日本語](README_JP.md) / Español / [Français](README_FR.md) / [한국어](README_KO.md) / [Русский](README_RU.md) / [Tiếng Việt](README_VI.md)
[![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)
@@ -23,7 +25,6 @@
</p> </p>
LangBot es una plataforma de desarrollo de robots de mensajería instantánea nativa de LLM de código abierto, con el objetivo de proporcionar una experiencia de desarrollo de robots de mensajería instantánea lista para usar, con funciones de aplicación LLM como Agent, RAG, MCP, adaptándose a plataformas de mensajería instantánea globales y proporcionando interfaces API ricas, compatible con desarrollo personalizado.
## 📦 Comenzar ## 📦 Comenzar
@@ -79,6 +80,9 @@ Haga clic en los botones Star y Watch en la esquina superior derecha del reposit
## ✨ Características ## ✨ Características
<img width="500" src="https://docs.langbot.app/ui/bot-page-en-rounded.png" />
- 💬 Chat con LLM / Agent: Compatible con múltiples LLMs, adaptado para chats grupales y privados; Admite conversaciones de múltiples rondas, llamadas a herramientas, capacidades multimodales y de salida en streaming. Implementación RAG (base de conocimientos) incorporada, e integración profunda con [Dify](https://dify.ai), [Coze](https://coze.com), [n8n](https://n8n.io) etc. LLMOps platforms. - 💬 Chat con LLM / Agent: Compatible con múltiples LLMs, adaptado para chats grupales y privados; Admite conversaciones de múltiples rondas, llamadas a herramientas, capacidades multimodales y de salida en streaming. Implementación RAG (base de conocimientos) incorporada, e integración profunda con [Dify](https://dify.ai), [Coze](https://coze.com), [n8n](https://n8n.io) etc. LLMOps platforms.
- 🤖 Soporte Multiplataforma: Actualmente compatible con QQ, QQ Channel, WeCom, WeChat personal, Lark, DingTalk, Discord, Telegram, KOOK, Slack, LINE, etc. - 🤖 Soporte Multiplataforma: Actualmente compatible con QQ, QQ Channel, WeCom, WeChat personal, Lark, DingTalk, Discord, Telegram, KOOK, Slack, LINE, etc.
- 🛠️ Alta Estabilidad, Rico en Funciones: Control de acceso nativo, limitación de velocidad, filtrado de palabras sensibles, etc.; Fácil de usar, admite múltiples métodos de despliegue. Compatible con múltiples configuraciones de pipeline, diferentes bots para diferentes escenarios. - 🛠️ Alta Estabilidad, Rico en Funciones: Control de acceso nativo, limitación de velocidad, filtrado de palabras sensibles, etc.; Fácil de usar, admite múltiples métodos de despliegue. Compatible con múltiples configuraciones de pipeline, diferentes bots para diferentes escenarios.

View File

@@ -1,12 +1,14 @@
<p align="center"> <p align="center">
<a href="https://langbot.app"> <a href="https://langbot.app">
<img src="https://docs.langbot.app/social_en.png" alt="LangBot"/> <img width="130" src="https://docs.langbot.app/langbot-logo.png" alt="LangBot"/>
</a> </a>
<div align="center"> <div align="center">
<a href="https://www.producthunt.com/products/langbot?utm_source=badge-follow&utm_medium=badge&utm_source=badge-langbot" target="_blank"><img src="https://api.producthunt.com/widgets/embed-image/v1/follow.svg?product_id=1077185&theme=light" alt="LangBot - Production&#0045;grade&#0032;IM&#0032;bot&#0032;made&#0032;easy&#0046; | Product Hunt" style="width: 250px; height: 54px;" width="250" height="54" /></a> <a href="https://www.producthunt.com/products/langbot?utm_source=badge-follow&utm_medium=badge&utm_source=badge-langbot" target="_blank"><img src="https://api.producthunt.com/widgets/embed-image/v1/follow.svg?product_id=1077185&theme=light" alt="LangBot - Production&#0045;grade&#0032;IM&#0032;bot&#0032;made&#0032;easy&#0046; | Product Hunt" style="width: 250px; height: 54px;" width="250" height="54" /></a>
<h3>Créez, déboguez et déployez rapidement des bots de messagerie instantanée avec LangBot.</h3>
[English](README_EN.md) / [简体中文](README.md) / [繁體中文](README_TW.md) / [日本語](README_JP.md) / [Español](README_ES.md) / Français / [한국어](README_KO.md) / [Русский](README_RU.md) / [Tiếng Việt](README_VI.md) [English](README_EN.md) / [简体中文](README.md) / [繁體中文](README_TW.md) / [日本語](README_JP.md) / [Español](README_ES.md) / Français / [한국어](README_KO.md) / [Русский](README_RU.md) / [Tiếng Việt](README_VI.md)
[![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)
@@ -23,8 +25,6 @@
</p> </p>
LangBot est une plateforme de développement de robots de messagerie instantanée native LLM open source, visant à fournir une expérience de développement de robots de messagerie instantanée prête à l'emploi, avec des fonctionnalités d'application LLM telles qu'Agent, RAG, MCP, s'adaptant aux plateformes de messagerie instantanée mondiales et fournissant des interfaces API riches, prenant en charge le développement personnalisé.
## 📦 Commencer ## 📦 Commencer
#### Démarrage Rapide #### Démarrage Rapide
@@ -79,6 +79,9 @@ Cliquez sur les boutons Star et Watch dans le coin supérieur droit du dépôt p
## ✨ Fonctionnalités ## ✨ Fonctionnalités
<img width="500" src="https://docs.langbot.app/ui/bot-page-en-rounded.png" />
- 💬 Chat avec LLM / Agent : Prend en charge plusieurs LLM, adapté aux chats de groupe et privés ; Prend en charge les conversations multi-tours, les appels d'outils, les capacités multimodales et de sortie en streaming. Implémentation RAG (base de connaissances) intégrée, et intégration profonde avec [Dify](https://dify.ai), [Coze](https://coze.com), [n8n](https://n8n.io) etc. LLMOps platforms. - 💬 Chat avec LLM / Agent : Prend en charge plusieurs LLM, adapté aux chats de groupe et privés ; Prend en charge les conversations multi-tours, les appels d'outils, les capacités multimodales et de sortie en streaming. Implémentation RAG (base de connaissances) intégrée, et intégration profonde avec [Dify](https://dify.ai), [Coze](https://coze.com), [n8n](https://n8n.io) etc. LLMOps platforms.
- 🤖 Support Multi-plateforme : Actuellement compatible avec QQ, QQ Channel, WeCom, WeChat personnel, Lark, DingTalk, Discord, Telegram, KOOK, Slack, LINE, etc. - 🤖 Support Multi-plateforme : Actuellement compatible avec QQ, QQ Channel, WeCom, WeChat personnel, Lark, DingTalk, Discord, Telegram, KOOK, Slack, LINE, etc.
- 🛠️ Haute Stabilité, Riche en Fonctionnalités : Contrôle d'accès natif, limitation de débit, filtrage de mots sensibles, etc. ; Facile à utiliser, prend en charge plusieurs méthodes de déploiement. Prend en charge plusieurs configurations de pipeline, différents bots pour différents scénarios. - 🛠️ Haute Stabilité, Riche en Fonctionnalités : Contrôle d'accès natif, limitation de débit, filtrage de mots sensibles, etc. ; Facile à utiliser, prend en charge plusieurs méthodes de déploiement. Prend en charge plusieurs configurations de pipeline, différents bots pour différents scénarios.

View File

@@ -1,12 +1,14 @@
<p align="center"> <p align="center">
<a href="https://langbot.app"> <a href="https://langbot.app">
<img src="https://docs.langbot.app/social_en.png" alt="LangBot"/> <img width="130" src="https://docs.langbot.app/langbot-logo.png" alt="LangBot"/>
</a> </a>
<div align="center"> <div align="center">
<a href="https://www.producthunt.com/products/langbot?utm_source=badge-follow&utm_medium=badge&utm_source=badge-langbot" target="_blank"><img src="https://api.producthunt.com/widgets/embed-image/v1/follow.svg?product_id=1077185&theme=light" alt="LangBot - Production&#0045;grade&#0032;IM&#0032;bot&#0032;made&#0032;easy&#0046; | Product Hunt" style="width: 250px; height: 54px;" width="250" height="54" /></a> <a href="https://www.producthunt.com/products/langbot?utm_source=badge-follow&utm_medium=badge&utm_source=badge-langbot" target="_blank"><img src="https://api.producthunt.com/widgets/embed-image/v1/follow.svg?product_id=1077185&theme=light" alt="LangBot - Production&#0045;grade&#0032;IM&#0032;bot&#0032;made&#0032;easy&#0046; | Product Hunt" style="width: 250px; height: 54px;" width="250" height="54" /></a>
<h3>LangBotでIMボットを素早く構築、デバッグ、デプロイ。</h3>
[English](README_EN.md) / [简体中文](README.md) / [繁體中文](README_TW.md) / 日本語 / [Español](README_ES.md) / [Français](README_FR.md) / [한국어](README_KO.md) / [Русский](README_RU.md) / [Tiếng Việt](README_VI.md) [English](README_EN.md) / [简体中文](README.md) / [繁體中文](README_TW.md) / 日本語 / [Español](README_ES.md) / [Français](README_FR.md) / [한국어](README_KO.md) / [Русский](README_RU.md) / [Tiếng Việt](README_VI.md)
[![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)
@@ -23,8 +25,6 @@
</p> </p>
LangBot は、エージェント、RAG、MCP などの LLM アプリケーション機能を備えた、オープンソースの LLM ネイティブのインスタントメッセージングロボット開発プラットフォームです。世界中のインスタントメッセージングプラットフォームに適応し、豊富な API インターフェースを提供し、カスタム開発をサポートします。
## 📦 始め方 ## 📦 始め方
#### クイックスタート #### クイックスタート
@@ -79,6 +79,9 @@ LangBotはBTPanelにリストされています。BTPanelをインストール
## ✨ 機能 ## ✨ 機能
<img width="500" src="https://docs.langbot.app/ui/bot-page-en-rounded.png" />
- 💬 LLM / エージェントとのチャット: 複数のLLMをサポートし、グループチャットとプライベートチャットに対応。マルチラウンドの会話、ツールの呼び出し、マルチモーダル、ストリーミング出力機能をサポート、RAG知識ベースを組み込み、[Dify](https://dify.ai)、[Coze](https://coze.com)、[n8n](https://n8n.io) などの LLMOps プラットフォームと深く統合。 - 💬 LLM / エージェントとのチャット: 複数のLLMをサポートし、グループチャットとプライベートチャットに対応。マルチラウンドの会話、ツールの呼び出し、マルチモーダル、ストリーミング出力機能をサポート、RAG知識ベースを組み込み、[Dify](https://dify.ai)、[Coze](https://coze.com)、[n8n](https://n8n.io) などの LLMOps プラットフォームと深く統合。
- 🤖 多プラットフォーム対応: 現在、QQ、QQ チャンネル、WeChat、個人 WeChat、Lark、DingTalk、Discord、Telegram、KOOK、Slack、LINE など、複数のプラットフォームをサポートしています。 - 🤖 多プラットフォーム対応: 現在、QQ、QQ チャンネル、WeChat、個人 WeChat、Lark、DingTalk、Discord、Telegram、KOOK、Slack、LINE など、複数のプラットフォームをサポートしています。
- 🛠️ 高い安定性、豊富な機能: ネイティブのアクセス制御、レート制限、敏感な単語のフィルタリングなどのメカニズムをサポート。使いやすく、複数のデプロイ方法をサポート。複数のパイプライン設定をサポートし、異なるボットを異なる用途に使用できます。 - 🛠️ 高い安定性、豊富な機能: ネイティブのアクセス制御、レート制限、敏感な単語のフィルタリングなどのメカニズムをサポート。使いやすく、複数のデプロイ方法をサポート。複数のパイプライン設定をサポートし、異なるボットを異なる用途に使用できます。

View File

@@ -1,12 +1,14 @@
<p align="center"> <p align="center">
<a href="https://langbot.app"> <a href="https://langbot.app">
<img src="https://docs.langbot.app/social_en.png" alt="LangBot"/> <img width="130" src="https://docs.langbot.app/langbot-logo.png" alt="LangBot"/>
</a> </a>
<div align="center"> <div align="center">
<a href="https://www.producthunt.com/products/langbot?utm_source=badge-follow&utm_medium=badge&utm_source=badge-langbot" target="_blank"><img src="https://api.producthunt.com/widgets/embed-image/v1/follow.svg?product_id=1077185&theme=light" alt="LangBot - Production&#0045;grade&#0032;IM&#0032;bot&#0032;made&#0032;easy&#0046; | Product Hunt" style="width: 250px; height: 54px;" width="250" height="54" /></a> <a href="https://www.producthunt.com/products/langbot?utm_source=badge-follow&utm_medium=badge&utm_source=badge-langbot" target="_blank"><img src="https://api.producthunt.com/widgets/embed-image/v1/follow.svg?product_id=1077185&theme=light" alt="LangBot - Production&#0045;grade&#0032;IM&#0032;bot&#0032;made&#0032;easy&#0046; | Product Hunt" style="width: 250px; height: 54px;" width="250" height="54" /></a>
<h3>LangBot으로 IM 봇을 빠르게 구축, 디버그 및 배포하세요.</h3>
[English](README_EN.md) / [简体中文](README.md) / [繁體中文](README_TW.md) / [日本語](README_JP.md) / [Español](README_ES.md) / [Français](README_FR.md) / 한국어 / [Русский](README_RU.md) / [Tiếng Việt](README_VI.md) [English](README_EN.md) / [简体中文](README.md) / [繁體中文](README_TW.md) / [日本語](README_JP.md) / [Español](README_ES.md) / [Français](README_FR.md) / 한국어 / [Русский](README_RU.md) / [Tiếng Việt](README_VI.md)
[![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)
@@ -23,8 +25,6 @@
</p> </p>
LangBot은 오픈 소스 LLM 네이티브 인스턴트 메시징 로봇 개발 플랫폼으로, Agent, RAG, MCP 등 다양한 LLM 애플리케이션 기능을 갖춘 즉시 사용 가능한 IM 로봇 개발 경험을 제공하며, 글로벌 인스턴트 메시징 플랫폼에 적응하고 풍부한 API 인터페이스를 제공하여 맞춤형 개발을 지원합니다.
## 📦 시작하기 ## 📦 시작하기
#### 빠른 시작 #### 빠른 시작
@@ -79,6 +79,9 @@ LangBot은 BTPanel에 등록되어 있습니다. BTPanel을 설치한 경우 [
## ✨ 기능 ## ✨ 기능
<img width="500" src="https://docs.langbot.app/ui/bot-page-en-rounded.png" />
- 💬 LLM / Agent와 채팅: 여러 LLM을 지원하며 그룹 채팅 및 개인 채팅에 적응; 멀티 라운드 대화, 도구 호출, 멀티모달, 스트리밍 출력 기능을 지원합니다. 내장된 RAG(지식 베이스) 구현 및 [Dify](https://dify.ai)、[Coze](https://coze.com)、[n8n](https://n8n.io) 등의 LLMOps 플랫폼과 깊이 통합됩니다. - 💬 LLM / Agent와 채팅: 여러 LLM을 지원하며 그룹 채팅 및 개인 채팅에 적응; 멀티 라운드 대화, 도구 호출, 멀티모달, 스트리밍 출력 기능을 지원합니다. 내장된 RAG(지식 베이스) 구현 및 [Dify](https://dify.ai)、[Coze](https://coze.com)、[n8n](https://n8n.io) 등의 LLMOps 플랫폼과 깊이 통합됩니다.
- 🤖 다중 플랫폼 지원: 현재 QQ, QQ Channel, WeCom, 개인 WeChat, Lark, DingTalk, Discord, Telegram, KOOK, Slack, LINE 등을 지원합니다. - 🤖 다중 플랫폼 지원: 현재 QQ, QQ Channel, WeCom, 개인 WeChat, Lark, DingTalk, Discord, Telegram, KOOK, Slack, LINE 등을 지원합니다.
- 🛠️ 높은 안정성, 풍부한 기능: 네이티브 액세스 제어, 속도 제한, 민감한 단어 필터링 등의 메커니즘; 사용하기 쉽고 여러 배포 방법을 지원합니다. 여러 파이프라인 구성을 지원하며 다양한 시나리오에 대해 다른 봇을 사용할 수 있습니다. - 🛠️ 높은 안정성, 풍부한 기능: 네이티브 액세스 제어, 속도 제한, 민감한 단어 필터링 등의 메커니즘; 사용하기 쉽고 여러 배포 방법을 지원합니다. 여러 파이프라인 구성을 지원하며 다양한 시나리오에 대해 다른 봇을 사용할 수 있습니다.

View File

@@ -1,12 +1,14 @@
<p align="center"> <p align="center">
<a href="https://langbot.app"> <a href="https://langbot.app">
<img src="https://docs.langbot.app/social_en.png" alt="LangBot"/> <img width="130" src="https://docs.langbot.app/langbot-logo.png" alt="LangBot"/>
</a> </a>
<div align="center"> <div align="center">
<a href="https://www.producthunt.com/products/langbot?utm_source=badge-follow&utm_medium=badge&utm_source=badge-langbot" target="_blank"><img src="https://api.producthunt.com/widgets/embed-image/v1/follow.svg?product_id=1077185&theme=light" alt="LangBot - Production&#0045;grade&#0032;IM&#0032;bot&#0032;made&#0032;easy&#0046; | Product Hunt" style="width: 250px; height: 54px;" width="250" height="54" /></a> <a href="https://www.producthunt.com/products/langbot?utm_source=badge-follow&utm_medium=badge&utm_source=badge-langbot" target="_blank"><img src="https://api.producthunt.com/widgets/embed-image/v1/follow.svg?product_id=1077185&theme=light" alt="LangBot - Production&#0045;grade&#0032;IM&#0032;bot&#0032;made&#0032;easy&#0046; | Product Hunt" style="width: 250px; height: 54px;" width="250" height="54" /></a>
<h3>Быстро создавайте, отлаживайте и развертывайте IM-ботов с LangBot.</h3>
[English](README_EN.md) / [简体中文](README.md) / [繁體中文](README_TW.md) / [日本語](README_JP.md) / [Español](README_ES.md) / [Français](README_FR.md) / [한국어](README_KO.md) / Русский / [Tiếng Việt](README_VI.md) [English](README_EN.md) / [简体中文](README.md) / [繁體中文](README_TW.md) / [日本語](README_JP.md) / [Español](README_ES.md) / [Français](README_FR.md) / [한국어](README_KO.md) / Русский / [Tiếng Việt](README_VI.md)
[![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)
@@ -23,8 +25,6 @@
</p> </p>
LangBot — это платформа разработки ботов для мгновенных сообщений на основе LLM с открытым исходным кодом, целью которой является предоставление готового к использованию опыта разработки ботов для IM, с функциями приложений LLM, такими как Agent, RAG, MCP, адаптацией к глобальным платформам мгновенных сообщений и предоставлением богатых API-интерфейсов, поддерживающих пользовательскую разработку.
## 📦 Начало работы ## 📦 Начало работы
#### Быстрый старт #### Быстрый старт
@@ -79,6 +79,9 @@ LangBot добавлен в BTPanel. Если у вас установлен BTP
## ✨ Функции ## ✨ Функции
<img width="500" src="https://docs.langbot.app/ui/bot-page-en-rounded.png" />
- 💬 Чат с LLM / Agent: Поддержка нескольких LLM, адаптация к групповым и личным чатам; Поддержка многораундовых разговоров, вызовов инструментов, мультимодальных возможностей и потоковой передачи. Встроенная реализация RAG (база знаний) и глубокая интеграция с [Dify](https://dify.ai), [Coze](https://coze.com), [n8n](https://n8n.io) 등의 LLMOps 플랫포트폼과 깊이 통합됩니다. - 💬 Чат с LLM / Agent: Поддержка нескольких LLM, адаптация к групповым и личным чатам; Поддержка многораундовых разговоров, вызовов инструментов, мультимодальных возможностей и потоковой передачи. Встроенная реализация RAG (база знаний) и глубокая интеграция с [Dify](https://dify.ai), [Coze](https://coze.com), [n8n](https://n8n.io) 등의 LLMOps 플랫포트폼과 깊이 통합됩니다.
- 🤖 Многоплатформенная поддержка: В настоящее время поддерживает QQ, QQ Channel, WeCom, личный WeChat, Lark, DingTalk, Discord, Telegram, KOOK, Slack, LINE и т.д. - 🤖 Многоплатформенная поддержка: В настоящее время поддерживает QQ, QQ Channel, WeCom, личный WeChat, Lark, DingTalk, Discord, Telegram, KOOK, Slack, LINE и т.д.
- 🛠️ Высокая стабильность, богатство функций: Нативный контроль доступа, ограничение скорости, фильтрация чувствительных слов и т.д.; Простота в использовании, поддержка нескольких методов развертывания. Поддержка нескольких конфигураций конвейера, разные боты для разных сценариев. - 🛠️ Высокая стабильность, богатство функций: Нативный контроль доступа, ограничение скорости, фильтрация чувствительных слов и т.д.; Простота в использовании, поддержка нескольких методов развертывания. Поддержка нескольких конфигураций конвейера, разные боты для разных сценариев.

View File

@@ -1,10 +1,12 @@
<p align="center"> <p align="center">
<a href="https://langbot.app"> <a href="https://langbot.app">
<img src="https://docs.langbot.app/social_zh.png" alt="LangBot"/> <img width="130" src="https://docs.langbot.app/langbot-logo.png" alt="LangBot"/>
</a> </a>
<div align="center"><a href="https://hellogithub.com/repository/langbot-app/LangBot" target="_blank"><img src="https://abroad.hellogithub.com/v1/widgets/recommend.svg?rid=5ce8ae2aa4f74316bf393b57b952433c&claim_uid=gtmc6YWjMZkT21R" alt="FeaturedHelloGitHub" style="width: 250px; height: 54px;" width="250" height="54" /></a> <div align="center"><a href="https://hellogithub.com/repository/langbot-app/LangBot" target="_blank"><img src="https://abroad.hellogithub.com/v1/widgets/recommend.svg?rid=5ce8ae2aa4f74316bf393b57b952433c&claim_uid=gtmc6YWjMZkT21R" alt="FeaturedHelloGitHub" style="width: 250px; height: 54px;" width="250" height="54" /></a>
<h3>使用 LangBot 快速建構、除錯和部署 IM 機器人。</h3>
[English](README_EN.md) / [简体中文](README.md) / 繁體中文 / [日本語](README_JP.md) / [Español](README_ES.md) / [Français](README_FR.md) / [한국어](README_KO.md) / [Русский](README_RU.md) / [Tiếng Việt](README_VI.md) [English](README_EN.md) / [简体中文](README.md) / 繁體中文 / [日本語](README_JP.md) / [Español](README_ES.md) / [Français](README_FR.md) / [한국어](README_KO.md) / [Русский](README_RU.md) / [Tiếng Việt](README_VI.md)
[![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)
@@ -23,8 +25,6 @@
</p> </p>
LangBot 是一個開源的大語言模型原生即時通訊機器人開發平台,旨在提供開箱即用的 IM 機器人開發體驗,具有 Agent、RAG、MCP 等多種 LLM 應用功能,適配全球主流即時通訊平台,並提供豐富的 API 介面,支援自定義開發。
## 📦 開始使用 ## 📦 開始使用
#### 快速部署 #### 快速部署
@@ -79,6 +79,9 @@ docker compose up -d
## ✨ 特性 ## ✨ 特性
<img width="500" src="https://docs.langbot.app/ui/bot-page-en-rounded.png" />
- 💬 大模型對話、Agent支援多種大模型適配群聊和私聊具有多輪對話、工具調用、多模態、流式輸出能力自帶 RAG知識庫實現並深度適配 [Dify](https://dify.ai)、[Coze](https://coze.com)、[n8n](https://n8n.io) 等 LLMOps 平台。 - 💬 大模型對話、Agent支援多種大模型適配群聊和私聊具有多輪對話、工具調用、多模態、流式輸出能力自帶 RAG知識庫實現並深度適配 [Dify](https://dify.ai)、[Coze](https://coze.com)、[n8n](https://n8n.io) 等 LLMOps 平台。
- 🤖 多平台支援:目前支援 QQ、QQ頻道、企業微信、個人微信、飛書、Discord、Telegram、KOOK、Slack、LINE 等平台。 - 🤖 多平台支援:目前支援 QQ、QQ頻道、企業微信、個人微信、飛書、Discord、Telegram、KOOK、Slack、LINE 等平台。
- 🛠️ 高穩定性、功能完備:原生支援訪問控制、限速、敏感詞過濾等機制;配置簡單,支援多種部署方式。支援多流水線配置,不同機器人用於不同應用場景。 - 🛠️ 高穩定性、功能完備:原生支援訪問控制、限速、敏感詞過濾等機制;配置簡單,支援多種部署方式。支援多流水線配置,不同機器人用於不同應用場景。

View File

@@ -1,12 +1,14 @@
<p align="center"> <p align="center">
<a href="https://langbot.app"> <a href="https://langbot.app">
<img src="https://docs.langbot.app/social_en.png" alt="LangBot"/> <img width="130" src="https://docs.langbot.app/langbot-logo.png" alt="LangBot"/>
</a> </a>
<div align="center"> <div align="center">
<a href="https://www.producthunt.com/products/langbot?utm_source=badge-follow&utm_medium=badge&utm_source=badge-langbot" target="_blank"><img src="https://api.producthunt.com/widgets/embed-image/v1/follow.svg?product_id=1077185&theme=light" alt="LangBot - Production&#0045;grade&#0032;IM&#0032;bot&#0032;made&#0032;easy&#0046; | Product Hunt" style="width: 250px; height: 54px;" width="250" height="54" /></a> <a href="https://www.producthunt.com/products/langbot?utm_source=badge-follow&utm_medium=badge&utm_source=badge-langbot" target="_blank"><img src="https://api.producthunt.com/widgets/embed-image/v1/follow.svg?product_id=1077185&theme=light" alt="LangBot - Production&#0045;grade&#0032;IM&#0032;bot&#0032;made&#0032;easy&#0046; | Product Hunt" style="width: 250px; height: 54px;" width="250" height="54" /></a>
<h3>Xây dựng, gỡ lỗi và triển khai bot IM nhanh chóng với LangBot.</h3>
[English](README_EN.md) / [简体中文](README.md) / [繁體中文](README_TW.md) / [日本語](README_JP.md) / [Español](README_ES.md) / [Français](README_FR.md) / [한국어](README_KO.md) / [Русский](README_RU.md) / Tiếng Việt [English](README_EN.md) / [简体中文](README.md) / [繁體中文](README_TW.md) / [日本語](README_JP.md) / [Español](README_ES.md) / [Français](README_FR.md) / [한국어](README_KO.md) / [Русский](README_RU.md) / Tiếng Việt
[![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)
@@ -23,8 +25,6 @@
</p> </p>
LangBot là một nền tảng phát triển robot nhắn tin tức thời gốc LLM mã nguồn mở, nhằm mục đích cung cấp trải nghiệm phát triển robot IM sẵn sàng sử dụng, với các chức năng ứng dụng LLM như Agent, RAG, MCP, thích ứng với các nền tảng nhắn tin tức thời toàn cầu và cung cấp giao diện API phong phú, hỗ trợ phát triển tùy chỉnh.
## 📦 Bắt đầu ## 📦 Bắt đầu
#### Khởi động Nhanh #### Khởi động Nhanh
@@ -79,6 +79,9 @@ Nhấp vào các nút Star và Watch ở góc trên bên phải của kho lưu t
## ✨ Tính năng ## ✨ Tính năng
<img width="500" src="https://docs.langbot.app/ui/bot-page-en-rounded.png" />
- 💬 Chat với LLM / Agent: Hỗ trợ nhiều LLM, thích ứng với chat nhóm và chat riêng tư; Hỗ trợ các cuộc trò chuyện nhiều vòng, gọi công cụ, khả năng đa phương thức và đầu ra streaming. Triển khai RAG (cơ sở kiến thức) tích hợp sẵn và tích hợp sâu với [Dify](https://dify.ai), [Coze](https://coze.com), [n8n](https://n8n.io) v.v. LLMOps platforms. - 💬 Chat với LLM / Agent: Hỗ trợ nhiều LLM, thích ứng với chat nhóm và chat riêng tư; Hỗ trợ các cuộc trò chuyện nhiều vòng, gọi công cụ, khả năng đa phương thức và đầu ra streaming. Triển khai RAG (cơ sở kiến thức) tích hợp sẵn và tích hợp sâu với [Dify](https://dify.ai), [Coze](https://coze.com), [n8n](https://n8n.io) v.v. LLMOps platforms.
- 🤖 Hỗ trợ Đa nền tảng: Hiện hỗ trợ QQ, QQ Channel, WeCom, WeChat cá nhân, Lark, DingTalk, Discord, Telegram, KOOK, Slack, LINE, v.v. - 🤖 Hỗ trợ Đa nền tảng: Hiện hỗ trợ QQ, QQ Channel, WeCom, WeChat cá nhân, Lark, DingTalk, Discord, Telegram, KOOK, Slack, LINE, v.v.
- 🛠️ Độ ổn định Cao, Tính năng Phong phú: Kiểm soát truy cập gốc, giới hạn tốc độ, lọc từ nhạy cảm, v.v.; Dễ sử dụng, hỗ trợ nhiều phương pháp triển khai. Hỗ trợ nhiều cấu hình pipeline, các bot khác nhau cho các kịch bản khác nhau. - 🛠️ Độ ổn định Cao, Tính năng Phong phú: Kiểm soát truy cập gốc, giới hạn tốc độ, lọc từ nhạy cảm, v.v.; Dễ sử dụng, hỗ trợ nhiều phương pháp triển khai. Hỗ trợ nhiều cấu hình pipeline, các bot khác nhau cho các kịch bản khác nhau.

View File

@@ -25,7 +25,6 @@ services:
platform: linux/amd64 # For Apple Silicon compatibility platform: linux/amd64 # For Apple Silicon compatibility
volumes: volumes:
- ./data:/app/data - ./data:/app/data
- ./plugins:/app/plugins
restart: on-failure restart: on-failure
environment: environment:
- TZ=Asia/Shanghai - TZ=Asia/Shanghai

View File

@@ -1,6 +1,6 @@
[project] [project]
name = "langbot" name = "langbot"
version = "4.6.2" version = "4.6.4"
description = "Easy-to-use global IM bot platform designed for LLM era" description = "Easy-to-use global IM bot platform designed for LLM era"
readme = "README.md" readme = "README.md"
license-files = ["LICENSE"] license-files = ["LICENSE"]
@@ -63,11 +63,13 @@ dependencies = [
"langchain-text-splitters>=0.0.1", "langchain-text-splitters>=0.0.1",
"chromadb>=0.4.24", "chromadb>=0.4.24",
"qdrant-client (>=1.15.1,<2.0.0)", "qdrant-client (>=1.15.1,<2.0.0)",
"langbot-plugin==0.2.0", "langbot-plugin==0.2.3",
"asyncpg>=0.30.0", "asyncpg>=0.30.0",
"line-bot-sdk>=3.19.0", "line-bot-sdk>=3.19.0",
"tboxsdk>=0.0.10", "tboxsdk>=0.0.10",
"boto3>=1.35.0", "boto3>=1.35.0",
"pymilvus>=2.6.4",
"pgvector>=0.4.1",
] ]
keywords = [ keywords = [
"bot", "bot",

View File

@@ -1,3 +1,3 @@
"""LangBot - Easy-to-use global IM bot platform designed for LLM era""" """LangBot - Easy-to-use global IM bot platform designed for LLM era"""
__version__ = '4.6.2' __version__ = '4.6.4'

View File

@@ -394,7 +394,6 @@ class WecomBotClient:
""" """
try: try:
self.wxcpt = WXBizMsgCrypt(self.Token, self.EnCodingAESKey, '') self.wxcpt = WXBizMsgCrypt(self.Token, self.EnCodingAESKey, '')
await self.logger.info(f'{req.method} {req.url} {str(req.args)}')
if req.method == 'GET': if req.method == 'GET':
return await self._handle_get_callback(req) return await self._handle_get_callback(req)
@@ -458,32 +457,174 @@ class WecomBotClient:
async def get_message(self, msg_json): async def get_message(self, msg_json):
message_data = {} message_data = {}
msg_type = msg_json.get('msgtype', '')
if msg_type:
message_data['msgtype'] = msg_type
if msg_json.get('chattype', '') == 'single': if msg_json.get('chattype', '') == 'single':
message_data['type'] = 'single' message_data['type'] = 'single'
elif msg_json.get('chattype', '') == 'group': elif msg_json.get('chattype', '') == 'group':
message_data['type'] = 'group' message_data['type'] = 'group'
if msg_json.get('msgtype') == 'text': max_inline_file_size = 5 * 1024 * 1024 # avoid decoding very large payloads by default
async def _safe_download(url: str):
if not url:
return None
return await self.download_url_to_base64(url, self.EnCodingAESKey)
if msg_type == 'text':
message_data['content'] = msg_json.get('text', {}).get('content') message_data['content'] = msg_json.get('text', {}).get('content')
elif msg_json.get('msgtype') == 'image': elif msg_type == 'markdown':
message_data['content'] = msg_json.get('markdown', {}).get('content') or msg_json.get('text', {}).get(
'content', ''
)
elif msg_type == 'image':
picurl = msg_json.get('image', {}).get('url', '') picurl = msg_json.get('image', {}).get('url', '')
base64 = await self.download_url_to_base64(picurl, self.EnCodingAESKey) base64_data = await _safe_download(picurl)
message_data['picurl'] = base64 if base64_data:
elif msg_json.get('msgtype') == 'mixed': message_data['picurl'] = base64_data
message_data['images'] = [base64_data]
elif msg_type == 'voice':
voice_info = msg_json.get('voice', {}) or {}
download_url = voice_info.get('url')
message_data['voice'] = {
'url': download_url,
'md5sum': voice_info.get('md5sum') or voice_info.get('md5'),
'filesize': voice_info.get('filesize') or voice_info.get('size'),
'sdkfileid': voice_info.get('sdkfileid') or voice_info.get('fileid'),
}
# 企业微信智能转写文本(如果已有)直接复用,避免重复转写
if voice_info.get('content'):
message_data['content'] = voice_info.get('content')
if (message_data['voice'].get('filesize') or 0) <= max_inline_file_size:
voice_base64 = await _safe_download(download_url)
if voice_base64:
message_data['voice']['base64'] = voice_base64
elif msg_type == 'video':
video_info = msg_json.get('video', {}) or {}
download_url = video_info.get('url')
video_data = {
'url': download_url,
'filesize': video_info.get('filesize') or video_info.get('size'),
'sdkfileid': video_info.get('sdkfileid') or video_info.get('fileid'),
'md5sum': video_info.get('md5sum') or video_info.get('md5'),
'filename': video_info.get('filename') or video_info.get('name'),
}
if (video_data.get('filesize') or 0) <= max_inline_file_size:
video_base64 = await _safe_download(download_url)
if video_base64:
video_data['base64'] = video_base64
message_data['video'] = video_data
elif msg_type == 'file':
file_info = msg_json.get('file', {}) or {}
download_url = file_info.get('url') or file_info.get('fileurl')
file_data = {
'filename': file_info.get('filename') or file_info.get('name'),
'filesize': file_info.get('filesize') or file_info.get('size'),
'md5sum': file_info.get('md5sum') or file_info.get('md5'),
'sdkfileid': file_info.get('sdkfileid') or file_info.get('fileid'),
'download_url': download_url,
'extra': file_info,
}
if (file_data.get('filesize') or 0) <= max_inline_file_size:
file_base64 = await _safe_download(download_url)
if file_base64:
file_data['base64'] = file_base64
message_data['file'] = file_data
elif msg_type == 'link':
message_data['link'] = msg_json.get('link', {})
if not message_data.get('content'):
title = message_data['link'].get('title', '')
desc = message_data['link'].get('description') or message_data['link'].get('digest', '')
message_data['content'] = '\n'.join(filter(None, [title, desc]))
elif msg_type == 'mixed':
items = msg_json.get('mixed', {}).get('msg_item', []) items = msg_json.get('mixed', {}).get('msg_item', [])
texts = [] texts = []
picurl = None images = []
files = []
voices = []
videos = []
links = []
for item in items: for item in items:
if item.get('msgtype') == 'text': item_type = item.get('msgtype')
if item_type == 'text':
texts.append(item.get('text', {}).get('content', '')) texts.append(item.get('text', {}).get('content', ''))
elif item.get('msgtype') == 'image' and picurl is None: elif item_type == 'image':
picurl = item.get('image', {}).get('url') img_url = item.get('image', {}).get('url')
base64_data = await _safe_download(img_url)
if base64_data:
images.append(base64_data)
elif item_type == 'file':
file_info = item.get('file', {}) or {}
download_url = file_info.get('url') or file_info.get('fileurl')
file_data = {
'filename': file_info.get('filename') or file_info.get('name'),
'filesize': file_info.get('filesize') or file_info.get('size'),
'md5sum': file_info.get('md5sum') or file_info.get('md5'),
'sdkfileid': file_info.get('sdkfileid') or file_info.get('fileid'),
'download_url': download_url,
'extra': file_info,
}
if (file_data.get('filesize') or 0) <= max_inline_file_size:
file_base64 = await _safe_download(download_url)
if file_base64:
file_data['base64'] = file_base64
files.append(file_data)
elif item_type == 'voice':
voice_info = item.get('voice', {}) or {}
download_url = voice_info.get('url')
voice_data = {
'url': download_url,
'md5sum': voice_info.get('md5sum') or voice_info.get('md5'),
'filesize': voice_info.get('filesize') or voice_info.get('size'),
'sdkfileid': voice_info.get('sdkfileid') or voice_info.get('fileid'),
}
if voice_info.get('content'):
texts.append(voice_info.get('content'))
if (voice_data.get('filesize') or 0) <= max_inline_file_size:
voice_base64 = await _safe_download(download_url)
if voice_base64:
voice_data['base64'] = voice_base64
voices.append(voice_data)
elif item_type == 'video':
video_info = item.get('video', {}) or {}
download_url = video_info.get('url')
video_data = {
'url': download_url,
'filesize': video_info.get('filesize') or video_info.get('size'),
'sdkfileid': video_info.get('sdkfileid') or video_info.get('fileid'),
'md5sum': video_info.get('md5sum') or video_info.get('md5'),
'filename': video_info.get('filename') or video_info.get('name'),
}
if (video_data.get('filesize') or 0) <= max_inline_file_size:
video_base64 = await _safe_download(download_url)
if video_base64:
video_data['base64'] = video_base64
videos.append(video_data)
elif item_type == 'link':
links.append(item.get('link', {}))
if texts: if texts:
message_data['content'] = ''.join(texts) # 拼接所有 text message_data['content'] = ' '.join(texts) # 拼接所有 text
if picurl: if images:
base64 = await self.download_url_to_base64(picurl, self.EnCodingAESKey) message_data['images'] = images
message_data['picurl'] = base64 # 只保留第一个 image message_data['picurl'] = images[0] # 只保留第一个 image
if files:
message_data['files'] = files
message_data['file'] = files[0]
if voices:
message_data['voices'] = voices
message_data['voice'] = voices[0]
if videos:
message_data['videos'] = videos
message_data['video'] = videos[0]
if links:
message_data['link'] = links[0]
if items:
message_data['attachments'] = items
else:
message_data['raw_msg'] = msg_json
# Extract user information # Extract user information
from_info = msg_json.get('from', {}) from_info = msg_json.get('from', {})

View File

@@ -17,6 +17,13 @@ class WecomBotEvent(dict):
""" """
return self.get('type', '') return self.get('type', '')
@property
def msgtype(self) -> str:
"""
消息 msgtype
"""
return self.get('msgtype', '')
@property @property
def userid(self) -> str: def userid(self) -> str:
""" """
@@ -52,6 +59,55 @@ class WecomBotEvent(dict):
""" """
return self.get('picurl', '') return self.get('picurl', '')
@property
def images(self):
"""
图片列表(兼容 mixed
"""
return self.get('images', [])
@property
def file(self):
"""
文件信息
"""
return self.get('file', {})
@property
def voice(self):
"""
语音信息
"""
return self.get('voice', {})
@property
def video(self):
"""
视频信息
"""
return self.get('video', {})
@property
def link(self):
"""
链接消息信息
"""
return self.get('link', {})
@property
def location(self):
"""
位置信息
"""
return self.get('location', {})
@property
def attachments(self):
"""
原始 mixed 中的附件项
"""
return self.get('attachments', [])
@property @property
def chatid(self) -> str: def chatid(self) -> str:
""" """

View File

@@ -139,6 +139,58 @@ class WecomClient:
await self.logger.error(f'发送图片失败:{data}') await self.logger.error(f'发送图片失败:{data}')
raise Exception('Failed to send image: ' + str(data)) raise Exception('Failed to send image: ' + str(data))
async def send_voice(self, user_id: str, agent_id: int, media_id: str):
if not await self.check_access_token():
self.access_token = await self.get_access_token(self.secret)
url = self.base_url + '/message/send?access_token=' + self.access_token
async with httpx.AsyncClient() as client:
params = {
'touser': user_id,
'msgtype': 'voice',
'agentid': agent_id,
'voice': {
'media_id': media_id,
},
'safe': 0,
'enable_id_trans': 0,
'enable_duplicate_check': 0,
'duplicate_check_interval': 1800,
}
response = await client.post(url, json=params)
data = response.json()
if data['errcode'] == 40014 or data['errcode'] == 42001:
self.access_token = await self.get_access_token(self.secret)
return await self.send_voice(user_id, agent_id, media_id)
if data['errcode'] != 0:
await self.logger.error(f'发送语音失败:{data}')
raise Exception('Failed to send voice: ' + str(data))
async def send_file(self, user_id: str, agent_id: int, media_id: str):
if not await self.check_access_token():
self.access_token = await self.get_access_token(self.secret)
url = self.base_url + '/message/send?access_token=' + self.access_token
async with httpx.AsyncClient() as client:
params = {
'touser': user_id,
'msgtype': 'file',
'agentid': agent_id,
'file': {
'media_id': media_id,
},
'safe': 0,
'enable_id_trans': 0,
'enable_duplicate_check': 0,
'duplicate_check_interval': 1800,
}
response = await client.post(url, json=params)
data = response.json()
if data['errcode'] == 40014 or data['errcode'] == 42001:
self.access_token = await self.get_access_token(self.secret)
return await self.send_file(user_id, agent_id, media_id)
if data['errcode'] != 0:
await self.logger.error(f'发送文件失败:{data}')
raise Exception('Failed to send file: ' + str(data))
async def send_private_msg(self, user_id: str, agent_id: int, content: str): async def send_private_msg(self, user_id: str, agent_id: int, content: str):
if not await self.check_access_token(): if not await self.check_access_token():
self.access_token = await self.get_access_token(self.secret) self.access_token = await self.get_access_token(self.secret)
@@ -287,7 +339,7 @@ class WecomClient:
return ext return ext
return 'jpg' # 默认返回jpg return 'jpg' # 默认返回jpg
async def upload_to_work(self, image: platform_message.Image): async def upload_image_to_work(self, image: platform_message.Image):
""" """
获取 media_id 获取 media_id
""" """
@@ -304,7 +356,7 @@ class WecomClient:
file_bytes = await f.read() file_bytes = await f.read()
file_name = image.path.split('/')[-1] file_name = image.path.split('/')[-1]
elif image.url: elif image.url:
file_bytes = await self.download_image_to_bytes(image.url) file_bytes = await self.download_media_to_bytes(image.url)
file_name = image.url.split('/')[-1] file_name = image.url.split('/')[-1]
elif image.base64: elif image.base64:
try: try:
@@ -339,7 +391,7 @@ class WecomClient:
data = response.json() data = response.json()
if data['errcode'] == 40014 or data['errcode'] == 42001: if data['errcode'] == 40014 or data['errcode'] == 42001:
self.access_token = await self.get_access_token(self.secret) self.access_token = await self.get_access_token(self.secret)
media_id = await self.upload_to_work(image) media_id = await self.upload_image_to_work(image)
if data.get('errcode', 0) != 0: if data.get('errcode', 0) != 0:
await self.logger.error(f'上传图片失败:{data}') await self.logger.error(f'上传图片失败:{data}')
raise Exception('failed to upload file') raise Exception('failed to upload file')
@@ -347,13 +399,128 @@ class WecomClient:
media_id = data.get('media_id') media_id = data.get('media_id')
return media_id return media_id
async def download_image_to_bytes(self, url: str) -> bytes: async def upload_voice_to_work(self, voice: platform_message.Voice):
"""
上传语音文件到企业微信
"""
if not await self.check_access_token():
self.access_token = await self.get_access_token(self.secret)
url = self.base_url + '/media/upload?access_token=' + self.access_token + '&type=file'
file_bytes = None
file_name = 'voice.mp3'
if voice.path:
async with aiofiles.open(voice.path, 'rb') as f:
file_bytes = await f.read()
file_name = voice.path.split('/')[-1]
elif voice.url:
file_bytes = await self.download_media_to_bytes(voice.url)
file_name = voice.url.split('/')[-1]
elif voice.base64:
try:
base64_data = voice.base64
if ',' in base64_data:
base64_data = base64_data.split(',', 1)[1]
padding = 4 - (len(base64_data) % 4) if len(base64_data) % 4 else 0
padded_base64 = base64_data + '=' * padding
file_bytes = base64.b64decode(padded_base64)
except binascii.Error as e:
raise ValueError(f'Invalid base64 string: {str(e)}')
else:
await self.logger.error('Voice对象出错')
raise ValueError('voice对象出错')
boundary = '-------------------------acebdf13572468'
headers = {'Content-Type': f'multipart/form-data; boundary={boundary}'}
body = (
(
f'--{boundary}\r\n'
f'Content-Disposition: form-data; name="media"; filename="{file_name}"; filelength={len(file_bytes)}\r\n'
f'Content-Type: application/octet-stream\r\n\r\n'
).encode('utf-8')
+ file_bytes
+ f'\r\n--{boundary}--\r\n'.encode('utf-8')
)
# print(body)
async with httpx.AsyncClient() as client:
response = await client.post(url, headers=headers, content=body)
data = response.json()
if data['errcode'] == 40014 or data['errcode'] == 42001:
self.access_token = await self.get_access_token(self.secret)
media_id = await self.upload_voice_to_work(voice)
if data.get('errcode', 0) != 0:
await self.logger.error(f'上传语音文件失败:{data}')
raise Exception('failed to upload file')
media_id = data.get('media_id')
return media_id
async def upload_file_to_work(self, file: platform_message.File):
"""
上传文件到企业微信
"""
if not await self.check_access_token():
self.access_token = await self.get_access_token(self.secret)
url = self.base_url + '/media/upload?access_token=' + self.access_token + '&type=file'
file_bytes = None
file_name = 'file.txt'
if file.path:
async with aiofiles.open(file.path, 'rb') as f:
file_bytes = await f.read()
file_name = file.path.split('/')[-1]
elif file.url:
file_bytes = await self.download_media_to_bytes(file.url)
file_name = file.url.split('/')[-1]
elif file.base64:
try:
base64_data = file.base64
if ',' in base64_data:
base64_data = base64_data.split(',', 1)[1]
padding = 4 - (len(base64_data) % 4) if len(base64_data) % 4 else 0
padded_base64 = base64_data + '=' * padding
file_bytes = base64.b64decode(padded_base64)
except binascii.Error as e:
raise ValueError(f'Invalid base64 string: {str(e)}')
else:
await self.logger.error('File对象出错')
raise ValueError('file对象出错')
boundary = '-------------------------acebdf13572468'
headers = {'Content-Type': f'multipart/form-data; boundary={boundary}'}
body = (
(
f'--{boundary}\r\n'
f'Content-Disposition: form-data; name="media"; filename="{file_name}"; filelength={len(file_bytes)}\r\n'
f'Content-Type: application/octet-stream\r\n\r\n'
).encode('utf-8')
+ file_bytes
+ f'\r\n--{boundary}--\r\n'.encode('utf-8')
)
async with httpx.AsyncClient() as client:
response = await client.post(url, headers=headers, content=body)
data = response.json()
if data['errcode'] == 40014 or data['errcode'] == 42001:
self.access_token = await self.get_access_token(self.secret)
media_id = await self.upload_file_to_work(file)
if data.get('errcode', 0) != 0:
await self.logger.error(f'上传文件失败:{data}')
raise Exception('failed to upload file')
media_id = data.get('media_id')
return media_id
async def download_media_to_bytes(self, url: str) -> bytes:
async with httpx.AsyncClient() as client: async with httpx.AsyncClient() as client:
response = await client.get(url) response = await client.get(url)
response.raise_for_status() response.raise_for_status()
return response.content return response.content
# 进行media_id的获取 # 进行media_id的获取
async def get_media_id(self, image: platform_message.Image): async def get_media_id(self, media: platform_message.Image | platform_message.Voice | platform_message.File):
media_id = await self.upload_to_work(image=image) if isinstance(media, platform_message.Image):
media_id = await self.upload_image_to_work(image=media)
elif isinstance(media, platform_message.Voice):
media_id = await self.upload_voice_to_work(voice=media)
elif isinstance(media, platform_message.File):
media_id = await self.upload_file_to_work(file=media)
else:
raise ValueError('Unsupported media type')
return media_id return media_id

View File

@@ -92,7 +92,11 @@ class HTTPController:
@self.quart_app.route('/') @self.quart_app.route('/')
async def index(): async def index():
return await quart.send_from_directory(frontend_path, 'index.html', mimetype='text/html') response = await quart.send_from_directory(frontend_path, 'index.html', mimetype='text/html')
response.headers['Cache-Control'] = 'no-cache, no-store, must-revalidate'
response.headers['Pragma'] = 'no-cache'
response.headers['Expires'] = '0'
return response
@self.quart_app.route('/<path:path>') @self.quart_app.route('/<path:path>')
async def static_file(path: str): async def static_file(path: str):

View File

@@ -59,7 +59,7 @@ class BotService:
adapter_runtime_values['bot_account_id'] = runtime_bot.adapter.bot_account_id adapter_runtime_values['bot_account_id'] = runtime_bot.adapter.bot_account_id
# Webhook URL for unified webhook adapters (independent of bot running state) # Webhook URL for unified webhook adapters (independent of bot running state)
if persistence_bot['adapter'] in ['wecom', 'wecombot', 'officialaccount', 'qqofficial', 'slack', 'wecomcs', 'LINE']: if persistence_bot['adapter'] in ['wecom', 'wecombot', 'officialaccount', 'qqofficial', 'slack', 'wecomcs', 'LINE', 'lark']:
webhook_prefix = self.ap.instance_config.data['api'].get('webhook_prefix', 'http://127.0.0.1:5300') webhook_prefix = self.ap.instance_config.data['api'].get('webhook_prefix', 'http://127.0.0.1:5300')
webhook_url = f'/bots/{bot_uuid}' webhook_url = f'/bots/{bot_uuid}'
adapter_runtime_values['webhook_url'] = webhook_url adapter_runtime_values['webhook_url'] = webhook_url

View File

@@ -1,8 +1,7 @@
from .. import migration from .. import migration
import sqlalchemy import sqlalchemy
import json
from ...entity.persistence import pipeline as persistence_pipeline
@migration.migration_class(2) @migration.migration_class(2)
@@ -11,30 +10,45 @@ class DBMigrateCombineQuoteMsgConfig(migration.DBMigration):
async def upgrade(self): async def upgrade(self):
"""Upgrade""" """Upgrade"""
# read all pipelines # Read all pipelines using raw SQL
pipelines = await self.ap.persistence_mgr.execute_async(sqlalchemy.select(persistence_pipeline.LegacyPipeline)) result = await self.ap.persistence_mgr.execute_async(
sqlalchemy.text('SELECT uuid, config FROM legacy_pipelines')
)
pipelines = result.fetchall()
for pipeline in pipelines: current_version = self.ap.ver_mgr.get_current_version()
serialized_pipeline = self.ap.persistence_mgr.serialize_model(persistence_pipeline.LegacyPipeline, pipeline)
config = serialized_pipeline['config'] for pipeline_row in pipelines:
uuid = pipeline_row[0]
config = json.loads(pipeline_row[1]) if isinstance(pipeline_row[1], str) else pipeline_row[1]
# Ensure 'trigger' exists
if 'trigger' not in config:
config['trigger'] = {}
# Ensure 'misc' exists in 'trigger'
if 'misc' not in config['trigger']: if 'misc' not in config['trigger']:
config['trigger']['misc'] = {} config['trigger']['misc'] = {}
# Add 'combine-quote-message' if not exists
if 'combine-quote-message' not in config['trigger']['misc']: if 'combine-quote-message' not in config['trigger']['misc']:
config['trigger']['misc']['combine-quote-message'] = False config['trigger']['misc']['combine-quote-message'] = False
await self.ap.persistence_mgr.execute_async( # Update using raw SQL with compatibility for both SQLite and PostgreSQL
sqlalchemy.update(persistence_pipeline.LegacyPipeline) if self.ap.persistence_mgr.db.name == 'postgresql':
.where(persistence_pipeline.LegacyPipeline.uuid == serialized_pipeline['uuid']) await self.ap.persistence_mgr.execute_async(
.values( sqlalchemy.text(
{ 'UPDATE legacy_pipelines SET config = :config::jsonb, for_version = :for_version WHERE uuid = :uuid'
'config': config, ),
'for_version': self.ap.ver_mgr.get_current_version(), {'config': json.dumps(config), 'for_version': current_version, 'uuid': uuid},
} )
else:
await self.ap.persistence_mgr.execute_async(
sqlalchemy.text(
'UPDATE legacy_pipelines SET config = :config, for_version = :for_version WHERE uuid = :uuid'
),
{'config': json.dumps(config), 'for_version': current_version, 'uuid': uuid},
) )
)
async def downgrade(self): async def downgrade(self):
"""Downgrade""" """Downgrade"""

View File

@@ -1,8 +1,7 @@
from .. import migration from .. import migration
import sqlalchemy import sqlalchemy
import json
from ...entity.persistence import pipeline as persistence_pipeline
@migration.migration_class(3) @migration.migration_class(3)
@@ -11,14 +10,23 @@ class DBMigrateN8nConfig(migration.DBMigration):
async def upgrade(self): async def upgrade(self):
"""Upgrade""" """Upgrade"""
# read all pipelines # Read all pipelines using raw SQL
pipelines = await self.ap.persistence_mgr.execute_async(sqlalchemy.select(persistence_pipeline.LegacyPipeline)) result = await self.ap.persistence_mgr.execute_async(
sqlalchemy.text('SELECT uuid, config FROM legacy_pipelines')
)
pipelines = result.fetchall()
for pipeline in pipelines: current_version = self.ap.ver_mgr.get_current_version()
serialized_pipeline = self.ap.persistence_mgr.serialize_model(persistence_pipeline.LegacyPipeline, pipeline)
config = serialized_pipeline['config'] for pipeline_row in pipelines:
uuid = pipeline_row[0]
config = json.loads(pipeline_row[1]) if isinstance(pipeline_row[1], str) else pipeline_row[1]
# Ensure 'ai' exists
if 'ai' not in config:
config['ai'] = {}
# Add 'n8n-service-api' if not exists
if 'n8n-service-api' not in config['ai']: if 'n8n-service-api' not in config['ai']:
config['ai']['n8n-service-api'] = { config['ai']['n8n-service-api'] = {
'webhook-url': 'http://your-n8n-webhook-url', 'webhook-url': 'http://your-n8n-webhook-url',
@@ -33,16 +41,21 @@ class DBMigrateN8nConfig(migration.DBMigration):
'output-key': 'response', 'output-key': 'response',
} }
await self.ap.persistence_mgr.execute_async( # Update using raw SQL with compatibility for both SQLite and PostgreSQL
sqlalchemy.update(persistence_pipeline.LegacyPipeline) if self.ap.persistence_mgr.db.name == 'postgresql':
.where(persistence_pipeline.LegacyPipeline.uuid == serialized_pipeline['uuid']) await self.ap.persistence_mgr.execute_async(
.values( sqlalchemy.text(
{ 'UPDATE legacy_pipelines SET config = :config::jsonb, for_version = :for_version WHERE uuid = :uuid'
'config': config, ),
'for_version': self.ap.ver_mgr.get_current_version(), {'config': json.dumps(config), 'for_version': current_version, 'uuid': uuid},
} )
else:
await self.ap.persistence_mgr.execute_async(
sqlalchemy.text(
'UPDATE legacy_pipelines SET config = :config, for_version = :for_version WHERE uuid = :uuid'
),
{'config': json.dumps(config), 'for_version': current_version, 'uuid': uuid},
) )
)
async def downgrade(self): async def downgrade(self):
"""Downgrade""" """Downgrade"""

View File

@@ -1,8 +1,7 @@
from .. import migration from .. import migration
import sqlalchemy import sqlalchemy
import json
from ...entity.persistence import pipeline as persistence_pipeline
@migration.migration_class(4) @migration.migration_class(4)
@@ -11,27 +10,43 @@ class DBMigrateRAGKBUUID(migration.DBMigration):
async def upgrade(self): async def upgrade(self):
"""升级""" """升级"""
# read all pipelines # Read all pipelines using raw SQL
pipelines = await self.ap.persistence_mgr.execute_async(sqlalchemy.select(persistence_pipeline.LegacyPipeline)) result = await self.ap.persistence_mgr.execute_async(
sqlalchemy.text('SELECT uuid, config FROM legacy_pipelines')
)
pipelines = result.fetchall()
for pipeline in pipelines: current_version = self.ap.ver_mgr.get_current_version()
serialized_pipeline = self.ap.persistence_mgr.serialize_model(persistence_pipeline.LegacyPipeline, pipeline)
config = serialized_pipeline['config'] for pipeline_row in pipelines:
uuid = pipeline_row[0]
config = json.loads(pipeline_row[1]) if isinstance(pipeline_row[1], str) else pipeline_row[1]
# Ensure nested structure exists
if 'ai' not in config:
config['ai'] = {}
if 'local-agent' not in config['ai']:
config['ai']['local-agent'] = {}
# Add 'knowledge-base' if not exists
if 'knowledge-base' not in config['ai']['local-agent']: if 'knowledge-base' not in config['ai']['local-agent']:
config['ai']['local-agent']['knowledge-base'] = '' config['ai']['local-agent']['knowledge-base'] = ''
await self.ap.persistence_mgr.execute_async( # Update using raw SQL with compatibility for both SQLite and PostgreSQL
sqlalchemy.update(persistence_pipeline.LegacyPipeline) if self.ap.persistence_mgr.db.name == 'postgresql':
.where(persistence_pipeline.LegacyPipeline.uuid == serialized_pipeline['uuid']) await self.ap.persistence_mgr.execute_async(
.values( sqlalchemy.text(
{ 'UPDATE legacy_pipelines SET config = :config::jsonb, for_version = :for_version WHERE uuid = :uuid'
'config': config, ),
'for_version': self.ap.ver_mgr.get_current_version(), {'config': json.dumps(config), 'for_version': current_version, 'uuid': uuid},
} )
else:
await self.ap.persistence_mgr.execute_async(
sqlalchemy.text(
'UPDATE legacy_pipelines SET config = :config, for_version = :for_version WHERE uuid = :uuid'
),
{'config': json.dumps(config), 'for_version': current_version, 'uuid': uuid},
) )
)
async def downgrade(self): async def downgrade(self):
"""降级""" """降级"""

View File

@@ -1,8 +1,7 @@
from .. import migration from .. import migration
import sqlalchemy import sqlalchemy
import json
from ...entity.persistence import pipeline as persistence_pipeline
@migration.migration_class(5) @migration.migration_class(5)
@@ -11,27 +10,43 @@ class DBMigratePipelineRemoveCotConfig(migration.DBMigration):
async def upgrade(self): async def upgrade(self):
"""Upgrade""" """Upgrade"""
# read all pipelines # Read all pipelines using raw SQL
pipelines = await self.ap.persistence_mgr.execute_async(sqlalchemy.select(persistence_pipeline.LegacyPipeline)) result = await self.ap.persistence_mgr.execute_async(
sqlalchemy.text('SELECT uuid, config FROM legacy_pipelines')
)
pipelines = result.fetchall()
for pipeline in pipelines: current_version = self.ap.ver_mgr.get_current_version()
serialized_pipeline = self.ap.persistence_mgr.serialize_model(persistence_pipeline.LegacyPipeline, pipeline)
config = serialized_pipeline['config'] for pipeline_row in pipelines:
uuid = pipeline_row[0]
config = json.loads(pipeline_row[1]) if isinstance(pipeline_row[1], str) else pipeline_row[1]
# Ensure nested structure exists
if 'output' not in config:
config['output'] = {}
if 'misc' not in config['output']:
config['output']['misc'] = {}
# Add 'remove-think' if not exists
if 'remove-think' not in config['output']['misc']: if 'remove-think' not in config['output']['misc']:
config['output']['misc']['remove-think'] = False config['output']['misc']['remove-think'] = False
await self.ap.persistence_mgr.execute_async( # Update using raw SQL with compatibility for both SQLite and PostgreSQL
sqlalchemy.update(persistence_pipeline.LegacyPipeline) if self.ap.persistence_mgr.db.name == 'postgresql':
.where(persistence_pipeline.LegacyPipeline.uuid == serialized_pipeline['uuid']) await self.ap.persistence_mgr.execute_async(
.values( sqlalchemy.text(
{ 'UPDATE legacy_pipelines SET config = :config::jsonb, for_version = :for_version WHERE uuid = :uuid'
'config': config, ),
'for_version': self.ap.ver_mgr.get_current_version(), {'config': json.dumps(config), 'for_version': current_version, 'uuid': uuid},
} )
else:
await self.ap.persistence_mgr.execute_async(
sqlalchemy.text(
'UPDATE legacy_pipelines SET config = :config, for_version = :for_version WHERE uuid = :uuid'
),
{'config': json.dumps(config), 'for_version': current_version, 'uuid': uuid},
) )
)
async def downgrade(self): async def downgrade(self):
"""Downgrade""" """Downgrade"""

View File

@@ -1,8 +1,7 @@
from .. import migration from .. import migration
import sqlalchemy import sqlalchemy
import json
from ...entity.persistence import pipeline as persistence_pipeline
@migration.migration_class(6) @migration.migration_class(6)
@@ -11,14 +10,23 @@ class DBMigrateLangflowApiConfig(migration.DBMigration):
async def upgrade(self): async def upgrade(self):
"""Upgrade""" """Upgrade"""
# read all pipelines # Read all pipelines using raw SQL
pipelines = await self.ap.persistence_mgr.execute_async(sqlalchemy.select(persistence_pipeline.LegacyPipeline)) result = await self.ap.persistence_mgr.execute_async(
sqlalchemy.text('SELECT uuid, config FROM legacy_pipelines')
)
pipelines = result.fetchall()
for pipeline in pipelines: current_version = self.ap.ver_mgr.get_current_version()
serialized_pipeline = self.ap.persistence_mgr.serialize_model(persistence_pipeline.LegacyPipeline, pipeline)
config = serialized_pipeline['config'] for pipeline_row in pipelines:
uuid = pipeline_row[0]
config = json.loads(pipeline_row[1]) if isinstance(pipeline_row[1], str) else pipeline_row[1]
# Ensure 'ai' exists
if 'ai' not in config:
config['ai'] = {}
# Add 'langflow-api' if not exists
if 'langflow-api' not in config['ai']: if 'langflow-api' not in config['ai']:
config['ai']['langflow-api'] = { config['ai']['langflow-api'] = {
'base-url': 'http://localhost:7860', 'base-url': 'http://localhost:7860',
@@ -29,16 +37,21 @@ class DBMigrateLangflowApiConfig(migration.DBMigration):
'tweaks': '{}', 'tweaks': '{}',
} }
await self.ap.persistence_mgr.execute_async( # Update using raw SQL with compatibility for both SQLite and PostgreSQL
sqlalchemy.update(persistence_pipeline.LegacyPipeline) if self.ap.persistence_mgr.db.name == 'postgresql':
.where(persistence_pipeline.LegacyPipeline.uuid == serialized_pipeline['uuid']) await self.ap.persistence_mgr.execute_async(
.values( sqlalchemy.text(
{ 'UPDATE legacy_pipelines SET config = :config::jsonb, for_version = :for_version WHERE uuid = :uuid'
'config': config, ),
'for_version': self.ap.ver_mgr.get_current_version(), {'config': json.dumps(config), 'for_version': current_version, 'uuid': uuid},
} )
else:
await self.ap.persistence_mgr.execute_async(
sqlalchemy.text(
'UPDATE legacy_pipelines SET config = :config, for_version = :for_version WHERE uuid = :uuid'
),
{'config': json.dumps(config), 'for_version': current_version, 'uuid': uuid},
) )
)
async def downgrade(self): async def downgrade(self):
"""Downgrade""" """Downgrade"""

View File

@@ -1,8 +1,7 @@
from .. import migration from .. import migration
import sqlalchemy import sqlalchemy
import json
from ...entity.persistence import pipeline as persistence_pipeline
@migration.migration_class(10) @migration.migration_class(10)
@@ -11,16 +10,20 @@ class DBMigratePipelineMultiKnowledgeBase(migration.DBMigration):
async def upgrade(self): async def upgrade(self):
"""Upgrade""" """Upgrade"""
# read all pipelines # Read all pipelines using raw SQL
pipelines = await self.ap.persistence_mgr.execute_async(sqlalchemy.select(persistence_pipeline.LegacyPipeline)) result = await self.ap.persistence_mgr.execute_async(
sqlalchemy.text('SELECT uuid, config FROM legacy_pipelines')
)
pipelines = result.fetchall()
for pipeline in pipelines: current_version = self.ap.ver_mgr.get_current_version()
serialized_pipeline = self.ap.persistence_mgr.serialize_model(persistence_pipeline.LegacyPipeline, pipeline)
config = serialized_pipeline['config'] for pipeline_row in pipelines:
uuid = pipeline_row[0]
config = json.loads(pipeline_row[1]) if isinstance(pipeline_row[1], str) else pipeline_row[1]
# Convert knowledge-base from string to array # Convert knowledge-base from string to array
if 'local-agent' in config['ai']: if 'ai' in config and 'local-agent' in config['ai']:
current_kb = config['ai']['local-agent'].get('knowledge-base', '') current_kb = config['ai']['local-agent'].get('knowledge-base', '')
# If it's already a list, skip # If it's already a list, skip
@@ -37,29 +40,38 @@ class DBMigratePipelineMultiKnowledgeBase(migration.DBMigration):
if 'knowledge-base' in config['ai']['local-agent']: if 'knowledge-base' in config['ai']['local-agent']:
del config['ai']['local-agent']['knowledge-base'] del config['ai']['local-agent']['knowledge-base']
await self.ap.persistence_mgr.execute_async( # Update using raw SQL with compatibility for both SQLite and PostgreSQL
sqlalchemy.update(persistence_pipeline.LegacyPipeline) if self.ap.persistence_mgr.db.name == 'postgresql':
.where(persistence_pipeline.LegacyPipeline.uuid == serialized_pipeline['uuid']) await self.ap.persistence_mgr.execute_async(
.values( sqlalchemy.text(
{ 'UPDATE legacy_pipelines SET config = :config::jsonb, for_version = :for_version WHERE uuid = :uuid'
'config': config, ),
'for_version': self.ap.ver_mgr.get_current_version(), {'config': json.dumps(config), 'for_version': current_version, 'uuid': uuid},
} )
) else:
) await self.ap.persistence_mgr.execute_async(
sqlalchemy.text(
'UPDATE legacy_pipelines SET config = :config, for_version = :for_version WHERE uuid = :uuid'
),
{'config': json.dumps(config), 'for_version': current_version, 'uuid': uuid},
)
async def downgrade(self): async def downgrade(self):
"""Downgrade""" """Downgrade"""
# read all pipelines # Read all pipelines using raw SQL
pipelines = await self.ap.persistence_mgr.execute_async(sqlalchemy.select(persistence_pipeline.LegacyPipeline)) result = await self.ap.persistence_mgr.execute_async(
sqlalchemy.text('SELECT uuid, config FROM legacy_pipelines')
)
pipelines = result.fetchall()
for pipeline in pipelines: current_version = self.ap.ver_mgr.get_current_version()
serialized_pipeline = self.ap.persistence_mgr.serialize_model(persistence_pipeline.LegacyPipeline, pipeline)
config = serialized_pipeline['config'] for pipeline_row in pipelines:
uuid = pipeline_row[0]
config = json.loads(pipeline_row[1]) if isinstance(pipeline_row[1], str) else pipeline_row[1]
# Convert knowledge-bases from array back to string # Convert knowledge-bases from array back to string
if 'local-agent' in config['ai']: if 'ai' in config and 'local-agent' in config['ai']:
current_kbs = config['ai']['local-agent'].get('knowledge-bases', []) current_kbs = config['ai']['local-agent'].get('knowledge-bases', [])
# If it's already a string, skip # If it's already a string, skip
@@ -76,13 +88,18 @@ class DBMigratePipelineMultiKnowledgeBase(migration.DBMigration):
if 'knowledge-bases' in config['ai']['local-agent']: if 'knowledge-bases' in config['ai']['local-agent']:
del config['ai']['local-agent']['knowledge-bases'] del config['ai']['local-agent']['knowledge-bases']
await self.ap.persistence_mgr.execute_async( # Update using raw SQL with compatibility for both SQLite and PostgreSQL
sqlalchemy.update(persistence_pipeline.LegacyPipeline) if self.ap.persistence_mgr.db.name == 'postgresql':
.where(persistence_pipeline.LegacyPipeline.uuid == serialized_pipeline['uuid']) await self.ap.persistence_mgr.execute_async(
.values( sqlalchemy.text(
{ 'UPDATE legacy_pipelines SET config = :config::jsonb, for_version = :for_version WHERE uuid = :uuid'
'config': config, ),
'for_version': self.ap.ver_mgr.get_current_version(), {'config': json.dumps(config), 'for_version': current_version, 'uuid': uuid},
} )
) else:
) await self.ap.persistence_mgr.execute_async(
sqlalchemy.text(
'UPDATE legacy_pipelines SET config = :config, for_version = :for_version WHERE uuid = :uuid'
),
{'config': json.dumps(config), 'for_version': current_version, 'uuid': uuid},
)

View File

@@ -1,8 +1,7 @@
from .. import migration from .. import migration
import sqlalchemy import sqlalchemy
import json
from ...entity.persistence import pipeline as persistence_pipeline
@migration.migration_class(11) @migration.migration_class(11)
@@ -11,29 +10,45 @@ class DBMigrateDifyApiConfig(migration.DBMigration):
async def upgrade(self): async def upgrade(self):
"""Upgrade""" """Upgrade"""
# read all pipelines # Read all pipelines using raw SQL
pipelines = await self.ap.persistence_mgr.execute_async(sqlalchemy.select(persistence_pipeline.LegacyPipeline)) result = await self.ap.persistence_mgr.execute_async(
sqlalchemy.text('SELECT uuid, config FROM legacy_pipelines')
)
pipelines = result.fetchall()
for pipeline in pipelines: current_version = self.ap.ver_mgr.get_current_version()
serialized_pipeline = self.ap.persistence_mgr.serialize_model(persistence_pipeline.LegacyPipeline, pipeline)
config = serialized_pipeline['config'] for pipeline_row in pipelines:
uuid = pipeline_row[0]
config = json.loads(pipeline_row[1]) if isinstance(pipeline_row[1], str) else pipeline_row[1]
# Ensure nested structure exists
if 'ai' not in config:
config['ai'] = {}
if 'dify-service-api' not in config['ai']:
config['ai']['dify-service-api'] = {}
# Add 'base-prompt' if not exists
if 'base-prompt' not in config['ai']['dify-service-api']: if 'base-prompt' not in config['ai']['dify-service-api']:
config['ai']['dify-service-api']['base-prompt'] = ( config['ai']['dify-service-api']['base-prompt'] = (
'When the file content is readable, please read the content of this file. When the file is an image, describe the content of this image.', 'When the file content is readable, please read the content of this file. When the file is an image, describe the content of this image.',
) )
await self.ap.persistence_mgr.execute_async( # Update using raw SQL with compatibility for both SQLite and PostgreSQL
sqlalchemy.update(persistence_pipeline.LegacyPipeline) if self.ap.persistence_mgr.db.name == 'postgresql':
.where(persistence_pipeline.LegacyPipeline.uuid == serialized_pipeline['uuid']) await self.ap.persistence_mgr.execute_async(
.values( sqlalchemy.text(
{ 'UPDATE legacy_pipelines SET config = :config::jsonb, for_version = :for_version WHERE uuid = :uuid'
'config': config, ),
'for_version': self.ap.ver_mgr.get_current_version(), {'config': json.dumps(config), 'for_version': current_version, 'uuid': uuid},
} )
else:
await self.ap.persistence_mgr.execute_async(
sqlalchemy.text(
'UPDATE legacy_pipelines SET config = :config, for_version = :for_version WHERE uuid = :uuid'
),
{'config': json.dumps(config), 'for_version': current_version, 'uuid': uuid},
) )
)
async def downgrade(self): async def downgrade(self):
"""Downgrade""" """Downgrade"""

View File

@@ -1,8 +1,7 @@
from .. import migration from .. import migration
import sqlalchemy import sqlalchemy
import json
from ...entity.persistence import pipeline as persistence_pipeline
@migration.migration_class(12) @migration.migration_class(12)
@@ -11,14 +10,25 @@ class DBMigratePipelineExtensionsEnableAll(migration.DBMigration):
async def upgrade(self): async def upgrade(self):
"""Upgrade""" """Upgrade"""
# read all pipelines # Read all pipelines using raw SQL
pipelines = await self.ap.persistence_mgr.execute_async(sqlalchemy.select(persistence_pipeline.LegacyPipeline)) result = await self.ap.persistence_mgr.execute_async(
sqlalchemy.text('SELECT uuid, extensions_preferences FROM legacy_pipelines')
)
pipelines = result.fetchall()
for pipeline in pipelines: current_version = self.ap.ver_mgr.get_current_version()
serialized_pipeline = self.ap.persistence_mgr.serialize_model(persistence_pipeline.LegacyPipeline, pipeline)
extensions_preferences = serialized_pipeline['extensions_preferences'] for pipeline_row in pipelines:
uuid = pipeline_row[0]
extensions_preferences = (
json.loads(pipeline_row[1]) if isinstance(pipeline_row[1], str) else pipeline_row[1]
)
# Ensure extensions_preferences is a dict
if extensions_preferences is None:
extensions_preferences = {}
# Add 'enable_all_plugins' if not exists
if 'enable_all_plugins' not in extensions_preferences: if 'enable_all_plugins' not in extensions_preferences:
if 'plugins' in extensions_preferences: if 'plugins' in extensions_preferences:
extensions_preferences['enable_all_plugins'] = False extensions_preferences['enable_all_plugins'] = False
@@ -26,6 +36,7 @@ class DBMigratePipelineExtensionsEnableAll(migration.DBMigration):
extensions_preferences['enable_all_plugins'] = True extensions_preferences['enable_all_plugins'] = True
extensions_preferences['plugins'] = [] extensions_preferences['plugins'] = []
# Add 'enable_all_mcp_servers' if not exists
if 'enable_all_mcp_servers' not in extensions_preferences: if 'enable_all_mcp_servers' not in extensions_preferences:
if 'mcp_servers' in extensions_preferences: if 'mcp_servers' in extensions_preferences:
extensions_preferences['enable_all_mcp_servers'] = False extensions_preferences['enable_all_mcp_servers'] = False
@@ -33,14 +44,29 @@ class DBMigratePipelineExtensionsEnableAll(migration.DBMigration):
extensions_preferences['enable_all_mcp_servers'] = True extensions_preferences['enable_all_mcp_servers'] = True
extensions_preferences['mcp_servers'] = [] extensions_preferences['mcp_servers'] = []
await self.ap.persistence_mgr.execute_async( # Update using raw SQL with compatibility for both SQLite and PostgreSQL
sqlalchemy.update(persistence_pipeline.LegacyPipeline) if self.ap.persistence_mgr.db.name == 'postgresql':
.where(persistence_pipeline.LegacyPipeline.uuid == serialized_pipeline['uuid']) await self.ap.persistence_mgr.execute_async(
.values( sqlalchemy.text(
extensions_preferences=extensions_preferences, 'UPDATE legacy_pipelines SET extensions_preferences = :extensions_preferences::jsonb, for_version = :for_version WHERE uuid = :uuid'
for_version=self.ap.ver_mgr.get_current_version(), ),
{
'extensions_preferences': json.dumps(extensions_preferences),
'for_version': current_version,
'uuid': uuid,
},
)
else:
await self.ap.persistence_mgr.execute_async(
sqlalchemy.text(
'UPDATE legacy_pipelines SET extensions_preferences = :extensions_preferences, for_version = :for_version WHERE uuid = :uuid'
),
{
'extensions_preferences': json.dumps(extensions_preferences),
'for_version': current_version,
'uuid': uuid,
},
) )
)
async def downgrade(self): async def downgrade(self):
"""Downgrade""" """Downgrade"""

View File

@@ -237,6 +237,7 @@ class RuntimePipeline:
launcher_type=query.launcher_type.value, launcher_type=query.launcher_type.value,
launcher_id=query.launcher_id, launcher_id=query.launcher_id,
sender_id=query.sender_id, sender_id=query.sender_id,
message_event=query.message_event,
message_chain=query.message_chain, message_chain=query.message_chain,
) )

View File

@@ -7,6 +7,7 @@ from langbot_plugin.api.entities.builtin.provider import message as provider_mes
import langbot_plugin.api.entities.events as events import langbot_plugin.api.entities.events as events
import langbot_plugin.api.entities.builtin.platform.message as platform_message import langbot_plugin.api.entities.builtin.platform.message as platform_message
import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query
import langbot_plugin.api.entities.builtin.platform.events as platform_events
@stage.stage_class('PreProcessor') @stage.stage_class('PreProcessor')
@@ -74,12 +75,26 @@ class PreProcessor(stage.PipelineStage):
self.ap.logger.debug(f'Bound MCP servers: {bound_mcp_servers}') self.ap.logger.debug(f'Bound MCP servers: {bound_mcp_servers}')
self.ap.logger.debug(f'Use funcs: {query.use_funcs}') self.ap.logger.debug(f'Use funcs: {query.use_funcs}')
sender_name = ''
if isinstance(query.message_event, platform_events.GroupMessage):
sender_name = query.message_event.sender.member_name
elif isinstance(query.message_event, platform_events.FriendMessage):
sender_name = query.message_event.sender.nickname
variables = { variables = {
'launcher_type': query.session.launcher_type.value,
'launcher_id': query.session.launcher_id,
'sender_id': query.sender_id,
'session_id': f'{query.session.launcher_type.value}_{query.session.launcher_id}', 'session_id': f'{query.session.launcher_type.value}_{query.session.launcher_id}',
'conversation_id': conversation.uuid, 'conversation_id': conversation.uuid,
'msg_create_time': ( 'msg_create_time': (
int(query.message_event.time) if query.message_event.time else int(datetime.datetime.now().timestamp()) int(query.message_event.time) if query.message_event.time else int(datetime.datetime.now().timestamp())
), ),
'group_name': query.message_event.group.name
if isinstance(query.message_event, platform_events.GroupMessage)
else '',
'sender_name': sender_name,
} }
query.variables.update(variables) query.variables.update(variables)
@@ -111,6 +126,12 @@ class PreProcessor(stage.PipelineStage):
): ):
if me.base64 is not None: if me.base64 is not None:
content_list.append(provider_message.ContentElement.from_image_base64(me.base64)) content_list.append(provider_message.ContentElement.from_image_base64(me.base64))
elif isinstance(me, platform_message.Voice):
# 转成文件链接,让下游 runner 上传到目标模型
if me.base64:
content_list.append(provider_message.ContentElement.from_file_base64(me.base64, 'voice.silk'))
elif me.url:
content_list.append(provider_message.ContentElement.from_file_url(me.url, 'voice'))
elif isinstance(me, platform_message.File): elif isinstance(me, platform_message.File):
# if me.url is not None: # if me.url is not None:
content_list.append(provider_message.ContentElement.from_file_url(me.url, me.name)) content_list.append(provider_message.ContentElement.from_file_url(me.url, me.name))

View File

@@ -40,6 +40,7 @@ class ChatMessageHandler(handler.MessageHandler):
launcher_id=query.launcher_id, launcher_id=query.launcher_id,
sender_id=query.sender_id, sender_id=query.sender_id,
text_message=str(query.message_chain), text_message=str(query.message_chain),
message_event=query.message_event,
message_chain=query.message_chain, message_chain=query.message_chain,
query=query, query=query,
) )
@@ -75,7 +76,7 @@ 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"]}')
if is_stream: if is_stream:
resp_message_id = uuid.uuid4() resp_message_id = uuid.uuid4()
@@ -90,7 +91,9 @@ class ChatMessageHandler(handler.MessageHandler):
await query.adapter.create_message_card(str(resp_message_id), query.message_event) await query.adapter.create_message_card(str(resp_message_id), query.message_event)
is_create_card = True is_create_card = True
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'Conversation({query.query_id}) Streaming Response: {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)
@@ -101,7 +104,9 @@ class ChatMessageHandler(handler.MessageHandler):
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'Conversation({query.query_id}) Response: {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)
@@ -112,7 +117,7 @@ class ChatMessageHandler(handler.MessageHandler):
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'Conversation({query.query_id}) Request Failed: {type(e).__name__} {str(e)}')
traceback.print_exc() traceback.print_exc()
hide_exception_info = query.pipeline_config['output']['misc']['hide-exception'] hide_exception_info = query.pipeline_config['output']['misc']['hide-exception']

View File

@@ -327,9 +327,6 @@ class AiocqhttpEventConverter(abstract_platform_adapter.AbstractEventConverter):
permission=platform_entities.Permission.Member, permission=platform_entities.Permission.Member,
), ),
special_title=event.sender['title'] if 'title' in event.sender else '', special_title=event.sender['title'] if 'title' in event.sender else '',
join_timestamp=0,
last_speak_timestamp=0,
mute_time_remaining=0,
), ),
message_chain=yiri_chain, message_chain=yiri_chain,
time=event.time, time=event.time,

View File

@@ -119,9 +119,6 @@ class DingTalkEventConverter(abstract_platform_adapter.AbstractEventConverter):
permission=platform_entities.Permission.Member, permission=platform_entities.Permission.Member,
), ),
special_title='', special_title='',
join_timestamp=0,
last_speak_timestamp=0,
mute_time_remaining=0,
) )
time = event.incoming_message.create_at time = event.incoming_message.create_at
return platform_events.GroupMessage( return platform_events.GroupMessage(

View File

@@ -8,6 +8,9 @@ import base64
import uuid import uuid
import os import os
import datetime import datetime
# 使用BytesIO创建文件对象避免路径问题
import io
import asyncio import asyncio
from enum import Enum from enum import Enum
@@ -594,7 +597,7 @@ class DiscordMessageConverter(abstract_platform_adapter.AbstractMessageConverter
break break
text_string = '' text_string = ''
image_files = [] files = []
for ele in message_chain: for ele in message_chain:
if isinstance(ele, platform_message.Image): if isinstance(ele, platform_message.Image):
@@ -668,22 +671,67 @@ class DiscordMessageConverter(abstract_platform_adapter.AbstractMessageConverter
continue # 跳过读取失败的文件 continue # 跳过读取失败的文件
if image_bytes: if image_bytes:
# 使用BytesIO创建文件对象避免路径问题 files.append(discord.File(fp=io.BytesIO(image_bytes), filename=filename))
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.Voice):
file_bytes = None
filename = f'{uuid.uuid4()}.mp3'
if ele.base64:
if ele.base64.startswith('data:'):
data_header = ele.base64.split(',')[0]
if 'wav' in data_header:
filename = f'{uuid.uuid4()}.wav'
elif 'mp3' in data_header:
filename = f'{uuid.uuid4()}.mp3'
elif 'ogg' in data_header:
filename = f'{uuid.uuid4()}.ogg'
elif 'm4a' in data_header:
filename = f'{uuid.uuid4()}.m4a'
elif 'aac' in data_header:
filename = f'{uuid.uuid4()}.aac'
elif 'flac' in data_header:
filename = f'{uuid.uuid4()}.flac'
elif 'alac' in data_header:
filename = f'{uuid.uuid4()}.alac'
elif 'opus' in data_header:
filename = f'{uuid.uuid4()}.opus'
elif 'webm' in data_header:
filename = f'{uuid.uuid4()}.webm'
file_base64 = ele.base64.split(',')[-1]
file_bytes = base64.b64decode(file_base64)
elif ele.url:
async with aiohttp.ClientSession() as session:
async with session.get(ele.url) as response:
file_bytes = await response.read()
if file_bytes:
files.append(discord.File(fp=io.BytesIO(file_bytes), filename=filename))
elif isinstance(ele, platform_message.File):
file_bytes = None
filename = f'{uuid.uuid4()}.{ele.name.split(".")[-1]}'
if ele.base64:
if ele.base64.startswith('data:'):
file_base64 = ele.base64.split(',')[1]
file_bytes = base64.b64decode(file_base64)
else:
file_bytes = base64.b64decode(ele.base64)
elif ele.url:
async with aiohttp.ClientSession() as session:
async with session.get(ele.url) as response:
file_bytes = await response.read()
if file_bytes:
files.append(discord.File(fp=io.BytesIO(file_bytes), filename=filename))
elif isinstance(ele, platform_message.Forward): elif isinstance(ele, platform_message.Forward):
for node in ele.node_list: for node in ele.node_list:
( (
node_text, node_text,
node_images, node_files,
) = await DiscordMessageConverter.yiri2target(node.message_chain) ) = await DiscordMessageConverter.yiri2target(node.message_chain)
text_string += node_text text_string += node_text
image_files.extend(node_images) files.extend(node_files)
return text_string, image_files return text_string, files
@staticmethod @staticmethod
async def target2yiri(message: discord.Message) -> platform_message.MessageChain: async def target2yiri(message: discord.Message) -> platform_message.MessageChain:
@@ -769,9 +817,6 @@ class DiscordEventConverter(abstract_platform_adapter.AbstractEventConverter):
permission=platform_entities.Permission.Member, permission=platform_entities.Permission.Member,
), ),
special_title='', special_title='',
join_timestamp=0,
last_speak_timestamp=0,
mute_time_remaining=0,
), ),
message_chain=message_chain, message_chain=message_chain,
time=event.created_at.timestamp(), time=event.created_at.timestamp(),
@@ -993,7 +1038,7 @@ class DiscordAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
await self.voice_manager.cleanup_inactive_connections() 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):
msg_to_send, image_files = await self.message_converter.yiri2target(message) msg_to_send, files = await self.message_converter.yiri2target(message)
try: try:
# 获取频道对象 # 获取频道对象
@@ -1006,8 +1051,8 @@ class DiscordAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
'content': msg_to_send, 'content': msg_to_send,
} }
if len(image_files) > 0: if len(files) > 0:
args['files'] = image_files args['files'] = files
await channel.send(**args) await channel.send(**args)
@@ -1021,15 +1066,16 @@ class DiscordAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
message: platform_message.MessageChain, message: platform_message.MessageChain,
quote_origin: bool = False, quote_origin: bool = False,
): ):
msg_to_send, image_files = await self.message_converter.yiri2target(message) msg_to_send, files = await self.message_converter.yiri2target(message)
assert isinstance(message_source.source_platform_object, discord.Message) assert isinstance(message_source.source_platform_object, discord.Message)
args = { args = {
'content': msg_to_send, 'content': msg_to_send,
} }
if len(image_files) > 0: if len(files) > 0:
args['files'] = image_files args['files'] = files
if quote_origin: if quote_origin:
args['reference'] = message_source.source_platform_object args['reference'] = message_source.source_platform_object

View File

@@ -137,7 +137,11 @@ class KookMessageConverter(abstract_platform_adapter.AbstractMessageConverter):
# For file messages, content is typically the file URL # For file messages, content is typically the file URL
attachments = extra.get('attachments', {}) attachments = extra.get('attachments', {})
file_name = attachments.get('name', 'file') file_name = attachments.get('name', 'file')
components.append(platform_message.Plain(text=f'[File: {file_name}]')) components.append(platform_message.File(url=content, name=file_name))
elif msg_type == 8: # Audio message
# For audio messages, content is typically the audio URL
attachments = extra.get('attachments', {})
components.append(platform_message.Voice(url=content))
elif msg_type == 9: # KMarkdown message elif msg_type == 9: # KMarkdown message
# Note: content is already stripped of mention patterns above # Note: content is already stripped of mention patterns above
if content: if content:
@@ -219,9 +223,6 @@ class KookEventConverter(abstract_platform_adapter.AbstractEventConverter):
permission=platform_entities.Permission.Member, permission=platform_entities.Permission.Member,
), ),
special_title='', special_title='',
join_timestamp=0,
last_speak_timestamp=0,
mute_time_remaining=0,
), ),
message_chain=message_chain, message_chain=message_chain,
time=event_time, time=event_time,
@@ -320,9 +321,6 @@ class KookAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
data = await response.json() data = await response.json()
if data.get('code') == 0: if data.get('code') == 0:
user_info = data['data'] user_info = data['data']
await self.logger.info(
f'Retrieved bot user info: {user_info.get("username")} (ID: {user_info.get("id")})'
)
return user_info return user_info
else: else:
raise Exception(f'Failed to get bot user info: {data.get("message")}') raise Exception(f'Failed to get bot user info: {data.get("message")}')
@@ -346,11 +344,10 @@ class KookAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
# Ignore messages from bot itself to prevent infinite loops # Ignore messages from bot itself to prevent infinite loops
if self.bot_account_id and str(author_id) == self.bot_account_id: if self.bot_account_id and str(author_id) == self.bot_account_id:
await self.logger.debug(f'Ignoring message from bot itself (author_id: {author_id})')
return return
# Only process text messages (type 1, 2, 4, 9, 10) in GROUP or PERSON channels # Only process text messages (type 1, 2, 4, 8, 9, 10) in GROUP or PERSON channels
if event_type in [1, 2, 4, 9, 10] and channel_type in ['GROUP', 'PERSON']: if event_type in [1, 2, 4, 8, 9, 10] and channel_type in ['GROUP', 'PERSON']:
try: try:
# Convert to LangBot event # Convert to LangBot event
lb_event = await self.event_converter.target2yiri(data, self.bot_account_id) lb_event = await self.event_converter.target2yiri(data, self.bot_account_id)
@@ -380,7 +377,6 @@ class KookAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
'sn': self.current_sn, 'sn': self.current_sn,
} }
await self.ws.send(json.dumps(ping_msg)) await self.ws.send(json.dumps(ping_msg))
await self.logger.debug(f'Sent PING with sn={self.current_sn}')
except Exception: except Exception:
# Connection closed or send failed, exit loop # Connection closed or send failed, exit loop
break break
@@ -401,10 +397,9 @@ class KookAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
self.gateway_url = await self._get_gateway_url() self.gateway_url = await self._get_gateway_url()
# Connect to WebSocket # Connect to WebSocket
await self.logger.info(f'Connecting to KOOK WebSocket: {self.gateway_url}')
async with websockets.connect(self.gateway_url) as ws: async with websockets.connect(self.gateway_url) as ws:
await self.logger.info(f'Connected to KOOK WebSocket: {self.gateway_url}')
self.ws = ws self.ws = ws
await self.logger.info('KOOK WebSocket connected')
# Start heartbeat # Start heartbeat
self.heartbeat_task = asyncio.create_task(self._heartbeat_loop()) self.heartbeat_task = asyncio.create_task(self._heartbeat_loop())
@@ -455,10 +450,11 @@ class KookAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
elif signal == 3: # PONG elif signal == 3: # PONG
await self._handle_pong(msg_data.get('d', {})) await self._handle_pong(msg_data.get('d', {}))
elif signal == 5: # RECONNECT elif signal == 5: # RECONNECT
await self.logger.info('Received RECONNECT signal') # await self.logger.info('Received RECONNECT signal')
break # Break to reconnect break # Break to reconnect
elif signal == 6: # RESUME ACK elif signal == 6: # RESUME ACK
await self.logger.info('Resume successful') # await self.logger.info('Resume successful')
pass
except json.JSONDecodeError: except json.JSONDecodeError:
await self.logger.error(f'Failed to parse message: {message}') await self.logger.error(f'Failed to parse message: {message}')
except Exception as e: except Exception as e:
@@ -571,6 +567,8 @@ class KookAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
if quote_origin and msg_id: if quote_origin and msg_id:
payload['quote'] = msg_id payload['quote'] = msg_id
payload['reply_msg_id'] = msg_id
headers = { headers = {
'Authorization': f'Bot {self.config["token"]}', 'Authorization': f'Bot {self.config["token"]}',
'Content-Type': 'application/json', 'Content-Type': 'application/json',

View File

@@ -55,9 +55,7 @@ class AESCipher(object):
class LarkMessageConverter(abstract_platform_adapter.AbstractMessageConverter): class LarkMessageConverter(abstract_platform_adapter.AbstractMessageConverter):
@staticmethod @staticmethod
async def upload_image_to_lark( async def upload_image_to_lark(msg: platform_message.Image, api_client: lark_oapi.Client) -> typing.Optional[str]:
msg: platform_message.Image, api_client: lark_oapi.Client
) -> typing.Optional[str]:
"""Upload an image to Lark and return the image_key, or None if upload fails.""" """Upload an image to Lark and return the image_key, or None if upload fails."""
image_bytes = None image_bytes = None
@@ -95,7 +93,9 @@ class LarkMessageConverter(abstract_platform_adapter.AbstractMessageConverter):
return None return None
if image_bytes is None: if image_bytes is None:
print(f'No image data available for Image message (url={msg.url}, base64={bool(msg.base64)}, path={msg.path})') print(
f'No image data available for Image message (url={msg.url}, base64={bool(msg.base64)}, path={msg.path})'
)
return None return None
try: try:
@@ -113,10 +113,7 @@ class LarkMessageConverter(abstract_platform_adapter.AbstractMessageConverter):
request = ( request = (
CreateImageRequest.builder() CreateImageRequest.builder()
.request_body( .request_body(
CreateImageRequestBody.builder() CreateImageRequestBody.builder().image_type('message').image(open(temp_file_path, 'rb')).build()
.image_type('message')
.image(open(temp_file_path, 'rb'))
.build()
) )
.build() .build()
) )
@@ -143,7 +140,7 @@ class LarkMessageConverter(abstract_platform_adapter.AbstractMessageConverter):
message_chain: platform_message.MessageChain, api_client: lark_oapi.Client message_chain: platform_message.MessageChain, api_client: lark_oapi.Client
) -> typing.Tuple[list, list]: ) -> typing.Tuple[list, list]:
"""Convert message chain to Lark format. """Convert message chain to Lark format.
Returns: Returns:
Tuple of (text_elements, image_keys): Tuple of (text_elements, image_keys):
- text_elements: List of paragraphs for post message format - text_elements: List of paragraphs for post message format
@@ -159,24 +156,24 @@ class LarkMessageConverter(abstract_platform_adapter.AbstractMessageConverter):
async def process_text_with_images(text: str) -> typing.Tuple[str, list]: async def process_text_with_images(text: str) -> typing.Tuple[str, list]:
"""Extract Markdown images from text and return cleaned text + image URLs.""" """Extract Markdown images from text and return cleaned text + image URLs."""
extracted_urls = [] extracted_urls = []
# Find all Markdown images # Find all Markdown images
matches = list(markdown_image_pattern.finditer(text)) matches = list(markdown_image_pattern.finditer(text))
if not matches: if not matches:
return text, [] return text, []
# Extract URLs and remove image syntax from text # Extract URLs and remove image syntax from text
cleaned_text = text cleaned_text = text
for match in reversed(matches): # Reverse to maintain correct positions for match in reversed(matches): # Reverse to maintain correct positions
url = match.group(2) url = match.group(2)
extracted_urls.insert(0, url) # Insert at beginning since we're going in reverse extracted_urls.insert(0, url) # Insert at beginning since we're going in reverse
# Replace image syntax with empty string or a placeholder # Replace image syntax with empty string or a placeholder
cleaned_text = cleaned_text[:match.start()] + cleaned_text[match.end():] cleaned_text = cleaned_text[: match.start()] + cleaned_text[match.end() :]
# Clean up multiple consecutive newlines that might result from removing images # Clean up multiple consecutive newlines that might result from removing images
cleaned_text = re.sub(r'\n{3,}', '\n\n', cleaned_text) cleaned_text = re.sub(r'\n{3,}', '\n\n', cleaned_text)
cleaned_text = cleaned_text.strip() cleaned_text = cleaned_text.strip()
return cleaned_text, extracted_urls return cleaned_text, extracted_urls
for msg in message_chain: for msg in message_chain:
@@ -189,14 +186,14 @@ class LarkMessageConverter(abstract_platform_adapter.AbstractMessageConverter):
text = msg.text.encode('latin1').decode('utf-8') text = msg.text.encode('latin1').decode('utf-8')
except UnicodeError: except UnicodeError:
text = msg.text.encode('utf-8', errors='replace').decode('utf-8') text = msg.text.encode('utf-8', errors='replace').decode('utf-8')
# Check for and extract Markdown images from text # Check for and extract Markdown images from text
cleaned_text, extracted_urls = await process_text_with_images(text) cleaned_text, extracted_urls = await process_text_with_images(text)
# Add cleaned text if not empty # Add cleaned text if not empty
if cleaned_text: if cleaned_text:
pending_paragraph.append({'tag': 'md', 'text': cleaned_text}) pending_paragraph.append({'tag': 'md', 'text': cleaned_text})
# Process extracted image URLs # Process extracted image URLs
for url in extracted_urls: for url in extracted_urls:
# Create a temporary Image message to upload # Create a temporary Image message to upload
@@ -204,7 +201,7 @@ class LarkMessageConverter(abstract_platform_adapter.AbstractMessageConverter):
image_key = await LarkMessageConverter.upload_image_to_lark(temp_image, api_client) image_key = await LarkMessageConverter.upload_image_to_lark(temp_image, api_client)
if image_key: if image_key:
image_keys.append(image_key) image_keys.append(image_key)
elif isinstance(msg, platform_message.At): elif isinstance(msg, platform_message.At):
pending_paragraph.append({'tag': 'at', 'user_id': msg.target, 'style': []}) pending_paragraph.append({'tag': 'at', 'user_id': msg.target, 'style': []})
elif isinstance(msg, platform_message.AtAll): elif isinstance(msg, platform_message.AtAll):
@@ -300,6 +297,10 @@ class LarkMessageConverter(abstract_platform_adapter.AbstractMessageConverter):
message_content['content'] = new_list message_content['content'] = new_list
elif message.message_type == 'image': elif message.message_type == 'image':
message_content['content'] = [{'tag': 'img', 'image_key': message_content['image_key'], 'style': []}] message_content['content'] = [{'tag': 'img', 'image_key': message_content['image_key'], 'style': []}]
elif message.message_type == 'file':
message_content['content'] = [
{'tag': 'file', 'file_key': message_content['file_key'], 'file_name': message_content['file_name']}
]
for ele in message_content['content']: for ele in message_content['content']:
if ele['tag'] == 'text': if ele['tag'] == 'text':
@@ -330,6 +331,33 @@ class LarkMessageConverter(abstract_platform_adapter.AbstractMessageConverter):
image_format = response.raw.headers['content-type'] image_format = response.raw.headers['content-type']
lb_msg_list.append(platform_message.Image(base64=f'data:{image_format};base64,{image_base64}')) lb_msg_list.append(platform_message.Image(base64=f'data:{image_format};base64,{image_base64}'))
elif ele['tag'] == 'file':
file_key = ele['file_key']
file_name = ele['file_name']
request: GetMessageResourceRequest = (
GetMessageResourceRequest.builder()
.message_id(message.message_id)
.file_key(file_key)
.type('file')
.build()
)
response: GetMessageResourceResponse = await api_client.im.v1.message_resource.aget(request)
if not response.success():
raise Exception(
f'client.im.v1.message_resource.get failed, code: {response.code}, msg: {response.msg}, log_id: {response.get_log_id()}, resp: \n{json.dumps(json.loads(response.raw.content), indent=4, ensure_ascii=False)}'
)
file_bytes = response.file.read()
file_base64 = base64.b64encode(file_bytes).decode()
file_format = response.raw.headers['content-type']
lb_msg_list.append(
platform_message.File(base64=f'data:{file_format};base64,{file_base64}', name=file_name)
)
return platform_message.MessageChain(lb_msg_list) return platform_message.MessageChain(lb_msg_list)
@@ -369,9 +397,6 @@ class LarkEventConverter(abstract_platform_adapter.AbstractEventConverter):
permission=platform_entities.Permission.Member, permission=platform_entities.Permission.Member,
), ),
special_title='', special_title='',
join_timestamp=0,
last_speak_timestamp=0,
mute_time_remaining=0,
), ),
message_chain=message_chain, message_chain=message_chain,
time=event.event.message.create_time, time=event.event.message.create_time,
@@ -391,6 +416,7 @@ class LarkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
message_converter: LarkMessageConverter = LarkMessageConverter() message_converter: LarkMessageConverter = LarkMessageConverter()
event_converter: LarkEventConverter = LarkEventConverter() event_converter: LarkEventConverter = LarkEventConverter()
cipher: AESCipher
listeners: typing.Dict[ listeners: typing.Dict[
typing.Type[platform_events.Event], typing.Type[platform_events.Event],
@@ -402,51 +428,11 @@ class LarkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
card_id_dict: dict[str, str] # 消息id到卡片id的映射便于创建卡片后的发送消息到指定卡片 card_id_dict: dict[str, str] # 消息id到卡片id的映射便于创建卡片后的发送消息到指定卡片
seq: int # 用于在发送卡片消息中识别消息顺序直接以seq作为标识 seq: int # 用于在发送卡片消息中识别消息顺序直接以seq作为标识
bot_uuid: str = None # 机器人UUID
def __init__(self, config: dict, logger: abstract_platform_logger.AbstractEventLogger, **kwargs): def __init__(self, config: dict, logger: abstract_platform_logger.AbstractEventLogger, **kwargs):
quart_app = quart.Quart(__name__) quart_app = quart.Quart(__name__)
@quart_app.route('/lark/callback', methods=['POST'])
async def lark_callback():
try:
data = await quart.request.json
if 'encrypt' in data:
cipher = AESCipher(config['encrypt-key'])
data = cipher.decrypt_string(data['encrypt'])
data = json.loads(data)
type = data.get('type')
if type is None:
context = EventContext(data)
type = context.header.event_type
if 'url_verification' == type:
# todo 验证verification token
return {'challenge': data.get('challenge')}
context = EventContext(data)
type = context.header.event_type
p2v1 = P2ImMessageReceiveV1()
p2v1.header = context.header
event = P2ImMessageReceiveV1Data()
event.message = EventMessage(context.event['message'])
event.sender = EventSender(context.event['sender'])
p2v1.event = event
p2v1.schema = context.schema
if 'im.message.receive_v1' == type:
try:
event = await self.event_converter.target2yiri(p2v1, self.api_client)
except Exception:
await self.logger.error(f'Error in lark callback: {traceback.format_exc()}')
if event.__class__ in self.listeners:
await self.listeners[event.__class__](event, self)
return {'code': 200, 'message': 'ok'}
except Exception:
await self.logger.error(f'Error in lark callback: {traceback.format_exc()}')
return {'code': 500, 'message': 'error'}
async def on_message(event: lark_oapi.im.v1.P2ImMessageReceiveV1): async def on_message(event: lark_oapi.im.v1.P2ImMessageReceiveV1):
lb_event = await self.event_converter.target2yiri(event, self.api_client) lb_event = await self.event_converter.target2yiri(event, self.api_client)
@@ -463,6 +449,7 @@ class LarkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
bot = lark_oapi.ws.Client(config['app_id'], config['app_secret'], event_handler=event_handler) bot = lark_oapi.ws.Client(config['app_id'], config['app_secret'], event_handler=event_handler)
api_client = lark_oapi.Client.builder().app_id(config['app_id']).app_secret(config['app_secret']).build() api_client = lark_oapi.Client.builder().app_id(config['app_id']).app_secret(config['app_secret']).build()
cipher = AESCipher(config.get('encrypt-key', ''))
super().__init__( super().__init__(
config=config, config=config,
@@ -475,6 +462,7 @@ class LarkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
bot=bot, bot=bot,
api_client=api_client, api_client=api_client,
bot_account_id=bot_account_id, bot_account_id=bot_account_id,
cipher=cipher,
**kwargs, **kwargs,
) )
@@ -859,8 +847,89 @@ class LarkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
): ):
self.listeners.pop(event_type) self.listeners.pop(event_type)
def set_bot_uuid(self, bot_uuid: str):
"""设置 bot UUID用于生成 webhook URL"""
self.bot_uuid = bot_uuid
async def handle_unified_webhook(self, bot_uuid: str, path: str, request):
"""处理统一 webhook 请求。
Args:
bot_uuid: Bot 的 UUID
path: 子路径(如果有的话)
request: Quart Request 对象
Returns:
响应数据
"""
try:
data = await request.json
if 'encrypt' in data:
data = self.cipher.decrypt_string(data['encrypt'])
data = json.loads(data)
type = data.get('type')
if type is None:
context = EventContext(data)
type = context.header.event_type
if 'url_verification' == type:
# todo 验证verification token
return {'challenge': data.get('challenge')}
context = EventContext(data)
type = context.header.event_type
p2v1 = P2ImMessageReceiveV1()
p2v1.header = context.header
event = P2ImMessageReceiveV1Data()
if 'im.message.receive_v1' == type:
try:
event.message = EventMessage(context.event['message'])
event.sender = EventSender(context.event['sender'])
p2v1.event = event
p2v1.schema = context.schema
event = await self.event_converter.target2yiri(p2v1, self.api_client)
except Exception:
await self.logger.error(f'Error in lark callback: {traceback.format_exc()}')
if event.__class__ in self.listeners:
await self.listeners[event.__class__](event, self)
elif 'im.chat.member.bot.added_v1' == type:
try:
bot_added_welcome_msg = self.config.get('bot_added_welcome', '')
if bot_added_welcome_msg:
final_content = {
'zh_Hans': {
'title': '',
'content': bot_added_welcome_msg,
},
}
chat_id = context.event['chat_id']
request: CreateMessageRequest = (
CreateMessageRequest.builder()
.receive_id_type('chat_id')
.request_body(
CreateMessageRequestBody.builder()
.receive_id(chat_id)
.content(json.dumps(final_content))
.msg_type('post')
.uuid(str(uuid.uuid4()))
.build()
)
.build()
)
response: CreateMessageResponse = self.api_client.im.v1.message.create(request)
if not response.success():
raise Exception(
f'client.im.v1.message.create failed, code: {response.code}, msg: {response.msg}, log_id: {response.get_log_id()}, resp: \n{json.dumps(json.loads(response.raw.content), indent=4, ensure_ascii=False)}'
)
except Exception:
await self.logger.error(f'Error in lark callback: {traceback.format_exc()}')
return {'code': 200, 'message': 'ok'}
except Exception:
await self.logger.error(f'Error in lark callback: {traceback.format_exc()}')
return {'code': 500, 'message': 'error'}
async def run_async(self): async def run_async(self):
port = self.config['port']
enable_webhook = self.config['enable-webhook'] enable_webhook = self.config['enable-webhook']
if not enable_webhook: if not enable_webhook:
@@ -875,16 +944,14 @@ class LarkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
else: else:
raise e raise e
else: else:
# 统一 webhook 模式下,不启动独立的 Quart 应用
# 保持运行但不启动独立端口
async def shutdown_trigger_placeholder(): async def keep_alive():
while True: while True:
await asyncio.sleep(1) await asyncio.sleep(1)
await self.quart_app.run_task( await keep_alive()
host='0.0.0.0',
port=port,
shutdown_trigger=shutdown_trigger_placeholder,
)
async def kill(self) -> bool: async def kill(self) -> bool:
# 需要断开连接,不然旧的连接会继续运行,导致飞书消息来时会随机选择一个连接 # 需要断开连接,不然旧的连接会继续运行,导致飞书消息来时会随机选择一个连接

View File

@@ -45,16 +45,6 @@ spec:
type: boolean type: boolean
required: true required: true
default: false default: false
- name: port
label:
en_US: Webhook Port
zh_Hans: Webhook端口
description:
en_US: Only valid when webhook mode is enabled, please fill in the webhook port
zh_Hans: 仅在启用 Webhook 模式时有效,请填写 Webhook 端口
type: integer
required: true
default: 2285
- name: encrypt-key - name: encrypt-key
label: label:
en_US: Encrypt Key en_US: Encrypt Key
@@ -75,6 +65,16 @@ spec:
type: boolean type: boolean
required: true required: true
default: false default: false
- name: bot_added_welcome
label:
en_US: Bot Welcome Message
zh_Hans: 机器人进群欢迎语
description:
en_US: Welcome message when the bot is added to a group, supports Markdown format
zh_Hans: 机器人进群欢迎语,支持 Markdown 格式
type: text
required: false
default: ""
execution: execution:
python: python:
path: ./lark.py path: ./lark.py

View File

@@ -437,9 +437,6 @@ class GewechatEventConverter(abstract_platform_adapter.AbstractEventConverter):
permission=platform_entities.Permission.Member, permission=platform_entities.Permission.Member,
), ),
special_title='', special_title='',
join_timestamp=0,
last_speak_timestamp=0,
mute_time_remaining=0,
), ),
message_chain=message_chain, message_chain=message_chain,
time=event['Data']['CreateTime'], time=event['Data']['CreateTime'],

View File

@@ -153,9 +153,6 @@ class NakuruProjectEventConverter(abstract_platform_adapter.AbstractEventConvert
permission=platform_entities.Permission.Member, permission=platform_entities.Permission.Member,
), ),
special_title=event.sender.title, special_title=event.sender.title,
join_timestamp=0,
last_speak_timestamp=0,
mute_time_remaining=0,
), ),
message_chain=yiri_chain, message_chain=yiri_chain,
time=event.time, time=event.time,

View File

@@ -279,11 +279,6 @@ class OfficialEventConverter(abstract_platform_adapter.AbstractEventConverter):
permission=platform_entities.Permission.Member, permission=platform_entities.Permission.Member,
), ),
special_title='', special_title='',
join_timestamp=int(
datetime.datetime.strptime(event.member.joined_at, '%Y-%m-%dT%H:%M:%S%z').timestamp()
),
last_speak_timestamp=datetime.datetime.now().timestamp(),
mute_time_remaining=0,
), ),
message_chain=OfficialMessageConverter.extract_message_chain_from_obj(event, event.id), message_chain=OfficialMessageConverter.extract_message_chain_from_obj(event, event.id),
time=int(datetime.datetime.strptime(event.timestamp, '%Y-%m-%dT%H:%M:%S%z').timestamp()), time=int(datetime.datetime.strptime(event.timestamp, '%Y-%m-%dT%H:%M:%S%z').timestamp()),
@@ -312,9 +307,6 @@ class OfficialEventConverter(abstract_platform_adapter.AbstractEventConverter):
permission=platform_entities.Permission.Member, permission=platform_entities.Permission.Member,
), ),
special_title='', special_title='',
join_timestamp=int(0),
last_speak_timestamp=datetime.datetime.now().timestamp(),
mute_time_remaining=0,
), ),
message_chain=OfficialMessageConverter.extract_message_chain_from_obj(event, event.id), message_chain=OfficialMessageConverter.extract_message_chain_from_obj(event, event.id),
time=int(datetime.datetime.strptime(event.timestamp, '%Y-%m-%dT%H:%M:%S%z').timestamp()), time=int(datetime.datetime.strptime(event.timestamp, '%Y-%m-%dT%H:%M:%S%z').timestamp()),

View File

@@ -108,9 +108,6 @@ class LINEEventConverter(abstract_platform_adapter.AbstractEventConverter):
permission=platform_entities.Permission.Member, permission=platform_entities.Permission.Member,
), ),
special_title='', special_title='',
join_timestamp=0,
last_speak_timestamp=0,
mute_time_remaining=0,
), ),
message_chain=message_chain, message_chain=message_chain,
time=event.timestamp, time=event.timestamp,
@@ -262,19 +259,6 @@ class LINEAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
# 保持运行但不启动独立端口 # 保持运行但不启动独立端口
# 打印 webhook 回调地址 # 打印 webhook 回调地址
if self.bot_uuid and hasattr(self.logger, 'ap'):
try:
api_port = self.logger.ap.instance_config.data['api']['port']
webhook_url = f'http://127.0.0.1:{api_port}/bots/{self.bot_uuid}'
webhook_url_public = f'http://<Your-Public-IP>:{api_port}/bots/{self.bot_uuid}'
await self.logger.info('LINE Webhook 回调地址:')
await self.logger.info(f' 本地地址: {webhook_url}')
await self.logger.info(f' 公网地址: {webhook_url_public}')
await self.logger.info('请在 LINE 后台配置此回调地址')
except Exception as e:
await self.logger.warning(f'无法生成 webhook URL: {e}')
async def keep_alive(): async def keep_alive():
while True: while True:
await asyncio.sleep(1) await asyncio.sleep(1)

View File

@@ -155,20 +155,6 @@ class OfficialAccountAdapter(abstract_platform_adapter.AbstractMessagePlatformAd
# 统一 webhook 模式下,不启动独立的 Quart 应用 # 统一 webhook 模式下,不启动独立的 Quart 应用
# 保持运行但不启动独立端口 # 保持运行但不启动独立端口
# 打印 webhook 回调地址
if self.bot_uuid and hasattr(self.logger, 'ap'):
try:
api_port = self.logger.ap.instance_config.data['api']['port']
webhook_url = f'http://127.0.0.1:{api_port}/bots/{self.bot_uuid}'
webhook_url_public = f'http://<Your-Public-IP>:{api_port}/bots/{self.bot_uuid}'
await self.logger.info('微信公众号 Webhook 回调地址:')
await self.logger.info(f' 本地地址: {webhook_url}')
await self.logger.info(f' 公网地址: {webhook_url_public}')
await self.logger.info('请在微信公众号后台配置此回调地址')
except Exception as e:
await self.logger.warning(f'无法生成 webhook URL: {e}')
async def keep_alive(): async def keep_alive():
while True: while True:
await asyncio.sleep(1) await asyncio.sleep(1)

View File

@@ -94,9 +94,6 @@ class QQOfficialEventConverter(abstract_platform_adapter.AbstractEventConverter)
permission=platform_entities.Permission.Member, permission=platform_entities.Permission.Member,
), ),
special_title='', special_title='',
join_timestamp=0,
last_speak_timestamp=0,
mute_time_remaining=0,
) )
time = int(datetime.datetime.strptime(event.timestamp, '%Y-%m-%dT%H:%M:%S%z').timestamp()) time = int(datetime.datetime.strptime(event.timestamp, '%Y-%m-%dT%H:%M:%S%z').timestamp())
return platform_events.GroupMessage( return platform_events.GroupMessage(
@@ -117,9 +114,6 @@ class QQOfficialEventConverter(abstract_platform_adapter.AbstractEventConverter)
permission=platform_entities.Permission.Member, permission=platform_entities.Permission.Member,
), ),
special_title='', special_title='',
join_timestamp=0,
last_speak_timestamp=0,
mute_time_remaining=0,
) )
time = int(datetime.datetime.strptime(event.timestamp, '%Y-%m-%dT%H:%M:%S%z').timestamp()) time = int(datetime.datetime.strptime(event.timestamp, '%Y-%m-%dT%H:%M:%S%z').timestamp())
return platform_events.GroupMessage( return platform_events.GroupMessage(
@@ -247,20 +241,6 @@ class QQOfficialAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter
# 统一 webhook 模式下,不启动独立的 Quart 应用 # 统一 webhook 模式下,不启动独立的 Quart 应用
# 保持运行但不启动独立端口 # 保持运行但不启动独立端口
# 打印 webhook 回调地址
if self.bot_uuid and hasattr(self.logger, 'ap'):
try:
api_port = self.logger.ap.instance_config.data['api']['port']
webhook_url = f'http://127.0.0.1:{api_port}/bots/{self.bot_uuid}'
webhook_url_public = f'http://<Your-Public-IP>:{api_port}/bots/{self.bot_uuid}'
await self.logger.info('QQ 官方机器人 Webhook 回调地址:')
await self.logger.info(f' 本地地址: {webhook_url}')
await self.logger.info(f' 公网地址: {webhook_url_public}')
await self.logger.info('请在 QQ 官方机器人后台配置此回调地址')
except Exception as e:
await self.logger.warning(f'无法生成 webhook URL: {e}')
async def keep_alive(): async def keep_alive():
while True: while True:
await asyncio.sleep(1) await asyncio.sleep(1)

View File

@@ -76,9 +76,6 @@ class SlackEventConverter(abstract_platform_adapter.AbstractEventConverter):
id=event.channel_id, name='MEMBER', permission=platform_entities.Permission.Member id=event.channel_id, name='MEMBER', permission=platform_entities.Permission.Member
), ),
special_title='', special_title='',
join_timestamp=0,
last_speak_timestamp=0,
mute_time_remaining=0,
) )
time = int(datetime.datetime.utcnow().timestamp()) time = int(datetime.datetime.utcnow().timestamp())
return platform_events.GroupMessage( return platform_events.GroupMessage(
@@ -112,10 +109,7 @@ class SlackAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
raise command_errors.ParamNotEnoughError('Slack机器人缺少相关配置项请查看文档或联系管理员') raise command_errors.ParamNotEnoughError('Slack机器人缺少相关配置项请查看文档或联系管理员')
bot = SlackClient( bot = SlackClient(
bot_token=config['bot_token'], bot_token=config['bot_token'], signing_secret=config['signing_secret'], logger=logger, unified_mode=True
signing_secret=config['signing_secret'],
logger=logger,
unified_mode=True
) )
super().__init__( super().__init__(
@@ -194,24 +188,10 @@ class SlackAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
async def run_async(self): async def run_async(self):
# 统一 webhook 模式下,不启动独立的 Quart 应用 # 统一 webhook 模式下,不启动独立的 Quart 应用
# 保持运行但不启动独立端口 # 保持运行但不启动独立端口
# 打印 webhook 回调地址
if self.bot_uuid and hasattr(self.logger, 'ap'):
try:
api_port = self.logger.ap.instance_config.data['api']['port']
webhook_url = f"http://127.0.0.1:{api_port}/bots/{self.bot_uuid}"
webhook_url_public = f"http://<Your-Public-IP>:{api_port}/bots/{self.bot_uuid}"
await self.logger.info(f"Slack 机器人 Webhook 回调地址:")
await self.logger.info(f" 本地地址: {webhook_url}")
await self.logger.info(f" 公网地址: {webhook_url_public}")
await self.logger.info(f"请在 Slack 后台配置此回调地址")
except Exception as e:
await self.logger.warning(f"无法生成 webhook URL: {e}")
async def keep_alive(): async def keep_alive():
while True: while True:
await asyncio.sleep(1) await asyncio.sleep(1)
await keep_alive() await keep_alive()
async def kill(self) -> bool: async def kill(self) -> bool:

View File

@@ -120,9 +120,6 @@ class TelegramEventConverter(abstract_platform_adapter.AbstractEventConverter):
permission=platform_entities.Permission.Member, permission=platform_entities.Permission.Member,
), ),
special_title='', special_title='',
join_timestamp=0,
last_speak_timestamp=0,
mute_time_remaining=0,
), ),
message_chain=lb_message, message_chain=lb_message,
time=event.message.date.timestamp(), time=event.message.date.timestamp(),

View File

@@ -97,8 +97,6 @@ class WebSocketAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter)
# 推送到所有相关连接 # 推送到所有相关连接
await self.outbound_message_queue.put(message_data) await self.outbound_message_queue.put(message_data)
await self.logger.info(f'Send message to {target_id}: {message}')
return message_data return message_data
async def reply_message( async def reply_message(
@@ -242,7 +240,6 @@ class WebSocketAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter)
async def run_async(self): async def run_async(self):
"""运行适配器""" """运行适配器"""
await self.logger.info('WebSocket适配器已启动')
try: try:
while True: while True:
@@ -258,12 +255,11 @@ class WebSocketAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter)
await asyncio.sleep(0.1) await asyncio.sleep(0.1)
except asyncio.CancelledError: except asyncio.CancelledError:
await self.logger.info('WebSocket适配器已停止')
raise raise
async def kill(self): async def kill(self):
"""停止适配器""" """停止适配器"""
await self.logger.info('WebSocket适配器正在停止') pass
async def _process_image_components(self, message_chain_obj: list): async def _process_image_components(self, message_chain_obj: list):
""" """

View File

@@ -501,9 +501,6 @@ class WeChatPadEventConverter(abstract_platform_adapter.AbstractEventConverter):
permission=platform_entities.Permission.Member, permission=platform_entities.Permission.Member,
), ),
special_title='', special_title='',
join_timestamp=0,
last_speak_timestamp=0,
mute_time_remaining=0,
), ),
message_chain=message_chain, message_chain=message_chain,
time=event['create_time'], time=event['create_time'],

View File

@@ -35,6 +35,20 @@ class WecomMessageConverter(abstract_platform_adapter.AbstractMessageConverter):
'media_id': await bot.get_media_id(msg), 'media_id': await bot.get_media_id(msg),
} }
) )
elif type(msg) is platform_message.Voice:
content_list.append(
{
'type': 'voice',
'media_id': await bot.get_media_id(msg),
}
)
elif type(msg) is platform_message.File:
content_list.append(
{
'type': 'file',
'media_id': await bot.get_media_id(msg),
}
)
elif type(msg) is platform_message.Forward: elif type(msg) is platform_message.Forward:
for node in msg.node_list: for node in msg.node_list:
content_list.extend((await WecomMessageConverter.yiri2target(node.message_chain, bot))) content_list.extend((await WecomMessageConverter.yiri2target(node.message_chain, bot)))
@@ -185,6 +199,10 @@ class WecomAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
await self.bot.send_private_msg(fixed_user_id, Wecom_event.agent_id, content['content']) await self.bot.send_private_msg(fixed_user_id, Wecom_event.agent_id, content['content'])
elif content['type'] == 'image': elif content['type'] == 'image':
await self.bot.send_image(fixed_user_id, Wecom_event.agent_id, content['media_id']) await self.bot.send_image(fixed_user_id, Wecom_event.agent_id, content['media_id'])
elif content['type'] == 'voice':
await self.bot.send_voice(fixed_user_id, Wecom_event.agent_id, content['media_id'])
elif content['type'] == 'file':
await self.bot.send_file(fixed_user_id, Wecom_event.agent_id, content['media_id'])
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_list = await WecomMessageConverter.yiri2target(message, self.bot) content_list = await WecomMessageConverter.yiri2target(message, self.bot)
@@ -197,6 +215,10 @@ class WecomAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
await self.bot.send_private_msg(user_id, agent_id, content['content']) await self.bot.send_private_msg(user_id, agent_id, content['content'])
if content['type'] == 'image': if content['type'] == 'image':
await self.bot.send_image(user_id, agent_id, content['media']) await self.bot.send_image(user_id, agent_id, content['media'])
if content['type'] == 'voice':
await self.bot.send_voice(user_id, agent_id, content['media'])
if content['type'] == 'file':
await self.bot.send_file(user_id, agent_id, content['media'])
def register_listener( def register_listener(
self, self,
@@ -232,19 +254,6 @@ class WecomAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
return await self.bot.handle_unified_webhook(request) return await self.bot.handle_unified_webhook(request)
async def run_async(self): async def run_async(self):
if self.bot_uuid and hasattr(self.logger, 'ap'):
try:
api_port = self.logger.ap.instance_config.data['api']['port']
webhook_url = f'http://127.0.0.1:{api_port}/bots/{self.bot_uuid}'
webhook_url_public = f'http://<Your-Public-IP>:{api_port}/bots/{self.bot_uuid}'
await self.logger.info('企业微信 Webhook 回调地址:')
await self.logger.info(f' 本地地址: {webhook_url}')
await self.logger.info(f' 公网地址: {webhook_url_public}')
await self.logger.info('请在企业微信后台配置此回调地址')
except Exception as e:
await self.logger.warning(f'无法生成 webhook URL: {e}')
async def keep_alive(): async def keep_alive():
while True: while True:
await asyncio.sleep(1) await asyncio.sleep(1)

View File

@@ -21,21 +21,21 @@ spec:
- name: secret - name: secret
label: label:
en_US: Secret en_US: Secret
zh_Hans: 密钥 zh_Hans: 密钥 (Secret)
type: string type: string
required: true required: true
default: "" default: ""
- name: token - name: token
label: label:
en_US: Token en_US: Token
zh_Hans: 令牌 zh_Hans: 令牌 (Token)
type: string type: string
required: true required: true
default: "" default: ""
- name: EncodingAESKey - name: EncodingAESKey
label: label:
en_US: EncodingAESKey en_US: EncodingAESKey
zh_Hans: 消息加解密密钥 zh_Hans: 消息加解密密钥 (EncodingAESKey)
type: string type: string
required: true required: true
default: "" default: ""

View File

@@ -28,9 +28,105 @@ class WecomBotMessageConverter(abstract_platform_adapter.AbstractMessageConverte
if event.type == 'group': if event.type == 'group':
yiri_msg_list.append(platform_message.At(target=event.ai_bot_id)) yiri_msg_list.append(platform_message.At(target=event.ai_bot_id))
yiri_msg_list.append(platform_message.Source(id=event.message_id, time=datetime.datetime.now())) yiri_msg_list.append(platform_message.Source(id=event.message_id, time=datetime.datetime.now()))
yiri_msg_list.append(platform_message.Plain(text=event.content))
if event.picurl != '': if event.content:
yiri_msg_list.append(platform_message.Image(base64=event.picurl)) yiri_msg_list.append(platform_message.Plain(text=event.content))
images = []
if event.images:
images.extend([img for img in event.images if img])
if not images and event.picurl:
images.append(event.picurl)
for image_base64 in images:
if image_base64:
yiri_msg_list.append(platform_message.Image(base64=image_base64))
file_info = event.file or {}
if file_info:
file_url = (
file_info.get('download_url')
or file_info.get('url')
or file_info.get('fileurl')
or file_info.get('path')
)
file_base64 = file_info.get('base64')
file_name = file_info.get('filename') or file_info.get('name')
file_size = file_info.get('filesize') or file_info.get('size')
file_data = file_url or file_base64
if file_data or file_name:
file_kwargs = {}
if file_data:
file_kwargs['url'] = file_data
if file_name:
file_kwargs['name'] = file_name
if file_size is not None:
file_kwargs['size'] = file_size
try:
yiri_msg_list.append(platform_message.File(**file_kwargs))
except Exception:
# 兜底
yiri_msg_list.append(platform_message.Unknown(text='[file message unsupported]'))
voice_info = event.voice or {}
if voice_info:
voice_payload = voice_info.get('base64') or voice_info.get('url')
if voice_payload:
if voice_info.get('base64') and not voice_payload.startswith('data:'):
voice_payload = f'data:audio/mpeg;base64,{voice_info.get("base64")}'
try:
yiri_msg_list.append(platform_message.Voice(base64=voice_payload))
except Exception:
try:
voice_kwargs = {'url': voice_payload}
voice_name = voice_info.get('filename') or voice_info.get('name')
voice_size = voice_info.get('filesize') or voice_info.get('size')
if voice_name:
voice_kwargs['name'] = voice_name
if voice_size is not None:
voice_kwargs['size'] = voice_size
yiri_msg_list.append(platform_message.File(**voice_kwargs))
except Exception:
yiri_msg_list.append(platform_message.Unknown(text='[voice message unsupported]'))
video_info = event.video or {}
if video_info:
video_payload = (
video_info.get('base64')
or video_info.get('url')
or video_info.get('download_url')
or video_info.get('fileurl')
)
if video_payload:
video_kwargs = {'url': video_payload}
video_name = video_info.get('filename') or video_info.get('name')
video_size = video_info.get('filesize') or video_info.get('size')
if video_name:
video_kwargs['name'] = video_name
if video_size is not None:
video_kwargs['size'] = video_size
try:
# 没有专门的视频类型,沿用 File 传递给上层
yiri_msg_list.append(platform_message.File(**video_kwargs))
except Exception:
yiri_msg_list.append(platform_message.Unknown(text='[video message unsupported]'))
if event.msgtype == 'link' and event.link:
link = event.link
summary = '\n'.join(
filter(
None,
[link.get('title', ''), link.get('description') or link.get('digest', ''), link.get('url', '')],
)
)
if summary:
yiri_msg_list.append(platform_message.Plain(text=summary))
has_content_element = any(
not isinstance(element, (platform_message.Source, platform_message.At)) for element in yiri_msg_list
)
if not has_content_element:
fallback_type = event.msgtype or 'unknown'
yiri_msg_list.append(platform_message.Unknown(text=f'[unsupported wecom msgtype: {fallback_type}]'))
chain = platform_message.MessageChain(yiri_msg_list) chain = platform_message.MessageChain(yiri_msg_list)
return chain return chain
@@ -67,9 +163,6 @@ class WecomBotEventConverter(abstract_platform_adapter.AbstractEventConverter):
permission=platform_entities.Permission.Member, permission=platform_entities.Permission.Member,
), ),
special_title='', special_title='',
join_timestamp=0,
last_speak_timestamp=0,
mute_time_remaining=0,
) )
time = datetime.datetime.now().timestamp() time = datetime.datetime.now().timestamp()
return platform_events.GroupMessage( return platform_events.GroupMessage(
@@ -211,20 +304,6 @@ class WecomBotAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
# 统一 webhook 模式下,不启动独立的 Quart 应用 # 统一 webhook 模式下,不启动独立的 Quart 应用
# 保持运行但不启动独立端口 # 保持运行但不启动独立端口
# 打印 webhook 回调地址
if self.bot_uuid and hasattr(self.logger, 'ap'):
try:
api_port = self.logger.ap.instance_config.data['api']['port']
webhook_url = f'http://127.0.0.1:{api_port}/bots/{self.bot_uuid}'
webhook_url_public = f'http://<Your-Public-IP>:{api_port}/bots/{self.bot_uuid}'
await self.logger.info('企业微信机器人 Webhook 回调地址:')
await self.logger.info(f' 本地地址: {webhook_url}')
await self.logger.info(f' 公网地址: {webhook_url_public}')
await self.logger.info('请在企业微信后台配置此回调地址')
except Exception as e:
await self.logger.warning(f'无法生成 webhook URL: {e}')
async def keep_alive(): async def keep_alive():
while True: while True:
await asyncio.sleep(1) await asyncio.sleep(1)

View File

@@ -21,14 +21,14 @@ spec:
- name: Token - name: Token
label: label:
en_US: Token en_US: Token
zh_Hans: 令牌 zh_Hans: 令牌 (Token)
type: string type: string
required: true required: true
default: "" default: ""
- name: EncodingAESKey - name: EncodingAESKey
label: label:
en_US: EncodingAESKey en_US: EncodingAESKey
zh_Hans: 消息加解密密钥 zh_Hans: 消息加解密密钥 (EncodingAESKey)
type: string type: string
required: true required: true
default: "" default: ""

View File

@@ -213,23 +213,10 @@ class WecomCSAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
# 统一 webhook 模式下,不启动独立的 Quart 应用 # 统一 webhook 模式下,不启动独立的 Quart 应用
# 保持运行但不启动独立端口 # 保持运行但不启动独立端口
# 打印 webhook 回调地址
if self.bot_uuid and hasattr(self.logger, 'ap'):
try:
api_port = self.logger.ap.instance_config.data['api']['port']
webhook_url = f"http://127.0.0.1:{api_port}/bots/{self.bot_uuid}"
webhook_url_public = f"http://<Your-Public-IP>:{api_port}/bots/{self.bot_uuid}"
await self.logger.info(f"企业微信客服 Webhook 回调地址:")
await self.logger.info(f" 本地地址: {webhook_url}")
await self.logger.info(f" 公网地址: {webhook_url_public}")
await self.logger.info(f"请在企业微信后台配置此回调地址")
except Exception as e:
await self.logger.warning(f"无法生成 webhook URL: {e}")
async def keep_alive(): async def keep_alive():
while True: while True:
await asyncio.sleep(1) await asyncio.sleep(1)
await keep_alive() await keep_alive()
async def kill(self) -> bool: async def kill(self) -> bool:

View File

@@ -139,6 +139,8 @@ class RuntimeConnectionHandler(handler.Handler):
message_chain_obj = platform_message.MessageChain.model_validate(message_chain) message_chain_obj = platform_message.MessageChain.model_validate(message_chain)
self.ap.logger.debug(f'Reply message: {message_chain_obj.model_dump(serialize_as_any=False)}')
await query.adapter.reply_message( await query.adapter.reply_message(
query.message_event, query.message_event,
message_chain_obj, message_chain_obj,
@@ -563,7 +565,7 @@ class RuntimeConnectionHandler(handler.Handler):
'event_context': event_context, 'event_context': event_context,
'include_plugins': include_plugins, 'include_plugins': include_plugins,
}, },
timeout=60, timeout=180,
) )
return result return result

View File

@@ -4,6 +4,7 @@ import typing
import json import json
import uuid import uuid
import base64 import base64
import mimetypes
from langbot.pkg.provider import runner from langbot.pkg.provider import runner
@@ -12,6 +13,7 @@ import langbot_plugin.api.entities.builtin.provider.message as provider_message
from langbot.pkg.utils import image from langbot.pkg.utils import image
import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query
from langbot.libs.dify_service_api.v1 import client, errors from langbot.libs.dify_service_api.v1 import client, errors
import httpx
@runner.runner_class('dify-service-api') @runner.runner_class('dify-service-api')
@@ -70,14 +72,43 @@ class DifyServiceAPIRunner(runner.RequestRunner):
content = f'<think>\n{thinking_content}\n</think>\n{content}'.strip() content = f'<think>\n{thinking_content}\n</think>\n{content}'.strip()
return content, thinking_content return content, thinking_content
async def _preprocess_user_message(self, query: pipeline_query.Query) -> tuple[str, list[str]]: async def _preprocess_user_message(self, query: pipeline_query.Query) -> tuple[str, list[dict]]:
"""预处理用户消息,提取纯文本,并将图片上传到 Dify 服务 """预处理用户消息,提取纯文本,并将图片/文件上传到 Dify 服务
Returns: Returns:
tuple[str, list[str]]: 纯文本和图片的 Dify 服务图片 ID tuple[str, list[dict]]: 纯文本和上传后的文件描述(包含 type 与 id
""" """
plain_text = '' plain_text = ''
file_ids = [] upload_files: list[dict] = []
user_tag = f'{query.session.launcher_type.value}_{query.session.launcher_id}'
async def upload_file_bytes(file_name: str, file_bytes: bytes, content_type: str) -> str:
file_name = file_name or 'file'
content_type = content_type or 'application/octet-stream'
file = (file_name, file_bytes, content_type)
resp = await self.dify_client.upload_file(file, user_tag)
return resp['id']
async def download_file(file_url: str) -> tuple[bytes, str]:
"""Download file from url (supports data url)."""
async with httpx.AsyncClient() as client_session:
resp = await client_session.get(file_url)
resp.raise_for_status()
content_type = (
resp.headers.get('content-type') or mimetypes.guess_type(file_url)[0] or 'application/octet-stream'
)
return resp.content, content_type
def _detect_file_type(content_type: str) -> str:
"""Map MIME to dify file type."""
if content_type and content_type.startswith('image/'):
return 'image'
if content_type and content_type.startswith('audio/'):
return 'audio'
if content_type and content_type.startswith('video/'):
return 'video'
return 'document'
if isinstance(query.user_message.content, list): if isinstance(query.user_message.content, list):
for ce in query.user_message.content: for ce in query.user_message.content:
@@ -86,30 +117,36 @@ class DifyServiceAPIRunner(runner.RequestRunner):
elif ce.type == 'image_base64': elif ce.type == 'image_base64':
image_b64, image_format = await image.extract_b64_and_format(ce.image_base64) image_b64, image_format = await image.extract_b64_and_format(ce.image_base64)
file_bytes = base64.b64decode(image_b64) file_bytes = base64.b64decode(image_b64)
file = ('img.png', file_bytes, f'image/{image_format}') image_id = await upload_file_bytes(f'img.{image_format}', file_bytes, f'image/{image_format}')
file_upload_resp = await self.dify_client.upload_file( upload_files.append({'type': 'image', 'id': image_id})
file, elif ce.type == 'file_url':
f'{query.session.launcher_type.value}_{query.session.launcher_id}', file_url = getattr(ce, 'file_url', None)
) file_name = getattr(ce, 'file_name', None) or 'file'
image_id = file_upload_resp['id'] try:
file_ids.append(image_id) file_bytes, content_type = await download_file(file_url)
# elif ce.type == "file_url": file_id = await upload_file_bytes(file_name, file_bytes, content_type)
# file_bytes = base64.b64decode(ce.file_url) file_type = _detect_file_type(content_type)
# file_upload_resp = await self.dify_client.upload_file( upload_files.append({'type': file_type, 'id': file_id})
# file_bytes, except Exception as e:
# f'{query.session.launcher_type.value}_{query.session.launcher_id}', self.ap.logger.warning(f'dify file upload failed: {e}')
# ) elif ce.type == 'file_base64':
# file_id = file_upload_resp['id'] file_name = getattr(ce, 'file_name', None) or 'file'
# file_ids.append(file_id)
header, b64_data = ce.file_base64.split(',', 1)
content_type = 'application/octet-stream'
if ';' in header:
content_type = header.split(';')[0][5:] or content_type
file_bytes = base64.b64decode(b64_data)
file_id = await upload_file_bytes(file_name, file_bytes, content_type)
file_type = _detect_file_type(content_type)
upload_files.append({'type': file_type, 'id': file_id})
elif isinstance(query.user_message.content, str): elif isinstance(query.user_message.content, str):
plain_text = query.user_message.content plain_text = query.user_message.content
# plain_text = "When the file content is readable, please read the content of this file. When the file is an image, describe the content of this image." if file_ids and not plain_text else plain_text
# plain_text = "The user message type cannot be parsed." if not file_ids and not plain_text else plain_text
# plain_text = plain_text if plain_text else "When the file content is readable, please read the content of this file. When the file is an image, describe the content of this image."
# print(self.pipeline_config['ai'])
plain_text = plain_text if plain_text else self.pipeline_config['ai']['dify-service-api']['base-prompt'] plain_text = plain_text if plain_text else self.pipeline_config['ai']['dify-service-api']['base-prompt']
return plain_text, file_ids return plain_text, upload_files
async def _chat_messages( async def _chat_messages(
self, query: pipeline_query.Query self, query: pipeline_query.Query
@@ -118,14 +155,15 @@ class DifyServiceAPIRunner(runner.RequestRunner):
cov_id = query.session.using_conversation.uuid or '' cov_id = query.session.using_conversation.uuid or ''
query.variables['conversation_id'] = cov_id query.variables['conversation_id'] = cov_id
plain_text, image_ids = await self._preprocess_user_message(query) plain_text, upload_files = await self._preprocess_user_message(query)
files = [ files = [
{ {
'type': 'image', 'type': f['type'],
'upload_file_id': image_id, 'transfer_method': 'local_file',
'upload_file_id': f['id'],
} }
for image_id in image_ids for f in upload_files
] ]
mode = 'basic' # 标记是基础编排还是工作流编排 mode = 'basic' # 标记是基础编排还是工作流编排
@@ -183,15 +221,15 @@ class DifyServiceAPIRunner(runner.RequestRunner):
cov_id = query.session.using_conversation.uuid or '' cov_id = query.session.using_conversation.uuid or ''
query.variables['conversation_id'] = cov_id query.variables['conversation_id'] = cov_id
plain_text, image_ids = await self._preprocess_user_message(query) plain_text, upload_files = await self._preprocess_user_message(query)
files = [ files = [
{ {
'type': 'image', 'type': f['type'],
'transfer_method': 'local_file', 'transfer_method': 'local_file',
'upload_file_id': image_id, 'upload_file_id': f['id'],
} }
for image_id in image_ids for f in upload_files
] ]
ignored_events = [] ignored_events = []
@@ -280,15 +318,15 @@ class DifyServiceAPIRunner(runner.RequestRunner):
query.variables['conversation_id'] = query.session.using_conversation.uuid query.variables['conversation_id'] = query.session.using_conversation.uuid
plain_text, image_ids = await self._preprocess_user_message(query) plain_text, upload_files = await self._preprocess_user_message(query)
files = [ files = [
{ {
'type': 'image', 'type': f['type'],
'transfer_method': 'local_file', 'transfer_method': 'local_file',
'upload_file_id': image_id, 'upload_file_id': f['id'],
} }
for image_id in image_ids for f in upload_files
] ]
ignored_events = ['text_chunk', 'workflow_started'] ignored_events = ['text_chunk', 'workflow_started']
@@ -352,15 +390,15 @@ class DifyServiceAPIRunner(runner.RequestRunner):
cov_id = query.session.using_conversation.uuid or '' cov_id = query.session.using_conversation.uuid or ''
query.variables['conversation_id'] = cov_id query.variables['conversation_id'] = cov_id
plain_text, image_ids = await self._preprocess_user_message(query) plain_text, upload_files = await self._preprocess_user_message(query)
files = [ files = [
{ {
'type': 'image', 'type': f['type'],
'transfer_method': 'local_file', 'transfer_method': 'local_file',
'upload_file_id': image_id, 'upload_file_id': f['id'],
} }
for image_id in image_ids for f in upload_files
] ]
basic_mode_pending_chunk = '' basic_mode_pending_chunk = ''
@@ -436,15 +474,15 @@ class DifyServiceAPIRunner(runner.RequestRunner):
cov_id = query.session.using_conversation.uuid or '' cov_id = query.session.using_conversation.uuid or ''
query.variables['conversation_id'] = cov_id query.variables['conversation_id'] = cov_id
plain_text, image_ids = await self._preprocess_user_message(query) plain_text, upload_files = await self._preprocess_user_message(query)
files = [ files = [
{ {
'type': 'image', 'type': f['type'],
'transfer_method': 'local_file', 'transfer_method': 'local_file',
'upload_file_id': image_id, 'upload_file_id': f['id'],
} }
for image_id in image_ids for f in upload_files
] ]
ignored_events = [] ignored_events = []
@@ -558,15 +596,15 @@ class DifyServiceAPIRunner(runner.RequestRunner):
query.variables['conversation_id'] = query.session.using_conversation.uuid query.variables['conversation_id'] = query.session.using_conversation.uuid
plain_text, image_ids = await self._preprocess_user_message(query) plain_text, upload_files = await self._preprocess_user_message(query)
files = [ files = [
{ {
'type': 'image', 'type': f['type'],
'transfer_method': 'local_file', 'transfer_method': 'local_file',
'upload_file_id': image_id, 'upload_file_id': f['id'],
} }
for image_id in image_ids for f in upload_files
] ]
ignored_events = ['workflow_started'] ignored_events = ['workflow_started']

View File

@@ -94,7 +94,6 @@ class LangflowAPIRunner(runner.RequestRunner):
if is_stream: if is_stream:
# 流式请求 # 流式请求
async with client.stream('POST', url, json=payload, headers=headers, timeout=120.0) as response: async with client.stream('POST', url, json=payload, headers=headers, timeout=120.0) as response:
print(response)
response.raise_for_status() response.raise_for_status()
accumulated_content = '' accumulated_content = ''

View File

@@ -33,6 +33,7 @@ class SessionManager:
session = provider_session.Session( session = provider_session.Session(
launcher_type=query.launcher_type, launcher_type=query.launcher_type,
launcher_id=query.launcher_id, launcher_id=query.launcher_id,
sender_id=query.sender_id,
) )
session._semaphore = asyncio.Semaphore(session_concurrency) session._semaphore = asyncio.Semaphore(session_concurrency)
self.session_list.append(session) self.session_list.append(session)

View File

@@ -4,6 +4,8 @@ from ..core import app
from .vdb import VectorDatabase from .vdb import VectorDatabase
from .vdbs.chroma import ChromaVectorDatabase from .vdbs.chroma import ChromaVectorDatabase
from .vdbs.qdrant import QdrantVectorDatabase from .vdbs.qdrant import QdrantVectorDatabase
from .vdbs.milvus import MilvusVectorDatabase
from .vdbs.pgvector_db import PgVectorDatabase
class VectorDBManager: class VectorDBManager:
@@ -16,12 +18,47 @@ class VectorDBManager:
async def initialize(self): async def initialize(self):
kb_config = self.ap.instance_config.data.get('vdb') kb_config = self.ap.instance_config.data.get('vdb')
if kb_config: if kb_config:
if kb_config.get('use') == 'chroma': vdb_type = kb_config.get('use')
if vdb_type == 'chroma':
self.vector_db = ChromaVectorDatabase(self.ap) self.vector_db = ChromaVectorDatabase(self.ap)
self.ap.logger.info('Initialized Chroma vector database backend.') self.ap.logger.info('Initialized Chroma vector database backend.')
elif kb_config.get('use') == 'qdrant':
elif vdb_type == 'qdrant':
self.vector_db = QdrantVectorDatabase(self.ap) self.vector_db = QdrantVectorDatabase(self.ap)
self.ap.logger.info('Initialized Qdrant vector database backend.') self.ap.logger.info('Initialized Qdrant vector database backend.')
elif vdb_type == 'milvus':
# Get Milvus configuration
milvus_config = kb_config.get('milvus', {})
uri = milvus_config.get('uri', './data/milvus.db')
token = milvus_config.get('token')
self.vector_db = MilvusVectorDatabase(self.ap, uri=uri, token=token)
self.ap.logger.info('Initialized Milvus vector database backend.')
elif vdb_type == 'pgvector':
# Get pgvector configuration
pgvector_config = kb_config.get('pgvector', {})
connection_string = pgvector_config.get('connection_string')
if connection_string:
self.vector_db = PgVectorDatabase(self.ap, connection_string=connection_string)
else:
# Use individual parameters
host = pgvector_config.get('host', 'localhost')
port = pgvector_config.get('port', 5432)
database = pgvector_config.get('database', 'langbot')
user = pgvector_config.get('user', 'postgres')
password = pgvector_config.get('password', 'postgres')
self.vector_db = PgVectorDatabase(
self.ap,
host=host,
port=port,
database=database,
user=user,
password=password
)
self.ap.logger.info('Initialized pgvector database backend.')
else: else:
self.vector_db = ChromaVectorDatabase(self.ap) self.vector_db = ChromaVectorDatabase(self.ap)
self.ap.logger.warning('No valid vector database backend configured, defaulting to Chroma.') self.ap.logger.warning('No valid vector database backend configured, defaulting to Chroma.')

View File

@@ -0,0 +1,249 @@
from __future__ import annotations
import asyncio
from typing import Any, Dict
from pymilvus import MilvusClient, DataType
from langbot.pkg.vector.vdb import VectorDatabase
from langbot.pkg.core import app
class MilvusVectorDatabase(VectorDatabase):
"""Milvus vector database implementation"""
def __init__(self, ap: app.Application, uri: str = "milvus.db", token: str = None):
"""Initialize Milvus vector database
Args:
ap: Application instance
uri: Milvus connection URI. For local file: "milvus.db"
For remote server: "http://localhost:19530"
token: Optional authentication token for remote connections
"""
self.ap = ap
self.uri = uri
self.token = token
self.client = None
self._collections = {}
self._initialize_client()
def _initialize_client(self):
"""Initialize Milvus client connection"""
try:
if self.token:
self.client = MilvusClient(uri=self.uri, token=self.token)
else:
self.client = MilvusClient(uri=self.uri)
self.ap.logger.info(f"Connected to Milvus at {self.uri}")
except Exception as e:
self.ap.logger.error(f"Failed to connect to Milvus: {e}")
raise
async def get_or_create_collection(self, collection: str):
"""Get or create a Milvus collection
Args:
collection: Collection name (corresponds to knowledge base UUID)
"""
if collection in self._collections:
return self._collections[collection]
# Check if collection exists
has_collection = await asyncio.to_thread(
self.client.has_collection, collection_name=collection
)
if not has_collection:
# Create collection with custom schema to support string IDs
from pymilvus import CollectionSchema, FieldSchema, DataType
fields = [
FieldSchema(name="id", dtype=DataType.VARCHAR, is_primary=True, max_length=255),
FieldSchema(name="vector", dtype=DataType.FLOAT_VECTOR, dim=1536),
FieldSchema(name="text", dtype=DataType.VARCHAR, max_length=65535),
FieldSchema(name="file_id", dtype=DataType.VARCHAR, max_length=255),
FieldSchema(name="chunk_uuid", dtype=DataType.VARCHAR, max_length=255),
]
schema = CollectionSchema(fields=fields, description="LangBot knowledge base vectors")
await asyncio.to_thread(
self.client.create_collection,
collection_name=collection,
schema=schema,
metric_type="COSINE",
)
# Create index for vector field (required for loading/searching)
index_params = {
"metric_type": "COSINE",
"index_type": "AUTOINDEX",
"params": {}
}
await asyncio.to_thread(
self.client.create_index,
collection_name=collection,
field_name="vector",
index_params=index_params
)
self.ap.logger.info(f"Created Milvus collection '{collection}' with index")
else:
self.ap.logger.info(f"Milvus collection '{collection}' already exists")
self._collections[collection] = collection
return collection
async def add_embeddings(
self,
collection: str,
ids: list[str],
embeddings_list: list[list[float]],
metadatas: list[dict[str, Any]],
) -> None:
"""Add vector embeddings to Milvus collection
Args:
collection: Collection name
ids: List of unique IDs for each vector
embeddings_list: List of embedding vectors
metadatas: List of metadata dictionaries for each vector
"""
await self.get_or_create_collection(collection)
# Prepare data in Milvus format
data = []
for i, vector_id in enumerate(ids):
entry = {
"id": vector_id,
"vector": embeddings_list[i],
}
# Add metadata fields
if metadatas and i < len(metadatas):
metadata = metadatas[i]
# Add common metadata fields
if "text" in metadata:
entry["text"] = metadata["text"]
if "file_id" in metadata:
entry["file_id"] = metadata["file_id"]
if "uuid" in metadata:
entry["chunk_uuid"] = metadata["uuid"]
data.append(entry)
# Insert data into Milvus
await asyncio.to_thread(
self.client.insert,
collection_name=collection,
data=data
)
# Load collection for searching (Milvus requires this)
await asyncio.to_thread(
self.client.load_collection,
collection_name=collection
)
self.ap.logger.info(f"Added {len(ids)} embeddings to Milvus collection '{collection}'")
async def search(
self, collection: str, query_embedding: list[float], k: int = 5
) -> Dict[str, Any]:
"""Search for similar vectors in Milvus collection
Args:
collection: Collection name
query_embedding: Query vector
k: Number of top results to return
Returns:
Dictionary with search results in Chroma-compatible format
"""
await self.get_or_create_collection(collection)
# Perform search
search_params = {
"metric_type": "COSINE",
"params": {}
}
results = await asyncio.to_thread(
self.client.search,
collection_name=collection,
data=[query_embedding],
limit=k,
search_params=search_params,
output_fields=["text", "file_id", "chunk_uuid"]
)
# Convert results to Chroma-compatible format
# Milvus returns: [[ {id, distance, entity: {...}} ]]
ids = []
distances = []
metadatas = []
if results and len(results) > 0:
for hit in results[0]:
ids.append(hit.get("id", ""))
distances.append(hit.get("distance", 0.0))
# Build metadata from entity fields
entity = hit.get("entity", {})
metadata = {}
if "text" in entity:
metadata["text"] = entity["text"]
if "file_id" in entity:
metadata["file_id"] = entity["file_id"]
if "chunk_uuid" in entity:
metadata["uuid"] = entity["chunk_uuid"]
metadatas.append(metadata)
# Return in Chroma-compatible format (nested lists)
result = {
"ids": [ids],
"distances": [distances],
"metadatas": [metadatas]
}
self.ap.logger.info(
f"Milvus search in '{collection}' returned {len(ids)} results"
)
return result
async def delete_by_file_id(self, collection: str, file_id: str) -> None:
"""Delete vectors from collection by file_id
Args:
collection: Collection name
file_id: File ID to filter deletion
"""
await self.get_or_create_collection(collection)
# Delete entities matching the file_id
await asyncio.to_thread(
self.client.delete,
collection_name=collection,
filter=f'file_id == "{file_id}"'
)
self.ap.logger.info(
f"Deleted embeddings from Milvus collection '{collection}' with file_id: {file_id}"
)
async def delete_collection(self, collection: str):
"""Delete a Milvus collection
Args:
collection: Collection name to delete
"""
if collection in self._collections:
del self._collections[collection]
# Check if collection exists before attempting deletion
has_collection = await asyncio.to_thread(
self.client.has_collection, collection_name=collection
)
if has_collection:
await asyncio.to_thread(
self.client.drop_collection, collection_name=collection
)
self.ap.logger.info(f"Deleted Milvus collection '{collection}'")
else:
self.ap.logger.warning(f"Milvus collection '{collection}' not found")

View File

@@ -0,0 +1,286 @@
from __future__ import annotations
import asyncio
from typing import Any, Dict
from sqlalchemy import create_engine, text, Column, String, Text
from sqlalchemy.orm import declarative_base, sessionmaker, Session
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker
from pgvector.sqlalchemy import Vector
from langbot.pkg.vector.vdb import VectorDatabase
from langbot.pkg.core import app
import uuid
Base = declarative_base()
class PgVectorEntry(Base):
"""SQLAlchemy model for pgvector entries"""
__tablename__ = 'langbot_vectors'
id = Column(String, primary_key=True)
collection = Column(String, index=True, nullable=False)
embedding = Column(Vector(1536)) # Default dimension, will be created dynamically
text = Column(Text)
file_id = Column(String, index=True)
chunk_uuid = Column(String)
class PgVectorDatabase(VectorDatabase):
"""PostgreSQL with pgvector extension database implementation"""
def __init__(
self,
ap: app.Application,
connection_string: str = None,
host: str = "localhost",
port: int = 5432,
database: str = "langbot",
user: str = "postgres",
password: str = "postgres"
):
"""Initialize pgvector database
Args:
ap: Application instance
connection_string: Full PostgreSQL connection string (overrides other params)
host: PostgreSQL host
port: PostgreSQL port
database: Database name
user: Database user
password: Database password
"""
self.ap = ap
# Build connection string if not provided
if connection_string:
self.connection_string = connection_string
else:
self.connection_string = (
f"postgresql+psycopg://{user}:{password}@{host}:{port}/{database}"
)
self.async_connection_string = self.connection_string.replace(
"postgresql://", "postgresql+asyncpg://"
).replace(
"postgresql+psycopg://", "postgresql+asyncpg://"
)
self.engine = None
self.async_engine = None
self.SessionLocal = None
self.AsyncSessionLocal = None
self._collections = set()
self._initialize_db()
def _initialize_db(self):
"""Initialize database connection and create tables"""
try:
# Create async engine for async operations
self.async_engine = create_async_engine(
self.async_connection_string,
echo=False,
pool_pre_ping=True
)
self.AsyncSessionLocal = async_sessionmaker(
self.async_engine,
class_=AsyncSession,
expire_on_commit=False
)
# Create sync engine for table creation
sync_connection_string = self.connection_string.replace(
"postgresql+asyncpg://", "postgresql+psycopg://"
)
self.engine = create_engine(sync_connection_string, echo=False)
# Create pgvector extension and tables
with self.engine.connect() as conn:
# Enable pgvector extension
conn.execute(text("CREATE EXTENSION IF NOT EXISTS vector"))
conn.commit()
# Create tables
Base.metadata.create_all(self.engine)
self.ap.logger.info(f"Connected to PostgreSQL with pgvector")
except Exception as e:
self.ap.logger.error(f"Failed to connect to PostgreSQL: {e}")
raise
async def get_or_create_collection(self, collection: str):
"""Get or create a collection (logical grouping in pgvector)
Args:
collection: Collection name (knowledge base UUID)
"""
# In pgvector, collections are logical - we just track them
if collection not in self._collections:
self._collections.add(collection)
self.ap.logger.info(f"Registered pgvector collection '{collection}'")
return collection
async def add_embeddings(
self,
collection: str,
ids: list[str],
embeddings_list: list[list[float]],
metadatas: list[dict[str, Any]],
) -> None:
"""Add vector embeddings to pgvector
Args:
collection: Collection name
ids: List of unique IDs for each vector
embeddings_list: List of embedding vectors
metadatas: List of metadata dictionaries
"""
await self.get_or_create_collection(collection)
async with self.AsyncSessionLocal() as session:
try:
for i, vector_id in enumerate(ids):
metadata = metadatas[i] if i < len(metadatas) else {}
entry = PgVectorEntry(
id=vector_id,
collection=collection,
embedding=embeddings_list[i],
text=metadata.get("text", ""),
file_id=metadata.get("file_id", ""),
chunk_uuid=metadata.get("uuid", "")
)
session.add(entry)
await session.commit()
self.ap.logger.info(
f"Added {len(ids)} embeddings to pgvector collection '{collection}'"
)
except Exception as e:
await session.rollback()
self.ap.logger.error(f"Error adding embeddings to pgvector: {e}")
raise
async def search(
self, collection: str, query_embedding: list[float], k: int = 5
) -> Dict[str, Any]:
"""Search for similar vectors using cosine distance
Args:
collection: Collection name
query_embedding: Query vector
k: Number of top results to return
Returns:
Dictionary with search results in Chroma-compatible format
"""
await self.get_or_create_collection(collection)
async with self.AsyncSessionLocal() as session:
try:
# Use cosine distance for similarity search
from sqlalchemy import select, func
# Query for similar vectors
stmt = (
select(
PgVectorEntry.id,
PgVectorEntry.text,
PgVectorEntry.file_id,
PgVectorEntry.chunk_uuid,
PgVectorEntry.embedding.cosine_distance(query_embedding).label('distance')
)
.filter(PgVectorEntry.collection == collection)
.order_by(PgVectorEntry.embedding.cosine_distance(query_embedding))
.limit(k)
)
result = await session.execute(stmt)
rows = result.fetchall()
# Convert to Chroma-compatible format
ids = []
distances = []
metadatas = []
for row in rows:
ids.append(row.id)
distances.append(float(row.distance))
metadatas.append({
"text": row.text or "",
"file_id": row.file_id or "",
"uuid": row.chunk_uuid or ""
})
result_dict = {
"ids": [ids],
"distances": [distances],
"metadatas": [metadatas]
}
self.ap.logger.info(
f"pgvector search in '{collection}' returned {len(ids)} results"
)
return result_dict
except Exception as e:
self.ap.logger.error(f"Error searching pgvector: {e}")
raise
async def delete_by_file_id(self, collection: str, file_id: str) -> None:
"""Delete vectors by file_id
Args:
collection: Collection name
file_id: File ID to filter deletion
"""
await self.get_or_create_collection(collection)
async with self.AsyncSessionLocal() as session:
try:
from sqlalchemy import delete
stmt = delete(PgVectorEntry).where(
PgVectorEntry.collection == collection,
PgVectorEntry.file_id == file_id
)
await session.execute(stmt)
await session.commit()
self.ap.logger.info(
f"Deleted embeddings from pgvector collection '{collection}' with file_id: {file_id}"
)
except Exception as e:
await session.rollback()
self.ap.logger.error(f"Error deleting from pgvector: {e}")
raise
async def delete_collection(self, collection: str):
"""Delete all vectors in a collection
Args:
collection: Collection name to delete
"""
if collection in self._collections:
self._collections.remove(collection)
async with self.AsyncSessionLocal() as session:
try:
from sqlalchemy import delete
stmt = delete(PgVectorEntry).where(
PgVectorEntry.collection == collection
)
await session.execute(stmt)
await session.commit()
self.ap.logger.info(f"Deleted pgvector collection '{collection}'")
except Exception as e:
await session.rollback()
self.ap.logger.error(f"Error deleting pgvector collection: {e}")
raise
async def close(self):
"""Close database connections"""
if self.async_engine:
await self.async_engine.dispose()
if self.engine:
self.engine.dispose()

View File

@@ -36,6 +36,15 @@ vdb:
host: localhost host: localhost
port: 6333 port: 6333
api_key: '' api_key: ''
milvus:
uri: 'http://127.0.0.1:19530'
token: ''
pgvector:
host: '127.0.0.1'
port: 5433
database: 'langbot'
user: 'postgres'
password: 'postgres'
storage: storage:
use: local use: local
s3: s3:
@@ -49,4 +58,4 @@ plugin:
runtime_ws_url: 'ws://langbot_plugin_runtime:5400/control/ws' runtime_ws_url: 'ws://langbot_plugin_runtime:5400/control/ws'
enable_marketplace: true enable_marketplace: true
cloud_service_url: 'https://space.langbot.app' cloud_service_url: 'https://space.langbot.app'
display_plugin_debug_url: 'http://localhost:5401' display_plugin_debug_url: 'ws://localhost:5401/plugin/debug/ws'

View File

@@ -14,6 +14,8 @@ from unittest.mock import AsyncMock, Mock
import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query
import langbot_plugin.api.entities.builtin.platform.message as platform_message import langbot_plugin.api.entities.builtin.platform.message as platform_message
import langbot_plugin.api.entities.builtin.platform.events as platform_events
import langbot_plugin.api.entities.builtin.platform.entities as platform_entities
import langbot_plugin.api.entities.builtin.provider.session as provider_session import langbot_plugin.api.entities.builtin.provider.session as provider_session
from langbot.pkg.pipeline import entities as pipeline_entities from langbot.pkg.pipeline import entities as pipeline_entities
@@ -159,12 +161,18 @@ def sample_message_chain():
@pytest.fixture @pytest.fixture
def sample_message_event(sample_message_chain): def sample_message_event(sample_message_chain):
"""Provides sample message event""" """Provides sample message event (FriendMessage)"""
event = Mock() sender = platform_entities.Friend(
event.sender = Mock() id=12345,
event.sender.id = 12345 nickname='TestUser',
event.time = 1609459200 # 2021-01-01 00:00:00 remark=None,
return event )
return platform_events.FriendMessage(
type='FriendMessage',
sender=sender,
message_chain=sample_message_chain,
time=1609459200, # 2021-01-01 00:00:00
)
@pytest.fixture @pytest.fixture

2
web/.gitignore vendored
View File

@@ -40,5 +40,3 @@ yarn-error.log*
# typescript # typescript
*.tsbuildinfo *.tsbuildinfo
next-env.d.ts next-env.d.ts
pnpm-lock.yaml

10394
web/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -51,11 +51,11 @@
"input-otp": "^1.4.2", "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.4.8", "next": "~15.5.7",
"next-themes": "^0.4.6", "next-themes": "^0.4.6",
"postcss": "^8.5.3", "postcss": "^8.5.3",
"react": "^19.0.0", "react": "19.2.1",
"react-dom": "^19.0.0", "react-dom": "19.2.1",
"react-hook-form": "^7.56.3", "react-hook-form": "^7.56.3",
"react-i18next": "^15.5.1", "react-i18next": "^15.5.1",
"react-markdown": "^10.1.0", "react-markdown": "^10.1.0",
@@ -82,8 +82,8 @@
"@types/mdast": "^4.0.4", "@types/mdast": "^4.0.4",
"@types/ms": "^2.1.0", "@types/ms": "^2.1.0",
"@types/node": "^20", "@types/node": "^20",
"@types/react": "^19", "@types/react": "~19.2.7",
"@types/react-dom": "^19", "@types/react-dom": "~19.2.3",
"@types/react-syntax-highlighter": "^15.5.13", "@types/react-syntax-highlighter": "^15.5.13",
"@types/unist": "^3.0.3", "@types/unist": "^3.0.3",
"eslint": "^9", "eslint": "^9",

6265
web/pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -111,14 +111,41 @@ export default function BotForm({
const [dynamicFormConfigList, setDynamicFormConfigList] = useState< const [dynamicFormConfigList, setDynamicFormConfigList] = useState<
IDynamicFormItemSchema[] IDynamicFormItemSchema[]
>([]); >([]);
const [filteredDynamicFormConfigList, setFilteredDynamicFormConfigList] =
useState<IDynamicFormItemSchema[]>([]);
const [, setIsLoading] = useState<boolean>(false); const [, setIsLoading] = useState<boolean>(false);
const [webhookUrl, setWebhookUrl] = useState<string>(''); const [webhookUrl, setWebhookUrl] = useState<string>('');
const webhookInputRef = React.useRef<HTMLInputElement>(null); const webhookInputRef = React.useRef<HTMLInputElement>(null);
// Watch adapter and adapter_config for filtering
const currentAdapter = form.watch('adapter');
const currentAdapterConfig = form.watch('adapter_config');
useEffect(() => { useEffect(() => {
setBotFormValues(); setBotFormValues();
}, []); }, []);
// Filter dynamic form config list based on enable-webhook status for Lark adapter
useEffect(() => {
if (currentAdapter === 'lark') {
const enableWebhook = currentAdapterConfig?.['enable-webhook'];
if (enableWebhook === false) {
// Hide encrypt-key field when webhook is disabled
setFilteredDynamicFormConfigList(
dynamicFormConfigList.filter(
(config) => config.name !== 'encrypt-key',
),
);
} else {
// Show all fields when webhook is enabled or undefined
setFilteredDynamicFormConfigList(dynamicFormConfigList);
}
} else {
// For non-Lark adapters, show all fields
setFilteredDynamicFormConfigList(dynamicFormConfigList);
}
}, [currentAdapter, currentAdapterConfig, dynamicFormConfigList]);
// 复制到剪贴板的辅助函数 - 使用页面上的真实input元素 // 复制到剪贴板的辅助函数 - 使用页面上的真实input元素
const copyToClipboard = () => { const copyToClipboard = () => {
console.log('[Copy] Attempting to copy from input element'); console.log('[Copy] Attempting to copy from input element');
@@ -498,34 +525,36 @@ export default function BotForm({
</div> </div>
{/* Webhook 地址显示(统一 Webhook 模式) */} {/* Webhook 地址显示(统一 Webhook 模式) */}
{webhookUrl && ( {webhookUrl &&
<FormItem> (currentAdapter !== 'lark' ||
<FormLabel>{t('bots.webhookUrl')}</FormLabel> currentAdapterConfig?.['enable-webhook'] !== false) && (
<div className="flex items-center gap-2"> <FormItem>
<Input <FormLabel>{t('bots.webhookUrl')}</FormLabel>
ref={webhookInputRef} <div className="flex items-center gap-2">
value={webhookUrl} <Input
readOnly ref={webhookInputRef}
className="flex-1 bg-gray-50 dark:bg-gray-900" value={webhookUrl}
onClick={(e) => { readOnly
// 点击输入框时自动全选 className="flex-1 bg-gray-50 dark:bg-gray-900"
(e.target as HTMLInputElement).select(); onClick={(e) => {
}} // 点击输入框时自动全选
/> (e.target as HTMLInputElement).select();
<Button }}
type="button" />
variant="outline" <Button
size="sm" type="button"
onClick={copyToClipboard} variant="outline"
> size="sm"
{t('common.copy')} onClick={copyToClipboard}
</Button> >
</div> {t('common.copy')}
<p className="text-sm text-gray-500 mt-1"> </Button>
{t('bots.webhookUrlHint')} </div>
</p> <p className="text-sm text-gray-500 mt-1">
</FormItem> {t('bots.webhookUrlHint')}
)} </p>
</FormItem>
)}
</> </>
)} )}
@@ -622,13 +651,13 @@ export default function BotForm({
</div> </div>
)} )}
{showDynamicForm && dynamicFormConfigList.length > 0 && ( {showDynamicForm && filteredDynamicFormConfigList.length > 0 && (
<div className="space-y-4"> <div className="space-y-4">
<div className="text-lg font-medium"> <div className="text-lg font-medium">
{t('bots.adapterConfig')} {t('bots.adapterConfig')}
</div> </div>
<DynamicFormComponent <DynamicFormComponent
itemConfigList={dynamicFormConfigList} itemConfigList={filteredDynamicFormConfigList}
initialValues={form.watch('adapter_config')} initialValues={form.watch('adapter_config')}
onSubmit={(values) => { onSubmit={(values) => {
form.setValue('adapter_config', values); form.setValue('adapter_config', values);

View File

@@ -44,12 +44,35 @@ export function BotLogCard({ botLog }: { botLog: BotLog }) {
const strArr = str.split(''); const strArr = str.split('');
return strArr; return strArr;
} }
// 根据日志级别返回对应的样式类
function getLevelStyles(level: string) {
switch (level.toLowerCase()) {
case 'error':
return 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400';
case 'warning':
return 'bg-orange-100 text-orange-800 dark:bg-orange-900/30 dark:text-orange-400';
case 'info':
return 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-400';
case 'debug':
return 'bg-gray-100 text-gray-800 dark:bg-gray-900/30 dark:text-gray-400';
default:
return 'bg-gray-100 text-gray-800 dark:bg-gray-900/30 dark:text-gray-400';
}
}
return ( return (
<div className={`${styles.botLogCardContainer}`}> <div className={`${styles.botLogCardContainer}`}>
{/* 头部标签,时间 */} {/* 头部标签,时间 */}
<div className={`${styles.cardTitleContainer}`}> <div className={`${styles.cardTitleContainer}`}>
<div className={`flex flex-row gap-2 items-center`}> <div className={`flex flex-row gap-2 items-center`}>
<div className={`${styles.tag}`}>{botLog.level}</div> <div
className={`px-2 py-1 rounded text-xs font-medium uppercase ${getLevelStyles(
botLog.level,
)}`}
>
{botLog.level}
</div>
{botLog.message_session_id && ( {botLog.message_session_id && (
<div <div
className={`${styles.tag} ${styles.chatTag}`} className={`${styles.tag} ${styles.chatTag}`}

View File

@@ -1,11 +1,19 @@
'use client'; 'use client';
import { BotLogManager } from '@/app/home/bots/components/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, useMemo } 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/components/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 {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover';
import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
import { ChevronDownIcon } from 'lucide-react';
import { debounce } from 'lodash'; import { debounce } from 'lodash';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
@@ -14,9 +22,21 @@ export function BotLogListComponent({ botId }: { botId: string }) {
const manager = useRef(new BotLogManager(botId)).current; const manager = useRef(new BotLogManager(botId)).current;
const [botLogList, setBotLogList] = useState<BotLog[]>([]); const [botLogList, setBotLogList] = useState<BotLog[]>([]);
const [autoFlush, setAutoFlush] = useState(true); const [autoFlush, setAutoFlush] = useState(true);
const [selectedLevels, setSelectedLevels] = useState<string[]>([
'info',
'warning',
'error',
]);
const listContainerRef = useRef<HTMLDivElement>(null); const listContainerRef = useRef<HTMLDivElement>(null);
const botLogListRef = useRef<BotLog[]>(botLogList); const botLogListRef = useRef<BotLog[]>(botLogList);
const logLevels = [
{ value: 'error', label: 'ERROR' },
{ value: 'warning', label: 'WARNING' },
{ value: 'info', label: 'INFO' },
{ value: 'debug', label: 'DEBUG' },
];
useEffect(() => { useEffect(() => {
initComponent(); initComponent();
return () => { return () => {
@@ -28,6 +48,42 @@ export function BotLogListComponent({ botId }: { botId: string }) {
botLogListRef.current = botLogList; botLogListRef.current = botLogList;
}, [botLogList]); }, [botLogList]);
// 根据级别过滤日志
const filteredLogs = useMemo(() => {
if (selectedLevels.length === 0) {
return botLogList;
}
return botLogList.filter((log) => selectedLevels.includes(log.level));
}, [botLogList, selectedLevels]);
const handleLevelToggle = (levelValue: string) => {
setSelectedLevels((prev) => {
if (prev.includes(levelValue)) {
return prev.filter((l) => l !== levelValue);
} else {
return [...prev, levelValue];
}
});
};
const getDisplayText = () => {
if (selectedLevels.length === 0) {
return t('bots.selectLevel');
}
if (selectedLevels.length === logLevels.length) {
return t('bots.allLevels');
}
// 如果选中3个或以上显示数量
if (selectedLevels.length >= 3) {
return `${selectedLevels.length} ${t('bots.levelsSelected')}`;
}
// 显示选中级别的标签(大写形式)
return logLevels
.filter((level) => selectedLevels.includes(level.value))
.map((level) => level.label)
.join(', ');
};
// 观测自动刷新状态 // 观测自动刷新状态
useEffect(() => { useEffect(() => {
if (autoFlush) { if (autoFlush) {
@@ -116,9 +172,43 @@ export function BotLogListComponent({ botId }: { botId: string }) {
<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)} />
<div className={'ml-4 mr-2'}>{t('bots.logLevel')}</div>
<Popover>
<PopoverTrigger asChild>
<Button
variant="outline"
size="sm"
className="w-[180px] flex items-center justify-between"
>
<span className="text-sm truncate flex-1 text-left">
{getDisplayText()}
</span>
<ChevronDownIcon className="ml-2 h-4 w-4 flex-shrink-0" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[180px] p-2">
<div className="flex flex-col gap-2">
{logLevels.map((level) => (
<div key={level.value} className="flex items-center space-x-2">
<Checkbox
id={level.value}
checked={selectedLevels.includes(level.value)}
onCheckedChange={() => handleLevelToggle(level.value)}
/>
<label
htmlFor={level.value}
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70 cursor-pointer"
>
{level.label}
</label>
</div>
))}
</div>
</PopoverContent>
</Popover>
</div> </div>
{botLogList.map((botLog) => { {filteredLogs.map((botLog) => {
return <BotLogCard botLog={botLog} key={botLog.seq_id} />; return <BotLogCard botLog={botLog} key={botLog.seq_id} />;
})} })}
</div> </div>

View File

@@ -192,6 +192,10 @@ const enUS = {
webhookUrlCopied: 'Webhook URL copied', webhookUrlCopied: 'Webhook URL copied',
webhookUrlHint: webhookUrlHint:
'Click the input to select all, then press Ctrl+C (Mac: Cmd+C) to copy, or click the button', 'Click the input to select all, then press Ctrl+C (Mac: Cmd+C) to copy, or click the button',
logLevel: 'Log Level',
allLevels: 'All Levels',
selectLevel: 'Select Level',
levelsSelected: 'levels selected',
}, },
plugins: { plugins: {
title: 'Extensions', title: 'Extensions',

View File

@@ -194,6 +194,10 @@ const jaJP = {
webhookUrlCopied: 'Webhook URL をコピーしました', webhookUrlCopied: 'Webhook URL をコピーしました',
webhookUrlHint: webhookUrlHint:
'入力ボックスをクリックして全選択し、Ctrl+C (Mac: Cmd+C) でコピーするか、右側のボタンをクリックしてください', '入力ボックスをクリックして全選択し、Ctrl+C (Mac: Cmd+C) でコピーするか、右側のボタンをクリックしてください',
logLevel: 'ログレベル',
allLevels: 'すべてのレベル',
selectLevel: 'レベルを選択',
levelsSelected: 'レベル選択済み',
}, },
plugins: { plugins: {
title: '拡張機能', title: '拡張機能',

View File

@@ -187,6 +187,10 @@ const zhHans = {
webhookUrlCopied: 'Webhook 地址已复制', webhookUrlCopied: 'Webhook 地址已复制',
webhookUrlHint: webhookUrlHint:
'点击输入框自动全选,然后按 Ctrl+C (Mac: Cmd+C) 复制,或点击右侧按钮', '点击输入框自动全选,然后按 Ctrl+C (Mac: Cmd+C) 复制,或点击右侧按钮',
logLevel: '日志级别',
allLevels: '全部级别',
selectLevel: '选择级别',
levelsSelected: '个级别已选',
}, },
plugins: { plugins: {
title: '插件扩展', title: '插件扩展',

View File

@@ -187,6 +187,10 @@ const zhHant = {
webhookUrlCopied: 'Webhook 位址已複製', webhookUrlCopied: 'Webhook 位址已複製',
webhookUrlHint: webhookUrlHint:
'點擊輸入框自動全選,然後按 Ctrl+C (Mac: Cmd+C) 複製,或點擊右側按鈕', '點擊輸入框自動全選,然後按 Ctrl+C (Mac: Cmd+C) 複製,或點擊右側按鈕',
logLevel: '日誌級別',
allLevels: '全部級別',
selectLevel: '選擇級別',
levelsSelected: '個級別已選',
}, },
plugins: { plugins: {
title: '外掛擴展', title: '外掛擴展',