mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-06-02 03:55:55 +00:00
Compare commits
22 Commits
feat/teams
...
v4.6.0b1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
34e2fa03ce | ||
|
|
7b63bcdc39 | ||
|
|
d26e81620d | ||
|
|
e7885539a7 | ||
|
|
f216505237 | ||
|
|
8b11eefd0c | ||
|
|
418cddd657 | ||
|
|
75edeb7a01 | ||
|
|
c5aa5be4d8 | ||
|
|
92614062cc | ||
|
|
09307d8c6d | ||
|
|
894db240ae | ||
|
|
f79cde5b0c | ||
|
|
d43c2c498c | ||
|
|
5f6036c5a8 | ||
|
|
dead0794b1 | ||
|
|
f784bad08b | ||
|
|
4e86e1c93d | ||
|
|
c0eec966ac | ||
|
|
62d6dae4f5 | ||
|
|
cab573f3e2 | ||
|
|
8fe59da302 |
11
.github/pull_request_template.md
vendored
11
.github/pull_request_template.md
vendored
@@ -2,17 +2,6 @@
|
||||
|
||||
> 请在此部分填写你实现/解决/优化的内容:
|
||||
> Summary of what you implemented/solved/optimized:
|
||||
>
|
||||
|
||||
### 更改前后对比截图 / Screenshots
|
||||
|
||||
> 请在此部分粘贴更改前后对比截图(可以是界面截图、控制台输出、对话截图等):
|
||||
> Please paste the screenshots of changes before and after here (can be interface screenshots, console output, conversation screenshots, etc.):
|
||||
>
|
||||
> 修改前 / Before:
|
||||
>
|
||||
> 修改后 / After:
|
||||
>
|
||||
|
||||
## 检查清单 / Checklist
|
||||
|
||||
|
||||
37
.mcp.json
37
.mcp.json
@@ -1,37 +0,0 @@
|
||||
{
|
||||
"mcpServers": {
|
||||
"shadcn": {
|
||||
"command": "npx",
|
||||
"args": [
|
||||
"shadcn@latest",
|
||||
"mcp"
|
||||
]
|
||||
},
|
||||
"sequential-thinking": {
|
||||
"type": "stdio",
|
||||
"command": "npx",
|
||||
"args": ["-y", "@modelcontextprotocol/server-sequential-thinking"],
|
||||
"env": {}
|
||||
},
|
||||
"github": {
|
||||
"type": "stdio",
|
||||
"command": "npx",
|
||||
"args": ["-y", "@modelcontextprotocol/server-github"],
|
||||
"env": {
|
||||
"GITHUB_PERSONAL_ACCESS_TOKEN": "${GITHUB_PERSONAL_ACCESS_TOKEN}"
|
||||
}
|
||||
},
|
||||
"fetch": {
|
||||
"type": "stdio",
|
||||
"command": "uvx",
|
||||
"args": ["mcp-server-fetch"],
|
||||
"env": {}
|
||||
},
|
||||
"playwright": {
|
||||
"type": "stdio",
|
||||
"command": "npx",
|
||||
"args": ["-y", "@playwright/mcp@latest"],
|
||||
"env": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
19
README.md
19
README.md
@@ -8,7 +8,7 @@
|
||||
|
||||
<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="Featured|HelloGitHub" style="width: 250px; height: 54px;" width="250" height="54" /></a>
|
||||
|
||||
[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) / (PR for your language)
|
||||
|
||||
[](https://discord.gg/wdNEHETs87)
|
||||
[](https://qm.qq.com/q/JLi38whHum)
|
||||
@@ -31,16 +31,25 @@ LangBot 是一个开源的大语言模型原生即时通信机器人开发平台
|
||||
|
||||
## 📦 开始使用
|
||||
|
||||
#### 快速部署
|
||||
#### 快速体验(推荐)
|
||||
|
||||
使用 `uvx` 一键启动(需要先安装 [uv](https://docs.astral.sh/uv/getting-started/installation/)):
|
||||
使用 `uvx` 一键启动(无需安装):
|
||||
|
||||
```bash
|
||||
uvx langbot
|
||||
```
|
||||
|
||||
或使用 `pip` 安装后运行:
|
||||
|
||||
```bash
|
||||
pip install langbot
|
||||
langbot
|
||||
```
|
||||
|
||||
访问 http://localhost:5300 即可开始使用。
|
||||
|
||||
详细文档[PyPI 安装](docs/PYPI_INSTALLATION.md)。
|
||||
|
||||
#### Docker Compose 部署
|
||||
|
||||
```bash
|
||||
@@ -83,10 +92,10 @@ docker compose up -d
|
||||
|
||||
## ✨ 特性
|
||||
|
||||
- 💬 大模型对话、Agent:支持多种大模型,适配群聊和私聊;具有多轮对话、工具调用、多模态、流式输出能力,自带 RAG(知识库)实现,并深度适配 [Dify](https://dify.ai)、[Coze](https://coze.com)、[n8n](https://n8n.io)等 LLMOps 平台。
|
||||
- 💬 大模型对话、Agent:支持多种大模型,适配群聊和私聊;具有多轮对话、工具调用、多模态、流式输出能力,自带 RAG(知识库)实现,并深度适配 [Dify](https://dify.ai)。
|
||||
- 🤖 多平台支持:目前支持 QQ、QQ频道、企业微信、个人微信、飞书、Discord、Telegram 等平台。
|
||||
- 🛠️ 高稳定性、功能完备:原生支持访问控制、限速、敏感词过滤等机制;配置简单,支持多种部署方式。支持多流水线配置,不同机器人用于不同应用场景。
|
||||
- 🧩 插件扩展、活跃社区:高稳定性、高安全性的生产级插件系统,支持事件驱动、组件扩展等插件机制;适配 Anthropic [MCP 协议](https://modelcontextprotocol.io/);目前已有数百个插件。
|
||||
- 🧩 插件扩展、活跃社区:支持事件驱动、组件扩展等插件机制;适配 Anthropic [MCP 协议](https://modelcontextprotocol.io/);目前已有数百个插件。
|
||||
- 😻 Web 管理面板:支持通过浏览器管理 LangBot 实例,不再需要手动编写配置文件。
|
||||
|
||||
详细规格特性请访问[文档](https://docs.langbot.app/zh/insight/features.html)。
|
||||
|
||||
21
README_EN.md
21
README_EN.md
@@ -5,9 +5,7 @@
|
||||
|
||||
<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-grade IM bot made easy. | Product Hunt" style="width: 250px; height: 54px;" width="250" height="54" /></a>
|
||||
|
||||
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) / (PR for your language)
|
||||
|
||||
[](https://discord.gg/wdNEHETs87)
|
||||
[](https://deepwiki.com/langbot-app/LangBot)
|
||||
@@ -27,16 +25,25 @@ LangBot is an open-source LLM native instant messaging robot development platfor
|
||||
|
||||
## 📦 Getting Started
|
||||
|
||||
#### Quick Start
|
||||
#### Quick Start (Recommended)
|
||||
|
||||
Use `uvx` to start with one command (need to install [uv](https://docs.astral.sh/uv/getting-started/installation/)):
|
||||
Use `uvx` to start with one command (no installation required):
|
||||
|
||||
```bash
|
||||
uvx langbot
|
||||
```
|
||||
|
||||
Or install with `pip` and run:
|
||||
|
||||
```bash
|
||||
pip install langbot
|
||||
langbot
|
||||
```
|
||||
|
||||
Visit http://localhost:5300 to start using it.
|
||||
|
||||
Detailed documentation [PyPI Installation](docs/PYPI_INSTALLATION.md).
|
||||
|
||||
#### Docker Compose Deployment
|
||||
|
||||
```bash
|
||||
@@ -79,10 +86,10 @@ Click the Star and Watch button in the upper right corner of the repository to g
|
||||
|
||||
## ✨ Features
|
||||
|
||||
- 💬 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).
|
||||
- 🤖 Multi-platform Support: Currently supports QQ, QQ Channel, WeCom, personal WeChat, Lark, DingTalk, Discord, Telegram, etc.
|
||||
- 🛠️ High Stability, Feature-rich: Native access control, rate limiting, sensitive word filtering, etc. mechanisms; Easy to use, supports multiple deployment methods. Supports multiple pipeline configurations, different bots can be used for different scenarios.
|
||||
- 🧩 Plugin Extension, Active Community: High stability, high security production-level plugin system; Support event-driven, component extension, etc. plugin mechanisms; Integrate Anthropic [MCP protocol](https://modelcontextprotocol.io/); Currently has hundreds of plugins.
|
||||
- 🧩 Plugin Extension, Active Community: Support event-driven, component extension, etc. plugin mechanisms; Integrate Anthropic [MCP protocol](https://modelcontextprotocol.io/); Currently has hundreds of plugins.
|
||||
- 😻 Web UI: Support management LangBot instance through the browser. No need to manually write configuration files.
|
||||
|
||||
For more detailed specifications, please refer to the [documentation](https://docs.langbot.app/en/insight/features.html).
|
||||
|
||||
143
README_ES.md
143
README_ES.md
@@ -1,143 +0,0 @@
|
||||
<p align="center">
|
||||
<a href="https://langbot.app">
|
||||
<img src="https://docs.langbot.app/social_en.png" alt="LangBot"/>
|
||||
</a>
|
||||
|
||||
<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-grade IM bot made easy. | Product Hunt" style="width: 250px; height: 54px;" width="250" height="54" /></a>
|
||||
|
||||
[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)
|
||||
|
||||
[](https://discord.gg/wdNEHETs87)
|
||||
[](https://deepwiki.com/langbot-app/LangBot)
|
||||
[](https://github.com/langbot-app/LangBot/releases/latest)
|
||||
<img src="https://img.shields.io/badge/python-3.10 ~ 3.13 -blue.svg" alt="python">
|
||||
|
||||
<a href="https://langbot.app">Inicio</a> |
|
||||
<a href="https://docs.langbot.app/en/insight/guide.html">Despliegue</a> |
|
||||
<a href="https://docs.langbot.app/en/plugin/plugin-intro.html">Plugin</a> |
|
||||
<a href="https://github.com/langbot-app/LangBot/issues/new?assignees=&labels=%E7%8B%AC%E7%AB%8B%E6%8F%92%E4%BB%B6&projects=&template=submit-plugin.yml&title=%5BPlugin%5D%3A+%E8%AF%B7%E6%B1%82%E7%99%BB%E8%AE%B0%E6%96%B0%E6%8F%92%E4%BB%B6">Enviar Plugin</a>
|
||||
|
||||
</div>
|
||||
|
||||
</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
|
||||
|
||||
#### Inicio Rápido
|
||||
|
||||
Use `uvx` para iniciar con un comando (necesita instalar [uv](https://docs.astral.sh/uv/getting-started/installation/)):
|
||||
|
||||
```bash
|
||||
uvx langbot
|
||||
```
|
||||
|
||||
Visite http://localhost:5300 para comenzar a usarlo.
|
||||
|
||||
#### Despliegue con Docker Compose
|
||||
|
||||
```bash
|
||||
git clone https://github.com/langbot-app/LangBot
|
||||
cd LangBot/docker
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
Visite http://localhost:5300 para comenzar a usarlo.
|
||||
|
||||
Documentación detallada [Despliegue con Docker](https://docs.langbot.app/en/deploy/langbot/docker.html).
|
||||
|
||||
#### Despliegue con un clic en BTPanel
|
||||
|
||||
LangBot ha sido listado en BTPanel. Si tiene BTPanel instalado, puede usar la [documentación](https://docs.langbot.app/en/deploy/langbot/one-click/bt.html) para usarlo.
|
||||
|
||||
#### Despliegue en la Nube Zeabur
|
||||
|
||||
Plantilla de Zeabur contribuida por la comunidad.
|
||||
|
||||
[](https://zeabur.com/en-US/templates/ZKTBDH)
|
||||
|
||||
#### Despliegue en la Nube Railway
|
||||
|
||||
[](https://railway.app/template/yRrAyL?referralCode=vogKPF)
|
||||
|
||||
#### Otros Métodos de Despliegue
|
||||
|
||||
Use directamente la versión publicada para ejecutar, consulte la documentación de [Despliegue Manual](https://docs.langbot.app/en/deploy/langbot/manual.html).
|
||||
|
||||
#### Despliegue en Kubernetes
|
||||
|
||||
Consulte la documentación de [Despliegue en Kubernetes](./docker/README_K8S.md).
|
||||
|
||||
## 😎 Manténgase Actualizado
|
||||
|
||||
Haga clic en los botones Star y Watch en la esquina superior derecha del repositorio para obtener las últimas actualizaciones.
|
||||
|
||||

|
||||
|
||||
## ✨ Características
|
||||
|
||||
- 💬 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, 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.
|
||||
- 🧩 Extensión de Plugin, Comunidad Activa: Sistema de plugin de alta estabilidad, alta seguridad de nivel de producción; Compatible con mecanismos de plugin impulsados por eventos, extensión de componentes, etc.; Integración del protocolo [MCP](https://modelcontextprotocol.io/) de Anthropic; Actualmente cuenta con cientos de plugins.
|
||||
- 😻 Interfaz Web: Admite la gestión de instancias de LangBot a través del navegador. No es necesario escribir archivos de configuración manualmente.
|
||||
|
||||
Para especificaciones más detalladas, consulte la [documentación](https://docs.langbot.app/en/insight/features.html).
|
||||
|
||||
O visite el entorno de demostración: https://demo.langbot.dev/
|
||||
- Información de inicio de sesión: Correo electrónico: `demo@langbot.app` Contraseña: `langbot123456`
|
||||
- Nota: Solo para demostración de WebUI, por favor no ingrese información confidencial en el entorno público.
|
||||
|
||||
### Plataformas de Mensajería
|
||||
|
||||
| Plataforma | Estado | Observaciones |
|
||||
| --- | --- | --- |
|
||||
| Discord | ✅ | |
|
||||
| Telegram | ✅ | |
|
||||
| Slack | ✅ | |
|
||||
| LINE | ✅ | |
|
||||
| QQ Personal | ✅ | |
|
||||
| QQ API Oficial | ✅ | |
|
||||
| WeCom | ✅ | |
|
||||
| WeComCS | ✅ | |
|
||||
| WeCom AI Bot | ✅ | |
|
||||
| WeChat Personal | ✅ | |
|
||||
| Lark | ✅ | |
|
||||
| DingTalk | ✅ | |
|
||||
|
||||
### LLMs
|
||||
|
||||
| LLM | Estado | Observaciones |
|
||||
| --- | --- | --- |
|
||||
| [OpenAI](https://platform.openai.com/) | ✅ | Disponible para cualquier modelo con formato de interfaz OpenAI |
|
||||
| [DeepSeek](https://www.deepseek.com/) | ✅ | |
|
||||
| [Moonshot](https://www.moonshot.cn/) | ✅ | |
|
||||
| [Anthropic](https://www.anthropic.com/) | ✅ | |
|
||||
| [xAI](https://x.ai/) | ✅ | |
|
||||
| [Zhipu AI](https://open.bigmodel.cn/) | ✅ | |
|
||||
| [CompShare](https://www.compshare.cn/?ytag=GPU_YY-gh_langbot) | ✅ | Plataforma de recursos LLM y GPU |
|
||||
| [PPIO](https://ppinfra.com/user/register?invited_by=QJKFYD&utm_source=github_langbot) | ✅ | Plataforma de recursos LLM y GPU |
|
||||
| [接口 AI](https://jiekou.ai/) | ✅ | Plataforma de agregación LLM |
|
||||
| [ShengSuanYun](https://www.shengsuanyun.com/?from=CH_KYIPP758) | ✅ | Plataforma de recursos LLM y GPU |
|
||||
| [302.AI](https://share.302.ai/SuTG99) | ✅ | Gateway LLM (MaaS) |
|
||||
| [Google Gemini](https://aistudio.google.com/prompts/new_chat) | ✅ | |
|
||||
| [Dify](https://dify.ai) | ✅ | Plataforma LLMOps |
|
||||
| [Ollama](https://ollama.com/) | ✅ | Plataforma de ejecución de LLM local |
|
||||
| [LMStudio](https://lmstudio.ai/) | ✅ | Plataforma de ejecución de LLM local |
|
||||
| [GiteeAI](https://ai.gitee.com/) | ✅ | Gateway de interfaz LLM (MaaS) |
|
||||
| [SiliconFlow](https://siliconflow.cn/) | ✅ | Gateway LLM (MaaS) |
|
||||
| [Aliyun Bailian](https://bailian.console.aliyun.com/) | ✅ | Gateway LLM (MaaS), plataforma LLMOps |
|
||||
| [Volc Engine Ark](https://console.volcengine.com/ark/region:ark+cn-beijing/model?vendor=Bytedance&view=LIST_VIEW) | ✅ | Gateway LLM (MaaS), plataforma LLMOps |
|
||||
| [ModelScope](https://modelscope.cn/docs/model-service/API-Inference/intro) | ✅ | Gateway LLM (MaaS) |
|
||||
| [MCP](https://modelcontextprotocol.io/) | ✅ | Compatible con acceso a herramientas a través del protocolo MCP |
|
||||
|
||||
## 🤝 Contribución de la Comunidad
|
||||
|
||||
Gracias a los siguientes [contribuidores de código](https://github.com/langbot-app/LangBot/graphs/contributors) y otros miembros de la comunidad por sus contribuciones a LangBot:
|
||||
|
||||
<a href="https://github.com/langbot-app/LangBot/graphs/contributors">
|
||||
<img src="https://contrib.rocks/image?repo=langbot-app/LangBot" />
|
||||
</a>
|
||||
143
README_FR.md
143
README_FR.md
@@ -1,143 +0,0 @@
|
||||
<p align="center">
|
||||
<a href="https://langbot.app">
|
||||
<img src="https://docs.langbot.app/social_en.png" alt="LangBot"/>
|
||||
</a>
|
||||
|
||||
<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-grade IM bot made easy. | Product Hunt" style="width: 250px; height: 54px;" width="250" height="54" /></a>
|
||||
|
||||
[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)
|
||||
|
||||
[](https://discord.gg/wdNEHETs87)
|
||||
[](https://deepwiki.com/langbot-app/LangBot)
|
||||
[](https://github.com/langbot-app/LangBot/releases/latest)
|
||||
<img src="https://img.shields.io/badge/python-3.10 ~ 3.13 -blue.svg" alt="python">
|
||||
|
||||
<a href="https://langbot.app">Accueil</a> |
|
||||
<a href="https://docs.langbot.app/en/insight/guide.html">Déploiement</a> |
|
||||
<a href="https://docs.langbot.app/en/plugin/plugin-intro.html">Plugin</a> |
|
||||
<a href="https://github.com/langbot-app/LangBot/issues/new?assignees=&labels=%E7%8B%AC%E7%AB%8B%E6%8F%92%E4%BB%B6&projects=&template=submit-plugin.yml&title=%5BPlugin%5D%3A+%E8%AF%B7%E6%B1%82%E7%99%BB%E8%AE%B0%E6%96%B0%E6%8F%92%E4%BB%B6">Soumettre un Plugin</a>
|
||||
|
||||
</div>
|
||||
|
||||
</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
|
||||
|
||||
#### Démarrage Rapide
|
||||
|
||||
Utilisez `uvx` pour démarrer avec une commande (besoin d'installer [uv](https://docs.astral.sh/uv/getting-started/installation/)) :
|
||||
|
||||
```bash
|
||||
uvx langbot
|
||||
```
|
||||
|
||||
Visitez http://localhost:5300 pour commencer à l'utiliser.
|
||||
|
||||
#### Déploiement avec Docker Compose
|
||||
|
||||
```bash
|
||||
git clone https://github.com/langbot-app/LangBot
|
||||
cd LangBot/docker
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
Visitez http://localhost:5300 pour commencer à l'utiliser.
|
||||
|
||||
Documentation détaillée [Déploiement Docker](https://docs.langbot.app/en/deploy/langbot/docker.html).
|
||||
|
||||
#### Déploiement en un clic sur BTPanel
|
||||
|
||||
LangBot a été répertorié sur BTPanel. Si vous avez installé BTPanel, vous pouvez utiliser la [documentation](https://docs.langbot.app/en/deploy/langbot/one-click/bt.html) pour l'utiliser.
|
||||
|
||||
#### Déploiement Cloud Zeabur
|
||||
|
||||
Modèle Zeabur contribué par la communauté.
|
||||
|
||||
[](https://zeabur.com/en-US/templates/ZKTBDH)
|
||||
|
||||
#### Déploiement Cloud Railway
|
||||
|
||||
[](https://railway.app/template/yRrAyL?referralCode=vogKPF)
|
||||
|
||||
#### Autres Méthodes de Déploiement
|
||||
|
||||
Utilisez directement la version publiée pour exécuter, consultez la documentation de [Déploiement Manuel](https://docs.langbot.app/en/deploy/langbot/manual.html).
|
||||
|
||||
#### Déploiement Kubernetes
|
||||
|
||||
Consultez la documentation de [Déploiement Kubernetes](./docker/README_K8S.md).
|
||||
|
||||
## 😎 Restez à Jour
|
||||
|
||||
Cliquez sur les boutons Star et Watch dans le coin supérieur droit du dépôt pour obtenir les dernières mises à jour.
|
||||
|
||||

|
||||
|
||||
## ✨ Fonctionnalités
|
||||
|
||||
- 💬 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, 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.
|
||||
- 🧩 Extension de Plugin, Communauté Active : Système de plugin de haute stabilité, haute sécurité de niveau production; Prend en charge les mécanismes de plugin pilotés par événements, l'extension de composants, etc. ; Intégration du protocole [MCP](https://modelcontextprotocol.io/) d'Anthropic ; Dispose actuellement de centaines de plugins.
|
||||
- 😻 Interface Web : Prend en charge la gestion des instances LangBot via le navigateur. Pas besoin d'écrire manuellement les fichiers de configuration.
|
||||
|
||||
Pour des spécifications plus détaillées, veuillez consulter la [documentation](https://docs.langbot.app/en/insight/features.html).
|
||||
|
||||
Ou visitez l'environnement de démonstration : https://demo.langbot.dev/
|
||||
- Informations de connexion : Email : `demo@langbot.app` Mot de passe : `langbot123456`
|
||||
- Note : Pour la démonstration WebUI uniquement, veuillez ne pas entrer d'informations sensibles dans l'environnement public.
|
||||
|
||||
### Plateformes de Messagerie
|
||||
|
||||
| Plateforme | Statut | Remarques |
|
||||
| --- | --- | --- |
|
||||
| Discord | ✅ | |
|
||||
| Telegram | ✅ | |
|
||||
| Slack | ✅ | |
|
||||
| LINE | ✅ | |
|
||||
| QQ Personnel | ✅ | |
|
||||
| API Officielle QQ | ✅ | |
|
||||
| WeCom | ✅ | |
|
||||
| WeComCS | ✅ | |
|
||||
| WeCom AI Bot | ✅ | |
|
||||
| WeChat Personnel | ✅ | |
|
||||
| Lark | ✅ | |
|
||||
| DingTalk | ✅ | |
|
||||
|
||||
### LLMs
|
||||
|
||||
| LLM | Statut | Remarques |
|
||||
| --- | --- | --- |
|
||||
| [OpenAI](https://platform.openai.com/) | ✅ | Disponible pour tout modèle au format d'interface OpenAI |
|
||||
| [DeepSeek](https://www.deepseek.com/) | ✅ | |
|
||||
| [Moonshot](https://www.moonshot.cn/) | ✅ | |
|
||||
| [Anthropic](https://www.anthropic.com/) | ✅ | |
|
||||
| [xAI](https://x.ai/) | ✅ | |
|
||||
| [Zhipu AI](https://open.bigmodel.cn/) | ✅ | |
|
||||
| [CompShare](https://www.compshare.cn/?ytag=GPU_YY-gh_langbot) | ✅ | Plateforme de ressources LLM et GPU |
|
||||
| [PPIO](https://ppinfra.com/user/register?invited_by=QJKFYD&utm_source=github_langbot) | ✅ | Plateforme de ressources LLM et GPU |
|
||||
| [接口 AI](https://jiekou.ai/) | ✅ | Plateforme d'agrégation LLM |
|
||||
| [ShengSuanYun](https://www.shengsuanyun.com/?from=CH_KYIPP758) | ✅ | Plateforme de ressources LLM et GPU |
|
||||
| [302.AI](https://share.302.ai/SuTG99) | ✅ | Passerelle LLM (MaaS) |
|
||||
| [Google Gemini](https://aistudio.google.com/prompts/new_chat) | ✅ | |
|
||||
| [Dify](https://dify.ai) | ✅ | Plateforme LLMOps |
|
||||
| [Ollama](https://ollama.com/) | ✅ | Plateforme d'exécution LLM locale |
|
||||
| [LMStudio](https://lmstudio.ai/) | ✅ | Plateforme d'exécution LLM locale |
|
||||
| [GiteeAI](https://ai.gitee.com/) | ✅ | Passerelle d'interface LLM (MaaS) |
|
||||
| [SiliconFlow](https://siliconflow.cn/) | ✅ | Passerelle LLM (MaaS) |
|
||||
| [Aliyun Bailian](https://bailian.console.aliyun.com/) | ✅ | Passerelle LLM (MaaS), plateforme LLMOps |
|
||||
| [Volc Engine Ark](https://console.volcengine.com/ark/region:ark+cn-beijing/model?vendor=Bytedance&view=LIST_VIEW) | ✅ | Passerelle LLM (MaaS), plateforme LLMOps |
|
||||
| [ModelScope](https://modelscope.cn/docs/model-service/API-Inference/intro) | ✅ | Passerelle LLM (MaaS) |
|
||||
| [MCP](https://modelcontextprotocol.io/) | ✅ | Prend en charge l'accès aux outils via le protocole MCP |
|
||||
|
||||
## 🤝 Contribution de la Communauté
|
||||
|
||||
Merci aux [contributeurs de code](https://github.com/langbot-app/LangBot/graphs/contributors) suivants et aux autres membres de la communauté pour leurs contributions à LangBot :
|
||||
|
||||
<a href="https://github.com/langbot-app/LangBot/graphs/contributors">
|
||||
<img src="https://contrib.rocks/image?repo=langbot-app/LangBot" />
|
||||
</a>
|
||||
18
README_JP.md
18
README_JP.md
@@ -5,9 +5,7 @@
|
||||
|
||||
<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-grade IM bot made easy. | Product Hunt" style="width: 250px; height: 54px;" width="250" height="54" /></a>
|
||||
|
||||
[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) / 日本語 / (PR for your language)
|
||||
|
||||
[](https://discord.gg/wdNEHETs87)
|
||||
[](https://deepwiki.com/langbot-app/LangBot)
|
||||
@@ -27,16 +25,6 @@ LangBot は、エージェント、RAG、MCP などの LLM アプリケーショ
|
||||
|
||||
## 📦 始め方
|
||||
|
||||
#### クイックスタート
|
||||
|
||||
`uvx` を使用した迅速なデプロイ([uv](https://docs.astral.sh/uv/getting-started/installation/) が必要です):
|
||||
|
||||
```bash
|
||||
uvx langbot
|
||||
```
|
||||
|
||||
http://localhost:5300 にアクセスして使用を開始します。
|
||||
|
||||
#### Docker Compose デプロイ
|
||||
|
||||
```bash
|
||||
@@ -79,10 +67,10 @@ LangBotはBTPanelにリストされています。BTPanelをインストール
|
||||
|
||||
## ✨ 機能
|
||||
|
||||
- 💬 LLM / エージェントとのチャット: 複数のLLMをサポートし、グループチャットとプライベートチャットに対応。マルチラウンドの会話、ツールの呼び出し、マルチモーダル、ストリーミング出力機能をサポート、RAG(知識ベース)を組み込み、[Dify](https://dify.ai)、[Coze](https://coze.com)、[n8n](https://n8n.io) などの LLMOps プラットフォームと深く統合。
|
||||
- 💬 LLM / エージェントとのチャット: 複数のLLMをサポートし、グループチャットとプライベートチャットに対応。マルチラウンドの会話、ツールの呼び出し、マルチモーダル、ストリーミング出力機能をサポート、RAG(知識ベース)を組み込み、[Dify](https://dify.ai) と深く統合。
|
||||
- 🤖 多プラットフォーム対応: 現在、QQ、QQ チャンネル、WeChat、個人 WeChat、Lark、DingTalk、Discord、Telegram など、複数のプラットフォームをサポートしています。
|
||||
- 🛠️ 高い安定性、豊富な機能: ネイティブのアクセス制御、レート制限、敏感な単語のフィルタリングなどのメカニズムをサポート。使いやすく、複数のデプロイ方法をサポート。複数のパイプライン設定をサポートし、異なるボットを異なる用途に使用できます。
|
||||
- 🧩 プラグイン拡張、活発なコミュニティ: 高い安定性、高いセキュリティの生産レベルのプラグインシステム;イベント駆動、コンポーネント拡張などのプラグインメカニズムをサポート。適配 Anthropic [MCP プロトコル](https://modelcontextprotocol.io/);豊富なエコシステム、現在数百のプラグインが存在。
|
||||
- 🧩 プラグイン拡張、活発なコミュニティ: イベント駆動、コンポーネント拡張などのプラグインメカニズムをサポート。適配 Anthropic [MCP プロトコル](https://modelcontextprotocol.io/);豊富なエコシステム、現在数百のプラグインが存在。
|
||||
- 😻 Web UI: ブラウザを通じてLangBotインスタンスを管理することをサポート。
|
||||
|
||||
詳細な仕様については、[ドキュメント](https://docs.langbot.app/en/insight/features.html)を参照してください。
|
||||
|
||||
143
README_KO.md
143
README_KO.md
@@ -1,143 +0,0 @@
|
||||
<p align="center">
|
||||
<a href="https://langbot.app">
|
||||
<img src="https://docs.langbot.app/social_en.png" alt="LangBot"/>
|
||||
</a>
|
||||
|
||||
<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-grade IM bot made easy. | Product Hunt" style="width: 250px; height: 54px;" width="250" height="54" /></a>
|
||||
|
||||
[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)
|
||||
|
||||
[](https://discord.gg/wdNEHETs87)
|
||||
[](https://deepwiki.com/langbot-app/LangBot)
|
||||
[](https://github.com/langbot-app/LangBot/releases/latest)
|
||||
<img src="https://img.shields.io/badge/python-3.10 ~ 3.13 -blue.svg" alt="python">
|
||||
|
||||
<a href="https://langbot.app">홈</a> |
|
||||
<a href="https://docs.langbot.app/en/insight/guide.html">배포</a> |
|
||||
<a href="https://docs.langbot.app/en/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>
|
||||
|
||||
</div>
|
||||
|
||||
</p>
|
||||
|
||||
LangBot은 오픈 소스 LLM 네이티브 인스턴트 메시징 로봇 개발 플랫폼으로, Agent, RAG, MCP 등 다양한 LLM 애플리케이션 기능을 갖춘 즉시 사용 가능한 IM 로봇 개발 경험을 제공하며, 글로벌 인스턴트 메시징 플랫폼에 적응하고 풍부한 API 인터페이스를 제공하여 맞춤형 개발을 지원합니다.
|
||||
|
||||
## 📦 시작하기
|
||||
|
||||
#### 빠른 시작
|
||||
|
||||
`uvx`를 사용하여 한 명령으로 시작하세요 ([uv](https://docs.astral.sh/uv/getting-started/installation/) 설치 필요):
|
||||
|
||||
```bash
|
||||
uvx langbot
|
||||
```
|
||||
|
||||
http://localhost:5300을 방문하여 사용을 시작하세요.
|
||||
|
||||
#### Docker Compose 배포
|
||||
|
||||
```bash
|
||||
git clone https://github.com/langbot-app/LangBot
|
||||
cd LangBot/docker
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
http://localhost:5300을 방문하여 사용을 시작하세요.
|
||||
|
||||
자세한 문서는 [Docker 배포](https://docs.langbot.app/en/deploy/langbot/docker.html)를 참조하세요.
|
||||
|
||||
#### BTPanel 원클릭 배포
|
||||
|
||||
LangBot은 BTPanel에 등록되어 있습니다. BTPanel을 설치한 경우 [문서](https://docs.langbot.app/en/deploy/langbot/one-click/bt.html)를 사용하여 사용할 수 있습니다.
|
||||
|
||||
#### Zeabur 클라우드 배포
|
||||
|
||||
커뮤니티에서 제공하는 Zeabur 템플릿입니다.
|
||||
|
||||
[](https://zeabur.com/en-US/templates/ZKTBDH)
|
||||
|
||||
#### Railway 클라우드 배포
|
||||
|
||||
[](https://railway.app/template/yRrAyL?referralCode=vogKPF)
|
||||
|
||||
#### 기타 배포 방법
|
||||
|
||||
릴리스 버전을 직접 사용하여 실행하려면 [수동 배포](https://docs.langbot.app/en/deploy/langbot/manual.html) 문서를 참조하세요.
|
||||
|
||||
#### Kubernetes 배포
|
||||
|
||||
[Kubernetes 배포](./docker/README_K8S.md) 문서를 참조하세요.
|
||||
|
||||
## 😎 최신 정보 받기
|
||||
|
||||
리포지토리 오른쪽 상단의 Star 및 Watch 버튼을 클릭하여 최신 업데이트를 받으세요.
|
||||
|
||||

|
||||
|
||||
## ✨ 기능
|
||||
|
||||
- 💬 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 등을 지원합니다.
|
||||
- 🛠️ 높은 안정성, 풍부한 기능: 네이티브 액세스 제어, 속도 제한, 민감한 단어 필터링 등의 메커니즘; 사용하기 쉽고 여러 배포 방법을 지원합니다. 여러 파이프라인 구성을 지원하며 다양한 시나리오에 대해 다른 봇을 사용할 수 있습니다.
|
||||
- 🧩 플러그인 확장, 활발한 커뮤니티: 고안정성, 고보안 생산 수준의 플러그인 시스템; 이벤트 기반, 컴포넌트 확장 등의 플러그인 메커니즘을 지원; Anthropic [MCP 프로토콜](https://modelcontextprotocol.io/) 통합; 현재 수백 개의 플러그인이 있습니다.
|
||||
- 😻 웹 UI: 브라우저를 통해 LangBot 인스턴스 관리를 지원합니다. 구성 파일을 수동으로 작성할 필요가 없습니다.
|
||||
|
||||
더 자세한 사양은 [문서](https://docs.langbot.app/en/insight/features.html)를 참조하세요.
|
||||
|
||||
또는 데모 환경을 방문하세요: https://demo.langbot.dev/
|
||||
- 로그인 정보: 이메일: `demo@langbot.app` 비밀번호: `langbot123456`
|
||||
- 참고: WebUI 데모 전용이므로 공개 환경에서는 민감한 정보를 입력하지 마세요.
|
||||
|
||||
### 메시징 플랫폼
|
||||
|
||||
| 플랫폼 | 상태 | 비고 |
|
||||
| --- | --- | --- |
|
||||
| Discord | ✅ | |
|
||||
| Telegram | ✅ | |
|
||||
| Slack | ✅ | |
|
||||
| LINE | ✅ | |
|
||||
| 개인 QQ | ✅ | |
|
||||
| QQ 공식 API | ✅ | |
|
||||
| WeCom | ✅ | |
|
||||
| WeComCS | ✅ | |
|
||||
| WeCom AI Bot | ✅ | |
|
||||
| 개인 WeChat | ✅ | |
|
||||
| Lark | ✅ | |
|
||||
| DingTalk | ✅ | |
|
||||
|
||||
### LLMs
|
||||
|
||||
| LLM | 상태 | 비고 |
|
||||
| --- | --- | --- |
|
||||
| [OpenAI](https://platform.openai.com/) | ✅ | 모든 OpenAI 인터페이스 형식 모델에 사용 가능 |
|
||||
| [DeepSeek](https://www.deepseek.com/) | ✅ | |
|
||||
| [Moonshot](https://www.moonshot.cn/) | ✅ | |
|
||||
| [Anthropic](https://www.anthropic.com/) | ✅ | |
|
||||
| [xAI](https://x.ai/) | ✅ | |
|
||||
| [Zhipu AI](https://open.bigmodel.cn/) | ✅ | |
|
||||
| [CompShare](https://www.compshare.cn/?ytag=GPU_YY-gh_langbot) | ✅ | LLM 및 GPU 리소스 플랫폼 |
|
||||
| [PPIO](https://ppinfra.com/user/register?invited_by=QJKFYD&utm_source=github_langbot) | ✅ | LLM 및 GPU 리소스 플랫폼 |
|
||||
| [接口 AI](https://jiekou.ai/) | ✅ | LLM 집계 플랫폼 |
|
||||
| [ShengSuanYun](https://www.shengsuanyun.com/?from=CH_KYIPP758) | ✅ | LLM 및 GPU 리소스 플랫폼 |
|
||||
| [302.AI](https://share.302.ai/SuTG99) | ✅ | LLM 게이트웨이(MaaS) |
|
||||
| [Google Gemini](https://aistudio.google.com/prompts/new_chat) | ✅ | |
|
||||
| [Dify](https://dify.ai) | ✅ | LLMOps 플랫폼 |
|
||||
| [Ollama](https://ollama.com/) | ✅ | 로컬 LLM 실행 플랫폼 |
|
||||
| [LMStudio](https://lmstudio.ai/) | ✅ | 로컬 LLM 실행 플랫폼 |
|
||||
| [GiteeAI](https://ai.gitee.com/) | ✅ | LLM 인터페이스 게이트웨이(MaaS) |
|
||||
| [SiliconFlow](https://siliconflow.cn/) | ✅ | LLM 게이트웨이(MaaS) |
|
||||
| [Aliyun Bailian](https://bailian.console.aliyun.com/) | ✅ | LLM 게이트웨이(MaaS), LLMOps 플랫폼 |
|
||||
| [Volc Engine Ark](https://console.volcengine.com/ark/region:ark+cn-beijing/model?vendor=Bytedance&view=LIST_VIEW) | ✅ | LLM 게이트웨이(MaaS), LLMOps 플랫폼 |
|
||||
| [ModelScope](https://modelscope.cn/docs/model-service/API-Inference/intro) | ✅ | LLM 게이트웨이(MaaS) |
|
||||
| [MCP](https://modelcontextprotocol.io/) | ✅ | MCP 프로토콜을 통한 도구 액세스 지원 |
|
||||
|
||||
## 🤝 커뮤니티 기여
|
||||
|
||||
다음 [코드 기여자](https://github.com/langbot-app/LangBot/graphs/contributors) 및 커뮤니티의 다른 구성원들의 LangBot 기여에 감사드립니다:
|
||||
|
||||
<a href="https://github.com/langbot-app/LangBot/graphs/contributors">
|
||||
<img src="https://contrib.rocks/image?repo=langbot-app/LangBot" />
|
||||
</a>
|
||||
143
README_RU.md
143
README_RU.md
@@ -1,143 +0,0 @@
|
||||
<p align="center">
|
||||
<a href="https://langbot.app">
|
||||
<img src="https://docs.langbot.app/social_en.png" alt="LangBot"/>
|
||||
</a>
|
||||
|
||||
<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-grade IM bot made easy. | Product Hunt" style="width: 250px; height: 54px;" width="250" height="54" /></a>
|
||||
|
||||
[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)
|
||||
|
||||
[](https://discord.gg/wdNEHETs87)
|
||||
[](https://deepwiki.com/langbot-app/LangBot)
|
||||
[](https://github.com/langbot-app/LangBot/releases/latest)
|
||||
<img src="https://img.shields.io/badge/python-3.10 ~ 3.13 -blue.svg" alt="python">
|
||||
|
||||
<a href="https://langbot.app">Главная</a> |
|
||||
<a href="https://docs.langbot.app/en/insight/guide.html">Развертывание</a> |
|
||||
<a href="https://docs.langbot.app/en/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>
|
||||
|
||||
</div>
|
||||
|
||||
</p>
|
||||
|
||||
LangBot — это платформа разработки ботов для мгновенных сообщений на основе LLM с открытым исходным кодом, целью которой является предоставление готового к использованию опыта разработки ботов для IM, с функциями приложений LLM, такими как Agent, RAG, MCP, адаптацией к глобальным платформам мгновенных сообщений и предоставлением богатых API-интерфейсов, поддерживающих пользовательскую разработку.
|
||||
|
||||
## 📦 Начало работы
|
||||
|
||||
#### Быстрый старт
|
||||
|
||||
Используйте `uvx` для запуска одной командой (требуется установка [uv](https://docs.astral.sh/uv/getting-started/installation/)):
|
||||
|
||||
```bash
|
||||
uvx langbot
|
||||
```
|
||||
|
||||
Посетите http://localhost:5300, чтобы начать использование.
|
||||
|
||||
#### Развертывание с Docker Compose
|
||||
|
||||
```bash
|
||||
git clone https://github.com/langbot-app/LangBot
|
||||
cd LangBot/docker
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
Посетите http://localhost:5300, чтобы начать использование.
|
||||
|
||||
Подробная документация [Развертывание Docker](https://docs.langbot.app/en/deploy/langbot/docker.html).
|
||||
|
||||
#### Развертывание одним кликом на BTPanel
|
||||
|
||||
LangBot добавлен в BTPanel. Если у вас установлен BTPanel, вы можете использовать [документацию](https://docs.langbot.app/en/deploy/langbot/one-click/bt.html) для его использования.
|
||||
|
||||
#### Облачное развертывание Zeabur
|
||||
|
||||
Шаблон Zeabur, предоставленный сообществом.
|
||||
|
||||
[](https://zeabur.com/en-US/templates/ZKTBDH)
|
||||
|
||||
#### Облачное развертывание Railway
|
||||
|
||||
[](https://railway.app/template/yRrAyL?referralCode=vogKPF)
|
||||
|
||||
#### Другие методы развертывания
|
||||
|
||||
Используйте выпущенную версию напрямую для запуска, см. документацию [Ручное развертывание](https://docs.langbot.app/en/deploy/langbot/manual.html).
|
||||
|
||||
#### Развертывание Kubernetes
|
||||
|
||||
См. документацию [Развертывание Kubernetes](./docker/README_K8S.md).
|
||||
|
||||
## 😎 Оставайтесь в курсе
|
||||
|
||||
Нажмите кнопки Star и Watch в правом верхнем углу репозитория, чтобы получать последние обновления.
|
||||
|
||||

|
||||
|
||||
## ✨ Функции
|
||||
|
||||
- 💬 Чат с 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 и т.д.
|
||||
- 🛠️ Высокая стабильность, богатство функций: Нативный контроль доступа, ограничение скорости, фильтрация чувствительных слов и т.д.; Простота в использовании, поддержка нескольких методов развертывания. Поддержка нескольких конфигураций конвейера, разные боты для разных сценариев.
|
||||
- 🧩 Расширение плагинов, активное сообщество: Высокая стабильность, высокая безопасность уровня производства; Поддержка механизмов плагинов, управляемых событиями, расширения компонентов и т.д.; Интеграция протокола [MCP](https://modelcontextprotocol.io/) от Anthropic; В настоящее время сотни плагинов.
|
||||
- 😻 Веб-интерфейс: Поддержка управления экземплярами LangBot через браузер. Нет необходимости вручную писать конфигурационные файлы.
|
||||
|
||||
Для более подробных спецификаций обратитесь к [документации](https://docs.langbot.app/en/insight/features.html).
|
||||
|
||||
Или посетите демонстрационную среду: https://demo.langbot.dev/
|
||||
- Информация для входа: Email: `demo@langbot.app` Пароль: `langbot123456`
|
||||
- Примечание: Только для демонстрации WebUI, пожалуйста, не вводите конфиденциальную информацию в общедоступной среде.
|
||||
|
||||
### Платформы обмена сообщениями
|
||||
|
||||
| Платформа | Статус | Примечания |
|
||||
| --- | --- | --- |
|
||||
| Discord | ✅ | |
|
||||
| Telegram | ✅ | |
|
||||
| Slack | ✅ | |
|
||||
| LINE | ✅ | |
|
||||
| Личный QQ | ✅ | |
|
||||
| Официальный API QQ | ✅ | |
|
||||
| WeCom | ✅ | |
|
||||
| WeComCS | ✅ | |
|
||||
| WeCom AI Bot | ✅ | |
|
||||
| Личный WeChat | ✅ | |
|
||||
| Lark | ✅ | |
|
||||
| DingTalk | ✅ | |
|
||||
|
||||
### LLMs
|
||||
|
||||
| LLM | Статус | Примечания |
|
||||
| --- | --- | --- |
|
||||
| [OpenAI](https://platform.openai.com/) | ✅ | Доступна для любой модели формата интерфейса OpenAI |
|
||||
| [DeepSeek](https://www.deepseek.com/) | ✅ | |
|
||||
| [Moonshot](https://www.moonshot.cn/) | ✅ | |
|
||||
| [Anthropic](https://www.anthropic.com/) | ✅ | |
|
||||
| [xAI](https://x.ai/) | ✅ | |
|
||||
| [Zhipu AI](https://open.bigmodel.cn/) | ✅ | |
|
||||
| [CompShare](https://www.compshare.cn/?ytag=GPU_YY-gh_langbot) | ✅ | Платформа ресурсов LLM и GPU |
|
||||
| [PPIO](https://ppinfra.com/user/register?invited_by=QJKFYD&utm_source=github_langbot) | ✅ | Платформа ресурсов LLM и GPU |
|
||||
| [接口 AI](https://jiekou.ai/) | ✅ | Платформа агрегации LLM |
|
||||
| [ShengSuanYun](https://www.shengsuanyun.com/?from=CH_KYIPP758) | ✅ | Платформа ресурсов LLM и GPU |
|
||||
| [302.AI](https://share.302.ai/SuTG99) | ✅ | Шлюз LLM (MaaS) |
|
||||
| [Google Gemini](https://aistudio.google.com/prompts/new_chat) | ✅ | |
|
||||
| [Dify](https://dify.ai) | ✅ | Платформа LLMOps |
|
||||
| [Ollama](https://ollama.com/) | ✅ | Платформа локального запуска LLM |
|
||||
| [LMStudio](https://lmstudio.ai/) | ✅ | Платформа локального запуска LLM |
|
||||
| [GiteeAI](https://ai.gitee.com/) | ✅ | Шлюз интерфейса LLM (MaaS) |
|
||||
| [SiliconFlow](https://siliconflow.cn/) | ✅ | Шлюз LLM (MaaS) |
|
||||
| [Aliyun Bailian](https://bailian.console.aliyun.com/) | ✅ | Шлюз LLM (MaaS), платформа LLMOps |
|
||||
| [Volc Engine Ark](https://console.volcengine.com/ark/region:ark+cn-beijing/model?vendor=Bytedance&view=LIST_VIEW) | ✅ | Шлюз LLM (MaaS), платформа LLMOps |
|
||||
| [ModelScope](https://modelscope.cn/docs/model-service/API-Inference/intro) | ✅ | Шлюз LLM (MaaS) |
|
||||
| [MCP](https://modelcontextprotocol.io/) | ✅ | Поддержка доступа к инструментам через протокол MCP |
|
||||
|
||||
## 🤝 Вклад сообщества
|
||||
|
||||
Спасибо следующим [контрибьюторам кода](https://github.com/langbot-app/LangBot/graphs/contributors) и другим членам сообщества за их вклад в LangBot:
|
||||
|
||||
<a href="https://github.com/langbot-app/LangBot/graphs/contributors">
|
||||
<img src="https://contrib.rocks/image?repo=langbot-app/LangBot" />
|
||||
</a>
|
||||
16
README_TW.md
16
README_TW.md
@@ -5,7 +5,7 @@
|
||||
|
||||
<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="Featured|HelloGitHub" style="width: 250px; height: 54px;" width="250" height="54" /></a>
|
||||
|
||||
[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) / (PR for your language)
|
||||
|
||||
[](https://discord.gg/wdNEHETs87)
|
||||
[](https://qm.qq.com/q/JLi38whHum)
|
||||
@@ -27,16 +27,6 @@ LangBot 是一個開源的大語言模型原生即時通訊機器人開發平台
|
||||
|
||||
## 📦 開始使用
|
||||
|
||||
#### 快速部署
|
||||
|
||||
使用 `uvx` 一鍵啟動(需要先安裝 [uv](https://docs.astral.sh/uv/getting-started/installation/) ):
|
||||
|
||||
```bash
|
||||
uvx langbot
|
||||
```
|
||||
|
||||
訪問 http://localhost:5300 即可開始使用。
|
||||
|
||||
#### Docker Compose 部署
|
||||
|
||||
```bash
|
||||
@@ -79,10 +69,10 @@ docker compose up -d
|
||||
|
||||
## ✨ 特性
|
||||
|
||||
- 💬 大模型對話、Agent:支援多種大模型,適配群聊和私聊;具有多輪對話、工具調用、多模態、流式輸出能力,自帶 RAG(知識庫)實現,並深度適配 [Dify](https://dify.ai)、[Coze](https://coze.com)、[n8n](https://n8n.io) 等 LLMOps 平台。
|
||||
- 💬 大模型對話、Agent:支援多種大模型,適配群聊和私聊;具有多輪對話、工具調用、多模態、流式輸出能力,自帶 RAG(知識庫)實現,並深度適配 [Dify](https://dify.ai)。
|
||||
- 🤖 多平台支援:目前支援 QQ、QQ頻道、企業微信、個人微信、飛書、Discord、Telegram 等平台。
|
||||
- 🛠️ 高穩定性、功能完備:原生支援訪問控制、限速、敏感詞過濾等機制;配置簡單,支援多種部署方式。支援多流水線配置,不同機器人用於不同應用場景。
|
||||
- 🧩 外掛擴展、活躍社群:高穩定性、高安全性的生產級外掛系統;支援事件驅動、組件擴展等外掛機制;適配 Anthropic [MCP 協議](https://modelcontextprotocol.io/);目前已有數百個外掛。
|
||||
- 🧩 外掛擴展、活躍社群:支援事件驅動、組件擴展等外掛機制;適配 Anthropic [MCP 協議](https://modelcontextprotocol.io/);目前已有數百個外掛。
|
||||
- 😻 Web 管理面板:支援通過瀏覽器管理 LangBot 實例,不再需要手動編寫配置文件。
|
||||
|
||||
詳細規格特性請訪問[文件](https://docs.langbot.app/zh/insight/features.html)。
|
||||
|
||||
143
README_VI.md
143
README_VI.md
@@ -1,143 +0,0 @@
|
||||
<p align="center">
|
||||
<a href="https://langbot.app">
|
||||
<img src="https://docs.langbot.app/social_en.png" alt="LangBot"/>
|
||||
</a>
|
||||
|
||||
<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-grade IM bot made easy. | Product Hunt" style="width: 250px; height: 54px;" width="250" height="54" /></a>
|
||||
|
||||
[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
|
||||
|
||||
[](https://discord.gg/wdNEHETs87)
|
||||
[](https://deepwiki.com/langbot-app/LangBot)
|
||||
[](https://github.com/langbot-app/LangBot/releases/latest)
|
||||
<img src="https://img.shields.io/badge/python-3.10 ~ 3.13 -blue.svg" alt="python">
|
||||
|
||||
<a href="https://langbot.app">Trang chủ</a> |
|
||||
<a href="https://docs.langbot.app/en/insight/guide.html">Triển khai</a> |
|
||||
<a href="https://docs.langbot.app/en/plugin/plugin-intro.html">Plugin</a> |
|
||||
<a href="https://github.com/langbot-app/LangBot/issues/new?assignees=&labels=%E7%8B%AC%E7%AB%8B%E6%8F%92%E4%BB%B6&projects=&template=submit-plugin.yml&title=%5BPlugin%5D%3A+%E8%AF%B7%E6%B1%82%E7%99%BB%E8%AE%B0%E6%96%B0%E6%8F%92%E4%BB%B6">Gửi Plugin</a>
|
||||
|
||||
</div>
|
||||
|
||||
</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
|
||||
|
||||
#### Khởi động Nhanh
|
||||
|
||||
Sử dụng `uvx` để khởi động bằng một lệnh (cần cài đặt [uv](https://docs.astral.sh/uv/getting-started/installation/)):
|
||||
|
||||
```bash
|
||||
uvx langbot
|
||||
```
|
||||
|
||||
Truy cập http://localhost:5300 để bắt đầu sử dụng.
|
||||
|
||||
#### Triển khai Docker Compose
|
||||
|
||||
```bash
|
||||
git clone https://github.com/langbot-app/LangBot
|
||||
cd LangBot/docker
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
Truy cập http://localhost:5300 để bắt đầu sử dụng.
|
||||
|
||||
Tài liệu chi tiết [Triển khai Docker](https://docs.langbot.app/en/deploy/langbot/docker.html).
|
||||
|
||||
#### Triển khai Một cú nhấp chuột trên BTPanel
|
||||
|
||||
LangBot đã được liệt kê trên BTPanel. Nếu bạn đã cài đặt BTPanel, bạn có thể sử dụng [tài liệu](https://docs.langbot.app/en/deploy/langbot/one-click/bt.html) để sử dụng nó.
|
||||
|
||||
#### Triển khai Cloud Zeabur
|
||||
|
||||
Mẫu Zeabur được đóng góp bởi cộng đồng.
|
||||
|
||||
[](https://zeabur.com/en-US/templates/ZKTBDH)
|
||||
|
||||
#### Triển khai Cloud Railway
|
||||
|
||||
[](https://railway.app/template/yRrAyL?referralCode=vogKPF)
|
||||
|
||||
#### Các Phương pháp Triển khai Khác
|
||||
|
||||
Sử dụng trực tiếp phiên bản phát hành để chạy, xem tài liệu [Triển khai Thủ công](https://docs.langbot.app/en/deploy/langbot/manual.html).
|
||||
|
||||
#### Triển khai Kubernetes
|
||||
|
||||
Tham khảo tài liệu [Triển khai Kubernetes](./docker/README_K8S.md).
|
||||
|
||||
## 😎 Cập nhật Mới nhất
|
||||
|
||||
Nhấp vào các nút Star và Watch ở góc trên bên phải của kho lưu trữ để nhận các bản cập nhật mới nhất.
|
||||
|
||||

|
||||
|
||||
## ✨ Tính năng
|
||||
|
||||
- 💬 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, 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.
|
||||
- 🧩 Mở rộng Plugin, Cộng đồng Hoạt động: Hỗ trợ các cơ chế plugin hướng sự kiện, mở rộng thành phần, v.v.; Tích hợp giao thức [MCP](https://modelcontextprotocol.io/) của Anthropic; Hiện có hàng trăng plugin.
|
||||
- 😻 Giao diện Web: Hỗ trợ quản lý các phiên bản LangBot thông qua trình duyệt. Không cần viết tệp cấu hình thủ công.
|
||||
|
||||
Để biết thêm thông số kỹ thuật chi tiết, vui lòng tham khảo [tài liệu](https://docs.langbot.app/en/insight/features.html).
|
||||
|
||||
Hoặc truy cập môi trường demo: https://demo.langbot.dev/
|
||||
- Thông tin đăng nhập: Email: `demo@langbot.app` Mật khẩu: `langbot123456`
|
||||
- Lưu ý: Chỉ dành cho demo WebUI, vui lòng không nhập bất kỳ thông tin nhạy cảm nào trong môi trường công cộng.
|
||||
|
||||
### Nền tảng Nhắn tin
|
||||
|
||||
| Nền tảng | Trạng thái | Ghi chú |
|
||||
| --- | --- | --- |
|
||||
| Discord | ✅ | |
|
||||
| Telegram | ✅ | |
|
||||
| Slack | ✅ | |
|
||||
| LINE | ✅ | |
|
||||
| QQ Cá nhân | ✅ | |
|
||||
| QQ API Chính thức | ✅ | |
|
||||
| WeCom | ✅ | |
|
||||
| WeComCS | ✅ | |
|
||||
| WeCom AI Bot | ✅ | |
|
||||
| WeChat Cá nhân | ✅ | |
|
||||
| Lark | ✅ | |
|
||||
| DingTalk | ✅ | |
|
||||
|
||||
### LLMs
|
||||
|
||||
| LLM | Trạng thái | Ghi chú |
|
||||
| --- | --- | --- |
|
||||
| [OpenAI](https://platform.openai.com/) | ✅ | Có sẵn cho bất kỳ mô hình định dạng giao diện OpenAI nào |
|
||||
| [DeepSeek](https://www.deepseek.com/) | ✅ | |
|
||||
| [Moonshot](https://www.moonshot.cn/) | ✅ | |
|
||||
| [Anthropic](https://www.anthropic.com/) | ✅ | |
|
||||
| [xAI](https://x.ai/) | ✅ | |
|
||||
| [Zhipu AI](https://open.bigmodel.cn/) | ✅ | |
|
||||
| [CompShare](https://www.compshare.cn/?ytag=GPU_YY-gh_langbot) | ✅ | Nền tảng tài nguyên LLM và GPU |
|
||||
| [PPIO](https://ppinfra.com/user/register?invited_by=QJKFYD&utm_source=github_langbot) | ✅ | Nền tảng tài nguyên LLM và GPU |
|
||||
| [接口 AI](https://jiekou.ai/) | ✅ | Nền tảng tổng hợp LLM |
|
||||
| [ShengSuanYun](https://www.shengsuanyun.com/?from=CH_KYIPP758) | ✅ | Nền tảng tài nguyên LLM và GPU |
|
||||
| [302.AI](https://share.302.ai/SuTG99) | ✅ | Cổng LLM (MaaS) |
|
||||
| [Google Gemini](https://aistudio.google.com/prompts/new_chat) | ✅ | |
|
||||
| [Dify](https://dify.ai) | ✅ | Nền tảng LLMOps |
|
||||
| [Ollama](https://ollama.com/) | ✅ | Nền tảng chạy LLM cục bộ |
|
||||
| [LMStudio](https://lmstudio.ai/) | ✅ | Nền tảng chạy LLM cục bộ |
|
||||
| [GiteeAI](https://ai.gitee.com/) | ✅ | Cổng giao diện LLM (MaaS) |
|
||||
| [SiliconFlow](https://siliconflow.cn/) | ✅ | Cổng LLM (MaaS) |
|
||||
| [Aliyun Bailian](https://bailian.console.aliyun.com/) | ✅ | Cổng LLM (MaaS), nền tảng LLMOps |
|
||||
| [Volc Engine Ark](https://console.volcengine.com/ark/region:ark+cn-beijing/model?vendor=Bytedance&view=LIST_VIEW) | ✅ | Cổng LLM (MaaS), nền tảng LLMOps |
|
||||
| [ModelScope](https://modelscope.cn/docs/model-service/API-Inference/intro) | ✅ | Cổng LLM (MaaS) |
|
||||
| [MCP](https://modelcontextprotocol.io/) | ✅ | Hỗ trợ truy cập công cụ qua giao thức MCP |
|
||||
|
||||
## 🤝 Đóng góp Cộng đồng
|
||||
|
||||
Cảm ơn các [người đóng góp mã](https://github.com/langbot-app/LangBot/graphs/contributors) sau đây và các thành viên khác trong cộng đồng vì những đóng góp của họ cho LangBot:
|
||||
|
||||
<a href="https://github.com/langbot-app/LangBot/graphs/contributors">
|
||||
<img src="https://contrib.rocks/image?repo=langbot-app/LangBot" />
|
||||
</a>
|
||||
@@ -1,162 +0,0 @@
|
||||
# Microsoft Teams Adapter Implementation
|
||||
|
||||
## Overview
|
||||
|
||||
A new Microsoft Teams platform adapter has been added to LangBot, enabling support for both personal chats and channel/group chats on Microsoft Teams.
|
||||
|
||||
## Files Created/Modified
|
||||
|
||||
### New Files
|
||||
|
||||
1. **`src/langbot/pkg/platform/sources/teams.py`** - Main adapter implementation
|
||||
- `TeamsMessageConverter`: Converts between LangBot message format and Teams Activity format
|
||||
- `TeamsEventConverter`: Converts between Teams Activity events and LangBot platform events
|
||||
- `TeamsAdapter`: Main adapter class with webhook handling
|
||||
|
||||
2. **`src/langbot/pkg/platform/sources/teams.yaml`** - Adapter manifest
|
||||
- Defines adapter metadata, configuration schema, and execution details
|
||||
- Configuration fields: `app_id` and `app_password`
|
||||
|
||||
### Modified Files
|
||||
|
||||
1. **`pyproject.toml`** - Added dependencies:
|
||||
- `botbuilder-core>=4.15.0`
|
||||
- `botbuilder-schema>=4.15.0`
|
||||
- `botframework-connector>=4.15.0`
|
||||
- Added "teams" keyword
|
||||
|
||||
## Features
|
||||
|
||||
### Supported Message Types
|
||||
|
||||
- ✅ Plain text messages
|
||||
- ✅ Image attachments (base64, URL, and file path)
|
||||
- ✅ @mentions (converted to At components)
|
||||
- ✅ Message replies with quote support
|
||||
|
||||
### Supported Chat Types
|
||||
|
||||
- ✅ Personal chats (1-on-1 conversations)
|
||||
- ✅ Channel chats (group/team conversations)
|
||||
|
||||
### Message Handling
|
||||
|
||||
- **Incoming Messages**: Received via webhook at the unified webhook endpoint
|
||||
- **Outgoing Messages**: Sent via Bot Framework SDK using conversation references
|
||||
- **Event Types**: FriendMessage (personal) and GroupMessage (channel/group)
|
||||
|
||||
## Configuration Requirements
|
||||
|
||||
### Azure Setup
|
||||
|
||||
1. **Register an Azure AD Application**:
|
||||
- Go to Azure Portal → Azure Active Directory → App registrations
|
||||
- Create a new registration
|
||||
- Note the **Application (client) ID** - this is your `app_id`
|
||||
- Create a **Client Secret** - this is your `app_password`
|
||||
|
||||
2. **Create Azure Bot Resource**:
|
||||
- Go to Azure Portal → Create a resource → Azure Bot
|
||||
- Link it to your Azure AD application
|
||||
- Enable the Microsoft Teams channel
|
||||
- Set the messaging endpoint to: `https://<your-domain>/bots/<bot-uuid>`
|
||||
|
||||
### LangBot Configuration
|
||||
|
||||
When creating a Teams bot in LangBot, provide:
|
||||
|
||||
```yaml
|
||||
adapter: teams
|
||||
adapter_config:
|
||||
app_id: "<your-microsoft-app-id>"
|
||||
app_password: "<your-microsoft-app-password>"
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
### Webhook Mode
|
||||
|
||||
The Teams adapter operates in webhook mode, similar to the Slack adapter:
|
||||
- Integrates with LangBot's unified webhook system
|
||||
- Receives messages at `/bots/<bot-uuid>` endpoint
|
||||
- No independent server process required
|
||||
|
||||
### Message Flow
|
||||
|
||||
1. **Incoming**:
|
||||
- Teams → Azure Bot Service → LangBot Webhook
|
||||
- Bot Framework validates the request
|
||||
- Activity converted to LangBot event format
|
||||
- Event dispatched to registered listeners
|
||||
|
||||
2. **Outgoing**:
|
||||
- LangBot message chain → Teams Activity format
|
||||
- Reply sent via Bot Framework adapter
|
||||
- Uses conversation reference for proper routing
|
||||
|
||||
## Authentication
|
||||
|
||||
- Uses Bot Framework authentication with Microsoft App credentials
|
||||
- JWT token validation handled by `BotFrameworkAdapter`
|
||||
- Authorization header validated on each incoming request
|
||||
|
||||
## Limitations & Notes
|
||||
|
||||
1. **Direct Send**: The `send_message` method is limited - use `reply_message` for best results
|
||||
2. **Conversation References**: The adapter uses conversation references from incoming activities to send replies
|
||||
3. **Image Handling**: Images are converted to inline attachments using data URIs
|
||||
4. **Streaming**: Streaming replies are not yet implemented
|
||||
|
||||
## Testing
|
||||
|
||||
To test the adapter:
|
||||
|
||||
1. Install dependencies: `uv sync`
|
||||
2. Verify adapter initialization: `uv run python -c "from src.langbot.pkg.platform.sources.teams import TeamsAdapter; print('OK')"`
|
||||
3. Configure a Teams bot in LangBot with your Azure credentials
|
||||
4. Expose the LangBot API endpoint publicly (use ngrok or similar)
|
||||
5. Set the messaging endpoint in Azure Bot Service
|
||||
6. Add the bot to Teams and start chatting
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Pydantic Validation Error on Initialization
|
||||
|
||||
**Fixed**: The adapter was updated to properly handle optional fields (`adapter`, `app`, `bot_uuid`) by:
|
||||
- Setting `default=None` in pydantic.Field definitions
|
||||
- Not passing these fields to `super().__init__()`
|
||||
- Setting the adapter instance after parent class initialization
|
||||
|
||||
This resolves the validation error: "Input should be an instance of Quart" / "Input should be a valid string"
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
Potential improvements:
|
||||
- Adaptive Cards support
|
||||
- Rich message formatting (markdown, cards)
|
||||
- File attachments (non-image)
|
||||
- Streaming message support
|
||||
- Proactive messaging
|
||||
- Team/channel member list retrieval
|
||||
- Reaction handling
|
||||
|
||||
## Dependencies
|
||||
|
||||
The following Python packages were added:
|
||||
- `botbuilder-core` - Core Bot Framework functionality
|
||||
- `botbuilder-schema` - Activity and entity schemas
|
||||
- `botframework-connector` - Bot Framework connector for authentication
|
||||
|
||||
## References
|
||||
|
||||
- [Microsoft Teams Bot Framework Documentation](https://learn.microsoft.com/en-us/microsoftteams/platform/bots/bot-features)
|
||||
- [Bot Framework SDK for Python](https://github.com/microsoft/botbuilder-python)
|
||||
- [Teams Conversation Bot Sample](https://learn.microsoft.com/en-us/samples/officedev/microsoft-teams-samples/officedev-microsoft-teams-samples-bot-conversation-python/)
|
||||
|
||||
## Sources
|
||||
|
||||
Research sources consulted during implementation:
|
||||
- [Create an Incoming Webhook - Teams | Microsoft Learn](https://learn.microsoft.com/en-us/microsoftteams/platform/webhooks-and-connectors/how-to/add-incoming-webhook)
|
||||
- [Teams Conversation Bot - Code Samples | Microsoft Learn](https://learn.microsoft.com/en-us/samples/officedev/microsoft-teams-samples/officedev-microsoft-teams-samples-bot-conversation-python/)
|
||||
- [GitHub - microsoft/botbuilder-python](https://github.com/microsoft/botbuilder-python)
|
||||
- [Tools and Bot Framework SDKs for Bots - Teams](https://learn.microsoft.com/en-us/microsoftteams/platform/bots/bot-features)
|
||||
@@ -7,7 +7,6 @@ services:
|
||||
langbot_plugin_runtime:
|
||||
image: rockchin/langbot:latest
|
||||
container_name: langbot_plugin_runtime
|
||||
platform: linux/amd64 # For Apple Silicon compatibility
|
||||
volumes:
|
||||
- ./data/plugins:/app/data/plugins
|
||||
ports:
|
||||
@@ -22,7 +21,6 @@ services:
|
||||
langbot:
|
||||
image: rockchin/langbot:latest
|
||||
container_name: langbot
|
||||
platform: linux/amd64 # For Apple Silicon compatibility
|
||||
volumes:
|
||||
- ./data:/app/data
|
||||
- ./plugins:/app/plugins
|
||||
@@ -30,8 +28,8 @@ services:
|
||||
environment:
|
||||
- TZ=Asia/Shanghai
|
||||
ports:
|
||||
- 5300:5300 # For web ui and webhook callback
|
||||
- 2280-2285:2280-2285 # For platform reverse connection
|
||||
- 5300:5300 # For web ui
|
||||
- 2280-2290:2280-2290 # For platform webhook
|
||||
networks:
|
||||
- langbot_network
|
||||
|
||||
|
||||
@@ -1,412 +0,0 @@
|
||||
# WebChat 到 WebSocket 迁移总结
|
||||
|
||||
## 概述
|
||||
|
||||
已完全移除旧的基于SSE的WebChat系统,并替换为基于WebSocket的双向实时通信系统。这是一个内置在LangBot中的完整IM系统,支持流式输出。
|
||||
|
||||
## 已删除的文件
|
||||
|
||||
### 后端
|
||||
- ❌ `src/langbot/pkg/api/http/controller/groups/pipelines/webchat.py` - 旧的SSE路由
|
||||
- ❌ `src/langbot/pkg/platform/sources/webchat.py` - 旧的WebChat适配器
|
||||
- ❌ `src/langbot/pkg/platform/sources/webchat.yaml` - 旧的配置文件
|
||||
|
||||
### 前端
|
||||
- ❌ BackendClient中所有SSE相关代码已完全移除
|
||||
- ❌ DebugDialog中所有SSE相关逻辑已完全替换
|
||||
|
||||
## 新增的文件
|
||||
|
||||
### 后端核心文件
|
||||
|
||||
**1. WebSocket连接管理器**
|
||||
```
|
||||
src/langbot/pkg/platform/sources/websocket_manager.py
|
||||
```
|
||||
- 管理所有并发WebSocket连接
|
||||
- 线程安全的连接池
|
||||
- 按流水线、会话类型分组
|
||||
- 广播和单播消息功能
|
||||
- 连接统计和监控
|
||||
|
||||
**2. WebSocket适配器**
|
||||
```
|
||||
src/langbot/pkg/platform/sources/websocket_adapter.py
|
||||
```
|
||||
- 实现平台适配器接口
|
||||
- **完整流式支持** (`reply_message_chunk` 方法)
|
||||
- 双向消息流处理
|
||||
- 消息历史管理
|
||||
- 会话管理
|
||||
|
||||
**3. WebSocket路由控制器**
|
||||
```
|
||||
src/langbot/pkg/api/http/controller/groups/pipelines/websocket_chat.py
|
||||
```
|
||||
- WebSocket端点处理
|
||||
- REST API接口
|
||||
- 心跳机制
|
||||
- 连接生命周期管理
|
||||
|
||||
**4. 配置文件**
|
||||
```
|
||||
src/langbot/pkg/platform/sources/websocket.yaml
|
||||
```
|
||||
- WebSocket适配器元数据
|
||||
|
||||
### 前端核心文件
|
||||
|
||||
**1. WebSocket客户端**
|
||||
```
|
||||
web/src/app/infra/websocket/WebSocketClient.ts
|
||||
```
|
||||
- WebSocket连接管理
|
||||
- 自动重连(最多5次)
|
||||
- 心跳机制(30秒)
|
||||
- 事件回调系统
|
||||
|
||||
**2. 更新的组件**
|
||||
```
|
||||
web/src/app/home/pipelines/components/debug-dialog/DebugDialog.tsx
|
||||
```
|
||||
- 完全重写,使用WebSocket
|
||||
- 实时连接状态显示
|
||||
- 流式消息支持
|
||||
- 自动重连
|
||||
|
||||
**3. HTTP客户端更新**
|
||||
```
|
||||
web/src/app/infra/http/BackendClient.ts
|
||||
```
|
||||
- 移除所有旧的WebChat API
|
||||
- 仅保留WebSocket API
|
||||
|
||||
### 测试工具
|
||||
|
||||
**Python测试客户端**
|
||||
```
|
||||
test_websocket_client.py
|
||||
```
|
||||
- 单连接交互测试
|
||||
- 多连接并发测试
|
||||
- 命令行工具
|
||||
|
||||
### 文档
|
||||
|
||||
**使用文档**
|
||||
```
|
||||
WEBSOCKET_README.md
|
||||
```
|
||||
- 完整的API文档
|
||||
- 架构说明
|
||||
- 使用示例
|
||||
- 故障排查
|
||||
|
||||
## 核心变更
|
||||
|
||||
### 后端变更
|
||||
|
||||
**1. botmgr.py**
|
||||
- ❌ 移除 `webchat_proxy_bot`
|
||||
- ✅ 仅保留 `websocket_proxy_bot`
|
||||
- ✅ 更新适配器过滤逻辑(排除`websocket`而非`webchat`)
|
||||
|
||||
**2. 适配器注册**
|
||||
```python
|
||||
# 旧代码(已删除)
|
||||
webchat_adapter_class = self.adapter_dict['webchat']
|
||||
self.webchat_proxy_bot = RuntimeBot(...)
|
||||
|
||||
# 新代码
|
||||
websocket_adapter_class = self.adapter_dict['websocket']
|
||||
self.websocket_proxy_bot = RuntimeBot(
|
||||
uuid='websocket-proxy-bot',
|
||||
name='WebSocket',
|
||||
adapter='websocket',
|
||||
...
|
||||
)
|
||||
```
|
||||
|
||||
### 前端变更
|
||||
|
||||
**1. API调用完全更换**
|
||||
|
||||
旧代码(已删除):
|
||||
```typescript
|
||||
// SSE流式请求
|
||||
await fetch(url, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ is_stream: true })
|
||||
})
|
||||
// 手动解析 text/event-stream
|
||||
```
|
||||
|
||||
新代码:
|
||||
```typescript
|
||||
// WebSocket实时通信
|
||||
const wsClient = new WebSocketClient(pipelineId, sessionType);
|
||||
await wsClient.connect();
|
||||
|
||||
wsClient.onMessage((message) => {
|
||||
// 流式消息自动处理
|
||||
setMessages(prev => [...prev, message]);
|
||||
});
|
||||
|
||||
wsClient.sendMessage(messageChain);
|
||||
```
|
||||
|
||||
**2. 连接状态管理**
|
||||
|
||||
新增功能:
|
||||
- ✅ 实时连接状态指示器(绿色/红色圆点)
|
||||
- ✅ 连接/断开toast提示
|
||||
- ✅ 自动重连逻辑
|
||||
- ✅ 心跳保活
|
||||
|
||||
**3. 流式支持**
|
||||
|
||||
完整的流式消息处理:
|
||||
```typescript
|
||||
wsClient.onMessage((message) => {
|
||||
if (message.is_final) {
|
||||
// 最终消息
|
||||
finalizeBotMessage(message);
|
||||
} else {
|
||||
// 中间消息块,实时更新UI
|
||||
updateBotMessage(message);
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
## API对比
|
||||
|
||||
### WebSocket端点
|
||||
|
||||
**连接**
|
||||
```
|
||||
ws://localhost:8000/api/v1/pipelines/<pipeline_uuid>/ws/connect?session_type=<person|group>
|
||||
```
|
||||
|
||||
**消息格式**
|
||||
|
||||
客户端发送:
|
||||
```json
|
||||
{
|
||||
"type": "message",
|
||||
"message": [
|
||||
{"type": "Plain", "text": "你好"}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
服务器响应(流式):
|
||||
```json
|
||||
{
|
||||
"type": "response",
|
||||
"data": {
|
||||
"id": 1,
|
||||
"role": "assistant",
|
||||
"content": "你好,我是...",
|
||||
"is_final": false,
|
||||
"timestamp": "2025-01-28T..."
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### REST API
|
||||
|
||||
| 端点 | 方法 | 说明 |
|
||||
|------|------|------|
|
||||
| `/api/v1/pipelines/<uuid>/ws/messages/<type>` | GET | 获取消息历史 |
|
||||
| `/api/v1/pipelines/<uuid>/ws/reset/<type>` | POST | 重置会话 |
|
||||
| `/api/v1/pipelines/<uuid>/ws/connections` | GET | 获取连接统计 |
|
||||
| `/api/v1/pipelines/<uuid>/ws/broadcast` | POST | 广播消息 |
|
||||
|
||||
## 流式支持详解
|
||||
|
||||
### 后端流式实现
|
||||
|
||||
**WebSocket Adapter**
|
||||
```python
|
||||
async def reply_message_chunk(
|
||||
self,
|
||||
message_source: platform_events.MessageEvent,
|
||||
bot_message,
|
||||
message: platform_message.MessageChain,
|
||||
quote_origin: bool = False,
|
||||
is_final: bool = False,
|
||||
) -> dict:
|
||||
"""回复消息块 - 流式"""
|
||||
message_data = WebSocketMessage(
|
||||
id=-1,
|
||||
role='assistant',
|
||||
content=str(message),
|
||||
message_chain=[component.__dict__ for component in message],
|
||||
timestamp=datetime.now().isoformat(),
|
||||
is_final=is_final and bot_message.tool_calls is None,
|
||||
)
|
||||
|
||||
# 发送到队列,由WebSocket连接处理发送
|
||||
await session.resp_queues[message_id].put(message_data)
|
||||
return message_data.model_dump()
|
||||
|
||||
async def is_stream_output_supported(self) -> bool:
|
||||
"""WebSocket始终支持流式输出"""
|
||||
return True
|
||||
```
|
||||
|
||||
### 前端流式处理
|
||||
|
||||
**DebugDialog组件**
|
||||
```typescript
|
||||
wsClient.onMessage((message) => {
|
||||
setMessages((prevMessages) => {
|
||||
const existingIndex = prevMessages.findIndex(
|
||||
(msg) => msg.role === 'assistant' && msg.content === 'Generating...'
|
||||
);
|
||||
|
||||
if (existingIndex !== -1) {
|
||||
// 更新正在生成的消息
|
||||
const updatedMessages = [...prevMessages];
|
||||
updatedMessages[existingIndex] = message;
|
||||
return updatedMessages;
|
||||
} else {
|
||||
// 添加新消息
|
||||
return [...prevMessages, message];
|
||||
}
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
## 兼容性说明
|
||||
|
||||
### ⚠️ 不兼容旧版本
|
||||
|
||||
此次迁移**完全不兼容**旧的WebChat系统:
|
||||
|
||||
1. **API端点变更**
|
||||
- 旧: `/api/v1/pipelines/<uuid>/chat/send`
|
||||
- 新: `ws://.../<uuid>/ws/connect`
|
||||
|
||||
2. **通信协议变更**
|
||||
- 旧: HTTP + SSE (Server-Sent Events)
|
||||
- 新: WebSocket (双向)
|
||||
|
||||
3. **流式实现变更**
|
||||
- 旧: `text/event-stream` 格式
|
||||
- 新: WebSocket JSON消息
|
||||
|
||||
### 迁移要求
|
||||
|
||||
使用新系统需要:
|
||||
1. ✅ 前端必须支持WebSocket
|
||||
2. ✅ 后端必须运行新的WebSocket适配器
|
||||
3. ✅ 清除旧的WebChat相关配置
|
||||
|
||||
## 优势对比
|
||||
|
||||
| 特性 | 旧WebChat (SSE) | 新WebSocket |
|
||||
|------|----------------|-------------|
|
||||
| 双向通信 | ❌ 单向(服务器→客户端) | ✅ 双向 |
|
||||
| 主动推送 | ❌ 不支持 | ✅ 支持 |
|
||||
| 连接管理 | ❌ 无状态 | ✅ 有状态,完整生命周期 |
|
||||
| 流式输出 | ✅ 支持 | ✅ 支持(更优) |
|
||||
| 心跳机制 | ❌ 无 | ✅ 30秒心跳 |
|
||||
| 自动重连 | ❌ 无 | ✅ 最多5次 |
|
||||
| 多连接 | ⚠️ 难以管理 | ✅ 完整支持 |
|
||||
| 连接状态 | ❌ 不可见 | ✅ 实时显示 |
|
||||
| 广播功能 | ❌ 不支持 | ✅ 支持 |
|
||||
|
||||
## 测试方式
|
||||
|
||||
### 1. Python测试客户端
|
||||
|
||||
```bash
|
||||
# 单连接测试
|
||||
python test_websocket_client.py <pipeline_uuid>
|
||||
|
||||
# 指定会话类型
|
||||
python test_websocket_client.py <pipeline_uuid> --session-type group
|
||||
|
||||
# 多连接并发测试(5个连接)
|
||||
python test_websocket_client.py <pipeline_uuid> --multi 5
|
||||
```
|
||||
|
||||
### 2. 前端测试
|
||||
|
||||
1. 启动LangBot服务器
|
||||
2. 访问前端界面
|
||||
3. 打开流水线调试对话框
|
||||
4. 观察连接状态指示器(左下角圆点)
|
||||
5. 发送消息测试流式响应
|
||||
|
||||
### 3. 浏览器控制台测试
|
||||
|
||||
```javascript
|
||||
const ws = new WebSocket('ws://localhost:8000/api/v1/pipelines/<uuid>/ws/connect?session_type=person');
|
||||
|
||||
ws.onopen = () => {
|
||||
console.log('已连接');
|
||||
ws.send(JSON.stringify({
|
||||
type: 'message',
|
||||
message: [{type: 'Plain', text: '你好'}]
|
||||
}));
|
||||
};
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
console.log('收到:', JSON.parse(event.data));
|
||||
};
|
||||
```
|
||||
|
||||
## 常见问题
|
||||
|
||||
### Q: 为什么完全删除旧代码而不保留兼容性?
|
||||
A: 根据需求,不需要考虑任何对老版本的兼容性,彻底迁移可以避免代码冗余和维护负担。
|
||||
|
||||
### Q: 流式输出如何工作?
|
||||
A:
|
||||
1. 后端通过`reply_message_chunk`发送消息块
|
||||
2. 消息块放入队列
|
||||
3. WebSocket连接从队列取出并发送
|
||||
4. 前端实时更新UI
|
||||
5. `is_final=true`表示最后一块
|
||||
|
||||
### Q: 如何确保连接不断开?
|
||||
A:
|
||||
1. 客户端每30秒发送心跳(ping)
|
||||
2. 服务器响应pong
|
||||
3. 连接断开时自动重连(最多5次)
|
||||
|
||||
### Q: 如何实现后端主动推送?
|
||||
A:
|
||||
1. 调用 `/api/v1/pipelines/<uuid>/ws/broadcast` API
|
||||
2. 消息会被推送到该流水线的所有连接
|
||||
3. 前端通过`onBroadcast`回调接收
|
||||
|
||||
## 总结
|
||||
|
||||
✅ **完成的工作**
|
||||
- 完全移除旧的WebChat/SSE系统
|
||||
- 实现完整的WebSocket双向通信系统
|
||||
- 支持流式输出
|
||||
- 支持多连接并发
|
||||
- 实现自动重连和心跳机制
|
||||
- 提供完整的测试工具和文档
|
||||
|
||||
✅ **核心特性**
|
||||
- 双向实时通信
|
||||
- 流式消息支持
|
||||
- 多连接管理
|
||||
- 自动重连
|
||||
- 心跳保活
|
||||
- 连接状态可视化
|
||||
- 广播消息
|
||||
|
||||
✅ **技术亮点**
|
||||
- 异步架构(asyncio)
|
||||
- 线程安全的连接管理
|
||||
- 独立的消息队列
|
||||
- 完整的错误处理
|
||||
- 模块化设计
|
||||
|
||||
🎉 系统已完全迁移到WebSocket,无任何旧代码遗留!
|
||||
@@ -1,394 +0,0 @@
|
||||
# LangBot WebSocket 双向通信系统
|
||||
|
||||
## 概述
|
||||
|
||||
这是一个内置在 LangBot 中的完整 IM (即时通讯) 系统,支持:
|
||||
|
||||
- ✅ WebSocket 双向实时通信
|
||||
- ✅ 多个客户端并发连接
|
||||
- ✅ 前端到后端的消息发送
|
||||
- ✅ 后端到前端的主动推送
|
||||
- ✅ 流式响应支持
|
||||
- ✅ 连接管理和会话隔离
|
||||
- ✅ 心跳机制
|
||||
- ✅ 广播消息功能
|
||||
|
||||
## 架构设计
|
||||
|
||||
### 核心组件
|
||||
|
||||
1. **WebSocketConnectionManager** (`websocket_manager.py`)
|
||||
- 管理所有活跃的 WebSocket 连接
|
||||
- 支持按流水线、会话类型查询连接
|
||||
- 提供广播和单播功能
|
||||
- 线程安全的并发访问控制
|
||||
|
||||
2. **WebSocketAdapter** (`websocket_adapter.py`)
|
||||
- 实现平台适配器接口
|
||||
- 处理消息的接收和发送
|
||||
- 支持流式输出
|
||||
- 管理消息历史
|
||||
|
||||
3. **WebSocketChatRouterGroup** (`websocket_chat.py`)
|
||||
- WebSocket 路由控制器
|
||||
- 处理连接建立、消息收发
|
||||
- 实现心跳机制
|
||||
- 提供 REST API 接口
|
||||
|
||||
## API 接口
|
||||
|
||||
### WebSocket 连接
|
||||
|
||||
#### 建立连接
|
||||
|
||||
```
|
||||
ws://localhost:8000/api/v1/pipelines/<pipeline_uuid>/ws/connect?session_type=<person|group>
|
||||
```
|
||||
|
||||
**参数:**
|
||||
- `pipeline_uuid`: 流水线 UUID (必需)
|
||||
- `session_type`: 会话类型,可选 `person` 或 `group` (默认: `person`)
|
||||
|
||||
**连接成功响应:**
|
||||
```json
|
||||
{
|
||||
"type": "connected",
|
||||
"connection_id": "550e8400-e29b-41d4-a716-446655440000",
|
||||
"pipeline_uuid": "your-pipeline-uuid",
|
||||
"session_type": "person",
|
||||
"timestamp": "2025-01-28T12:00:00"
|
||||
}
|
||||
```
|
||||
|
||||
### 消息格式
|
||||
|
||||
#### 客户端发送消息
|
||||
|
||||
**发送聊天消息:**
|
||||
```json
|
||||
{
|
||||
"type": "message",
|
||||
"message": [
|
||||
{
|
||||
"type": "Plain",
|
||||
"text": "你好,这是一条测试消息"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**发送心跳:**
|
||||
```json
|
||||
{
|
||||
"type": "ping"
|
||||
}
|
||||
```
|
||||
|
||||
**主动断开连接:**
|
||||
```json
|
||||
{
|
||||
"type": "disconnect"
|
||||
}
|
||||
```
|
||||
|
||||
#### 服务器响应消息
|
||||
|
||||
**聊天响应 (流式):**
|
||||
```json
|
||||
{
|
||||
"type": "response",
|
||||
"data": {
|
||||
"id": 1,
|
||||
"role": "assistant",
|
||||
"content": "这是机器人的回复",
|
||||
"message_chain": [...],
|
||||
"timestamp": "2025-01-28T12:00:00",
|
||||
"is_final": false,
|
||||
"connection_id": "..."
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**心跳响应:**
|
||||
```json
|
||||
{
|
||||
"type": "pong",
|
||||
"timestamp": "2025-01-28T12:00:00"
|
||||
}
|
||||
```
|
||||
|
||||
**广播消息:**
|
||||
```json
|
||||
{
|
||||
"type": "broadcast",
|
||||
"message": "这是一条广播消息",
|
||||
"timestamp": "2025-01-28T12:00:00"
|
||||
}
|
||||
```
|
||||
|
||||
**错误消息:**
|
||||
```json
|
||||
{
|
||||
"type": "error",
|
||||
"message": "错误描述"
|
||||
}
|
||||
```
|
||||
|
||||
### REST API 接口
|
||||
|
||||
#### 1. 获取消息历史
|
||||
|
||||
```http
|
||||
GET /api/v1/pipelines/<pipeline_uuid>/ws/messages/<session_type>
|
||||
```
|
||||
|
||||
**响应:**
|
||||
```json
|
||||
{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"data": {
|
||||
"messages": [...]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### 2. 重置会话
|
||||
|
||||
```http
|
||||
POST /api/v1/pipelines/<pipeline_uuid>/ws/reset/<session_type>
|
||||
```
|
||||
|
||||
**响应:**
|
||||
```json
|
||||
{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"data": {
|
||||
"message": "Session reset successfully"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### 3. 获取连接统计
|
||||
|
||||
```http
|
||||
GET /api/v1/pipelines/<pipeline_uuid>/ws/connections
|
||||
```
|
||||
|
||||
**响应:**
|
||||
```json
|
||||
{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"data": {
|
||||
"stats": {
|
||||
"total_connections": 5,
|
||||
"pipelines": 2,
|
||||
"connections_by_pipeline": {
|
||||
"pipeline-1": 3,
|
||||
"pipeline-2": 2
|
||||
},
|
||||
"connections_by_session_type": {
|
||||
"person": 4,
|
||||
"group": 1
|
||||
}
|
||||
},
|
||||
"connections": [
|
||||
{
|
||||
"connection_id": "...",
|
||||
"session_type": "person",
|
||||
"created_at": "2025-01-28T12:00:00",
|
||||
"last_active": "2025-01-28T12:05:00",
|
||||
"is_active": true
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### 4. 广播消息 (后端主动推送)
|
||||
|
||||
```http
|
||||
POST /api/v1/pipelines/<pipeline_uuid>/ws/broadcast
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"message": "这是一条广播消息"
|
||||
}
|
||||
```
|
||||
|
||||
**响应:**
|
||||
```json
|
||||
{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"data": {
|
||||
"message": "Broadcast sent successfully"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 使用示例
|
||||
|
||||
### Python 客户端示例
|
||||
|
||||
使用提供的测试客户端:
|
||||
|
||||
```bash
|
||||
# 安装依赖
|
||||
pip install websockets
|
||||
|
||||
# 单个连接测试
|
||||
python test_websocket_client.py <pipeline_uuid>
|
||||
|
||||
# 指定会话类型
|
||||
python test_websocket_client.py <pipeline_uuid> --session-type group
|
||||
|
||||
# 多连接并发测试
|
||||
python test_websocket_client.py <pipeline_uuid> --multi 5
|
||||
```
|
||||
|
||||
### JavaScript 客户端示例
|
||||
|
||||
```javascript
|
||||
// 建立 WebSocket 连接
|
||||
const ws = new WebSocket('ws://localhost:8000/api/v1/pipelines/your-pipeline-uuid/ws/connect?session_type=person');
|
||||
|
||||
// 连接建立
|
||||
ws.onopen = () => {
|
||||
console.log('WebSocket 连接已建立');
|
||||
|
||||
// 发送消息
|
||||
ws.send(JSON.stringify({
|
||||
type: 'message',
|
||||
message: [
|
||||
{
|
||||
type: 'Plain',
|
||||
text: '你好'
|
||||
}
|
||||
]
|
||||
}));
|
||||
};
|
||||
|
||||
// 接收消息
|
||||
ws.onmessage = (event) => {
|
||||
const data = JSON.parse(event.data);
|
||||
|
||||
if (data.type === 'connected') {
|
||||
console.log('连接成功:', data.connection_id);
|
||||
} else if (data.type === 'response') {
|
||||
console.log('机器人回复:', data.data.content);
|
||||
if (data.data.is_final) {
|
||||
console.log('响应完成');
|
||||
}
|
||||
} else if (data.type === 'broadcast') {
|
||||
console.log('收到广播:', data.message);
|
||||
}
|
||||
};
|
||||
|
||||
// 连接关闭
|
||||
ws.onclose = () => {
|
||||
console.log('WebSocket 连接已关闭');
|
||||
};
|
||||
|
||||
// 错误处理
|
||||
ws.onerror = (error) => {
|
||||
console.error('WebSocket 错误:', error);
|
||||
};
|
||||
|
||||
// 发送心跳
|
||||
setInterval(() => {
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify({ type: 'ping' }));
|
||||
}
|
||||
}, 30000); // 每 30 秒发送一次心跳
|
||||
```
|
||||
|
||||
## 特性说明
|
||||
|
||||
### 1. 多连接支持
|
||||
|
||||
系统支持同时建立多个 WebSocket 连接,每个连接都有唯一的 `connection_id`。连接按照流水线和会话类型进行分组管理。
|
||||
|
||||
### 2. 双向通信
|
||||
|
||||
- **前端 → 后端**: 客户端可以主动发送消息给服务器
|
||||
- **后端 → 前端**: 服务器可以通过广播 API 主动推送消息给客户端
|
||||
|
||||
### 3. 流式响应
|
||||
|
||||
支持流式输出,机器人的响应会分块发送,客户端可以实时显示部分响应内容。
|
||||
|
||||
### 4. 会话隔离
|
||||
|
||||
支持 `person` 和 `group` 两种会话类型,不同类型的会话消息历史互不影响。
|
||||
|
||||
### 5. 连接管理
|
||||
|
||||
- 自动追踪连接状态
|
||||
- 记录最后活跃时间
|
||||
- 支持连接统计查询
|
||||
- 连接断开时自动清理资源
|
||||
|
||||
### 6. 心跳机制
|
||||
|
||||
客户端可以定期发送 `ping` 消息,服务器会响应 `pong`,用于保持连接活跃和检测连接状态。
|
||||
|
||||
## 架构优势
|
||||
|
||||
1. **高并发**: 使用 asyncio 异步架构,支持大量并发连接
|
||||
2. **可扩展**: 模块化设计,易于扩展新功能
|
||||
3. **线程安全**: 连接管理器使用锁机制保证并发安全
|
||||
4. **消息队列**: 每个连接独立的发送队列,避免消息混乱
|
||||
5. **灵活路由**: 支持按流水线、会话类型灵活路由消息
|
||||
|
||||
## 注意事项
|
||||
|
||||
1. **认证**: 当前 WebSocket 连接不需要认证,生产环境建议添加认证机制
|
||||
2. **心跳**: 建议客户端实现心跳机制,避免连接超时
|
||||
3. **重连**: 客户端应实现断线重连逻辑
|
||||
4. **消息大小**: 注意控制单条消息大小,避免内存溢出
|
||||
5. **连接数限制**: 生产环境建议设置最大连接数限制
|
||||
|
||||
## 故障排查
|
||||
|
||||
### 连接失败
|
||||
|
||||
1. 检查流水线 UUID 是否正确
|
||||
2. 检查服务器是否正常运行
|
||||
3. 检查防火墙设置
|
||||
|
||||
### 消息发送失败
|
||||
|
||||
1. 检查消息格式是否正确
|
||||
2. 检查连接是否仍然活跃
|
||||
3. 查看服务器日志获取详细错误信息
|
||||
|
||||
### 性能问题
|
||||
|
||||
1. 检查并发连接数是否过多
|
||||
2. 检查消息处理速度
|
||||
3. 考虑使用连接池或负载均衡
|
||||
|
||||
## 开发调试
|
||||
|
||||
启用详细日志:
|
||||
|
||||
```python
|
||||
import logging
|
||||
logging.getLogger('langbot.pkg.platform.sources.websocket_adapter').setLevel(logging.DEBUG)
|
||||
logging.getLogger('langbot.pkg.platform.sources.websocket_manager').setLevel(logging.DEBUG)
|
||||
logging.getLogger('langbot.pkg.api.http.controller.groups.pipelines.websocket_chat').setLevel(logging.DEBUG)
|
||||
```
|
||||
|
||||
## 后续改进建议
|
||||
|
||||
1. 添加用户认证和授权机制
|
||||
2. 实现消息持久化
|
||||
3. 添加消息加密
|
||||
4. 实现更丰富的消息类型 (图片、文件等)
|
||||
5. 添加消息已读/未读状态
|
||||
6. 实现群组聊天功能
|
||||
7. 添加在线状态显示
|
||||
8. 实现消息撤回功能
|
||||
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "langbot"
|
||||
version = "4.6.1"
|
||||
version = "4.6.0-beta.1"
|
||||
description = "Easy-to-use global IM bot platform designed for LLM era"
|
||||
readme = "README.md"
|
||||
license-files = ["LICENSE"]
|
||||
@@ -14,9 +14,6 @@ dependencies = [
|
||||
"anthropic>=0.51.0",
|
||||
"argon2-cffi>=23.1.0",
|
||||
"async-lru>=2.0.5",
|
||||
"botbuilder-core>=4.15.0",
|
||||
"botbuilder-schema>=4.15.0",
|
||||
"botframework-connector>=4.15.0",
|
||||
"certifi>=2025.4.26",
|
||||
"colorlog~=6.6.0",
|
||||
"cryptography>=44.0.3",
|
||||
@@ -49,6 +46,7 @@ dependencies = [
|
||||
"urllib3>=2.4.0",
|
||||
"websockets>=15.0.1",
|
||||
"python-socks>=2.7.1", # dingtalk missing dependency
|
||||
"taskgroup==0.0.0a4", # graingert/taskgroup#20
|
||||
"pip>=25.1.1",
|
||||
"ruff>=0.11.9",
|
||||
"pre-commit>=4.2.0",
|
||||
@@ -66,7 +64,7 @@ dependencies = [
|
||||
"langchain-text-splitters>=0.0.1",
|
||||
"chromadb>=0.4.24",
|
||||
"qdrant-client (>=1.15.1,<2.0.0)",
|
||||
"langbot-plugin==0.2.0",
|
||||
"langbot-plugin==0.1.11b1",
|
||||
"asyncpg>=0.30.0",
|
||||
"line-bot-sdk>=3.19.0",
|
||||
"tboxsdk>=0.0.10",
|
||||
@@ -76,7 +74,6 @@ keywords = [
|
||||
"bot",
|
||||
"agent",
|
||||
"telegram",
|
||||
"teams",
|
||||
"plugins",
|
||||
"openai",
|
||||
"instant-messaging",
|
||||
@@ -111,7 +108,7 @@ requires = ["setuptools>=61.0", "wheel"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[tool.setuptools]
|
||||
package-data = { "langbot" = ["templates/**", "pkg/provider/modelmgr/requesters/*", "pkg/platform/sources/*", "web/out/**"] }
|
||||
package-data = { "langbot" = ["templates/*", "pkg/provider/modelmgr/requesters/*", "pkg/platform/sources/*", "web/out/**"] }
|
||||
|
||||
[dependency-groups]
|
||||
dev = [
|
||||
|
||||
@@ -26,7 +26,7 @@ markers =
|
||||
|
||||
# Coverage options (when using pytest-cov)
|
||||
[coverage:run]
|
||||
source = langbot
|
||||
source = langbot.pkg
|
||||
omit =
|
||||
*/tests/*
|
||||
*/test_*.py
|
||||
|
||||
@@ -22,7 +22,7 @@ echo "Running all unit tests..."
|
||||
|
||||
# Run tests with coverage
|
||||
pytest tests/unit_tests/ -v --tb=short \
|
||||
--cov=langbot \
|
||||
--cov=pkg \
|
||||
--cov-report=xml \
|
||||
"$@"
|
||||
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
"""LangBot - Easy-to-use global IM bot platform designed for LLM era"""
|
||||
|
||||
__version__ = '4.6.1'
|
||||
__version__ = '4.6.0-beta.1'
|
||||
|
||||
@@ -32,7 +32,6 @@ class AsyncDifyServiceClient:
|
||||
conversation_id: str = '',
|
||||
files: list[dict[str, typing.Any]] = [],
|
||||
timeout: float = 30.0,
|
||||
model_config: dict[str, typing.Any] | None = None,
|
||||
) -> typing.AsyncGenerator[dict[str, typing.Any], None]:
|
||||
"""发送消息"""
|
||||
if response_mode != 'streaming':
|
||||
@@ -43,16 +42,6 @@ class AsyncDifyServiceClient:
|
||||
trust_env=True,
|
||||
timeout=timeout,
|
||||
) as client:
|
||||
payload = {
|
||||
'inputs': inputs,
|
||||
'query': query,
|
||||
'user': user,
|
||||
'response_mode': response_mode,
|
||||
'conversation_id': conversation_id,
|
||||
'files': files,
|
||||
'model_config': model_config or {},
|
||||
}
|
||||
|
||||
async with client.stream(
|
||||
'POST',
|
||||
'/chat-messages',
|
||||
@@ -60,7 +49,14 @@ class AsyncDifyServiceClient:
|
||||
'Authorization': f'Bearer {self.api_key}',
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
json=payload,
|
||||
json={
|
||||
'inputs': inputs,
|
||||
'query': query,
|
||||
'user': user,
|
||||
'response_mode': response_mode,
|
||||
'conversation_id': conversation_id,
|
||||
'files': files,
|
||||
},
|
||||
) as r:
|
||||
async for chunk in r.aiter_lines():
|
||||
if r.status_code != 200:
|
||||
|
||||
@@ -23,25 +23,20 @@ xml_template = """
|
||||
|
||||
|
||||
class OAClient:
|
||||
def __init__(self, token: str, EncodingAESKey: str, AppID: str, Appsecret: str, logger: None, unified_mode: bool = False):
|
||||
def __init__(self, token: str, EncodingAESKey: str, AppID: str, Appsecret: str, logger: None):
|
||||
self.token = token
|
||||
self.aes = EncodingAESKey
|
||||
self.appid = AppID
|
||||
self.appsecret = Appsecret
|
||||
self.base_url = 'https://api.weixin.qq.com'
|
||||
self.access_token = ''
|
||||
self.unified_mode = unified_mode
|
||||
self.app = Quart(__name__)
|
||||
|
||||
# 只有在非统一模式下才注册独立路由
|
||||
if not self.unified_mode:
|
||||
self.app.add_url_rule(
|
||||
'/callback/command',
|
||||
'handle_callback',
|
||||
self.handle_callback_request,
|
||||
methods=['GET', 'POST'],
|
||||
)
|
||||
|
||||
self.app.add_url_rule(
|
||||
'/callback/command',
|
||||
'handle_callback',
|
||||
self.handle_callback_request,
|
||||
methods=['GET', 'POST'],
|
||||
)
|
||||
self._message_handlers = {
|
||||
'example': [],
|
||||
}
|
||||
@@ -51,39 +46,19 @@ class OAClient:
|
||||
self.logger = logger
|
||||
|
||||
async def handle_callback_request(self):
|
||||
"""处理回调请求(独立端口模式,使用全局 request)。"""
|
||||
return await self._handle_callback_internal(request)
|
||||
|
||||
async def handle_unified_webhook(self, req):
|
||||
"""处理回调请求(统一 webhook 模式,显式传递 request)。
|
||||
|
||||
Args:
|
||||
req: Quart Request 对象
|
||||
|
||||
Returns:
|
||||
响应数据
|
||||
"""
|
||||
return await self._handle_callback_internal(req)
|
||||
|
||||
async def _handle_callback_internal(self, req):
|
||||
"""处理回调请求的内部实现,包括 GET 验证和 POST 消息接收。
|
||||
|
||||
Args:
|
||||
req: Quart Request 对象
|
||||
"""
|
||||
try:
|
||||
# 每隔100毫秒查询是否生成ai回答
|
||||
start_time = time.time()
|
||||
signature = req.args.get('signature', '')
|
||||
timestamp = req.args.get('timestamp', '')
|
||||
nonce = req.args.get('nonce', '')
|
||||
echostr = req.args.get('echostr', '')
|
||||
msg_signature = req.args.get('msg_signature', '')
|
||||
signature = request.args.get('signature', '')
|
||||
timestamp = request.args.get('timestamp', '')
|
||||
nonce = request.args.get('nonce', '')
|
||||
echostr = request.args.get('echostr', '')
|
||||
msg_signature = request.args.get('msg_signature', '')
|
||||
if msg_signature is None:
|
||||
await self.logger.error('msg_signature不在请求体中')
|
||||
raise Exception('msg_signature不在请求体中')
|
||||
|
||||
if req.method == 'GET':
|
||||
if request.method == 'GET':
|
||||
# 校验签名
|
||||
check_str = ''.join(sorted([self.token, timestamp, nonce]))
|
||||
check_signature = hashlib.sha1(check_str.encode('utf-8')).hexdigest()
|
||||
@@ -93,8 +68,8 @@ class OAClient:
|
||||
else:
|
||||
await self.logger.error('拒绝请求')
|
||||
raise Exception('拒绝请求')
|
||||
elif req.method == 'POST':
|
||||
encryt_msg = await req.data
|
||||
elif request.method == 'POST':
|
||||
encryt_msg = await request.data
|
||||
wxcpt = WXBizMsgCrypt(self.token, self.aes, self.appid)
|
||||
ret, xml_msg = wxcpt.DecryptMsg(encryt_msg, msg_signature, timestamp, nonce)
|
||||
xml_msg = xml_msg.decode('utf-8')
|
||||
@@ -207,7 +182,6 @@ class OAClientForLongerResponse:
|
||||
Appsecret: str,
|
||||
LoadingMessage: str,
|
||||
logger: None,
|
||||
unified_mode: bool = False,
|
||||
):
|
||||
self.token = token
|
||||
self.aes = EncodingAESKey
|
||||
@@ -215,18 +189,13 @@ class OAClientForLongerResponse:
|
||||
self.appsecret = Appsecret
|
||||
self.base_url = 'https://api.weixin.qq.com'
|
||||
self.access_token = ''
|
||||
self.unified_mode = unified_mode
|
||||
self.app = Quart(__name__)
|
||||
|
||||
# 只有在非统一模式下才注册独立路由
|
||||
if not self.unified_mode:
|
||||
self.app.add_url_rule(
|
||||
'/callback/command',
|
||||
'handle_callback',
|
||||
self.handle_callback_request,
|
||||
methods=['GET', 'POST'],
|
||||
)
|
||||
|
||||
self.app.add_url_rule(
|
||||
'/callback/command',
|
||||
'handle_callback',
|
||||
self.handle_callback_request,
|
||||
methods=['GET', 'POST'],
|
||||
)
|
||||
self._message_handlers = {
|
||||
'example': [],
|
||||
}
|
||||
@@ -237,44 +206,24 @@ class OAClientForLongerResponse:
|
||||
self.logger = logger
|
||||
|
||||
async def handle_callback_request(self):
|
||||
"""处理回调请求(独立端口模式,使用全局 request)。"""
|
||||
return await self._handle_callback_internal(request)
|
||||
|
||||
async def handle_unified_webhook(self, req):
|
||||
"""处理回调请求(统一 webhook 模式,显式传递 request)。
|
||||
|
||||
Args:
|
||||
req: Quart Request 对象
|
||||
|
||||
Returns:
|
||||
响应数据
|
||||
"""
|
||||
return await self._handle_callback_internal(req)
|
||||
|
||||
async def _handle_callback_internal(self, req):
|
||||
"""处理回调请求的内部实现,包括 GET 验证和 POST 消息接收。
|
||||
|
||||
Args:
|
||||
req: Quart Request 对象
|
||||
"""
|
||||
try:
|
||||
signature = req.args.get('signature', '')
|
||||
timestamp = req.args.get('timestamp', '')
|
||||
nonce = req.args.get('nonce', '')
|
||||
echostr = req.args.get('echostr', '')
|
||||
msg_signature = req.args.get('msg_signature', '')
|
||||
signature = request.args.get('signature', '')
|
||||
timestamp = request.args.get('timestamp', '')
|
||||
nonce = request.args.get('nonce', '')
|
||||
echostr = request.args.get('echostr', '')
|
||||
msg_signature = request.args.get('msg_signature', '')
|
||||
|
||||
if msg_signature is None:
|
||||
await self.logger.error('msg_signature不在请求体中')
|
||||
raise Exception('msg_signature不在请求体中')
|
||||
|
||||
if req.method == 'GET':
|
||||
if request.method == 'GET':
|
||||
check_str = ''.join(sorted([self.token, timestamp, nonce]))
|
||||
check_signature = hashlib.sha1(check_str.encode('utf-8')).hexdigest()
|
||||
return echostr if check_signature == signature else '拒绝请求'
|
||||
|
||||
elif req.method == 'POST':
|
||||
encryt_msg = await req.data
|
||||
elif request.method == 'POST':
|
||||
encryt_msg = await request.data
|
||||
wxcpt = WXBizMsgCrypt(self.token, self.aes, self.appid)
|
||||
ret, xml_msg = wxcpt.DecryptMsg(encryt_msg, msg_signature, timestamp, nonce)
|
||||
xml_msg = xml_msg.decode('utf-8')
|
||||
|
||||
@@ -10,20 +10,38 @@ import traceback
|
||||
from cryptography.hazmat.primitives.asymmetric import ed25519
|
||||
|
||||
|
||||
def handle_validation(body: dict, bot_secret: str):
|
||||
# bot正确的secert是32位的,此处仅为了适配演示demo
|
||||
while len(bot_secret) < 32:
|
||||
bot_secret = bot_secret * 2
|
||||
bot_secret = bot_secret[:32]
|
||||
# 实际使用场景中以上三行内容可清除
|
||||
|
||||
seed_bytes = bot_secret.encode()
|
||||
|
||||
signing_key = ed25519.Ed25519PrivateKey.from_private_bytes(seed_bytes)
|
||||
|
||||
msg = body['d']['event_ts'] + body['d']['plain_token']
|
||||
msg_bytes = msg.encode()
|
||||
|
||||
signature = signing_key.sign(msg_bytes)
|
||||
|
||||
signature_hex = signature.hex()
|
||||
|
||||
response = {'plain_token': body['d']['plain_token'], 'signature': signature_hex}
|
||||
|
||||
return response
|
||||
|
||||
|
||||
class QQOfficialClient:
|
||||
def __init__(self, secret: str, token: str, app_id: str, logger: None, unified_mode: bool = False):
|
||||
self.unified_mode = unified_mode
|
||||
def __init__(self, secret: str, token: str, app_id: str, logger: None):
|
||||
self.app = Quart(__name__)
|
||||
|
||||
# 只有在非统一模式下才注册独立路由
|
||||
if not self.unified_mode:
|
||||
self.app.add_url_rule(
|
||||
'/callback/command',
|
||||
'handle_callback',
|
||||
self.handle_callback_request,
|
||||
methods=['GET', 'POST'],
|
||||
)
|
||||
|
||||
self.app.add_url_rule(
|
||||
'/callback/command',
|
||||
'handle_callback',
|
||||
self.handle_callback_request,
|
||||
methods=['GET', 'POST'],
|
||||
)
|
||||
self.secret = secret
|
||||
self.token = token
|
||||
self.app_id = app_id
|
||||
@@ -64,45 +82,18 @@ class QQOfficialClient:
|
||||
raise Exception(f'获取access_token失败: {e}')
|
||||
|
||||
async def handle_callback_request(self):
|
||||
"""处理回调请求(独立端口模式,使用全局 request)"""
|
||||
return await self._handle_callback_internal(request)
|
||||
|
||||
async def handle_unified_webhook(self, req):
|
||||
"""处理回调请求(统一 webhook 模式,显式传递 request)。
|
||||
|
||||
Args:
|
||||
req: Quart Request 对象
|
||||
|
||||
Returns:
|
||||
响应数据
|
||||
"""
|
||||
return await self._handle_callback_internal(req)
|
||||
|
||||
async def _handle_callback_internal(self, req):
|
||||
"""处理回调请求的内部实现。
|
||||
|
||||
Args:
|
||||
req: Quart Request 对象
|
||||
"""
|
||||
"""处理回调请求"""
|
||||
try:
|
||||
|
||||
body = await req.get_data()
|
||||
|
||||
print(f'[QQ Official] Received request, body length: {len(body)}')
|
||||
|
||||
if not body or len(body) == 0:
|
||||
print('[QQ Official] Received empty body, might be health check or GET request')
|
||||
return {'code': 0, 'message': 'ok'}, 200
|
||||
|
||||
# 读取请求数据
|
||||
body = await request.get_data()
|
||||
payload = json.loads(body)
|
||||
|
||||
|
||||
# 验证是否为回调验证请求
|
||||
if payload.get('op') == 13:
|
||||
validation_data = payload.get('d')
|
||||
if not validation_data:
|
||||
return {'error': "missing 'd' field"}, 400
|
||||
response = await self.verify(validation_data)
|
||||
return response, 200
|
||||
# 生成签名
|
||||
response = handle_validation(payload, self.secret)
|
||||
|
||||
return response
|
||||
|
||||
if payload.get('op') == 0:
|
||||
message_data = await self.get_message(payload)
|
||||
@@ -113,7 +104,6 @@ class QQOfficialClient:
|
||||
return {'code': 0, 'message': 'success'}
|
||||
|
||||
except Exception as e:
|
||||
print(f'[QQ Official] ERROR: {traceback.format_exc()}')
|
||||
await self.logger.error(f'Error in handle_callback_request: {traceback.format_exc()}')
|
||||
return {'error': str(e)}, 400
|
||||
|
||||
@@ -271,26 +261,3 @@ class QQOfficialClient:
|
||||
if self.access_token_expiry_time is None:
|
||||
return True
|
||||
return time.time() > self.access_token_expiry_time
|
||||
|
||||
async def repeat_seed(self, bot_secret: str, target_size: int = 32) -> bytes:
|
||||
seed = bot_secret
|
||||
while len(seed) < target_size:
|
||||
seed *= 2
|
||||
return seed[:target_size].encode("utf-8")
|
||||
|
||||
async def verify(self, validation_payload: dict):
|
||||
seed = await self.repeat_seed(self.secret)
|
||||
private_key = ed25519.Ed25519PrivateKey.from_private_bytes(seed)
|
||||
|
||||
event_ts = validation_payload.get("event_ts", "")
|
||||
plain_token = validation_payload.get("plain_token", "")
|
||||
msg = event_ts + plain_token
|
||||
|
||||
# sign
|
||||
signature = private_key.sign(msg.encode()).hex()
|
||||
|
||||
response = {
|
||||
"plain_token": plain_token,
|
||||
"signature": signature,
|
||||
}
|
||||
return response
|
||||
|
||||
@@ -8,19 +8,14 @@ import langbot_plugin.api.entities.builtin.platform.events as platform_events
|
||||
|
||||
|
||||
class SlackClient:
|
||||
def __init__(self, bot_token: str, signing_secret: str, logger: None, unified_mode: bool = False):
|
||||
def __init__(self, bot_token: str, signing_secret: str, logger: None):
|
||||
self.bot_token = bot_token
|
||||
self.signing_secret = signing_secret
|
||||
self.unified_mode = unified_mode
|
||||
self.app = Quart(__name__)
|
||||
self.client = AsyncWebClient(self.bot_token)
|
||||
|
||||
# 只有在非统一模式下才注册独立路由
|
||||
if not self.unified_mode:
|
||||
self.app.add_url_rule(
|
||||
'/callback/command', 'handle_callback', self.handle_callback_request, methods=['GET', 'POST']
|
||||
)
|
||||
|
||||
self.app.add_url_rule(
|
||||
'/callback/command', 'handle_callback', self.handle_callback_request, methods=['GET', 'POST']
|
||||
)
|
||||
self._message_handlers = {
|
||||
'example': [],
|
||||
}
|
||||
@@ -28,28 +23,8 @@ class SlackClient:
|
||||
self.logger = logger
|
||||
|
||||
async def handle_callback_request(self):
|
||||
"""处理回调请求(独立端口模式,使用全局 request)"""
|
||||
return await self._handle_callback_internal(request)
|
||||
|
||||
async def handle_unified_webhook(self, req):
|
||||
"""处理回调请求(统一 webhook 模式,显式传递 request)。
|
||||
|
||||
Args:
|
||||
req: Quart Request 对象
|
||||
|
||||
Returns:
|
||||
响应数据
|
||||
"""
|
||||
return await self._handle_callback_internal(req)
|
||||
|
||||
async def _handle_callback_internal(self, req):
|
||||
"""处理回调请求的内部实现。
|
||||
|
||||
Args:
|
||||
req: Quart Request 对象
|
||||
"""
|
||||
try:
|
||||
body = await req.get_data()
|
||||
body = await request.get_data()
|
||||
data = json.loads(body)
|
||||
if 'type' in data:
|
||||
if data['type'] == 'url_verification':
|
||||
|
||||
@@ -200,7 +200,7 @@ class StreamSessionManager:
|
||||
|
||||
|
||||
class WecomBotClient:
|
||||
def __init__(self, Token: str, EnCodingAESKey: str, Corpid: str, logger: EventLogger, unified_mode: bool = False):
|
||||
def __init__(self, Token: str, EnCodingAESKey: str, Corpid: str, logger: EventLogger):
|
||||
"""企业微信智能机器人客户端。
|
||||
|
||||
Args:
|
||||
@@ -208,7 +208,6 @@ class WecomBotClient:
|
||||
EnCodingAESKey: 企业微信消息加解密密钥。
|
||||
Corpid: 企业 ID。
|
||||
logger: 日志记录器。
|
||||
unified_mode: 是否使用统一 webhook 模式(默认 False)。
|
||||
|
||||
Example:
|
||||
>>> client = WecomBotClient(Token='token', EnCodingAESKey='aeskey', Corpid='corp', logger=logger)
|
||||
@@ -218,15 +217,10 @@ class WecomBotClient:
|
||||
self.EnCodingAESKey = EnCodingAESKey
|
||||
self.Corpid = Corpid
|
||||
self.ReceiveId = ''
|
||||
self.unified_mode = unified_mode
|
||||
self.app = Quart(__name__)
|
||||
|
||||
# 只有在非统一模式下才注册独立路由
|
||||
if not self.unified_mode:
|
||||
self.app.add_url_rule(
|
||||
'/callback/command', 'handle_callback', self.handle_callback_request, methods=['POST', 'GET']
|
||||
)
|
||||
|
||||
self.app.add_url_rule(
|
||||
'/callback/command', 'handle_callback', self.handle_callback_request, methods=['POST', 'GET']
|
||||
)
|
||||
self._message_handlers = {
|
||||
'example': [],
|
||||
}
|
||||
@@ -365,7 +359,7 @@ class WecomBotClient:
|
||||
return await self._encrypt_and_reply(payload, nonce)
|
||||
|
||||
async def handle_callback_request(self):
|
||||
"""企业微信回调入口(独立端口模式,使用全局 request)。
|
||||
"""企业微信回调入口。
|
||||
|
||||
Returns:
|
||||
Quart Response: 根据请求类型返回验证、首包或刷新结果。
|
||||
@@ -373,34 +367,15 @@ class WecomBotClient:
|
||||
Example:
|
||||
作为 Quart 路由处理函数直接注册并使用。
|
||||
"""
|
||||
return await self._handle_callback_internal(request)
|
||||
|
||||
async def handle_unified_webhook(self, req):
|
||||
"""处理回调请求(统一 webhook 模式,显式传递 request)。
|
||||
|
||||
Args:
|
||||
req: Quart Request 对象
|
||||
|
||||
Returns:
|
||||
响应数据
|
||||
"""
|
||||
return await self._handle_callback_internal(req)
|
||||
|
||||
async def _handle_callback_internal(self, req):
|
||||
"""处理回调请求的内部实现,包括 GET 验证和 POST 消息接收。
|
||||
|
||||
Args:
|
||||
req: Quart Request 对象
|
||||
"""
|
||||
try:
|
||||
self.wxcpt = WXBizMsgCrypt(self.Token, self.EnCodingAESKey, '')
|
||||
await self.logger.info(f'{req.method} {req.url} {str(req.args)}')
|
||||
await self.logger.info(f'{request.method} {request.url} {str(request.args)}')
|
||||
|
||||
if req.method == 'GET':
|
||||
return await self._handle_get_callback(req)
|
||||
if request.method == 'GET':
|
||||
return await self._handle_get_callback()
|
||||
|
||||
if req.method == 'POST':
|
||||
return await self._handle_post_callback(req)
|
||||
if request.method == 'POST':
|
||||
return await self._handle_post_callback()
|
||||
|
||||
return Response('', status=405)
|
||||
|
||||
@@ -408,13 +383,13 @@ class WecomBotClient:
|
||||
await self.logger.error(traceback.format_exc())
|
||||
return Response('Internal Server Error', status=500)
|
||||
|
||||
async def _handle_get_callback(self, req) -> tuple[Response, int] | Response:
|
||||
async def _handle_get_callback(self) -> tuple[Response, int] | Response:
|
||||
"""处理企业微信的 GET 验证请求。"""
|
||||
|
||||
msg_signature = unquote(req.args.get('msg_signature', ''))
|
||||
timestamp = unquote(req.args.get('timestamp', ''))
|
||||
nonce = unquote(req.args.get('nonce', ''))
|
||||
echostr = unquote(req.args.get('echostr', ''))
|
||||
msg_signature = unquote(request.args.get('msg_signature', ''))
|
||||
timestamp = unquote(request.args.get('timestamp', ''))
|
||||
nonce = unquote(request.args.get('nonce', ''))
|
||||
echostr = unquote(request.args.get('echostr', ''))
|
||||
|
||||
if not all([msg_signature, timestamp, nonce, echostr]):
|
||||
await self.logger.error('请求参数缺失')
|
||||
@@ -427,16 +402,16 @@ class WecomBotClient:
|
||||
|
||||
return Response(decrypted_str, mimetype='text/plain')
|
||||
|
||||
async def _handle_post_callback(self, req) -> tuple[Response, int] | Response:
|
||||
async def _handle_post_callback(self) -> tuple[Response, int] | Response:
|
||||
"""处理企业微信的 POST 回调请求。"""
|
||||
|
||||
self.stream_sessions.cleanup()
|
||||
|
||||
msg_signature = unquote(req.args.get('msg_signature', ''))
|
||||
timestamp = unquote(req.args.get('timestamp', ''))
|
||||
nonce = unquote(req.args.get('nonce', ''))
|
||||
msg_signature = unquote(request.args.get('msg_signature', ''))
|
||||
timestamp = unquote(request.args.get('timestamp', ''))
|
||||
nonce = unquote(request.args.get('nonce', ''))
|
||||
|
||||
encrypted_json = await req.get_json()
|
||||
encrypted_json = await request.get_json()
|
||||
encrypted_msg = (encrypted_json or {}).get('encrypt', '')
|
||||
if not encrypted_msg:
|
||||
await self.logger.error("请求体中缺少 'encrypt' 字段")
|
||||
|
||||
@@ -29,7 +29,12 @@ class WecomBotEvent(dict):
|
||||
"""
|
||||
用户名称
|
||||
"""
|
||||
return self.get('username', '') or self.get('from', {}).get('alias', '') or self.get('from', {}).get('name', '') or self.userid
|
||||
return (
|
||||
self.get('username', '')
|
||||
or self.get('from', {}).get('alias', '')
|
||||
or self.get('from', {}).get('name', '')
|
||||
or self.userid
|
||||
)
|
||||
|
||||
@property
|
||||
def chatname(self) -> str:
|
||||
@@ -65,7 +70,7 @@ class WecomBotEvent(dict):
|
||||
消息id
|
||||
"""
|
||||
return self.get('msgid', '')
|
||||
|
||||
|
||||
@property
|
||||
def ai_bot_id(self) -> str:
|
||||
"""
|
||||
|
||||
@@ -21,7 +21,6 @@ class WecomClient:
|
||||
EncodingAESKey: str,
|
||||
contacts_secret: str,
|
||||
logger: None,
|
||||
unified_mode: bool = False,
|
||||
):
|
||||
self.corpid = corpid
|
||||
self.secret = secret
|
||||
@@ -32,18 +31,13 @@ class WecomClient:
|
||||
self.access_token = ''
|
||||
self.secret_for_contacts = contacts_secret
|
||||
self.logger = logger
|
||||
self.unified_mode = unified_mode
|
||||
self.app = Quart(__name__)
|
||||
|
||||
# 只有在非统一模式下才注册独立路由
|
||||
if not self.unified_mode:
|
||||
self.app.add_url_rule(
|
||||
'/callback/command',
|
||||
'handle_callback',
|
||||
self.handle_callback_request,
|
||||
methods=['GET', 'POST'],
|
||||
)
|
||||
|
||||
self.app.add_url_rule(
|
||||
'/callback/command',
|
||||
'handle_callback',
|
||||
self.handle_callback_request,
|
||||
methods=['GET', 'POST'],
|
||||
)
|
||||
self._message_handlers = {
|
||||
'example': [],
|
||||
}
|
||||
@@ -115,13 +109,14 @@ class WecomClient:
|
||||
async def send_image(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
|
||||
url = self.base_url + '/media/upload?access_token=' + self.access_token
|
||||
async with httpx.AsyncClient() as client:
|
||||
params = {
|
||||
'touser': user_id,
|
||||
'msgtype': 'image',
|
||||
'toparty': '',
|
||||
'totag': '',
|
||||
'agentid': agent_id,
|
||||
'msgtype': 'image',
|
||||
'image': {
|
||||
'media_id': media_id,
|
||||
},
|
||||
@@ -130,13 +125,19 @@ class WecomClient:
|
||||
'enable_duplicate_check': 0,
|
||||
'duplicate_check_interval': 1800,
|
||||
}
|
||||
response = await client.post(url, json=params)
|
||||
data = response.json()
|
||||
try:
|
||||
response = await client.post(url, json=params)
|
||||
data = response.json()
|
||||
except Exception as e:
|
||||
await self.logger.error(f'发送图片失败:{data}')
|
||||
raise Exception('Failed to send image: ' + str(e))
|
||||
|
||||
# 企业微信错误码40014和42001,代表accesstoken问题
|
||||
if data['errcode'] == 40014 or data['errcode'] == 42001:
|
||||
self.access_token = await self.get_access_token(self.secret)
|
||||
return await self.send_image(user_id, agent_id, media_id)
|
||||
|
||||
if data['errcode'] != 0:
|
||||
await self.logger.error(f'发送图片失败:{data}')
|
||||
raise Exception('Failed to send image: ' + str(data))
|
||||
|
||||
async def send_private_msg(self, user_id: str, agent_id: int, content: str):
|
||||
@@ -167,43 +168,25 @@ class WecomClient:
|
||||
raise Exception('Failed to send message: ' + str(data))
|
||||
|
||||
async def handle_callback_request(self):
|
||||
"""处理回调请求(独立端口模式,使用全局 request)。"""
|
||||
return await self._handle_callback_internal(request)
|
||||
|
||||
async def handle_unified_webhook(self, req):
|
||||
"""处理回调请求(统一 webhook 模式,显式传递 request)。
|
||||
|
||||
Args:
|
||||
req: Quart Request 对象
|
||||
|
||||
Returns:
|
||||
响应数据
|
||||
"""
|
||||
return await self._handle_callback_internal(req)
|
||||
|
||||
async def _handle_callback_internal(self, req):
|
||||
"""
|
||||
处理回调请求的内部实现,包括 GET 验证和 POST 消息接收。
|
||||
|
||||
Args:
|
||||
req: Quart Request 对象
|
||||
处理回调请求,包括 GET 验证和 POST 消息接收。
|
||||
"""
|
||||
try:
|
||||
msg_signature = req.args.get('msg_signature')
|
||||
timestamp = req.args.get('timestamp')
|
||||
nonce = req.args.get('nonce')
|
||||
msg_signature = request.args.get('msg_signature')
|
||||
timestamp = request.args.get('timestamp')
|
||||
nonce = request.args.get('nonce')
|
||||
|
||||
wxcpt = WXBizMsgCrypt(self.token, self.aes, self.corpid)
|
||||
if req.method == 'GET':
|
||||
echostr = req.args.get('echostr')
|
||||
if request.method == 'GET':
|
||||
echostr = request.args.get('echostr')
|
||||
ret, reply_echo_str = wxcpt.VerifyURL(msg_signature, timestamp, nonce, echostr)
|
||||
if ret != 0:
|
||||
await self.logger.error('验证失败')
|
||||
raise Exception(f'验证失败,错误码: {ret}')
|
||||
return reply_echo_str
|
||||
|
||||
elif req.method == 'POST':
|
||||
encrypt_msg = await req.data
|
||||
elif request.method == 'POST':
|
||||
encrypt_msg = await request.data
|
||||
ret, xml_msg = wxcpt.DecryptMsg(encrypt_msg, msg_signature, timestamp, nonce)
|
||||
if ret != 0:
|
||||
await self.logger.error('消息解密失败')
|
||||
|
||||
@@ -13,7 +13,7 @@ import aiofiles
|
||||
|
||||
|
||||
class WecomCSClient:
|
||||
def __init__(self, corpid: str, secret: str, token: str, EncodingAESKey: str, logger: None, unified_mode: bool = False):
|
||||
def __init__(self, corpid: str, secret: str, token: str, EncodingAESKey: str, logger: None):
|
||||
self.corpid = corpid
|
||||
self.secret = secret
|
||||
self.access_token_for_contacts = ''
|
||||
@@ -22,15 +22,10 @@ class WecomCSClient:
|
||||
self.base_url = 'https://qyapi.weixin.qq.com/cgi-bin'
|
||||
self.access_token = ''
|
||||
self.logger = logger
|
||||
self.unified_mode = unified_mode
|
||||
self.app = Quart(__name__)
|
||||
|
||||
# 只有在非统一模式下才注册独立路由
|
||||
if not self.unified_mode:
|
||||
self.app.add_url_rule(
|
||||
'/callback/command', 'handle_callback', self.handle_callback_request, methods=['GET', 'POST']
|
||||
)
|
||||
|
||||
self.app.add_url_rule(
|
||||
'/callback/command', 'handle_callback', self.handle_callback_request, methods=['GET', 'POST']
|
||||
)
|
||||
self._message_handlers = {
|
||||
'example': [],
|
||||
}
|
||||
@@ -197,45 +192,27 @@ class WecomCSClient:
|
||||
return data
|
||||
|
||||
async def handle_callback_request(self):
|
||||
"""处理回调请求(独立端口模式,使用全局 request)。"""
|
||||
return await self._handle_callback_internal(request)
|
||||
|
||||
async def handle_unified_webhook(self, req):
|
||||
"""处理回调请求(统一 webhook 模式,显式传递 request)。
|
||||
|
||||
Args:
|
||||
req: Quart Request 对象
|
||||
|
||||
Returns:
|
||||
响应数据
|
||||
"""
|
||||
return await self._handle_callback_internal(req)
|
||||
|
||||
async def _handle_callback_internal(self, req):
|
||||
"""
|
||||
处理回调请求的内部实现,包括 GET 验证和 POST 消息接收。
|
||||
|
||||
Args:
|
||||
req: Quart Request 对象
|
||||
处理回调请求,包括 GET 验证和 POST 消息接收。
|
||||
"""
|
||||
try:
|
||||
msg_signature = req.args.get('msg_signature')
|
||||
timestamp = req.args.get('timestamp')
|
||||
nonce = req.args.get('nonce')
|
||||
msg_signature = request.args.get('msg_signature')
|
||||
timestamp = request.args.get('timestamp')
|
||||
nonce = request.args.get('nonce')
|
||||
try:
|
||||
wxcpt = WXBizMsgCrypt(self.token, self.aes, self.corpid)
|
||||
except Exception as e:
|
||||
raise Exception(f'初始化失败,错误码: {e}')
|
||||
|
||||
if req.method == 'GET':
|
||||
echostr = req.args.get('echostr')
|
||||
if request.method == 'GET':
|
||||
echostr = request.args.get('echostr')
|
||||
ret, reply_echo_str = wxcpt.VerifyURL(msg_signature, timestamp, nonce, echostr)
|
||||
if ret != 0:
|
||||
raise Exception(f'验证失败,错误码: {ret}')
|
||||
return reply_echo_str
|
||||
|
||||
elif req.method == 'POST':
|
||||
encrypt_msg = await req.data
|
||||
elif request.method == 'POST':
|
||||
encrypt_msg = await request.data
|
||||
ret, xml_msg = wxcpt.DecryptMsg(encrypt_msg, msg_signature, timestamp, nonce)
|
||||
if ret != 0:
|
||||
raise Exception(f'消息解密失败,错误码: {ret}')
|
||||
|
||||
@@ -28,56 +28,8 @@ class FilesRouterGroup(group.RouterGroup):
|
||||
|
||||
return quart.Response(image_bytes, mimetype=mime_type)
|
||||
|
||||
@self.route('/images', methods=['POST'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)
|
||||
async def upload_image() -> quart.Response:
|
||||
request = quart.request
|
||||
|
||||
# Check file size limit before reading the file
|
||||
content_length = request.content_length
|
||||
if content_length and content_length > group.MAX_FILE_SIZE:
|
||||
return self.fail(400, 'Image size exceeds 10MB limit.')
|
||||
|
||||
# get file bytes from 'file'
|
||||
files = await request.files
|
||||
if 'file' not in files:
|
||||
return self.fail(400, 'No image file provided')
|
||||
|
||||
file = files['file']
|
||||
assert isinstance(file, quart.datastructures.FileStorage)
|
||||
|
||||
file_bytes = await asyncio.to_thread(file.stream.read)
|
||||
|
||||
# Double-check actual file size after reading
|
||||
if len(file_bytes) > group.MAX_FILE_SIZE:
|
||||
return self.fail(400, 'Image size exceeds 10MB limit.')
|
||||
|
||||
# Validate image file extension
|
||||
allowed_extensions = {'jpg', 'jpeg', 'png', 'gif', 'webp'}
|
||||
if '.' in file.filename:
|
||||
file_name, extension = file.filename.rsplit('.', 1)
|
||||
extension = extension.lower()
|
||||
else:
|
||||
return self.fail(400, 'Invalid image file: no file extension')
|
||||
|
||||
if extension not in allowed_extensions:
|
||||
return self.fail(400, f'Invalid image format. Allowed formats: {", ".join(allowed_extensions)}')
|
||||
|
||||
# check if file name contains '/' or '\'
|
||||
if '/' in file_name or '\\' in file_name:
|
||||
return self.fail(400, 'File name contains invalid characters')
|
||||
|
||||
file_key = file_name + '_' + str(uuid.uuid4())[:8] + '.' + extension
|
||||
|
||||
# save file to storage
|
||||
await self.ap.storage_mgr.storage_provider.save(file_key, file_bytes)
|
||||
return self.success(
|
||||
data={
|
||||
'file_key': file_key,
|
||||
}
|
||||
)
|
||||
|
||||
@self.route('/documents', methods=['POST'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)
|
||||
async def upload_document() -> quart.Response:
|
||||
@self.route('/documents', methods=['POST'], auth_type=group.AuthType.USER_TOKEN)
|
||||
async def _() -> quart.Response:
|
||||
request = quart.request
|
||||
|
||||
# Check file size limit before reading the file
|
||||
|
||||
@@ -5,7 +5,7 @@ from ... import group
|
||||
@group.group_class('knowledge_base', '/api/v1/knowledge/bases')
|
||||
class KnowledgeBaseRouterGroup(group.RouterGroup):
|
||||
async def initialize(self) -> None:
|
||||
@self.route('', methods=['POST', 'GET'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)
|
||||
@self.route('', methods=['POST', 'GET'])
|
||||
async def handle_knowledge_bases() -> quart.Response:
|
||||
if quart.request.method == 'GET':
|
||||
knowledge_bases = await self.ap.knowledge_service.get_knowledge_bases()
|
||||
@@ -21,7 +21,6 @@ class KnowledgeBaseRouterGroup(group.RouterGroup):
|
||||
@self.route(
|
||||
'/<knowledge_base_uuid>',
|
||||
methods=['GET', 'DELETE', 'PUT'],
|
||||
auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
|
||||
)
|
||||
async def handle_specific_knowledge_base(knowledge_base_uuid: str) -> quart.Response:
|
||||
if quart.request.method == 'GET':
|
||||
@@ -48,7 +47,6 @@ class KnowledgeBaseRouterGroup(group.RouterGroup):
|
||||
@self.route(
|
||||
'/<knowledge_base_uuid>/files',
|
||||
methods=['GET', 'POST'],
|
||||
auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
|
||||
)
|
||||
async def get_knowledge_base_files(knowledge_base_uuid: str) -> str:
|
||||
if quart.request.method == 'GET':
|
||||
@@ -76,7 +74,6 @@ class KnowledgeBaseRouterGroup(group.RouterGroup):
|
||||
@self.route(
|
||||
'/<knowledge_base_uuid>/files/<file_id>',
|
||||
methods=['DELETE'],
|
||||
auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
|
||||
)
|
||||
async def delete_specific_file_in_kb(file_id: str, knowledge_base_uuid: str) -> str:
|
||||
await self.ap.knowledge_service.delete_file(knowledge_base_uuid, file_id)
|
||||
@@ -85,7 +82,6 @@ class KnowledgeBaseRouterGroup(group.RouterGroup):
|
||||
@self.route(
|
||||
'/<knowledge_base_uuid>/retrieve',
|
||||
methods=['POST'],
|
||||
auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
|
||||
)
|
||||
async def retrieve_knowledge_base(knowledge_base_uuid: str) -> str:
|
||||
json_data = await quart.request.json
|
||||
|
||||
@@ -1,61 +0,0 @@
|
||||
import quart
|
||||
from ... import group
|
||||
|
||||
|
||||
@group.group_class('external_knowledge_base', '/api/v1/knowledge/external-bases')
|
||||
class ExternalKnowledgeBaseRouterGroup(group.RouterGroup):
|
||||
async def initialize(self) -> None:
|
||||
@self.route('/retrievers', methods=['GET'])
|
||||
async def list_knowledge_retrievers() -> quart.Response:
|
||||
"""List all available knowledge retrievers from plugins."""
|
||||
retrievers = await self.ap.plugin_connector.list_knowledge_retrievers()
|
||||
return self.success(data={'retrievers': retrievers})
|
||||
|
||||
@self.route('', methods=['POST', 'GET'])
|
||||
async def handle_external_knowledge_bases() -> quart.Response:
|
||||
if quart.request.method == 'GET':
|
||||
external_kbs = await self.ap.external_kb_service.get_external_knowledge_bases()
|
||||
return self.success(data={'bases': external_kbs})
|
||||
|
||||
elif quart.request.method == 'POST':
|
||||
json_data = await quart.request.json
|
||||
kb_uuid = await self.ap.external_kb_service.create_external_knowledge_base(json_data)
|
||||
return self.success(data={'uuid': kb_uuid})
|
||||
|
||||
return self.http_status(405, -1, 'Method not allowed')
|
||||
|
||||
@self.route(
|
||||
'/<kb_uuid>',
|
||||
methods=['GET', 'DELETE', 'PUT'],
|
||||
)
|
||||
async def handle_specific_external_knowledge_base(kb_uuid: str) -> quart.Response:
|
||||
if quart.request.method == 'GET':
|
||||
external_kb = await self.ap.external_kb_service.get_external_knowledge_base(kb_uuid)
|
||||
|
||||
if external_kb is None:
|
||||
return self.http_status(404, -1, 'external knowledge base not found')
|
||||
|
||||
return self.success(
|
||||
data={
|
||||
'base': external_kb,
|
||||
}
|
||||
)
|
||||
|
||||
elif quart.request.method == 'PUT':
|
||||
json_data = await quart.request.json
|
||||
await self.ap.external_kb_service.update_external_knowledge_base(kb_uuid, json_data)
|
||||
return self.success({})
|
||||
|
||||
elif quart.request.method == 'DELETE':
|
||||
await self.ap.external_kb_service.delete_external_knowledge_base(kb_uuid)
|
||||
return self.success({})
|
||||
|
||||
@self.route(
|
||||
'/<kb_uuid>/retrieve',
|
||||
methods=['POST'],
|
||||
)
|
||||
async def retrieve_external_knowledge_base(kb_uuid: str) -> str:
|
||||
json_data = await quart.request.json
|
||||
query = json_data.get('query')
|
||||
results = await self.ap.external_kb_service.retrieve_external_knowledge_base(kb_uuid, query)
|
||||
return self.success(data={'results': results})
|
||||
@@ -59,33 +59,25 @@ class PipelinesRouterGroup(group.RouterGroup):
|
||||
if pipeline is None:
|
||||
return self.http_status(404, -1, 'pipeline not found')
|
||||
|
||||
# Only include plugins with pipeline-related components (Command, EventListener, Tool)
|
||||
# Plugins that only have KnowledgeRetriever components are not suitable for pipeline extensions
|
||||
pipeline_component_kinds = ['Command', 'EventListener', 'Tool']
|
||||
plugins = await self.ap.plugin_connector.list_plugins(component_kinds=pipeline_component_kinds)
|
||||
plugins = await self.ap.plugin_connector.list_plugins()
|
||||
mcp_servers = await self.ap.mcp_service.get_mcp_servers(contain_runtime_info=True)
|
||||
|
||||
extensions_prefs = pipeline.get('extensions_preferences', {})
|
||||
return self.success(
|
||||
data={
|
||||
'enable_all_plugins': extensions_prefs.get('enable_all_plugins', True),
|
||||
'enable_all_mcp_servers': extensions_prefs.get('enable_all_mcp_servers', True),
|
||||
'bound_plugins': extensions_prefs.get('plugins', []),
|
||||
'bound_plugins': pipeline.get('extensions_preferences', {}).get('plugins', []),
|
||||
'available_plugins': plugins,
|
||||
'bound_mcp_servers': extensions_prefs.get('mcp_servers', []),
|
||||
'bound_mcp_servers': pipeline.get('extensions_preferences', {}).get('mcp_servers', []),
|
||||
'available_mcp_servers': mcp_servers,
|
||||
}
|
||||
)
|
||||
elif quart.request.method == 'PUT':
|
||||
# Update bound plugins and MCP servers for this pipeline
|
||||
json_data = await quart.request.json
|
||||
enable_all_plugins = json_data.get('enable_all_plugins', True)
|
||||
enable_all_mcp_servers = json_data.get('enable_all_mcp_servers', True)
|
||||
bound_plugins = json_data.get('bound_plugins', [])
|
||||
bound_mcp_servers = json_data.get('bound_mcp_servers', [])
|
||||
|
||||
await self.ap.pipeline_service.update_pipeline_extensions(
|
||||
pipeline_uuid, bound_plugins, bound_mcp_servers, enable_all_plugins, enable_all_mcp_servers
|
||||
pipeline_uuid, bound_plugins, bound_mcp_servers
|
||||
)
|
||||
|
||||
return self.success()
|
||||
|
||||
109
src/langbot/pkg/api/http/controller/groups/pipelines/webchat.py
Normal file
109
src/langbot/pkg/api/http/controller/groups/pipelines/webchat.py
Normal file
@@ -0,0 +1,109 @@
|
||||
import json
|
||||
|
||||
import quart
|
||||
|
||||
from ... import group
|
||||
|
||||
|
||||
@group.group_class('webchat', '/api/v1/pipelines/<pipeline_uuid>/chat')
|
||||
class WebChatDebugRouterGroup(group.RouterGroup):
|
||||
async def initialize(self) -> None:
|
||||
@self.route('/send', methods=['POST'])
|
||||
async def send_message(pipeline_uuid: str) -> str:
|
||||
"""Send a message to the pipeline for debugging"""
|
||||
|
||||
async def stream_generator(generator):
|
||||
yield 'data: {"type": "start"}\n\n'
|
||||
async for message in generator:
|
||||
yield f'data: {json.dumps({"message": message})}\n\n'
|
||||
yield 'data: {"type": "end"}\n\n'
|
||||
|
||||
try:
|
||||
data = await quart.request.get_json()
|
||||
session_type = data.get('session_type', 'person')
|
||||
message_chain_obj = data.get('message', [])
|
||||
is_stream = data.get('is_stream', False)
|
||||
|
||||
if not message_chain_obj:
|
||||
return self.http_status(400, -1, 'message is required')
|
||||
|
||||
if session_type not in ['person', 'group']:
|
||||
return self.http_status(400, -1, 'session_type must be person or group')
|
||||
|
||||
webchat_adapter = self.ap.platform_mgr.webchat_proxy_bot.adapter
|
||||
|
||||
if not webchat_adapter:
|
||||
return self.http_status(404, -1, 'WebChat adapter not found')
|
||||
|
||||
if is_stream:
|
||||
generator = webchat_adapter.send_webchat_message(
|
||||
pipeline_uuid, session_type, message_chain_obj, is_stream
|
||||
)
|
||||
# 设置正确的响应头
|
||||
headers = {
|
||||
'Content-Type': 'text/event-stream',
|
||||
'Transfer-Encoding': 'chunked',
|
||||
'Cache-Control': 'no-cache',
|
||||
'Connection': 'keep-alive',
|
||||
}
|
||||
return quart.Response(stream_generator(generator), mimetype='text/event-stream', headers=headers)
|
||||
|
||||
else: # non-stream
|
||||
result = None
|
||||
async for message in webchat_adapter.send_webchat_message(
|
||||
pipeline_uuid, session_type, message_chain_obj
|
||||
):
|
||||
result = message
|
||||
if result is not None:
|
||||
return self.success(
|
||||
data={
|
||||
'message': result,
|
||||
}
|
||||
)
|
||||
else:
|
||||
return self.http_status(400, -1, 'message is required')
|
||||
|
||||
except Exception as e:
|
||||
return self.http_status(500, -1, f'Internal server error: {str(e)}')
|
||||
|
||||
@self.route('/messages/<session_type>', methods=['GET'])
|
||||
async def get_messages(pipeline_uuid: str, session_type: str) -> str:
|
||||
"""Get the message history of the pipeline for debugging"""
|
||||
try:
|
||||
if session_type not in ['person', 'group']:
|
||||
return self.http_status(400, -1, 'session_type must be person or group')
|
||||
|
||||
webchat_adapter = self.ap.platform_mgr.webchat_proxy_bot.adapter
|
||||
|
||||
if not webchat_adapter:
|
||||
return self.http_status(404, -1, 'WebChat adapter not found')
|
||||
|
||||
messages = webchat_adapter.get_webchat_messages(pipeline_uuid, session_type)
|
||||
|
||||
return self.success(data={'messages': messages})
|
||||
|
||||
except Exception as e:
|
||||
return self.http_status(500, -1, f'Internal server error: {str(e)}')
|
||||
|
||||
@self.route('/reset/<session_type>', methods=['POST'])
|
||||
async def reset_session(session_type: str) -> str:
|
||||
"""Reset the debug session"""
|
||||
try:
|
||||
if session_type not in ['person', 'group']:
|
||||
return self.http_status(400, -1, 'session_type must be person or group')
|
||||
|
||||
webchat_adapter = None
|
||||
for bot in self.ap.platform_mgr.bots:
|
||||
if hasattr(bot.adapter, '__class__') and bot.adapter.__class__.__name__ == 'WebChatAdapter':
|
||||
webchat_adapter = bot.adapter
|
||||
break
|
||||
|
||||
if not webchat_adapter:
|
||||
return self.http_status(404, -1, 'WebChat adapter not found')
|
||||
|
||||
webchat_adapter.reset_debug_session(session_type)
|
||||
|
||||
return self.success(data={'message': 'Session reset successfully'})
|
||||
|
||||
except Exception as e:
|
||||
return self.http_status(500, -1, f'Internal server error: {str(e)}')
|
||||
@@ -1,243 +0,0 @@
|
||||
"""WebSocket聊天路由 - 支持双向实时通信"""
|
||||
|
||||
import asyncio
|
||||
import datetime
|
||||
import json
|
||||
import logging
|
||||
|
||||
import quart
|
||||
|
||||
from ... import group
|
||||
from ......platform.sources.websocket_manager import ws_connection_manager
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@group.group_class('websocket_chat', '/api/v1/pipelines/<pipeline_uuid>/ws')
|
||||
class WebSocketChatRouterGroup(group.RouterGroup):
|
||||
async def initialize(self) -> None:
|
||||
# 直接使用 quart_app 注册 WebSocket 路由
|
||||
@self.quart_app.websocket(self.path + '/connect')
|
||||
async def websocket_connect(pipeline_uuid: str):
|
||||
"""
|
||||
建立WebSocket连接
|
||||
|
||||
URL参数:
|
||||
- pipeline_uuid: 流水线UUID
|
||||
- session_type: 会话类型 (person/group)
|
||||
"""
|
||||
try:
|
||||
# 获取参数 - 在WebSocket上下文中使用 quart.websocket.args
|
||||
session_type = quart.websocket.args.get('session_type', 'person')
|
||||
|
||||
if session_type not in ['person', 'group']:
|
||||
await quart.websocket.send(
|
||||
json.dumps({'type': 'error', 'message': 'session_type must be person or group'})
|
||||
)
|
||||
return
|
||||
|
||||
# 获取WebSocket适配器
|
||||
websocket_adapter = self.ap.platform_mgr.websocket_proxy_bot.adapter
|
||||
|
||||
if not websocket_adapter:
|
||||
await quart.websocket.send(json.dumps({'type': 'error', 'message': 'WebSocket adapter not found'}))
|
||||
return
|
||||
|
||||
# 注册连接
|
||||
connection = await ws_connection_manager.add_connection(
|
||||
websocket=quart.websocket._get_current_object(),
|
||||
pipeline_uuid=pipeline_uuid,
|
||||
session_type=session_type,
|
||||
metadata={'user_agent': quart.websocket.headers.get('User-Agent', '')},
|
||||
)
|
||||
|
||||
# 发送连接成功消息
|
||||
await quart.websocket.send(
|
||||
json.dumps(
|
||||
{
|
||||
'type': 'connected',
|
||||
'connection_id': connection.connection_id,
|
||||
'pipeline_uuid': pipeline_uuid,
|
||||
'session_type': session_type,
|
||||
'timestamp': connection.created_at.isoformat(),
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
logger.debug(
|
||||
f'WebSocket connection established: {connection.connection_id} '
|
||||
f'(pipeline={pipeline_uuid}, session_type={session_type})'
|
||||
)
|
||||
|
||||
# 创建接收和发送任务
|
||||
receive_task = asyncio.create_task(self._handle_receive(connection, websocket_adapter))
|
||||
send_task = asyncio.create_task(self._handle_send(connection))
|
||||
|
||||
# 等待任务完成
|
||||
try:
|
||||
await asyncio.gather(receive_task, send_task)
|
||||
except Exception as e:
|
||||
logger.error(f'WebSocket task execution error: {e}')
|
||||
finally:
|
||||
# 清理连接
|
||||
await ws_connection_manager.remove_connection(connection.connection_id)
|
||||
logger.debug(f'WebSocket connection cleaned: {connection.connection_id}')
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f'WebSocket connection error: {e}', exc_info=True)
|
||||
try:
|
||||
await quart.websocket.send(json.dumps({'type': 'error', 'message': str(e)}))
|
||||
except:
|
||||
pass
|
||||
|
||||
@self.route('/messages/<session_type>', methods=['GET'])
|
||||
async def get_messages(pipeline_uuid: str, session_type: str) -> str:
|
||||
"""获取消息历史"""
|
||||
try:
|
||||
if session_type not in ['person', 'group']:
|
||||
return self.http_status(400, -1, 'session_type must be person or group')
|
||||
|
||||
websocket_adapter = self.ap.platform_mgr.websocket_proxy_bot.adapter
|
||||
|
||||
if not websocket_adapter:
|
||||
return self.http_status(404, -1, 'WebSocket adapter not found')
|
||||
|
||||
messages = websocket_adapter.get_websocket_messages(pipeline_uuid, session_type)
|
||||
|
||||
return self.success(data={'messages': messages})
|
||||
|
||||
except Exception as e:
|
||||
return self.http_status(500, -1, f'Internal server error: {str(e)}')
|
||||
|
||||
@self.route('/reset/<session_type>', methods=['POST'])
|
||||
async def reset_session(pipeline_uuid: str, session_type: str) -> str:
|
||||
"""重置会话"""
|
||||
try:
|
||||
if session_type not in ['person', 'group']:
|
||||
return self.http_status(400, -1, 'session_type must be person or group')
|
||||
|
||||
websocket_adapter = self.ap.platform_mgr.websocket_proxy_bot.adapter
|
||||
|
||||
if not websocket_adapter:
|
||||
return self.http_status(404, -1, 'WebSocket adapter not found')
|
||||
|
||||
websocket_adapter.reset_session(pipeline_uuid, session_type)
|
||||
|
||||
return self.success(data={'message': 'Session reset successfully'})
|
||||
|
||||
except Exception as e:
|
||||
return self.http_status(500, -1, f'Internal server error: {str(e)}')
|
||||
|
||||
@self.route('/connections', methods=['GET'])
|
||||
async def get_connections(pipeline_uuid: str) -> str:
|
||||
"""获取当前连接统计"""
|
||||
try:
|
||||
stats = ws_connection_manager.get_stats()
|
||||
connections = await ws_connection_manager.get_connections_by_pipeline(pipeline_uuid)
|
||||
|
||||
return self.success(
|
||||
data={
|
||||
'stats': stats,
|
||||
'connections': [
|
||||
{
|
||||
'connection_id': conn.connection_id,
|
||||
'session_type': conn.session_type,
|
||||
'created_at': conn.created_at.isoformat(),
|
||||
'last_active': conn.last_active.isoformat(),
|
||||
'is_active': conn.is_active,
|
||||
}
|
||||
for conn in connections
|
||||
],
|
||||
}
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
return self.http_status(500, -1, f'Internal server error: {str(e)}')
|
||||
|
||||
@self.route('/broadcast', methods=['POST'])
|
||||
async def broadcast_message(pipeline_uuid: str) -> str:
|
||||
"""向所有连接广播消息(后端主动推送)"""
|
||||
try:
|
||||
data = await quart.request.get_json()
|
||||
message = data.get('message')
|
||||
|
||||
if not message:
|
||||
return self.http_status(400, -1, 'message is required')
|
||||
|
||||
# 广播消息
|
||||
broadcast_data = {
|
||||
'type': 'broadcast',
|
||||
'message': message,
|
||||
'timestamp': datetime.datetime.now().isoformat(),
|
||||
}
|
||||
|
||||
await ws_connection_manager.broadcast_to_pipeline(pipeline_uuid, broadcast_data)
|
||||
|
||||
return self.success(data={'message': 'Broadcast sent successfully'})
|
||||
|
||||
except Exception as e:
|
||||
return self.http_status(500, -1, f'Internal server error: {str(e)}')
|
||||
|
||||
async def _handle_receive(self, connection, websocket_adapter):
|
||||
"""处理接收消息的任务"""
|
||||
try:
|
||||
while connection.is_active:
|
||||
# 接收消息
|
||||
message = await quart.websocket.receive()
|
||||
|
||||
# 更新活跃时间
|
||||
await ws_connection_manager.update_activity(connection.connection_id)
|
||||
|
||||
try:
|
||||
data = json.loads(message)
|
||||
message_type = data.get('type', 'message')
|
||||
|
||||
if message_type == 'ping':
|
||||
# 心跳响应
|
||||
await connection.send_queue.put(
|
||||
{'type': 'pong', 'timestamp': datetime.datetime.now().isoformat()}
|
||||
)
|
||||
|
||||
elif message_type == 'message':
|
||||
# 处理用户消息
|
||||
logger.debug(f'收到消息: {data} from {connection.connection_id}')
|
||||
|
||||
# 处理消息(不等待响应,响应会通过broadcast异步发送)
|
||||
await websocket_adapter.handle_websocket_message(connection, data)
|
||||
|
||||
elif message_type == 'disconnect':
|
||||
# 客户端主动断开
|
||||
logger.debug(f'Client disconnected: {connection.connection_id}')
|
||||
break
|
||||
|
||||
else:
|
||||
logger.warning(f'Unknown message type: {message_type}')
|
||||
|
||||
except json.JSONDecodeError:
|
||||
logger.error(f'Invalid JSON message: {message}')
|
||||
await connection.send_queue.put({'type': 'error', 'message': 'Invalid JSON format'})
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f'Receive message error: {e}', exc_info=True)
|
||||
finally:
|
||||
connection.is_active = False
|
||||
|
||||
async def _handle_send(self, connection):
|
||||
"""处理发送消息的任务"""
|
||||
try:
|
||||
while connection.is_active:
|
||||
# 从队列获取消息
|
||||
try:
|
||||
message = await asyncio.wait_for(connection.send_queue.get(), timeout=1.0)
|
||||
|
||||
# 发送消息
|
||||
await quart.websocket.send(json.dumps(message))
|
||||
|
||||
except asyncio.TimeoutError:
|
||||
# 超时继续循环
|
||||
continue
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f'Send message error: {e}', exc_info=True)
|
||||
finally:
|
||||
connection.is_active = False
|
||||
@@ -1,7 +1,6 @@
|
||||
import quart
|
||||
import mimetypes
|
||||
|
||||
from ... import group
|
||||
from langbot.pkg.utils import importutil
|
||||
|
||||
|
||||
@group.group_class('adapters', '/api/v1/platform/adapters')
|
||||
@@ -32,6 +31,4 @@ class AdaptersRouterGroup(group.RouterGroup):
|
||||
if icon_path is None:
|
||||
return self.http_status(404, -1, 'icon not found')
|
||||
|
||||
return quart.Response(
|
||||
importutil.read_resource_file_bytes(icon_path), mimetype=mimetypes.guess_type(icon_path)[0]
|
||||
)
|
||||
return await quart.send_file(icon_path)
|
||||
|
||||
@@ -18,8 +18,7 @@ class BotsRouterGroup(group.RouterGroup):
|
||||
@self.route('/<bot_uuid>', methods=['GET', 'PUT', 'DELETE'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)
|
||||
async def _(bot_uuid: str) -> str:
|
||||
if quart.request.method == 'GET':
|
||||
# 返回运行时信息,包括webhook地址等
|
||||
bot = await self.ap.bot_service.get_runtime_bot_info(bot_uuid)
|
||||
bot = await self.ap.bot_service.get_bot(bot_uuid)
|
||||
if bot is None:
|
||||
return self.http_status(404, -1, 'bot not found')
|
||||
return self.success(data={'bot': bot})
|
||||
@@ -43,32 +42,3 @@ class BotsRouterGroup(group.RouterGroup):
|
||||
'total_count': total_count,
|
||||
}
|
||||
)
|
||||
|
||||
@self.route('/<bot_uuid>/send_message', methods=['POST'], auth_type=group.AuthType.API_KEY)
|
||||
async def _(bot_uuid: str) -> str:
|
||||
"""Send message to a specific target via bot"""
|
||||
json_data = await quart.request.json
|
||||
target_type = json_data.get('target_type')
|
||||
target_id = json_data.get('target_id')
|
||||
message_chain_data = json_data.get('message_chain')
|
||||
|
||||
# Validate required fields
|
||||
if not target_type:
|
||||
return self.http_status(400, -1, 'target_type is required')
|
||||
if not target_id:
|
||||
return self.http_status(400, -1, 'target_id is required')
|
||||
if not message_chain_data:
|
||||
return self.http_status(400, -1, 'message_chain is required')
|
||||
|
||||
# Validate target_type
|
||||
if target_type not in ['person', 'group']:
|
||||
return self.http_status(400, -1, 'target_type must be either "person" or "group"')
|
||||
|
||||
try:
|
||||
await self.ap.bot_service.send_message(bot_uuid, target_type, target_id, message_chain_data)
|
||||
return self.success(data={'sent': True})
|
||||
except Exception as e:
|
||||
import traceback
|
||||
|
||||
traceback.print_exc()
|
||||
return self.http_status(500, -1, f'Failed to send message: {str(e)}')
|
||||
|
||||
@@ -15,32 +15,16 @@ from langbot_plugin.runtime.plugin.mgr import PluginInstallSource
|
||||
@group.group_class('plugins', '/api/v1/plugins')
|
||||
class PluginsRouterGroup(group.RouterGroup):
|
||||
async def initialize(self) -> None:
|
||||
@self.route('', methods=['GET'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)
|
||||
@self.route('', methods=['GET'], auth_type=group.AuthType.USER_TOKEN)
|
||||
async def _() -> str:
|
||||
plugins = await self.ap.plugin_connector.list_plugins()
|
||||
|
||||
return self.success(data={'plugins': plugins})
|
||||
|
||||
@self.route('/debug-info', methods=['GET'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)
|
||||
async def _() -> str:
|
||||
"""Get plugin debug information including debug URL and key"""
|
||||
debug_info = await self.ap.plugin_connector.get_debug_info()
|
||||
|
||||
# Get debug URL from config
|
||||
plugin_config = self.ap.instance_config.data.get('plugin', {})
|
||||
debug_url = plugin_config.get('display_plugin_debug_url', 'http://localhost:5401')
|
||||
|
||||
return self.success(
|
||||
data={
|
||||
'debug_url': debug_url,
|
||||
'plugin_debug_key': debug_info.get('plugin_debug_key', ''),
|
||||
}
|
||||
)
|
||||
|
||||
@self.route(
|
||||
'/<author>/<plugin_name>/upgrade',
|
||||
methods=['POST'],
|
||||
auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
|
||||
auth_type=group.AuthType.USER_TOKEN,
|
||||
)
|
||||
async def _(author: str, plugin_name: str) -> str:
|
||||
ctx = taskmgr.TaskContext.new()
|
||||
@@ -56,7 +40,7 @@ class PluginsRouterGroup(group.RouterGroup):
|
||||
@self.route(
|
||||
'/<author>/<plugin_name>',
|
||||
methods=['GET', 'DELETE'],
|
||||
auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
|
||||
auth_type=group.AuthType.USER_TOKEN,
|
||||
)
|
||||
async def _(author: str, plugin_name: str) -> str:
|
||||
if quart.request.method == 'GET':
|
||||
@@ -82,7 +66,7 @@ class PluginsRouterGroup(group.RouterGroup):
|
||||
@self.route(
|
||||
'/<author>/<plugin_name>/config',
|
||||
methods=['GET', 'PUT'],
|
||||
auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
|
||||
auth_type=group.AuthType.USER_TOKEN,
|
||||
)
|
||||
async def _(author: str, plugin_name: str) -> quart.Response:
|
||||
plugin = await self.ap.plugin_connector.get_plugin_info(author, plugin_name)
|
||||
@@ -98,16 +82,6 @@ class PluginsRouterGroup(group.RouterGroup):
|
||||
|
||||
return self.success(data={})
|
||||
|
||||
@self.route(
|
||||
'/<author>/<plugin_name>/readme',
|
||||
methods=['GET'],
|
||||
auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
|
||||
)
|
||||
async def _(author: str, plugin_name: str) -> quart.Response:
|
||||
language = quart.request.args.get('language', 'en')
|
||||
readme = await self.ap.plugin_connector.get_plugin_readme(author, plugin_name, language=language)
|
||||
return self.success(data={'readme': readme})
|
||||
|
||||
@self.route(
|
||||
'/<author>/<plugin_name>/icon',
|
||||
methods=['GET'],
|
||||
@@ -122,18 +96,7 @@ class PluginsRouterGroup(group.RouterGroup):
|
||||
|
||||
return quart.Response(icon_data, mimetype=mime_type)
|
||||
|
||||
@self.route(
|
||||
'/<author>/<plugin_name>/assets/<filepath>',
|
||||
methods=['GET'],
|
||||
auth_type=group.AuthType.NONE,
|
||||
)
|
||||
async def _(author: str, plugin_name: str, filepath: str) -> quart.Response:
|
||||
asset_data = await self.ap.plugin_connector.get_plugin_assets(author, plugin_name, filepath)
|
||||
asset_bytes = base64.b64decode(asset_data['asset_base64'])
|
||||
mime_type = asset_data['mime_type']
|
||||
return quart.Response(asset_bytes, mimetype=mime_type)
|
||||
|
||||
@self.route('/github/releases', methods=['POST'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)
|
||||
@self.route('/github/releases', methods=['POST'], auth_type=group.AuthType.USER_TOKEN)
|
||||
async def _() -> str:
|
||||
"""Get releases from a GitHub repository URL"""
|
||||
data = await quart.request.json
|
||||
@@ -182,7 +145,7 @@ class PluginsRouterGroup(group.RouterGroup):
|
||||
@self.route(
|
||||
'/github/release-assets',
|
||||
methods=['POST'],
|
||||
auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
|
||||
auth_type=group.AuthType.USER_TOKEN,
|
||||
)
|
||||
async def _() -> str:
|
||||
"""Get assets from a specific GitHub release"""
|
||||
@@ -236,7 +199,7 @@ class PluginsRouterGroup(group.RouterGroup):
|
||||
except httpx.RequestError as e:
|
||||
return self.http_status(500, -1, f'Failed to fetch release assets: {str(e)}')
|
||||
|
||||
@self.route('/install/github', methods=['POST'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)
|
||||
@self.route('/install/github', methods=['POST'], auth_type=group.AuthType.USER_TOKEN)
|
||||
async def _() -> str:
|
||||
"""Install plugin from GitHub release asset"""
|
||||
data = await quart.request.json
|
||||
@@ -270,7 +233,7 @@ class PluginsRouterGroup(group.RouterGroup):
|
||||
@self.route(
|
||||
'/install/marketplace',
|
||||
methods=['POST'],
|
||||
auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
|
||||
auth_type=group.AuthType.USER_TOKEN,
|
||||
)
|
||||
async def _() -> str:
|
||||
data = await quart.request.json
|
||||
@@ -286,7 +249,7 @@ class PluginsRouterGroup(group.RouterGroup):
|
||||
|
||||
return self.success(data={'task_id': wrapper.id})
|
||||
|
||||
@self.route('/install/local', methods=['POST'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)
|
||||
@self.route('/install/local', methods=['POST'], auth_type=group.AuthType.USER_TOKEN)
|
||||
async def _() -> str:
|
||||
file = (await quart.request.files).get('file')
|
||||
if file is None:
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import quart
|
||||
import mimetypes
|
||||
|
||||
from ... import group
|
||||
from langbot.pkg.utils import importutil
|
||||
|
||||
|
||||
@group.group_class('provider/requesters', '/api/v1/provider/requesters')
|
||||
@@ -34,6 +32,4 @@ class RequestersRouterGroup(group.RouterGroup):
|
||||
if icon_path is None:
|
||||
return self.http_status(404, -1, 'icon not found')
|
||||
|
||||
return quart.Response(
|
||||
importutil.read_resource_file_bytes(icon_path), mimetype=mimetypes.guess_type(icon_path)[0]
|
||||
)
|
||||
return await quart.send_file(icon_path)
|
||||
|
||||
@@ -55,6 +55,17 @@ class SystemRouterGroup(group.RouterGroup):
|
||||
|
||||
return self.success(data=exec(py_code, {'ap': ap}))
|
||||
|
||||
@self.route('/debug/tools/call', methods=['POST'], auth_type=group.AuthType.USER_TOKEN)
|
||||
async def _() -> str:
|
||||
if not constants.debug_mode:
|
||||
return self.http_status(403, 403, 'Forbidden')
|
||||
|
||||
data = await quart.request.json
|
||||
|
||||
return self.success(
|
||||
data=await self.ap.tool_mgr.execute_func_call(data['tool_name'], data['tool_parameters'])
|
||||
)
|
||||
|
||||
@self.route(
|
||||
'/debug/plugin/action',
|
||||
methods=['POST'],
|
||||
|
||||
@@ -1,57 +1,49 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import quart
|
||||
import traceback
|
||||
|
||||
from .. import group
|
||||
|
||||
|
||||
@group.group_class('webhooks', '/bots')
|
||||
class WebhookRouterGroup(group.RouterGroup):
|
||||
@group.group_class('webhooks', '/api/v1/webhooks')
|
||||
class WebhooksRouterGroup(group.RouterGroup):
|
||||
async def initialize(self) -> None:
|
||||
@self.route('/<bot_uuid>', methods=['GET', 'POST'], auth_type=group.AuthType.NONE)
|
||||
async def handle_webhook(bot_uuid: str):
|
||||
"""处理 bot webhook 回调(无子路径)"""
|
||||
return await self._dispatch_webhook(bot_uuid, '')
|
||||
@self.route('', methods=['GET', 'POST'])
|
||||
async def _() -> str:
|
||||
if quart.request.method == 'GET':
|
||||
webhooks = await self.ap.webhook_service.get_webhooks()
|
||||
return self.success(data={'webhooks': webhooks})
|
||||
elif quart.request.method == 'POST':
|
||||
json_data = await quart.request.json
|
||||
name = json_data.get('name', '')
|
||||
url = json_data.get('url', '')
|
||||
description = json_data.get('description', '')
|
||||
enabled = json_data.get('enabled', True)
|
||||
|
||||
@self.route('/<bot_uuid>/<path:path>', methods=['GET', 'POST'], auth_type=group.AuthType.NONE)
|
||||
async def handle_webhook_with_path(bot_uuid: str, path: str):
|
||||
"""处理 bot webhook 回调(带子路径)"""
|
||||
return await self._dispatch_webhook(bot_uuid, path)
|
||||
if not name:
|
||||
return self.http_status(400, -1, 'Name is required')
|
||||
if not url:
|
||||
return self.http_status(400, -1, 'URL is required')
|
||||
|
||||
async def _dispatch_webhook(self, bot_uuid: str, path: str):
|
||||
"""分发 webhook 请求到对应的 bot adapter
|
||||
webhook = await self.ap.webhook_service.create_webhook(name, url, description, enabled)
|
||||
return self.success(data={'webhook': webhook})
|
||||
|
||||
Args:
|
||||
bot_uuid: Bot 的 UUID
|
||||
path: 子路径(如果有的话)
|
||||
@self.route('/<int:webhook_id>', methods=['GET', 'PUT', 'DELETE'])
|
||||
async def _(webhook_id: int) -> str:
|
||||
if quart.request.method == 'GET':
|
||||
webhook = await self.ap.webhook_service.get_webhook(webhook_id)
|
||||
if webhook is None:
|
||||
return self.http_status(404, -1, 'Webhook not found')
|
||||
return self.success(data={'webhook': webhook})
|
||||
|
||||
Returns:
|
||||
适配器返回的响应
|
||||
"""
|
||||
try:
|
||||
|
||||
runtime_bot = await self.ap.platform_mgr.get_bot_by_uuid(bot_uuid)
|
||||
elif quart.request.method == 'PUT':
|
||||
json_data = await quart.request.json
|
||||
name = json_data.get('name')
|
||||
url = json_data.get('url')
|
||||
description = json_data.get('description')
|
||||
enabled = json_data.get('enabled')
|
||||
|
||||
if not runtime_bot:
|
||||
return quart.jsonify({'error': 'Bot not found'}), 404
|
||||
await self.ap.webhook_service.update_webhook(webhook_id, name, url, description, enabled)
|
||||
return self.success()
|
||||
|
||||
if not runtime_bot.enable:
|
||||
return quart.jsonify({'error': 'Bot is disabled'}), 403
|
||||
|
||||
|
||||
if not hasattr(runtime_bot.adapter, 'handle_unified_webhook'):
|
||||
return quart.jsonify({'error': 'Adapter does not support unified webhook'}), 501
|
||||
|
||||
|
||||
response = await runtime_bot.adapter.handle_unified_webhook(
|
||||
bot_uuid=bot_uuid,
|
||||
path=path,
|
||||
request=quart.request,
|
||||
)
|
||||
|
||||
return response
|
||||
|
||||
except Exception as e:
|
||||
self.ap.logger.error(f'Webhook dispatch error for bot {bot_uuid}: {traceback.format_exc()}')
|
||||
return quart.jsonify({'error': str(e)}), 500
|
||||
elif quart.request.method == 'DELETE':
|
||||
await self.ap.webhook_service.delete_webhook(webhook_id)
|
||||
return self.success()
|
||||
|
||||
@@ -58,16 +58,6 @@ class BotService:
|
||||
if runtime_bot is not None:
|
||||
adapter_runtime_values['bot_account_id'] = runtime_bot.adapter.bot_account_id
|
||||
|
||||
# Webhook URL for unified webhook adapters (independent of bot running state)
|
||||
if persistence_bot['adapter'] in ['wecom', 'wecombot', 'officialaccount', 'qqofficial', 'slack', 'wecomcs', 'LINE']:
|
||||
webhook_prefix = self.ap.instance_config.data['api'].get('webhook_prefix', 'http://127.0.0.1:5300')
|
||||
webhook_url = f'/bots/{bot_uuid}'
|
||||
adapter_runtime_values['webhook_url'] = webhook_url
|
||||
adapter_runtime_values['webhook_full_url'] = f'{webhook_prefix}{webhook_url}'
|
||||
else:
|
||||
adapter_runtime_values['webhook_url'] = None
|
||||
adapter_runtime_values['webhook_full_url'] = None
|
||||
|
||||
persistence_bot['adapter_runtime_values'] = adapter_runtime_values
|
||||
|
||||
return persistence_bot
|
||||
@@ -149,29 +139,3 @@ class BotService:
|
||||
logs, total_count = await runtime_bot.logger.get_logs(from_index, max_count)
|
||||
|
||||
return [log.to_json() for log in logs], total_count
|
||||
|
||||
async def send_message(self, bot_uuid: str, target_type: str, target_id: str, message_chain_data: dict) -> None:
|
||||
"""Send message to a specific target via bot
|
||||
|
||||
Args:
|
||||
bot_uuid: The UUID of the bot
|
||||
target_type: The type of the target, can be "group", "person"
|
||||
target_id: The ID of the target
|
||||
message_chain_data: The message chain data in dict format
|
||||
"""
|
||||
# Import here to avoid circular imports
|
||||
import langbot_plugin.api.entities.builtin.platform.message as platform_message
|
||||
|
||||
# Get runtime bot
|
||||
runtime_bot = await self.ap.platform_mgr.get_bot_by_uuid(bot_uuid)
|
||||
if runtime_bot is None:
|
||||
raise Exception(f'Bot not found: {bot_uuid}')
|
||||
|
||||
# Validate and convert message chain
|
||||
try:
|
||||
message_chain = platform_message.MessageChain.model_validate(message_chain_data)
|
||||
except Exception as e:
|
||||
raise Exception(f'Invalid message_chain format: {str(e)}')
|
||||
|
||||
# Send message via adapter
|
||||
await runtime_bot.adapter.send_message(target_type, str(target_id), message_chain)
|
||||
|
||||
@@ -1,80 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from ....core import app
|
||||
import sqlalchemy
|
||||
from langbot.pkg.entity.persistence import rag as persistence_rag
|
||||
import uuid
|
||||
|
||||
|
||||
class ExternalKBService:
|
||||
"""External KB service"""
|
||||
|
||||
ap: app.Application
|
||||
|
||||
def __init__(self, ap: app.Application) -> None:
|
||||
self.ap = ap
|
||||
|
||||
# External Knowledge Base methods
|
||||
async def get_external_knowledge_bases(self) -> list[dict]:
|
||||
result = await self.ap.persistence_mgr.execute_async(sqlalchemy.select(persistence_rag.ExternalKnowledgeBase))
|
||||
external_kbs = result.all()
|
||||
return [
|
||||
self.ap.persistence_mgr.serialize_model(persistence_rag.ExternalKnowledgeBase, external_kb)
|
||||
for external_kb in external_kbs
|
||||
]
|
||||
|
||||
async def get_external_knowledge_base(self, kb_uuid: str) -> dict | None:
|
||||
result = await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.select(persistence_rag.ExternalKnowledgeBase).where(
|
||||
persistence_rag.ExternalKnowledgeBase.uuid == kb_uuid
|
||||
)
|
||||
)
|
||||
external_kb = result.first()
|
||||
if external_kb is None:
|
||||
return None
|
||||
return self.ap.persistence_mgr.serialize_model(persistence_rag.ExternalKnowledgeBase, external_kb)
|
||||
|
||||
async def create_external_knowledge_base(self, kb_data: dict) -> str:
|
||||
kb_data['uuid'] = str(uuid.uuid4())
|
||||
await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.insert(persistence_rag.ExternalKnowledgeBase).values(kb_data)
|
||||
)
|
||||
|
||||
kb = await self.get_external_knowledge_base(kb_data['uuid'])
|
||||
|
||||
await self.ap.rag_mgr.load_external_knowledge_base(kb)
|
||||
|
||||
return kb_data['uuid']
|
||||
|
||||
async def retrieve_external_knowledge_base(self, kb_uuid: str, query: str) -> list[dict]:
|
||||
"""Retrieve external knowledge base"""
|
||||
runtime_kb = await self.ap.rag_mgr.get_knowledge_base_by_uuid(kb_uuid)
|
||||
if runtime_kb is None:
|
||||
raise Exception('Knowledge base not found')
|
||||
return [
|
||||
result.model_dump() for result in await runtime_kb.retrieve(query, 5)
|
||||
] # top_k is just a placeholder for external knowledge base
|
||||
|
||||
async def update_external_knowledge_base(self, kb_uuid: str, kb_data: dict) -> None:
|
||||
if 'uuid' in kb_data:
|
||||
del kb_data['uuid']
|
||||
|
||||
await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.update(persistence_rag.ExternalKnowledgeBase)
|
||||
.values(kb_data)
|
||||
.where(persistence_rag.ExternalKnowledgeBase.uuid == kb_uuid)
|
||||
)
|
||||
await self.ap.rag_mgr.remove_knowledge_base_from_runtime(kb_uuid)
|
||||
|
||||
kb = await self.get_external_knowledge_base(kb_uuid)
|
||||
|
||||
await self.ap.rag_mgr.load_external_knowledge_base(kb)
|
||||
|
||||
async def delete_external_knowledge_base(self, kb_uuid: str) -> None:
|
||||
await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.delete(persistence_rag.ExternalKnowledgeBase).where(
|
||||
persistence_rag.ExternalKnowledgeBase.uuid == kb_uuid
|
||||
)
|
||||
)
|
||||
|
||||
await self.ap.rag_mgr.delete_knowledge_base(kb_uuid)
|
||||
@@ -71,19 +71,7 @@ class KnowledgeService:
|
||||
runtime_kb = await self.ap.rag_mgr.get_knowledge_base_by_uuid(kb_uuid)
|
||||
if runtime_kb is None:
|
||||
raise Exception('Knowledge base not found')
|
||||
# Only internal KBs support file storage
|
||||
if runtime_kb.get_type() != 'internal':
|
||||
raise Exception('Only internal knowledge bases support file storage')
|
||||
result = await runtime_kb.store_file(file_id)
|
||||
|
||||
# Update the KB's updated_at timestamp
|
||||
await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.update(persistence_rag.KnowledgeBase)
|
||||
.values(updated_at=sqlalchemy.func.now())
|
||||
.where(persistence_rag.KnowledgeBase.uuid == kb_uuid)
|
||||
)
|
||||
|
||||
return result
|
||||
return await runtime_kb.store_file(file_id)
|
||||
|
||||
async def retrieve_knowledge_base(self, kb_uuid: str, query: str) -> list[dict]:
|
||||
"""检索知识库"""
|
||||
@@ -107,18 +95,8 @@ class KnowledgeService:
|
||||
runtime_kb = await self.ap.rag_mgr.get_knowledge_base_by_uuid(kb_uuid)
|
||||
if runtime_kb is None:
|
||||
raise Exception('Knowledge base not found')
|
||||
# Only internal KBs support file deletion
|
||||
if runtime_kb.get_type() != 'internal':
|
||||
raise Exception('Only internal knowledge bases support file deletion')
|
||||
await runtime_kb.delete_file(file_id)
|
||||
|
||||
# Update the KB's updated_at timestamp
|
||||
await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.update(persistence_rag.KnowledgeBase)
|
||||
.values(updated_at=sqlalchemy.func.now())
|
||||
.where(persistence_rag.KnowledgeBase.uuid == kb_uuid)
|
||||
)
|
||||
|
||||
async def delete_knowledge_base(self, kb_uuid: str) -> None:
|
||||
"""删除知识库"""
|
||||
await self.ap.rag_mgr.delete_knowledge_base(kb_uuid)
|
||||
|
||||
@@ -85,15 +85,6 @@ class PipelineService:
|
||||
with open(template_path, 'r', encoding='utf-8') as f:
|
||||
pipeline_data['config'] = json.load(f)
|
||||
|
||||
# Ensure extensions_preferences is set with enable_all_plugins and enable_all_mcp_servers=True by default
|
||||
if 'extensions_preferences' not in pipeline_data:
|
||||
pipeline_data['extensions_preferences'] = {
|
||||
'enable_all_plugins': True,
|
||||
'enable_all_mcp_servers': True,
|
||||
'plugins': [],
|
||||
'mcp_servers': [],
|
||||
}
|
||||
|
||||
await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.insert(persistence_pipeline.LegacyPipeline).values(**pipeline_data)
|
||||
)
|
||||
@@ -152,12 +143,7 @@ class PipelineService:
|
||||
await self.ap.pipeline_mgr.remove_pipeline(pipeline_uuid)
|
||||
|
||||
async def update_pipeline_extensions(
|
||||
self,
|
||||
pipeline_uuid: str,
|
||||
bound_plugins: list[dict],
|
||||
bound_mcp_servers: list[str] = None,
|
||||
enable_all_plugins: bool = True,
|
||||
enable_all_mcp_servers: bool = True,
|
||||
self, pipeline_uuid: str, bound_plugins: list[dict], bound_mcp_servers: list[str] = None
|
||||
) -> None:
|
||||
"""Update the bound plugins and MCP servers for a pipeline"""
|
||||
# Get current pipeline
|
||||
@@ -173,8 +159,6 @@ class PipelineService:
|
||||
|
||||
# Update extensions_preferences
|
||||
extensions_preferences = pipeline.extensions_preferences or {}
|
||||
extensions_preferences['enable_all_plugins'] = enable_all_plugins
|
||||
extensions_preferences['enable_all_mcp_servers'] = enable_all_mcp_servers
|
||||
extensions_preferences['plugins'] = bound_plugins
|
||||
if bound_mcp_servers is not None:
|
||||
extensions_preferences['mcp_servers'] = bound_mcp_servers
|
||||
|
||||
@@ -26,7 +26,6 @@ from ..api.http.service import knowledge as knowledge_service
|
||||
from ..api.http.service import mcp as mcp_service
|
||||
from ..api.http.service import apikey as apikey_service
|
||||
from ..api.http.service import webhook as webhook_service
|
||||
from ..api.http.service import external_kb as external_kb_service
|
||||
from ..discover import engine as discover_engine
|
||||
from ..storage import mgr as storagemgr
|
||||
from ..utils import logcache
|
||||
@@ -124,8 +123,6 @@ class Application:
|
||||
|
||||
knowledge_service: knowledge_service.KnowledgeService = None
|
||||
|
||||
external_kb_service: external_kb_service.ExternalKBService = None
|
||||
|
||||
mcp_service: mcp_service.MCPService = None
|
||||
|
||||
apikey_service: apikey_service.ApiKeyService = None
|
||||
|
||||
@@ -23,7 +23,6 @@ from ...api.http.service import knowledge as knowledge_service
|
||||
from ...api.http.service import mcp as mcp_service
|
||||
from ...api.http.service import apikey as apikey_service
|
||||
from ...api.http.service import webhook as webhook_service
|
||||
from ...api.http.service import external_kb as external_kb_service
|
||||
from ...discover import engine as discover_engine
|
||||
from ...storage import mgr as storagemgr
|
||||
from ...utils import logcache
|
||||
@@ -64,6 +63,14 @@ class BuildAppStage(stage.BootingStage):
|
||||
ap.persistence_mgr = persistence_mgr_inst
|
||||
await persistence_mgr_inst.initialize()
|
||||
|
||||
async def runtime_disconnect_callback(connector: plugin_connector.PluginRuntimeConnector) -> None:
|
||||
await asyncio.sleep(3)
|
||||
await plugin_connector_inst.initialize()
|
||||
|
||||
plugin_connector_inst = plugin_connector.PluginRuntimeConnector(ap, runtime_disconnect_callback)
|
||||
await plugin_connector_inst.initialize()
|
||||
ap.plugin_connector = plugin_connector_inst
|
||||
|
||||
cmd_mgr_inst = cmdmgr.CommandManager(ap)
|
||||
await cmd_mgr_inst.initialize()
|
||||
ap.cmd_mgr = cmd_mgr_inst
|
||||
@@ -123,9 +130,6 @@ class BuildAppStage(stage.BootingStage):
|
||||
knowledge_service_inst = knowledge_service.KnowledgeService(ap)
|
||||
ap.knowledge_service = knowledge_service_inst
|
||||
|
||||
external_kb_service_inst = external_kb_service.ExternalKBService(ap)
|
||||
ap.external_kb_service = external_kb_service_inst
|
||||
|
||||
mcp_service_inst = mcp_service.MCPService(ap)
|
||||
ap.mcp_service = mcp_service_inst
|
||||
|
||||
@@ -135,13 +139,5 @@ class BuildAppStage(stage.BootingStage):
|
||||
webhook_service_inst = webhook_service.WebhookService(ap)
|
||||
ap.webhook_service = webhook_service_inst
|
||||
|
||||
async def runtime_disconnect_callback(connector: plugin_connector.PluginRuntimeConnector) -> None:
|
||||
await asyncio.sleep(3)
|
||||
await plugin_connector_inst.initialize()
|
||||
|
||||
plugin_connector_inst = plugin_connector.PluginRuntimeConnector(ap, runtime_disconnect_callback)
|
||||
await plugin_connector_inst.initialize()
|
||||
ap.plugin_connector = plugin_connector_inst
|
||||
|
||||
ctrl = controller.Controller(ap)
|
||||
ap.ctrl = ctrl
|
||||
|
||||
@@ -22,11 +22,7 @@ class LegacyPipeline(Base):
|
||||
is_default = sqlalchemy.Column(sqlalchemy.Boolean, nullable=False, default=False)
|
||||
stages = sqlalchemy.Column(sqlalchemy.JSON, nullable=False)
|
||||
config = sqlalchemy.Column(sqlalchemy.JSON, nullable=False)
|
||||
extensions_preferences = sqlalchemy.Column(
|
||||
sqlalchemy.JSON,
|
||||
nullable=False,
|
||||
default={'enable_all_plugins': True, 'enable_all_mcp_servers': True, 'plugins': [], 'mcp_servers': []},
|
||||
)
|
||||
extensions_preferences = sqlalchemy.Column(sqlalchemy.JSON, nullable=False, default={})
|
||||
|
||||
|
||||
class PipelineRunRecord(Base):
|
||||
|
||||
@@ -1,6 +1,20 @@
|
||||
import sqlalchemy
|
||||
from .base import Base
|
||||
|
||||
# Base = declarative_base()
|
||||
# DATABASE_URL = os.getenv('DATABASE_URL', 'sqlite:///./rag_knowledge.db')
|
||||
# print("Using database URL:", DATABASE_URL)
|
||||
|
||||
|
||||
# engine = create_engine(DATABASE_URL, connect_args={'check_same_thread': False})
|
||||
|
||||
# SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
||||
|
||||
# def create_db_and_tables():
|
||||
# """Creates all database tables defined in the Base."""
|
||||
# Base.metadata.create_all(bind=engine)
|
||||
# print('Database tables created or already exist.')
|
||||
|
||||
|
||||
class KnowledgeBase(Base):
|
||||
__tablename__ = 'knowledge_bases'
|
||||
@@ -8,7 +22,6 @@ class KnowledgeBase(Base):
|
||||
name = sqlalchemy.Column(sqlalchemy.String, index=True)
|
||||
description = sqlalchemy.Column(sqlalchemy.Text)
|
||||
created_at = sqlalchemy.Column(sqlalchemy.DateTime, default=sqlalchemy.func.now())
|
||||
updated_at = sqlalchemy.Column(sqlalchemy.DateTime, default=sqlalchemy.func.now(), onupdate=sqlalchemy.func.now())
|
||||
embedding_model_uuid = sqlalchemy.Column(sqlalchemy.String, default='')
|
||||
top_k = sqlalchemy.Column(sqlalchemy.Integer, default=5)
|
||||
|
||||
@@ -30,13 +43,8 @@ class Chunk(Base):
|
||||
text = sqlalchemy.Column(sqlalchemy.Text)
|
||||
|
||||
|
||||
class ExternalKnowledgeBase(Base):
|
||||
__tablename__ = 'external_knowledge_bases'
|
||||
uuid = sqlalchemy.Column(sqlalchemy.String(255), primary_key=True, unique=True)
|
||||
name = sqlalchemy.Column(sqlalchemy.String, index=True)
|
||||
description = sqlalchemy.Column(sqlalchemy.Text)
|
||||
plugin_author = sqlalchemy.Column(sqlalchemy.String, nullable=False)
|
||||
plugin_name = sqlalchemy.Column(sqlalchemy.String, nullable=False)
|
||||
retriever_name = sqlalchemy.Column(sqlalchemy.String, nullable=False)
|
||||
retriever_config = sqlalchemy.Column(sqlalchemy.JSON, nullable=False, default={})
|
||||
created_at = sqlalchemy.Column(sqlalchemy.DateTime, default=sqlalchemy.func.now())
|
||||
# class Vector(Base):
|
||||
# __tablename__ = 'knowledge_base_vectors'
|
||||
# uuid = sqlalchemy.Column(sqlalchemy.String(255), primary_key=True, unique=True)
|
||||
# chunk_id = sqlalchemy.Column(sqlalchemy.String, nullable=True)
|
||||
# embedding = sqlalchemy.Column(sqlalchemy.LargeBinary)
|
||||
|
||||
0
src/langbot/pkg/entity/rag/__init__.py
Normal file
0
src/langbot/pkg/entity/rag/__init__.py
Normal file
13
src/langbot/pkg/entity/rag/retriever.py
Normal file
13
src/langbot/pkg/entity/rag/retriever.py
Normal file
@@ -0,0 +1,13 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import pydantic
|
||||
|
||||
from typing import Any
|
||||
|
||||
|
||||
class RetrieveResultEntry(pydantic.BaseModel):
|
||||
id: str
|
||||
|
||||
metadata: dict[str, Any]
|
||||
|
||||
distance: float
|
||||
@@ -7,7 +7,7 @@ from ...entity.persistence import pipeline as persistence_pipeline
|
||||
|
||||
@migration.migration_class(11)
|
||||
class DBMigrateDifyApiConfig(migration.DBMigration):
|
||||
"""Dify base prompt config"""
|
||||
"""Langflow API config"""
|
||||
|
||||
async def upgrade(self):
|
||||
"""Upgrade"""
|
||||
|
||||
@@ -1,47 +0,0 @@
|
||||
from .. import migration
|
||||
|
||||
import sqlalchemy
|
||||
|
||||
from ...entity.persistence import pipeline as persistence_pipeline
|
||||
|
||||
|
||||
@migration.migration_class(12)
|
||||
class DBMigratePipelineExtensionsEnableAll(migration.DBMigration):
|
||||
"""Pipeline extensions enable all"""
|
||||
|
||||
async def upgrade(self):
|
||||
"""Upgrade"""
|
||||
# read all pipelines
|
||||
pipelines = await self.ap.persistence_mgr.execute_async(sqlalchemy.select(persistence_pipeline.LegacyPipeline))
|
||||
|
||||
for pipeline in pipelines:
|
||||
serialized_pipeline = self.ap.persistence_mgr.serialize_model(persistence_pipeline.LegacyPipeline, pipeline)
|
||||
|
||||
extensions_preferences = serialized_pipeline['extensions_preferences']
|
||||
|
||||
if 'enable_all_plugins' not in extensions_preferences:
|
||||
if 'plugins' in extensions_preferences:
|
||||
extensions_preferences['enable_all_plugins'] = False
|
||||
else:
|
||||
extensions_preferences['enable_all_plugins'] = True
|
||||
extensions_preferences['plugins'] = []
|
||||
|
||||
if 'enable_all_mcp_servers' not in extensions_preferences:
|
||||
if 'mcp_servers' in extensions_preferences:
|
||||
extensions_preferences['enable_all_mcp_servers'] = False
|
||||
else:
|
||||
extensions_preferences['enable_all_mcp_servers'] = True
|
||||
extensions_preferences['mcp_servers'] = []
|
||||
|
||||
await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.update(persistence_pipeline.LegacyPipeline)
|
||||
.where(persistence_pipeline.LegacyPipeline.uuid == serialized_pipeline['uuid'])
|
||||
.values(
|
||||
extensions_preferences=extensions_preferences,
|
||||
for_version=self.ap.ver_mgr.get_current_version(),
|
||||
)
|
||||
)
|
||||
|
||||
async def downgrade(self):
|
||||
"""Downgrade"""
|
||||
pass
|
||||
@@ -1,49 +0,0 @@
|
||||
import sqlalchemy
|
||||
from .. import migration
|
||||
|
||||
|
||||
@migration.migration_class(13)
|
||||
class DBMigrateKnowledgeBaseUpdatedAt(migration.DBMigration):
|
||||
"""Add updated_at field to knowledge_bases table"""
|
||||
|
||||
async def upgrade(self):
|
||||
"""Upgrade"""
|
||||
# Get all column names from the table
|
||||
columns = []
|
||||
|
||||
if self.ap.persistence_mgr.db.name == 'postgresql':
|
||||
result = await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.text(
|
||||
"SELECT column_name FROM information_schema.columns WHERE table_name = 'knowledge_bases';"
|
||||
)
|
||||
)
|
||||
all_result = result.fetchall()
|
||||
columns = [row[0] for row in all_result]
|
||||
else:
|
||||
result = await self.ap.persistence_mgr.execute_async(sqlalchemy.text('PRAGMA table_info(knowledge_bases);'))
|
||||
all_result = result.fetchall()
|
||||
columns = [row[1] for row in all_result]
|
||||
|
||||
# Check and add updated_at column
|
||||
if 'updated_at' not in columns:
|
||||
if self.ap.persistence_mgr.db.name == 'postgresql':
|
||||
await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.text(
|
||||
'ALTER TABLE knowledge_bases ADD COLUMN updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP'
|
||||
)
|
||||
)
|
||||
else:
|
||||
# SQLite doesn't support DEFAULT CURRENT_TIMESTAMP in ALTER TABLE
|
||||
# Add column without default first
|
||||
await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.text('ALTER TABLE knowledge_bases ADD COLUMN updated_at DATETIME')
|
||||
)
|
||||
|
||||
# Set initial updated_at values to created_at for existing records
|
||||
await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.text('UPDATE knowledge_bases SET updated_at = created_at WHERE updated_at IS NULL')
|
||||
)
|
||||
|
||||
async def downgrade(self):
|
||||
"""Downgrade"""
|
||||
pass
|
||||
@@ -69,17 +69,11 @@ class RuntimePipeline:
|
||||
stage_containers: list[StageInstContainer]
|
||||
"""阶段实例容器"""
|
||||
|
||||
bound_plugins: list[str] | None
|
||||
"""绑定到此流水线的插件列表(格式:author/plugin_name),None表示启用所有"""
|
||||
bound_plugins: list[str]
|
||||
"""绑定到此流水线的插件列表(格式:author/plugin_name)"""
|
||||
|
||||
bound_mcp_servers: list[str] | None
|
||||
"""绑定到此流水线的MCP服务器列表(格式:uuid),None表示启用所有"""
|
||||
|
||||
enable_all_plugins: bool
|
||||
"""是否启用所有插件"""
|
||||
|
||||
enable_all_mcp_servers: bool
|
||||
"""是否启用所有MCP服务器"""
|
||||
bound_mcp_servers: list[str]
|
||||
"""绑定到此流水线的MCP服务器列表(格式:uuid)"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
@@ -93,22 +87,11 @@ class RuntimePipeline:
|
||||
|
||||
# Extract bound plugins and MCP servers from extensions_preferences
|
||||
extensions_prefs = pipeline_entity.extensions_preferences or {}
|
||||
self.enable_all_plugins = extensions_prefs.get('enable_all_plugins', True)
|
||||
self.enable_all_mcp_servers = extensions_prefs.get('enable_all_mcp_servers', True)
|
||||
plugin_list = extensions_prefs.get('plugins', [])
|
||||
self.bound_plugins = [f'{p["author"]}/{p["name"]}' for p in plugin_list] if plugin_list else []
|
||||
|
||||
if self.enable_all_plugins:
|
||||
# None indicates to use all available plugins
|
||||
self.bound_plugins = None
|
||||
else:
|
||||
plugin_list = extensions_prefs.get('plugins', [])
|
||||
self.bound_plugins = [f'{p["author"]}/{p["name"]}' for p in plugin_list] if plugin_list else []
|
||||
|
||||
if self.enable_all_mcp_servers:
|
||||
# None indicates to use all available MCP servers
|
||||
self.bound_mcp_servers = None
|
||||
else:
|
||||
mcp_server_list = extensions_prefs.get('mcp_servers', [])
|
||||
self.bound_mcp_servers = mcp_server_list if mcp_server_list else []
|
||||
mcp_server_list = extensions_prefs.get('mcp_servers', [])
|
||||
self.bound_mcp_servers = mcp_server_list if mcp_server_list else []
|
||||
|
||||
async def run(self, query: pipeline_query.Query):
|
||||
query.pipeline_config = self.pipeline_entity.config
|
||||
|
||||
@@ -99,7 +99,7 @@ class PreProcessor(stage.PipelineStage):
|
||||
content_list: list[provider_message.ContentElement] = []
|
||||
|
||||
plain_text = ''
|
||||
quote_msg = query.pipeline_config['trigger'].get('misc', '').get('combine-quote-message')
|
||||
qoute_msg = query.pipeline_config['trigger'].get('misc', '').get('combine-quote-message')
|
||||
|
||||
for me in query.message_chain:
|
||||
if isinstance(me, platform_message.Plain):
|
||||
@@ -114,7 +114,7 @@ class PreProcessor(stage.PipelineStage):
|
||||
elif isinstance(me, platform_message.File):
|
||||
# if me.url is not None:
|
||||
content_list.append(provider_message.ContentElement.from_file_url(me.url, me.name))
|
||||
elif isinstance(me, platform_message.Quote) and quote_msg:
|
||||
elif isinstance(me, platform_message.Quote) and qoute_msg:
|
||||
for msg in me.origin:
|
||||
if isinstance(msg, platform_message.Plain):
|
||||
content_list.append(provider_message.ContentElement.from_text(msg.text))
|
||||
|
||||
@@ -40,7 +40,6 @@ class ChatMessageHandler(handler.MessageHandler):
|
||||
launcher_id=query.launcher_id,
|
||||
sender_id=query.sender_id,
|
||||
text_message=str(query.message_chain),
|
||||
message_chain=query.message_chain,
|
||||
query=query,
|
||||
)
|
||||
|
||||
|
||||
@@ -96,7 +96,7 @@ class ResponseWrapper(stage.PipelineStage):
|
||||
if result.tool_calls is not None and len(result.tool_calls) > 0: # 有函数调用
|
||||
function_names = [tc.function.name for tc in result.tool_calls]
|
||||
|
||||
reply_text = f'Call {".".join(function_names)}...'
|
||||
reply_text = f'调用函数 {".".join(function_names)}...'
|
||||
|
||||
query.resp_message_chain.append(
|
||||
platform_message.MessageChain([platform_message.Plain(text=reply_text)])
|
||||
|
||||
@@ -156,7 +156,7 @@ class PlatformManager:
|
||||
|
||||
bots: list[RuntimeBot]
|
||||
|
||||
websocket_proxy_bot: RuntimeBot
|
||||
webchat_proxy_bot: RuntimeBot
|
||||
|
||||
adapter_components: list[engine.Component]
|
||||
|
||||
@@ -178,29 +178,31 @@ class PlatformManager:
|
||||
adapter_dict[component.metadata.name] = component.get_python_component_class()
|
||||
self.adapter_dict = adapter_dict
|
||||
|
||||
# initialize websocket adapter
|
||||
websocket_adapter_class = self.adapter_dict['websocket']
|
||||
websocket_logger = EventLogger(name='websocket-adapter', ap=self.ap)
|
||||
websocket_adapter_inst = websocket_adapter_class(
|
||||
webchat_adapter_class = self.adapter_dict['webchat']
|
||||
|
||||
# initialize webchat adapter
|
||||
webchat_logger = EventLogger(name='webchat-adapter', ap=self.ap)
|
||||
webchat_adapter_inst = webchat_adapter_class(
|
||||
{},
|
||||
websocket_logger,
|
||||
webchat_logger,
|
||||
ap=self.ap,
|
||||
is_stream=False,
|
||||
)
|
||||
|
||||
self.websocket_proxy_bot = RuntimeBot(
|
||||
self.webchat_proxy_bot = RuntimeBot(
|
||||
ap=self.ap,
|
||||
bot_entity=persistence_bot.Bot(
|
||||
uuid='websocket-proxy-bot',
|
||||
name='WebSocket',
|
||||
uuid='webchat-proxy-bot',
|
||||
name='WebChat',
|
||||
description='',
|
||||
adapter='websocket',
|
||||
adapter='webchat',
|
||||
adapter_config={},
|
||||
enable=True,
|
||||
),
|
||||
adapter=websocket_adapter_inst,
|
||||
logger=websocket_logger,
|
||||
adapter=webchat_adapter_inst,
|
||||
logger=webchat_logger,
|
||||
)
|
||||
await self.websocket_proxy_bot.initialize()
|
||||
await self.webchat_proxy_bot.initialize()
|
||||
|
||||
await self.load_bots_from_db()
|
||||
|
||||
@@ -245,10 +247,6 @@ class PlatformManager:
|
||||
logger,
|
||||
)
|
||||
|
||||
# 如果 adapter 支持 set_bot_uuid 方法,设置 bot_uuid(用于统一 webhook)
|
||||
if hasattr(adapter_inst, 'set_bot_uuid'):
|
||||
adapter_inst.set_bot_uuid(bot_entity.uuid)
|
||||
|
||||
runtime_bot = RuntimeBot(ap=self.ap, bot_entity=bot_entity, adapter=adapter_inst, logger=logger)
|
||||
|
||||
await runtime_bot.initialize()
|
||||
@@ -273,7 +271,7 @@ class PlatformManager:
|
||||
|
||||
def get_available_adapters_info(self) -> list[dict]:
|
||||
return [
|
||||
component.to_plain_dict() for component in self.adapter_components if component.metadata.name != 'websocket'
|
||||
component.to_plain_dict() for component in self.adapter_components if component.metadata.name != 'webchat'
|
||||
]
|
||||
|
||||
def get_available_adapter_info_by_name(self, name: str) -> dict | None:
|
||||
@@ -290,7 +288,7 @@ class PlatformManager:
|
||||
|
||||
async def run(self):
|
||||
# This method will only be called when the application launching
|
||||
await self.websocket_proxy_bot.run()
|
||||
await self.webchat_proxy_bot.run()
|
||||
|
||||
for bot in self.bots:
|
||||
if bot.enable:
|
||||
|
||||
@@ -209,7 +209,7 @@ class AiocqhttpMessageConverter(abstract_platform_adapter.AbstractMessageConvert
|
||||
elif msg_data['type'] == 'text':
|
||||
reply_list.append(platform_message.Plain(text=msg_data['data']['text']))
|
||||
|
||||
elif msg_data['type'] == 'forward': # 这里来应该传入转发消息组,暂时传入Quote
|
||||
elif msg_data['type'] == 'forward': # 这里来应该传入转发消息组,暂时传入qoute
|
||||
for forward_msg_datas in msg_data['data']['content']:
|
||||
for forward_msg_data in forward_msg_datas['message']:
|
||||
await process_message_data(forward_msg_data, reply_list)
|
||||
@@ -259,7 +259,7 @@ class AiocqhttpMessageConverter(abstract_platform_adapter.AbstractMessageConvert
|
||||
# await process_message_data(msg_data, yiri_msg_list)
|
||||
pass
|
||||
|
||||
elif msg.type == 'reply': # 此处处理引用消息传入Quote
|
||||
elif msg.type == 'reply': # 此处处理引用消息传入Qoute
|
||||
msg_datas = await bot.get_msg(message_id=msg.data['id'])
|
||||
|
||||
for msg_data in msg_datas['message']:
|
||||
|
||||
@@ -12,41 +12,17 @@ from langbot.pkg.platform.logger import EventLogger
|
||||
|
||||
class DingTalkMessageConverter(abstract_platform_adapter.AbstractMessageConverter):
|
||||
@staticmethod
|
||||
def _format_image_as_markdown(msg: platform_message.Image) -> str:
|
||||
"""Convert an Image message to Markdown format for DingTalk."""
|
||||
if msg.url:
|
||||
return f'\n\n'
|
||||
elif msg.base64:
|
||||
# For base64 images, try to include them as data URIs
|
||||
# DingTalk may have limited support for base64 in markdown
|
||||
if msg.base64.startswith('data:'):
|
||||
return f'\n\n'
|
||||
else:
|
||||
return f'\n\n'
|
||||
return ''
|
||||
|
||||
@staticmethod
|
||||
async def yiri2target(message_chain: platform_message.MessageChain, markdown_enabled: bool = True):
|
||||
async def yiri2target(message_chain: platform_message.MessageChain):
|
||||
content = ''
|
||||
at = False
|
||||
for msg in message_chain:
|
||||
if type(msg) is platform_message.At:
|
||||
at = True
|
||||
elif type(msg) is platform_message.Plain:
|
||||
if type(msg) is platform_message.Plain:
|
||||
content += msg.text
|
||||
elif type(msg) is platform_message.Image:
|
||||
# DingTalk supports markdown images when markdown_card is enabled
|
||||
# When markdown is disabled, images cannot be rendered in plain text mode
|
||||
if markdown_enabled:
|
||||
content += DingTalkMessageConverter._format_image_as_markdown(msg)
|
||||
# Note: When markdown_enabled is False, images are not included
|
||||
# as DingTalk plain text messages don't support image embedding
|
||||
elif type(msg) is platform_message.Forward:
|
||||
if type(msg) is platform_message.Forward:
|
||||
for node in msg.node_list:
|
||||
forwarded_content, _ = await DingTalkMessageConverter.yiri2target(
|
||||
node.message_chain, markdown_enabled
|
||||
)
|
||||
content += forwarded_content
|
||||
content += (await DingTalkMessageConverter.yiri2target(node.message_chain))[0]
|
||||
return content, at
|
||||
|
||||
@staticmethod
|
||||
@@ -181,8 +157,7 @@ class DingTalkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
||||
)
|
||||
incoming_message = event.incoming_message
|
||||
|
||||
markdown_enabled = self.config.get('markdown_card', False)
|
||||
content, at = await DingTalkMessageConverter.yiri2target(message, markdown_enabled)
|
||||
content, at = await DingTalkMessageConverter.yiri2target(message)
|
||||
await self.bot.send_message(content, incoming_message, at)
|
||||
|
||||
async def reply_message_chunk(
|
||||
@@ -203,8 +178,7 @@ class DingTalkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
||||
msg_seq = bot_message.msg_sequence
|
||||
|
||||
if (msg_seq - 1) % 8 == 0 or is_final:
|
||||
markdown_enabled = self.config.get('markdown_card', False)
|
||||
content, at = await DingTalkMessageConverter.yiri2target(message, markdown_enabled)
|
||||
content, at = await DingTalkMessageConverter.yiri2target(message)
|
||||
|
||||
card_instance, card_instance_id = self.card_instance_id_dict[message_id]
|
||||
if not content and bot_message.content:
|
||||
@@ -217,8 +191,7 @@ class DingTalkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
||||
self.card_instance_id_dict.pop(message_id) # 消息回复结束之后删除卡片实例id
|
||||
|
||||
async def send_message(self, target_type: str, target_id: str, message: platform_message.MessageChain):
|
||||
markdown_enabled = self.config.get('markdown_card', False)
|
||||
content, _ = await DingTalkMessageConverter.yiri2target(message, markdown_enabled)
|
||||
content = await DingTalkMessageConverter.yiri2target(message)
|
||||
if target_type == 'person':
|
||||
await self.bot.send_proactive_message_to_one(target_id, content)
|
||||
if target_type == 'group':
|
||||
|
||||
@@ -54,179 +54,122 @@ class AESCipher(object):
|
||||
|
||||
|
||||
class LarkMessageConverter(abstract_platform_adapter.AbstractMessageConverter):
|
||||
@staticmethod
|
||||
async def upload_image_to_lark(
|
||||
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."""
|
||||
image_bytes = None
|
||||
|
||||
if msg.base64:
|
||||
try:
|
||||
# Remove data URL prefix if present
|
||||
base64_data = msg.base64
|
||||
if base64_data.startswith('data:'):
|
||||
base64_data = base64_data.split(',', 1)[1]
|
||||
image_bytes = base64.b64decode(base64_data)
|
||||
except Exception as e:
|
||||
print(f'Failed to decode base64 image: {e}')
|
||||
traceback.print_exc()
|
||||
return None
|
||||
elif msg.url:
|
||||
try:
|
||||
async with aiohttp.ClientSession() as session:
|
||||
async with session.get(msg.url) as response:
|
||||
if response.status == 200:
|
||||
image_bytes = await response.read()
|
||||
else:
|
||||
print(f'Failed to download image from {msg.url}: HTTP {response.status}')
|
||||
return None
|
||||
except Exception as e:
|
||||
print(f'Failed to download image from {msg.url}: {e}')
|
||||
traceback.print_exc()
|
||||
return None
|
||||
elif msg.path:
|
||||
try:
|
||||
with open(msg.path, 'rb') as f:
|
||||
image_bytes = f.read()
|
||||
except Exception as e:
|
||||
print(f'Failed to read image from path {msg.path}: {e}')
|
||||
traceback.print_exc()
|
||||
return 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})')
|
||||
return None
|
||||
|
||||
try:
|
||||
# Create a temporary file to store the image bytes
|
||||
import tempfile
|
||||
import os
|
||||
|
||||
with tempfile.NamedTemporaryFile(delete=False) as temp_file:
|
||||
temp_file.write(image_bytes)
|
||||
temp_file.flush()
|
||||
temp_file_path = temp_file.name
|
||||
|
||||
try:
|
||||
# Create image request using the temporary file
|
||||
request = (
|
||||
CreateImageRequest.builder()
|
||||
.request_body(
|
||||
CreateImageRequestBody.builder()
|
||||
.image_type('message')
|
||||
.image(open(temp_file_path, 'rb'))
|
||||
.build()
|
||||
)
|
||||
.build()
|
||||
)
|
||||
|
||||
response = await api_client.im.v1.image.acreate(request)
|
||||
|
||||
if not response.success():
|
||||
print(
|
||||
f'client.im.v1.image.create failed, code: {response.code}, msg: {response.msg}, log_id: {response.get_log_id()}'
|
||||
)
|
||||
return None
|
||||
|
||||
return response.data.image_key
|
||||
finally:
|
||||
# Clean up the temporary file
|
||||
os.unlink(temp_file_path)
|
||||
except Exception as e:
|
||||
print(f'Failed to upload image to Lark: {e}')
|
||||
traceback.print_exc()
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
async def yiri2target(
|
||||
message_chain: platform_message.MessageChain, api_client: lark_oapi.Client
|
||||
) -> typing.Tuple[list, list]:
|
||||
"""Convert message chain to Lark format.
|
||||
|
||||
Returns:
|
||||
Tuple of (text_elements, image_keys):
|
||||
- text_elements: List of paragraphs for post message format
|
||||
- image_keys: List of image_key strings for separate image messages
|
||||
"""
|
||||
) -> typing.Tuple[list]:
|
||||
message_elements = []
|
||||
image_keys = []
|
||||
pending_paragraph = []
|
||||
|
||||
# Regex pattern to match Markdown image syntax: 
|
||||
markdown_image_pattern = re.compile(r'!\[([^\]]*)\]\(([^)]+)\)')
|
||||
|
||||
async def process_text_with_images(text: str) -> typing.Tuple[str, list]:
|
||||
"""Extract Markdown images from text and return cleaned text + image URLs."""
|
||||
extracted_urls = []
|
||||
|
||||
# Find all Markdown images
|
||||
matches = list(markdown_image_pattern.finditer(text))
|
||||
if not matches:
|
||||
return text, []
|
||||
|
||||
# Extract URLs and remove image syntax from text
|
||||
cleaned_text = text
|
||||
for match in reversed(matches): # Reverse to maintain correct positions
|
||||
url = match.group(2)
|
||||
extracted_urls.insert(0, url) # Insert at beginning since we're going in reverse
|
||||
# Replace image syntax with empty string or a placeholder
|
||||
cleaned_text = cleaned_text[:match.start()] + cleaned_text[match.end():]
|
||||
|
||||
# Clean up multiple consecutive newlines that might result from removing images
|
||||
cleaned_text = re.sub(r'\n{3,}', '\n\n', cleaned_text)
|
||||
cleaned_text = cleaned_text.strip()
|
||||
|
||||
return cleaned_text, extracted_urls
|
||||
|
||||
for msg in message_chain:
|
||||
if isinstance(msg, platform_message.Plain):
|
||||
# Ensure text is valid UTF-8
|
||||
try:
|
||||
text = msg.text.encode('utf-8').decode('utf-8')
|
||||
pending_paragraph.append({'tag': 'md', 'text': text})
|
||||
except UnicodeError:
|
||||
# If text is not valid UTF-8, try to decode with other encodings
|
||||
try:
|
||||
text = msg.text.encode('latin1').decode('utf-8')
|
||||
pending_paragraph.append({'tag': 'md', 'text': text})
|
||||
except UnicodeError:
|
||||
# If still fails, replace invalid characters
|
||||
text = msg.text.encode('utf-8', errors='replace').decode('utf-8')
|
||||
|
||||
# Check for and extract Markdown images from text
|
||||
cleaned_text, extracted_urls = await process_text_with_images(text)
|
||||
|
||||
# Add cleaned text if not empty
|
||||
if cleaned_text:
|
||||
pending_paragraph.append({'tag': 'md', 'text': cleaned_text})
|
||||
|
||||
# Process extracted image URLs
|
||||
for url in extracted_urls:
|
||||
# Create a temporary Image message to upload
|
||||
temp_image = platform_message.Image(url=url)
|
||||
image_key = await LarkMessageConverter.upload_image_to_lark(temp_image, api_client)
|
||||
if image_key:
|
||||
image_keys.append(image_key)
|
||||
|
||||
pending_paragraph.append({'tag': 'md', 'text': text})
|
||||
elif isinstance(msg, platform_message.At):
|
||||
pending_paragraph.append({'tag': 'at', 'user_id': msg.target, 'style': []})
|
||||
elif isinstance(msg, platform_message.AtAll):
|
||||
pending_paragraph.append({'tag': 'at', 'user_id': 'all', 'style': []})
|
||||
elif isinstance(msg, platform_message.Image):
|
||||
# Upload image and get image_key
|
||||
image_key = await LarkMessageConverter.upload_image_to_lark(msg, api_client)
|
||||
if image_key:
|
||||
# Store image_key for separate image message
|
||||
image_keys.append(image_key)
|
||||
image_bytes = None
|
||||
|
||||
if msg.base64:
|
||||
try:
|
||||
# Remove data URL prefix if present
|
||||
if msg.base64.startswith('data:'):
|
||||
msg.base64 = msg.base64.split(',', 1)[1]
|
||||
image_bytes = base64.b64decode(msg.base64)
|
||||
except Exception:
|
||||
traceback.print_exc()
|
||||
continue
|
||||
elif msg.url:
|
||||
try:
|
||||
async with aiohttp.ClientSession() as session:
|
||||
async with session.get(msg.url) as response:
|
||||
if response.status == 200:
|
||||
image_bytes = await response.read()
|
||||
else:
|
||||
traceback.print_exc()
|
||||
continue
|
||||
except Exception:
|
||||
traceback.print_exc()
|
||||
continue
|
||||
elif msg.path:
|
||||
try:
|
||||
with open(msg.path, 'rb') as f:
|
||||
image_bytes = f.read()
|
||||
except Exception:
|
||||
traceback.print_exc()
|
||||
continue
|
||||
|
||||
if image_bytes is None:
|
||||
continue
|
||||
|
||||
try:
|
||||
# Create a temporary file to store the image bytes
|
||||
import tempfile
|
||||
|
||||
with tempfile.NamedTemporaryFile(delete=False) as temp_file:
|
||||
temp_file.write(image_bytes)
|
||||
temp_file.flush()
|
||||
|
||||
# Create image request using the temporary file
|
||||
request = (
|
||||
CreateImageRequest.builder()
|
||||
.request_body(
|
||||
CreateImageRequestBody.builder()
|
||||
.image_type('message')
|
||||
.image(open(temp_file.name, 'rb'))
|
||||
.build()
|
||||
)
|
||||
.build()
|
||||
)
|
||||
|
||||
response = await api_client.im.v1.image.acreate(request)
|
||||
|
||||
if not response.success():
|
||||
raise Exception(
|
||||
f'client.im.v1.image.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)}'
|
||||
)
|
||||
|
||||
image_key = response.data.image_key
|
||||
|
||||
message_elements.append(pending_paragraph)
|
||||
message_elements.append(
|
||||
[
|
||||
{
|
||||
'tag': 'img',
|
||||
'image_key': image_key,
|
||||
}
|
||||
]
|
||||
)
|
||||
pending_paragraph = []
|
||||
except Exception:
|
||||
traceback.print_exc()
|
||||
continue
|
||||
finally:
|
||||
# Clean up the temporary file
|
||||
import os
|
||||
|
||||
if 'temp_file' in locals():
|
||||
os.unlink(temp_file.name)
|
||||
elif isinstance(msg, platform_message.Forward):
|
||||
for node in msg.node_list:
|
||||
sub_elements, sub_image_keys = await LarkMessageConverter.yiri2target(
|
||||
node.message_chain, api_client
|
||||
)
|
||||
message_elements.extend(sub_elements)
|
||||
image_keys.extend(sub_image_keys)
|
||||
message_elements.extend(await LarkMessageConverter.yiri2target(node.message_chain, api_client))
|
||||
|
||||
if pending_paragraph:
|
||||
message_elements.append(pending_paragraph)
|
||||
|
||||
return message_elements, image_keys
|
||||
return message_elements
|
||||
|
||||
@staticmethod
|
||||
async def target2yiri(
|
||||
@@ -724,63 +667,36 @@ class LarkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
||||
):
|
||||
# 不再需要了,因为message_id已经被包含到message_chain中
|
||||
# lark_event = await self.event_converter.yiri2target(message_source)
|
||||
text_elements, image_keys = await self.message_converter.yiri2target(message, self.api_client)
|
||||
lark_message = await self.message_converter.yiri2target(message, self.api_client)
|
||||
|
||||
# Send text message if there are text elements
|
||||
if text_elements:
|
||||
final_content = {
|
||||
'zh_Hans': {
|
||||
'title': '',
|
||||
'content': text_elements,
|
||||
},
|
||||
}
|
||||
final_content = {
|
||||
'zh_Hans': {
|
||||
'title': '',
|
||||
'content': lark_message,
|
||||
},
|
||||
}
|
||||
|
||||
request: ReplyMessageRequest = (
|
||||
ReplyMessageRequest.builder()
|
||||
.message_id(message_source.message_chain.message_id)
|
||||
.request_body(
|
||||
ReplyMessageRequestBody.builder()
|
||||
.content(json.dumps(final_content))
|
||||
.msg_type('post')
|
||||
.reply_in_thread(False)
|
||||
.uuid(str(uuid.uuid4()))
|
||||
.build()
|
||||
)
|
||||
request: ReplyMessageRequest = (
|
||||
ReplyMessageRequest.builder()
|
||||
.message_id(message_source.message_chain.message_id)
|
||||
.request_body(
|
||||
ReplyMessageRequestBody.builder()
|
||||
.content(json.dumps(final_content))
|
||||
.msg_type('post')
|
||||
.reply_in_thread(False)
|
||||
.uuid(str(uuid.uuid4()))
|
||||
.build()
|
||||
)
|
||||
.build()
|
||||
)
|
||||
|
||||
response: ReplyMessageResponse = await self.api_client.im.v1.message.areply(request)
|
||||
response: ReplyMessageResponse = await self.api_client.im.v1.message.areply(request)
|
||||
|
||||
if not response.success():
|
||||
raise Exception(
|
||||
f'client.im.v1.message.reply 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)}'
|
||||
)
|
||||
|
||||
# Send image messages separately using msg_type='image'
|
||||
for image_key in image_keys:
|
||||
image_content = json.dumps({'image_key': image_key})
|
||||
|
||||
request: ReplyMessageRequest = (
|
||||
ReplyMessageRequest.builder()
|
||||
.message_id(message_source.message_chain.message_id)
|
||||
.request_body(
|
||||
ReplyMessageRequestBody.builder()
|
||||
.content(image_content)
|
||||
.msg_type('image')
|
||||
.reply_in_thread(False)
|
||||
.uuid(str(uuid.uuid4()))
|
||||
.build()
|
||||
)
|
||||
.build()
|
||||
if not response.success():
|
||||
raise Exception(
|
||||
f'client.im.v1.message.reply 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)}'
|
||||
)
|
||||
|
||||
response: ReplyMessageResponse = await self.api_client.im.v1.message.areply(request)
|
||||
|
||||
if not response.success():
|
||||
raise Exception(
|
||||
f'client.im.v1.message.reply (image) 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)}'
|
||||
)
|
||||
|
||||
async def reply_message_chunk(
|
||||
self,
|
||||
message_source: platform_events.MessageEvent,
|
||||
@@ -796,15 +712,14 @@ class LarkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
||||
message_id = bot_message.resp_message_id
|
||||
msg_seq = bot_message.msg_sequence
|
||||
if msg_seq % 8 == 0 or is_final:
|
||||
text_elements, image_keys = await self.message_converter.yiri2target(message, self.api_client)
|
||||
lark_message = await self.message_converter.yiri2target(message, self.api_client)
|
||||
|
||||
text_message = ''
|
||||
if text_elements:
|
||||
for ele in text_elements[0]:
|
||||
if ele['tag'] == 'text':
|
||||
text_message += ele['text']
|
||||
elif ele['tag'] == 'md':
|
||||
text_message += ele['text']
|
||||
for ele in lark_message[0]:
|
||||
if ele['tag'] == 'text':
|
||||
text_message += ele['text']
|
||||
elif ele['tag'] == 'md':
|
||||
text_message += ele['text']
|
||||
|
||||
# content = {
|
||||
# 'type': 'card_json',
|
||||
|
||||
@@ -41,9 +41,10 @@ class LINEMessageConverter(abstract_platform_adapter.AbstractMessageConverter):
|
||||
elif isinstance(component, platform_message.Plain):
|
||||
content_list.append({'type': 'text', 'content': component.text})
|
||||
elif isinstance(component, platform_message.Image):
|
||||
# Only add image if it has a valid URL
|
||||
if component.url:
|
||||
content_list.append({'type': 'image', 'image': component.url})
|
||||
if not component.url:
|
||||
pass
|
||||
content_list.append({'type': 'image', 'image': component.url})
|
||||
|
||||
elif isinstance(component, platform_message.Voice):
|
||||
content_list.append({'type': 'voice', 'url': component.url, 'length': component.length})
|
||||
|
||||
@@ -121,7 +122,6 @@ class LINEEventConverter(abstract_platform_adapter.AbstractEventConverter):
|
||||
class LINEAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
||||
bot: MessagingApi
|
||||
api_client: ApiClient
|
||||
parser: WebhookParser
|
||||
|
||||
bot_account_id: str # 用于在流水线中识别at是否是本bot,直接以bot_name作为标识
|
||||
message_converter: LINEMessageConverter
|
||||
@@ -133,7 +133,7 @@ class LINEAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
||||
]
|
||||
|
||||
config: dict
|
||||
bot_uuid: str = None
|
||||
quart_app: quart.Quart
|
||||
|
||||
card_id_dict: dict[str, str] # 消息id到卡片id的映射,便于创建卡片后的发送消息到指定卡片
|
||||
|
||||
@@ -150,6 +150,7 @@ class LINEAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
||||
super().__init__(
|
||||
config=config,
|
||||
logger=logger,
|
||||
quart_app=quart.Quart(__name__),
|
||||
listeners={},
|
||||
card_id_dict={},
|
||||
seq=1,
|
||||
@@ -163,6 +164,29 @@ class LINEAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
||||
bot_account_id=bot_account_id,
|
||||
)
|
||||
|
||||
@self.quart_app.route('/line/callback', methods=['POST'])
|
||||
async def line_callback():
|
||||
try:
|
||||
signature = quart.request.headers.get('X-Line-Signature')
|
||||
body = await quart.request.get_data(as_text=True)
|
||||
events = parser.parse(body, signature) # 解密解析消息
|
||||
|
||||
try:
|
||||
# print(events)
|
||||
lb_event = await self.event_converter.target2yiri(events[0], self.api_client)
|
||||
if lb_event.__class__ in self.listeners:
|
||||
await self.listeners[lb_event.__class__](lb_event, self)
|
||||
except InvalidSignatureError:
|
||||
self.logger.info(
|
||||
f'Invalid signature. Please check your channel access token/channel secret.{traceback.format_exc()}'
|
||||
)
|
||||
return quart.Response('Invalid signature', status=400)
|
||||
|
||||
return {'code': 200, 'message': 'ok'}
|
||||
except Exception:
|
||||
await self.logger.error(f'Error in LINE callback: {traceback.format_exc()}')
|
||||
return {'code': 500, 'message': 'error'}
|
||||
|
||||
async def send_message(self, target_type: str, target_id: str, message: platform_message.MessageChain):
|
||||
pass
|
||||
|
||||
@@ -183,12 +207,10 @@ class LINEAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
||||
)
|
||||
)
|
||||
elif content['type'] == 'image':
|
||||
# LINE ImageMessage requires original_content_url and preview_image_url
|
||||
image_url = content['image']
|
||||
self.bot.reply_message_with_http_info(
|
||||
ReplyMessageRequest(
|
||||
reply_token=message_source.source_platform_object.reply_token,
|
||||
messages=[ImageMessage(original_content_url=image_url, preview_image_url=image_url)],
|
||||
messages=[ImageMessage(text=content['content'])],
|
||||
)
|
||||
)
|
||||
|
||||
@@ -213,73 +235,18 @@ class LINEAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
||||
):
|
||||
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:
|
||||
signature = request.headers.get('X-Line-Signature')
|
||||
body = await request.get_data(as_text=True)
|
||||
|
||||
# Check if signature header exists
|
||||
if not signature:
|
||||
await self.logger.warning('Missing X-Line-Signature header')
|
||||
return quart.Response('Missing X-Line-Signature header', status=400)
|
||||
|
||||
try:
|
||||
events = self.parser.parse(body, signature) # 解密解析消息
|
||||
except InvalidSignatureError:
|
||||
await self.logger.info(
|
||||
f'Invalid signature. Please check your channel access token/channel secret.{traceback.format_exc()}'
|
||||
)
|
||||
return quart.Response('Invalid signature', status=400)
|
||||
|
||||
# 处理事件
|
||||
if events and len(events) > 0:
|
||||
lb_event = await self.event_converter.target2yiri(events[0], self.api_client)
|
||||
if lb_event.__class__ in self.listeners:
|
||||
await self.listeners[lb_event.__class__](lb_event, self)
|
||||
|
||||
return {'code': 200, 'message': 'ok'}
|
||||
except Exception:
|
||||
await self.logger.error(f'Error in LINE callback: {traceback.format_exc()}')
|
||||
print(traceback.format_exc())
|
||||
return {'code': 500, 'message': 'error'}
|
||||
|
||||
async def run_async(self):
|
||||
# 统一 webhook 模式下,不启动独立的 Quart 应用
|
||||
# 保持运行但不启动独立端口
|
||||
port = self.config['port']
|
||||
|
||||
# 打印 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 shutdown_trigger_placeholder():
|
||||
while True:
|
||||
await asyncio.sleep(1)
|
||||
|
||||
await keep_alive()
|
||||
await self.quart_app.run_task(
|
||||
host='0.0.0.0',
|
||||
port=port,
|
||||
shutdown_trigger=shutdown_trigger_placeholder,
|
||||
)
|
||||
|
||||
async def kill(self) -> bool:
|
||||
pass
|
||||
|
||||
@@ -22,6 +22,18 @@ spec:
|
||||
type: string
|
||||
required: true
|
||||
default: ""
|
||||
- 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 端口
|
||||
ja_JP: Webhookポートを入力してください
|
||||
zh_Hant: 請填寫 Webhook 端口
|
||||
type: integer
|
||||
required: true
|
||||
default: 2287
|
||||
- name: channel_secret
|
||||
label:
|
||||
en_US: Channel secret
|
||||
|
||||
@@ -11,7 +11,7 @@ from langbot.libs.official_account_api.api import OAClientForLongerResponse
|
||||
import langbot_plugin.api.entities.builtin.platform.entities as platform_entities
|
||||
import langbot_plugin.api.entities.builtin.platform.message as platform_message
|
||||
import langbot_plugin.api.entities.builtin.platform.events as platform_events
|
||||
from ..logger import EventLogger
|
||||
from langbot.pkg.platform.logger import EventLogger
|
||||
|
||||
|
||||
class OAMessageConverter(abstract_platform_adapter.AbstractMessageConverter):
|
||||
@@ -58,16 +58,13 @@ class OfficialAccountAdapter(abstract_platform_adapter.AbstractMessagePlatformAd
|
||||
message_converter: OAMessageConverter = OAMessageConverter()
|
||||
event_converter: OAEventConverter = OAEventConverter()
|
||||
bot: typing.Union[OAClient, OAClientForLongerResponse] = pydantic.Field(exclude=True)
|
||||
bot_uuid: str = None
|
||||
|
||||
def __init__(self, config: dict, logger: EventLogger):
|
||||
# 校验必填项
|
||||
required_keys = ['token', 'EncodingAESKey', 'AppSecret', 'AppID', 'Mode']
|
||||
missing_keys = [k for k in required_keys if k not in config]
|
||||
if missing_keys:
|
||||
raise Exception(f'OfficialAccount 缺少配置项: {missing_keys}')
|
||||
|
||||
# 创建运行时 bot 对象,始终使用统一 webhook 模式
|
||||
if config['Mode'] == 'drop':
|
||||
bot = OAClient(
|
||||
token=config['token'],
|
||||
@@ -75,7 +72,6 @@ class OfficialAccountAdapter(abstract_platform_adapter.AbstractMessagePlatformAd
|
||||
Appsecret=config['AppSecret'],
|
||||
AppID=config['AppID'],
|
||||
logger=logger,
|
||||
unified_mode=True,
|
||||
)
|
||||
elif config['Mode'] == 'passive':
|
||||
bot = OAClientForLongerResponse(
|
||||
@@ -85,7 +81,6 @@ class OfficialAccountAdapter(abstract_platform_adapter.AbstractMessagePlatformAd
|
||||
AppID=config['AppID'],
|
||||
LoadingMessage=config.get('LoadingMessage', ''),
|
||||
logger=logger,
|
||||
unified_mode=True,
|
||||
)
|
||||
else:
|
||||
raise KeyError('请设置微信公众号通信模式')
|
||||
@@ -134,46 +129,16 @@ class OfficialAccountAdapter(abstract_platform_adapter.AbstractMessagePlatformAd
|
||||
elif event_type == platform_events.GroupMessage:
|
||||
pass
|
||||
|
||||
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:
|
||||
响应数据
|
||||
"""
|
||||
return await self.bot.handle_unified_webhook(request)
|
||||
|
||||
async def run_async(self):
|
||||
# 统一 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 shutdown_trigger_placeholder():
|
||||
while True:
|
||||
await asyncio.sleep(1)
|
||||
|
||||
await keep_alive()
|
||||
await self.bot.run_task(
|
||||
host=self.config['host'],
|
||||
port=self.config['port'],
|
||||
shutdown_trigger=shutdown_trigger_placeholder,
|
||||
)
|
||||
|
||||
async def kill(self) -> bool:
|
||||
return False
|
||||
|
||||
@@ -53,6 +53,23 @@ spec:
|
||||
type: string
|
||||
required: true
|
||||
default: "AI正在思考中,请发送任意内容获取回复。"
|
||||
- name: host
|
||||
label:
|
||||
en_US: Host
|
||||
zh_Hans: 监听主机
|
||||
description:
|
||||
en_US: The host that Official Account listens on for Webhook connections.
|
||||
zh_Hans: 微信公众号监听的主机,除非你知道自己在做什么,否则请写 0.0.0.0
|
||||
type: string
|
||||
required: true
|
||||
default: 0.0.0.0
|
||||
- name: port
|
||||
label:
|
||||
en_US: Port
|
||||
zh_Hans: 监听端口
|
||||
type: integer
|
||||
required: true
|
||||
default: 2287
|
||||
execution:
|
||||
python:
|
||||
path: ./officialaccount.py
|
||||
|
||||
@@ -11,8 +11,8 @@ import langbot_plugin.api.entities.builtin.platform.events as platform_events
|
||||
import langbot_plugin.api.entities.builtin.platform.entities as platform_entities
|
||||
from langbot.libs.qq_official_api.api import QQOfficialClient
|
||||
from langbot.libs.qq_official_api.qqofficialevent import QQOfficialEvent
|
||||
from ...utils import image
|
||||
from ..logger import EventLogger
|
||||
from langbot.pkg.utils import image
|
||||
from langbot.pkg.platform.logger import EventLogger
|
||||
|
||||
|
||||
class QQOfficialMessageConverter(abstract_platform_adapter.AbstractMessageConverter):
|
||||
@@ -134,14 +134,11 @@ class QQOfficialAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter
|
||||
bot: QQOfficialClient
|
||||
config: dict
|
||||
bot_account_id: str
|
||||
bot_uuid: str = None
|
||||
message_converter: QQOfficialMessageConverter = QQOfficialMessageConverter()
|
||||
event_converter: QQOfficialEventConverter = QQOfficialEventConverter()
|
||||
|
||||
def __init__(self, config: dict, logger: EventLogger):
|
||||
bot = QQOfficialClient(
|
||||
app_id=config['appid'], secret=config['secret'], token=config['token'], logger=logger, unified_mode=True
|
||||
)
|
||||
bot = QQOfficialClient(app_id=config['appid'], secret=config['secret'], token=config['token'], logger=logger)
|
||||
|
||||
super().__init__(
|
||||
config=config,
|
||||
@@ -226,46 +223,16 @@ class QQOfficialAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter
|
||||
self.bot.on_message('GROUP_AT_MESSAGE_CREATE')(on_message)
|
||||
self.bot.on_message('AT_MESSAGE_CREATE')(on_message)
|
||||
|
||||
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:
|
||||
响应数据
|
||||
"""
|
||||
return await self.bot.handle_unified_webhook(request)
|
||||
|
||||
async def run_async(self):
|
||||
# 统一 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 shutdown_trigger_placeholder():
|
||||
while True:
|
||||
await asyncio.sleep(1)
|
||||
|
||||
await keep_alive()
|
||||
await self.bot.run_task(
|
||||
host='0.0.0.0',
|
||||
port=self.config['port'],
|
||||
shutdown_trigger=shutdown_trigger_placeholder,
|
||||
)
|
||||
|
||||
async def kill(self) -> bool:
|
||||
return False
|
||||
|
||||
@@ -25,6 +25,13 @@ spec:
|
||||
type: string
|
||||
required: true
|
||||
default: ""
|
||||
- name: port
|
||||
label:
|
||||
en_US: Port
|
||||
zh_Hans: 监听端口
|
||||
type: integer
|
||||
required: true
|
||||
default: 2284
|
||||
- name: token
|
||||
label:
|
||||
en_US: Token
|
||||
|
||||
@@ -24,20 +24,9 @@ class SlackMessageConverter(abstract_platform_adapter.AbstractMessageConverter):
|
||||
if type(msg) is platform_message.Plain:
|
||||
content_list.append(
|
||||
{
|
||||
'type': 'text',
|
||||
'content': msg.text,
|
||||
}
|
||||
)
|
||||
elif type(msg) is platform_message.Image:
|
||||
# Slack supports images via unfurling URLs
|
||||
# Include image URL in the message so Slack can unfurl it
|
||||
if msg.url:
|
||||
content_list.append(
|
||||
{
|
||||
'type': 'image',
|
||||
'content': msg.url,
|
||||
}
|
||||
)
|
||||
|
||||
return content_list
|
||||
|
||||
@@ -97,12 +86,13 @@ class SlackEventConverter(abstract_platform_adapter.AbstractEventConverter):
|
||||
class SlackAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
||||
bot: SlackClient
|
||||
bot_account_id: str
|
||||
bot_uuid: str = None
|
||||
message_converter: SlackMessageConverter = SlackMessageConverter()
|
||||
event_converter: SlackEventConverter = SlackEventConverter()
|
||||
config: dict
|
||||
|
||||
def __init__(self, config: dict, logger: EventLogger):
|
||||
self.config = config
|
||||
self.logger = logger
|
||||
required_keys = [
|
||||
'bot_token',
|
||||
'signing_secret',
|
||||
@@ -111,18 +101,8 @@ class SlackAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
||||
if missing_keys:
|
||||
raise command_errors.ParamNotEnoughError('Slack机器人缺少相关配置项,请查看文档或联系管理员')
|
||||
|
||||
bot = SlackClient(
|
||||
bot_token=config['bot_token'],
|
||||
signing_secret=config['signing_secret'],
|
||||
logger=logger,
|
||||
unified_mode=True
|
||||
)
|
||||
|
||||
super().__init__(
|
||||
config=config,
|
||||
logger=logger,
|
||||
bot=bot,
|
||||
bot_account_id=config['bot_token'],
|
||||
self.bot = SlackClient(
|
||||
bot_token=self.config['bot_token'], signing_secret=self.config['signing_secret'], logger=self.logger
|
||||
)
|
||||
|
||||
async def reply_message(
|
||||
@@ -136,24 +116,18 @@ class SlackAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
||||
content_list = await SlackMessageConverter.yiri2target(message)
|
||||
|
||||
for content in content_list:
|
||||
# Both text and image (URL) are sent as text messages
|
||||
# Slack will auto-unfurl image URLs
|
||||
message_content = content['content']
|
||||
if slack_event.type == 'channel':
|
||||
await self.bot.send_message_to_channel(message_content, slack_event.channel_id)
|
||||
await self.bot.send_message_to_channel(content['content'], slack_event.channel_id)
|
||||
if slack_event.type == 'im':
|
||||
await self.bot.send_message_to_one(message_content, slack_event.user_id)
|
||||
await self.bot.send_message_to_one(content['content'], slack_event.user_id)
|
||||
|
||||
async def send_message(self, target_type: str, target_id: str, message: platform_message.MessageChain):
|
||||
content_list = await SlackMessageConverter.yiri2target(message)
|
||||
for content in content_list:
|
||||
# Both text and image (URL) are sent as text messages
|
||||
# Slack will auto-unfurl image URLs
|
||||
message_content = content['content']
|
||||
if target_type == 'person':
|
||||
await self.bot.send_message_to_one(message_content, target_id)
|
||||
await self.bot.send_message_to_one(content['content'], target_id)
|
||||
if target_type == 'group':
|
||||
await self.bot.send_message_to_channel(message_content, target_id)
|
||||
await self.bot.send_message_to_channel(content['content'], target_id)
|
||||
|
||||
def register_listener(
|
||||
self,
|
||||
@@ -174,45 +148,16 @@ class SlackAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
||||
elif event_type == platform_events.GroupMessage:
|
||||
self.bot.on_message('channel')(on_message)
|
||||
|
||||
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:
|
||||
响应数据
|
||||
"""
|
||||
return await self.bot.handle_unified_webhook(request)
|
||||
|
||||
async def run_async(self):
|
||||
# 统一 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 shutdown_trigger_placeholder():
|
||||
while True:
|
||||
await asyncio.sleep(1)
|
||||
await keep_alive()
|
||||
|
||||
await self.bot.run_task(
|
||||
host='0.0.0.0',
|
||||
port=self.config['port'],
|
||||
shutdown_trigger=shutdown_trigger_placeholder,
|
||||
)
|
||||
|
||||
async def kill(self) -> bool:
|
||||
return False
|
||||
|
||||
@@ -25,6 +25,13 @@ spec:
|
||||
type: string
|
||||
required: true
|
||||
default: ""
|
||||
- name: port
|
||||
label:
|
||||
en_US: Port
|
||||
zh_Hans: 监听端口
|
||||
type: int
|
||||
required: true
|
||||
default: 2288
|
||||
execution:
|
||||
python:
|
||||
path: ./slack.py
|
||||
|
||||
@@ -1,398 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import typing
|
||||
import asyncio
|
||||
import traceback
|
||||
import base64
|
||||
import datetime
|
||||
import aiohttp
|
||||
import pydantic
|
||||
|
||||
from botbuilder.core import BotFrameworkAdapter, BotFrameworkAdapterSettings, TurnContext
|
||||
from botbuilder.schema import Activity, ActivityTypes, ChannelAccount, ConversationAccount
|
||||
from botframework.connector.auth import MicrosoftAppCredentials
|
||||
from quart import Quart, request, Response
|
||||
|
||||
import langbot_plugin.api.definition.abstract.platform.adapter as abstract_platform_adapter
|
||||
import langbot_plugin.api.entities.builtin.platform.events as platform_events
|
||||
import langbot_plugin.api.entities.builtin.platform.message as platform_message
|
||||
import langbot_plugin.api.entities.builtin.platform.entities as platform_entities
|
||||
import langbot_plugin.api.definition.abstract.platform.event_logger as abstract_platform_logger
|
||||
|
||||
|
||||
class TeamsMessageConverter(abstract_platform_adapter.AbstractMessageConverter):
|
||||
"""Convert messages between LangBot format and Teams Activity format"""
|
||||
|
||||
@staticmethod
|
||||
async def yiri2target(message_chain: platform_message.MessageChain) -> dict:
|
||||
"""Convert LangBot message chain to Teams message format"""
|
||||
text_content = []
|
||||
attachments = []
|
||||
|
||||
for component in message_chain:
|
||||
if isinstance(component, platform_message.Plain):
|
||||
text_content.append(component.text)
|
||||
elif isinstance(component, platform_message.Image):
|
||||
# Teams supports image attachments
|
||||
image_data = None
|
||||
image_type = 'image/png'
|
||||
|
||||
if component.base64:
|
||||
# Extract base64 data
|
||||
if component.base64.startswith('data:'):
|
||||
parts = component.base64.split(',')
|
||||
header = parts[0]
|
||||
if 'image/' in header:
|
||||
image_type = header.split(';')[0].replace('data:', '')
|
||||
image_data = parts[1] if len(parts) > 1 else component.base64
|
||||
else:
|
||||
image_data = component.base64
|
||||
elif component.url:
|
||||
# For URLs, Teams can display them inline
|
||||
text_content.append(f'\n{component.url}')
|
||||
continue
|
||||
elif component.path:
|
||||
try:
|
||||
with open(component.path, 'rb') as f:
|
||||
image_bytes = f.read()
|
||||
image_data = base64.b64encode(image_bytes).decode('utf-8')
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
if image_data:
|
||||
# Create inline image attachment
|
||||
attachments.append({
|
||||
'contentType': image_type,
|
||||
'contentUrl': f'data:{image_type};base64,{image_data}',
|
||||
'name': 'image'
|
||||
})
|
||||
|
||||
result = {
|
||||
'text': ''.join(text_content),
|
||||
}
|
||||
|
||||
if attachments:
|
||||
result['attachments'] = attachments
|
||||
|
||||
return result
|
||||
|
||||
@staticmethod
|
||||
async def target2yiri(activity: Activity) -> platform_message.MessageChain:
|
||||
"""Convert Teams Activity to LangBot message chain"""
|
||||
components = []
|
||||
|
||||
# Add message source
|
||||
components.append(
|
||||
platform_message.Source(
|
||||
id=activity.id,
|
||||
time=activity.timestamp if activity.timestamp else datetime.datetime.now()
|
||||
)
|
||||
)
|
||||
|
||||
# Handle mentions (convert to At components)
|
||||
if activity.entities:
|
||||
for entity in activity.entities:
|
||||
if entity.type == 'mention':
|
||||
mentioned = entity.mentioned
|
||||
if mentioned:
|
||||
components.append(platform_message.At(target=str(mentioned.id)))
|
||||
|
||||
# Add text content
|
||||
if activity.text:
|
||||
text = activity.text
|
||||
# Remove bot mentions from text
|
||||
if activity.entities:
|
||||
for entity in activity.entities:
|
||||
if entity.type == 'mention' and entity.text:
|
||||
text = text.replace(entity.text, '').strip()
|
||||
|
||||
if text:
|
||||
components.append(platform_message.Plain(text=text))
|
||||
|
||||
# Handle attachments (images, files, etc.)
|
||||
if activity.attachments:
|
||||
for attachment in activity.attachments:
|
||||
if attachment.content_type and 'image' in attachment.content_type:
|
||||
# Download and convert image to base64
|
||||
if attachment.content_url:
|
||||
try:
|
||||
async with aiohttp.ClientSession() as session:
|
||||
async with session.get(attachment.content_url) as response:
|
||||
image_data = await response.read()
|
||||
image_base64 = base64.b64encode(image_data).decode('utf-8')
|
||||
content_type = attachment.content_type or 'image/png'
|
||||
components.append(
|
||||
platform_message.Image(
|
||||
base64=f'data:{content_type};base64,{image_base64}'
|
||||
)
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return platform_message.MessageChain(components)
|
||||
|
||||
|
||||
class TeamsEventConverter(abstract_platform_adapter.AbstractEventConverter):
|
||||
"""Convert events between Teams Activity and LangBot platform events"""
|
||||
|
||||
@staticmethod
|
||||
async def yiri2target(event: platform_events.Event) -> Activity:
|
||||
"""Convert LangBot event to Teams Activity"""
|
||||
return event.source_platform_object
|
||||
|
||||
@staticmethod
|
||||
async def target2yiri(activity: Activity, bot_id: str) -> platform_events.Event:
|
||||
"""Convert Teams Activity to LangBot event"""
|
||||
message_chain = await TeamsMessageConverter.target2yiri(activity)
|
||||
|
||||
# Determine if it's a personal or channel/group chat
|
||||
conversation_type = activity.conversation.conversation_type if activity.conversation else None
|
||||
|
||||
if conversation_type == 'personal':
|
||||
# Personal chat (1-on-1)
|
||||
return platform_events.FriendMessage(
|
||||
sender=platform_entities.Friend(
|
||||
id=activity.from_property.id,
|
||||
nickname=activity.from_property.name or activity.from_property.id,
|
||||
remark=activity.from_property.id,
|
||||
),
|
||||
message_chain=message_chain,
|
||||
time=activity.timestamp.timestamp() if activity.timestamp else datetime.datetime.now().timestamp(),
|
||||
source_platform_object=activity,
|
||||
)
|
||||
else:
|
||||
# Channel or group chat
|
||||
return platform_events.GroupMessage(
|
||||
sender=platform_entities.GroupMember(
|
||||
id=activity.from_property.id,
|
||||
member_name=activity.from_property.name or activity.from_property.id,
|
||||
permission=platform_entities.Permission.Member,
|
||||
group=platform_entities.Group(
|
||||
id=activity.conversation.id,
|
||||
name=activity.conversation.name or activity.conversation.id,
|
||||
permission=platform_entities.Permission.Member,
|
||||
),
|
||||
special_title='',
|
||||
join_timestamp=0,
|
||||
last_speak_timestamp=0,
|
||||
mute_time_remaining=0,
|
||||
),
|
||||
message_chain=message_chain,
|
||||
time=activity.timestamp.timestamp() if activity.timestamp else datetime.datetime.now().timestamp(),
|
||||
source_platform_object=activity,
|
||||
)
|
||||
|
||||
|
||||
class TeamsAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
||||
"""Microsoft Teams platform adapter for LangBot"""
|
||||
|
||||
adapter: BotFrameworkAdapter = pydantic.Field(exclude=True, default=None)
|
||||
app: Quart = pydantic.Field(exclude=True, default=None)
|
||||
bot_uuid: typing.Optional[str] = None
|
||||
|
||||
message_converter: TeamsMessageConverter = TeamsMessageConverter()
|
||||
event_converter: TeamsEventConverter = TeamsEventConverter()
|
||||
|
||||
config: dict
|
||||
|
||||
listeners: typing.Dict[
|
||||
typing.Type[platform_events.Event],
|
||||
typing.Callable[[platform_events.Event, abstract_platform_adapter.AbstractMessagePlatformAdapter], None],
|
||||
] = {}
|
||||
|
||||
def __init__(self, config: dict, logger: abstract_platform_logger.AbstractEventLogger, **kwargs):
|
||||
"""Initialize Teams adapter with app credentials"""
|
||||
# Validate required config
|
||||
if 'app_id' not in config or 'app_password' not in config:
|
||||
raise ValueError('Teams adapter requires app_id and app_password in configuration')
|
||||
|
||||
# Create Bot Framework adapter settings
|
||||
settings = BotFrameworkAdapterSettings(
|
||||
app_id=config['app_id'],
|
||||
app_password=config['app_password']
|
||||
)
|
||||
|
||||
# Create Bot Framework adapter
|
||||
adapter_instance = BotFrameworkAdapter(settings)
|
||||
|
||||
super().__init__(
|
||||
config=config,
|
||||
logger=logger,
|
||||
bot_account_id=config['app_id'],
|
||||
listeners={},
|
||||
**kwargs
|
||||
)
|
||||
|
||||
# Set the adapter after initialization
|
||||
self.adapter = adapter_instance
|
||||
|
||||
async def _handle_activity(self, activity: Activity):
|
||||
"""Internal method to handle incoming activities"""
|
||||
try:
|
||||
# Only process message activities from users (not bots)
|
||||
if activity.type == ActivityTypes.message:
|
||||
if activity.from_property and not (hasattr(activity.from_property, 'role') and activity.from_property.role == 'bot'):
|
||||
# Convert to LangBot event
|
||||
lb_event = await self.event_converter.target2yiri(activity, self.bot_account_id)
|
||||
|
||||
# Call appropriate listener
|
||||
event_type = type(lb_event)
|
||||
if event_type in self.listeners:
|
||||
await self.listeners[event_type](lb_event, self)
|
||||
except Exception as e:
|
||||
await self.logger.error(f'Error handling Teams activity: {str(e)}\n{traceback.format_exc()}')
|
||||
|
||||
async def send_message(self, target_type: str, target_id: str, message: platform_message.MessageChain):
|
||||
"""Send a message to a specific target"""
|
||||
try:
|
||||
message_data = await self.message_converter.yiri2target(message)
|
||||
|
||||
# Create activity
|
||||
activity = Activity(
|
||||
type=ActivityTypes.message,
|
||||
text=message_data.get('text', ''),
|
||||
attachments=message_data.get('attachments', []),
|
||||
conversation=ConversationAccount(id=target_id),
|
||||
)
|
||||
|
||||
# Send via Bot Framework adapter
|
||||
# Note: This requires a conversation reference which we would need to store
|
||||
# For now, this is a placeholder - full implementation would require conversation tracking
|
||||
await self.logger.warning('Direct send_message not fully implemented - use reply_message instead')
|
||||
except Exception as e:
|
||||
await self.logger.error(f'Error sending Teams message: {str(e)}')
|
||||
|
||||
async def reply_message(
|
||||
self,
|
||||
message_source: platform_events.MessageEvent,
|
||||
message: platform_message.MessageChain,
|
||||
quote_origin: bool = False,
|
||||
):
|
||||
"""Reply to a message"""
|
||||
try:
|
||||
assert isinstance(message_source.source_platform_object, Activity)
|
||||
source_activity: Activity = message_source.source_platform_object
|
||||
|
||||
message_data = await self.message_converter.yiri2target(message)
|
||||
|
||||
# Build conversation reference from source activity
|
||||
conversation_reference = TurnContext.get_conversation_reference(source_activity)
|
||||
|
||||
# Create reply activity
|
||||
async def send_reply(turn_context: TurnContext):
|
||||
reply_text = message_data.get('text', '')
|
||||
reply_attachments = message_data.get('attachments', [])
|
||||
|
||||
if quote_origin:
|
||||
# Include reply_to_id to quote the original message
|
||||
await turn_context.send_activity(
|
||||
Activity(
|
||||
type=ActivityTypes.message,
|
||||
text=reply_text,
|
||||
attachments=reply_attachments,
|
||||
reply_to_id=source_activity.id
|
||||
)
|
||||
)
|
||||
else:
|
||||
await turn_context.send_activity(
|
||||
Activity(
|
||||
type=ActivityTypes.message,
|
||||
text=reply_text,
|
||||
attachments=reply_attachments
|
||||
)
|
||||
)
|
||||
|
||||
# Continue conversation using the conversation reference
|
||||
await self.adapter.continue_conversation(
|
||||
conversation_reference,
|
||||
send_reply,
|
||||
self.bot_account_id
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
await self.logger.error(f'Error replying to Teams message: {str(e)}\n{traceback.format_exc()}')
|
||||
|
||||
def register_listener(
|
||||
self,
|
||||
event_type: typing.Type[platform_events.Event],
|
||||
callback: typing.Callable[
|
||||
[platform_events.Event, abstract_platform_adapter.AbstractMessagePlatformAdapter], None
|
||||
],
|
||||
):
|
||||
"""Register event listener"""
|
||||
self.listeners[event_type] = callback
|
||||
|
||||
def unregister_listener(
|
||||
self,
|
||||
event_type: typing.Type[platform_events.Event],
|
||||
_callback: typing.Callable[
|
||||
[platform_events.Event, abstract_platform_adapter.AbstractMessagePlatformAdapter], None
|
||||
],
|
||||
):
|
||||
"""Unregister event listener"""
|
||||
if event_type in self.listeners:
|
||||
del self.listeners[event_type]
|
||||
|
||||
def set_bot_uuid(self, bot_uuid: str):
|
||||
"""Set bot UUID for webhook URL generation"""
|
||||
self.bot_uuid = bot_uuid
|
||||
|
||||
async def handle_unified_webhook(self, _bot_uuid: str, _path: str, request_obj):
|
||||
"""Handle unified webhook requests from Teams
|
||||
|
||||
Args:
|
||||
_bot_uuid: Bot UUID (unused)
|
||||
_path: Sub-path (unused)
|
||||
request_obj: Quart Request object
|
||||
|
||||
Returns:
|
||||
Response data
|
||||
"""
|
||||
try:
|
||||
# Get request body
|
||||
body = await request_obj.get_json()
|
||||
|
||||
# Get authorization header
|
||||
auth_header = request_obj.headers.get('Authorization', '')
|
||||
|
||||
# Process activity
|
||||
async def bot_logic(turn_context: TurnContext):
|
||||
# Handle the activity
|
||||
await self._handle_activity(turn_context.activity)
|
||||
|
||||
# Process the request through Bot Framework adapter
|
||||
await self.adapter.process_activity(body, auth_header, bot_logic)
|
||||
|
||||
return Response(status=200)
|
||||
|
||||
except Exception as e:
|
||||
await self.logger.error(f'Error processing Teams webhook: {str(e)}\n{traceback.format_exc()}')
|
||||
return Response(status=500)
|
||||
|
||||
async def run_async(self):
|
||||
"""Run the adapter - Teams uses webhook mode, so just keep alive and log webhook URL"""
|
||||
# Print webhook callback address
|
||||
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"Teams Bot Webhook URL:")
|
||||
await self.logger.info(f" Local: {webhook_url}")
|
||||
await self.logger.info(f" Public: {webhook_url_public}")
|
||||
await self.logger.info(f"Configure this URL as the messaging endpoint in Azure Bot Service")
|
||||
except Exception as e:
|
||||
await self.logger.warning(f"Could not generate webhook URL: {e}")
|
||||
|
||||
# Keep the adapter running
|
||||
async def keep_alive():
|
||||
while True:
|
||||
await asyncio.sleep(1)
|
||||
|
||||
await keep_alive()
|
||||
|
||||
async def kill(self) -> bool:
|
||||
"""Shutdown the adapter"""
|
||||
await self.logger.info('Teams adapter stopped')
|
||||
return True
|
||||
@@ -1,37 +0,0 @@
|
||||
apiVersion: v1
|
||||
kind: MessagePlatformAdapter
|
||||
metadata:
|
||||
name: teams
|
||||
label:
|
||||
en_US: Microsoft Teams
|
||||
zh_Hans: 微软 Teams
|
||||
description:
|
||||
en_US: Microsoft Teams Adapter - supports personal and channel/group chats
|
||||
zh_Hans: 微软 Teams 适配器,支持个人聊天和频道/群组聊天
|
||||
icon: teams.svg
|
||||
spec:
|
||||
config:
|
||||
- name: app_id
|
||||
label:
|
||||
en_US: App ID
|
||||
zh_Hans: 应用 ID
|
||||
description:
|
||||
en_US: Microsoft App ID from Azure Bot registration
|
||||
zh_Hans: Azure Bot 注册的 Microsoft 应用 ID
|
||||
type: string
|
||||
required: true
|
||||
default: ""
|
||||
- name: app_password
|
||||
label:
|
||||
en_US: App Password
|
||||
zh_Hans: 应用密码
|
||||
description:
|
||||
en_US: Microsoft App Password (Client Secret) from Azure Bot registration
|
||||
zh_Hans: Azure Bot 注册的 Microsoft 应用密码(客户端密钥)
|
||||
type: string
|
||||
required: true
|
||||
default: ""
|
||||
execution:
|
||||
python:
|
||||
path: ./teams.py
|
||||
attr: TeamsAdapter
|
||||
304
src/langbot/pkg/platform/sources/webchat.py
Normal file
304
src/langbot/pkg/platform/sources/webchat.py
Normal file
@@ -0,0 +1,304 @@
|
||||
import asyncio
|
||||
import logging
|
||||
import typing
|
||||
from datetime import datetime
|
||||
|
||||
import pydantic
|
||||
|
||||
import langbot_plugin.api.definition.abstract.platform.adapter as abstract_platform_adapter
|
||||
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.definition.abstract.platform.event_logger as abstract_platform_logger
|
||||
from ...core import app
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class WebChatMessage(pydantic.BaseModel):
|
||||
id: int
|
||||
role: str
|
||||
content: str
|
||||
message_chain: list[dict]
|
||||
timestamp: str
|
||||
is_final: bool = False
|
||||
|
||||
|
||||
class WebChatSession:
|
||||
id: str
|
||||
message_lists: dict[str, list[WebChatMessage]] = {}
|
||||
resp_waiters: dict[int, asyncio.Future[WebChatMessage]]
|
||||
resp_queues: dict[int, asyncio.Queue[WebChatMessage]]
|
||||
|
||||
def __init__(self, id: str):
|
||||
self.id = id
|
||||
self.message_lists = {}
|
||||
self.resp_waiters = {}
|
||||
self.resp_queues = {}
|
||||
|
||||
def get_message_list(self, pipeline_uuid: str) -> list[WebChatMessage]:
|
||||
if pipeline_uuid not in self.message_lists:
|
||||
self.message_lists[pipeline_uuid] = []
|
||||
|
||||
return self.message_lists[pipeline_uuid]
|
||||
|
||||
|
||||
class WebChatAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
||||
"""WebChat调试适配器,用于流水线调试"""
|
||||
|
||||
webchat_person_session: WebChatSession = pydantic.Field(exclude=True, default_factory=WebChatSession)
|
||||
webchat_group_session: WebChatSession = pydantic.Field(exclude=True, default_factory=WebChatSession)
|
||||
|
||||
listeners: dict[
|
||||
typing.Type[platform_events.Event],
|
||||
typing.Callable[[platform_events.Event, abstract_platform_adapter.AbstractMessagePlatformAdapter], None],
|
||||
] = pydantic.Field(default_factory=dict, exclude=True)
|
||||
|
||||
is_stream: bool = pydantic.Field(exclude=True)
|
||||
debug_messages: dict[str, list[dict]] = pydantic.Field(default_factory=dict, exclude=True)
|
||||
|
||||
ap: app.Application = pydantic.Field(exclude=True)
|
||||
|
||||
def __init__(self, config: dict, logger: abstract_platform_logger.AbstractEventLogger, **kwargs):
|
||||
super().__init__(
|
||||
config=config,
|
||||
logger=logger,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
self.webchat_person_session = WebChatSession(id='webchatperson')
|
||||
self.webchat_group_session = WebChatSession(id='webchatgroup')
|
||||
|
||||
self.bot_account_id = 'webchatbot'
|
||||
|
||||
self.debug_messages = {}
|
||||
|
||||
async def send_message(
|
||||
self,
|
||||
target_type: str,
|
||||
target_id: str,
|
||||
message: platform_message.MessageChain,
|
||||
) -> dict:
|
||||
"""发送消息到调试会话"""
|
||||
session_key = target_id
|
||||
|
||||
if session_key not in self.debug_messages:
|
||||
self.debug_messages[session_key] = []
|
||||
|
||||
message_data = {
|
||||
'id': len(self.debug_messages[session_key]) + 1,
|
||||
'type': 'bot',
|
||||
'content': str(message),
|
||||
'timestamp': datetime.now().isoformat(),
|
||||
'message_chain': [component.__dict__ for component in message],
|
||||
}
|
||||
|
||||
self.debug_messages[session_key].append(message_data)
|
||||
|
||||
await self.logger.info(f'Send message to {session_key}: {message}')
|
||||
|
||||
return message_data
|
||||
|
||||
async def reply_message(
|
||||
self,
|
||||
message_source: platform_events.MessageEvent,
|
||||
message: platform_message.MessageChain,
|
||||
quote_origin: bool = False,
|
||||
) -> dict:
|
||||
"""回复消息"""
|
||||
message_data = WebChatMessage(
|
||||
id=-1,
|
||||
role='assistant',
|
||||
content=str(message),
|
||||
message_chain=[component.__dict__ for component in message],
|
||||
timestamp=datetime.now().isoformat(),
|
||||
)
|
||||
|
||||
# notify waiter
|
||||
if isinstance(message_source, platform_events.FriendMessage):
|
||||
await self.webchat_person_session.resp_queues[message_source.message_chain.message_id].put(message_data)
|
||||
elif isinstance(message_source, platform_events.GroupMessage):
|
||||
await self.webchat_group_session.resp_queues[message_source.message_chain.message_id].put(message_data)
|
||||
|
||||
return message_data.model_dump()
|
||||
|
||||
async def reply_message_chunk(
|
||||
self,
|
||||
message_source: platform_events.MessageEvent,
|
||||
bot_message,
|
||||
message: platform_message.MessageChain,
|
||||
quote_origin: bool = False,
|
||||
is_final: bool = False,
|
||||
) -> dict:
|
||||
"""回复消息"""
|
||||
message_data = WebChatMessage(
|
||||
id=-1,
|
||||
role='assistant',
|
||||
content=str(message),
|
||||
message_chain=[component.__dict__ for component in message],
|
||||
timestamp=datetime.now().isoformat(),
|
||||
)
|
||||
|
||||
# notify waiter
|
||||
session = (
|
||||
self.webchat_group_session
|
||||
if isinstance(message_source, platform_events.GroupMessage)
|
||||
else self.webchat_person_session
|
||||
)
|
||||
if message_source.message_chain.message_id not in session.resp_waiters:
|
||||
# session.resp_waiters[message_source.message_chain.message_id] = asyncio.Queue()
|
||||
queue = session.resp_queues[message_source.message_chain.message_id]
|
||||
|
||||
# if isinstance(message_source, platform_events.FriendMessage):
|
||||
# queue = self.webchat_person_session.resp_queues[message_source.message_chain.message_id]
|
||||
# elif isinstance(message_source, platform_events.GroupMessage):
|
||||
# queue = self.webchat_group_session.resp_queues[message_source.message_chain.message_id]
|
||||
if is_final and bot_message.tool_calls is None:
|
||||
message_data.is_final = True
|
||||
# print(message_data)
|
||||
await queue.put(message_data)
|
||||
|
||||
return message_data.model_dump()
|
||||
|
||||
async def is_stream_output_supported(self) -> bool:
|
||||
return self.is_stream
|
||||
|
||||
def register_listener(
|
||||
self,
|
||||
event_type: typing.Type[platform_events.Event],
|
||||
func: typing.Callable[
|
||||
[platform_events.Event, abstract_platform_adapter.AbstractMessagePlatformAdapter], typing.Awaitable[None]
|
||||
],
|
||||
):
|
||||
"""注册事件监听器"""
|
||||
self.listeners[event_type] = func
|
||||
|
||||
def unregister_listener(
|
||||
self,
|
||||
event_type: typing.Type[platform_events.Event],
|
||||
func: typing.Callable[
|
||||
[platform_events.Event, abstract_platform_adapter.AbstractMessagePlatformAdapter], typing.Awaitable[None]
|
||||
],
|
||||
):
|
||||
"""取消注册事件监听器"""
|
||||
del self.listeners[event_type]
|
||||
|
||||
async def is_muted(self, group_id: int) -> bool:
|
||||
return False
|
||||
|
||||
async def run_async(self):
|
||||
"""运行适配器"""
|
||||
await self.logger.info('WebChat调试适配器已启动')
|
||||
|
||||
try:
|
||||
while True:
|
||||
await asyncio.sleep(1)
|
||||
except asyncio.CancelledError:
|
||||
await self.logger.info('WebChat调试适配器已停止')
|
||||
raise
|
||||
|
||||
async def kill(self):
|
||||
"""停止适配器"""
|
||||
await self.logger.info('WebChat调试适配器正在停止')
|
||||
|
||||
async def send_webchat_message(
|
||||
self,
|
||||
pipeline_uuid: str,
|
||||
session_type: str,
|
||||
message_chain_obj: typing.List[dict],
|
||||
is_stream: bool = False,
|
||||
) -> dict:
|
||||
self.is_stream = is_stream
|
||||
"""发送调试消息到流水线"""
|
||||
if session_type == 'person':
|
||||
use_session = self.webchat_person_session
|
||||
else:
|
||||
use_session = self.webchat_group_session
|
||||
|
||||
message_chain = platform_message.MessageChain.parse_obj(message_chain_obj)
|
||||
|
||||
message_id = len(use_session.get_message_list(pipeline_uuid)) + 1
|
||||
|
||||
use_session.resp_queues[message_id] = asyncio.Queue()
|
||||
logger.debug(f'Initialized queue for message_id: {message_id}')
|
||||
|
||||
use_session.get_message_list(pipeline_uuid).append(
|
||||
WebChatMessage(
|
||||
id=message_id,
|
||||
role='user',
|
||||
content=str(message_chain),
|
||||
message_chain=message_chain_obj,
|
||||
timestamp=datetime.now().isoformat(),
|
||||
)
|
||||
)
|
||||
|
||||
message_chain.insert(0, platform_message.Source(id=message_id, time=datetime.now().timestamp()))
|
||||
|
||||
if session_type == 'person':
|
||||
sender = platform_entities.Friend(id='webchatperson', nickname='User', remark='User')
|
||||
event = platform_events.FriendMessage(
|
||||
sender=sender, message_chain=message_chain, time=datetime.now().timestamp()
|
||||
)
|
||||
else:
|
||||
group = platform_entities.Group(
|
||||
id='webchatgroup', name='Group', permission=platform_entities.Permission.Member
|
||||
)
|
||||
sender = platform_entities.GroupMember(
|
||||
id='webchatperson',
|
||||
member_name='User',
|
||||
group=group,
|
||||
permission=platform_entities.Permission.Member,
|
||||
)
|
||||
event = platform_events.GroupMessage(
|
||||
sender=sender, message_chain=message_chain, time=datetime.now().timestamp()
|
||||
)
|
||||
|
||||
self.ap.platform_mgr.webchat_proxy_bot.bot_entity.use_pipeline_uuid = pipeline_uuid
|
||||
|
||||
# trigger pipeline
|
||||
if event.__class__ in self.listeners:
|
||||
await self.listeners[event.__class__](event, self)
|
||||
|
||||
if is_stream:
|
||||
queue = use_session.resp_queues[message_id]
|
||||
msg_id = len(use_session.get_message_list(pipeline_uuid)) + 1
|
||||
while True:
|
||||
resp_message = await queue.get()
|
||||
resp_message.id = msg_id
|
||||
if resp_message.is_final:
|
||||
resp_message.id = msg_id
|
||||
use_session.get_message_list(pipeline_uuid).append(resp_message)
|
||||
yield resp_message.model_dump()
|
||||
break
|
||||
yield resp_message.model_dump()
|
||||
use_session.resp_queues.pop(message_id)
|
||||
|
||||
else: # non-stream
|
||||
# set waiter
|
||||
# waiter = asyncio.Future[WebChatMessage]()
|
||||
# use_session.resp_waiters[message_id] = waiter
|
||||
# # waiter.add_done_callback(lambda future: use_session.resp_waiters.pop(message_id))
|
||||
#
|
||||
# resp_message = await waiter
|
||||
#
|
||||
# resp_message.id = len(use_session.get_message_list(pipeline_uuid)) + 1
|
||||
#
|
||||
# use_session.get_message_list(pipeline_uuid).append(resp_message)
|
||||
#
|
||||
# yield resp_message.model_dump()
|
||||
msg_id = len(use_session.get_message_list(pipeline_uuid)) + 1
|
||||
|
||||
queue = use_session.resp_queues[message_id]
|
||||
resp_message = await queue.get()
|
||||
use_session.get_message_list(pipeline_uuid).append(resp_message)
|
||||
resp_message.id = msg_id
|
||||
resp_message.is_final = True
|
||||
|
||||
yield resp_message.model_dump()
|
||||
|
||||
def get_webchat_messages(self, pipeline_uuid: str, session_type: str) -> list[dict]:
|
||||
"""获取调试消息历史"""
|
||||
if session_type == 'person':
|
||||
return [message.model_dump() for message in self.webchat_person_session.get_message_list(pipeline_uuid)]
|
||||
else:
|
||||
return [message.model_dump() for message in self.webchat_group_session.get_message_list(pipeline_uuid)]
|
||||
17
src/langbot/pkg/platform/sources/webchat.yaml
Normal file
17
src/langbot/pkg/platform/sources/webchat.yaml
Normal file
@@ -0,0 +1,17 @@
|
||||
apiVersion: v1
|
||||
kind: MessagePlatformAdapter
|
||||
metadata:
|
||||
name: webchat
|
||||
label:
|
||||
en_US: "WebChat Debug"
|
||||
zh_Hans: "网页聊天调试"
|
||||
description:
|
||||
en_US: "WebChat adapter for pipeline debugging"
|
||||
zh_Hans: "用于流水线调试的网页聊天适配器"
|
||||
icon: ""
|
||||
spec:
|
||||
config: []
|
||||
execution:
|
||||
python:
|
||||
path: "webchat.py"
|
||||
attr: "WebChatAdapter"
|
||||
@@ -1,17 +0,0 @@
|
||||
apiVersion: v1
|
||||
kind: MessagePlatformAdapter
|
||||
metadata:
|
||||
name: websocket
|
||||
label:
|
||||
en_US: "WebSocket Chat"
|
||||
zh_Hans: "WebSocket 聊天"
|
||||
description:
|
||||
en_US: "WebSocket adapter for bidirectional real-time communication"
|
||||
zh_Hans: "用于双向实时通信的 WebSocket 适配器"
|
||||
icon: ""
|
||||
spec:
|
||||
config: []
|
||||
execution:
|
||||
python:
|
||||
path: "websocket_adapter.py"
|
||||
attr: "WebSocketAdapter"
|
||||
@@ -1,409 +0,0 @@
|
||||
"""WebSocket适配器 - 支持双向通信的IM系统"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import typing
|
||||
from datetime import datetime
|
||||
|
||||
import pydantic
|
||||
|
||||
import langbot_plugin.api.definition.abstract.platform.adapter as abstract_platform_adapter
|
||||
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.definition.abstract.platform.event_logger as abstract_platform_logger
|
||||
from ...core import app
|
||||
from .websocket_manager import ws_connection_manager, WebSocketConnection
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class WebSocketMessage(pydantic.BaseModel):
|
||||
"""WebSocket消息格式"""
|
||||
|
||||
id: int
|
||||
role: str # 'user' or 'assistant'
|
||||
content: str
|
||||
message_chain: list[dict]
|
||||
timestamp: str
|
||||
is_final: bool = False
|
||||
connection_id: str = ''
|
||||
"""发送者连接ID"""
|
||||
|
||||
|
||||
class WebSocketSession:
|
||||
"""WebSocket会话 - 管理单个会话的消息历史"""
|
||||
|
||||
id: str
|
||||
message_lists: dict[str, list[WebSocketMessage]] = {}
|
||||
"""消息列表 {pipeline_uuid: [messages]}"""
|
||||
|
||||
def __init__(self, id: str):
|
||||
self.id = id
|
||||
self.message_lists = {}
|
||||
|
||||
def get_message_list(self, pipeline_uuid: str) -> list[WebSocketMessage]:
|
||||
if pipeline_uuid not in self.message_lists:
|
||||
self.message_lists[pipeline_uuid] = []
|
||||
return self.message_lists[pipeline_uuid]
|
||||
|
||||
|
||||
class WebSocketAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
||||
"""WebSocket适配器 - 支持双向实时通信"""
|
||||
|
||||
websocket_person_session: WebSocketSession = pydantic.Field(exclude=True, default_factory=WebSocketSession)
|
||||
websocket_group_session: WebSocketSession = pydantic.Field(exclude=True, default_factory=WebSocketSession)
|
||||
|
||||
listeners: dict[
|
||||
typing.Type[platform_events.Event],
|
||||
typing.Callable[[platform_events.Event, abstract_platform_adapter.AbstractMessagePlatformAdapter], None],
|
||||
] = pydantic.Field(default_factory=dict, exclude=True)
|
||||
|
||||
ap: app.Application = pydantic.Field(exclude=True)
|
||||
|
||||
# 主动推送消息的队列
|
||||
outbound_message_queue: asyncio.Queue = pydantic.Field(default_factory=asyncio.Queue, exclude=True)
|
||||
"""后端主动推送消息的队列"""
|
||||
|
||||
def __init__(self, config: dict, logger: abstract_platform_logger.AbstractEventLogger, **kwargs):
|
||||
super().__init__(
|
||||
config=config,
|
||||
logger=logger,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
self.websocket_person_session = WebSocketSession(id='websocketperson')
|
||||
self.websocket_group_session = WebSocketSession(id='websocketgroup')
|
||||
|
||||
self.bot_account_id = 'websocketbot'
|
||||
self.outbound_message_queue = asyncio.Queue()
|
||||
|
||||
async def send_message(
|
||||
self,
|
||||
target_type: str,
|
||||
target_id: str,
|
||||
message: platform_message.MessageChain,
|
||||
) -> dict:
|
||||
"""发送消息 - 这里用于主动推送消息到前端"""
|
||||
message_data = {
|
||||
'type': 'bot_message',
|
||||
'target_type': target_type,
|
||||
'target_id': target_id,
|
||||
'content': str(message),
|
||||
'message_chain': [component.__dict__ for component in message],
|
||||
'timestamp': datetime.now().isoformat(),
|
||||
}
|
||||
|
||||
# 推送到所有相关连接
|
||||
await self.outbound_message_queue.put(message_data)
|
||||
|
||||
await self.logger.info(f'Send message to {target_id}: {message}')
|
||||
|
||||
return message_data
|
||||
|
||||
async def reply_message(
|
||||
self,
|
||||
message_source: platform_events.MessageEvent,
|
||||
message: platform_message.MessageChain,
|
||||
quote_origin: bool = False,
|
||||
) -> dict:
|
||||
"""回复消息 - 非流式"""
|
||||
# 获取会话和pipeline信息
|
||||
session = (
|
||||
self.websocket_group_session
|
||||
if isinstance(message_source, platform_events.GroupMessage)
|
||||
else self.websocket_person_session
|
||||
)
|
||||
|
||||
# 从message_source获取pipeline_uuid和connection_id
|
||||
pipeline_uuid = self.ap.platform_mgr.websocket_proxy_bot.bot_entity.use_pipeline_uuid
|
||||
session_type = 'group' if isinstance(message_source, platform_events.GroupMessage) else 'person'
|
||||
|
||||
# 生成新的消息ID
|
||||
msg_id = len(session.get_message_list(pipeline_uuid)) + 1
|
||||
|
||||
message_data = WebSocketMessage(
|
||||
id=msg_id,
|
||||
role='assistant',
|
||||
content=str(message),
|
||||
message_chain=[component.__dict__ for component in message],
|
||||
timestamp=datetime.now().isoformat(),
|
||||
is_final=True,
|
||||
)
|
||||
|
||||
# 保存到历史记录
|
||||
session.get_message_list(pipeline_uuid).append(message_data)
|
||||
|
||||
# 直接广播到所有该pipeline的连接,包含session_type信息
|
||||
await ws_connection_manager.broadcast_to_pipeline(
|
||||
pipeline_uuid,
|
||||
{
|
||||
'type': 'response',
|
||||
'session_type': session_type,
|
||||
'data': message_data.model_dump(),
|
||||
},
|
||||
session_type=session_type,
|
||||
)
|
||||
|
||||
return message_data.model_dump()
|
||||
|
||||
async def reply_message_chunk(
|
||||
self,
|
||||
message_source: platform_events.MessageEvent,
|
||||
bot_message,
|
||||
message: platform_message.MessageChain,
|
||||
quote_origin: bool = False,
|
||||
is_final: bool = False,
|
||||
) -> dict:
|
||||
"""回复消息块 - 流式"""
|
||||
# 获取会话和pipeline信息
|
||||
session = (
|
||||
self.websocket_group_session
|
||||
if isinstance(message_source, platform_events.GroupMessage)
|
||||
else self.websocket_person_session
|
||||
)
|
||||
|
||||
pipeline_uuid = self.ap.platform_mgr.websocket_proxy_bot.bot_entity.use_pipeline_uuid
|
||||
session_type = 'group' if isinstance(message_source, platform_events.GroupMessage) else 'person'
|
||||
message_list = session.get_message_list(pipeline_uuid)
|
||||
|
||||
# 检查是否是新的流式消息(通过bot_message对象判断)
|
||||
# 如果列表为空,或者最后一条消息已经is_final=True,则创建新消息
|
||||
if not message_list or message_list[-1].is_final:
|
||||
# 创建新消息
|
||||
msg_id = len(message_list) + 1
|
||||
message_data = WebSocketMessage(
|
||||
id=msg_id,
|
||||
role='assistant',
|
||||
content=str(message),
|
||||
message_chain=[component.__dict__ for component in message],
|
||||
timestamp=datetime.now().isoformat(),
|
||||
is_final=is_final and bot_message.tool_calls is None,
|
||||
)
|
||||
|
||||
# 只有在is_final时才保存到历史记录
|
||||
if is_final and bot_message.tool_calls is None:
|
||||
message_list.append(message_data)
|
||||
else:
|
||||
# 更新最后一条消息
|
||||
msg_id = message_list[-1].id
|
||||
message_data = WebSocketMessage(
|
||||
id=msg_id,
|
||||
role='assistant',
|
||||
content=str(message),
|
||||
message_chain=[component.__dict__ for component in message],
|
||||
timestamp=message_list[-1].timestamp, # 保持原始时间戳
|
||||
is_final=is_final and bot_message.tool_calls is None,
|
||||
)
|
||||
|
||||
# 如果是final,更新历史记录中的最后一条
|
||||
if is_final and bot_message.tool_calls is None:
|
||||
message_list[-1] = message_data
|
||||
|
||||
# 直接广播到所有该pipeline的连接,包含session_type信息
|
||||
await ws_connection_manager.broadcast_to_pipeline(
|
||||
pipeline_uuid,
|
||||
{
|
||||
'type': 'response',
|
||||
'session_type': session_type,
|
||||
'data': message_data.model_dump(),
|
||||
},
|
||||
session_type=session_type,
|
||||
)
|
||||
|
||||
return message_data.model_dump()
|
||||
|
||||
async def is_stream_output_supported(self) -> bool:
|
||||
"""WebSocket始终支持流式输出"""
|
||||
return True
|
||||
|
||||
def register_listener(
|
||||
self,
|
||||
event_type: typing.Type[platform_events.Event],
|
||||
func: typing.Callable[
|
||||
[platform_events.Event, abstract_platform_adapter.AbstractMessagePlatformAdapter], typing.Awaitable[None]
|
||||
],
|
||||
):
|
||||
"""注册事件监听器"""
|
||||
self.listeners[event_type] = func
|
||||
|
||||
def unregister_listener(
|
||||
self,
|
||||
event_type: typing.Type[platform_events.Event],
|
||||
func: typing.Callable[
|
||||
[platform_events.Event, abstract_platform_adapter.AbstractMessagePlatformAdapter], typing.Awaitable[None]
|
||||
],
|
||||
):
|
||||
"""取消注册事件监听器"""
|
||||
del self.listeners[event_type]
|
||||
|
||||
async def is_muted(self, group_id: int) -> bool:
|
||||
return False
|
||||
|
||||
async def run_async(self):
|
||||
"""运行适配器"""
|
||||
await self.logger.info('WebSocket适配器已启动')
|
||||
|
||||
try:
|
||||
while True:
|
||||
# 处理主动推送消息
|
||||
if not self.outbound_message_queue.empty():
|
||||
try:
|
||||
message = await asyncio.wait_for(self.outbound_message_queue.get(), timeout=0.1)
|
||||
# 广播到所有相关连接
|
||||
target_id = message.get('target_id', '')
|
||||
await ws_connection_manager.broadcast_to_pipeline(target_id, message)
|
||||
except asyncio.TimeoutError:
|
||||
pass
|
||||
|
||||
await asyncio.sleep(0.1)
|
||||
except asyncio.CancelledError:
|
||||
await self.logger.info('WebSocket适配器已停止')
|
||||
raise
|
||||
|
||||
async def kill(self):
|
||||
"""停止适配器"""
|
||||
await self.logger.info('WebSocket适配器正在停止')
|
||||
|
||||
async def _process_image_components(self, message_chain_obj: list):
|
||||
"""
|
||||
处理消息链中的图片组件,将path转换为base64
|
||||
|
||||
Args:
|
||||
message_chain_obj: 消息链对象列表
|
||||
"""
|
||||
import base64
|
||||
|
||||
storage_mgr = self.ap.storage_mgr
|
||||
|
||||
for component in message_chain_obj:
|
||||
if component.get('type') == 'Image' and component.get('path'):
|
||||
try:
|
||||
# 从storage读取文件
|
||||
file_content = await storage_mgr.storage_provider.load(component['path'])
|
||||
|
||||
# 转换为base64
|
||||
base64_str = base64.b64encode(file_content).decode('utf-8')
|
||||
|
||||
# 添加data URI前缀(根据文件扩展名判断MIME类型)
|
||||
file_key = component['path']
|
||||
if file_key.lower().endswith(('.jpg', '.jpeg')):
|
||||
mime_type = 'image/jpeg'
|
||||
elif file_key.lower().endswith('.png'):
|
||||
mime_type = 'image/png'
|
||||
elif file_key.lower().endswith('.gif'):
|
||||
mime_type = 'image/gif'
|
||||
elif file_key.lower().endswith('.webp'):
|
||||
mime_type = 'image/webp'
|
||||
else:
|
||||
mime_type = 'image/png' # 默认
|
||||
|
||||
component['base64'] = f'data:{mime_type};base64,{base64_str}'
|
||||
await storage_mgr.storage_provider.delete(component['path'])
|
||||
component['path'] = ''
|
||||
# 保留path字段用于后端处理,前端使用base64显示
|
||||
except Exception as e:
|
||||
await self.logger.error(f'加载图片文件失败 {component["path"]}: {e}')
|
||||
|
||||
async def handle_websocket_message(
|
||||
self,
|
||||
connection: WebSocketConnection,
|
||||
message_data: dict,
|
||||
):
|
||||
"""
|
||||
处理从WebSocket接收的消息
|
||||
|
||||
这个方法只负责接收消息、保存到历史记录、并触发事件处理
|
||||
不等待任何响应,响应消息会通过reply_message/reply_message_chunk直接发送
|
||||
|
||||
Args:
|
||||
connection: WebSocket连接对象
|
||||
message_data: 消息数据
|
||||
"""
|
||||
pipeline_uuid = connection.pipeline_uuid
|
||||
session_type = connection.session_type
|
||||
|
||||
# 选择会话
|
||||
use_session = self.websocket_group_session if session_type == 'group' else self.websocket_person_session
|
||||
|
||||
# 解析消息链
|
||||
message_chain_obj = message_data.get('message', [])
|
||||
|
||||
# 处理图片组件:将path转换为base64
|
||||
await self._process_image_components(message_chain_obj)
|
||||
|
||||
message_chain = platform_message.MessageChain.model_validate(message_chain_obj)
|
||||
|
||||
# 生成消息ID
|
||||
message_id = len(use_session.get_message_list(pipeline_uuid)) + 1
|
||||
|
||||
# 保存用户消息
|
||||
user_message = WebSocketMessage(
|
||||
id=message_id,
|
||||
role='user',
|
||||
content=str(message_chain),
|
||||
message_chain=message_chain_obj,
|
||||
timestamp=datetime.now().isoformat(),
|
||||
connection_id=connection.connection_id,
|
||||
is_final=True, # 用户消息始终是完整的,非流式
|
||||
)
|
||||
use_session.get_message_list(pipeline_uuid).append(user_message)
|
||||
|
||||
# 广播用户消息到所有连接(包括发送者),包含session_type信息
|
||||
await ws_connection_manager.broadcast_to_pipeline(
|
||||
pipeline_uuid,
|
||||
{
|
||||
'type': 'user_message',
|
||||
'session_type': session_type,
|
||||
'data': user_message.model_dump(),
|
||||
},
|
||||
session_type=session_type,
|
||||
)
|
||||
|
||||
# 添加消息源
|
||||
message_chain.insert(0, platform_message.Source(id=message_id, time=datetime.now().timestamp()))
|
||||
|
||||
# 创建事件
|
||||
if session_type == 'person':
|
||||
sender = platform_entities.Friend(
|
||||
id=f'websocket_{connection.connection_id}', nickname='User', remark='User'
|
||||
)
|
||||
event = platform_events.FriendMessage(
|
||||
sender=sender, message_chain=message_chain, time=datetime.now().timestamp()
|
||||
)
|
||||
else:
|
||||
group = platform_entities.Group(
|
||||
id='websocketgroup', name='Group', permission=platform_entities.Permission.Member
|
||||
)
|
||||
sender = platform_entities.GroupMember(
|
||||
id=f'websocket_{connection.connection_id}',
|
||||
member_name='User',
|
||||
group=group,
|
||||
permission=platform_entities.Permission.Member,
|
||||
)
|
||||
event = platform_events.GroupMessage(
|
||||
sender=sender, message_chain=message_chain, time=datetime.now().timestamp()
|
||||
)
|
||||
|
||||
# 设置流水线UUID
|
||||
self.ap.platform_mgr.websocket_proxy_bot.bot_entity.use_pipeline_uuid = pipeline_uuid
|
||||
|
||||
# 异步触发事件处理(不等待结果)
|
||||
if event.__class__ in self.listeners:
|
||||
asyncio.create_task(self.listeners[event.__class__](event, self))
|
||||
|
||||
def get_websocket_messages(self, pipeline_uuid: str, session_type: str) -> list[dict]:
|
||||
"""获取消息历史"""
|
||||
if session_type == 'person':
|
||||
return [message.model_dump() for message in self.websocket_person_session.get_message_list(pipeline_uuid)]
|
||||
else:
|
||||
return [message.model_dump() for message in self.websocket_group_session.get_message_list(pipeline_uuid)]
|
||||
|
||||
def reset_session(self, pipeline_uuid: str, session_type: str):
|
||||
"""重置会话"""
|
||||
if session_type == 'person':
|
||||
if pipeline_uuid in self.websocket_person_session.message_lists:
|
||||
self.websocket_person_session.message_lists[pipeline_uuid] = []
|
||||
else:
|
||||
if pipeline_uuid in self.websocket_group_session.message_lists:
|
||||
self.websocket_group_session.message_lists[pipeline_uuid] = []
|
||||
@@ -1,188 +0,0 @@
|
||||
"""WebSocket连接管理器 - 管理多个并发WebSocket连接"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import typing
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
import pydantic
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class WebSocketConnection(pydantic.BaseModel):
|
||||
"""单个WebSocket连接"""
|
||||
|
||||
model_config = pydantic.ConfigDict(arbitrary_types_allowed=True)
|
||||
|
||||
connection_id: str = pydantic.Field(default_factory=lambda: str(uuid.uuid4()))
|
||||
"""连接唯一ID"""
|
||||
|
||||
pipeline_uuid: str
|
||||
"""关联的流水线UUID"""
|
||||
|
||||
session_type: str # 'person' or 'group'
|
||||
"""会话类型"""
|
||||
|
||||
websocket: typing.Any = pydantic.Field(exclude=True)
|
||||
"""WebSocket连接对象 (quart.websocket)"""
|
||||
|
||||
created_at: datetime = pydantic.Field(default_factory=datetime.now)
|
||||
"""连接创建时间"""
|
||||
|
||||
last_active: datetime = pydantic.Field(default_factory=datetime.now)
|
||||
"""最后活跃时间"""
|
||||
|
||||
send_queue: asyncio.Queue = pydantic.Field(default_factory=asyncio.Queue, exclude=True)
|
||||
"""发送消息队列"""
|
||||
|
||||
is_active: bool = True
|
||||
"""连接是否活跃"""
|
||||
|
||||
metadata: dict = pydantic.Field(default_factory=dict)
|
||||
"""连接元数据(可存储额外信息)"""
|
||||
|
||||
|
||||
class WebSocketConnectionManager:
|
||||
"""WebSocket连接管理器 - 支持多连接并发"""
|
||||
|
||||
def __init__(self):
|
||||
self.connections: dict[str, WebSocketConnection] = {}
|
||||
"""所有活跃连接 {connection_id: connection}"""
|
||||
|
||||
self.pipeline_connections: dict[str, set[str]] = {}
|
||||
"""流水线到连接的映射 {pipeline_uuid: {connection_id, ...}}"""
|
||||
|
||||
self.session_connections: dict[str, set[str]] = {}
|
||||
"""会话类型到连接的映射 {session_type: {connection_id, ...}}"""
|
||||
|
||||
self._lock = asyncio.Lock()
|
||||
"""线程锁,保护并发访问"""
|
||||
|
||||
async def add_connection(
|
||||
self,
|
||||
websocket: typing.Any,
|
||||
pipeline_uuid: str,
|
||||
session_type: str,
|
||||
metadata: dict = None,
|
||||
) -> WebSocketConnection:
|
||||
"""添加新的WebSocket连接"""
|
||||
async with self._lock:
|
||||
connection = WebSocketConnection(
|
||||
pipeline_uuid=pipeline_uuid,
|
||||
session_type=session_type,
|
||||
websocket=websocket,
|
||||
metadata=metadata or {},
|
||||
)
|
||||
|
||||
self.connections[connection.connection_id] = connection
|
||||
|
||||
# 更新流水线映射
|
||||
if pipeline_uuid not in self.pipeline_connections:
|
||||
self.pipeline_connections[pipeline_uuid] = set()
|
||||
self.pipeline_connections[pipeline_uuid].add(connection.connection_id)
|
||||
|
||||
# 更新会话类型映射
|
||||
if session_type not in self.session_connections:
|
||||
self.session_connections[session_type] = set()
|
||||
self.session_connections[session_type].add(connection.connection_id)
|
||||
|
||||
logger.debug(
|
||||
f'WebSocket connection established: {connection.connection_id} '
|
||||
f'(pipeline={pipeline_uuid}, session_type={session_type})'
|
||||
)
|
||||
|
||||
return connection
|
||||
|
||||
async def remove_connection(self, connection_id: str):
|
||||
"""移除WebSocket连接"""
|
||||
async with self._lock:
|
||||
if connection_id not in self.connections:
|
||||
return
|
||||
|
||||
connection = self.connections[connection_id]
|
||||
connection.is_active = False
|
||||
|
||||
# 从流水线映射中移除
|
||||
if connection.pipeline_uuid in self.pipeline_connections:
|
||||
self.pipeline_connections[connection.pipeline_uuid].discard(connection_id)
|
||||
if not self.pipeline_connections[connection.pipeline_uuid]:
|
||||
del self.pipeline_connections[connection.pipeline_uuid]
|
||||
|
||||
# 从会话类型映射中移除
|
||||
if connection.session_type in self.session_connections:
|
||||
self.session_connections[connection.session_type].discard(connection_id)
|
||||
if not self.session_connections[connection.session_type]:
|
||||
del self.session_connections[connection.session_type]
|
||||
|
||||
del self.connections[connection_id]
|
||||
|
||||
logger.debug(f'WebSocket connection disconnected: {connection_id}')
|
||||
|
||||
async def get_connection(self, connection_id: str) -> typing.Optional[WebSocketConnection]:
|
||||
"""获取指定连接"""
|
||||
return self.connections.get(connection_id)
|
||||
|
||||
async def get_connections_by_pipeline(self, pipeline_uuid: str) -> list[WebSocketConnection]:
|
||||
"""获取指定流水线的所有连接"""
|
||||
connection_ids = self.pipeline_connections.get(pipeline_uuid, set())
|
||||
return [self.connections[cid] for cid in connection_ids if cid in self.connections]
|
||||
|
||||
async def get_connections_by_session_type(self, session_type: str) -> list[WebSocketConnection]:
|
||||
"""获取指定会话类型的所有连接"""
|
||||
connection_ids = self.session_connections.get(session_type, set())
|
||||
return [self.connections[cid] for cid in connection_ids if cid in self.connections]
|
||||
|
||||
async def broadcast_to_pipeline(self, pipeline_uuid: str, message: dict, session_type: str = None):
|
||||
"""向指定流水线的所有连接广播消息
|
||||
|
||||
Args:
|
||||
pipeline_uuid: 流水线UUID
|
||||
message: 要广播的消息
|
||||
session_type: 可选的会话类型过滤器,如果提供则只向匹配的session_type连接广播
|
||||
"""
|
||||
connections = await self.get_connections_by_pipeline(pipeline_uuid)
|
||||
|
||||
# 如果指定了session_type,只向匹配的连接广播
|
||||
if session_type is not None:
|
||||
connections = [conn for conn in connections if conn.session_type == session_type]
|
||||
|
||||
tasks = []
|
||||
for conn in connections:
|
||||
tasks.append(self.send_to_connection(conn.connection_id, message))
|
||||
if tasks:
|
||||
await asyncio.gather(*tasks, return_exceptions=True)
|
||||
|
||||
async def send_to_connection(self, connection_id: str, message: dict):
|
||||
"""向指定连接发送消息"""
|
||||
connection = await self.get_connection(connection_id)
|
||||
if not connection or not connection.is_active:
|
||||
logger.warning(f'Attempt to send message to invalid connection: {connection_id}')
|
||||
return
|
||||
|
||||
try:
|
||||
await connection.send_queue.put(message)
|
||||
connection.last_active = datetime.now()
|
||||
except Exception as e:
|
||||
logger.error(f'Failed to send message to connection {connection_id}: {e}')
|
||||
await self.remove_connection(connection_id)
|
||||
|
||||
async def update_activity(self, connection_id: str):
|
||||
"""更新连接活跃时间"""
|
||||
connection = await self.get_connection(connection_id)
|
||||
if connection:
|
||||
connection.last_active = datetime.now()
|
||||
|
||||
def get_stats(self) -> dict:
|
||||
"""获取连接统计信息"""
|
||||
return {
|
||||
'total_connections': len(self.connections),
|
||||
'pipelines': len(self.pipeline_connections),
|
||||
'connections_by_pipeline': {k: len(v) for k, v in self.pipeline_connections.items()},
|
||||
'connections_by_session_type': {k: len(v) for k, v in self.session_connections.items()},
|
||||
}
|
||||
|
||||
|
||||
# 全局连接管理器实例
|
||||
ws_connection_manager = WebSocketConnectionManager()
|
||||
@@ -8,8 +8,8 @@ import datetime
|
||||
from langbot.libs.wecom_api.api import WecomClient
|
||||
import langbot_plugin.api.definition.abstract.platform.adapter as abstract_platform_adapter
|
||||
from langbot.libs.wecom_api.wecomevent import WecomEvent
|
||||
from ...utils import image
|
||||
from ..logger import EventLogger
|
||||
from langbot.pkg.utils import image
|
||||
from langbot.pkg.platform.logger import EventLogger
|
||||
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
|
||||
@@ -131,7 +131,6 @@ class WecomAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
||||
message_converter: WecomMessageConverter = WecomMessageConverter()
|
||||
event_converter: WecomEventConverter = WecomEventConverter()
|
||||
config: dict
|
||||
bot_uuid: str = None
|
||||
|
||||
def __init__(self, config: dict, logger: EventLogger):
|
||||
# 校验必填项
|
||||
@@ -142,12 +141,11 @@ class WecomAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
||||
'EncodingAESKey',
|
||||
'contacts_secret',
|
||||
]
|
||||
|
||||
missing_keys = [key for key in required_keys if key not in config]
|
||||
if missing_keys:
|
||||
raise Exception(f'Wecom 缺少配置项: {missing_keys}')
|
||||
|
||||
# 创建运行时 bot 对象,始终使用统一 webhook 模式
|
||||
# 创建运行时 bot 对象
|
||||
bot = WecomClient(
|
||||
corpid=config['corpid'],
|
||||
secret=config['secret'],
|
||||
@@ -155,7 +153,6 @@ class WecomAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
||||
EncodingAESKey=config['EncodingAESKey'],
|
||||
contacts_secret=config['contacts_secret'],
|
||||
logger=logger,
|
||||
unified_mode=True,
|
||||
)
|
||||
|
||||
super().__init__(
|
||||
@@ -165,10 +162,6 @@ class WecomAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
||||
bot_account_id='',
|
||||
)
|
||||
|
||||
def set_bot_uuid(self, bot_uuid: str):
|
||||
"""设置 bot UUID(用于生成 webhook URL)"""
|
||||
self.bot_uuid = bot_uuid
|
||||
|
||||
async def reply_message(
|
||||
self,
|
||||
message_source: platform_events.MessageEvent,
|
||||
@@ -187,6 +180,9 @@ class WecomAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
||||
await self.bot.send_image(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):
|
||||
"""企业微信目前只有发送给个人的方法,
|
||||
构造target_id的方式为前半部分为账户id,后半部分为agent_id,中间使用“|”符号隔开。
|
||||
"""
|
||||
content_list = await WecomMessageConverter.yiri2target(message, self.bot)
|
||||
parts = target_id.split('|')
|
||||
user_id = parts[0]
|
||||
@@ -218,38 +214,16 @@ class WecomAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
||||
elif event_type == platform_events.GroupMessage:
|
||||
pass
|
||||
|
||||
async def handle_unified_webhook(self, bot_uuid: str, path: str, request):
|
||||
"""处理统一 webhook 请求。
|
||||
|
||||
Args:
|
||||
bot_uuid: Bot 的 UUID
|
||||
path: 子路径(如果有的话)
|
||||
request: Quart Request 对象
|
||||
|
||||
Returns:
|
||||
响应数据
|
||||
"""
|
||||
return await self.bot.handle_unified_webhook(request)
|
||||
|
||||
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 shutdown_trigger_placeholder():
|
||||
while True:
|
||||
await asyncio.sleep(1)
|
||||
|
||||
await keep_alive()
|
||||
await self.bot.run_task(
|
||||
host=self.config['host'],
|
||||
port=self.config['port'],
|
||||
shutdown_trigger=shutdown_trigger_placeholder,
|
||||
)
|
||||
|
||||
async def kill(self) -> bool:
|
||||
return False
|
||||
|
||||
@@ -11,6 +11,23 @@ metadata:
|
||||
icon: wecom.png
|
||||
spec:
|
||||
config:
|
||||
- name: host
|
||||
label:
|
||||
en_US: Host
|
||||
zh_Hans: 监听主机
|
||||
description:
|
||||
en_US: Webhook host, unless you know what you're doing, please write 0.0.0.0
|
||||
zh_Hans: Webhook 监听主机,除非你知道自己在做什么,否则请写 0.0.0.0
|
||||
type: string
|
||||
required: true
|
||||
default: "0.0.0.0"
|
||||
- name: port
|
||||
label:
|
||||
en_US: Port
|
||||
zh_Hans: 监听端口
|
||||
type: integer
|
||||
required: true
|
||||
default: 2290
|
||||
- name: corpid
|
||||
label:
|
||||
en_US: Corpid
|
||||
|
||||
@@ -8,7 +8,7 @@ import langbot_plugin.api.definition.abstract.platform.adapter as abstract_platf
|
||||
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
|
||||
from ..logger import EventLogger
|
||||
from langbot.pkg.platform.logger import EventLogger
|
||||
from langbot.libs.wecom_ai_bot_api.wecombotevent import WecomBotEvent
|
||||
from langbot.libs.wecom_ai_bot_api.api import WecomBotClient
|
||||
|
||||
@@ -88,20 +88,19 @@ class WecomBotAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
||||
message_converter: WecomBotMessageConverter = WecomBotMessageConverter()
|
||||
event_converter: WecomBotEventConverter = WecomBotEventConverter()
|
||||
config: dict
|
||||
bot_uuid: str = None
|
||||
|
||||
def __init__(self, config: dict, logger: EventLogger):
|
||||
required_keys = ['Token', 'EncodingAESKey', 'Corpid', 'BotId']
|
||||
required_keys = ['Token', 'EncodingAESKey', 'Corpid', 'BotId', 'port']
|
||||
missing_keys = [key for key in required_keys if key not in config]
|
||||
if missing_keys:
|
||||
raise Exception(f'WecomBot 缺少配置项: {missing_keys}')
|
||||
|
||||
# 创建运行时 bot 对象
|
||||
bot = WecomBotClient(
|
||||
Token=config['Token'],
|
||||
EnCodingAESKey=config['EncodingAESKey'],
|
||||
Corpid=config['Corpid'],
|
||||
logger=logger,
|
||||
unified_mode=True,
|
||||
)
|
||||
bot_account_id = config['BotId']
|
||||
|
||||
@@ -190,46 +189,16 @@ class WecomBotAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
||||
except Exception:
|
||||
print(traceback.format_exc())
|
||||
|
||||
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:
|
||||
响应数据
|
||||
"""
|
||||
return await self.bot.handle_unified_webhook(request)
|
||||
|
||||
async def run_async(self):
|
||||
# 统一 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 shutdown_trigger_placeholder():
|
||||
while True:
|
||||
await asyncio.sleep(1)
|
||||
|
||||
await keep_alive()
|
||||
await self.bot.run_task(
|
||||
host='0.0.0.0',
|
||||
port=self.config['port'],
|
||||
shutdown_trigger=shutdown_trigger_placeholder,
|
||||
)
|
||||
|
||||
async def kill(self) -> bool:
|
||||
return False
|
||||
|
||||
@@ -11,6 +11,13 @@ metadata:
|
||||
icon: wecombot.png
|
||||
spec:
|
||||
config:
|
||||
- name: port
|
||||
label:
|
||||
en_US: Port
|
||||
zh_Hans: 监听端口
|
||||
type: integer
|
||||
required: true
|
||||
default: 2291
|
||||
- name: Corpid
|
||||
label:
|
||||
en_US: Corpid
|
||||
|
||||
@@ -121,7 +121,6 @@ class WecomCSAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
||||
bot: WecomCSClient = pydantic.Field(exclude=True)
|
||||
message_converter: WecomMessageConverter = WecomMessageConverter()
|
||||
event_converter: WecomEventConverter = WecomEventConverter()
|
||||
bot_uuid: str = None
|
||||
|
||||
def __init__(self, config: dict, logger: abstract_platform_logger.AbstractEventLogger):
|
||||
required_keys = [
|
||||
@@ -140,7 +139,6 @@ class WecomCSAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
||||
token=config['token'],
|
||||
EncodingAESKey=config['EncodingAESKey'],
|
||||
logger=logger,
|
||||
unified_mode=True,
|
||||
)
|
||||
|
||||
super().__init__(
|
||||
@@ -172,10 +170,6 @@ class WecomCSAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
||||
async def send_message(self, target_type: str, target_id: str, message: platform_message.MessageChain):
|
||||
pass
|
||||
|
||||
def set_bot_uuid(self, bot_uuid: str):
|
||||
"""设置 bot UUID(用于生成 webhook URL)"""
|
||||
self.bot_uuid = bot_uuid
|
||||
|
||||
def register_listener(
|
||||
self,
|
||||
event_type: typing.Type[platform_events.Event],
|
||||
@@ -196,41 +190,16 @@ class WecomCSAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
||||
elif event_type == platform_events.GroupMessage:
|
||||
pass
|
||||
|
||||
async def handle_unified_webhook(self, bot_uuid: str, path: str, request):
|
||||
"""处理统一 webhook 请求。
|
||||
|
||||
Args:
|
||||
bot_uuid: Bot 的 UUID
|
||||
path: 子路径(如果有的话)
|
||||
request: Quart Request 对象
|
||||
|
||||
Returns:
|
||||
响应数据
|
||||
"""
|
||||
return await self.bot.handle_unified_webhook(request)
|
||||
|
||||
async def run_async(self):
|
||||
# 统一 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 shutdown_trigger_placeholder():
|
||||
while True:
|
||||
await asyncio.sleep(1)
|
||||
await keep_alive()
|
||||
|
||||
await self.bot.run_task(
|
||||
host='0.0.0.0',
|
||||
port=self.config['port'],
|
||||
shutdown_trigger=shutdown_trigger_placeholder,
|
||||
)
|
||||
|
||||
async def kill(self) -> bool:
|
||||
return False
|
||||
|
||||
@@ -11,6 +11,13 @@ metadata:
|
||||
icon: wecom.png
|
||||
spec:
|
||||
config:
|
||||
- name: port
|
||||
label:
|
||||
en_US: Port
|
||||
zh_Hans: 监听端口
|
||||
type: int
|
||||
required: true
|
||||
default: 2289
|
||||
- name: corpid
|
||||
label:
|
||||
en_US: Corpid
|
||||
|
||||
@@ -7,10 +7,7 @@ import typing
|
||||
import os
|
||||
import sys
|
||||
import httpx
|
||||
import traceback
|
||||
import sqlalchemy
|
||||
from async_lru import alru_cache
|
||||
from langbot_plugin.api.entities.builtin.pipeline.query import provider_session
|
||||
|
||||
from ..core import app
|
||||
from . import handler
|
||||
@@ -29,7 +26,6 @@ from langbot_plugin.api.entities.builtin.command import (
|
||||
)
|
||||
from langbot_plugin.runtime.plugin.mgr import PluginInstallSource
|
||||
from ..core import taskmgr
|
||||
from ..entity.persistence import plugin as persistence_plugin
|
||||
|
||||
|
||||
class PluginRuntimeConnector:
|
||||
@@ -102,12 +98,6 @@ class PluginRuntimeConnector:
|
||||
self.handler_task = asyncio.create_task(self.handler.run())
|
||||
_ = await self.handler.ping()
|
||||
self.ap.logger.info('Connected to plugin runtime.')
|
||||
# Sync polymorphic component instances after connection
|
||||
try:
|
||||
await self.sync_polymorphic_component_instances()
|
||||
except Exception as e:
|
||||
traceback.print_exc()
|
||||
self.ap.logger.error(f'Failed to sync polymorphic component instances: {e}')
|
||||
await self.handler_task
|
||||
|
||||
task: asyncio.Task | None = None
|
||||
@@ -284,88 +274,11 @@ class PluginRuntimeConnector:
|
||||
task_context.trace('Cleaning up plugin configuration and storage...')
|
||||
await self.handler.cleanup_plugin_data(plugin_author, plugin_name)
|
||||
|
||||
async def list_plugins(self, component_kinds: list[str] | None = None) -> list[dict[str, Any]]:
|
||||
"""List plugins, optionally filtered by component kinds.
|
||||
|
||||
Args:
|
||||
component_kinds: Optional list of component kinds to filter by.
|
||||
If provided, only plugins that contain at least one
|
||||
component of the specified kinds will be returned.
|
||||
E.g., ['Command', 'EventListener', 'Tool'] for pipeline-related plugins.
|
||||
"""
|
||||
async def list_plugins(self) -> list[dict[str, Any]]:
|
||||
if not self.is_enable_plugin:
|
||||
return []
|
||||
|
||||
plugins = await self.handler.list_plugins()
|
||||
|
||||
# Filter plugins by component kinds if specified
|
||||
if component_kinds is not None:
|
||||
filtered_plugins = []
|
||||
for plugin in plugins:
|
||||
components = plugin.get('components', [])
|
||||
has_matching_component = False
|
||||
for component in components:
|
||||
component_kind = component.get('manifest', {}).get('manifest', {}).get('kind', '')
|
||||
if component_kind in component_kinds:
|
||||
has_matching_component = True
|
||||
break
|
||||
if has_matching_component:
|
||||
filtered_plugins.append(plugin)
|
||||
plugins = filtered_plugins
|
||||
|
||||
# Sort plugins: debug plugins first, then by installation time (newest first)
|
||||
# Get installation timestamps from database in a single query
|
||||
plugin_timestamps = {}
|
||||
|
||||
if plugins:
|
||||
# Build list of (author, name) tuples for all plugins
|
||||
plugin_ids = []
|
||||
for plugin in plugins:
|
||||
author = plugin.get('manifest', {}).get('manifest', {}).get('metadata', {}).get('author', '')
|
||||
name = plugin.get('manifest', {}).get('manifest', {}).get('metadata', {}).get('name', '')
|
||||
if author and name:
|
||||
plugin_ids.append((author, name))
|
||||
|
||||
# Fetch all timestamps in a single query using OR conditions
|
||||
if plugin_ids:
|
||||
conditions = [
|
||||
sqlalchemy.and_(
|
||||
persistence_plugin.PluginSetting.plugin_author == author,
|
||||
persistence_plugin.PluginSetting.plugin_name == name,
|
||||
)
|
||||
for author, name in plugin_ids
|
||||
]
|
||||
|
||||
result = await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.select(
|
||||
persistence_plugin.PluginSetting.plugin_author,
|
||||
persistence_plugin.PluginSetting.plugin_name,
|
||||
persistence_plugin.PluginSetting.created_at,
|
||||
).where(sqlalchemy.or_(*conditions))
|
||||
)
|
||||
|
||||
for row in result:
|
||||
plugin_id = f'{row.plugin_author}/{row.plugin_name}'
|
||||
plugin_timestamps[plugin_id] = row.created_at
|
||||
|
||||
# Sort: debug plugins first (descending), then by created_at (descending)
|
||||
def sort_key(plugin):
|
||||
author = plugin.get('manifest', {}).get('manifest', {}).get('metadata', {}).get('author', '')
|
||||
name = plugin.get('manifest', {}).get('manifest', {}).get('metadata', {}).get('name', '')
|
||||
plugin_id = f'{author}/{name}'
|
||||
|
||||
is_debug = plugin.get('debug', False)
|
||||
created_at = plugin_timestamps.get(plugin_id)
|
||||
|
||||
# Return tuple: (not is_debug, -timestamp)
|
||||
# not is_debug: False (0) for debug plugins, True (1) for non-debug
|
||||
# -timestamp: to sort newest first (will be None for plugins without timestamp)
|
||||
timestamp_value = -created_at.timestamp() if created_at else 0
|
||||
return (not is_debug, timestamp_value)
|
||||
|
||||
plugins.sort(key=sort_key)
|
||||
|
||||
return plugins
|
||||
return await self.handler.list_plugins()
|
||||
|
||||
async def get_plugin_info(self, author: str, plugin_name: str) -> dict[str, Any]:
|
||||
return await self.handler.get_plugin_info(author, plugin_name)
|
||||
@@ -377,20 +290,6 @@ class PluginRuntimeConnector:
|
||||
async def get_plugin_icon(self, plugin_author: str, plugin_name: str) -> dict[str, Any]:
|
||||
return await self.handler.get_plugin_icon(plugin_author, plugin_name)
|
||||
|
||||
@alru_cache(ttl=5 * 60) # 5 minutes
|
||||
async def get_plugin_readme(self, plugin_author: str, plugin_name: str, language: str = 'en') -> str:
|
||||
return await self.handler.get_plugin_readme(plugin_author, plugin_name, language)
|
||||
|
||||
@alru_cache(ttl=5 * 60)
|
||||
async def get_plugin_assets(self, plugin_author: str, plugin_name: str, filepath: str) -> dict[str, Any]:
|
||||
return await self.handler.get_plugin_assets(plugin_author, plugin_name, filepath)
|
||||
|
||||
async def get_debug_info(self) -> dict[str, Any]:
|
||||
"""Get debug information including debug key and WS URL"""
|
||||
if not self.is_enable_plugin:
|
||||
return {}
|
||||
return await self.handler.get_debug_info()
|
||||
|
||||
async def emit_event(
|
||||
self,
|
||||
event: events.BaseEventModel,
|
||||
@@ -422,20 +321,13 @@ class PluginRuntimeConnector:
|
||||
return tools
|
||||
|
||||
async def call_tool(
|
||||
self,
|
||||
tool_name: str,
|
||||
parameters: dict[str, Any],
|
||||
session: provider_session.Session,
|
||||
query_id: int,
|
||||
bound_plugins: list[str] | None = None,
|
||||
self, tool_name: str, parameters: dict[str, Any], bound_plugins: list[str] | None = None
|
||||
) -> dict[str, Any]:
|
||||
if not self.is_enable_plugin:
|
||||
return {'error': 'Tool not found: plugin system is disabled'}
|
||||
|
||||
# Pass include_plugins to runtime for validation
|
||||
return await self.handler.call_tool(
|
||||
tool_name, parameters, session.model_dump(serialize_as_any=True), query_id, include_plugins=bound_plugins
|
||||
)
|
||||
return await self.handler.call_tool(tool_name, parameters, include_plugins=bound_plugins)
|
||||
|
||||
async def list_commands(self, bound_plugins: list[str] | None = None) -> list[ComponentManifest]:
|
||||
if not self.is_enable_plugin:
|
||||
@@ -463,31 +355,6 @@ class PluginRuntimeConnector:
|
||||
|
||||
yield cmd_ret
|
||||
|
||||
# KnowledgeRetriever methods
|
||||
async def list_knowledge_retrievers(self, bound_plugins: list[str] | None = None) -> list[dict[str, Any]]:
|
||||
"""List all available KnowledgeRetriever components."""
|
||||
if not self.is_enable_plugin:
|
||||
return []
|
||||
|
||||
retrievers_data = await self.handler.list_knowledge_retrievers(include_plugins=bound_plugins)
|
||||
return retrievers_data
|
||||
|
||||
async def retrieve_knowledge(
|
||||
self,
|
||||
plugin_author: str,
|
||||
plugin_name: str,
|
||||
retriever_name: str,
|
||||
instance_id: str,
|
||||
retrieval_context: dict[str, Any],
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Retrieve knowledge using a KnowledgeRetriever instance."""
|
||||
if not self.is_enable_plugin:
|
||||
return []
|
||||
|
||||
return await self.handler.retrieve_knowledge(
|
||||
plugin_author, plugin_name, retriever_name, instance_id, retrieval_context
|
||||
)
|
||||
|
||||
def dispose(self):
|
||||
# No need to consider the shutdown on Windows
|
||||
# for Windows can kill processes and subprocesses chainly
|
||||
@@ -499,42 +366,3 @@ class PluginRuntimeConnector:
|
||||
if self.heartbeat_task is not None:
|
||||
self.heartbeat_task.cancel()
|
||||
self.heartbeat_task = None
|
||||
|
||||
async def sync_polymorphic_component_instances(self) -> dict[str, Any]:
|
||||
"""Sync polymorphic component instances with runtime.
|
||||
|
||||
This collects all external knowledge bases from database and sends to runtime
|
||||
to ensure instance integrity across restarts.
|
||||
"""
|
||||
if not self.is_enable_plugin:
|
||||
return {}
|
||||
|
||||
# ===== external knowledge bases =====
|
||||
|
||||
external_kbs = await self.ap.external_kb_service.get_external_knowledge_bases()
|
||||
|
||||
# Build required_instances list
|
||||
required_instances = []
|
||||
for kb in external_kbs:
|
||||
required_instances.append(
|
||||
{
|
||||
'instance_id': kb['uuid'],
|
||||
'plugin_author': kb['plugin_author'],
|
||||
'plugin_name': kb['plugin_name'],
|
||||
'component_kind': 'KnowledgeRetriever',
|
||||
'component_name': kb['retriever_name'],
|
||||
'config': kb['retriever_config'],
|
||||
}
|
||||
)
|
||||
|
||||
self.ap.logger.info(f'Syncing {len(required_instances)} polymorphic component instances to runtime')
|
||||
|
||||
# Send to runtime
|
||||
sync_result = await self.handler.sync_polymorphic_component_instances(required_instances)
|
||||
|
||||
self.ap.logger.info(
|
||||
f'Sync complete: {len(sync_result.get("success_instances", []))} succeeded, '
|
||||
f'{len(sync_result.get("failed_instances", []))} failed'
|
||||
)
|
||||
|
||||
return sync_result
|
||||
|
||||
@@ -602,51 +602,6 @@ class RuntimeConnectionHandler(handler.Handler):
|
||||
'mime_type': mime_type,
|
||||
}
|
||||
|
||||
async def get_plugin_readme(self, plugin_author: str, plugin_name: str, language: str = 'en') -> str:
|
||||
"""Get plugin readme"""
|
||||
try:
|
||||
result = await self.call_action(
|
||||
LangBotToRuntimeAction.GET_PLUGIN_README,
|
||||
{
|
||||
'plugin_author': plugin_author,
|
||||
'plugin_name': plugin_name,
|
||||
'language': language,
|
||||
},
|
||||
timeout=20,
|
||||
)
|
||||
except Exception:
|
||||
traceback.print_exc()
|
||||
return ''
|
||||
|
||||
readme_file_key = result.get('readme_file_key')
|
||||
if not readme_file_key:
|
||||
return ''
|
||||
|
||||
readme_bytes = await self.read_local_file(readme_file_key)
|
||||
await self.delete_local_file(readme_file_key)
|
||||
|
||||
return readme_bytes.decode('utf-8')
|
||||
|
||||
async def get_plugin_assets(self, plugin_author: str, plugin_name: str, filepath: str) -> dict[str, Any]:
|
||||
"""Get plugin assets"""
|
||||
result = await self.call_action(
|
||||
LangBotToRuntimeAction.GET_PLUGIN_ASSETS_FILE,
|
||||
{
|
||||
'plugin_author': plugin_author,
|
||||
'plugin_name': plugin_name,
|
||||
'file_path': filepath,
|
||||
},
|
||||
timeout=20,
|
||||
)
|
||||
asset_file_key = result['file_file_key']
|
||||
mime_type = result['mime_type']
|
||||
asset_bytes = await self.read_local_file(asset_file_key)
|
||||
await self.delete_local_file(asset_file_key)
|
||||
return {
|
||||
'asset_base64': base64.b64encode(asset_bytes).decode('utf-8'),
|
||||
'mime_type': mime_type,
|
||||
}
|
||||
|
||||
async def cleanup_plugin_data(self, plugin_author: str, plugin_name: str) -> None:
|
||||
"""Cleanup plugin settings and binary storage"""
|
||||
# Delete plugin settings
|
||||
@@ -665,12 +620,7 @@ class RuntimeConnectionHandler(handler.Handler):
|
||||
)
|
||||
|
||||
async def call_tool(
|
||||
self,
|
||||
tool_name: str,
|
||||
parameters: dict[str, Any],
|
||||
session: dict[str, Any],
|
||||
query_id: int,
|
||||
include_plugins: list[str] | None = None,
|
||||
self, tool_name: str, parameters: dict[str, Any], include_plugins: list[str] | None = None
|
||||
) -> dict[str, Any]:
|
||||
"""Call tool"""
|
||||
result = await self.call_action(
|
||||
@@ -678,11 +628,9 @@ class RuntimeConnectionHandler(handler.Handler):
|
||||
{
|
||||
'tool_name': tool_name,
|
||||
'tool_parameters': parameters,
|
||||
'session': session,
|
||||
'query_id': query_id,
|
||||
'include_plugins': include_plugins,
|
||||
},
|
||||
timeout=180,
|
||||
timeout=60,
|
||||
)
|
||||
|
||||
return result['tool_response']
|
||||
@@ -708,62 +656,8 @@ class RuntimeConnectionHandler(handler.Handler):
|
||||
'command_context': command_context,
|
||||
'include_plugins': include_plugins,
|
||||
},
|
||||
timeout=180,
|
||||
timeout=60,
|
||||
)
|
||||
|
||||
async for ret in gen:
|
||||
yield ret
|
||||
|
||||
# KnowledgeRetriever methods
|
||||
async def list_knowledge_retrievers(self, include_plugins: list[str] | None = None) -> list[dict[str, Any]]:
|
||||
"""List knowledge retrievers"""
|
||||
result = await self.call_action(
|
||||
LangBotToRuntimeAction.LIST_KNOWLEDGE_RETRIEVERS,
|
||||
{
|
||||
'include_plugins': include_plugins,
|
||||
},
|
||||
timeout=10,
|
||||
)
|
||||
return result['retrievers']
|
||||
|
||||
async def retrieve_knowledge(
|
||||
self,
|
||||
plugin_author: str,
|
||||
plugin_name: str,
|
||||
retriever_name: str,
|
||||
instance_id: str,
|
||||
retrieval_context: dict[str, Any],
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Retrieve knowledge"""
|
||||
result = await self.call_action(
|
||||
LangBotToRuntimeAction.RETRIEVE_KNOWLEDGE,
|
||||
{
|
||||
'plugin_author': plugin_author,
|
||||
'plugin_name': plugin_name,
|
||||
'retriever_name': retriever_name,
|
||||
'instance_id': instance_id,
|
||||
'retrieval_context': retrieval_context,
|
||||
},
|
||||
timeout=30,
|
||||
)
|
||||
return result['retrieval_results']
|
||||
|
||||
async def sync_polymorphic_component_instances(self, required_instances: list[dict[str, Any]]) -> dict[str, Any]:
|
||||
"""Sync polymorphic component instances with runtime"""
|
||||
result = await self.call_action(
|
||||
LangBotToRuntimeAction.SYNC_POLYMORPHIC_COMPONENT_INSTANCES,
|
||||
{
|
||||
'required_instances': required_instances,
|
||||
},
|
||||
timeout=30,
|
||||
)
|
||||
return result
|
||||
|
||||
async def get_debug_info(self) -> dict[str, Any]:
|
||||
"""Get debug information including debug key and WS URL"""
|
||||
result = await self.call_action(
|
||||
LangBotToRuntimeAction.GET_DEBUG_INFO,
|
||||
{},
|
||||
timeout=10,
|
||||
)
|
||||
return result
|
||||
|
||||
@@ -6,7 +6,6 @@ import typing
|
||||
from .. import runner
|
||||
import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query
|
||||
import langbot_plugin.api.entities.builtin.provider.message as provider_message
|
||||
import langbot_plugin.api.entities.builtin.rag.context as rag_context
|
||||
|
||||
|
||||
rag_combined_prompt_template = """
|
||||
@@ -64,7 +63,7 @@ class LocalAgentRunner(runner.RequestRunner):
|
||||
|
||||
if kb_uuids and user_message_text:
|
||||
# only support text for now
|
||||
all_results: list[rag_context.RetrievalResultEntry] = []
|
||||
all_results = []
|
||||
|
||||
# Retrieve from each knowledge base
|
||||
for kb_uuid in kb_uuids:
|
||||
@@ -74,15 +73,7 @@ class LocalAgentRunner(runner.RequestRunner):
|
||||
self.ap.logger.warning(f'Knowledge base {kb_uuid} not found, skipping')
|
||||
continue
|
||||
|
||||
# Get top_k based on KB type
|
||||
if kb.get_type() == 'internal':
|
||||
top_k = kb.knowledge_base_entity.top_k
|
||||
elif kb.get_type() == 'external':
|
||||
top_k = 5 # external kb's top_k is managed by plugin config
|
||||
else:
|
||||
top_k = 5 # default fallback
|
||||
|
||||
result = await kb.retrieve(user_message_text, top_k)
|
||||
result = await kb.retrieve(user_message_text, kb.knowledge_base_entity.top_k)
|
||||
|
||||
if result:
|
||||
all_results.extend(result)
|
||||
@@ -90,14 +81,9 @@ class LocalAgentRunner(runner.RequestRunner):
|
||||
final_user_message_text = ''
|
||||
|
||||
if all_results:
|
||||
texts = []
|
||||
idx = 1
|
||||
for entry in all_results:
|
||||
for content in entry.content:
|
||||
if content.type == 'text' and content.text is not None:
|
||||
texts.append(f'[{idx}] {content.text}')
|
||||
idx += 1
|
||||
rag_context = '\n\n'.join(texts)
|
||||
rag_context = '\n\n'.join(
|
||||
f'[{i + 1}] {entry.metadata.get("text", "")}' for i, entry in enumerate(all_results)
|
||||
)
|
||||
final_user_message_text = rag_combined_prompt_template.format(
|
||||
rag_context=rag_context, user_message=user_message_text
|
||||
)
|
||||
@@ -123,10 +109,6 @@ class LocalAgentRunner(runner.RequestRunner):
|
||||
|
||||
use_llm_model = await self.ap.model_mgr.get_model_by_uuid(query.use_llm_model_uuid)
|
||||
|
||||
self.ap.logger.debug(
|
||||
f'localagent req: query={query.query_id} req_messages={req_messages} use_llm_model={query.use_llm_model_uuid}'
|
||||
)
|
||||
|
||||
if not is_stream:
|
||||
# 非流式输出,直接请求
|
||||
|
||||
@@ -214,7 +196,7 @@ class LocalAgentRunner(runner.RequestRunner):
|
||||
|
||||
parameters = json.loads(func.arguments)
|
||||
|
||||
func_ret = await self.ap.tool_mgr.execute_func_call(func.name, parameters, query=query)
|
||||
func_ret = await self.ap.tool_mgr.execute_func_call(func.name, parameters)
|
||||
if is_stream:
|
||||
msg = provider_message.MessageChunk(
|
||||
role='tool',
|
||||
@@ -239,10 +221,6 @@ class LocalAgentRunner(runner.RequestRunner):
|
||||
|
||||
req_messages.append(err_msg)
|
||||
|
||||
self.ap.logger.debug(
|
||||
f'localagent req: query={query.query_id} req_messages={req_messages} use_llm_model={query.use_llm_model_uuid}'
|
||||
)
|
||||
|
||||
if is_stream:
|
||||
tool_calls_map = {}
|
||||
msg_idx = 0
|
||||
|
||||
@@ -68,33 +68,6 @@ class N8nServiceAPIRunner(runner.RequestRunner):
|
||||
|
||||
return plain_text
|
||||
|
||||
async def _process_stream_response(self, response: aiohttp.ClientResponse) -> typing.AsyncGenerator[
|
||||
provider_message.Message, None]:
|
||||
"""处理流式响应"""
|
||||
full_content = ""
|
||||
message_idx = 0
|
||||
is_final = False
|
||||
async for chunk in response.content.iter_chunked(1024):
|
||||
if not chunk:
|
||||
continue
|
||||
|
||||
try:
|
||||
data = json.loads(chunk)
|
||||
if data.get('type') == 'item' and 'content' in data:
|
||||
message_idx += 1
|
||||
content = data['content']
|
||||
full_content += content
|
||||
elif data.get('type') == 'end':
|
||||
is_final = True
|
||||
if is_final or message_idx % 8 == 0:
|
||||
yield provider_message.MessageChunk(
|
||||
role='assistant',
|
||||
content=full_content,
|
||||
is_final=is_final,
|
||||
)
|
||||
except json.JSONDecodeError:
|
||||
self.ap.logger.warning(f"Failed to parse final JSON line: {response.text()}")
|
||||
|
||||
async def _call_webhook(self, query: pipeline_query.Query) -> typing.AsyncGenerator[provider_message.Message, None]:
|
||||
"""调用n8n webhook"""
|
||||
# 生成会话ID(如果不存在)
|
||||
@@ -107,7 +80,6 @@ class N8nServiceAPIRunner(runner.RequestRunner):
|
||||
# 准备请求数据
|
||||
payload = {
|
||||
# 基本消息内容
|
||||
'chatInput' :plain_text, # 考虑到之前用户直接用的message model这里添加新键
|
||||
'message': plain_text,
|
||||
'user_message_text': plain_text,
|
||||
'conversation_id': query.session.using_conversation.uuid,
|
||||
@@ -119,11 +91,6 @@ class N8nServiceAPIRunner(runner.RequestRunner):
|
||||
# 添加所有变量到payload
|
||||
payload.update(query.variables)
|
||||
|
||||
try:
|
||||
is_stream = await query.adapter.is_stream_output_supported()
|
||||
except AttributeError:
|
||||
is_stream = False
|
||||
|
||||
try:
|
||||
# 准备请求头和认证信息
|
||||
headers = {}
|
||||
@@ -159,57 +126,30 @@ class N8nServiceAPIRunner(runner.RequestRunner):
|
||||
|
||||
# 调用webhook
|
||||
async with aiohttp.ClientSession() as session:
|
||||
if is_stream:
|
||||
# 流式请求
|
||||
async with session.post(
|
||||
self.webhook_url,
|
||||
json=payload,
|
||||
headers=headers,
|
||||
auth=auth,
|
||||
timeout=self.timeout
|
||||
) as response:
|
||||
if response.status != 200:
|
||||
error_text = await response.text()
|
||||
self.ap.logger.error(f'n8n webhook call failed: {response.status}, {error_text}')
|
||||
raise Exception(f'n8n webhook call failed: {response.status}, {error_text}')
|
||||
async with session.post(
|
||||
self.webhook_url, json=payload, headers=headers, auth=auth, timeout=self.timeout
|
||||
) as response:
|
||||
if response.status != 200:
|
||||
error_text = await response.text()
|
||||
self.ap.logger.error(f'n8n webhook call failed: {response.status}, {error_text}')
|
||||
raise Exception(f'n8n webhook call failed: {response.status}, {error_text}')
|
||||
|
||||
# 处理流式响应
|
||||
async for chunk in self._process_stream_response(response):
|
||||
yield chunk
|
||||
# 解析响应
|
||||
response_data = await response.json()
|
||||
self.ap.logger.debug(f'n8n webhook response: {response_data}')
|
||||
|
||||
# 从响应中提取输出
|
||||
if self.output_key in response_data:
|
||||
output_content = response_data[self.output_key]
|
||||
else:
|
||||
async with session.post(
|
||||
self.webhook_url,
|
||||
json=payload,
|
||||
headers=headers,
|
||||
auth=auth,
|
||||
timeout=self.timeout
|
||||
) as response:
|
||||
try:
|
||||
async for chunk in self._process_stream_response(response):
|
||||
output_content = chunk.content if chunk.is_final else ''
|
||||
except:
|
||||
# 非流式请求(保持原有逻辑)
|
||||
if response.status != 200:
|
||||
error_text = await response.text()
|
||||
self.ap.logger.error(f'n8n webhook call failed: {response.status}, {error_text}')
|
||||
raise Exception(f'n8n webhook call failed: {response.status}, {error_text}')
|
||||
# 如果没有指定的输出键,则使用整个响应
|
||||
output_content = json.dumps(response_data, ensure_ascii=False)
|
||||
|
||||
# 解析响应
|
||||
response_data = await response.json()
|
||||
self.ap.logger.debug(f'n8n webhook response: {response_data}')
|
||||
|
||||
# 从响应中提取输出
|
||||
if self.output_key in response_data:
|
||||
output_content = response_data[self.output_key]
|
||||
else:
|
||||
# 如果没有指定的输出键,则使用整个响应
|
||||
output_content = json.dumps(response_data, ensure_ascii=False)
|
||||
|
||||
# 返回消息
|
||||
yield provider_message.Message(
|
||||
role='assistant',
|
||||
content=output_content,
|
||||
)
|
||||
# 返回消息
|
||||
yield provider_message.Message(
|
||||
role='assistant',
|
||||
content=output_content,
|
||||
)
|
||||
except Exception as e:
|
||||
self.ap.logger.error(f'n8n webhook call exception: {str(e)}')
|
||||
raise N8nAPIError(f'n8n webhook call exception: {str(e)}')
|
||||
@@ -217,4 +157,4 @@ class N8nServiceAPIRunner(runner.RequestRunner):
|
||||
async def run(self, query: pipeline_query.Query) -> typing.AsyncGenerator[provider_message.Message, None]:
|
||||
"""运行请求"""
|
||||
async for msg in self._call_webhook(query):
|
||||
yield msg
|
||||
yield msg
|
||||
|
||||
@@ -3,8 +3,6 @@ from __future__ import annotations
|
||||
import abc
|
||||
import typing
|
||||
|
||||
from langbot_plugin.api.entities.events import pipeline_query
|
||||
|
||||
from ...core import app
|
||||
import langbot_plugin.api.entities.builtin.resource.tool as resource_tool
|
||||
|
||||
@@ -47,7 +45,7 @@ class ToolLoader(abc.ABC):
|
||||
pass
|
||||
|
||||
@abc.abstractmethod
|
||||
async def invoke_tool(self, name: str, parameters: dict, query: pipeline_query.Query) -> typing.Any:
|
||||
async def invoke_tool(self, name: str, parameters: dict) -> typing.Any:
|
||||
"""执行工具调用"""
|
||||
pass
|
||||
|
||||
|
||||
@@ -4,7 +4,6 @@ import enum
|
||||
import typing
|
||||
from contextlib import AsyncExitStack
|
||||
import traceback
|
||||
from langbot_plugin.api.entities.events import pipeline_query
|
||||
import sqlalchemy
|
||||
import asyncio
|
||||
|
||||
@@ -330,7 +329,7 @@ class MCPLoader(loader.ToolLoader):
|
||||
return True
|
||||
return False
|
||||
|
||||
async def invoke_tool(self, name: str, parameters: dict, query: pipeline_query.Query) -> typing.Any:
|
||||
async def invoke_tool(self, name: str, parameters: dict) -> typing.Any:
|
||||
"""执行工具调用"""
|
||||
for session in self.sessions.values():
|
||||
for function in session.get_tools():
|
||||
|
||||
@@ -3,8 +3,6 @@ from __future__ import annotations
|
||||
import typing
|
||||
import traceback
|
||||
|
||||
from langbot_plugin.api.entities.events import pipeline_query
|
||||
|
||||
from .. import loader
|
||||
import langbot_plugin.api.entities.builtin.resource.tool as resource_tool
|
||||
|
||||
@@ -45,11 +43,9 @@ class PluginToolLoader(loader.ToolLoader):
|
||||
return tool
|
||||
return None
|
||||
|
||||
async def invoke_tool(self, name: str, parameters: dict, query: pipeline_query.Query) -> typing.Any:
|
||||
async def invoke_tool(self, name: str, parameters: dict) -> typing.Any:
|
||||
try:
|
||||
return await self.ap.plugin_connector.call_tool(
|
||||
name, parameters, session=query.session, query_id=query.query_id
|
||||
)
|
||||
return await self.ap.plugin_connector.call_tool(name, parameters)
|
||||
except Exception as e:
|
||||
self.ap.logger.error(f'执行函数 {name} 时发生错误: {e}')
|
||||
traceback.print_exc()
|
||||
|
||||
@@ -7,7 +7,6 @@ from langbot.pkg.utils import importutil
|
||||
from langbot.pkg.provider.tools import loaders
|
||||
from langbot.pkg.provider.tools.loaders import mcp as mcp_loader, plugin as plugin_loader
|
||||
import langbot_plugin.api.entities.builtin.resource.tool as resource_tool
|
||||
from langbot_plugin.api.entities.events import pipeline_query
|
||||
|
||||
importutil.import_modules_in_pkg(loaders)
|
||||
|
||||
@@ -92,13 +91,13 @@ class ToolManager:
|
||||
|
||||
return tools
|
||||
|
||||
async def execute_func_call(self, name: str, parameters: dict, query: pipeline_query.Query) -> typing.Any:
|
||||
async def execute_func_call(self, name: str, parameters: dict) -> typing.Any:
|
||||
"""执行函数调用"""
|
||||
|
||||
if await self.plugin_tool_loader.has_tool(name):
|
||||
return await self.plugin_tool_loader.invoke_tool(name, parameters, query)
|
||||
return await self.plugin_tool_loader.invoke_tool(name, parameters)
|
||||
elif await self.mcp_tool_loader.has_tool(name):
|
||||
return await self.mcp_tool_loader.invoke_tool(name, parameters, query)
|
||||
return await self.mcp_tool_loader.invoke_tool(name, parameters)
|
||||
else:
|
||||
raise ValueError(f'未找到工具: {name}')
|
||||
|
||||
|
||||
@@ -1,55 +0,0 @@
|
||||
"""Base classes and interfaces for knowledge bases"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import abc
|
||||
|
||||
from langbot.pkg.core import app
|
||||
from langbot_plugin.api.entities.builtin.rag import context as rag_context
|
||||
|
||||
|
||||
class KnowledgeBaseInterface(metaclass=abc.ABCMeta):
|
||||
"""Abstract interface for all knowledge base types"""
|
||||
|
||||
ap: app.Application
|
||||
|
||||
def __init__(self, ap: app.Application):
|
||||
self.ap = ap
|
||||
|
||||
@abc.abstractmethod
|
||||
async def initialize(self):
|
||||
"""Initialize the knowledge base"""
|
||||
pass
|
||||
|
||||
@abc.abstractmethod
|
||||
async def retrieve(self, query: str, top_k: int) -> list[rag_context.RetrievalResultEntry]:
|
||||
"""Retrieve relevant documents from the knowledge base
|
||||
|
||||
Args:
|
||||
query: The query string
|
||||
top_k: Number of top results to return
|
||||
|
||||
Returns:
|
||||
List of retrieve result entries
|
||||
"""
|
||||
pass
|
||||
|
||||
@abc.abstractmethod
|
||||
def get_uuid(self) -> str:
|
||||
"""Get the UUID of the knowledge base"""
|
||||
pass
|
||||
|
||||
@abc.abstractmethod
|
||||
def get_name(self) -> str:
|
||||
"""Get the name of the knowledge base"""
|
||||
pass
|
||||
|
||||
@abc.abstractmethod
|
||||
def get_type(self) -> str:
|
||||
"""Get the type of knowledge base (internal/external)"""
|
||||
pass
|
||||
|
||||
@abc.abstractmethod
|
||||
async def dispose(self):
|
||||
"""Clean up resources"""
|
||||
pass
|
||||
@@ -1,85 +0,0 @@
|
||||
"""External knowledge base implementation"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from langbot.pkg.core import app
|
||||
from langbot.pkg.entity.persistence import rag as persistence_rag
|
||||
from langbot_plugin.api.entities.builtin.rag import context as rag_context
|
||||
from .base import KnowledgeBaseInterface
|
||||
|
||||
|
||||
class ExternalKnowledgeBase(KnowledgeBaseInterface):
|
||||
"""External knowledge base that queries via HTTP API or plugin retriever"""
|
||||
|
||||
external_kb_entity: persistence_rag.ExternalKnowledgeBase
|
||||
|
||||
# Plugin retriever instance ID
|
||||
retriever_instance_id: str | None
|
||||
|
||||
def __init__(self, ap: app.Application, external_kb_entity: persistence_rag.ExternalKnowledgeBase):
|
||||
super().__init__(ap)
|
||||
self.external_kb_entity = external_kb_entity
|
||||
self.retriever_instance_id = None
|
||||
|
||||
async def initialize(self):
|
||||
"""Initialize the external knowledge base"""
|
||||
# Use KB UUID as instance ID
|
||||
# Instance creation is now handled by the unified sync mechanism
|
||||
# when LangBot connects to runtime
|
||||
self.retriever_instance_id = self.external_kb_entity.uuid
|
||||
|
||||
self.ap.logger.info(
|
||||
f'Initialized external KB {self.external_kb_entity.uuid}, instance will be created by sync mechanism'
|
||||
)
|
||||
|
||||
async def retrieve(self, query: str, top_k: int = 5) -> list[rag_context.RetrievalResultEntry]:
|
||||
"""Retrieve documents from external knowledge base via plugin retriever"""
|
||||
if not self.retriever_instance_id:
|
||||
self.ap.logger.error(f'No retriever instance for KB {self.external_kb_entity.uuid}')
|
||||
return []
|
||||
|
||||
try:
|
||||
results = await self.ap.plugin_connector.retrieve_knowledge(
|
||||
self.external_kb_entity.plugin_author,
|
||||
self.external_kb_entity.plugin_name,
|
||||
self.external_kb_entity.retriever_name,
|
||||
self.retriever_instance_id,
|
||||
{'query': query},
|
||||
)
|
||||
|
||||
# Convert plugin results to RetrievalResultEntry
|
||||
retrieval_entries = []
|
||||
for result in results:
|
||||
retrieval_entries.append(rag_context.RetrievalResultEntry(**result))
|
||||
|
||||
return retrieval_entries
|
||||
except Exception as e:
|
||||
self.ap.logger.error(f'Plugin retriever error: {e}')
|
||||
import traceback
|
||||
|
||||
traceback.print_exc()
|
||||
return []
|
||||
|
||||
def get_uuid(self) -> str:
|
||||
"""Get the UUID of the external knowledge base"""
|
||||
return self.external_kb_entity.uuid
|
||||
|
||||
def get_name(self) -> str:
|
||||
"""Get the name of the external knowledge base"""
|
||||
return self.external_kb_entity.name
|
||||
|
||||
def get_type(self) -> str:
|
||||
"""Get the type of knowledge base"""
|
||||
return 'external'
|
||||
|
||||
async def dispose(self):
|
||||
"""Clean up resources"""
|
||||
# Trigger sync to immediately delete the instance from plugin process
|
||||
# This ensures instance is cleaned up without waiting for next LangBot restart
|
||||
try:
|
||||
await self.ap.plugin_connector.sync_polymorphic_component_instances()
|
||||
self.ap.logger.info(
|
||||
f'Disposed external KB {self.external_kb_entity.uuid}, triggered sync to delete instance'
|
||||
)
|
||||
except Exception as e:
|
||||
self.ap.logger.error(f'Failed to sync after disposing KB: {e}')
|
||||
@@ -10,12 +10,10 @@ from langbot.pkg.rag.knowledge.services.retriever import Retriever
|
||||
import sqlalchemy
|
||||
from langbot.pkg.entity.persistence import rag as persistence_rag
|
||||
from langbot.pkg.core import taskmgr
|
||||
from langbot_plugin.api.entities.builtin.rag import context as rag_context
|
||||
from .base import KnowledgeBaseInterface
|
||||
from .external import ExternalKnowledgeBase
|
||||
from langbot.pkg.entity.rag import retriever as retriever_entities
|
||||
|
||||
|
||||
class RuntimeKnowledgeBase(KnowledgeBaseInterface):
|
||||
class RuntimeKnowledgeBase:
|
||||
ap: app.Application
|
||||
|
||||
knowledge_base_entity: persistence_rag.KnowledgeBase
|
||||
@@ -29,7 +27,7 @@ class RuntimeKnowledgeBase(KnowledgeBaseInterface):
|
||||
retriever: Retriever
|
||||
|
||||
def __init__(self, ap: app.Application, knowledge_base_entity: persistence_rag.KnowledgeBase):
|
||||
super().__init__(ap)
|
||||
self.ap = ap
|
||||
self.knowledge_base_entity = knowledge_base_entity
|
||||
self.parser = parser.FileParser(ap=self.ap)
|
||||
self.chunker = chunker.Chunker(ap=self.ap)
|
||||
@@ -189,7 +187,7 @@ class RuntimeKnowledgeBase(KnowledgeBaseInterface):
|
||||
|
||||
return stored_file_tasks[0] if stored_file_tasks else ''
|
||||
|
||||
async def retrieve(self, query: str, top_k: int) -> list[rag_context.RetrievalResultEntry]:
|
||||
async def retrieve(self, query: str, top_k: int) -> list[retriever_entities.RetrieveResultEntry]:
|
||||
embedding_model = await self.ap.model_mgr.get_embedding_model_by_uuid(
|
||||
self.knowledge_base_entity.embedding_model_uuid
|
||||
)
|
||||
@@ -208,18 +206,6 @@ class RuntimeKnowledgeBase(KnowledgeBaseInterface):
|
||||
sqlalchemy.delete(persistence_rag.File).where(persistence_rag.File.uuid == file_id)
|
||||
)
|
||||
|
||||
def get_uuid(self) -> str:
|
||||
"""Get the UUID of the knowledge base"""
|
||||
return self.knowledge_base_entity.uuid
|
||||
|
||||
def get_name(self) -> str:
|
||||
"""Get the name of the knowledge base"""
|
||||
return self.knowledge_base_entity.name
|
||||
|
||||
def get_type(self) -> str:
|
||||
"""Get the type of knowledge base"""
|
||||
return 'internal'
|
||||
|
||||
async def dispose(self):
|
||||
await self.ap.vector_db_mgr.vector_db.delete_collection(self.knowledge_base_entity.uuid)
|
||||
|
||||
@@ -227,7 +213,7 @@ class RuntimeKnowledgeBase(KnowledgeBaseInterface):
|
||||
class RAGManager:
|
||||
ap: app.Application
|
||||
|
||||
knowledge_bases: list[KnowledgeBaseInterface]
|
||||
knowledge_bases: list[RuntimeKnowledgeBase]
|
||||
|
||||
def __init__(self, ap: app.Application):
|
||||
self.ap = ap
|
||||
@@ -241,8 +227,8 @@ class RAGManager:
|
||||
|
||||
self.knowledge_bases = []
|
||||
|
||||
# Load internal knowledge bases
|
||||
result = await self.ap.persistence_mgr.execute_async(sqlalchemy.select(persistence_rag.KnowledgeBase))
|
||||
|
||||
knowledge_bases = result.all()
|
||||
|
||||
for knowledge_base in knowledge_bases:
|
||||
@@ -253,21 +239,6 @@ class RAGManager:
|
||||
f'Error loading knowledge base {knowledge_base.uuid}: {e}\n{traceback.format_exc()}'
|
||||
)
|
||||
|
||||
# Load external knowledge bases
|
||||
external_result = await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.select(persistence_rag.ExternalKnowledgeBase)
|
||||
)
|
||||
external_kbs = external_result.all()
|
||||
|
||||
for external_kb in external_kbs:
|
||||
try:
|
||||
# Don't trigger sync during batch loading - will sync once after LangBot connects to runtime
|
||||
await self.load_external_knowledge_base(external_kb, trigger_sync=False)
|
||||
except Exception as e:
|
||||
self.ap.logger.error(
|
||||
f'Error loading external knowledge base {external_kb.uuid}: {e}\n{traceback.format_exc()}'
|
||||
)
|
||||
|
||||
async def load_knowledge_base(
|
||||
self,
|
||||
knowledge_base_entity: persistence_rag.KnowledgeBase | sqlalchemy.Row | dict,
|
||||
@@ -285,54 +256,21 @@ class RAGManager:
|
||||
|
||||
return runtime_knowledge_base
|
||||
|
||||
async def load_external_knowledge_base(
|
||||
self,
|
||||
external_kb_entity: persistence_rag.ExternalKnowledgeBase | sqlalchemy.Row | dict,
|
||||
trigger_sync: bool = True,
|
||||
) -> ExternalKnowledgeBase:
|
||||
"""Load external knowledge base into runtime
|
||||
|
||||
Args:
|
||||
external_kb_entity: External KB entity to load
|
||||
trigger_sync: Whether to trigger sync after loading (default True for manual creation, False for batch loading)
|
||||
"""
|
||||
if isinstance(external_kb_entity, sqlalchemy.Row):
|
||||
external_kb_entity = persistence_rag.ExternalKnowledgeBase(**external_kb_entity._mapping)
|
||||
elif isinstance(external_kb_entity, dict):
|
||||
external_kb_entity = persistence_rag.ExternalKnowledgeBase(**external_kb_entity)
|
||||
|
||||
external_kb = ExternalKnowledgeBase(ap=self.ap, external_kb_entity=external_kb_entity)
|
||||
|
||||
await external_kb.initialize()
|
||||
|
||||
self.knowledge_bases.append(external_kb)
|
||||
|
||||
# Trigger sync to create the instance immediately (for manual creation)
|
||||
# Skip sync during batch loading from DB to avoid multiple sync calls
|
||||
if trigger_sync:
|
||||
try:
|
||||
await self.ap.plugin_connector.sync_polymorphic_component_instances()
|
||||
self.ap.logger.info(f'Triggered sync after loading external KB {external_kb_entity.uuid}')
|
||||
except Exception as e:
|
||||
self.ap.logger.error(f'Failed to sync after loading external KB: {e}')
|
||||
|
||||
return external_kb
|
||||
|
||||
async def get_knowledge_base_by_uuid(self, kb_uuid: str) -> KnowledgeBaseInterface | None:
|
||||
async def get_knowledge_base_by_uuid(self, kb_uuid: str) -> RuntimeKnowledgeBase | None:
|
||||
for kb in self.knowledge_bases:
|
||||
if kb.get_uuid() == kb_uuid:
|
||||
if kb.knowledge_base_entity.uuid == kb_uuid:
|
||||
return kb
|
||||
return None
|
||||
|
||||
async def remove_knowledge_base_from_runtime(self, kb_uuid: str):
|
||||
for kb in self.knowledge_bases:
|
||||
if kb.get_uuid() == kb_uuid:
|
||||
if kb.knowledge_base_entity.uuid == kb_uuid:
|
||||
self.knowledge_bases.remove(kb)
|
||||
return
|
||||
|
||||
async def delete_knowledge_base(self, kb_uuid: str):
|
||||
for kb in self.knowledge_bases:
|
||||
if kb.get_uuid() == kb_uuid:
|
||||
if kb.knowledge_base_entity.uuid == kb_uuid:
|
||||
await kb.dispose()
|
||||
self.knowledge_bases.remove(kb)
|
||||
return
|
||||
|
||||
@@ -3,8 +3,7 @@ from __future__ import annotations
|
||||
from . import base_service
|
||||
from ....core import app
|
||||
from ....provider.modelmgr.requester import RuntimeEmbeddingModel
|
||||
from langbot_plugin.api.entities.builtin.rag import context as rag_context
|
||||
from langbot_plugin.api.entities.builtin.provider.message import ContentElement
|
||||
from ....entity.rag import retriever as retriever_entities
|
||||
|
||||
|
||||
class Retriever(base_service.BaseService):
|
||||
@@ -14,7 +13,7 @@ class Retriever(base_service.BaseService):
|
||||
|
||||
async def retrieve(
|
||||
self, kb_id: str, query: str, embedding_model: RuntimeEmbeddingModel, k: int = 5
|
||||
) -> list[rag_context.RetrievalResultEntry]:
|
||||
) -> list[retriever_entities.RetrieveResultEntry]:
|
||||
self.ap.logger.info(
|
||||
f"Retrieving for query: '{query[:10]}' with k={k} using {embedding_model.model_entity.uuid}"
|
||||
)
|
||||
@@ -36,12 +35,11 @@ class Retriever(base_service.BaseService):
|
||||
self.ap.logger.info('No relevant chunks found in vector database.')
|
||||
return []
|
||||
|
||||
result: list[rag_context.RetrievalResultEntry] = []
|
||||
result: list[retriever_entities.RetrieveResultEntry] = []
|
||||
|
||||
for i, id in enumerate(matched_vector_ids):
|
||||
entry = rag_context.RetrievalResultEntry(
|
||||
entry = retriever_entities.RetrieveResultEntry(
|
||||
id=id,
|
||||
content=[ContentElement.from_text(vector_metadatas[i].get('text', ''))],
|
||||
metadata=vector_metadatas[i],
|
||||
distance=distances[i],
|
||||
)
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import langbot
|
||||
semantic_version = 'v4.6.0-beta.1'
|
||||
|
||||
semantic_version = f'v{langbot.__version__}'
|
||||
|
||||
required_database_version = 13
|
||||
required_database_version = 11
|
||||
"""Tag the version of the database schema, used to check if the database needs to be migrated"""
|
||||
|
||||
debug_mode = False
|
||||
|
||||
@@ -41,9 +41,5 @@ def read_resource_file(resource_path: str) -> str:
|
||||
return f.read()
|
||||
|
||||
|
||||
def read_resource_file_bytes(resource_path: str) -> bytes:
|
||||
return importlib.resources.files('langbot').joinpath(resource_path).read_bytes()
|
||||
|
||||
|
||||
def list_resource_files(resource_path: str) -> list[str]:
|
||||
return [f.name for f in importlib.resources.files('langbot').joinpath(resource_path).iterdir()]
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
admins: []
|
||||
api:
|
||||
port: 5300
|
||||
webhook_prefix: 'http://127.0.0.1:5300'
|
||||
command:
|
||||
enable: true
|
||||
prefix:
|
||||
@@ -49,4 +48,3 @@ plugin:
|
||||
runtime_ws_url: 'ws://langbot_plugin_runtime:5400/control/ws'
|
||||
enable_marketplace: true
|
||||
cloud_service_url: 'https://space.langbot.app'
|
||||
display_plugin_debug_url: 'http://localhost:5401'
|
||||
|
||||
@@ -1,143 +0,0 @@
|
||||
"""
|
||||
Tests for webhook_prefix configuration
|
||||
"""
|
||||
|
||||
import os
|
||||
import pytest
|
||||
from typing import Any
|
||||
|
||||
|
||||
def _apply_env_overrides_to_config(cfg: dict) -> dict:
|
||||
"""Apply environment variable overrides to data/config.yaml
|
||||
|
||||
Environment variables should be uppercase and use __ (double underscore)
|
||||
to represent nested keys. For example:
|
||||
- CONCURRENCY__PIPELINE overrides concurrency.pipeline
|
||||
- PLUGIN__RUNTIME_WS_URL overrides plugin.runtime_ws_url
|
||||
|
||||
Arrays and dict types are ignored.
|
||||
|
||||
Args:
|
||||
cfg: Configuration dictionary
|
||||
|
||||
Returns:
|
||||
Updated configuration dictionary
|
||||
"""
|
||||
|
||||
def convert_value(value: str, original_value: Any) -> Any:
|
||||
"""Convert string value to appropriate type based on original value
|
||||
|
||||
Args:
|
||||
value: String value from environment variable
|
||||
original_value: Original value to infer type from
|
||||
|
||||
Returns:
|
||||
Converted value (falls back to string if conversion fails)
|
||||
"""
|
||||
if isinstance(original_value, bool):
|
||||
return value.lower() in ('true', '1', 'yes', 'on')
|
||||
elif isinstance(original_value, int):
|
||||
try:
|
||||
return int(value)
|
||||
except ValueError:
|
||||
# If conversion fails, keep as string (user error, but non-breaking)
|
||||
return value
|
||||
elif isinstance(original_value, float):
|
||||
try:
|
||||
return float(value)
|
||||
except ValueError:
|
||||
# If conversion fails, keep as string (user error, but non-breaking)
|
||||
return value
|
||||
else:
|
||||
return value
|
||||
|
||||
# Process environment variables
|
||||
for env_key, env_value in os.environ.items():
|
||||
# Check if the environment variable is uppercase and contains __
|
||||
if not env_key.isupper():
|
||||
continue
|
||||
if '__' not in env_key:
|
||||
continue
|
||||
|
||||
# Convert environment variable name to config path
|
||||
# e.g., CONCURRENCY__PIPELINE -> ['concurrency', 'pipeline']
|
||||
keys = [key.lower() for key in env_key.split('__')]
|
||||
|
||||
# Navigate to the target value and validate the path
|
||||
current = cfg
|
||||
|
||||
for i, key in enumerate(keys):
|
||||
if not isinstance(current, dict) or key not in current:
|
||||
break
|
||||
|
||||
if i == len(keys) - 1:
|
||||
# At the final key - check if it's a scalar value
|
||||
if isinstance(current[key], (dict, list)):
|
||||
# Skip dict and list types
|
||||
pass
|
||||
else:
|
||||
# Valid scalar value - convert and set it
|
||||
converted_value = convert_value(env_value, current[key])
|
||||
current[key] = converted_value
|
||||
else:
|
||||
# Navigate deeper
|
||||
current = current[key]
|
||||
|
||||
return cfg
|
||||
|
||||
|
||||
class TestWebhookDisplayPrefix:
|
||||
"""Test webhook_prefix configuration functionality"""
|
||||
|
||||
def test_default_webhook_prefix(self):
|
||||
"""Test that the default webhook display prefix is correctly set"""
|
||||
cfg = {'api': {'port': 5300, 'webhook_prefix': 'http://127.0.0.1:5300'}}
|
||||
|
||||
# Should have the default value
|
||||
assert cfg['api']['webhook_prefix'] == 'http://127.0.0.1:5300'
|
||||
|
||||
def test_webhook_prefix_env_override(self):
|
||||
"""Test overriding webhook_prefix via environment variable"""
|
||||
cfg = {'api': {'port': 5300, 'webhook_prefix': 'http://127.0.0.1:5300'}}
|
||||
|
||||
# Set environment variable
|
||||
os.environ['API__WEBHOOK_PREFIX'] = 'https://example.com:8080'
|
||||
|
||||
result = _apply_env_overrides_to_config(cfg)
|
||||
|
||||
assert result['api']['webhook_prefix'] == 'https://example.com:8080'
|
||||
|
||||
# Cleanup
|
||||
del os.environ['API__WEBHOOK_PREFIX']
|
||||
|
||||
def test_webhook_prefix_with_custom_domain(self):
|
||||
"""Test webhook_prefix with custom domain"""
|
||||
cfg = {'api': {'port': 5300, 'webhook_prefix': 'http://127.0.0.1:5300'}}
|
||||
|
||||
# Set to a custom domain
|
||||
os.environ['API__WEBHOOK_PREFIX'] = 'https://bot.mycompany.com'
|
||||
|
||||
result = _apply_env_overrides_to_config(cfg)
|
||||
|
||||
assert result['api']['webhook_prefix'] == 'https://bot.mycompany.com'
|
||||
|
||||
# Cleanup
|
||||
del os.environ['API__WEBHOOK_PREFIX']
|
||||
|
||||
def test_webhook_prefix_with_subdirectory(self):
|
||||
"""Test webhook_prefix with subdirectory path"""
|
||||
cfg = {'api': {'port': 5300, 'webhook_prefix': 'http://127.0.0.1:5300'}}
|
||||
|
||||
# Set to a URL with subdirectory
|
||||
os.environ['API__WEBHOOK_PREFIX'] = 'https://example.com/langbot'
|
||||
|
||||
result = _apply_env_overrides_to_config(cfg)
|
||||
|
||||
assert result['api']['webhook_prefix'] == 'https://example.com/langbot'
|
||||
|
||||
# Cleanup
|
||||
del os.environ['API__WEBHOOK_PREFIX']
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
pytest.main([__file__, '-v'])
|
||||
@@ -1 +0,0 @@
|
||||
# Plugin connector unit tests
|
||||
@@ -1,363 +0,0 @@
|
||||
"""Test plugin list filtering by component kinds."""
|
||||
|
||||
from datetime import datetime
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_plugin_list_filter_by_component_kinds():
|
||||
"""Test that plugins can be filtered by component kinds."""
|
||||
from src.langbot.pkg.plugin.connector import PluginRuntimeConnector
|
||||
|
||||
# Mock the application
|
||||
mock_app = MagicMock()
|
||||
mock_app.instance_config.data.get.return_value = {'enable': True}
|
||||
mock_app.logger = MagicMock()
|
||||
|
||||
# Create connector
|
||||
connector = PluginRuntimeConnector(mock_app, AsyncMock())
|
||||
connector.handler = MagicMock()
|
||||
|
||||
# Mock plugin data with different component kinds
|
||||
mock_plugins = [
|
||||
{
|
||||
'debug': False,
|
||||
'manifest': {
|
||||
'manifest': {
|
||||
'metadata': {
|
||||
'author': 'author1',
|
||||
'name': 'plugin_with_tool',
|
||||
}
|
||||
}
|
||||
},
|
||||
'components': [
|
||||
{
|
||||
'manifest': {
|
||||
'manifest': {
|
||||
'kind': 'Tool',
|
||||
'metadata': {'name': 'tool1'}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
'debug': False,
|
||||
'manifest': {
|
||||
'manifest': {
|
||||
'metadata': {
|
||||
'author': 'author2',
|
||||
'name': 'plugin_with_knowledge_retriever_only',
|
||||
}
|
||||
}
|
||||
},
|
||||
'components': [
|
||||
{
|
||||
'manifest': {
|
||||
'manifest': {
|
||||
'kind': 'KnowledgeRetriever',
|
||||
'metadata': {'name': 'retriever1'}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
'debug': False,
|
||||
'manifest': {
|
||||
'manifest': {
|
||||
'metadata': {
|
||||
'author': 'author3',
|
||||
'name': 'plugin_with_command',
|
||||
}
|
||||
}
|
||||
},
|
||||
'components': [
|
||||
{
|
||||
'manifest': {
|
||||
'manifest': {
|
||||
'kind': 'Command',
|
||||
'metadata': {'name': 'cmd1'}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
'debug': False,
|
||||
'manifest': {
|
||||
'manifest': {
|
||||
'metadata': {
|
||||
'author': 'author4',
|
||||
'name': 'plugin_with_event_listener',
|
||||
}
|
||||
}
|
||||
},
|
||||
'components': [
|
||||
{
|
||||
'manifest': {
|
||||
'manifest': {
|
||||
'kind': 'EventListener',
|
||||
'metadata': {'name': 'listener1'}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
'debug': False,
|
||||
'manifest': {
|
||||
'manifest': {
|
||||
'metadata': {
|
||||
'author': 'author5',
|
||||
'name': 'plugin_with_mixed_components',
|
||||
}
|
||||
}
|
||||
},
|
||||
'components': [
|
||||
{
|
||||
'manifest': {
|
||||
'manifest': {
|
||||
'kind': 'KnowledgeRetriever',
|
||||
'metadata': {'name': 'retriever2'}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
'manifest': {
|
||||
'manifest': {
|
||||
'kind': 'Tool',
|
||||
'metadata': {'name': 'tool2'}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
]
|
||||
|
||||
connector.handler.list_plugins = AsyncMock(return_value=mock_plugins)
|
||||
|
||||
# Mock database query
|
||||
async def mock_execute_async(query):
|
||||
mock_result = MagicMock()
|
||||
mock_result.__iter__ = lambda self: iter([])
|
||||
return mock_result
|
||||
|
||||
mock_app.persistence_mgr.execute_async = mock_execute_async
|
||||
|
||||
# Test filtering by pipeline component kinds (Command, EventListener, Tool)
|
||||
pipeline_component_kinds = ['Command', 'EventListener', 'Tool']
|
||||
result = await connector.list_plugins(component_kinds=pipeline_component_kinds)
|
||||
|
||||
# Verify that only plugins with pipeline-related components are returned
|
||||
assert len(result) == 4
|
||||
plugin_names = [p['manifest']['manifest']['metadata']['name'] for p in result]
|
||||
assert 'plugin_with_tool' in plugin_names
|
||||
assert 'plugin_with_command' in plugin_names
|
||||
assert 'plugin_with_event_listener' in plugin_names
|
||||
assert 'plugin_with_mixed_components' in plugin_names
|
||||
# Plugin with only KnowledgeRetriever should NOT be included
|
||||
assert 'plugin_with_knowledge_retriever_only' not in plugin_names
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_plugin_list_filter_no_filter():
|
||||
"""Test that all plugins are returned when no filter is specified."""
|
||||
from src.langbot.pkg.plugin.connector import PluginRuntimeConnector
|
||||
|
||||
# Mock the application
|
||||
mock_app = MagicMock()
|
||||
mock_app.instance_config.data.get.return_value = {'enable': True}
|
||||
mock_app.logger = MagicMock()
|
||||
|
||||
# Create connector
|
||||
connector = PluginRuntimeConnector(mock_app, AsyncMock())
|
||||
connector.handler = MagicMock()
|
||||
|
||||
# Mock plugin data with different component kinds
|
||||
mock_plugins = [
|
||||
{
|
||||
'debug': False,
|
||||
'manifest': {
|
||||
'manifest': {
|
||||
'metadata': {
|
||||
'author': 'author1',
|
||||
'name': 'plugin1',
|
||||
}
|
||||
}
|
||||
},
|
||||
'components': [
|
||||
{
|
||||
'manifest': {
|
||||
'manifest': {
|
||||
'kind': 'Tool',
|
||||
'metadata': {'name': 'tool1'}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
'debug': False,
|
||||
'manifest': {
|
||||
'manifest': {
|
||||
'metadata': {
|
||||
'author': 'author2',
|
||||
'name': 'plugin2',
|
||||
}
|
||||
}
|
||||
},
|
||||
'components': [
|
||||
{
|
||||
'manifest': {
|
||||
'manifest': {
|
||||
'kind': 'KnowledgeRetriever',
|
||||
'metadata': {'name': 'retriever1'}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
]
|
||||
|
||||
connector.handler.list_plugins = AsyncMock(return_value=mock_plugins)
|
||||
|
||||
# Mock database query
|
||||
async def mock_execute_async(query):
|
||||
mock_result = MagicMock()
|
||||
mock_result.__iter__ = lambda self: iter([])
|
||||
return mock_result
|
||||
|
||||
mock_app.persistence_mgr.execute_async = mock_execute_async
|
||||
|
||||
# Test without filter - should return all plugins
|
||||
result = await connector.list_plugins()
|
||||
|
||||
assert len(result) == 2
|
||||
plugin_names = [p['manifest']['manifest']['metadata']['name'] for p in result]
|
||||
assert 'plugin1' in plugin_names
|
||||
assert 'plugin2' in plugin_names
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_plugin_list_filter_empty_result():
|
||||
"""Test that empty list is returned when no plugins match the filter."""
|
||||
from src.langbot.pkg.plugin.connector import PluginRuntimeConnector
|
||||
|
||||
# Mock the application
|
||||
mock_app = MagicMock()
|
||||
mock_app.instance_config.data.get.return_value = {'enable': True}
|
||||
mock_app.logger = MagicMock()
|
||||
|
||||
# Create connector
|
||||
connector = PluginRuntimeConnector(mock_app, AsyncMock())
|
||||
connector.handler = MagicMock()
|
||||
|
||||
# Mock plugin data - only KnowledgeRetriever plugins
|
||||
mock_plugins = [
|
||||
{
|
||||
'debug': False,
|
||||
'manifest': {
|
||||
'manifest': {
|
||||
'metadata': {
|
||||
'author': 'author1',
|
||||
'name': 'plugin1',
|
||||
}
|
||||
}
|
||||
},
|
||||
'components': [
|
||||
{
|
||||
'manifest': {
|
||||
'manifest': {
|
||||
'kind': 'KnowledgeRetriever',
|
||||
'metadata': {'name': 'retriever1'}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
]
|
||||
|
||||
connector.handler.list_plugins = AsyncMock(return_value=mock_plugins)
|
||||
|
||||
# Mock database query
|
||||
async def mock_execute_async(query):
|
||||
mock_result = MagicMock()
|
||||
mock_result.__iter__ = lambda self: iter([])
|
||||
return mock_result
|
||||
|
||||
mock_app.persistence_mgr.execute_async = mock_execute_async
|
||||
|
||||
# Filter by Tool kind - should return empty list
|
||||
result = await connector.list_plugins(component_kinds=['Tool'])
|
||||
|
||||
assert len(result) == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_plugin_list_filter_plugin_without_components():
|
||||
"""Test that plugins without components are excluded when filtering."""
|
||||
from src.langbot.pkg.plugin.connector import PluginRuntimeConnector
|
||||
|
||||
# Mock the application
|
||||
mock_app = MagicMock()
|
||||
mock_app.instance_config.data.get.return_value = {'enable': True}
|
||||
mock_app.logger = MagicMock()
|
||||
|
||||
# Create connector
|
||||
connector = PluginRuntimeConnector(mock_app, AsyncMock())
|
||||
connector.handler = MagicMock()
|
||||
|
||||
# Mock plugin data - one with components, one without
|
||||
mock_plugins = [
|
||||
{
|
||||
'debug': False,
|
||||
'manifest': {
|
||||
'manifest': {
|
||||
'metadata': {
|
||||
'author': 'author1',
|
||||
'name': 'plugin_with_tool',
|
||||
}
|
||||
}
|
||||
},
|
||||
'components': [
|
||||
{
|
||||
'manifest': {
|
||||
'manifest': {
|
||||
'kind': 'Tool',
|
||||
'metadata': {'name': 'tool1'}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
'debug': False,
|
||||
'manifest': {
|
||||
'manifest': {
|
||||
'metadata': {
|
||||
'author': 'author2',
|
||||
'name': 'plugin_without_components',
|
||||
}
|
||||
}
|
||||
},
|
||||
'components': []
|
||||
},
|
||||
]
|
||||
|
||||
connector.handler.list_plugins = AsyncMock(return_value=mock_plugins)
|
||||
|
||||
# Mock database query
|
||||
async def mock_execute_async(query):
|
||||
mock_result = MagicMock()
|
||||
mock_result.__iter__ = lambda self: iter([])
|
||||
return mock_result
|
||||
|
||||
mock_app.persistence_mgr.execute_async = mock_execute_async
|
||||
|
||||
# Filter by Tool kind - should return only plugin with Tool
|
||||
result = await connector.list_plugins(component_kinds=['Tool'])
|
||||
|
||||
assert len(result) == 1
|
||||
assert result[0]['manifest']['manifest']['metadata']['name'] == 'plugin_with_tool'
|
||||
@@ -1,228 +0,0 @@
|
||||
"""Test plugin list sorting functionality."""
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_plugin_list_sorting_debug_first():
|
||||
"""Test that debug plugins appear before non-debug plugins."""
|
||||
from src.langbot.pkg.plugin.connector import PluginRuntimeConnector
|
||||
|
||||
# Mock the application
|
||||
mock_app = MagicMock()
|
||||
mock_app.instance_config.data.get.return_value = {'enable': True}
|
||||
mock_app.logger = MagicMock()
|
||||
|
||||
# Create connector
|
||||
connector = PluginRuntimeConnector(mock_app, AsyncMock())
|
||||
connector.handler = MagicMock()
|
||||
|
||||
# Mock plugin data with different debug states and timestamps
|
||||
now = datetime.now()
|
||||
mock_plugins = [
|
||||
{
|
||||
'debug': False,
|
||||
'manifest': {
|
||||
'manifest': {
|
||||
'metadata': {
|
||||
'author': 'author1',
|
||||
'name': 'plugin1',
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
'debug': True,
|
||||
'manifest': {
|
||||
'manifest': {
|
||||
'metadata': {
|
||||
'author': 'author2',
|
||||
'name': 'plugin2',
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
'debug': False,
|
||||
'manifest': {
|
||||
'manifest': {
|
||||
'metadata': {
|
||||
'author': 'author3',
|
||||
'name': 'plugin3',
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
connector.handler.list_plugins = AsyncMock(return_value=mock_plugins)
|
||||
|
||||
# Mock database query to return all timestamps in a single batch
|
||||
async def mock_execute_async(query):
|
||||
mock_result = MagicMock()
|
||||
|
||||
# Create mock rows for all plugins with timestamps
|
||||
mock_rows = []
|
||||
|
||||
# plugin1: oldest, plugin2: middle, plugin3: newest
|
||||
mock_row1 = MagicMock()
|
||||
mock_row1.plugin_author = 'author1'
|
||||
mock_row1.plugin_name = 'plugin1'
|
||||
mock_row1.created_at = now - timedelta(days=2)
|
||||
mock_rows.append(mock_row1)
|
||||
|
||||
mock_row2 = MagicMock()
|
||||
mock_row2.plugin_author = 'author2'
|
||||
mock_row2.plugin_name = 'plugin2'
|
||||
mock_row2.created_at = now - timedelta(days=1)
|
||||
mock_rows.append(mock_row2)
|
||||
|
||||
mock_row3 = MagicMock()
|
||||
mock_row3.plugin_author = 'author3'
|
||||
mock_row3.plugin_name = 'plugin3'
|
||||
mock_row3.created_at = now
|
||||
mock_rows.append(mock_row3)
|
||||
|
||||
# Make the result iterable
|
||||
mock_result.__iter__ = lambda self: iter(mock_rows)
|
||||
|
||||
return mock_result
|
||||
|
||||
mock_app.persistence_mgr.execute_async = mock_execute_async
|
||||
|
||||
# Call list_plugins
|
||||
result = await connector.list_plugins()
|
||||
|
||||
# Verify sorting: debug plugin should be first
|
||||
assert len(result) == 3
|
||||
assert result[0]['debug'] is True # plugin2 (debug)
|
||||
assert result[0]['manifest']['manifest']['metadata']['name'] == 'plugin2'
|
||||
|
||||
# Remaining should be sorted by created_at (newest first)
|
||||
assert result[1]['debug'] is False
|
||||
assert result[1]['manifest']['manifest']['metadata']['name'] == 'plugin3' # newest non-debug
|
||||
assert result[2]['debug'] is False
|
||||
assert result[2]['manifest']['manifest']['metadata']['name'] == 'plugin1' # oldest non-debug
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_plugin_list_sorting_by_installation_time():
|
||||
"""Test that non-debug plugins are sorted by installation time (newest first)."""
|
||||
from src.langbot.pkg.plugin.connector import PluginRuntimeConnector
|
||||
|
||||
# Mock the application
|
||||
mock_app = MagicMock()
|
||||
mock_app.instance_config.data.get.return_value = {'enable': True}
|
||||
mock_app.logger = MagicMock()
|
||||
|
||||
# Create connector
|
||||
connector = PluginRuntimeConnector(mock_app, AsyncMock())
|
||||
connector.handler = MagicMock()
|
||||
|
||||
# Mock plugin data - all non-debug with different installation times
|
||||
now = datetime.now()
|
||||
mock_plugins = [
|
||||
{
|
||||
'debug': False,
|
||||
'manifest': {
|
||||
'manifest': {
|
||||
'metadata': {
|
||||
'author': 'author1',
|
||||
'name': 'oldest_plugin',
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
'debug': False,
|
||||
'manifest': {
|
||||
'manifest': {
|
||||
'metadata': {
|
||||
'author': 'author2',
|
||||
'name': 'middle_plugin',
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
'debug': False,
|
||||
'manifest': {
|
||||
'manifest': {
|
||||
'metadata': {
|
||||
'author': 'author3',
|
||||
'name': 'newest_plugin',
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
connector.handler.list_plugins = AsyncMock(return_value=mock_plugins)
|
||||
|
||||
# Mock database query to return all timestamps in a single batch
|
||||
async def mock_execute_async(query):
|
||||
mock_result = MagicMock()
|
||||
|
||||
# Create mock rows for all plugins with timestamps
|
||||
mock_rows = []
|
||||
|
||||
# oldest_plugin: oldest, middle_plugin: middle, newest_plugin: newest
|
||||
mock_row1 = MagicMock()
|
||||
mock_row1.plugin_author = 'author1'
|
||||
mock_row1.plugin_name = 'oldest_plugin'
|
||||
mock_row1.created_at = now - timedelta(days=10)
|
||||
mock_rows.append(mock_row1)
|
||||
|
||||
mock_row2 = MagicMock()
|
||||
mock_row2.plugin_author = 'author2'
|
||||
mock_row2.plugin_name = 'middle_plugin'
|
||||
mock_row2.created_at = now - timedelta(days=5)
|
||||
mock_rows.append(mock_row2)
|
||||
|
||||
mock_row3 = MagicMock()
|
||||
mock_row3.plugin_author = 'author3'
|
||||
mock_row3.plugin_name = 'newest_plugin'
|
||||
mock_row3.created_at = now
|
||||
mock_rows.append(mock_row3)
|
||||
|
||||
# Make the result iterable
|
||||
mock_result.__iter__ = lambda self: iter(mock_rows)
|
||||
|
||||
return mock_result
|
||||
|
||||
mock_app.persistence_mgr.execute_async = mock_execute_async
|
||||
|
||||
# Call list_plugins
|
||||
result = await connector.list_plugins()
|
||||
|
||||
# Verify sorting: newest first
|
||||
assert len(result) == 3
|
||||
assert result[0]['manifest']['manifest']['metadata']['name'] == 'newest_plugin'
|
||||
assert result[1]['manifest']['manifest']['metadata']['name'] == 'middle_plugin'
|
||||
assert result[2]['manifest']['manifest']['metadata']['name'] == 'oldest_plugin'
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_plugin_list_empty():
|
||||
"""Test that empty plugin list is handled correctly."""
|
||||
from src.langbot.pkg.plugin.connector import PluginRuntimeConnector
|
||||
|
||||
# Mock the application
|
||||
mock_app = MagicMock()
|
||||
mock_app.instance_config.data.get.return_value = {'enable': True}
|
||||
mock_app.logger = MagicMock()
|
||||
|
||||
# Create connector
|
||||
connector = PluginRuntimeConnector(mock_app, AsyncMock())
|
||||
connector.handler = MagicMock()
|
||||
|
||||
# Mock empty plugin list
|
||||
connector.handler.list_plugins = AsyncMock(return_value=[])
|
||||
|
||||
# Call list_plugins
|
||||
result = await connector.list_plugins()
|
||||
|
||||
# Verify empty list
|
||||
assert len(result) == 0
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user