Compare commits

..

25 Commits

Author SHA1 Message Date
Junyan Qin
4011a302af chore: bump version 4.6.0b2 for testing 2025-11-16 19:28:52 +08:00
Junyan Qin
deb725a2e2 fix: send adapters and requesters icons 2025-11-16 19:26:30 +08:00
Junyan Qin
33eb866660 chore: add templates/** 2025-11-16 19:20:43 +08:00
Junyan Qin
34e2fa03ce chore: bump version 4.6.0-beta.1 for testing 2025-11-16 19:11:02 +08:00
Junyan Qin
7b63bcdc39 ci: publish pypi 2025-11-16 19:09:24 +08:00
Junyan Qin
d26e81620d fix: tests 2025-11-16 18:39:45 +08:00
Junyan Qin
e7885539a7 fix: read default-pipeline-config.json 2025-11-16 18:13:10 +08:00
Junyan Qin
f216505237 fix: read default-pipeline-config.json 2025-11-16 18:12:29 +08:00
Junyan Qin
8b11eefd0c Merge branch 'master' into copilot/create-langbot-python-package 2025-11-16 17:50:37 +08:00
Junyan Qin
418cddd657 chore: fix imports 2025-11-16 17:44:18 +08:00
Junyan Qin
75edeb7a01 chore: adjust dir structure 2025-11-16 16:28:04 +08:00
Junyan Qin
c5aa5be4d8 chore: update 2025-11-07 23:19:51 +08:00
Junyan Qin
92614062cc chore: update 2025-11-07 23:10:57 +08:00
Junyan Qin
09307d8c6d chore: update 2025-11-07 23:04:49 +08:00
Junyan Qin
894db240ae chore: update 2025-11-07 23:02:50 +08:00
Junyan Qin
f79cde5b0c chore: update 2025-11-07 22:55:33 +08:00
Junyan Qin
d43c2c498c chore: try pack templates in langbot/ 2025-11-07 22:51:30 +08:00
Junyan Qin
5f6036c5a8 chore: update pyproject.toml 2025-11-07 22:19:15 +08:00
copilot-swe-agent[bot]
dead0794b1 Simplify package configuration and document behavioral differences
- Removed redundant package-data configuration, relying on MANIFEST.in
- Added documentation about behavioral differences between package and source installation
- Clarified that include-package-data=true uses MANIFEST.in for data files

Co-authored-by: RockChinQ <45992437+RockChinQ@users.noreply.github.com>
2025-11-07 14:08:57 +00:00
copilot-swe-agent[bot]
f784bad08b Fix code review issues
- Use specific exception types instead of bare except
- Fix misleading comments about directory levels
- Remove redundant existence check before makedirs with exist_ok=True
- Use context manager for file opening to ensure proper cleanup

Co-authored-by: RockChinQ <45992437+RockChinQ@users.noreply.github.com>
2025-11-07 14:06:49 +00:00
copilot-swe-agent[bot]
4e86e1c93d Address code review feedback
- Made package-data configuration more specific to langbot package only
- Improved path detection with caching to avoid repeated file I/O
- Removed sys.path searching which was incorrect for package data
- Removed interactive input() call for non-interactive environment compatibility
- Simplified error messages for version check

Co-authored-by: RockChinQ <45992437+RockChinQ@users.noreply.github.com>
2025-11-07 14:04:47 +00:00
copilot-swe-agent[bot]
c0eec966ac Add PyPI installation documentation
- Created PYPI_INSTALLATION.md with detailed installation and usage instructions
- Updated README.md to feature uvx/pip installation as recommended method
- Updated README_EN.md with same changes for English documentation

Co-authored-by: RockChinQ <45992437+RockChinQ@users.noreply.github.com>
2025-11-07 14:02:49 +00:00
copilot-swe-agent[bot]
62d6dae4f5 Add PyPI publishing workflow and update license
- Created GitHub Actions workflow to build frontend and publish to PyPI
- Added license field to pyproject.toml to fix deprecation warning
- Updated .gitignore to exclude build artifacts
- Tested package building successfully

Co-authored-by: RockChinQ <45992437+RockChinQ@users.noreply.github.com>
2025-11-07 14:01:07 +00:00
copilot-swe-agent[bot]
cab573f3e2 Add package structure and resource path utilities
- Created langbot/ package with __init__.py and __main__.py entry point
- Added paths utility to find frontend and resource files from package installation
- Updated config loading to use resource paths
- Updated frontend serving to use resource paths
- Added MANIFEST.in for package data inclusion
- Updated pyproject.toml with build system and entry points

Co-authored-by: RockChinQ <45992437+RockChinQ@users.noreply.github.com>
2025-11-07 13:58:18 +00:00
copilot-swe-agent[bot]
8fe59da302 Initial plan 2025-11-07 13:48:46 +00:00
134 changed files with 2133 additions and 10530 deletions

View File

@@ -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

View File

@@ -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": {}
}
}
}

View File

@@ -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="FeaturedHelloGitHub" 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)
[![Discord](https://img.shields.io/discord/1335141740050649118?logo=discord&labelColor=%20%235462eb&logoColor=%20%23f5f5f5&color=%20%235462eb)](https://discord.gg/wdNEHETs87)
[![QQ Group](https://img.shields.io/badge/%E7%A4%BE%E5%8C%BAQQ%E7%BE%A4-966235608-blue)](https://qm.qq.com/q/JLi38whHum)
@@ -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)。

View File

@@ -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&#0045;grade&#0032;IM&#0032;bot&#0032;made&#0032;easy&#0046; | 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)
[![Discord](https://img.shields.io/discord/1335141740050649118?logo=discord&labelColor=%20%235462eb&logoColor=%20%23f5f5f5&color=%20%235462eb)](https://discord.gg/wdNEHETs87)
[![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/langbot-app/LangBot)
@@ -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).

View File

@@ -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&#0045;grade&#0032;IM&#0032;bot&#0032;made&#0032;easy&#0046; | 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)
[![Discord](https://img.shields.io/discord/1335141740050649118?logo=discord&labelColor=%20%235462eb&logoColor=%20%23f5f5f5&color=%20%235462eb)](https://discord.gg/wdNEHETs87)
[![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/langbot-app/LangBot)
[![GitHub release (latest by date)](https://img.shields.io/github/v/release/langbot-app/LangBot)](https://github.com/langbot-app/LangBot/releases/latest)
<img src="https://img.shields.io/badge/python-3.10 ~ 3.13 -blue.svg" alt="python">
<a href="https://langbot.app">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.
[![Deploy on Zeabur](https://zeabur.com/button.svg)](https://zeabur.com/en-US/templates/ZKTBDH)
#### Despliegue en la Nube Railway
[![Deploy on Railway](https://railway.com/button.svg)](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.
![star gif](https://docs.langbot.app/star.gif)
## ✨ 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>

View File

@@ -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&#0045;grade&#0032;IM&#0032;bot&#0032;made&#0032;easy&#0046; | 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)
[![Discord](https://img.shields.io/discord/1335141740050649118?logo=discord&labelColor=%20%235462eb&logoColor=%20%23f5f5f5&color=%20%235462eb)](https://discord.gg/wdNEHETs87)
[![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/langbot-app/LangBot)
[![GitHub release (latest by date)](https://img.shields.io/github/v/release/langbot-app/LangBot)](https://github.com/langbot-app/LangBot/releases/latest)
<img src="https://img.shields.io/badge/python-3.10 ~ 3.13 -blue.svg" alt="python">
<a href="https://langbot.app">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é.
[![Deploy on Zeabur](https://zeabur.com/button.svg)](https://zeabur.com/en-US/templates/ZKTBDH)
#### Déploiement Cloud Railway
[![Deploy on Railway](https://railway.com/button.svg)](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.
![star gif](https://docs.langbot.app/star.gif)
## ✨ 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>

View File

@@ -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&#0045;grade&#0032;IM&#0032;bot&#0032;made&#0032;easy&#0046; | 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)
[![Discord](https://img.shields.io/discord/1335141740050649118?logo=discord&labelColor=%20%235462eb&logoColor=%20%23f5f5f5&color=%20%235462eb)](https://discord.gg/wdNEHETs87)
[![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/langbot-app/LangBot)
@@ -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)を参照してください。

View File

@@ -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&#0045;grade&#0032;IM&#0032;bot&#0032;made&#0032;easy&#0046; | 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)
[![Discord](https://img.shields.io/discord/1335141740050649118?logo=discord&labelColor=%20%235462eb&logoColor=%20%23f5f5f5&color=%20%235462eb)](https://discord.gg/wdNEHETs87)
[![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/langbot-app/LangBot)
[![GitHub release (latest by date)](https://img.shields.io/github/v/release/langbot-app/LangBot)](https://github.com/langbot-app/LangBot/releases/latest)
<img src="https://img.shields.io/badge/python-3.10 ~ 3.13 -blue.svg" alt="python">
<a href="https://langbot.app">홈</a>
<a href="https://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 템플릿입니다.
[![Deploy on Zeabur](https://zeabur.com/button.svg)](https://zeabur.com/en-US/templates/ZKTBDH)
#### Railway 클라우드 배포
[![Deploy on Railway](https://railway.com/button.svg)](https://railway.app/template/yRrAyL?referralCode=vogKPF)
#### 기타 배포 방법
릴리스 버전을 직접 사용하여 실행하려면 [수동 배포](https://docs.langbot.app/en/deploy/langbot/manual.html) 문서를 참조하세요.
#### Kubernetes 배포
[Kubernetes 배포](./docker/README_K8S.md) 문서를 참조하세요.
## 😎 최신 정보 받기
리포지토리 오른쪽 상단의 Star 및 Watch 버튼을 클릭하여 최신 업데이트를 받으세요.
![star gif](https://docs.langbot.app/star.gif)
## ✨ 기능
- 💬 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>

View File

@@ -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&#0045;grade&#0032;IM&#0032;bot&#0032;made&#0032;easy&#0046; | 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)
[![Discord](https://img.shields.io/discord/1335141740050649118?logo=discord&labelColor=%20%235462eb&logoColor=%20%23f5f5f5&color=%20%235462eb)](https://discord.gg/wdNEHETs87)
[![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/langbot-app/LangBot)
[![GitHub release (latest by date)](https://img.shields.io/github/v/release/langbot-app/LangBot)](https://github.com/langbot-app/LangBot/releases/latest)
<img src="https://img.shields.io/badge/python-3.10 ~ 3.13 -blue.svg" alt="python">
<a href="https://langbot.app">Главная</a>
<a href="https://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, предоставленный сообществом.
[![Deploy on Zeabur](https://zeabur.com/button.svg)](https://zeabur.com/en-US/templates/ZKTBDH)
#### Облачное развертывание Railway
[![Deploy on Railway](https://railway.com/button.svg)](https://railway.app/template/yRrAyL?referralCode=vogKPF)
#### Другие методы развертывания
Используйте выпущенную версию напрямую для запуска, см. документацию [Ручное развертывание](https://docs.langbot.app/en/deploy/langbot/manual.html).
#### Развертывание Kubernetes
См. документацию [Развертывание Kubernetes](./docker/README_K8S.md).
## 😎 Оставайтесь в курсе
Нажмите кнопки Star и Watch в правом верхнем углу репозитория, чтобы получать последние обновления.
![star gif](https://docs.langbot.app/star.gif)
## ✨ Функции
- 💬 Чат с 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>

View File

@@ -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="FeaturedHelloGitHub" 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)
[![Discord](https://img.shields.io/discord/1335141740050649118?logo=discord&labelColor=%20%235462eb&logoColor=%20%23f5f5f5&color=%20%235462eb)](https://discord.gg/wdNEHETs87)
[![QQ Group](https://img.shields.io/badge/%E7%A4%BE%E5%8C%BAQQ%E7%BE%A4-966235608-blue)](https://qm.qq.com/q/JLi38whHum)
@@ -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)。

View File

@@ -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&#0045;grade&#0032;IM&#0032;bot&#0032;made&#0032;easy&#0046; | 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
[![Discord](https://img.shields.io/discord/1335141740050649118?logo=discord&labelColor=%20%235462eb&logoColor=%20%23f5f5f5&color=%20%235462eb)](https://discord.gg/wdNEHETs87)
[![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/langbot-app/LangBot)
[![GitHub release (latest by date)](https://img.shields.io/github/v/release/langbot-app/LangBot)](https://github.com/langbot-app/LangBot/releases/latest)
<img src="https://img.shields.io/badge/python-3.10 ~ 3.13 -blue.svg" alt="python">
<a href="https://langbot.app">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.
[![Deploy on Zeabur](https://zeabur.com/button.svg)](https://zeabur.com/en-US/templates/ZKTBDH)
#### Triển khai Cloud Railway
[![Deploy on Railway](https://railway.com/button.svg)](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.
![star gif](https://docs.langbot.app/star.gif)
## ✨ 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>

View File

@@ -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

View File

@@ -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无任何旧代码遗留

View File

@@ -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. 实现消息撤回功能

View File

@@ -1,6 +1,6 @@
[project]
name = "langbot"
version = "4.5.4"
version = "4.6.0-beta.2"
description = "Easy-to-use global IM bot platform designed for LLM era"
readme = "README.md"
license-files = ["LICENSE"]
@@ -63,7 +63,7 @@ dependencies = [
"langchain-text-splitters>=0.0.1",
"chromadb>=0.4.24",
"qdrant-client (>=1.15.1,<2.0.0)",
"langbot-plugin==0.2.0b1",
"langbot-plugin==0.1.11b1",
"asyncpg>=0.30.0",
"line-bot-sdk>=3.19.0",
"tboxsdk>=0.0.10",

View File

@@ -26,7 +26,7 @@ markers =
# Coverage options (when using pytest-cov)
[coverage:run]
source = langbot
source = langbot.pkg
omit =
*/tests/*
*/test_*.py

View File

@@ -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 \
"$@"

View File

@@ -1,3 +1,3 @@
"""LangBot - Easy-to-use global IM bot platform designed for LLM era"""
__version__ = '4.5.4'
__version__ = '4.6.0-beta.2'

View File

@@ -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:

View File

@@ -109,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,
},
@@ -124,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):

View File

@@ -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

View 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

View File

@@ -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})

View File

@@ -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()

View 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)}')

View File

@@ -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

View File

@@ -42,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)}')

View File

@@ -15,7 +15,7 @@ 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()
@@ -24,7 +24,7 @@ class PluginsRouterGroup(group.RouterGroup):
@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()
@@ -40,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':
@@ -66,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)
@@ -82,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'],
@@ -106,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
@@ -166,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"""
@@ -220,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
@@ -254,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
@@ -270,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:

View File

@@ -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'],

View File

@@ -139,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)

View File

@@ -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)

View File

@@ -71,9 +71,6 @@ 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')
return await runtime_kb.store_file(file_id)
async def retrieve_knowledge_base(self, kb_uuid: str, query: str) -> list[dict]:
@@ -98,9 +95,6 @@ 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)
async def delete_knowledge_base(self, kb_uuid: str) -> None:

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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):

View File

@@ -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'
@@ -29,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)

View File

View 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

View File

@@ -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"""

View File

@@ -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

View File

@@ -69,17 +69,11 @@ class RuntimePipeline:
stage_containers: list[StageInstContainer]
"""阶段实例容器"""
bound_plugins: list[str] | None
"""绑定到此流水线的插件列表格式author/plugin_nameNone表示启用所有"""
bound_plugins: list[str]
"""绑定到此流水线的插件列表格式author/plugin_name"""
bound_mcp_servers: list[str] | None
"""绑定到此流水线的MCP服务器列表格式uuidNone表示启用所有"""
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

View File

@@ -6,7 +6,6 @@ from .. import stage, entities
from langbot_plugin.api.entities.builtin.provider import message as provider_message
import langbot_plugin.api.entities.events as events
import langbot_plugin.api.entities.builtin.platform.message as platform_message
import langbot_plugin.api.entities.builtin.platform.events as platform_events
import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query
@@ -75,19 +74,12 @@ class PreProcessor(stage.PipelineStage):
self.ap.logger.debug(f'Bound MCP servers: {bound_mcp_servers}')
self.ap.logger.debug(f'Use funcs: {query.use_funcs}')
# Extract sender name from message event
sender_name = ''
if isinstance(query.message_event, (platform_events.FriendMessage, platform_events.GroupMessage)):
sender_name = query.message_event.sender.get_name()
variables = {
'session_id': f'{query.session.launcher_type.value}_{query.session.launcher_id}',
'conversation_id': conversation.uuid,
'msg_create_time': (
int(query.message_event.time) if query.message_event.time else int(datetime.datetime.now().timestamp())
),
'sender_id': str(query.sender_id),
'sender_name': sender_name,
}
query.variables.update(variables)
@@ -107,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):
@@ -122,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))

View File

@@ -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,
)

View File

@@ -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)])

View File

@@ -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()
@@ -269,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:
@@ -286,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:

View File

@@ -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']:

View File

@@ -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![image]({msg.url})\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![image]({msg.base64})\n'
else:
return f'\n![image](data:image/png;base64,{msg.base64})\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':

View File

@@ -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: ![alt](url)
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',

View File

@@ -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})
@@ -206,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'])],
)
)

View File

@@ -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
@@ -127,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,

View 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)]

View 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"

View File

@@ -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"

View File

@@ -1,402 +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的连接
await ws_connection_manager.broadcast_to_pipeline(
pipeline_uuid,
{
'type': 'response',
'data': message_data.model_dump(),
},
)
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
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的连接
await ws_connection_manager.broadcast_to_pipeline(
pipeline_uuid,
{
'type': 'response',
'data': message_data.model_dump(),
},
)
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)
# 广播用户消息到所有连接(包括发送者)
await ws_connection_manager.broadcast_to_pipeline(
pipeline_uuid,
{
'type': 'user_message',
'data': user_message.model_dump(),
},
)
# 添加消息源
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] = []

View File

@@ -1,177 +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):
"""向指定流水线的所有连接广播消息"""
connections = await self.get_connections_by_pipeline(pipeline_uuid)
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()

View File

@@ -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,14 +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 emit_event(
self,
event: events.BaseEventModel,
@@ -416,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:
@@ -457,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
@@ -493,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

View File

@@ -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,53 +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

View File

@@ -298,8 +298,6 @@ class DifyServiceAPIRunner(runner.RequestRunner):
'langbot_session_id': query.variables['session_id'],
'langbot_conversation_id': query.variables['conversation_id'],
'langbot_msg_create_time': query.variables['msg_create_time'],
'langbot_sender_id': query.variables.get('sender_id', ''),
'langbot_sender_name': query.variables.get('sender_name', ''),
}
inputs.update(query.variables)
@@ -578,8 +576,6 @@ class DifyServiceAPIRunner(runner.RequestRunner):
'langbot_session_id': query.variables['session_id'],
'langbot_conversation_id': query.variables['conversation_id'],
'langbot_msg_create_time': query.variables['msg_create_time'],
'langbot_sender_id': query.variables.get('sender_id', ''),
'langbot_sender_name': query.variables.get('sender_name', ''),
}
inputs.update(query.variables)

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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():

View File

@@ -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()

View File

@@ -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}')

View File

@@ -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

View File

@@ -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}')

View File

@@ -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

View File

@@ -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],
)

View File

@@ -1,8 +1,6 @@
import langbot
semantic_version = 'v4.6.0-beta.2'
semantic_version = f'v{langbot.__version__}'
required_database_version = 12
required_database_version = 11
"""Tag the version of the database schema, used to check if the database needs to be migrated"""
debug_mode = False

View File

@@ -1,110 +0,0 @@
"""
Test that preproc module adds sender_id and sender_name variables.
These tests verify the variables are correctly extracted from message events.
"""
import pytest
from unittest.mock import Mock, AsyncMock
@pytest.mark.asyncio
async def test_sender_variables_friend_message():
"""Test sender_id and sender_name are extracted from FriendMessage"""
# Create mock Friend sender
mock_sender = Mock()
mock_sender.id = 'test_user_123'
mock_sender.nickname = 'Test User'
mock_sender.remark = 'Test Remark'
mock_sender.get_name = Mock(return_value='Test User')
# Verify get_name returns nickname
assert mock_sender.get_name() == 'Test User'
@pytest.mark.asyncio
async def test_sender_variables_group_message():
"""Test sender_id and sender_name are extracted from GroupMessage"""
# Create mock GroupMember sender
mock_sender = Mock()
mock_sender.id = 'group_user_456'
mock_sender.member_name = 'Group User Name'
mock_sender.get_name = Mock(return_value='Group User Name')
# Verify get_name returns member_name
assert mock_sender.get_name() == 'Group User Name'
def test_sender_id_string_conversion():
"""Test sender_id is converted to string"""
# Test integer sender_id
sender_id_int = 12345
assert str(sender_id_int) == '12345'
# Test string sender_id
sender_id_str = 'user_abc'
assert str(sender_id_str) == 'user_abc'
def test_sender_name_empty_fallback():
"""Test sender_name defaults to empty string when not available"""
# When sender has no name
sender_name = ''
assert sender_name == ''
def test_variables_dict_structure():
"""Test the variables dictionary has expected structure"""
# Simulate what the variables dict should look like
variables = {
'session_id': 'person_12345',
'conversation_id': 'conv-uuid-123',
'msg_create_time': 1609459200,
'sender_id': '12345',
'sender_name': 'Test User',
}
# Verify all expected keys are present
assert 'session_id' in variables
assert 'conversation_id' in variables
assert 'msg_create_time' in variables
assert 'sender_id' in variables
assert 'sender_name' in variables
# Verify values
assert variables['sender_id'] == '12345'
assert variables['sender_name'] == 'Test User'
def test_dify_workflow_inputs_structure():
"""Test the Dify workflow inputs have expected legacy variables"""
plain_text = 'Hello world'
variables = {
'session_id': 'person_12345',
'conversation_id': 'conv-uuid-123',
'msg_create_time': 1609459200,
'sender_id': '12345',
'sender_name': 'Test User',
}
# Simulate Dify workflow inputs structure
inputs = {
'langbot_user_message_text': plain_text,
'langbot_session_id': variables['session_id'],
'langbot_conversation_id': variables['conversation_id'],
'langbot_msg_create_time': variables['msg_create_time'],
'langbot_sender_id': variables.get('sender_id', ''),
'langbot_sender_name': variables.get('sender_name', ''),
}
inputs.update(variables)
# Verify all legacy variables are present
assert inputs['langbot_user_message_text'] == 'Hello world'
assert inputs['langbot_session_id'] == 'person_12345'
assert inputs['langbot_conversation_id'] == 'conv-uuid-123'
assert inputs['langbot_msg_create_time'] == 1609459200
assert inputs['langbot_sender_id'] == '12345'
assert inputs['langbot_sender_name'] == 'Test User'
# Verify regular variables are also present
assert inputs['sender_id'] == '12345'
assert inputs['sender_name'] == 'Test User'

View File

@@ -1 +0,0 @@
# Plugin connector unit tests

View File

@@ -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'

View File

@@ -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

1865
web/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -45,7 +45,6 @@
"axios": "^1.12.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"highlight.js": "^11.11.1",
"i18next": "^25.1.2",
"i18next-browser-languagedetector": "^8.1.0",
"input-otp": "^1.4.2",
@@ -60,11 +59,6 @@
"react-i18next": "^15.5.1",
"react-markdown": "^10.1.0",
"react-photo-view": "^1.2.7",
"react-syntax-highlighter": "^16.1.0",
"rehype-autolink-headings": "^7.1.0",
"rehype-highlight": "^7.0.2",
"rehype-raw": "^7.0.0",
"rehype-slug": "^6.0.0",
"remark-gfm": "^4.0.1",
"sonner": "^2.0.3",
"tailwind-merge": "^3.2.0",
@@ -78,7 +72,6 @@
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"@types/react-syntax-highlighter": "^15.5.13",
"eslint": "^9",
"eslint-config-next": "15.2.4",
"eslint-config-prettier": "^10.1.2",
@@ -88,6 +81,5 @@
"tw-animate-css": "^1.2.9",
"typescript": "^5.8.3",
"typescript-eslint": "^8.31.1"
},
"packageManager": "pnpm@8.9.2+sha512.b9d35fe91b2a5854dadc43034a3e7b2e675fa4b56e20e8e09ef078fa553c18f8aed44051e7b36e8b8dd435f97eb0c44c4ff3b44fc7c6fa7d21e1fac17bbe661e"
}
}

View File

@@ -129,6 +129,7 @@ export default function BotForm({
form.setValue('adapter_config', val.adapter_config);
form.setValue('enable', val.enable);
form.setValue('use_pipeline_uuid', val.use_pipeline_uuid || '');
console.log('form', form.getValues());
handleAdapterSelect(val.adapter);
// dynamicForm.setFieldsValue(val.adapter_config);
})
@@ -144,6 +145,7 @@ export default function BotForm({
async function initBotFormComponent() {
// 初始化流水线列表
const pipelinesRes = await httpClient.getPipelines();
console.log('rawPipelineList', pipelinesRes);
setPipelineNameList(
pipelinesRes.pipelines.map((item) => {
return {
@@ -155,6 +157,7 @@ export default function BotForm({
// 拉取adapter
const adaptersRes = await httpClient.getAdapters();
console.log('rawAdapterList', adaptersRes);
setAdapterNameList(
adaptersRes.adapters.map((item) => {
return {
@@ -250,10 +253,12 @@ export default function BotForm({
}
// 只有通过外层固定表单验证才会走到这里,真正的提交逻辑在这里
function onDynamicFormSubmit() {
function onDynamicFormSubmit(value: object) {
setIsLoading(true);
console.log('set loading', true);
if (initBotId) {
// 编辑提交
// console.log('submit edit', form.getFieldsValue(), value);
const updateBot: Bot = {
uuid: initBotId,
name: form.getValues().name,
@@ -265,7 +270,8 @@ export default function BotForm({
};
httpClient
.updateBot(initBotId, updateBot)
.then(() => {
.then((res) => {
console.log('update bot success', res);
onFormSubmit(form.getValues());
toast.success(t('bots.saveSuccess'));
})
@@ -279,6 +285,7 @@ export default function BotForm({
});
} else {
// 创建提交
console.log('submit create', form.getValues(), value);
const newBot: Bot = {
name: form.getValues().name,
description: form.getValues().description,
@@ -288,6 +295,7 @@ export default function BotForm({
httpClient
.createBot(newBot)
.then((res) => {
console.log('create bot success', res);
toast.success(t('bots.createSuccess'));
initBotId = res.uuid;

View File

@@ -86,6 +86,7 @@ export default function BotConfigPage() {
}
function handleNewBotCreated(botId: string) {
console.log('new bot created', botId);
getBotList();
setSelectedBotId(botId);
}

View File

@@ -241,7 +241,7 @@ export default function ApiIntegrationDialog({
onOpenChange(newOpen);
}}
>
<DialogContent className="sm:max-w-[800px] h-[26rem] flex flex-col">
<DialogContent className="sm:max-w-[800px]">
<DialogHeader>
<DialogTitle>{t('common.manageApiIntegration')}</DialogTitle>
</DialogHeader>
@@ -249,7 +249,7 @@ export default function ApiIntegrationDialog({
<Tabs
value={activeTab}
onValueChange={setActiveTab}
className="w-full flex-1 flex flex-col overflow-hidden"
className="w-full"
>
<TabsList className="shadow-md py-3 bg-[#f0f0f0] dark:bg-[#2a2a2e]">
<TabsTrigger className="px-5 py-4 cursor-pointer" value="apikeys">
@@ -264,10 +264,7 @@ export default function ApiIntegrationDialog({
</TabsList>
{/* API Keys Tab */}
<TabsContent
value="apikeys"
className="space-y-4 flex-1 flex flex-col overflow-hidden"
>
<TabsContent value="apikeys" className="space-y-4">
<div className="flex items-start gap-2 text-sm text-muted-foreground">
{t('common.apiKeyHint')}
</div>
@@ -292,16 +289,12 @@ export default function ApiIntegrationDialog({
{t('common.noApiKeys')}
</div>
) : (
<div className="border rounded-md overflow-auto flex-1">
<div className="border rounded-md">
<Table>
<TableHeader>
<TableRow>
<TableHead className="min-w-[120px]">
{t('common.name')}
</TableHead>
<TableHead className="min-w-[200px]">
{t('common.apiKeyValue')}
</TableHead>
<TableHead>{t('common.name')}</TableHead>
<TableHead>{t('common.apiKeyValue')}</TableHead>
<TableHead className="w-[100px]">
{t('common.actions')}
</TableHead>
@@ -354,10 +347,7 @@ export default function ApiIntegrationDialog({
</TabsContent>
{/* Webhooks Tab */}
<TabsContent
value="webhooks"
className="space-y-4 flex-1 flex flex-col overflow-hidden"
>
<TabsContent value="webhooks" className="space-y-4">
<div className="flex items-start gap-2 text-sm text-muted-foreground">
{t('common.webhookHint')}
</div>
@@ -382,20 +372,16 @@ export default function ApiIntegrationDialog({
{t('common.noWebhooks')}
</div>
) : (
<div className="border rounded-md overflow-auto flex-1 max-w-full">
<Table className="table-fixed w-full">
<div className="border rounded-md">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-[150px]">
{t('common.name')}
</TableHead>
<TableHead className="w-[380px]">
{t('common.webhookUrl')}
</TableHead>
<TableHead>{t('common.name')}</TableHead>
<TableHead>{t('common.webhookUrl')}</TableHead>
<TableHead className="w-[80px]">
{t('common.webhookEnabled')}
</TableHead>
<TableHead className="w-[80px]">
<TableHead className="w-[100px]">
{t('common.actions')}
</TableHead>
</TableRow>
@@ -403,30 +389,20 @@ export default function ApiIntegrationDialog({
<TableBody>
{webhooks.map((webhook) => (
<TableRow key={webhook.id}>
<TableCell className="truncate">
<div className="truncate">
<div
className="font-medium truncate"
title={webhook.name}
>
{webhook.name}
</div>
<TableCell>
<div>
<div className="font-medium">{webhook.name}</div>
{webhook.description && (
<div
className="text-sm text-muted-foreground truncate"
title={webhook.description}
>
<div className="text-sm text-muted-foreground">
{webhook.description}
</div>
)}
</div>
</TableCell>
<TableCell>
<div className="overflow-x-auto max-w-[380px]">
<code className="text-sm bg-muted px-2 py-1 rounded whitespace-nowrap inline-block">
{webhook.url}
</code>
</div>
<code className="text-sm bg-muted px-2 py-1 rounded break-all">
{webhook.url}
</code>
</TableCell>
<TableCell>
<Switch

View File

@@ -110,6 +110,8 @@ export default function DynamicFormComponent({
// 当 initialValues 变化时更新表单值
// 但要避免因为内部表单更新触发的 onSubmit 导致的 initialValues 变化而重新设置表单
useEffect(() => {
console.log('initialValues', initialValues);
// 首次挂载时,使用 initialValues 初始化表单
if (isInitialMount.current) {
isInitialMount.current = false;
@@ -146,6 +148,7 @@ export default function DynamicFormComponent({
const subscription = form.watch(() => {
// 获取完整的表单值,确保包含所有默认值
const formValues = form.getValues();
console.log('formValues', formValues);
const finalValues = itemConfigList.reduce(
(acc, item) => {
acc[item.name] = formValues[item.name] ?? item.default;
@@ -153,6 +156,7 @@ export default function DynamicFormComponent({
},
{} as Record<string, object>,
);
console.log('finalValues', finalValues);
onSubmit?.(finalValues);
});
return () => subscription.unsubscribe();

View File

@@ -9,7 +9,6 @@ import {
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
@@ -18,13 +17,8 @@ import { ControllerRenderProps } from 'react-hook-form';
import { Button } from '@/components/ui/button';
import { useEffect, useState } from 'react';
import { httpClient } from '@/app/infra/http/HttpClient';
import {
LLMModel,
Bot,
KnowledgeBase,
ExternalKnowledgeBase,
ApiRespPluginSystemStatus,
} from '@/app/infra/entities/api';
import { LLMModel, Bot } from '@/app/infra/entities/api';
import { KnowledgeBase } from '@/app/infra/entities/api';
import { toast } from 'sonner';
import {
HoverCard,
@@ -57,15 +51,10 @@ export default function DynamicFormItemComponent({
}) {
const [llmModels, setLlmModels] = useState<LLMModel[]>([]);
const [knowledgeBases, setKnowledgeBases] = useState<KnowledgeBase[]>([]);
const [externalKnowledgeBases, setExternalKnowledgeBases] = useState<
ExternalKnowledgeBase[]
>([]);
const [bots, setBots] = useState<Bot[]>([]);
const [uploading, setUploading] = useState<boolean>(false);
const [kbDialogOpen, setKbDialogOpen] = useState(false);
const [tempSelectedKBIds, setTempSelectedKBIds] = useState<string[]>([]);
const [pluginSystemStatus, setPluginSystemStatus] =
useState<ApiRespPluginSystemStatus | null>(null);
const { t } = useTranslation();
const handleFileUpload = async (file: File): Promise<IFileConfig | null> => {
@@ -124,37 +113,9 @@ export default function DynamicFormItemComponent({
.catch((err) => {
toast.error('Failed to get knowledge base list: ' + err.message);
});
// Fetch plugin system status
httpClient
.getPluginSystemStatus()
.then((status) => {
setPluginSystemStatus(status);
})
.catch((err) => {
console.error('Failed to get plugin system status:', err);
});
}
}, [config.type]);
useEffect(() => {
if (
(config.type === DynamicFormItemType.KNOWLEDGE_BASE_SELECTOR ||
config.type === DynamicFormItemType.KNOWLEDGE_BASE_MULTI_SELECTOR) &&
pluginSystemStatus?.is_enable &&
pluginSystemStatus?.is_connected
) {
httpClient
.getExternalKnowledgeBases()
.then((resp) => {
setExternalKnowledgeBases(resp.bases);
})
.catch((err) => {
console.error('Failed to get external knowledge base list:', err);
});
}
}, [config.type, pluginSystemStatus]);
useEffect(() => {
if (config.type === DynamicFormItemType.BOT_SELECTOR) {
httpClient
@@ -279,7 +240,7 @@ export default function DynamicFormItemComponent({
model.requester,
)}
alt="icon"
className="w-8 h-8 rounded-[8%]"
className="w-8 h-8 rounded-full"
/>
<h4 className="font-medium">{model.name}</h4>
</div>
@@ -379,39 +340,12 @@ export default function DynamicFormItemComponent({
<SelectContent>
<SelectGroup>
<SelectItem value="__none__">{t('knowledge.empty')}</SelectItem>
{knowledgeBases.map((base) => (
<SelectItem key={base.uuid} value={base.uuid ?? ''}>
{base.name}
</SelectItem>
))}
</SelectGroup>
{knowledgeBases.length > 0 && (
<SelectGroup>
<SelectLabel>{t('knowledge.builtIn')}</SelectLabel>
{knowledgeBases.map((base) => (
<SelectItem key={base.uuid} value={base.uuid ?? ''}>
{base.name}
</SelectItem>
))}
</SelectGroup>
)}
{externalKnowledgeBases.length > 0 && (
<SelectGroup>
<SelectLabel>{t('knowledge.external')}</SelectLabel>
{externalKnowledgeBases.map((base) => (
<SelectItem key={base.uuid} value={base.uuid ?? ''}>
<div className="flex items-center gap-2">
<img
src={httpClient.getPluginIconURL(
base.plugin_author,
base.plugin_name,
)}
alt="plugin icon"
className="w-4 h-4 rounded-[8%] flex-shrink-0"
/>
<span>{base.name}</span>
</div>
</SelectItem>
))}
</SelectGroup>
)}
</SelectContent>
</Select>
);
@@ -424,36 +358,19 @@ export default function DynamicFormItemComponent({
<div className="space-y-2">
{field.value.map((kbId: string) => {
const kb = knowledgeBases.find((base) => base.uuid === kbId);
const externalKb = externalKnowledgeBases.find(
(base) => base.uuid === kbId,
);
const currentKb = kb || externalKb;
if (!currentKb) return null;
if (!kb) return null;
return (
<div
key={kbId}
className="flex items-center justify-between rounded-lg border p-3 hover:bg-accent"
>
<div className="flex items-center gap-2 flex-1">
{externalKb && (
<img
src={httpClient.getPluginIconURL(
externalKb.plugin_author,
externalKb.plugin_name,
)}
alt="plugin icon"
className="w-8 h-8 rounded-[8%] flex-shrink-0"
/>
<div className="flex-1">
<div className="font-medium">{kb.name}</div>
{kb.description && (
<div className="text-sm text-muted-foreground">
{kb.description}
</div>
)}
<div className="flex-1 min-w-0">
<div className="font-medium">{currentKb.name}</div>
{currentKb.description && (
<div className="text-sm text-muted-foreground">
{currentKb.description}
</div>
)}
</div>
</div>
<Button
type="button"
@@ -500,96 +417,39 @@ export default function DynamicFormItemComponent({
<DialogHeader>
<DialogTitle>{t('knowledge.selectKnowledgeBases')}</DialogTitle>
</DialogHeader>
<div className="flex-1 overflow-y-auto space-y-4 pr-2">
{/* Built-in Knowledge Bases */}
{knowledgeBases.length > 0 && (
<div className="space-y-2">
<div className="text-sm font-semibold text-muted-foreground px-2">
{t('knowledge.builtIn')}
</div>
{knowledgeBases.map((base) => {
const isSelected = tempSelectedKBIds.includes(
base.uuid ?? '',
);
return (
<div
key={base.uuid}
className="flex items-center gap-3 rounded-lg border p-3 hover:bg-accent cursor-pointer"
onClick={() => {
const kbId = base.uuid ?? '';
setTempSelectedKBIds((prev) =>
prev.includes(kbId)
? prev.filter((id) => id !== kbId)
: [...prev, kbId],
);
}}
>
<Checkbox
checked={isSelected}
aria-label={`Select ${base.name}`}
/>
<div className="flex-1">
<div className="font-medium">{base.name}</div>
{base.description && (
<div className="text-sm text-muted-foreground">
{base.description}
</div>
)}
<div className="flex-1 overflow-y-auto space-y-2 pr-2">
{knowledgeBases.map((base) => {
const isSelected = tempSelectedKBIds.includes(
base.uuid ?? '',
);
return (
<div
key={base.uuid}
className="flex items-center gap-3 rounded-lg border p-3 hover:bg-accent cursor-pointer"
onClick={() => {
const kbId = base.uuid ?? '';
setTempSelectedKBIds((prev) =>
prev.includes(kbId)
? prev.filter((id) => id !== kbId)
: [...prev, kbId],
);
}}
>
<Checkbox
checked={isSelected}
aria-label={`Select ${base.name}`}
/>
<div className="flex-1">
<div className="font-medium">{base.name}</div>
{base.description && (
<div className="text-sm text-muted-foreground">
{base.description}
</div>
</div>
);
})}
</div>
)}
{/* External Knowledge Bases */}
{externalKnowledgeBases.length > 0 && (
<div className="space-y-2">
<div className="text-sm font-semibold text-muted-foreground px-2">
{t('knowledge.external')}
)}
</div>
</div>
{externalKnowledgeBases.map((base) => {
const isSelected = tempSelectedKBIds.includes(
base.uuid ?? '',
);
return (
<div
key={base.uuid}
className="flex items-center gap-3 rounded-lg border p-3 hover:bg-accent cursor-pointer"
onClick={() => {
const kbId = base.uuid ?? '';
setTempSelectedKBIds((prev) =>
prev.includes(kbId)
? prev.filter((id) => id !== kbId)
: [...prev, kbId],
);
}}
>
<Checkbox
checked={isSelected}
aria-label={`Select ${base.name}`}
/>
<img
src={httpClient.getPluginIconURL(
base.plugin_author,
base.plugin_name,
)}
alt="plugin icon"
className="w-8 h-8 rounded-[8%] flex-shrink-0"
/>
<div className="flex-1">
<div className="font-medium">{base.name}</div>
{base.description && (
<div className="text-sm text-muted-foreground">
{base.description}
</div>
)}
</div>
</div>
);
})}
</div>
)}
);
})}
</div>
<DialogFooter>
<Button

View File

@@ -66,6 +66,7 @@ export default function HomeSidebar({
.catch((error) => {
console.error('Failed to fetch GitHub star count:', error);
});
return () => console.log('sidebar.unmounted');
}, []);
function handleChildClick(child: SidebarChildVO) {
@@ -89,6 +90,7 @@ export default function HomeSidebar({
}
function handleRoute(child: SidebarChildVO) {
console.log(child);
router.push(`${child.route}`);
}
@@ -100,6 +102,7 @@ export default function HomeSidebar({
routeList[1] === 'home' &&
sidebarConfigList.find((childConfig) => childConfig.route === pathname)
) {
console.log('find success');
const routeSelectChild = sidebarConfigList.find(
(childConfig) => childConfig.route === pathname,
);
@@ -141,6 +144,7 @@ export default function HomeSidebar({
<div
key={config.id}
onClick={() => {
console.log('click:', config.id);
handleChildClick(config);
}}
>
@@ -165,7 +169,7 @@ export default function HomeSidebar({
onClick={() => {
window.open('https://github.com/langbot-app/LangBot', '_blank');
}}
className="flex justify-center cursor-pointer p-2 rounded-lg transition-colors"
className="flex justify-center cursor-pointer p-2 rounded-lg hover:bg-accent/30 transition-colors"
>
<Badge
variant="outline"
@@ -184,6 +188,40 @@ export default function HomeSidebar({
</div>
)}
<SidebarChild
onClick={() => {
// open docs.langbot.app
const language = localStorage.getItem('langbot_language');
if (language === 'zh-Hans') {
window.open(
'https://docs.langbot.app/zh/insight/guide.html',
'_blank',
);
} else if (language === 'zh-Hant') {
window.open(
'https://docs.langbot.app/zh/insight/guide.html',
'_blank',
);
} else {
window.open(
'https://docs.langbot.app/en/insight/guide.html',
'_blank',
);
}
}}
isSelected={false}
icon={
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
>
<path d="M12 22C6.47715 22 2 17.5228 2 12C2 6.47715 6.47715 2 12 2C17.5228 2 22 6.47715 22 12C22 17.5228 17.5228 22 12 22ZM12 20C16.4183 20 20 16.4183 20 12C20 7.58172 16.4183 4 12 4C7.58172 4 4 7.58172 4 12C4 16.4183 7.58172 20 12 20ZM11 15H13V17H11V15ZM13 13.3551V14H11V12.5C11 11.9477 11.4477 11.5 12 11.5C12.8284 11.5 13.5 10.8284 13.5 10C13.5 9.17157 12.8284 8.5 12 8.5C11.2723 8.5 10.6656 9.01823 10.5288 9.70577L8.56731 9.31346C8.88637 7.70919 10.302 6.5 12 6.5C13.933 6.5 15.5 8.067 15.5 10C15.5 11.5855 14.4457 12.9248 13 13.3551Z"></path>
</svg>
}
name={t('common.helpDocs')}
/>
<SidebarChild
onClick={() => {
setApiKeyDialogOpen(true);
@@ -264,41 +302,6 @@ export default function HomeSidebar({
<div className="flex flex-col gap-2 w-full">
<span className="text-sm font-medium">{t('common.account')}</span>
<Button
variant="ghost"
className="w-full justify-start font-normal"
onClick={() => {
// open docs.langbot.app
const language = localStorage.getItem('langbot_language');
if (language === 'zh-Hans') {
window.open(
'https://docs.langbot.app/zh/insight/guide.html',
'_blank',
);
} else if (language === 'zh-Hant') {
window.open(
'https://docs.langbot.app/zh/insight/guide.html',
'_blank',
);
} else {
window.open(
'https://docs.langbot.app/en/insight/guide.html',
'_blank',
);
}
setPopoverOpen(false);
}}
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
className="w-4 h-4 mr-2"
>
<path d="M12 22C6.47715 22 2 17.5228 2 12C2 6.47715 6.47715 2 12 2C17.5228 2 22 6.47715 22 12C22 17.5228 17.5228 22 12 22ZM12 20C16.4183 20 20 16.4183 20 12C20 7.58172 16.4183 4 12 4C7.58172 4 4 7.58172 4 12C4 16.4183 7.58172 20 12 20ZM11 15H13V17H11V15ZM13 13.3551V14H11V12.5C11 11.9477 11.4477 11.5 12 11.5C12.8284 11.5 13.5 10.8284 13.5 10C13.5 9.17157 12.8284 8.5 12 8.5C11.2723 8.5 10.6656 9.01823 10.5288 9.70577L8.56731 9.31346C8.88637 7.70919 10.302 6.5 12 6.5C13.933 6.5 15.5 8.067 15.5 10C15.5 11.5855 14.4457 12.9248 13 13.3551Z"></path>
</svg>
{t('common.helpDocs')}
</Button>
<Button
variant="ghost"
className="w-full justify-start font-normal"

View File

@@ -25,14 +25,11 @@ import { httpClient } from '@/app/infra/http/HttpClient';
import KBForm from '@/app/home/knowledge/components/kb-form/KBForm';
import KBDoc from '@/app/home/knowledge/components/kb-docs/KBDoc';
import KBRetrieve from '@/app/home/knowledge/components/kb-retrieve/KBRetrieve';
import ExternalKBForm from '@/app/home/knowledge/components/external-kb-form/ExternalKBForm';
import ExternalKBRetrieve from '@/app/home/knowledge/components/kb-retrieve/ExternalKBRetrieve';
interface KBDetailDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
kbId?: string;
kbType: 'builtin' | 'external';
onFormCancel: () => void;
onKbDeleted: () => void;
onNewKbCreated: (kbId: string) => void;
@@ -43,7 +40,6 @@ export default function KBDetailDialog({
open,
onOpenChange,
kbId: propKbId,
kbType,
onFormCancel,
onKbDeleted,
onNewKbCreated,
@@ -59,7 +55,6 @@ export default function KBDetailDialog({
setActiveMenu('metadata');
}, [propKbId, open]);
// Build menu based on KB type
const menu = [
{
key: 'metadata',
@@ -74,24 +69,19 @@ export default function KBDetailDialog({
</svg>
),
},
// Only show documents for builtin KB
...(kbType === 'builtin'
? [
{
key: 'documents',
label: t('knowledge.documents'),
icon: (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
>
<path d="M21 8V20.9932C21 21.5501 20.5552 22 20.0066 22H3.9934C3.44495 22 3 21.556 3 21.0082V2.9918C3 2.45531 3.4487 2 4.00221 2H14.9968L21 8ZM19 9H14V4H5V20H19V9ZM8 7H11V9H8V7ZM8 11H16V13H8V11ZM8 15H16V17H8V15Z"></path>
</svg>
),
},
]
: []),
{
key: 'documents',
label: t('knowledge.documents'),
icon: (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
>
<path d="M21 8V20.9932C21 21.5501 20.5552 22 20.0066 22H3.9934C3.44495 22 3 21.556 3 21.0082V2.9918C3 2.45531 3.4487 2 4.00221 2H14.9968L21 8ZM19 9H14V4H5V20H19V9ZM8 7H11V9H8V7ZM8 11H16V13H8V11ZM8 15H16V17H8V15Z"></path>
</svg>
),
},
{
key: 'retrieve',
label: t('knowledge.retrieve'),
@@ -108,12 +98,7 @@ export default function KBDetailDialog({
];
const confirmDelete = () => {
const deletePromise =
kbType === 'builtin'
? httpClient.deleteKnowledgeBase(kbId ?? '')
: httpClient.deleteExternalKnowledgeBase(kbId ?? '');
deletePromise.then(() => {
httpClient.deleteKnowledgeBase(kbId ?? '').then(() => {
onKbDeleted();
});
setShowDeleteConfirm(false);
@@ -126,35 +111,22 @@ export default function KBDetailDialog({
<DialogContent className="overflow-hidden p-0 !max-w-[40vw] max-h-[70vh] flex">
<main className="flex flex-1 flex-col h-[70vh]">
<DialogHeader className="px-6 pt-6 pb-4 shrink-0">
<DialogTitle>
{kbType === 'builtin'
? t('knowledge.createKnowledgeBase')
: t('knowledge.addExternal')}
</DialogTitle>
<DialogTitle>{t('knowledge.createKnowledgeBase')}</DialogTitle>
</DialogHeader>
<div className="flex-1 overflow-y-auto px-6 pb-6">
{kbType === 'builtin' ? (
{activeMenu === 'metadata' && (
<KBForm
initKbId={undefined}
onNewKbCreated={onNewKbCreated}
onKbUpdated={onKbUpdated}
/>
) : (
<ExternalKBForm
initKBId={undefined}
onFormSubmit={() => onOpenChange(false)}
onKBDeleted={() => {}}
onNewKBCreated={onNewKbCreated}
/>
)}
{activeMenu === 'documents' && <div>documents</div>}
</div>
{activeMenu === 'metadata' && (
<DialogFooter className="px-6 py-4 border-t shrink-0">
<div className="flex justify-end gap-2">
<Button
type="submit"
form={kbType === 'builtin' ? 'kb-form' : 'external-kb-form'}
>
<Button type="submit" form="kb-form">
{t('common.save')}
</Button>
<Button
@@ -216,33 +188,15 @@ export default function KBDetailDialog({
</DialogTitle>
</DialogHeader>
<div className="flex-1 overflow-y-auto px-6 pb-6">
{activeMenu === 'metadata' &&
(kbType === 'builtin' ? (
<KBForm
initKbId={kbId}
onNewKbCreated={onNewKbCreated}
onKbUpdated={onKbUpdated}
/>
) : (
<ExternalKBForm
initKBId={kbId}
onFormSubmit={() => onOpenChange(false)}
onKBDeleted={() => {
onKbDeleted();
onOpenChange(false);
}}
onNewKBCreated={onNewKbCreated}
/>
))}
{activeMenu === 'documents' && kbType === 'builtin' && (
<KBDoc kbId={kbId} />
{activeMenu === 'metadata' && (
<KBForm
initKbId={kbId}
onNewKbCreated={onNewKbCreated}
onKbUpdated={onKbUpdated}
/>
)}
{activeMenu === 'retrieve' &&
(kbType === 'builtin' ? (
<KBRetrieve kbId={kbId} />
) : (
<ExternalKBRetrieve kbId={kbId} />
))}
{activeMenu === 'documents' && <KBDoc kbId={kbId} />}
{activeMenu === 'retrieve' && <KBRetrieve kbId={kbId} />}
</div>
{activeMenu === 'metadata' && (
<DialogFooter className="px-6 py-4 border-t shrink-0">
@@ -254,12 +208,7 @@ export default function KBDetailDialog({
>
{t('common.delete')}
</Button>
<Button
type="submit"
form={
kbType === 'builtin' ? 'kb-form' : 'external-kb-form'
}
>
<Button type="submit" form="kb-form">
{t('common.save')}
</Button>
<Button

View File

@@ -1,74 +0,0 @@
import { ExternalKBCardVO } from '@/app/home/knowledge/components/external-kb-card/ExternalKBCardVO';
import { useTranslation } from 'react-i18next';
import styles from '../kb-card/KBCard.module.css';
import { httpClient } from '@/app/infra/http/HttpClient';
export default function ExternalKBCard({
kbCardVO,
}: {
kbCardVO: ExternalKBCardVO;
}) {
const { t } = useTranslation();
return (
<div className={`${styles.cardContainer}`}>
<div className="w-full h-full flex flex-row items-start gap-3">
{/* Icon */}
<img
src={httpClient.getPluginIconURL(
kbCardVO.pluginAuthor,
kbCardVO.pluginName,
)}
alt="plugin icon"
className="w-16 h-16 mt-1 rounded-[8%] flex-shrink-0"
/>
{/* Info Column */}
<div className="flex flex-col flex-1 min-w-0 h-full">
{/* Top section: Name, Description and Plugin Info */}
<div className="flex flex-col gap-0">
{/* Name and Description */}
<div className={`${styles.basicInfoNameContainer}`}>
<div className={`${styles.basicInfoNameText} ${styles.bigText}`}>
{kbCardVO.name}
</div>
<div className={`${styles.basicInfoDescriptionText}`}>
{kbCardVO.description}
</div>
</div>
{/* Plugin Info */}
<div className="flex flex-row gap-2 items-center mt-1">
<svg
className="w-5 h-5 text-gray-500 dark:text-gray-400"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
>
<path d="M7 5C7 2.79086 8.79086 1 11 1C13.2091 1 15 2.79086 15 5H18C18.5523 5 19 5.44772 19 6V9C21.2091 9 23 10.7909 23 13C23 15.2091 21.2091 17 19 17V20C19 20.5523 18.5523 21 18 21H4C3.44772 21 3 20.5523 3 20V6C3 5.44772 3.44772 5 4 5H7ZM11 3C9.89543 3 9 3.89543 9 5C9 5.23554 9.0403 5.45952 9.11355 5.66675C9.22172 5.97282 9.17461 6.31235 8.98718 6.57739C8.79974 6.84243 8.49532 7 8.17071 7H5V19H17V15.8293C17 15.5047 17.1576 15.2003 17.4226 15.0128C17.6877 14.8254 18.0272 14.7783 18.3332 14.8865C18.5405 14.9597 18.7645 15 19 15C20.1046 15 21 14.1046 21 13C21 11.8954 20.1046 11 19 11C18.7645 11 18.5405 11.0403 18.3332 11.1135C18.0272 11.2217 17.6877 11.1746 17.4226 10.9872C17.1576 10.7997 17 10.4953 17 10.1707V7H13.8293C13.5047 7 13.2003 6.84243 13.0128 6.57739C12.8254 6.31235 12.7783 5.97282 12.8865 5.66675C12.9597 5.45952 13 5.23555 13 5C13 3.89543 12.1046 3 11 3Z"></path>
</svg>
<span className="text-sm font-medium text-gray-600 dark:text-gray-400">
{kbCardVO.pluginAuthor} / {kbCardVO.pluginName}
</span>
</div>
</div>
{/* Bottom section: Update Time */}
<div className="flex flex-row gap-2 items-center mt-auto">
<svg
className="w-5 h-5 text-gray-500 dark:text-gray-400"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
>
<path d="M12 22C6.47715 22 2 17.5228 2 12C2 6.47715 6.47715 2 12 2C17.5228 2 22 6.47715 22 12C22 17.5228 17.5228 22 12 22ZM12 20C16.4183 20 20 16.4183 20 12C20 7.58172 16.4183 4 12 4C7.58172 4 4 7.58172 4 12C4 16.4183 7.58172 20 12 20ZM13 12H17V14H11V7H13V12Z"></path>
</svg>
<span className="text-sm font-medium text-gray-600 dark:text-gray-400">
{t('knowledge.updateTime')}
{kbCardVO.lastUpdatedTimeAgo}
</span>
</div>
</div>
</div>
</div>
);
}

View File

@@ -1,39 +0,0 @@
export class ExternalKBCardVO {
id: string;
name: string;
description: string;
retrieverName: string;
retrieverConfig: Record<string, unknown>;
lastUpdatedTimeAgo: string;
pluginAuthor: string;
pluginName: string;
constructor({
id,
name,
description,
retrieverName,
retrieverConfig,
lastUpdatedTimeAgo,
pluginAuthor,
pluginName,
}: {
id: string;
name: string;
description: string;
retrieverName: string;
retrieverConfig: Record<string, unknown>;
lastUpdatedTimeAgo: string;
pluginAuthor: string;
pluginName: string;
}) {
this.id = id;
this.name = name;
this.description = description;
this.retrieverName = retrieverName;
this.retrieverConfig = retrieverConfig;
this.lastUpdatedTimeAgo = lastUpdatedTimeAgo;
this.pluginAuthor = pluginAuthor;
this.pluginName = pluginName;
}
}

View File

@@ -1,558 +0,0 @@
import { useEffect, useState } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import { toast } from 'sonner';
import { useTranslation } from 'react-i18next';
import { UUID } from 'uuidjs';
import {
DynamicFormItemConfig,
getDefaultValues,
parseDynamicFormItemType,
} from '@/app/home/components/dynamic-form/DynamicFormItemConfig';
import { IDynamicFormItemSchema } from '@/app/infra/entities/form/dynamic';
import DynamicFormComponent from '@/app/home/components/dynamic-form/DynamicFormComponent';
import { httpClient } from '@/app/infra/http/HttpClient';
import { ExternalKnowledgeBase } from '@/app/infra/entities/api';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogFooter,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@/components/ui/form';
import { Input } from '@/components/ui/input';
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import {
HoverCard,
HoverCardContent,
HoverCardTrigger,
} from '@/components/ui/hover-card';
import { extractI18nObject } from '@/i18n/I18nProvider';
import { I18nObject } from '@/app/infra/entities/common';
// Form schema
const getFormSchema = (t: (key: string) => string) =>
z.object({
name: z.string().min(1, { message: t('knowledge.nameRequired') }),
description: z.string().optional(),
plugin_author: z.string().min(1, { message: 'Please select a retriever' }),
plugin_name: z.string().min(1, { message: 'Please select a retriever' }),
retriever_name: z.string().min(1, { message: 'Please select a retriever' }),
retriever_config: z.record(z.string(), z.any()),
});
// Retriever information interface
interface RetrieverInfo {
plugin_author: string;
plugin_name: string;
retriever_name: string;
retriever_description: I18nObject;
manifest: {
manifest?: {
metadata?: {
label?: I18nObject;
description?: I18nObject;
};
spec?: {
config?: IDynamicFormItemSchema[];
};
};
};
}
interface ExternalKBFormProps {
initKBId?: string;
onFormSubmit: (value: z.infer<ReturnType<typeof getFormSchema>>) => void;
onKBDeleted: () => void;
onNewKBCreated: (kbId: string) => void;
}
export default function ExternalKBForm({
initKBId,
onFormSubmit,
onKBDeleted,
onNewKBCreated,
}: ExternalKBFormProps) {
const { t } = useTranslation();
const formSchema = getFormSchema(t);
// Form setup
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: {
name: '',
description: '',
plugin_author: '',
plugin_name: '',
retriever_name: '',
retriever_config: {},
},
});
// State management
const [showDeleteConfirmModal, setShowDeleteConfirmModal] = useState(false);
const [availableRetrievers, setAvailableRetrievers] = useState<
RetrieverInfo[]
>([]);
const [retrieverNameToConfigMap, setRetrieverNameToConfigMap] = useState(
new Map<string, IDynamicFormItemSchema[]>(),
);
const [showDynamicForm, setShowDynamicForm] = useState<boolean>(false);
const [dynamicFormConfigList, setDynamicFormConfigList] = useState<
IDynamicFormItemSchema[]
>([]);
// Initialize form when initKBId changes
useEffect(() => {
loadFormData();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [initKBId]);
/**
* Load form data: initialize retrievers list and load KB config if editing
*/
async function loadFormData() {
const configMap = await loadAvailableRetrievers();
if (initKBId) {
// Edit mode: load existing KB configuration
try {
const kbConfig = await loadKBConfig(initKBId);
// Set form values
form.setValue('name', kbConfig.name);
form.setValue('description', kbConfig.description || '');
form.setValue('plugin_author', kbConfig.plugin_author);
form.setValue('plugin_name', kbConfig.plugin_name);
form.setValue('retriever_name', kbConfig.retriever_name);
form.setValue('retriever_config', kbConfig.retriever_config);
// Load dynamic form for the selected retriever
const fullName = `${kbConfig.plugin_author}/${kbConfig.plugin_name}/${kbConfig.retriever_name}`;
loadDynamicFormConfig(fullName, configMap);
} catch (err) {
toast.error('Failed to load KB config: ' + (err as Error).message);
}
} else {
// Create mode: reset form
form.reset();
}
}
/**
* Load available retrievers from API and build config map
*/
async function loadAvailableRetrievers(): Promise<
Map<string, IDynamicFormItemSchema[]>
> {
const retrieversRes = await httpClient.listKnowledgeRetrievers();
setAvailableRetrievers((retrieversRes.retrievers || []) as RetrieverInfo[]);
// Build retriever name to config map
const configMap = new Map<string, IDynamicFormItemSchema[]>();
((retrieversRes.retrievers || []) as RetrieverInfo[]).forEach(
(retriever) => {
const fullName = `${retriever.plugin_author}/${retriever.plugin_name}/${retriever.retriever_name}`;
const configSchema = retriever.manifest?.manifest?.spec?.config || [];
configMap.set(
fullName,
configSchema.map(
(item) =>
new DynamicFormItemConfig({
default: item.default,
id: UUID.generate(),
label: item.label,
description: item.description,
name: item.name,
required: item.required,
type: parseDynamicFormItemType(item.type),
options: item.options,
}),
),
);
},
);
setRetrieverNameToConfigMap(configMap);
return configMap;
}
/**
* Load KB configuration from API
*/
async function loadKBConfig(
kbId: string,
): Promise<z.infer<typeof formSchema>> {
const res = await httpClient.getExternalKnowledgeBase(kbId);
const kb = res.base;
return {
name: kb.name,
description: kb.description,
plugin_author: kb.plugin_author,
plugin_name: kb.plugin_name,
retriever_name: kb.retriever_name,
retriever_config: kb.retriever_config || {},
};
}
/**
* Load dynamic form configuration for selected retriever
* @param fullRetrieverName - Full retriever name in format: plugin_author/plugin_name/retriever_name
* @param configMapOverride - Optional config map to use (for initial load)
*/
function loadDynamicFormConfig(
fullRetrieverName: string,
configMapOverride?: Map<string, IDynamicFormItemSchema[]>,
) {
if (!fullRetrieverName) {
setShowDynamicForm(false);
return;
}
// Use provided config map or fall back to state
const configMap = configMapOverride || retrieverNameToConfigMap;
const configList = configMap.get(fullRetrieverName);
if (configList && configList.length > 0) {
setDynamicFormConfigList(configList);
setShowDynamicForm(true);
// Only reset to default values when manually selecting (not initial load)
if (!configMapOverride) {
form.setValue('retriever_config', getDefaultValues(configList));
}
} else {
setShowDynamicForm(false);
if (!configMapOverride) {
form.setValue('retriever_config', {});
}
}
}
/**
* Handle retriever selection change
*/
function handleRetrieverSelect(fullRetrieverName: string) {
if (!fullRetrieverName) {
setShowDynamicForm(false);
return;
}
// Parse and update form fields
const parts = fullRetrieverName.split('/');
if (parts.length === 3) {
form.setValue('plugin_author', parts[0]);
form.setValue('plugin_name', parts[1]);
form.setValue('retriever_name', parts[2]);
}
// Load dynamic form configuration
loadDynamicFormConfig(fullRetrieverName);
}
/**
* Handle form submission (create or update)
*/
function handleFormSubmit() {
const formData: ExternalKnowledgeBase = {
name: form.getValues().name,
description: form.getValues().description || '',
plugin_author: form.getValues().plugin_author,
plugin_name: form.getValues().plugin_name,
retriever_name: form.getValues().retriever_name,
retriever_config: form.getValues().retriever_config,
};
if (initKBId) {
// Update existing KB
httpClient
.updateExternalKnowledgeBase(initKBId, { ...formData, uuid: initKBId })
.then(() => {
onFormSubmit(form.getValues());
toast.success(t('knowledge.updateExternalSuccess'));
})
.catch((err) => {
toast.error('Failed to update KB: ' + err.message);
});
} else {
// Create new KB
httpClient
.createExternalKnowledgeBase(formData)
.then((res) => {
toast.success(t('knowledge.createExternalSuccess'));
onNewKBCreated(res.uuid);
form.reset();
})
.catch((err) => {
toast.error('Failed to create KB: ' + err.message);
});
}
}
/**
* Handle KB deletion
*/
function handleDelete() {
if (!initKBId) return;
httpClient
.deleteExternalKnowledgeBase(initKBId)
.then(() => {
onKBDeleted();
toast.success(t('knowledge.deleteExternalSuccess'));
})
.catch((err) => {
toast.error('Failed to delete KB: ' + err.message);
});
}
/**
* Get retriever label with i18n support
*/
function getRetrieverLabel(fullName: string): string {
const retriever = availableRetrievers.find(
(r) =>
`${r.plugin_author}/${r.plugin_name}/${r.retriever_name}` === fullName,
);
return retriever?.manifest?.manifest?.metadata?.label
? extractI18nObject(retriever.manifest.manifest.metadata.label)
: fullName;
}
// Compute full retriever name for display
const currentRetrieverFullName =
form.watch('plugin_author') &&
form.watch('plugin_name') &&
form.watch('retriever_name')
? `${form.watch('plugin_author')}/${form.watch(
'plugin_name',
)}/${form.watch('retriever_name')}`
: '';
return (
<div>
{/* Delete Confirmation Dialog */}
<Dialog
open={showDeleteConfirmModal}
onOpenChange={setShowDeleteConfirmModal}
>
<DialogContent>
<DialogHeader>
<DialogTitle>{t('common.confirmDelete')}</DialogTitle>
</DialogHeader>
<DialogDescription>
{t('knowledge.deleteConfirmation')}
</DialogDescription>
<DialogFooter>
<Button
variant="outline"
onClick={() => setShowDeleteConfirmModal(false)}
>
{t('common.cancel')}
</Button>
<Button
variant="destructive"
onClick={() => {
handleDelete();
setShowDeleteConfirmModal(false);
}}
>
{t('common.confirmDelete')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Main Form */}
<Form {...form}>
<form
id="external-kb-form"
onSubmit={form.handleSubmit(handleFormSubmit)}
className="space-y-8"
>
<div className="space-y-4">
{/* KB Name */}
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>
{t('knowledge.kbName')}
<span className="text-red-500">*</span>
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{/* KB Description */}
<FormField
control={form.control}
name="description"
render={({ field }) => (
<FormItem>
<FormLabel>{t('knowledge.kbDescription')}</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{/* Retriever Selector */}
<FormField
control={form.control}
name="retriever_name"
render={() => (
<FormItem>
<FormLabel>
{t('knowledge.retriever')}
<span className="text-red-500">*</span>
</FormLabel>
<FormControl>
<Select
onValueChange={handleRetrieverSelect}
value={currentRetrieverFullName}
>
<SelectTrigger className="w-full bg-[#ffffff] dark:bg-[#2a2a2e]">
<SelectValue
placeholder={t('knowledge.selectRetriever')}
/>
</SelectTrigger>
<SelectContent className="fixed z-[1000]">
<SelectGroup>
{availableRetrievers.map((retriever) => {
const fullName = `${retriever.plugin_author}/${retriever.plugin_name}/${retriever.retriever_name}`;
const label = retriever.manifest?.manifest?.metadata
?.label
? extractI18nObject(
retriever.manifest.manifest.metadata.label,
)
: retriever.retriever_name;
const description = extractI18nObject(
retriever.retriever_description,
);
return (
<HoverCard
key={fullName}
openDelay={0}
closeDelay={0}
>
<HoverCardTrigger asChild>
<SelectItem value={fullName}>
{label}
</SelectItem>
</HoverCardTrigger>
<HoverCardContent
className="w-80 data-[state=open]:animate-none"
align="end"
side="right"
sideOffset={10}
>
<div className="space-y-2">
<div className="flex items-start gap-3">
<img
src={httpClient.getPluginIconURL(
retriever.plugin_author,
retriever.plugin_name,
)}
alt="plugin icon"
className="w-10 h-10 rounded-[8%] flex-shrink-0"
/>
<div className="flex flex-col gap-1 flex-1 min-w-0">
<h4 className="font-medium text-sm">
{label}
</h4>
<p className="text-xs text-muted-foreground">
{retriever.plugin_author} /{' '}
{retriever.plugin_name}
</p>
</div>
</div>
{description && (
<p className="text-sm text-muted-foreground">
{description}
</p>
)}
</div>
</HoverCardContent>
</HoverCard>
);
})}
</SelectGroup>
</SelectContent>
</Select>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{/* Selected Retriever Card */}
{currentRetrieverFullName && (
<div className="flex items-start gap-3 p-4 rounded-lg border">
<img
src={httpClient.getPluginIconURL(
form.watch('plugin_author'),
form.watch('plugin_name'),
)}
alt="plugin icon"
className="w-12 h-12 rounded-[8%] flex-shrink-0"
/>
<div className="flex flex-col gap-1">
<div className="font-medium">
{getRetrieverLabel(currentRetrieverFullName)}
</div>
<div className="text-sm text-gray-500">
{form.watch('plugin_author')} / {form.watch('plugin_name')}
</div>
</div>
</div>
)}
{/* Dynamic Retriever Configuration Form */}
{showDynamicForm && dynamicFormConfigList.length > 0 && (
<div className="space-y-4">
<div className="text-lg font-medium">
{t('knowledge.retrieverConfiguration')}
</div>
<DynamicFormComponent
itemConfigList={dynamicFormConfigList}
initialValues={form.watch('retriever_config')}
onSubmit={(values) => {
form.setValue('retriever_config', values);
}}
/>
</div>
)}
</div>
</form>
</Form>
</div>
);
}

View File

@@ -103,6 +103,8 @@ export default function KBForm({
};
const onSubmit = (data: z.infer<typeof formSchema>) => {
console.log('data', data);
if (initKbId) {
// update knowledge base
const updateKb: KnowledgeBase = {
@@ -114,6 +116,7 @@ export default function KBForm({
httpClient
.updateKnowledgeBase(initKbId, updateKb)
.then((res) => {
console.log('update knowledge base success', res);
onKbUpdated(res.uuid);
toast.success(t('knowledge.updateKnowledgeBaseSuccess'));
})
@@ -132,6 +135,7 @@ export default function KBForm({
httpClient
.createKnowledgeBase(newKb)
.then((res) => {
console.log('create knowledge base success', res);
onNewKbCreated(res.uuid);
})
.catch((err) => {
@@ -196,6 +200,7 @@ export default function KBForm({
disabled={!!initKbId}
onValueChange={(value) => {
field.onChange(value);
console.log('value', value);
}}
value={field.value}
>
@@ -230,7 +235,7 @@ export default function KBForm({
model.requester,
)}
alt="icon"
className="w-8 h-8 rounded-[8%]"
className="w-8 h-8 rounded-full"
/>
<h4 className="font-medium">
{model.name}

View File

@@ -1,35 +0,0 @@
'use client';
import React from 'react';
import { httpClient } from '@/app/infra/http/HttpClient';
import { RetrieveResult } from '@/app/infra/entities/api';
import KBRetrieveGeneric from './KBRetrieveGeneric';
interface ExternalKBRetrieveProps {
kbId: string;
}
/**
* External knowledge base retrieve component
* Uses the generic retrieve component with external KB API
*/
export default function ExternalKBRetrieve({ kbId }: ExternalKBRetrieveProps) {
const getResultTitle = (result: RetrieveResult): string => {
// For external KB, try to get document_name or use a generic title
return (
(result.metadata.document_name as string) ||
(result.metadata.source as string) ||
result.id
);
};
return (
<KBRetrieveGeneric
kbId={kbId}
retrieveFunction={httpClient.retrieveExternalKnowledgeBase.bind(
httpClient,
)}
getResultTitle={getResultTitle}
/>
);
}

View File

@@ -48,36 +48,11 @@ export default function KBRetrieve({ kbId }: KBRetrieveProps) {
}
};
const getFileName = (fileId?: string) => {
if (!fileId) return '';
const getFileName = (fileId: string) => {
const file = files.find((f) => f.uuid === fileId);
return file?.file_name || fileId;
};
/**
* Extract text content from the content array
* The content array may contain multiple items with type 'text'
*/
const extractTextFromContent = (result: RetrieveResult): string => {
// First try to get content from the new format
if (result.content && Array.isArray(result.content)) {
const textParts = result.content
.filter((item) => item.type === 'text' && item.text)
.map((item) => item.text);
if (textParts.length > 0) {
return textParts.join('\n\n');
}
}
// Fallback to metadata.text for backward compatibility
if (result.metadata?.text) {
return result.metadata.text as string;
}
return '';
};
return (
<div className="space-y-4">
<div className="flex gap-2">
@@ -112,7 +87,7 @@ export default function KBRetrieve({ kbId }: KBRetrieveProps) {
</CardHeader>
<CardContent>
<p className="text-sm whitespace-pre-wrap">
{extractTextFromContent(result)}
{result.metadata.text}
</p>
</CardContent>
</Card>

View File

@@ -1,124 +0,0 @@
'use client';
import React, { useState } from 'react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { useTranslation } from 'react-i18next';
import { RetrieveResult } from '@/app/infra/entities/api';
import { toast } from 'sonner';
interface KBRetrieveGenericProps {
kbId: string;
retrieveFunction: (
kbId: string,
query: string,
) => Promise<{ results: RetrieveResult[] }>;
getResultTitle?: (result: RetrieveResult) => string;
}
/**
* Generic knowledge base retrieve component
* Supports both builtin and external knowledge bases
*/
export default function KBRetrieveGeneric({
kbId,
retrieveFunction,
getResultTitle,
}: KBRetrieveGenericProps) {
const { t } = useTranslation();
const [query, setQuery] = useState('');
const [results, setResults] = useState<RetrieveResult[]>([]);
const [loading, setLoading] = useState(false);
const handleRetrieve = async () => {
if (!query.trim()) return;
setLoading(true);
try {
setResults([]);
const response = await retrieveFunction(kbId, query);
setResults(response.results);
} catch (error) {
console.error('Retrieve failed:', error);
toast.error(t('knowledge.retrieveError'));
} finally {
setLoading(false);
}
};
const getTitle = (result: RetrieveResult): string => {
if (getResultTitle) {
return getResultTitle(result);
}
// Default: use file_id or document_name from metadata
return (
(result.metadata.file_id as string) ||
(result.metadata.document_name as string) ||
result.id
);
};
/**
* Extract text content from the content array
* The content array may contain multiple items with type 'text'
*/
const extractTextFromContent = (result: RetrieveResult): string => {
// First try to get content from the new format
if (result.content && Array.isArray(result.content)) {
const textParts = result.content
.filter((item) => item.type === 'text' && item.text)
.map((item) => item.text);
if (textParts.length > 0) {
return textParts.join('\n\n');
}
}
return '';
};
return (
<div className="space-y-4">
<div className="flex gap-2">
<Input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder={t('knowledge.queryPlaceholder')}
onKeyPress={(e) => e.key === 'Enter' && handleRetrieve()}
/>
<Button onClick={handleRetrieve} disabled={loading || !query.trim()}>
{t('knowledge.query')}
</Button>
</div>
<div className="space-y-3">
{results.length === 0 && !loading && (
<p className="text-muted-foreground">{t('knowledge.noResults')}</p>
)}
{loading ? (
<p className="text-muted-foreground">{t('common.loading')}</p>
) : (
results.map((result) => (
<Card key={result.id} className="w-full">
<CardHeader className="pb-3">
<CardTitle className="text-sm font-medium flex justify-between items-center">
<span>{getTitle(result)}</span>
<span className="text-xs text-muted-foreground">
{t('knowledge.distance')}: {result.distance.toFixed(4)}
</span>
</CardTitle>
</CardHeader>
<CardContent>
<p className="text-sm whitespace-pre-wrap">
{extractTextFromContent(result)}
</p>
</CardContent>
</Card>
))
)}
</div>
</div>
);
}

View File

@@ -5,7 +5,6 @@
.knowledgeListContainer {
width: 100%;
margin-top: 2rem;
padding-left: 0.8rem;
padding-right: 0.8rem;
display: grid;

View File

@@ -5,48 +5,23 @@ import styles from './knowledgeBase.module.css';
import { useTranslation } from 'react-i18next';
import { useEffect, useState } from 'react';
import { KnowledgeBaseVO } from '@/app/home/knowledge/components/kb-card/KBCardVO';
import { ExternalKBCardVO } from '@/app/home/knowledge/components/external-kb-card/ExternalKBCardVO';
import KBCard from '@/app/home/knowledge/components/kb-card/KBCard';
import ExternalKBCard from '@/app/home/knowledge/components/external-kb-card/ExternalKBCard';
import KBDetailDialog from '@/app/home/knowledge/KBDetailDialog';
import { httpClient } from '@/app/infra/http/HttpClient';
import {
KnowledgeBase,
ExternalKnowledgeBase,
ApiRespPluginSystemStatus,
} from '@/app/infra/entities/api';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { KnowledgeBase } from '@/app/infra/entities/api';
export default function KnowledgePage() {
const { t } = useTranslation();
const [activeTab, setActiveTab] = useState('builtin');
const [knowledgeBaseList, setKnowledgeBaseList] = useState<KnowledgeBaseVO[]>(
[],
);
const [externalKBList, setExternalKBList] = useState<ExternalKBCardVO[]>([]);
const [selectedKbId, setSelectedKbId] = useState<string>('');
const [selectedKbType, setSelectedKbType] = useState<'builtin' | 'external'>(
'builtin',
);
const [detailDialogOpen, setDetailDialogOpen] = useState(false);
const [pluginSystemStatus, setPluginSystemStatus] =
useState<ApiRespPluginSystemStatus | null>(null);
useEffect(() => {
getKnowledgeBaseList();
getExternalKBList();
fetchPluginSystemStatus();
}, []);
async function fetchPluginSystemStatus() {
try {
const status = await httpClient.getPluginSystemStatus();
setPluginSystemStatus(status);
} catch (error) {
console.error('Failed to fetch plugin system status:', error);
}
}
async function getKnowledgeBaseList() {
const resp = await httpClient.getKnowledgeBases();
setKnowledgeBaseList(
@@ -78,64 +53,13 @@ export default function KnowledgePage() {
);
}
async function getExternalKBList() {
try {
const resp = await httpClient.getExternalKnowledgeBases();
setExternalKBList(
resp.bases.map((kb: ExternalKnowledgeBase) => {
const currentTime = new Date();
const lastUpdatedTimeAgo = Math.floor(
(currentTime.getTime() -
new Date(kb.created_at ?? currentTime.getTime()).getTime()) /
1000 /
60 /
60 /
24,
);
const lastUpdatedTimeAgoText =
lastUpdatedTimeAgo > 0
? ` ${lastUpdatedTimeAgo} ${t('knowledge.daysAgo')}`
: t('knowledge.today');
return new ExternalKBCardVO({
id: kb.uuid || '',
name: kb.name,
description: kb.description,
retrieverName: `${kb.plugin_author}/${kb.plugin_name}/${kb.retriever_name}`,
retrieverConfig: kb.retriever_config || {},
lastUpdatedTimeAgo: lastUpdatedTimeAgoText,
pluginAuthor: kb.plugin_author,
pluginName: kb.plugin_name,
});
}),
);
} catch (error) {
console.error('Failed to load external knowledge bases:', error);
}
}
const handleKBCardClick = (kbId: string) => {
setSelectedKbId(kbId);
setSelectedKbType('builtin');
setDetailDialogOpen(true);
};
const handleCreateKBClick = () => {
setSelectedKbId('');
setSelectedKbType('builtin');
setDetailDialogOpen(true);
};
const handleExternalKBCardClick = (kbId: string) => {
setSelectedKbId(kbId);
setSelectedKbType('external');
setDetailDialogOpen(true);
};
const handleCreateExternalKB = () => {
setSelectedKbId('');
setSelectedKbType('external');
setDetailDialogOpen(true);
};
@@ -144,30 +68,18 @@ export default function KnowledgePage() {
};
const handleKbDeleted = () => {
if (selectedKbType === 'builtin') {
getKnowledgeBaseList();
} else {
getExternalKBList();
}
getKnowledgeBaseList();
setDetailDialogOpen(false);
};
const handleNewKbCreated = (newKbId: string) => {
if (selectedKbType === 'builtin') {
getKnowledgeBaseList();
} else {
getExternalKBList();
}
getKnowledgeBaseList();
setSelectedKbId(newKbId);
setDetailDialogOpen(true);
};
const handleKbUpdated = () => {
if (selectedKbType === 'builtin') {
getKnowledgeBaseList();
} else {
getExternalKBList();
}
getKnowledgeBaseList();
};
return (
@@ -176,73 +88,28 @@ export default function KnowledgePage() {
open={detailDialogOpen}
onOpenChange={setDetailDialogOpen}
kbId={selectedKbId || undefined}
kbType={selectedKbType}
onFormCancel={handleFormCancel}
onKbDeleted={handleKbDeleted}
onNewKbCreated={handleNewKbCreated}
onKbUpdated={handleKbUpdated}
/>
<Tabs value={activeTab} onValueChange={setActiveTab} className="w-full">
<div className="flex flex-row justify-between items-center px-[0.8rem]">
<TabsList className="shadow-md py-5 bg-[#f0f0f0] dark:bg-[#2a2a2e]">
<TabsTrigger value="builtin" className="px-6 py-4 cursor-pointer">
{t('knowledge.builtIn')}
</TabsTrigger>
{/* Only show external tab if plugin system is enabled and connected */}
{pluginSystemStatus?.is_enable &&
pluginSystemStatus?.is_connected && (
<TabsTrigger
value="external"
className="px-6 py-4 cursor-pointer"
>
{t('knowledge.external')}
</TabsTrigger>
)}
</TabsList>
</div>
<div className={styles.knowledgeListContainer}>
<CreateCardComponent
width={'100%'}
height={'10rem'}
plusSize={'90px'}
onClick={handleCreateKBClick}
/>
<TabsContent value="builtin">
<div className={styles.knowledgeListContainer}>
<CreateCardComponent
width={'100%'}
height={'10rem'}
plusSize={'90px'}
onClick={handleCreateKBClick}
/>
{knowledgeBaseList.map((kb) => {
return (
<div key={kb.id} onClick={() => handleKBCardClick(kb.id)}>
<KBCard kbCardVO={kb} />
</div>
);
})}
</div>
</TabsContent>
<TabsContent value="external">
<div className={styles.knowledgeListContainer}>
<CreateCardComponent
width={'100%'}
height={'10rem'}
plusSize={'90px'}
onClick={handleCreateExternalKB}
/>
{externalKBList.map((kb) => {
return (
<div
key={kb.id}
onClick={() => handleExternalKBCardClick(kb.id)}
>
<ExternalKBCard kbCardVO={kb} />
</div>
);
})}
</div>
</TabsContent>
</Tabs>
{knowledgeBaseList.map((kb) => {
return (
<div key={kb.id} onClick={() => handleKBCardClick(kb.id)}>
<KBCard kbCardVO={kb} />
</div>
);
})}
</div>
</div>
);
}

View File

@@ -326,7 +326,8 @@ export default function EmbeddingForm({
api_keys: apiKey ? [apiKey] : [],
extra_args: extraArgsObj,
})
.then(() => {
.then((res) => {
console.log(res);
toast.success(t('models.testSuccess'));
})
.catch(() => {

View File

@@ -341,7 +341,8 @@ export default function LLMForm({
abilities: form.getValues('abilities'),
extra_args: extraArgsObj,
})
.then(() => {
.then((res) => {
console.log(res);
toast.success(t('models.testSuccess'));
})
.catch(() => {

View File

@@ -54,6 +54,7 @@ export default function LLMConfigPage() {
.getProviderLLMModels()
.then((resp) => {
const llmModelList: LLMCardVO[] = resp.models.map((model: LLMModel) => {
console.log('model', model);
return new LLMCardVO({
id: model.uuid,
iconURL: httpClient.getProviderRequesterIconURL(model.requester),
@@ -65,6 +66,7 @@ export default function LLMConfigPage() {
abilities: model.abilities || [],
});
});
console.log('get llmModelList', llmModelList);
setCardList(llmModelList);
})
.catch((err) => {
@@ -76,6 +78,7 @@ export default function LLMConfigPage() {
function selectLLM(cardVO: LLMCardVO) {
setIsEditForm(true);
setNowSelectedLLM(cardVO);
console.log('set now vo', cardVO);
setModalOpen(true);
}
function handleCreateModelClick() {

View File

@@ -49,7 +49,6 @@ export default function PipelineDialog({
propPipelineId,
);
const [currentMode, setCurrentMode] = useState<DialogMode>('config');
const [isWebSocketConnected, setIsWebSocketConnected] = useState(false);
useEffect(() => {
setPipelineId(propPipelineId);
@@ -185,29 +184,10 @@ export default function PipelineDialog({
</Sidebar>
<main className="flex flex-1 flex-col h-full min-h-0">
<DialogHeader
className="px-6 pt-6 pb-4 shrink-0 flex flex-row items-center justify-start"
className="px-6 pt-6 pb-4 shrink-0"
style={{ height: '4rem' }}
>
<DialogTitle>{getDialogTitle()}</DialogTitle>
{currentMode === 'debug' && (
<div className="flex items-center gap-2 ml-2">
<div
className={`w-2.5 h-2.5 rounded-full ${
isWebSocketConnected ? 'bg-green-500' : 'bg-red-500'
}`}
title={
isWebSocketConnected
? t('pipelines.debugDialog.connected')
: t('pipelines.debugDialog.disconnected')
}
/>
<span className="text-sm text-gray-600 dark:text-gray-400">
{isWebSocketConnected
? t('pipelines.debugDialog.connected')
: t('pipelines.debugDialog.disconnected')}
</span>
</div>
)}
</DialogHeader>
<div
className="flex-1 overflow-y-auto px-6 pb-4 w-full"
@@ -237,7 +217,6 @@ export default function PipelineDialog({
open={true}
pipelineId={pipelineId}
isEmbedded={true}
onConnectionStatusChange={setIsWebSocketConnected}
/>
)}
</div>

View File

@@ -4,35 +4,30 @@ import { httpClient } from '@/app/infra/http/HttpClient';
import { DialogContent } from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { ScrollArea } from '@/components/ui/scroll-area';
import { cn } from '@/lib/utils';
import {
Message,
MessageChainComponent,
Image,
Plain,
At,
Quote,
Voice,
Source,
} from '@/app/infra/entities/message';
import { Message } from '@/app/infra/entities/message';
import { toast } from 'sonner';
import AtBadge from './AtBadge';
import { WebSocketClient } from '@/app/infra/websocket/WebSocketClient';
import ImagePreviewDialog from './ImagePreviewDialog';
import { Switch } from '@/components/ui/switch';
interface MessageComponent {
type: 'At' | 'Plain';
target?: string;
text?: string;
}
interface DebugDialogProps {
open: boolean;
pipelineId: string;
isEmbedded?: boolean;
onConnectionStatusChange?: (isConnected: boolean) => void;
}
export default function DebugDialog({
open,
pipelineId,
isEmbedded = false,
onConnectionStatusChange,
}: DebugDialogProps) {
const { t } = useTranslation();
const [selectedPipelineId, setSelectedPipelineId] = useState(pipelineId);
@@ -42,21 +37,10 @@ export default function DebugDialog({
const [showAtPopover, setShowAtPopover] = useState(false);
const [hasAt, setHasAt] = useState(false);
const [isHovering, setIsHovering] = useState(false);
const [isConnected, setIsConnected] = useState(false);
const [selectedImages, setSelectedImages] = useState<
Array<{ file: File; preview: string; fileKey?: string }>
>([]);
const [isUploading, setIsUploading] = useState(false);
const [previewImageUrl, setPreviewImageUrl] = useState<string>('');
const [showImagePreview, setShowImagePreview] = useState(false);
const [quotedMessage, setQuotedMessage] = useState<Message | null>(null);
const [hoveredMessageId, setHoveredMessageId] = useState<number | null>(null);
const [isStreaming, setIsStreaming] = useState(true);
const messagesEndRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
const popoverRef = useRef<HTMLDivElement>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const wsClientRef = useRef<WebSocketClient | null>(null);
const isInitializingRef = useRef<boolean>(false);
const scrollToBottom = useCallback(() => {
// 使用setTimeout确保在DOM更新后执行滚动
@@ -76,7 +60,7 @@ export default function DebugDialog({
const loadMessages = useCallback(
async (pipelineId: string) => {
try {
const response = await httpClient.getWebSocketHistoryMessages(
const response = await httpClient.getWebChatHistoryMessages(
pipelineId,
sessionType,
);
@@ -87,123 +71,23 @@ export default function DebugDialog({
},
[sessionType],
);
// 初始化WebSocket连接
const initWebSocket = useCallback(
async (pipelineId: string) => {
// 防止重复初始化
if (isInitializingRef.current) {
return;
}
try {
isInitializingRef.current = true;
// 断开旧连接
if (wsClientRef.current) {
wsClientRef.current.disconnect();
wsClientRef.current = null;
}
// 创建新连接
const wsClient = new WebSocketClient(pipelineId, sessionType);
wsClient
.onConnected(() => {
setIsConnected(true);
isInitializingRef.current = false;
})
.onMessage((wsMessage) => {
// 将 WebSocketMessage 转换为 Message 类型
const message: Message = {
...wsMessage,
message_chain: wsMessage.message_chain as MessageChainComponent[],
};
setMessages((prevMessages) => {
// 查找是否已存在相同ID的消息
const existingIndex = prevMessages.findIndex(
(m) => m.id === message.id,
);
if (existingIndex >= 0) {
// 更新已存在的消息(流式输出)
const newMessages = [...prevMessages];
newMessages[existingIndex] = message;
return newMessages;
} else {
// 添加新消息
return [...prevMessages, message];
}
});
})
.onError((error) => {
console.error('WebSocket错误:', error);
setIsConnected(false);
isInitializingRef.current = false;
toast.error(t('pipelines.debugDialog.connectionError'));
})
.onClose(() => {
setIsConnected(false);
isInitializingRef.current = false;
})
.onBroadcast((message) => {
toast.info(message);
});
await wsClient.connect();
wsClientRef.current = wsClient;
} catch (error) {
console.error('WebSocket连接失败:', error);
setIsConnected(false);
isInitializingRef.current = false;
toast.error(t('pipelines.debugDialog.connectionFailed'));
}
},
[sessionType, t],
);
// 在useEffect中监听messages变化时滚动
useEffect(() => {
scrollToBottom();
}, [messages, scrollToBottom]);
// 监听 open 和 pipelineId 变化,进入时连接,离开时断开
useEffect(() => {
if (open) {
setSelectedPipelineId(pipelineId);
} else {
// 关闭对话框时立即断开WebSocket
if (wsClientRef.current) {
wsClientRef.current.disconnect();
wsClientRef.current = null;
setIsConnected(false);
isInitializingRef.current = false;
}
loadMessages(pipelineId);
}
return () => {
// 组件卸载时断开WebSocket
if (wsClientRef.current) {
wsClientRef.current.disconnect();
wsClientRef.current = null;
isInitializingRef.current = false;
}
};
}, [open, pipelineId]);
// 监听 sessionType 和 selectedPipelineId 变化,重新加载消息和连接
useEffect(() => {
if (open) {
loadMessages(selectedPipelineId);
initWebSocket(selectedPipelineId);
}
}, [sessionType, selectedPipelineId, open, loadMessages, initWebSocket]);
// 通知父组件连接状态变化
useEffect(() => {
onConnectionStatusChange?.(isConnected);
}, [isConnected, onConnectionStatusChange]);
}, [sessionType, selectedPipelineId, open, loadMessages]);
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
@@ -263,69 +147,12 @@ export default function DebugDialog({
}
};
const handleImageSelect = async (e: React.ChangeEvent<HTMLInputElement>) => {
const files = e.target.files;
if (!files || files.length === 0) return;
const newImages: Array<{ file: File; preview: string }> = [];
for (let i = 0; i < files.length; i++) {
const file = files[i];
if (file.type.startsWith('image/')) {
const preview = URL.createObjectURL(file);
newImages.push({ file, preview });
}
}
setSelectedImages((prev) => [...prev, ...newImages]);
};
const handleRemoveImage = (index: number) => {
setSelectedImages((prev) => {
const newImages = [...prev];
URL.revokeObjectURL(newImages[index].preview);
newImages.splice(index, 1);
return newImages;
});
};
const sendMessage = async () => {
if (
!inputValue.trim() &&
!hasAt &&
selectedImages.length === 0 &&
!quotedMessage
)
return;
if (!isConnected || !wsClientRef.current) {
toast.error(t('pipelines.debugDialog.notConnected'));
return;
}
if (!inputValue.trim() && !hasAt) return;
try {
setIsUploading(true);
const messageChain = [];
// 添加引用消息(如果有)
if (quotedMessage) {
// 获取被引用消息的Source组件以获取message_id
const sourceComponent = quotedMessage.message_chain.find(
(c) => c.type === 'Source',
) as Source | undefined;
const messageId = sourceComponent
? sourceComponent.id
: quotedMessage.id;
messageChain.push({
type: 'Quote',
id: messageId,
origin: quotedMessage.message_chain.filter(
(c) => c.type !== 'Source',
),
});
}
let text_content = inputValue.trim();
if (hasAt) {
text_content = ' ' + text_content;
@@ -334,233 +161,142 @@ export default function DebugDialog({
if (hasAt) {
messageChain.push({
type: 'At',
target: 'websocketbot',
display: 'websocketbot',
target: 'webchatbot',
});
}
messageChain.push({
type: 'Plain',
text: text_content,
});
// 添加文本
if (text_content) {
messageChain.push({
type: 'Plain',
text: text_content,
});
if (hasAt) {
// for showing
text_content = '@webchatbot' + text_content;
}
// 上传图片并添加到消息链
for (const image of selectedImages) {
const userMessage: Message = {
id: -1,
role: 'user',
content: text_content,
timestamp: new Date().toISOString(),
message_chain: messageChain,
};
// 根据isStreaming状态决定使用哪种传输方式
if (isStreaming) {
// streaming
// 创建初始bot消息
const placeholderRandomId = Math.floor(Math.random() * 1000000);
const botMessagePlaceholder: Message = {
id: placeholderRandomId,
role: 'assistant',
content: 'Generating...',
timestamp: new Date().toISOString(),
message_chain: [{ type: 'Plain', text: 'Generating...' }],
};
// 添加用户消息和初始bot消息到状态
setMessages((prevMessages) => [
...prevMessages,
userMessage,
botMessagePlaceholder,
]);
setInputValue('');
setHasAt(false);
try {
const result = await httpClient.uploadWebSocketImage(
await httpClient.sendStreamingWebChatMessage(
sessionType,
messageChain,
selectedPipelineId,
image.file,
(data) => {
// 处理流式响应数据
console.log('data', data);
if (data.message) {
// 更新完整内容
setMessages((prevMessages) => {
const updatedMessages = [...prevMessages];
const botMessageIndex = updatedMessages.findIndex(
(message) => message.id === placeholderRandomId,
);
if (botMessageIndex !== -1) {
updatedMessages[botMessageIndex] = {
...updatedMessages[botMessageIndex],
content: data.message.content,
message_chain: [
{ type: 'Plain', text: data.message.content },
],
};
}
return updatedMessages;
});
}
},
() => {},
(error) => {
// 处理错误
console.error('Streaming error:', error);
if (sessionType === 'person') {
toast.error(t('pipelines.debugDialog.sendFailed'));
}
},
);
messageChain.push({
type: 'Image',
path: result.file_key,
});
} catch (error) {
console.error('图片上传失败:', error);
toast.error(t('pipelines.debugDialog.imageUploadFailed'));
console.error('Failed to send streaming message:', error);
if (sessionType === 'person') {
toast.error(t('pipelines.debugDialog.sendFailed'));
}
}
} else {
// non-streaming
setMessages((prevMessages) => [...prevMessages, userMessage]);
setInputValue('');
setHasAt(false);
const response = await httpClient.sendWebChatMessage(
sessionType,
messageChain,
selectedPipelineId,
180000,
);
setMessages((prevMessages) => [...prevMessages, response.message]);
}
// 清空输入框、图片和引用消息
setInputValue('');
setHasAt(false);
setQuotedMessage(null);
selectedImages.forEach((img) => URL.revokeObjectURL(img.preview));
setSelectedImages([]);
// 通过WebSocket发送消息
// 不在本地添加消息等待后端广播回来带有正确的ID
wsClientRef.current.sendMessage(messageChain);
} catch (error) {
} catch (
// eslint-disable-next-line @typescript-eslint/no-explicit-any
error: any
) {
console.log(error, 'type of error', typeof error);
console.error('Failed to send message:', error);
toast.error(t('pipelines.debugDialog.sendFailed'));
if (!error.message.includes('timeout') && sessionType === 'person') {
toast.error(t('pipelines.debugDialog.sendFailed'));
}
} finally {
setIsUploading(false);
inputRef.current?.focus();
}
};
const renderMessageComponent = (
component: MessageChainComponent,
index: number,
) => {
switch (component.type) {
case 'Plain':
return <span key={index}>{(component as Plain).text}</span>;
case 'At': {
const atComponent = component as At;
// 优先使用 display如果没有则使用 target
const displayName =
atComponent.display || atComponent.target?.toString() || '';
return (
<span key={index} className="inline-flex align-middle mx-1">
<AtBadge targetName={displayName} readonly={true} />
</span>
);
}
case 'AtAll':
return (
<span key={index} className="inline-flex align-middle mx-1">
<AtBadge targetName="全体成员" readonly={true} />
</span>
);
case 'Image': {
const img = component as Image;
const imageUrl = img.url || (img.base64 ? img.base64 : '');
if (!imageUrl) return null;
return (
<div key={index} className="my-2">
<img
src={imageUrl}
alt="Image"
className="max-w-full max-h-96 rounded-lg cursor-pointer hover:opacity-90 transition-opacity"
onClick={() => {
setPreviewImageUrl(imageUrl);
setShowImagePreview(true);
}}
/>
</div>
);
}
case 'File': {
const file = component as MessageChainComponent & { name?: string };
return (
<div key={index} className="my-2 flex items-center gap-2 text-sm">
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
<path d="M8 4a3 3 0 00-3 3v4a5 5 0 0010 0V7a1 1 0 112 0v4a7 7 0 11-14 0V7a5 5 0 0110 0v4a3 3 0 11-6 0V7a1 1 0 012 0v4a1 1 0 102 0V7a3 3 0 00-3-3z" />
</svg>
<span>[] {file.name || 'Unknown'}</span>
</div>
);
}
case 'Voice': {
const voice = component as Voice;
const voiceUrl = voice.url || (voice.base64 ? voice.base64 : '');
if (!voiceUrl) {
return <span key={index}>[]</span>;
}
return (
<div key={index} className="my-2 flex items-center gap-2">
<div className="flex items-center gap-2 px-3 py-2 bg-gray-100 dark:bg-gray-800 rounded-lg">
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
<path d="M18 3a1 1 0 00-1.196-.98l-10 2A1 1 0 006 5v9.114A4.369 4.369 0 005 14c-1.657 0-3 .895-3 2s1.343 2 3 2 3-.895 3-2V7.82l8-1.6v5.894A4.37 4.37 0 0015 12c-1.657 0-3 .895-3 2s1.343 2 3 2 3-.895 3-2V3z" />
</svg>
<audio
controls
src={voiceUrl}
className="h-8"
style={{ maxWidth: '200px' }}
>
Your browser does not support the audio element.
</audio>
{voice.length && voice.length > 0 && (
<span className="text-xs text-gray-500 dark:text-gray-400">
{voice.length}s
</span>
)}
</div>
</div>
);
}
case 'Quote': {
const quote = component as Quote;
return (
<div
key={index}
className="mb-2 pl-3 border-l-2 border-gray-400 dark:border-gray-500"
>
<div className="text-sm opacity-75">
{quote.origin?.map((comp, idx) =>
renderMessageComponent(comp as MessageChainComponent, idx),
)}
</div>
</div>
);
}
case 'Source':
// Source 不显示
return null;
default:
return <span key={index}>[{component.type}]</span>;
}
};
const getMessageTimestamp = (message: Message): number => {
// 首先尝试从message_chain中的Source组件获取时间戳
const sourceComponent = message.message_chain.find(
(c) => c.type === 'Source',
) as Source | undefined;
if (sourceComponent && sourceComponent.timestamp) {
return sourceComponent.timestamp;
}
// 如果没有Source组件使用message.timestamp
// 假设timestamp是ISO字符串转换为Unix时间戳
if (message.timestamp) {
return Math.floor(new Date(message.timestamp).getTime() / 1000);
}
return 0;
};
const formatTimestamp = (timestamp: number): string => {
if (!timestamp) return '';
const date = new Date(timestamp * 1000);
const now = new Date();
const hours = date.getHours().toString().padStart(2, '0');
const minutes = date.getMinutes().toString().padStart(2, '0');
// 判断是否是今天
const isToday = now.toDateString() === date.toDateString();
if (isToday) {
return `${hours}:${minutes}`;
}
// 判断是否是昨天
const yesterday = new Date(now);
yesterday.setDate(yesterday.getDate() - 1);
const isYesterday = yesterday.toDateString() === date.toDateString();
if (isYesterday) {
return `${t('bots.yesterday')} ${hours}:${minutes}`;
}
// 判断是否是今年
const isThisYear = now.getFullYear() === date.getFullYear();
if (isThisYear) {
const month = date.getMonth() + 1;
const day = date.getDate();
return t('bots.dateFormat', { month, day });
}
// 更早的日期
return t('bots.earlier');
};
const renderMessageContent = (message: Message) => {
return (
<div className="text-base leading-relaxed align-middle whitespace-pre-wrap">
{message.message_chain.map((component, index) =>
renderMessageComponent(component, index),
<span className="text-base leading-relaxed align-middle whitespace-pre-wrap">
{(message.message_chain as MessageComponent[]).map(
(component, index) => {
if (component.type === 'At') {
return (
<AtBadge
key={index}
targetName={component.target || ''}
readonly={true}
/>
);
} else if (component.type === 'Plain') {
return <span key={index}>{component.text}</span>;
}
return null;
},
)}
</div>
</span>
);
};
@@ -605,10 +341,11 @@ export default function DebugDialog({
<path d="M2 22C2 17.5817 5.58172 14 10 14C14.4183 14 18 17.5817 18 22H16C16 18.6863 13.3137 16 10 16C6.68629 16 4 18.6863 4 22H2ZM10 13C6.685 13 4 10.315 4 7C4 3.685 6.685 1 10 1C13.315 1 16 3.685 16 7C16 10.315 13.315 13 10 13ZM10 11C12.21 11 14 9.21 14 7C14 4.79 12.21 3 10 3C7.79 3 6 4.79 6 7C6 9.21 7.79 11 10 11ZM18.2837 14.7028C21.0644 15.9561 23 18.752 23 22H21C21 19.564 19.5483 17.4671 17.4628 16.5271L18.2837 14.7028ZM17.5962 3.41321C19.5944 4.23703 21 6.20361 21 8.5C21 11.3702 18.8042 13.7252 16 13.9776V11.9646C17.6967 11.7222 19 10.264 19 8.5C19 7.11935 18.2016 5.92603 17.041 5.35635L17.5962 3.41321Z"></path>
</svg>
</Button>
<div className="flex-1" />
</div>
<div className="flex-1 flex flex-col w-[10rem] h-full min-h-0">
<ScrollArea className="flex-1 p-6 overflow-y-auto min-h-0 bg-white dark:bg-black scroll-area">
<ScrollArea className="flex-1 p-6 overflow-y-auto min-h-0 bg-white dark:bg-black">
<div className="space-y-6">
{messages.length === 0 ? (
<div className="text-center text-muted-foreground py-12 text-lg">
@@ -619,68 +356,31 @@ export default function DebugDialog({
<div
key={message.id + message.timestamp}
className={cn(
'flex group',
'flex',
message.role === 'user' ? 'justify-end' : 'justify-start',
)}
onMouseEnter={() => setHoveredMessageId(message.id)}
onMouseLeave={() => setHoveredMessageId(null)}
>
<div
className={cn(
'relative flex items-end gap-2',
message.role === 'user' ? 'flex-row-reverse' : 'flex-row',
'max-w-md px-5 py-3 rounded-2xl',
message.role === 'user'
? 'bg-[#2288ee] text-white rounded-br-none'
: 'bg-gray-100 dark:bg-gray-800 text-gray-900 dark:text-gray-100 rounded-bl-none',
)}
>
{renderMessageContent(message)}
<div
className={cn(
'max-w-md px-5 py-3 rounded-2xl',
'text-xs mt-2',
message.role === 'user'
? 'bg-[#2288ee] text-white rounded-br-none'
: 'bg-gray-100 dark:bg-gray-800 text-gray-900 dark:text-gray-100 rounded-bl-none',
? 'text-white/70'
: 'text-gray-500 dark:text-gray-400',
)}
>
{renderMessageContent(message)}
<div
className={cn(
'text-xs mt-2 flex items-center justify-between gap-2',
message.role === 'user'
? 'text-white/70'
: 'text-gray-500 dark:text-gray-400',
)}
>
<span>
{message.role === 'user'
? t('pipelines.debugDialog.userMessage')
: t('pipelines.debugDialog.botMessage')}
</span>
<span className="text-[10px]">
{formatTimestamp(getMessageTimestamp(message))}
</span>
</div>
{message.role === 'user'
? t('pipelines.debugDialog.userMessage')
: t('pipelines.debugDialog.botMessage')}
</div>
{hoveredMessageId === message.id && (
<Button
variant="ghost"
size="sm"
className="h-6 px-2 text-xs opacity-0 group-hover:opacity-100 transition-opacity bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 whitespace-nowrap"
onClick={() => setQuotedMessage(message)}
>
<svg
className="w-3 h-3 mr-1"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M3 10h10a8 8 0 018 8v2M3 10l6 6m-6-6l6-6"
/>
</svg>
{t('pipelines.debugDialog.reply')}
</Button>
)}
</div>
</div>
))
@@ -689,93 +389,16 @@ export default function DebugDialog({
</div>
</ScrollArea>
{/* 引用消息预览区域 */}
{quotedMessage && (
<div className="px-4 py-2 bg-gray-50 dark:bg-gray-900 border-t border-gray-200 dark:border-gray-700">
<div className="flex items-start gap-2">
<div className="flex-1 pl-3 border-l-2 border-[#2288ee]">
<div className="text-xs text-gray-500 dark:text-gray-400 mb-1">
{t('pipelines.debugDialog.replyTo')}{' '}
{quotedMessage.role === 'user'
? t('pipelines.debugDialog.userMessage')
: t('pipelines.debugDialog.botMessage')}
</div>
<div className="text-sm text-gray-700 dark:text-gray-300 line-clamp-2">
{quotedMessage.message_chain
.filter((c) => c.type === 'Plain')
.map((c) => (c as Plain).text)
.join('')}
</div>
</div>
<button
onClick={() => setQuotedMessage(null)}
className="w-5 h-5 text-gray-500 hover:text-gray-700 dark:hover:text-gray-300"
>
×
</button>
</div>
</div>
)}
{/* 图片预览区域 */}
{selectedImages.length > 0 && (
<div className="px-4 pb-2 bg-white dark:bg-black">
<div className="flex gap-2 flex-wrap">
{selectedImages.map((image, index) => (
<div key={index} className="relative group">
<img
src={image.preview}
alt={`preview-${index}`}
className="w-20 h-20 object-cover rounded-lg border border-gray-300 dark:border-gray-600"
/>
<button
onClick={() => handleRemoveImage(index)}
className="absolute -top-2 -right-2 w-5 h-5 bg-red-500 text-white rounded-full flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity"
>
×
</button>
</div>
))}
</div>
</div>
)}
<div className="p-4 pb-0 bg-white dark:bg-black flex gap-2">
<div className="flex gap-2">
<input
ref={fileInputRef}
type="file"
accept="image/*"
multiple
onChange={handleImageSelect}
className="hidden"
/>
<Button
variant="ghost"
size="icon"
onClick={() => fileInputRef.current?.click()}
disabled={!isConnected || isUploading}
className="w-10 h-10 rounded-md hover:bg-gray-100 dark:hover:bg-gray-700"
title="上传图片"
>
<svg
className="w-5 h-5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"
/>
</svg>
</Button>
<div className="flex items-center gap-2">
<span className="text-sm text-gray-600">
{t('pipelines.debugDialog.streaming')}
</span>
<Switch checked={isStreaming} onCheckedChange={setIsStreaming} />
</div>
<div className="flex-1 flex items-center gap-2">
{hasAt && (
<AtBadge targetName="websocketbot" onRemove={handleAtRemove} />
<AtBadge targetName="webchatbot" onRemove={handleAtRemove} />
)}
<div className="relative flex-1">
<Input
@@ -789,8 +412,7 @@ export default function DebugDialog({
? t('pipelines.debugDialog.privateChat')
: t('pipelines.debugDialog.groupChat'),
})}
disabled={!isConnected || isUploading}
className="flex-1 rounded-md px-3 py-2 border border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100 focus:border-[#2288ee] transition-none text-base disabled:opacity-50"
className="flex-1 rounded-md px-3 py-2 border border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100 focus:border-[#2288ee] transition-none text-base"
/>
{showAtPopover && (
<div
@@ -809,7 +431,7 @@ export default function DebugDialog({
onMouseLeave={() => setIsHovering(false)}
>
<span className="text-gray-800 dark:text-gray-200">
@websocketbot - {t('pipelines.debugDialog.atTips')}
@webchatbot - {t('pipelines.debugDialog.atTips')}
</span>
</div>
</div>
@@ -818,17 +440,10 @@ export default function DebugDialog({
</div>
<Button
onClick={sendMessage}
disabled={
(!inputValue.trim() &&
!hasAt &&
selectedImages.length === 0 &&
!quotedMessage) ||
!isConnected ||
isUploading
}
className="rounded-md bg-[#2288ee] hover:bg-[#2288ee] w-20 text-white px-6 py-2 text-base font-medium transition-none flex items-center gap-2 shadow-none disabled:opacity-50"
disabled={!inputValue.trim() && !hasAt}
className="rounded-md bg-[#2288ee] hover:bg-[#2288ee] w-20 text-white px-6 py-2 text-base font-medium transition-none flex items-center gap-2 shadow-none"
>
{isUploading ? '上传中...' : t('pipelines.debugDialog.send')}
<>{t('pipelines.debugDialog.send')}</>
</Button>
</div>
</div>
@@ -838,30 +453,16 @@ export default function DebugDialog({
// 如果是嵌入模式,直接返回内容
if (isEmbedded) {
return (
<>
<div className="flex flex-col h-full min-h-0">
<div className="flex-1 min-h-0 flex flex-col">{renderContent()}</div>
</div>
<ImagePreviewDialog
open={showImagePreview}
imageUrl={previewImageUrl}
onClose={() => setShowImagePreview(false)}
/>
</>
<div className="flex flex-col h-full min-h-0">
<div className="flex-1 min-h-0 flex flex-col">{renderContent()}</div>
</div>
);
}
// 原有的Dialog包装
return (
<>
<DialogContent className="!max-w-[70vw] max-w-6xl h-[70vh] p-6 flex flex-col rounded-2xl shadow-2xl bg-white dark:bg-black">
{renderContent()}
</DialogContent>
<ImagePreviewDialog
open={showImagePreview}
imageUrl={previewImageUrl}
onClose={() => setShowImagePreview(false)}
/>
</>
<DialogContent className="!max-w-[70vw] max-w-6xl h-[70vh] p-6 flex flex-col rounded-2xl shadow-2xl bg-white dark:bg-black">
{renderContent()}
</DialogContent>
);
}

View File

@@ -1,56 +0,0 @@
import React from 'react';
interface ImagePreviewDialogProps {
open: boolean;
imageUrl: string;
onClose: () => void;
}
export default function ImagePreviewDialog({
open,
imageUrl,
onClose,
}: ImagePreviewDialogProps) {
if (!open) return null;
return (
<div
className="fixed inset-0 z-[100] flex items-center justify-center p-8 animate-in fade-in duration-200"
onClick={onClose}
>
{/* 背景遮罩 */}
<div className="absolute inset-0 bg-black/20 " />
{/* 内容区域 */}
<div className="relative z-10 flex flex-col items-center gap-2">
{/* 关闭按钮 - 在图片上方 */}
<button
onClick={onClose}
className="self-end w-9 h-9 rounded-full bg-white hover:bg-gray-100 dark:bg-gray-800 dark:hover:bg-gray-700 text-gray-800 dark:text-gray-100 shadow-lg transition-all hover:scale-105 flex items-center justify-center"
>
<svg
className="w-4 h-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
{/* 图片 */}
<img
src={imageUrl}
alt="Preview"
className="max-w-[50vw] max-h-[50vh] object-contain rounded-lg shadow-2xl bg-white"
onClick={(e) => e.stopPropagation()}
/>
</div>
</div>
);
}

View File

@@ -16,8 +16,6 @@ import {
import { Checkbox } from '@/components/ui/checkbox';
import { Plus, X, Server, Wrench } from 'lucide-react';
import { Badge } from '@/components/ui/badge';
import { Switch } from '@/components/ui/switch';
import { Label } from '@/components/ui/label';
import { Plugin } from '@/app/infra/entities/plugin';
import { MCPServer } from '@/app/infra/entities/api';
import PluginComponentList from '@/app/home/plugins/components/plugin-installed/PluginComponentList';
@@ -29,8 +27,6 @@ export default function PipelineExtension({
}) {
const { t } = useTranslation();
const [loading, setLoading] = useState(true);
const [enableAllPlugins, setEnableAllPlugins] = useState(true);
const [enableAllMCPServers, setEnableAllMCPServers] = useState(true);
const [selectedPlugins, setSelectedPlugins] = useState<Plugin[]>([]);
const [allPlugins, setAllPlugins] = useState<Plugin[]>([]);
const [selectedMCPServers, setSelectedMCPServers] = useState<MCPServer[]>([]);
@@ -57,9 +53,6 @@ export default function PipelineExtension({
setLoading(true);
const data = await backendClient.getPipelineExtensions(pipelineId);
setEnableAllPlugins(data.enable_all_plugins ?? true);
setEnableAllMCPServers(data.enable_all_mcp_servers ?? true);
const boundPluginIds = new Set(
data.bound_plugins.map((p) => `${p.author}/${p.name}`),
);
@@ -87,12 +80,7 @@ export default function PipelineExtension({
}
};
const saveToBackend = async (
plugins: Plugin[],
mcpServers: MCPServer[],
newEnableAllPlugins?: boolean,
newEnableAllMCPServers?: boolean,
) => {
const saveToBackend = async (plugins: Plugin[], mcpServers: MCPServer[]) => {
try {
const boundPluginsArray = plugins.map((plugin) => {
const metadata = plugin.manifest.manifest.metadata;
@@ -108,8 +96,6 @@ export default function PipelineExtension({
pipelineId,
boundPluginsArray,
boundMCPServerIds,
newEnableAllPlugins ?? enableAllPlugins,
newEnableAllMCPServers ?? enableAllMCPServers,
);
toast.success(t('pipelines.extensions.saveSuccess'));
} catch (error) {
@@ -160,26 +146,6 @@ export default function PipelineExtension({
);
};
const handleToggleAllPlugins = () => {
if (tempSelectedPluginIds.length === allPlugins.length) {
// Deselect all
setTempSelectedPluginIds([]);
} else {
// Select all
setTempSelectedPluginIds(allPlugins.map((p) => getPluginId(p)));
}
};
const handleToggleAllMCPServers = () => {
if (tempSelectedMCPIds.length === allMCPServers.length) {
// Deselect all
setTempSelectedMCPIds([]);
} else {
// Select all
setTempSelectedMCPIds(allMCPServers.map((s) => s.uuid || ''));
}
};
const handleConfirmPluginSelection = async () => {
const newSelected = allPlugins.filter((p) =>
tempSelectedPluginIds.includes(getPluginId(p)),
@@ -198,26 +164,6 @@ export default function PipelineExtension({
await saveToBackend(selectedPlugins, newSelected);
};
const handleToggleEnableAllPlugins = async (checked: boolean) => {
setEnableAllPlugins(checked);
await saveToBackend(
selectedPlugins,
selectedMCPServers,
checked,
undefined,
);
};
const handleToggleEnableAllMCPServers = async (checked: boolean) => {
setEnableAllMCPServers(checked);
await saveToBackend(
selectedPlugins,
selectedMCPServers,
undefined,
checked,
);
};
if (loading) {
return (
<div className="space-y-4">
@@ -232,32 +178,11 @@ export default function PipelineExtension({
<div className="space-y-6">
{/* Plugins Section */}
<div className="space-y-3">
<div className="flex items-center justify-between">
<h3 className="text-sm font-semibold text-foreground">
{t('pipelines.extensions.pluginsTitle')}
</h3>
<div className="flex items-center gap-2">
<Label
htmlFor="enable-all-plugins"
className="text-sm font-normal cursor-pointer"
>
{t('pipelines.extensions.enableAllPlugins')}
</Label>
<Switch
id="enable-all-plugins"
checked={enableAllPlugins}
onCheckedChange={handleToggleEnableAllPlugins}
/>
</div>
</div>
<h3 className="text-sm font-semibold text-foreground">
{t('pipelines.extensions.pluginsTitle')}
</h3>
<div className="space-y-2">
{enableAllPlugins ? (
<div className="flex h-32 items-center justify-center rounded-lg border-2 border-dashed border-border bg-muted/30">
<p className="text-sm text-muted-foreground">
{t('pipelines.extensions.allPluginsEnabled')}
</p>
</div>
) : selectedPlugins.length === 0 ? (
{selectedPlugins.length === 0 ? (
<div className="flex h-32 items-center justify-center rounded-lg border-2 border-dashed border-border">
<p className="text-sm text-muted-foreground">
{t('pipelines.extensions.noPluginsSelected')}
@@ -289,19 +214,7 @@ export default function PipelineExtension({
</div>
<div className="flex gap-1 mt-1">
<PluginComponentList
components={(() => {
const componentKindCount: Record<string, number> =
{};
for (const component of plugin.components) {
const kind = component.manifest.manifest.kind;
if (componentKindCount[kind]) {
componentKindCount[kind]++;
} else {
componentKindCount[kind] = 1;
}
}
return componentKindCount;
})()}
components={plugin.components}
showComponentName={true}
showTitle={false}
useBadge={true}
@@ -333,7 +246,6 @@ export default function PipelineExtension({
onClick={handleOpenPluginDialog}
variant="outline"
className="w-full"
disabled={enableAllPlugins}
>
<Plus className="mr-2 h-4 w-4" />
{t('pipelines.extensions.addPlugin')}
@@ -342,32 +254,11 @@ export default function PipelineExtension({
{/* MCP Servers Section */}
<div className="space-y-3">
<div className="flex items-center justify-between">
<h3 className="text-sm font-semibold text-foreground">
{t('pipelines.extensions.mcpServersTitle')}
</h3>
<div className="flex items-center gap-2">
<Label
htmlFor="enable-all-mcp-servers"
className="text-sm font-normal cursor-pointer"
>
{t('pipelines.extensions.enableAllMCPServers')}
</Label>
<Switch
id="enable-all-mcp-servers"
checked={enableAllMCPServers}
onCheckedChange={handleToggleEnableAllMCPServers}
/>
</div>
</div>
<h3 className="text-sm font-semibold text-foreground">
{t('pipelines.extensions.mcpServersTitle')}
</h3>
<div className="space-y-2">
{enableAllMCPServers ? (
<div className="flex h-32 items-center justify-center rounded-lg border-2 border-dashed border-border bg-muted/30">
<p className="text-sm text-muted-foreground">
{t('pipelines.extensions.allMCPServersEnabled')}
</p>
</div>
) : selectedMCPServers.length === 0 ? (
{selectedMCPServers.length === 0 ? (
<div className="flex h-32 items-center justify-center rounded-lg border-2 border-dashed border-border">
<p className="text-sm text-muted-foreground">
{t('pipelines.extensions.noMCPServersSelected')}
@@ -427,7 +318,6 @@ export default function PipelineExtension({
onClick={handleOpenMCPDialog}
variant="outline"
className="w-full"
disabled={enableAllMCPServers}
>
<Plus className="mr-2 h-4 w-4" />
{t('pipelines.extensions.addMCPServer')}
@@ -440,86 +330,49 @@ export default function PipelineExtension({
<DialogHeader>
<DialogTitle>{t('pipelines.extensions.selectPlugins')}</DialogTitle>
</DialogHeader>
{allPlugins.length > 0 && (
<div
className="flex items-center gap-3 px-1 py-2 border-b cursor-pointer"
onClick={handleToggleAllPlugins}
>
<Checkbox
checked={
tempSelectedPluginIds.length === allPlugins.length &&
allPlugins.length > 0
}
onCheckedChange={handleToggleAllPlugins}
/>
<span className="text-sm font-medium">
{t('pipelines.extensions.selectAll')}
</span>
</div>
)}
<div className="flex-1 overflow-y-auto space-y-2 pr-2">
{allPlugins.length === 0 ? (
<div className="flex h-full items-center justify-center">
<p className="text-sm text-muted-foreground">
{t('pipelines.extensions.noPluginsInstalled')}
</p>
</div>
) : (
allPlugins.map((plugin) => {
const pluginId = getPluginId(plugin);
const metadata = plugin.manifest.manifest.metadata;
const isSelected = tempSelectedPluginIds.includes(pluginId);
return (
<div
key={pluginId}
className="flex items-center gap-3 rounded-lg border p-3 hover:bg-accent cursor-pointer"
onClick={() => handleTogglePlugin(pluginId)}
>
<Checkbox checked={isSelected} />
<img
src={backendClient.getPluginIconURL(
metadata.author || '',
metadata.name,
)}
alt={metadata.name}
className="w-10 h-10 rounded-lg border bg-muted object-cover flex-shrink-0"
/>
<div className="flex-1">
<div className="font-medium">{metadata.name}</div>
<div className="text-sm text-muted-foreground">
{metadata.author} v{metadata.version}
</div>
<div className="flex gap-1 mt-1">
<PluginComponentList
components={(() => {
const componentKindCount: Record<string, number> =
{};
for (const component of plugin.components) {
const kind = component.manifest.manifest.kind;
if (componentKindCount[kind]) {
componentKindCount[kind]++;
} else {
componentKindCount[kind] = 1;
}
}
return componentKindCount;
})()}
showComponentName={true}
showTitle={false}
useBadge={true}
t={t}
/>
</div>
</div>
{!plugin.enabled && (
<Badge variant="secondary">
{t('pipelines.extensions.disabled')}
</Badge>
{allPlugins.map((plugin) => {
const pluginId = getPluginId(plugin);
const metadata = plugin.manifest.manifest.metadata;
const isSelected = tempSelectedPluginIds.includes(pluginId);
return (
<div
key={pluginId}
className="flex items-center gap-3 rounded-lg border p-3 hover:bg-accent cursor-pointer"
onClick={() => handleTogglePlugin(pluginId)}
>
<Checkbox checked={isSelected} />
<img
src={backendClient.getPluginIconURL(
metadata.author || '',
metadata.name,
)}
alt={metadata.name}
className="w-10 h-10 rounded-lg border bg-muted object-cover flex-shrink-0"
/>
<div className="flex-1">
<div className="font-medium">{metadata.name}</div>
<div className="text-sm text-muted-foreground">
{metadata.author} v{metadata.version}
</div>
<div className="flex gap-1 mt-1">
<PluginComponentList
components={plugin.components}
showComponentName={true}
showTitle={false}
useBadge={true}
t={t}
/>
</div>
</div>
);
})
)}
{!plugin.enabled && (
<Badge variant="secondary">
{t('pipelines.extensions.disabled')}
</Badge>
)}
</div>
);
})}
</div>
<DialogFooter>
<Button
@@ -543,74 +396,47 @@ export default function PipelineExtension({
{t('pipelines.extensions.selectMCPServers')}
</DialogTitle>
</DialogHeader>
{allMCPServers.length > 0 && (
<div
className="flex items-center gap-3 px-1 py-2 border-b cursor-pointer"
onClick={handleToggleAllMCPServers}
>
<Checkbox
checked={
tempSelectedMCPIds.length === allMCPServers.length &&
allMCPServers.length > 0
}
onCheckedChange={handleToggleAllMCPServers}
/>
<span className="text-sm font-medium">
{t('pipelines.extensions.selectAll')}
</span>
</div>
)}
<div className="flex-1 overflow-y-auto space-y-2 pr-2">
{allMCPServers.length === 0 ? (
<div className="flex h-full items-center justify-center">
<p className="text-sm text-muted-foreground">
{t('pipelines.extensions.noMCPServersConfigured')}
</p>
</div>
) : (
allMCPServers.map((server) => {
const isSelected = tempSelectedMCPIds.includes(
server.uuid || '',
);
return (
<div
key={server.uuid}
className="flex items-center gap-3 rounded-lg border p-3 hover:bg-accent cursor-pointer"
onClick={() => handleToggleMCPServer(server.uuid || '')}
>
<Checkbox checked={isSelected} />
<div className="w-10 h-10 rounded-lg border bg-muted flex items-center justify-center flex-shrink-0">
<Server className="h-5 w-5 text-muted-foreground" />
</div>
<div className="flex-1">
<div className="font-medium">{server.name}</div>
<div className="text-sm text-muted-foreground">
{server.mode}
</div>
{server.runtime_info &&
server.runtime_info.status === 'connected' && (
<Badge
variant="outline"
className="flex items-center gap-1 mt-1"
>
<Wrench className="h-3 w-3 text-black dark:text-white" />
<span className="text-xs text-black dark:text-white">
{t('pipelines.extensions.toolCount', {
count: server.runtime_info.tool_count || 0,
})}
</span>
</Badge>
)}
</div>
{!server.enable && (
<Badge variant="secondary">
{t('pipelines.extensions.disabled')}
</Badge>
)}
{allMCPServers.map((server) => {
const isSelected = tempSelectedMCPIds.includes(server.uuid || '');
return (
<div
key={server.uuid}
className="flex items-center gap-3 rounded-lg border p-3 hover:bg-accent cursor-pointer"
onClick={() => handleToggleMCPServer(server.uuid || '')}
>
<Checkbox checked={isSelected} />
<div className="w-10 h-10 rounded-lg border bg-muted flex items-center justify-center flex-shrink-0">
<Server className="h-5 w-5 text-muted-foreground" />
</div>
);
})
)}
<div className="flex-1">
<div className="font-medium">{server.name}</div>
<div className="text-sm text-muted-foreground">
{server.mode}
</div>
{server.runtime_info &&
server.runtime_info.status === 'connected' && (
<Badge
variant="outline"
className="flex items-center gap-1 mt-1"
>
<Wrench className="h-3 w-3 text-black dark:text-white" />
<span className="text-xs text-black dark:text-white">
{t('pipelines.extensions.toolCount', {
count: server.runtime_info.tool_count || 0,
})}
</span>
</Badge>
)}
</div>
{!server.enable && (
<Badge variant="secondary">
{t('pipelines.extensions.disabled')}
</Badge>
)}
</div>
);
})}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setMcpDialogOpen(false)}>

View File

@@ -159,6 +159,7 @@ export default function PipelineFormComponent({
}, [form, isEditMode]);
function handleFormSubmit(values: FormValues) {
console.log('handleFormSubmit', values);
if (isEditMode) {
handleModify(values);
} else {
@@ -167,6 +168,7 @@ export default function PipelineFormComponent({
}
function handleCreate(values: FormValues) {
console.log('handleCreate', values);
const pipeline: Pipeline = {
config: {},
description: values.basic.description,

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