Compare commits

...

22 Commits

Author SHA1 Message Date
copilot-swe-agent[bot]
ba1de9408d Fix prettier formatting issue in PluginCardComponent
Co-authored-by: RockChinQ <45992437+RockChinQ@users.noreply.github.com>
2025-11-24 17:18:13 +00:00
copilot-swe-agent[bot]
f49f9fdff1 Add View Source menu item and remove clickable source badges
- Add "viewSource" translation key to all language files
- Add View Source menu item to plugin card dropdown (only for GitHub/marketplace plugins)
- Remove onClick handlers and ExternalLink icons from source badges
- Keep the badges themselves for visual indication of plugin source

Co-authored-by: RockChinQ <45992437+RockChinQ@users.noreply.github.com>
2025-11-24 17:15:02 +00:00
copilot-swe-agent[bot]
6b20cb7144 Initial plan 2025-11-24 17:08:09 +00:00
Xiaoyu Su
2e1f16d7b4 feat: improvements for installed plugin card
* feat:Add README display to installed plugins

* chore: Increase the timeout of call_tool

* perf: smaller animation

* fix: add endpiont for readme

* feat: supports for multilingual READMEs

* feat: supports for getting readme img

* chore: bump langbot-plugin to 0.1.13b1

* perf: plugin card layout

* fix: import useTranslation linter error

---------

Co-authored-by: Junyan Qin <rockchinq@gmail.com>
2025-11-25 00:12:03 +08:00
Tigrex Dai
50c33c5213 Fix typo for variable and comment 'Quote' (#1800) 2025-11-24 23:09:31 +08:00
Copilot
ace6d62d76 perf: Sort installed plugins: debug plugins first, then by installation time (#1798)
* Initial plan

* Implement plugin list sorting: debug plugins first, then by installation time

Co-authored-by: RockChinQ <45992437+RockChinQ@users.noreply.github.com>

* Apply ruff formatting

* Add unit tests for plugin list sorting functionality

Co-authored-by: RockChinQ <45992437+RockChinQ@users.noreply.github.com>

* Optimize database query to avoid N+1 problem and update tests

Co-authored-by: RockChinQ <45992437+RockChinQ@users.noreply.github.com>

* Remove redundant assertion in test

Co-authored-by: RockChinQ <45992437+RockChinQ@users.noreply.github.com>

* perf: plugin list sorting

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: RockChinQ <45992437+RockChinQ@users.noreply.github.com>
Co-authored-by: Junyan Qin <rockchinq@gmail.com>
2025-11-23 13:46:45 +08:00
Junyan Qin
b7c4c21796 feat: add message_chain field to *NormalMessageReceived events 2025-11-22 14:59:12 +08:00
Copilot
66602da9cb feat: add model_config parameter support for Dify assistant type apps (#1796)
* Initial plan

* feat: add model_config parameter support for Dify assistant type

- Add model_config parameter to AsyncDifyServiceClient.chat_messages method
- Add _get_model_config helper method to DifyServiceAPIRunner
- Pass model_config from pipeline configuration to all chat_messages calls
- Add model-config configuration field to dify-service-api schema in ai.yaml
- Support optional model configuration for assistant type apps in open-source Dify

Co-authored-by: RockChinQ <45992437+RockChinQ@users.noreply.github.com>

* refactor: improve model_config implementation based on code review

- Simplify _get_model_config method logic
- Add more descriptive comment about model_config usage
- Clarify when model_config is used (assistant type apps)

Co-authored-by: RockChinQ <45992437+RockChinQ@users.noreply.github.com>

* feat: only modify client.py

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: RockChinQ <45992437+RockChinQ@users.noreply.github.com>
Co-authored-by: Junyan Qin <rockchinq@gmail.com>
2025-11-22 14:38:26 +08:00
wrj97
31b483509c fix: fix n8n streaming support issue (#1787)
* fix: fix n8n streaming support issue

Add streaming support detection and proper message type handling for
n8n service API runner. Previously, when streaming was enabled, n8n
integration would fail due to incorrect message type usage.

1. Added streaming capability detection by checking adapter's
is_stream_output_supported method
2. Implemented conditional message generation using MessageChunk for
streaming mode and Message for non-streaming mode
3. Added proper error handling for adapters that don't support streaming
detection

* fix: add n8n webhook streaming model ,Optimized the streaming output when calling n8n.

---------

Co-authored-by: Dong_master <2213070223@qq.com>
2025-11-22 14:17:46 +08:00
Junyan Qin
ba7cf69c9d doc: update READMEs 2025-11-22 00:20:39 +08:00
Junyan Qin
37296be67e feat: refactor plugin market interaction and migrate to LangBot Space
- Replace plugin detail dialog with hover buttons interaction
- Add "Install" and "View Details" hover buttons on plugin cards
- Remove PluginDetailDialog component
- Update plugin marketplace URL format to /market/{author}/{plugin}
- Redirect all plugin detail views to LangBot Space
- Add i18n support for 4 languages (zh-Hans, en-US, zh-Hant, ja-JP)
- Optimize hover overlay styles for light/dark theme

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-21 14:23:22 +08:00
Junyan Qin
6c03a1dd31 perf: add supports for showing multilingual plugin README 2025-11-21 12:14:04 +08:00
Junyan Qin
b75ec9e989 doc: add product hunt badges 2025-11-21 10:31:57 +08:00
Copilot
5c8523e4ef docs: Add multilingual README files (Spanish, French, Korean, Russian, Vietnamese) (#1794)
* Initial plan

* Add multilingual README files (ES, FR, KO, RU, VI)

Co-authored-by: RockChinQ <45992437+RockChinQ@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: RockChinQ <45992437+RockChinQ@users.noreply.github.com>
2025-11-21 00:36:35 +08:00
Junyan Qin
9802a42a9e perf: add request plugin button to marketplace 2025-11-20 23:41:45 +08:00
Junyan Qin
99e3abec72 chore: bump version 4.5.4 2025-11-20 23:19:37 +08:00
Junyan Qin
fc2efdf994 chore: bump langbot-plugin 0.1.12 2025-11-20 21:51:44 +08:00
Junyan Qin
6ed672d996 perf: tips msg for tool call 2025-11-20 21:45:22 +08:00
Junyan Qin
2bf593fa6b feat: pass session and query_id to tool call 2025-11-20 21:17:47 +08:00
Junyan Qin
3182214663 fix: linter errors 2025-11-20 19:48:34 +08:00
Junyan Qin
20614b20b7 feat: add component filter to marketplace page 2025-11-20 19:46:33 +08:00
Junyan Qin
da323817f7 feat: add plugin components displaying in marketplace page 2025-11-20 18:50:00 +08:00
52 changed files with 4173 additions and 664 deletions

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) / (PR for your language)
[English](README_EN.md) / 简体中文 / [繁體中文](README_TW.md) / [日本語](README_JP.md) / [Español](README_ES.md) / [Français](README_FR.md) / [한국어](README_KO.md) / [Русский](README_RU.md) / [Tiếng Việt](README_VI.md)
[![Discord](https://img.shields.io/discord/1335141740050649118?logo=discord&labelColor=%20%235462eb&logoColor=%20%23f5f5f5&color=%20%235462eb)](https://discord.gg/wdNEHETs87)
[![QQ Group](https://img.shields.io/badge/%E7%A4%BE%E5%8C%BAQQ%E7%BE%A4-966235608-blue)](https://qm.qq.com/q/JLi38whHum)
@@ -83,10 +83,10 @@ docker compose up -d
## ✨ 特性
- 💬 大模型对话、Agent支持多种大模型适配群聊和私聊具有多轮对话、工具调用、多模态、流式输出能力自带 RAG知识库实现并深度适配 [Dify](https://dify.ai)。
- 💬 大模型对话、Agent支持多种大模型适配群聊和私聊具有多轮对话、工具调用、多模态、流式输出能力自带 RAG知识库实现并深度适配 [Dify](https://dify.ai)、[Coze](https://coze.com)、[n8n](https://n8n.io)等 LLMOps 平台
- 🤖 多平台支持:目前支持 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,7 +5,9 @@
<div align="center">
English / [简体中文](README.md) / [繁體中文](README_TW.md) / [日本語](README_JP.md) / (PR for your language)
<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)
[![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)
@@ -77,10 +79,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).
- 💬 Chat with LLM / Agent: Supports multiple LLMs, adapt to group chats and private chats; Supports multi-round conversations, tool calls, multi-modal, and streaming output capabilities. Built-in RAG (knowledge base) implementation, and deeply integrates with [Dify](https://dify.ai), [Coze](https://coze.com), [n8n](https://n8n.io) etc. LLMOps platforms.
- 🤖 Multi-platform Support: Currently supports QQ, QQ Channel, WeCom, personal WeChat, Lark, DingTalk, Discord, Telegram, etc.
- 🛠️ High Stability, Feature-rich: Native access control, rate limiting, sensitive word filtering, etc. mechanisms; Easy to use, supports multiple deployment methods. Supports multiple pipeline configurations, different bots can be used for different scenarios.
- 🧩 Plugin Extension, Active Community: Support event-driven, component extension, etc. plugin mechanisms; Integrate Anthropic [MCP protocol](https://modelcontextprotocol.io/); Currently has hundreds of plugins.
- 🧩 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.
- 😻 Web UI: Support management LangBot instance through the browser. No need to manually write configuration files.
For more detailed specifications, please refer to the [documentation](https://docs.langbot.app/en/insight/features.html).

143
README_ES.md Normal file
View File

@@ -0,0 +1,143 @@
<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>

143
README_FR.md Normal file
View File

@@ -0,0 +1,143 @@
<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,7 +5,9 @@
<div align="center">
[English](README_EN.md) / [简体中文](README.md) / [繁體中文](README_TW.md) / 日本語 / (PR for your language)
<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)
[![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)
@@ -77,10 +79,10 @@ LangBotはBTPanelにリストされています。BTPanelをインストール
## ✨ 機能
- 💬 LLM / エージェントとのチャット: 複数のLLMをサポートし、グループチャットとプライベートチャットに対応。マルチラウンドの会話、ツールの呼び出し、マルチモーダル、ストリーミング出力機能をサポート、RAG知識ベースを組み込み、[Dify](https://dify.ai) と深く統合。
- 💬 LLM / エージェントとのチャット: 複数のLLMをサポートし、グループチャットとプライベートチャットに対応。マルチラウンドの会話、ツールの呼び出し、マルチモーダル、ストリーミング出力機能をサポート、RAG知識ベースを組み込み、[Dify](https://dify.ai)、[Coze](https://coze.com)、[n8n](https://n8n.io) などの LLMOps プラットフォームと深く統合。
- 🤖 多プラットフォーム対応: 現在、QQ、QQ チャンネル、WeChat、個人 WeChat、Lark、DingTalk、Discord、Telegram など、複数のプラットフォームをサポートしています。
- 🛠️ 高い安定性、豊富な機能: ネイティブのアクセス制御、レート制限、敏感な単語のフィルタリングなどのメカニズムをサポート。使いやすく、複数のデプロイ方法をサポート。複数のパイプライン設定をサポートし、異なるボットを異なる用途に使用できます。
- 🧩 プラグイン拡張、活発なコミュニティ: イベント駆動、コンポーネント拡張などのプラグインメカニズムをサポート。適配 Anthropic [MCP プロトコル](https://modelcontextprotocol.io/);豊富なエコシステム、現在数百のプラグインが存在。
- 🧩 プラグイン拡張、活発なコミュニティ: 高い安定性、高いセキュリティの生産レベルのプラグインシステム;イベント駆動、コンポーネント拡張などのプラグインメカニズムをサポート。適配 Anthropic [MCP プロトコル](https://modelcontextprotocol.io/);豊富なエコシステム、現在数百のプラグインが存在。
- 😻 Web UI: ブラウザを通じてLangBotインスタンスを管理することをサポート。
詳細な仕様については、[ドキュメント](https://docs.langbot.app/en/insight/features.html)を参照してください。

143
README_KO.md Normal file
View File

@@ -0,0 +1,143 @@
<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>

143
README_RU.md Normal file
View File

@@ -0,0 +1,143 @@
<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) / (PR for your language)
[English](README_EN.md) / [简体中文](README.md) / 繁體中文 / [日本語](README_JP.md) / [Español](README_ES.md) / [Français](README_FR.md) / [한국어](README_KO.md) / [Русский](README_RU.md) / [Tiếng Việt](README_VI.md)
[![Discord](https://img.shields.io/discord/1335141740050649118?logo=discord&labelColor=%20%235462eb&logoColor=%20%23f5f5f5&color=%20%235462eb)](https://discord.gg/wdNEHETs87)
[![QQ Group](https://img.shields.io/badge/%E7%A4%BE%E5%8C%BAQQ%E7%BE%A4-966235608-blue)](https://qm.qq.com/q/JLi38whHum)
@@ -79,10 +79,10 @@ docker compose up -d
## ✨ 特性
- 💬 大模型對話、Agent支援多種大模型適配群聊和私聊具有多輪對話、工具調用、多模態、流式輸出能力自帶 RAG知識庫實現並深度適配 [Dify](https://dify.ai)。
- 💬 大模型對話、Agent支援多種大模型適配群聊和私聊具有多輪對話、工具調用、多模態、流式輸出能力自帶 RAG知識庫實現並深度適配 [Dify](https://dify.ai)、[Coze](https://coze.com)、[n8n](https://n8n.io) 等 LLMOps 平台
- 🤖 多平台支援:目前支援 QQ、QQ頻道、企業微信、個人微信、飛書、Discord、Telegram 等平台。
- 🛠️ 高穩定性、功能完備:原生支援訪問控制、限速、敏感詞過濾等機制;配置簡單,支援多種部署方式。支援多流水線配置,不同機器人用於不同應用場景。
- 🧩 外掛擴展、活躍社群:支援事件驅動、組件擴展等外掛機制;適配 Anthropic [MCP 協議](https://modelcontextprotocol.io/);目前已有數百個外掛。
- 🧩 外掛擴展、活躍社群:高穩定性、高安全性的生產級外掛系統;支援事件驅動、組件擴展等外掛機制;適配 Anthropic [MCP 協議](https://modelcontextprotocol.io/);目前已有數百個外掛。
- 😻 Web 管理面板:支援通過瀏覽器管理 LangBot 實例,不再需要手動編寫配置文件。
詳細規格特性請訪問[文件](https://docs.langbot.app/zh/insight/features.html)。

143
README_VI.md Normal file
View File

@@ -0,0 +1,143 @@
<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

@@ -1,6 +1,6 @@
[project]
name = "langbot"
version = "4.5.3"
version = "4.5.4"
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.1.11",
"langbot-plugin==0.1.13b1",
"asyncpg>=0.30.0",
"line-bot-sdk>=3.19.0",
"tboxsdk>=0.0.10",

View File

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

View File

@@ -32,6 +32,7 @@ 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':
@@ -42,6 +43,16 @@ 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',
@@ -49,14 +60,7 @@ class AsyncDifyServiceClient:
'Authorization': f'Bearer {self.api_key}',
'Content-Type': 'application/json',
},
json={
'inputs': inputs,
'query': query,
'user': user,
'response_mode': response_mode,
'conversation_id': conversation_id,
'files': files,
},
json=payload,
) as r:
async for chunk in r.aiter_lines():
if r.status_code != 200:

View File

@@ -82,6 +82,16 @@ class PluginsRouterGroup(group.RouterGroup):
return self.success(data={})
@self.route(
'/<author>/<plugin_name>/readme',
methods=['GET'],
auth_type=group.AuthType.USER_TOKEN,
)
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'],
@@ -96,6 +106,17 @@ 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)
async def _() -> str:
"""Get releases from a GitHub repository URL"""

View File

@@ -55,17 +55,6 @@ 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

@@ -99,7 +99,7 @@ class PreProcessor(stage.PipelineStage):
content_list: list[provider_message.ContentElement] = []
plain_text = ''
qoute_msg = query.pipeline_config['trigger'].get('misc', '').get('combine-quote-message')
quote_msg = query.pipeline_config['trigger'].get('misc', '').get('combine-quote-message')
for me in query.message_chain:
if isinstance(me, platform_message.Plain):
@@ -114,7 +114,7 @@ class PreProcessor(stage.PipelineStage):
elif isinstance(me, platform_message.File):
# if me.url is not None:
content_list.append(provider_message.ContentElement.from_file_url(me.url, me.name))
elif isinstance(me, platform_message.Quote) and qoute_msg:
elif isinstance(me, platform_message.Quote) and quote_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,6 +40,7 @@ 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'调用函数 {".".join(function_names)}...'
reply_text = f'Call {".".join(function_names)}...'
query.resp_message_chain.append(
platform_message.MessageChain([platform_message.Plain(text=reply_text)])

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': # 这里来应该传入转发消息组,暂时传入qoute
elif msg_data['type'] == 'forward': # 这里来应该传入转发消息组,暂时传入Quote
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': # 此处处理引用消息传入Qoute
elif msg.type == 'reply': # 此处处理引用消息传入Quote
msg_datas = await bot.get_msg(message_id=msg.data['id'])
for msg_data in msg_datas['message']:

View File

@@ -7,7 +7,9 @@ import typing
import os
import sys
import httpx
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
@@ -26,6 +28,7 @@ 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:
@@ -278,7 +281,61 @@ class PluginRuntimeConnector:
if not self.is_enable_plugin:
return []
return await self.handler.list_plugins()
plugins = await self.handler.list_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
async def get_plugin_info(self, author: str, plugin_name: str) -> dict[str, Any]:
return await self.handler.get_plugin_info(author, plugin_name)
@@ -290,6 +347,14 @@ 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,
@@ -321,13 +386,20 @@ class PluginRuntimeConnector:
return tools
async def call_tool(
self, tool_name: str, parameters: dict[str, Any], bound_plugins: list[str] | None = None
self,
tool_name: str,
parameters: dict[str, Any],
session: provider_session.Session,
query_id: int,
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, include_plugins=bound_plugins)
return await self.handler.call_tool(
tool_name, parameters, session.model_dump(serialize_as_any=True), query_id, include_plugins=bound_plugins
)
async def list_commands(self, bound_plugins: list[str] | None = None) -> list[ComponentManifest]:
if not self.is_enable_plugin:

View File

@@ -602,6 +602,51 @@ 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
@@ -620,7 +665,12 @@ class RuntimeConnectionHandler(handler.Handler):
)
async def call_tool(
self, tool_name: str, parameters: dict[str, Any], include_plugins: list[str] | None = None
self,
tool_name: str,
parameters: dict[str, Any],
session: dict[str, Any],
query_id: int,
include_plugins: list[str] | None = None,
) -> dict[str, Any]:
"""Call tool"""
result = await self.call_action(
@@ -628,9 +678,11 @@ class RuntimeConnectionHandler(handler.Handler):
{
'tool_name': tool_name,
'tool_parameters': parameters,
'session': session,
'query_id': query_id,
'include_plugins': include_plugins,
},
timeout=60,
timeout=180,
)
return result['tool_response']

View File

@@ -196,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)
func_ret = await self.ap.tool_mgr.execute_func_call(func.name, parameters, query=query)
if is_stream:
msg = provider_message.MessageChunk(
role='tool',

View File

@@ -68,6 +68,33 @@ 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如果不存在
@@ -80,6 +107,7 @@ 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,
@@ -91,6 +119,11 @@ 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 = {}
@@ -126,30 +159,57 @@ class N8nServiceAPIRunner(runner.RequestRunner):
# 调用webhook
async with aiohttp.ClientSession() as session:
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}')
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}')
# 解析响应
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]
# 处理流式响应
async for chunk in self._process_stream_response(response):
yield chunk
else:
# 如果没有指定的输出键,则使用整个响应
output_content = json.dumps(response_data, ensure_ascii=False)
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}')
# 返回消息
yield provider_message.Message(
role='assistant',
content=output_content,
)
# 解析响应
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,
)
except Exception as e:
self.ap.logger.error(f'n8n webhook call exception: {str(e)}')
raise N8nAPIError(f'n8n webhook call exception: {str(e)}')
@@ -157,4 +217,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,6 +3,8 @@ 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
@@ -45,7 +47,7 @@ class ToolLoader(abc.ABC):
pass
@abc.abstractmethod
async def invoke_tool(self, name: str, parameters: dict) -> typing.Any:
async def invoke_tool(self, name: str, parameters: dict, query: pipeline_query.Query) -> typing.Any:
"""执行工具调用"""
pass

View File

@@ -4,6 +4,7 @@ import enum
import typing
from contextlib import AsyncExitStack
import traceback
from langbot_plugin.api.entities.events import pipeline_query
import sqlalchemy
import asyncio
@@ -329,7 +330,7 @@ class MCPLoader(loader.ToolLoader):
return True
return False
async def invoke_tool(self, name: str, parameters: dict) -> typing.Any:
async def invoke_tool(self, name: str, parameters: dict, query: pipeline_query.Query) -> typing.Any:
"""执行工具调用"""
for session in self.sessions.values():
for function in session.get_tools():

View File

@@ -3,6 +3,8 @@ 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
@@ -43,9 +45,11 @@ class PluginToolLoader(loader.ToolLoader):
return tool
return None
async def invoke_tool(self, name: str, parameters: dict) -> typing.Any:
async def invoke_tool(self, name: str, parameters: dict, query: pipeline_query.Query) -> typing.Any:
try:
return await self.ap.plugin_connector.call_tool(name, parameters)
return await self.ap.plugin_connector.call_tool(
name, parameters, session=query.session, query_id=query.query_id
)
except Exception as e:
self.ap.logger.error(f'执行函数 {name} 时发生错误: {e}')
traceback.print_exc()

View File

@@ -7,6 +7,7 @@ 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)
@@ -91,13 +92,13 @@ class ToolManager:
return tools
async def execute_func_call(self, name: str, parameters: dict) -> typing.Any:
async def execute_func_call(self, name: str, parameters: dict, query: pipeline_query.Query) -> typing.Any:
"""执行函数调用"""
if await self.plugin_tool_loader.has_tool(name):
return await self.plugin_tool_loader.invoke_tool(name, parameters)
return await self.plugin_tool_loader.invoke_tool(name, parameters, query)
elif await self.mcp_tool_loader.has_tool(name):
return await self.mcp_tool_loader.invoke_tool(name, parameters)
return await self.mcp_tool_loader.invoke_tool(name, parameters, query)
else:
raise ValueError(f'未找到工具: {name}')

View File

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

View File

@@ -0,0 +1,228 @@
"""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,6 +45,7 @@
"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",
@@ -59,6 +60,11 @@
"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",
@@ -72,6 +78,7 @@
"@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",
@@ -81,5 +88,6 @@
"tw-animate-css": "^1.2.9",
"typescript": "^5.8.3",
"typescript-eslint": "^8.31.1"
}
},
"packageManager": "pnpm@8.9.2+sha512.b9d35fe91b2a5854dadc43034a3e7b2e675fa4b56e20e8e09ef078fa553c18f8aed44051e7b36e8b8dd435f97eb0c44c4ff3b44fc7c6fa7d21e1fac17bbe661e"
}

View File

@@ -234,7 +234,19 @@ export default function PipelineExtension({
</div>
<div className="flex gap-1 mt-1">
<PluginComponentList
components={plugin.components}
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}
@@ -401,7 +413,19 @@ export default function PipelineExtension({
</div>
<div className="flex gap-1 mt-1">
<PluginComponentList
components={plugin.components}
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}

View File

@@ -1,4 +1,3 @@
import { PluginComponent } from '@/app/infra/entities/plugin';
import { TFunction } from 'i18next';
import { Wrench, AudioWaveform, Hash } from 'lucide-react';
import { Badge } from '@/components/ui/badge';
@@ -9,31 +8,22 @@ export default function PluginComponentList({
showTitle,
useBadge,
t,
responsive = false,
}: {
components: PluginComponent[];
components: Record<string, number>;
showComponentName: boolean;
showTitle: boolean;
useBadge: boolean;
t: TFunction;
responsive?: boolean;
}) {
const componentKindCount: Record<string, number> = {};
for (const component of components) {
const kind = component.manifest.manifest.kind;
if (componentKindCount[kind]) {
componentKindCount[kind]++;
} else {
componentKindCount[kind] = 1;
}
}
const kindIconMap: Record<string, React.ReactNode> = {
Tool: <Wrench className="w-5 h-5" />,
EventListener: <AudioWaveform className="w-5 h-5" />,
Command: <Hash className="w-5 h-5" />,
};
const componentKindList = Object.keys(componentKindCount);
const componentKindList = Object.keys(components || {});
return (
<>
@@ -44,11 +34,21 @@ export default function PluginComponentList({
return (
<>
{useBadge && (
<Badge variant="outline">
<Badge
key={kind}
variant="outline"
className="flex items-center gap-1"
>
{kindIconMap[kind]}
{showComponentName &&
t('plugins.componentName.' + kind) + ' '}
{componentKindCount[kind]}
{/* 响应式显示组件名称:在中等屏幕以上显示 */}
{responsive ? (
<span className="hidden md:inline">
{t('plugins.componentName.' + kind)}
</span>
) : (
showComponentName && t('plugins.componentName.' + kind)
)}
<span className="ml-1">{components[kind]}</span>
</Badge>
)}
@@ -58,9 +58,15 @@ export default function PluginComponentList({
className="flex flex-row items-center justify-start gap-[0.2rem]"
>
{kindIconMap[kind]}
{showComponentName &&
t('plugins.componentName.' + kind) + ' '}
{componentKindCount[kind]}
{/* 响应式显示组件名称:在中等屏幕以上显示 */}
{responsive ? (
<span className="hidden md:inline">
{t('plugins.componentName.' + kind)}
</span>
) : (
showComponentName && t('plugins.componentName.' + kind)
)}
<span className="ml-1">{components[kind]}</span>
</div>
)}
</>

View File

@@ -4,6 +4,7 @@ import { useState, useEffect, forwardRef, useImperativeHandle } from 'react';
import { PluginCardVO } from '@/app/home/plugins/components/plugin-installed/PluginCardVO';
import PluginCardComponent from '@/app/home/plugins/components/plugin-installed/plugin-card/PluginCardComponent';
import PluginForm from '@/app/home/plugins/components/plugin-installed/plugin-form/PluginForm';
import PluginReadme from '@/app/home/plugins/components/plugin-installed/plugin-readme/PluginReadme';
import styles from '@/app/home/plugins/plugins.module.css';
import { httpClient } from '@/app/infra/http/HttpClient';
import {
@@ -39,6 +40,8 @@ const PluginInstalledComponent = forwardRef<PluginInstalledComponentRef>(
const [selectedPlugin, setSelectedPlugin] = useState<PluginCardVO | null>(
null,
);
const [readmeModalOpen, setReadmeModalOpen] = useState<boolean>(false);
const [readmePlugin, setReadmePlugin] = useState<PluginCardVO | null>(null);
const [showOperationModal, setShowOperationModal] = useState(false);
const [operationType, setOperationType] = useState<PluginOperationType>(
PluginOperationType.DELETE,
@@ -106,6 +109,11 @@ const PluginInstalledComponent = forwardRef<PluginInstalledComponentRef>(
setModalOpen(true);
}
function handleViewReadme(plugin: PluginCardVO) {
setReadmePlugin(plugin);
setReadmeModalOpen(true);
}
function handlePluginDelete(plugin: PluginCardVO) {
setTargetPlugin(plugin);
setOperationType(PluginOperationType.DELETE);
@@ -316,6 +324,25 @@ const PluginInstalledComponent = forwardRef<PluginInstalledComponentRef>(
</DialogContent>
</Dialog>
<Dialog open={readmeModalOpen} onOpenChange={setReadmeModalOpen}>
<DialogContent className="sm:max-w-[900px] max-w-[90vw] max-h-[85vh] p-0 flex flex-col">
<DialogHeader className="px-6 pt-6 pb-2 border-b">
<DialogTitle>
{readmePlugin &&
`${readmePlugin.author}/${readmePlugin.name} - ${t('plugins.readme')}`}
</DialogTitle>
</DialogHeader>
<div className="flex-1 overflow-y-auto">
{readmePlugin && (
<PluginReadme
pluginAuthor={readmePlugin.author}
pluginName={readmePlugin.name}
/>
)}
</div>
</DialogContent>
</Dialog>
{pluginList.map((vo, index) => {
return (
<div key={index}>
@@ -324,6 +351,7 @@ const PluginInstalledComponent = forwardRef<PluginInstalledComponentRef>(
onCardClick={() => handlePluginClick(vo)}
onDeleteClick={() => handlePluginDelete(vo)}
onUpgradeClick={() => handlePluginUpdate(vo)}
onViewReadme={() => handleViewReadme(vo)}
/>
</div>
);

View File

@@ -2,7 +2,15 @@ import { PluginCardVO } from '@/app/home/plugins/components/plugin-installed/Plu
import { useState } from 'react';
import { Badge } from '@/components/ui/badge';
import { useTranslation } from 'react-i18next';
import { BugIcon, ExternalLink, Ellipsis, Trash, ArrowUp } from 'lucide-react';
import {
BugIcon,
ExternalLink,
Ellipsis,
Trash,
ArrowUp,
Settings,
FileText,
} from 'lucide-react';
import { getCloudServiceClientSync } from '@/app/infra/http';
import { httpClient } from '@/app/infra/http/HttpClient';
import { Button } from '@/components/ui/button';
@@ -19,53 +27,59 @@ export default function PluginCardComponent({
onCardClick,
onDeleteClick,
onUpgradeClick,
onViewReadme,
}: {
cardVO: PluginCardVO;
onCardClick: () => void;
onDeleteClick: (cardVO: PluginCardVO) => void;
onUpgradeClick: (cardVO: PluginCardVO) => void;
onViewReadme: (cardVO: PluginCardVO) => void;
}) {
const { t } = useTranslation();
const [dropdownOpen, setDropdownOpen] = useState(false);
const [isHovered, setIsHovered] = useState(false);
return (
<>
<div
className="w-[100%] h-[10rem] bg-white rounded-[10px] shadow-[0px_2px_2px_0_rgba(0,0,0,0.2)] p-[1.2rem] cursor-pointer dark:bg-[#1f1f22]"
onClick={onCardClick}
className="w-[100%] h-[10rem] bg-white rounded-[10px] shadow-[0px_2px_2px_0_rgba(0,0,0,0.2)] p-[1.2rem] cursor-pointer dark:bg-[#1f1f22] relative transition-all duration-200 hover:shadow-[0px_3px_6px_0_rgba(0,0,0,0.12)] hover:scale-[1.005]"
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => {
if (!dropdownOpen) {
setIsHovered(false);
}
}}
>
<div className="w-full h-full flex flex-row items-start justify-start gap-[1.2rem]">
{/* <svg
className="w-16 h-16 text-[#2288ee]"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
>
<path d="M8 4C8 2.34315 9.34315 1 11 1C12.6569 1 14 2.34315 14 4C14 4.35064 13.9398 4.68722 13.8293 5H18C18.5523 5 19 5.44772 19 6V10.1707C19.3128 10.0602 19.6494 10 20 10C21.6569 10 23 11.3431 23 13C23 14.6569 21.6569 16 20 16C19.6494 16 19.3128 15.9398 19 15.8293V20C19 20.5523 18.5523 21 18 21H4C3.44772 21 3 20.5523 3 20V6C3 5.44772 3.44772 5 4 5H8.17071C8.06015 4.68722 8 4.35064 8 4Z"></path>
</svg> */}
{/* Icon - fixed width */}
<img
src={httpClient.getPluginIconURL(cardVO.author, cardVO.name)}
alt="plugin icon"
className="w-16 h-16 rounded-[8%]"
className="w-16 h-16 rounded-[8%] flex-shrink-0"
/>
<div className="w-full h-full flex flex-col items-start justify-between gap-[0.6rem]">
<div className="flex flex-col items-start justify-start">
<div className="flex flex-col items-start justify-start">
<div className="text-[0.7rem] text-[#666] dark:text-[#999]">
{/* Content area - flexible width with min-width to prevent overflow */}
<div className="flex-1 min-w-0 h-full flex flex-col items-start justify-between gap-[0.6rem]">
{/* Top content area - allows overflow with max height */}
<div className="flex flex-col items-start justify-start w-full min-w-0 flex-1 overflow-hidden">
<div className="flex flex-col items-start justify-start w-full min-w-0">
<div className="text-[0.7rem] text-[#666] dark:text-[#999] truncate w-full">
{cardVO.author} / {cardVO.name}
</div>
<div className="flex flex-row items-center justify-start gap-[0.4rem]">
<div className="text-[1.2rem] text-black dark:text-[#f0f0f0]">
<div className="flex flex-row items-center justify-start gap-[0.4rem] flex-wrap max-w-full">
<div className="text-[1.2rem] text-black dark:text-[#f0f0f0] truncate max-w-[10rem]">
{cardVO.label}
</div>
<Badge variant="outline" className="text-[0.7rem]">
<Badge
variant="outline"
className="text-[0.7rem] flex-shrink-0"
>
v{cardVO.version}
</Badge>
{cardVO.debug && (
<Badge
variant="outline"
className="text-[0.7rem] border-orange-400 text-orange-400"
className="text-[0.7rem] border-orange-400 text-orange-400 flex-shrink-0"
>
<BugIcon className="w-4 h-4" />
{t('plugins.debugging')}
@@ -76,23 +90,15 @@ export default function PluginCardComponent({
{cardVO.install_source === 'github' && (
<Badge
variant="outline"
className="text-[0.7rem] border-blue-400 text-blue-400"
onClick={(e) => {
e.stopPropagation();
window.open(
cardVO.install_info.github_url,
'_blank',
);
}}
className="text-[0.7rem] border-blue-400 text-blue-400 flex-shrink-0"
>
{t('plugins.fromGithub')}
<ExternalLink className="w-4 h-4" />
</Badge>
)}
{cardVO.install_source === 'local' && (
<Badge
variant="outline"
className="text-[0.7rem] border-green-400 text-green-400"
className="text-[0.7rem] border-green-400 text-green-400 flex-shrink-0"
>
{t('plugins.fromLocal')}
</Badge>
@@ -100,20 +106,9 @@ export default function PluginCardComponent({
{cardVO.install_source === 'marketplace' && (
<Badge
variant="outline"
className="text-[0.7rem] border-purple-400 text-purple-400"
onClick={(e) => {
e.stopPropagation();
window.open(
getCloudServiceClientSync().getPluginMarketplaceURL(
cardVO.author,
cardVO.name,
),
'_blank',
);
}}
className="text-[0.7rem] border-purple-400 text-purple-400 flex-shrink-0"
>
{t('plugins.fromMarketplace')}
<ExternalLink className="w-4 h-4" />
</Badge>
)}
</>
@@ -121,14 +116,26 @@ export default function PluginCardComponent({
</div>
</div>
<div className="text-[0.8rem] text-[#666] line-clamp-2 dark:text-[#999]">
<div className="text-[0.8rem] text-[#666] line-clamp-2 dark:text-[#999] w-full">
{cardVO.description}
</div>
</div>
<div className="w-full flex flex-row items-start justify-start gap-[0.6rem]">
{/* Components list - fixed at bottom */}
<div className="w-full flex flex-row items-start justify-start gap-[0.6rem] flex-shrink-0 min-h-[1.5rem]">
<PluginComponentList
components={cardVO.components}
components={(() => {
const componentKindCount: Record<string, number> = {};
for (const component of cardVO.components) {
const kind = component.manifest.manifest.kind;
if (componentKindCount[kind]) {
componentKindCount[kind]++;
} else {
componentKindCount[kind] = 1;
}
}
return componentKindCount;
})()}
showComponentName={false}
showTitle={true}
useBadge={false}
@@ -137,13 +144,25 @@ export default function PluginCardComponent({
</div>
</div>
<div className="flex flex-col items-center justify-between h-full">
{/* Menu button - fixed width and position */}
<div className="flex flex-col items-center justify-between h-full relative z-20 flex-shrink-0">
<div className="flex items-center justify-center"></div>
<div className="flex items-center justify-center">
<DropdownMenu open={dropdownOpen} onOpenChange={setDropdownOpen}>
<DropdownMenu
open={dropdownOpen}
onOpenChange={(open) => {
setDropdownOpen(open);
if (!open) {
setIsHovered(false);
}
}}
>
<DropdownMenuTrigger asChild>
<Button variant="ghost">
<Button
variant="ghost"
className="bg-white dark:bg-[#1f1f22] hover:bg-gray-100 dark:hover:bg-[#2a2a2d]"
>
<Ellipsis className="w-4 h-4" />
</Button>
</DropdownMenuTrigger>
@@ -162,8 +181,33 @@ export default function PluginCardComponent({
<span>{t('plugins.update')}</span>
</DropdownMenuItem>
)}
{/**view source */}
{(cardVO.install_source === 'github' ||
cardVO.install_source === 'marketplace') && (
<DropdownMenuItem
className="flex flex-row items-center justify-start gap-[0.4rem] cursor-pointer"
onClick={(e) => {
e.stopPropagation();
if (cardVO.install_source === 'github') {
window.open(cardVO.install_info.github_url, '_blank');
} else if (cardVO.install_source === 'marketplace') {
window.open(
getCloudServiceClientSync().getPluginMarketplaceURL(
cardVO.author,
cardVO.name,
),
'_blank',
);
}
setDropdownOpen(false);
}}
>
<ExternalLink className="w-4 h-4" />
<span>{t('plugins.viewSource')}</span>
</DropdownMenuItem>
)}
<DropdownMenuItem
className="flex flex-row items-center justify-start gap-[0.4rem] cursor-pointer"
className="flex flex-row items-center justify-start gap-[0.4rem] cursor-pointer text-red-600 focus:text-red-600"
onClick={(e) => {
e.stopPropagation();
onDeleteClick(cardVO);
@@ -178,6 +222,45 @@ export default function PluginCardComponent({
</div>
</div>
</div>
{/* Hover overlay with action buttons */}
<div
className={`absolute inset-0 bg-gray-100/55 dark:bg-black/35 rounded-[10px] flex items-center justify-center gap-3 transition-all duration-200 z-10 ${
isHovered ? 'opacity-100' : 'opacity-0 pointer-events-none'
}`}
>
<Button
onClick={(e) => {
e.stopPropagation();
onViewReadme(cardVO);
}}
className={`bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-lg shadow-sm flex items-center gap-2 transition-all duration-200 ${
isHovered
? 'translate-y-0 opacity-100'
: 'translate-y-1 opacity-0'
}`}
style={{ transitionDelay: isHovered ? '10ms' : '0ms' }}
>
<FileText className="w-4 h-4" />
{t('plugins.readme')}
</Button>
<Button
onClick={(e) => {
e.stopPropagation();
onCardClick();
}}
variant="outline"
className={`bg-white hover:bg-gray-100 text-gray-900 dark:bg-white dark:hover:bg-gray-100 dark:text-gray-900 px-4 py-2 rounded-lg shadow-sm flex items-center gap-2 transition-all duration-200 ${
isHovered
? 'translate-y-0 opacity-100'
: 'translate-y-1 opacity-0'
}`}
style={{ transitionDelay: isHovered ? '20ms' : '0ms' }}
>
<Settings className="w-4 h-4" />
{t('plugins.config')}
</Button>
</div>
</div>
</>
);

View File

@@ -160,7 +160,18 @@ export default function PluginForm({
<div className="mb-4 flex flex-row items-center justify-start gap-[0.4rem]">
<PluginComponentList
components={pluginInfo.components}
components={(() => {
const componentKindCount: Record<string, number> = {};
for (const component of pluginInfo.components) {
const kind = component.manifest.manifest.kind;
if (componentKindCount[kind]) {
componentKindCount[kind]++;
} else {
componentKindCount[kind] = 1;
}
}
return componentKindCount;
})()}
showComponentName={true}
showTitle={false}
useBadge={true}
@@ -173,7 +184,7 @@ export default function PluginForm({
itemConfigList={pluginInfo.manifest.manifest.spec.config}
initialValues={pluginConfig.config as Record<string, object>}
onSubmit={(values) => {
// 只保存表单值的引用不触发状态更新
// 只保存表单值的引用,不触发状态更新
currentFormValues.current = values;
}}
onFileUploaded={(fileKey) => {

View File

@@ -0,0 +1,127 @@
import { useState, useEffect } from 'react';
import { httpClient } from '@/app/infra/http/HttpClient';
import { useTranslation } from 'react-i18next';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import rehypeRaw from 'rehype-raw';
import rehypeHighlight from 'rehype-highlight';
import rehypeSlug from 'rehype-slug';
import rehypeAutolinkHeadings from 'rehype-autolink-headings';
import { getAPILanguageCode } from '@/i18n/I18nProvider';
import './github-markdown.css';
export default function PluginReadme({
pluginAuthor,
pluginName,
}: {
pluginAuthor: string;
pluginName: string;
}) {
const { t } = useTranslation();
const [readme, setReadme] = useState<string>('');
const [isLoadingReadme, setIsLoadingReadme] = useState(false);
const language = getAPILanguageCode();
useEffect(() => {
// Fetch plugin README
setIsLoadingReadme(true);
httpClient
.getPluginReadme(pluginAuthor, pluginName, language)
.then((res) => {
setReadme(res.readme);
})
.catch(() => {
setReadme('');
})
.finally(() => {
setIsLoadingReadme(false);
});
}, [pluginAuthor, pluginName]);
return (
<div className="w-full h-full overflow-auto">
{isLoadingReadme ? (
<div className="p-6 text-sm text-gray-500 dark:text-gray-400">
{t('plugins.loadingReadme')}
</div>
) : readme ? (
<div className="markdown-body p-6 max-w-none pt-0">
<ReactMarkdown
remarkPlugins={[remarkGfm]}
rehypePlugins={[
rehypeRaw,
rehypeHighlight,
rehypeSlug,
[
rehypeAutolinkHeadings,
{
behavior: 'wrap',
properties: {
className: ['anchor'],
},
},
],
]}
components={{
ul: ({ children }) => <ul className="list-disc">{children}</ul>,
ol: ({ children }) => (
<ol className="list-decimal">{children}</ol>
),
li: ({ children }) => <li className="ml-4">{children}</li>,
img: ({ src, alt, ...props }) => {
let imageSrc = src || '';
if (typeof imageSrc !== 'string') {
return (
<img
src={src}
alt={alt || ''}
className="max-w-full h-auto rounded-lg my-4"
{...props}
/>
);
}
if (
imageSrc &&
!imageSrc.startsWith('http://') &&
!imageSrc.startsWith('https://') &&
!imageSrc.startsWith('data:')
) {
imageSrc = imageSrc.replace(/^(\.\/|\/)+/, '');
if (!imageSrc.startsWith('assets/')) {
imageSrc = `assets/${imageSrc}`;
}
const assetPath = imageSrc.replace(/^assets\//, '');
imageSrc = httpClient.getPluginAssetURL(
pluginAuthor,
pluginName,
assetPath,
);
}
return (
<img
src={imageSrc}
alt={alt || ''}
className="max-w-lg h-auto my-4"
{...props}
/>
);
},
}}
>
{readme}
</ReactMarkdown>
</div>
) : (
<div className="p-6 text-sm text-gray-500 dark:text-gray-400">
{t('plugins.noReadme')}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,401 @@
/* GitHub-style Markdown CSS */
.markdown-body {
-ms-text-size-adjust: 100%;
-webkit-text-size-adjust: 100%;
color: var(--color-fg-default);
background-color: transparent;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Noto Sans',
Helvetica, Arial, sans-serif;
font-size: 16px;
line-height: 1.5;
word-wrap: break-word;
}
/* Hide light theme highlight.js styles in dark mode */
.dark .markdown-body .hljs {
background: transparent !important;
}
/* Ensure code blocks have proper styling */
.markdown-body pre code.hljs {
background: transparent;
}
.markdown-body .octicon {
display: inline-block;
fill: currentColor;
vertical-align: text-bottom;
}
.markdown-body h1,
.markdown-body h2,
.markdown-body h3,
.markdown-body h4,
.markdown-body h5,
.markdown-body h6 {
margin-top: 24px;
margin-bottom: 16px;
font-weight: 600;
line-height: 1.25;
}
.markdown-body h1 {
font-size: 2em;
padding-bottom: 0.3em;
border-bottom: 1px solid var(--color-border-muted);
}
.markdown-body h2 {
font-size: 1.5em;
padding-bottom: 0.3em;
border-bottom: 1px solid var(--color-border-muted);
}
.markdown-body h3 {
font-size: 1.25em;
}
.markdown-body h4 {
font-size: 1em;
}
.markdown-body h5 {
font-size: 0.875em;
}
.markdown-body h6 {
font-size: 0.85em;
color: var(--color-fg-muted);
}
.markdown-body p {
margin-top: 0;
margin-bottom: 16px;
}
.markdown-body blockquote {
margin: 0 0 16px 0;
padding: 0 1em;
color: var(--color-fg-muted);
border-left: 0.25em solid var(--color-border-default);
}
.markdown-body ul,
.markdown-body ol {
margin-top: 0;
margin-bottom: 16px;
padding-left: 2em;
}
.markdown-body ul {
list-style-type: disc;
}
.markdown-body ol {
list-style-type: decimal;
}
.markdown-body li {
margin-top: 0.25em;
}
.markdown-body li + li {
margin-top: 0.25em;
}
.markdown-body li > p {
margin-top: 16px;
margin-bottom: 16px;
}
.markdown-body li > p:first-child {
margin-top: 0;
}
.markdown-body li > p:last-child {
margin-bottom: 0;
}
/* Nested lists */
.markdown-body ul ul,
.markdown-body ul ol,
.markdown-body ol ol,
.markdown-body ol ul {
margin-top: 0.25em;
margin-bottom: 0;
}
.markdown-body ul ul {
list-style-type: circle;
}
.markdown-body ul ul ul {
list-style-type: square;
}
.markdown-body code {
padding: 0.2em 0.4em;
margin: 0;
font-size: 85%;
background-color: var(--color-neutral-muted);
border-radius: 6px;
font-family: ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Consolas,
'Liberation Mono', monospace;
}
.markdown-body pre {
margin-top: 0;
margin-bottom: 16px;
padding: 16px;
overflow: auto;
font-size: 85%;
line-height: 1.45;
background-color: var(--color-canvas-subtle);
border-radius: 6px;
}
.markdown-body pre code {
display: inline;
padding: 0;
margin: 0;
overflow: visible;
line-height: inherit;
word-wrap: normal;
background-color: transparent;
border: 0;
}
.markdown-body a {
color: var(--color-accent-fg);
text-decoration: none;
}
.markdown-body a:hover {
text-decoration: underline;
}
.markdown-body table {
border-spacing: 0;
border-collapse: collapse;
display: block;
width: max-content;
max-width: 100%;
overflow: auto;
margin-top: 0;
margin-bottom: 16px;
}
.markdown-body table tr {
background-color: transparent;
border-top: 1px solid var(--color-border-muted);
}
.markdown-body table tr:nth-child(2n) {
background-color: var(--color-canvas-subtle);
}
.markdown-body table th,
.markdown-body table td {
padding: 6px 13px;
border: 1px solid var(--color-border-default);
}
.markdown-body table th {
font-weight: 600;
background-color: var(--color-canvas-subtle);
}
.markdown-body img {
max-width: 50%;
box-sizing: content-box;
background-color: transparent;
}
.markdown-body hr {
height: 0.25em;
padding: 0;
margin: 24px 0;
background-color: var(--color-border-default);
border: 0;
}
/* Light theme colors */
.markdown-body {
--color-fg-default: #1f2328;
--color-fg-muted: #656d76;
--color-canvas-subtle: #f6f8fa;
--color-border-default: #d0d7de;
--color-border-muted: #d8dee4;
--color-neutral-muted: rgba(175, 184, 193, 0.2);
--color-accent-fg: #0969da;
}
/* Dark theme colors */
.dark .markdown-body {
--color-fg-default: #e6edf3;
--color-fg-muted: #8d96a0;
--color-canvas-subtle: #161b22;
--color-border-default: #30363d;
--color-border-muted: #21262d;
--color-neutral-muted: rgba(110, 118, 129, 0.4);
--color-accent-fg: #4493f8;
}
/* Code highlighting styles */
.markdown-body .hljs {
display: block;
overflow-x: auto;
padding: 0;
background: transparent;
color: var(--color-fg-default);
}
/* Light theme syntax highlighting */
.markdown-body .hljs-comment,
.markdown-body .hljs-quote {
color: #6a737d;
font-style: italic;
}
.markdown-body .hljs-keyword,
.markdown-body .hljs-selector-tag,
.markdown-body .hljs-subst {
color: #d73a49;
}
.markdown-body .hljs-number,
.markdown-body .hljs-literal,
.markdown-body .hljs-variable,
.markdown-body .hljs-template-variable,
.markdown-body .hljs-tag .hljs-attr {
color: #005cc5;
}
.markdown-body .hljs-string,
.markdown-body .hljs-doctag {
color: #032f62;
}
.markdown-body .hljs-title,
.markdown-body .hljs-section,
.markdown-body .hljs-selector-id {
color: #6f42c1;
font-weight: bold;
}
.markdown-body .hljs-type,
.markdown-body .hljs-class .hljs-title {
color: #6f42c1;
}
.markdown-body .hljs-tag,
.markdown-body .hljs-name,
.markdown-body .hljs-attribute {
color: #22863a;
font-weight: normal;
}
.markdown-body .hljs-regexp,
.markdown-body .hljs-link {
color: #032f62;
}
.markdown-body .hljs-symbol,
.markdown-body .hljs-bullet {
color: #e36209;
}
.markdown-body .hljs-built_in,
.markdown-body .hljs-builtin-name {
color: #005cc5;
}
.markdown-body .hljs-meta {
color: #6a737d;
}
.markdown-body .hljs-deletion {
background-color: #ffeef0;
}
.markdown-body .hljs-addition {
background-color: #e6ffed;
}
.markdown-body .hljs-emphasis {
font-style: italic;
}
.markdown-body .hljs-strong {
font-weight: bold;
}
/* Dark theme syntax highlighting */
.dark .markdown-body .hljs-comment,
.dark .markdown-body .hljs-quote {
color: #8b949e;
}
.dark .markdown-body .hljs-keyword,
.dark .markdown-body .hljs-selector-tag,
.dark .markdown-body .hljs-subst {
color: #ff7b72;
}
.dark .markdown-body .hljs-number,
.dark .markdown-body .hljs-literal,
.dark .markdown-body .hljs-variable,
.dark .markdown-body .hljs-template-variable,
.dark .markdown-body .hljs-tag .hljs-attr {
color: #79c0ff;
}
.dark .markdown-body .hljs-string,
.dark .markdown-body .hljs-doctag {
color: #a5d6ff;
}
.dark .markdown-body .hljs-title,
.dark .markdown-body .hljs-section,
.dark .markdown-body .hljs-selector-id {
color: #d2a8ff;
font-weight: bold;
}
.dark .markdown-body .hljs-type,
.dark .markdown-body .hljs-class .hljs-title {
color: #d2a8ff;
}
.dark .markdown-body .hljs-tag,
.dark .markdown-body .hljs-name,
.dark .markdown-body .hljs-attribute {
color: #7ee787;
}
.dark .markdown-body .hljs-regexp,
.dark .markdown-body .hljs-link {
color: #a5d6ff;
}
.dark .markdown-body .hljs-symbol,
.dark .markdown-body .hljs-bullet {
color: #ffa657;
}
.dark .markdown-body .hljs-built_in,
.dark .markdown-body .hljs-builtin-name {
color: #79c0ff;
}
.dark .markdown-body .hljs-meta {
color: #8b949e;
}
.dark .markdown-body .hljs-deletion {
background-color: rgba(248, 81, 73, 0.15);
}
.dark .markdown-body .hljs-addition {
background-color: rgba(46, 160, 67, 0.15);
}

View File

@@ -10,10 +10,10 @@ import {
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Search, Loader2 } from 'lucide-react';
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group';
import { Search, Loader2, Wrench, AudioWaveform, Hash } from 'lucide-react';
import PluginMarketCardComponent from './plugin-market-card/PluginMarketCardComponent';
import { PluginMarketCardVO } from './plugin-market-card/PluginMarketCardVO';
import PluginDetailDialog from './plugin-detail-dialog/PluginDetailDialog';
import { getCloudServiceClientSync } from '@/app/infra/http';
import { useTranslation } from 'react-i18next';
import { PluginV4 } from '@/app/infra/entities/plugin';
@@ -38,6 +38,7 @@ function MarketPageContent({
const searchParams = useSearchParams();
const [searchQuery, setSearchQuery] = useState('');
const [componentFilter, setComponentFilter] = useState<string>('all');
const [plugins, setPlugins] = useState<PluginMarketCardVO[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [isLoadingMore, setIsLoadingMore] = useState(false);
@@ -46,15 +47,6 @@ function MarketPageContent({
const [total, setTotal] = useState(0);
const [sortOption, setSortOption] = useState('install_count_desc');
// Plugin detail dialog state
const [selectedPluginAuthor, setSelectedPluginAuthor] = useState<
string | null
>(null);
const [selectedPluginName, setSelectedPluginName] = useState<string | null>(
null,
);
const [dialogOpen, setDialogOpen] = useState(false);
const pageSize = 16; // 每页16个4行x4列
const searchTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const scrollContainerRef = useRef<HTMLDivElement | null>(null);
@@ -111,6 +103,7 @@ function MarketPageContent({
),
githubURL: plugin.repository,
version: plugin.latest_version,
components: plugin.components,
});
}, []);
@@ -124,25 +117,20 @@ function MarketPageContent({
}
try {
let response;
const { sortBy, sortOrder } = getCurrentSort();
const filterValue =
componentFilter === 'all' ? undefined : componentFilter;
if (isSearch && searchQuery.trim()) {
response = await getCloudServiceClientSync().searchMarketplacePlugins(
searchQuery.trim(),
// Always use searchMarketplacePlugins to support component filtering
const response =
await getCloudServiceClientSync().searchMarketplacePlugins(
isSearch && searchQuery.trim() ? searchQuery.trim() : '',
page,
pageSize,
sortBy,
sortOrder,
filterValue,
);
} else {
response = await getCloudServiceClientSync().getMarketplacePlugins(
page,
pageSize,
sortBy,
sortOrder,
);
}
const data: ApiRespMarketplacePlugins = response;
const newPlugins = data.plugins.map(transformToVO);
@@ -167,7 +155,14 @@ function MarketPageContent({
setIsLoadingMore(false);
}
},
[searchQuery, pageSize, transformToVO, plugins.length, getCurrentSort],
[
searchQuery,
componentFilter,
pageSize,
transformToVO,
plugins.length,
getCurrentSort,
],
);
// 初始加载
@@ -212,38 +207,59 @@ function MarketPageContent({
// fetchPlugins will be called by useEffect when sortOption changes
}, []);
// 当排序选项变化时重新加载数据
// 组件筛选变化处理
const handleComponentFilterChange = useCallback((value: string) => {
setComponentFilter(value);
setCurrentPage(1);
setPlugins([]);
// fetchPlugins will be called by useEffect when componentFilter changes
}, []);
// 当排序选项或组件筛选变化时重新加载数据
useEffect(() => {
fetchPlugins(1, !!searchQuery.trim(), true);
}, [sortOption]);
}, [sortOption, componentFilter]);
// 处理URL参数检查是否需要打开插件详情对话框
// 处理URL参数重定向到 LangBot Space
useEffect(() => {
const author = searchParams.get('author');
const pluginName = searchParams.get('plugin');
if (author && pluginName) {
setSelectedPluginAuthor(author);
setSelectedPluginName(pluginName);
setDialogOpen(true);
const detailUrl = `https://space.langbot.app/market/${author}/${pluginName}`;
window.open(detailUrl, '_blank');
}
}, [searchParams]);
// 插件详情对话框处理函数
const handlePluginClick = useCallback(
(author: string, pluginName: string) => {
setSelectedPluginAuthor(author);
setSelectedPluginName(pluginName);
setDialogOpen(true);
},
[],
);
// 处理安装插件
const handleInstallPlugin = useCallback(
async (author: string, pluginName: string) => {
try {
// Find the full plugin object from the list
const pluginVO = plugins.find(
(p) => p.author === author && p.pluginName === pluginName,
);
if (!pluginVO) {
console.error('Plugin not found:', author, pluginName);
return;
}
const handleDialogClose = useCallback(() => {
setDialogOpen(false);
setSelectedPluginAuthor(null);
setSelectedPluginName(null);
}, []);
// Fetch full plugin details to get PluginV4 object
const response = await getCloudServiceClientSync().getPluginDetail(
author,
pluginName,
);
const pluginV4: PluginV4 = response.plugin;
// Call the install function passed from parent
installPlugin(pluginV4);
} catch (error) {
console.error('Failed to install plugin:', error);
toast.error(t('market.installFailed'));
}
},
[plugins, installPlugin, t],
);
// 清理定时器
useEffect(() => {
@@ -342,9 +358,59 @@ function MarketPageContent({
</div>
</div>
{/* Sort dropdown */}
<div className="flex items-center justify-center">
<div className="w-full max-w-2xl flex items-center gap-2 sm:gap-3">
{/* Component filter and sort */}
<div className="flex flex-col sm:flex-row items-center justify-center gap-3 sm:gap-4 px-3 sm:px-4">
{/* Component filter */}
<div className="flex flex-col sm:flex-row items-center gap-2">
<span className="text-xs sm:text-sm text-muted-foreground whitespace-nowrap">
{t('market.filterByComponent')}:
</span>
<ToggleGroup
type="single"
spacing={2}
size="sm"
value={componentFilter}
onValueChange={(value) => {
if (value) handleComponentFilterChange(value);
}}
className="justify-start"
>
<ToggleGroupItem
value="all"
aria-label="All components"
className="text-xs sm:text-sm"
>
{t('market.allComponents')}
</ToggleGroupItem>
<ToggleGroupItem
value="Tool"
aria-label="Tool"
className="text-xs sm:text-sm"
>
<Wrench className="h-4 w-4 mr-1" />
{t('plugins.componentName.Tool')}
</ToggleGroupItem>
<ToggleGroupItem
value="Command"
aria-label="Command"
className="text-xs sm:text-sm"
>
<Hash className="h-4 w-4 mr-1" />
{t('plugins.componentName.Command')}
</ToggleGroupItem>
<ToggleGroupItem
value="EventListener"
aria-label="EventListener"
className="text-xs sm:text-sm"
>
<AudioWaveform className="h-4 w-4 mr-1" />
{t('plugins.componentName.EventListener')}
</ToggleGroupItem>
</ToggleGroup>
</div>
{/* Sort dropdown */}
<div className="flex items-center gap-2 sm:gap-3">
<span className="text-xs sm:text-sm text-muted-foreground whitespace-nowrap">
{t('market.sortBy')}:
</span>
@@ -396,7 +462,7 @@ function MarketPageContent({
<PluginMarketCardComponent
key={plugin.pluginId}
cardVO={plugin}
onPluginClick={handlePluginClick}
onInstall={handleInstallPlugin}
/>
))}
</div>
@@ -413,20 +479,20 @@ function MarketPageContent({
{!hasMore && plugins.length > 0 && (
<div className="text-center text-muted-foreground py-6">
{t('market.allLoaded')}
{' · '}
<a
href="https://github.com/langbot-app/langbot-plugin-demo/issues/new?template=plugin-request.yml"
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline"
>
{t('market.requestPlugin')}
</a>
</div>
)}
</>
)}
</div>
{/* Plugin detail dialog */}
<PluginDetailDialog
open={dialogOpen}
onOpenChange={handleDialogClose}
author={selectedPluginAuthor}
pluginName={selectedPluginName}
installPlugin={installPlugin}
/>
</div>
);
}

View File

@@ -1,402 +0,0 @@
'use client';
import { useState, useEffect } from 'react';
import { Dialog, DialogContent } from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Loader2, Download, Users } from 'lucide-react';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import { useTranslation } from 'react-i18next';
import { toast } from 'sonner';
import { PluginV4 } from '@/app/infra/entities/plugin';
import { getCloudServiceClientSync } from '@/app/infra/http';
import { extractI18nObject } from '@/i18n/I18nProvider';
interface PluginDetailDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
author: string | null;
pluginName: string | null;
installPlugin: (plugin: PluginV4) => void;
}
export default function PluginDetailDialog({
open,
onOpenChange,
author,
pluginName,
installPlugin,
}: PluginDetailDialogProps) {
const { t } = useTranslation();
const [plugin, setPlugin] = useState<PluginV4 | null>(null);
const [readme, setReadme] = useState<string>('');
const [isLoading, setIsLoading] = useState(false);
const [isLoadingReadme, setIsLoadingReadme] = useState(false);
// 获取插件详情和README
useEffect(() => {
if (open && author && pluginName) {
fetchPluginData();
}
}, [open, author, pluginName]);
const fetchPluginData = async () => {
if (!author || !pluginName) return;
setIsLoading(true);
try {
// 获取插件详情
const detailResponse = await getCloudServiceClientSync().getPluginDetail(
author,
pluginName,
);
setPlugin(detailResponse.plugin);
// 获取README
setIsLoadingReadme(true);
try {
const readmeResponse =
await getCloudServiceClientSync().getPluginREADME(author, pluginName);
setReadme(readmeResponse.readme);
} catch (error) {
console.warn('Failed to load README:', error);
setReadme(t('market.noReadme'));
} finally {
setIsLoadingReadme(false);
}
} catch (error) {
console.error('Failed to fetch plugin details:', error);
toast.error(t('market.loadFailed'));
onOpenChange(false);
} finally {
setIsLoading(false);
}
};
if (!open) return null;
const PluginHeader = () => (
<div className="flex items-center gap-4 mb-6">
<img
src={getCloudServiceClientSync().getPluginIconURL(author!, pluginName!)}
alt={plugin!.name}
className="w-16 h-16 rounded-xl border bg-gray-50 object-cover flex-shrink-0"
/>
<div className="flex-1 min-w-0">
<h1 className="text-2xl font-bold text-gray-900 mb-2 dark:text-white">
{extractI18nObject(plugin!.label) || plugin!.name}
</h1>
<div className="flex items-center gap-2 text-sm text-gray-600 mb-3 dark:text-gray-400">
<Users className="w-4 h-4" />
<span>
{plugin!.author} / {plugin!.name}
</span>
</div>
<div className="flex flex-wrap items-center gap-2">
<Badge variant="outline" className="dark:bg-gray-800 dark:text-white">
v{plugin!.latest_version}
</Badge>
<Badge
variant="outline"
className="flex items-center gap-1 dark:bg-gray-800 dark:text-white"
>
<Download className="w-4 h-4" />
{plugin!.install_count.toLocaleString()} {t('market.downloads')}
</Badge>
{plugin!.repository && (
<button
onClick={(e) => {
e.stopPropagation();
window.open(plugin!.repository, '_blank');
}}
className="flex items-center gap-1 px-2 py-1 text-xs bg-gray-100 hover:bg-gray-200 rounded-md transition-colors dark:bg-gray-800 dark:text-white dark:hover:bg-gray-700 cursor-pointer"
>
<svg className="w-4 h-4" viewBox="0 0 24 24" fill="currentColor">
<path d="M12.001 2C6.47598 2 2.00098 6.475 2.00098 12C2.00098 16.425 4.86348 20.1625 8.83848 21.4875C9.33848 21.575 9.52598 21.275 9.52598 21.0125C9.52598 20.775 9.51348 19.9875 9.51348 19.15C7.00098 19.6125 6.35098 18.5375 6.15098 17.975C6.03848 17.6875 5.55098 16.8 5.12598 16.5625C4.77598 16.375 4.27598 15.9125 5.11348 15.9C5.90098 15.8875 6.46348 16.625 6.65098 16.925C7.55098 18.4375 8.98848 18.0125 9.56348 17.75C9.65098 17.1 9.91348 16.6625 10.201 16.4125C7.97598 16.1625 5.65098 15.3 5.65098 11.475C5.65098 10.3875 6.03848 9.4875 6.67598 8.7875C6.57598 8.5375 6.22598 7.5125 6.77598 6.1375C6.77598 6.1375 7.61348 5.875 9.52598 7.1625C10.326 6.9375 11.176 6.825 12.026 6.825C12.876 6.825 13.726 6.9375 14.526 7.1625C16.4385 5.8625 17.276 6.1375 17.276 6.1375C17.826 7.5125 17.476 8.5375 17.376 8.7875C18.0135 9.4875 18.401 10.375 18.401 11.475C18.401 15.3125 16.0635 16.1625 13.8385 16.4125C14.201 16.725 14.5135 17.325 14.5135 18.2625C14.5135 19.6 14.501 20.675 14.501 21.0125C14.501 21.275 14.6885 21.5875 15.1885 21.4875C19.259 20.1133 21.9999 16.2963 22.001 12C22.001 6.475 17.526 2 12.001 2Z" />
</svg>
GitHub
</button>
)}
</div>
</div>
</div>
);
const PluginDescription = () => (
<div className="mb-6">
<p className="text-gray-700 leading-relaxed text-base dark:text-gray-400">
{extractI18nObject(plugin!.description) || t('market.noDescription')}
</p>
</div>
);
const PluginOptions = () => (
<div className="space-y-4">
<Button
onClick={() => installPlugin(plugin!)}
className="w-full h-12 text-base font-medium"
>
<Download className="w-5 h-5 mr-2" />
{t('market.install')}
</Button>
</div>
);
const ReadmeContent = () => (
<div className="prose prose-sm max-w-none text-gray-800 dark:text-gray-400">
<ReactMarkdown
remarkPlugins={[remarkGfm]}
components={{
// 表格组件
table: ({ ...props }) => (
<div className="my-6 w-full overflow-x-auto rounded-lg">
<table
className="w-full border-collapse bg-white dark:bg-gray-800"
{...props}
/>
</div>
),
thead: ({ ...props }) => (
<thead
className="bg-gray-50 dark:bg-gray-900 dark:text-gray-400"
{...props}
/>
),
tbody: ({ ...props }) => (
<tbody
className="divide-y divide-gray-200 dark:divide-gray-700 dark:text-gray-400"
{...props}
/>
),
th: ({ ...props }) => (
<th
className="px-4 py-3 text-left text-sm font-semibold text-gray-900 border-r border-gray-200 last:border-r-0 dark:border-gray-700 dark:text-gray-400"
{...props}
/>
),
td: ({ ...props }) => (
<td
className="px-4 py-3 text-sm text-gray-700 border-r border-gray-200 last:border-r-0 dark:border-gray-700 dark:text-gray-400"
{...props}
/>
),
tr: ({ ...props }) => (
<tr
className="hover:bg-gray-50 transition-colors dark:hover:bg-gray-800 dark:text-gray-400"
{...props}
/>
),
// 删除线支持
del: ({ ...props }) => (
<del
className="text-gray-500 line-through dark:text-gray-400"
{...props}
/>
),
// Todo 列表支持
input: ({ type, checked, ...props }) => {
if (type === 'checkbox') {
return (
<input
type="checkbox"
checked={checked}
disabled
className="mr-2 rounded border-gray-300 text-blue-600 focus:ring-blue-500 cursor-default dark:border-gray-700"
{...props}
/>
);
}
return <input type={type} {...props} />;
},
ul: ({ ...props }) => (
<ul className="list-disc ml-5 dark:text-gray-400" {...props} />
),
ol: ({ ...props }) => (
<ol className="list-decimal ml-5 dark:text-gray-400" {...props} />
),
li: ({ ...props }) => <li className="mb-1" {...props} />,
h1: ({ ...props }) => (
<h1
className="text-3xl font-bold my-2 dark:text-gray-400"
{...props}
/>
),
h2: ({ ...props }) => (
<h2
className="text-2xl font-semibold mb-2 mt-4 dark:text-gray-400"
{...props}
/>
),
h3: ({ ...props }) => (
<h3
className="text-xl font-semibold mb-2 mt-4 dark:text-gray-400"
{...props}
/>
),
h4: ({ ...props }) => (
<h4
className="text-lg font-semibold mb-2 mt-4 dark:text-gray-400"
{...props}
/>
),
h5: ({ ...props }) => (
<h5
className="text-base font-semibold mb-2 mt-4 dark:text-gray-400"
{...props}
/>
),
h6: ({ ...props }) => (
<h6
className="text-sm font-semibold mb-2 mt-4 dark:text-gray-400"
{...props}
/>
),
p: ({ ...props }) => (
<p className="leading-relaxed dark:text-gray-400" {...props} />
),
code: ({ className, children, ...props }) => {
const match = /language-(\w+)/.exec(className || '');
const isCodeBlock = match ? true : false;
// 如果是代码块(有语言标识),由 pre 标签处理样式,淡灰色底,黑色字
if (isCodeBlock) {
return (
<code
className="bg-gray-100 text-gray-800 px-1.5 py-0.5 rounded text-sm font-mono dark:bg-gray-800 dark:text-gray-400"
{...props}
>
{children}
</code>
);
}
// 内联代码样式 - 淡灰色底
return (
<code
className="bg-gray-100 text-gray-800 px-1.5 py-0.5 rounded text-sm font-mono inline-block dark:bg-gray-800 dark:text-gray-400"
{...props}
>
{children}
</code>
);
},
pre: ({ ...props }) => (
<pre
className="bg-gray-100 text-gray-800 rounded-lg my-4 shadow-sm max-h-[500px] relative dark:bg-gray-800 dark:text-gray-400"
style={{
// 内边距确保内容不被滚动条覆盖
padding: '16px',
// 保持代码不换行以启用横向滚动
whiteSpace: 'pre',
// 滚动设置
overflowX: 'auto',
overflowY: 'auto',
// 确保滚动条在内部
boxSizing: 'border-box',
}}
{...props}
/>
),
// 图片组件 - 转换本地路径为API路径
img: ({ src, alt, ...props }) => {
// 处理图片路径
let imageSrc = src || '';
// 确保 src 是字符串类型
if (typeof imageSrc !== 'string') {
return (
<img
src={src}
alt={alt || ''}
className="max-w-full h-auto rounded-lg my-4"
{...props}
/>
);
}
// 如果是相对路径转换为API路径
if (
imageSrc &&
!imageSrc.startsWith('http://') &&
!imageSrc.startsWith('https://') &&
!imageSrc.startsWith('data:')
) {
// 移除开头的 ./ 或 / (支持多个前缀)
imageSrc = imageSrc.replace(/^(\.\/|\/)+/, '');
// 如果路径以 assets/ 开头,直接使用
// 否则假设它在 assets/ 目录下
if (!imageSrc.startsWith('assets/')) {
imageSrc = `assets/${imageSrc}`;
}
// 移除 assets/ 前缀以构建API URL
const assetPath = imageSrc.replace(/^assets\//, '');
imageSrc = getCloudServiceClientSync().getPluginAssetURL(
author!,
pluginName!,
assetPath,
);
}
return (
<img
src={imageSrc}
alt={alt || ''}
className="max-w-lg h-auto my-4"
{...props}
/>
);
},
}}
>
{readme}
</ReactMarkdown>
</div>
);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="!min-w-[50vw] max-w-none max-h-[90vh] h-[90vh] p-0">
{isLoading ? (
<div className="flex items-center justify-center py-12 h-full">
<Loader2 className="h-8 w-8 animate-spin" />
<span className="ml-2">{t('market.loading')}</span>
</div>
) : plugin ? (
<div className="flex flex-col h-full overflow-hidden">
{/* 插件信息区域 */}
<div className="flex-shrink-0 bg-white border-b m-4 pt-2 dark:bg-black">
<div className="flex gap-6 p-2 px-4">
<div className="flex-1">
<PluginHeader />
<PluginDescription />
</div>
<div className="w-40 pr-4 flex-shrink-0">
<PluginOptions />
</div>
</div>
</div>
{/* README 区域 */}
<div className="flex-1 overflow-hidden px-8">
<div className="h-full bg-white overflow-y-auto pb-2 dark:bg-black">
{isLoadingReadme ? (
<div className="flex items-center justify-center py-12">
<Loader2 className="h-8 w-8 animate-spin" />
<span className="ml-3 text-gray-600">
{t('market.loading')}
</span>
</div>
) : (
<ReadmeContent />
)}
</div>
</div>
</div>
) : null}
</DialogContent>
</Dialog>
);
}

View File

@@ -1,24 +1,58 @@
import { PluginMarketCardVO } from './PluginMarketCardVO';
import { useTranslation } from 'react-i18next';
import { Badge } from '@/components/ui/badge';
import {
Wrench,
AudioWaveform,
Hash,
Download,
ExternalLink,
} from 'lucide-react';
import { useState } from 'react';
import { Button } from '@/components/ui/button';
export default function PluginMarketCardComponent({
cardVO,
onPluginClick,
onInstall,
}: {
cardVO: PluginMarketCardVO;
onPluginClick?: (author: string, pluginName: string) => void;
onInstall?: (author: string, pluginName: string) => void;
}) {
function handleCardClick() {
if (onPluginClick) {
onPluginClick(cardVO.author, cardVO.pluginName);
const { t } = useTranslation();
const [isHovered, setIsHovered] = useState(false);
function handleInstallClick(e: React.MouseEvent) {
e.stopPropagation();
if (onInstall) {
onInstall(cardVO.author, cardVO.pluginName);
}
}
function handleViewDetailsClick(e: React.MouseEvent) {
e.stopPropagation();
const detailUrl = `https://space.langbot.app/market/${cardVO.author}/${cardVO.pluginName}`;
window.open(detailUrl, '_blank');
}
const kindIconMap: Record<string, React.ReactNode> = {
Tool: <Wrench className="w-4 h-4" />,
EventListener: <AudioWaveform className="w-4 h-4" />,
Command: <Hash className="w-4 h-4" />,
};
const componentKindNameMap: Record<string, string> = {
Tool: t('plugins.componentName.Tool'),
EventListener: t('plugins.componentName.EventListener'),
Command: t('plugins.componentName.Command'),
};
return (
<div
className="w-[100%] h-auto min-h-[8rem] sm:h-[9rem] bg-white rounded-[10px] shadow-[0px_0px_4px_0_rgba(0,0,0,0.2)] p-3 sm:p-[1rem] cursor-pointer hover:shadow-[0px_2px_8px_0_rgba(0,0,0,0.15)] transition-shadow duration-200 dark:bg-[#1f1f22]"
onClick={handleCardClick}
className="w-[100%] h-auto min-h-[8rem] sm:min-h-[9rem] bg-white rounded-[10px] shadow-[0px_0px_4px_0_rgba(0,0,0,0.2)] p-3 sm:p-[1rem] hover:shadow-[0px_3px_6px_0_rgba(0,0,0,0.12)] transition-all duration-200 hover:scale-[1.005] dark:bg-[#1f1f22] relative"
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
>
<div className="w-full h-full flex flex-col justify-between gap-2">
<div className="w-full h-full flex flex-col justify-between gap-3">
{/* 上部分:插件信息 */}
<div className="flex flex-row items-start justify-start gap-2 sm:gap-[1.2rem] min-h-0">
<img
@@ -60,25 +94,76 @@ export default function PluginMarketCardComponent({
</div>
</div>
{/* 下部分:下载量 */}
<div className="w-full flex flex-row items-center justify-start gap-[0.3rem] sm:gap-[0.4rem] px-0 sm:px-[0.4rem] flex-shrink-0">
<svg
className="w-4 h-4 sm:w-[1.2rem] sm:h-[1.2rem] text-[#2563eb] flex-shrink-0"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
<polyline points="7,10 12,15 17,10" />
<line x1="12" y1="15" x2="12" y2="3" />
</svg>
<div className="text-xs sm:text-sm text-[#2563eb] font-medium whitespace-nowrap">
{cardVO.installCount.toLocaleString()}
{/* 下部分:下载量和组件列表 */}
<div className="w-full flex flex-row items-center justify-between gap-[0.3rem] sm:gap-[0.4rem] px-0 sm:px-[0.4rem] flex-shrink-0">
<div className="flex flex-row items-center justify-start gap-[0.3rem] sm:gap-[0.4rem]">
<svg
className="w-4 h-4 sm:w-[1.2rem] sm:h-[1.2rem] text-[#2563eb] flex-shrink-0"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
<polyline points="7,10 12,15 17,10" />
<line x1="12" y1="15" x2="12" y2="3" />
</svg>
<div className="text-xs sm:text-sm text-[#2563eb] font-medium whitespace-nowrap">
{cardVO.installCount.toLocaleString()}
</div>
</div>
{/* 组件列表 */}
{cardVO.components && Object.keys(cardVO.components).length > 0 && (
<div className="flex flex-row items-center gap-1">
{Object.entries(cardVO.components).map(([kind, count]) => (
<Badge
key={kind}
variant="outline"
className="flex items-center gap-1"
>
{kindIconMap[kind]}
{/* 响应式显示组件名称:在中等屏幕以上显示 */}
<span className="hidden md:inline">
{componentKindNameMap[kind]}
</span>
<span className="ml-1">{count}</span>
</Badge>
))}
</div>
)}
</div>
</div>
{/* Hover overlay with action buttons */}
<div
className={`absolute inset-0 bg-gray-100/55 dark:bg-black/35 rounded-[10px] flex items-center justify-center gap-3 transition-all duration-200 ${
isHovered ? 'opacity-100' : 'opacity-0 pointer-events-none'
}`}
>
<Button
onClick={handleInstallClick}
className={`bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-lg shadow-sm flex items-center gap-2 transition-all duration-200 ${
isHovered ? 'translate-y-0 opacity-100' : 'translate-y-1 opacity-0'
}`}
style={{ transitionDelay: isHovered ? '10ms' : '0ms' }}
>
<Download className="w-4 h-4" />
{t('market.install')}
</Button>
<Button
onClick={handleViewDetailsClick}
variant="outline"
className={`bg-white hover:bg-gray-100 text-gray-900 dark:bg-white dark:hover:bg-gray-100 dark:text-gray-900 px-4 py-2 rounded-lg shadow-sm flex items-center gap-2 transition-all duration-200 ${
isHovered ? 'translate-y-0 opacity-100' : 'translate-y-1 opacity-0'
}`}
style={{ transitionDelay: isHovered ? '20ms' : '0ms' }}
>
<ExternalLink className="w-4 h-4" />
{t('market.viewDetails')}
</Button>
</div>
</div>
);
}

View File

@@ -8,6 +8,7 @@ export interface IPluginMarketCardVO {
iconURL: string;
githubURL: string;
version: string;
components?: Record<string, number>;
}
export class PluginMarketCardVO implements IPluginMarketCardVO {
@@ -20,6 +21,7 @@ export class PluginMarketCardVO implements IPluginMarketCardVO {
githubURL: string;
installCount: number;
version: string;
components?: Record<string, number>;
constructor(prop: IPluginMarketCardVO) {
this.description = prop.description;
@@ -31,5 +33,6 @@ export class PluginMarketCardVO implements IPluginMarketCardVO {
this.installCount = prop.installCount;
this.pluginId = prop.pluginId;
this.version = prop.version;
this.components = prop.components;
}
}

View File

@@ -40,6 +40,7 @@ export interface PluginV4 {
tags: string[];
install_count: number;
latest_version: string;
components: Record<string, number>;
status: PluginV4Status;
created_at: string;
updated_at: string;

View File

@@ -483,6 +483,27 @@ export class BackendClient extends BaseHttpClient {
return this.delete(`/api/v1/plugins/config-files/${fileKey}`);
}
public getPluginReadme(
author: string,
name: string,
language: string = 'en',
): Promise<{ readme: string }> {
return this.get(
`/api/v1/plugins/${author}/${name}/readme?language=${language}`,
);
}
public getPluginAssetURL(
author: string,
name: string,
filepath: string,
): string {
return (
this.instance.defaults.baseURL +
`/api/v1/plugins/${author}/${name}/assets/${filepath}`
);
}
public getPluginIconURL(author: string, name: string): string {
if (this.instance.defaults.baseURL === '/') {
const url = window.location.href;

View File

@@ -34,6 +34,7 @@ export class CloudServiceClient extends BaseHttpClient {
page_size: number,
sort_by?: string,
sort_order?: string,
component_filter?: string,
): Promise<ApiRespMarketplacePlugins> {
return this.post<ApiRespMarketplacePlugins>(
'/api/v1/marketplace/plugins/search',
@@ -43,6 +44,7 @@ export class CloudServiceClient extends BaseHttpClient {
page_size,
sort_by,
sort_order,
component_filter,
},
);
}
@@ -59,9 +61,11 @@ export class CloudServiceClient extends BaseHttpClient {
public getPluginREADME(
author: string,
pluginName: string,
language?: string,
): Promise<{ readme: string }> {
return this.get<{ readme: string }>(
`/api/v1/marketplace/plugins/${author}/${pluginName}/resources/README`,
language ? { language } : undefined,
);
}
@@ -78,6 +82,6 @@ export class CloudServiceClient extends BaseHttpClient {
}
public getPluginMarketplaceURL(author: string, name: string): string {
return `${this.baseURL}/market?author=${author}&plugin=${name}`;
return `https://space.langbot.app/market/${author}/${name}`;
}
}

View File

@@ -8,32 +8,40 @@ import { cn } from '@/lib/utils';
import { toggleVariants } from '@/components/ui/toggle';
const ToggleGroupContext = React.createContext<
VariantProps<typeof toggleVariants>
VariantProps<typeof toggleVariants> & {
spacing?: number;
}
>({
size: 'default',
variant: 'default',
spacing: 0,
});
function ToggleGroup({
className,
variant,
size,
spacing = 0,
children,
...props
}: React.ComponentProps<typeof ToggleGroupPrimitive.Root> &
VariantProps<typeof toggleVariants>) {
VariantProps<typeof toggleVariants> & {
spacing?: number;
}) {
return (
<ToggleGroupPrimitive.Root
data-slot="toggle-group"
data-variant={variant}
data-size={size}
data-spacing={spacing}
style={{ '--gap': spacing } as React.CSSProperties}
className={cn(
'group/toggle-group flex w-fit items-center rounded-md data-[variant=outline]:shadow-xs',
'group/toggle-group flex w-fit items-center gap-[--spacing(var(--gap))] rounded-md data-[spacing=default]:data-[variant=outline]:shadow-xs',
className,
)}
{...props}
>
<ToggleGroupContext.Provider value={{ variant, size }}>
<ToggleGroupContext.Provider value={{ variant, size, spacing }}>
{children}
</ToggleGroupContext.Provider>
</ToggleGroupPrimitive.Root>
@@ -55,12 +63,14 @@ function ToggleGroupItem({
data-slot="toggle-group-item"
data-variant={context.variant || variant}
data-size={context.size || size}
data-spacing={context.spacing}
className={cn(
toggleVariants({
variant: context.variant || variant,
size: context.size || size,
}),
'min-w-0 flex-1 shrink-0 rounded-none shadow-none first:rounded-l-md last:rounded-r-md focus:z-10 focus-visible:z-10 data-[variant=outline]:border-l-0 data-[variant=outline]:first:border-l',
'w-auto min-w-0 shrink-0 px-3 focus:z-10 focus-visible:z-10',
'data-[spacing=0]:rounded-none data-[spacing=0]:shadow-none data-[spacing=0]:first:rounded-l-md data-[spacing=0]:last:rounded-r-md data-[spacing=0]:data-[variant=outline]:border-l-0 data-[spacing=0]:data-[variant=outline]:first:border-l',
className,
)}
{...props}

View File

@@ -7,7 +7,7 @@ import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from '@/lib/utils';
const toggleVariants = cva(
"inline-flex items-center justify-center gap-2 rounded-md text-sm font-medium hover:bg-muted hover:text-muted-foreground disabled:pointer-events-none disabled:opacity-50 data-[state=on]:bg-accent data-[state=on]:text-accent-foreground [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 [&_svg]:shrink-0 focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] outline-none transition-[color,box-shadow] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive whitespace-nowrap",
"inline-flex items-center justify-center gap-2 rounded-md text-sm font-medium hover:bg-muted hover:text-muted-foreground disabled:pointer-events-none disabled:opacity-50 data-[state=on]:bg-accent data-[state=on]:text-accent-foreground dark:data-[state=on]:bg-slate-700 dark:data-[state=on]:text-white [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 [&_svg]:shrink-0 focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] outline-none transition-[color,box-shadow] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive whitespace-nowrap",
{
variants: {
variant: {

View File

@@ -37,3 +37,20 @@ export const extractI18nObject = (i18nObject: I18nObject): string => {
''
);
};
// 工具函数:将 i18n 语言代码转换为 API 语言代码
// i18n 使用zh-Hans, en-US, ja-JP
// API 使用zh_Hans, en, ja_JP
export const getAPILanguageCode = (): string => {
const language = i18n.language;
// zh-Hans -> zh_Hans
if (language === 'zh-Hans') return 'zh_Hans';
// zh-Hant -> zh_Hant
if (language === 'zh-Hant') return 'zh_Hant';
// en-US -> en
if (language === 'en-US') return 'en';
// ja-JP -> ja_JP
if (language === 'ja-JP') return 'ja_JP';
// 默认返回 en
return 'en';
};

View File

@@ -280,6 +280,11 @@ const enUS = {
saveConfigSuccessDebugPlugin:
'Configuration saved successfully, please manually restart the plugin',
saveConfigError: 'Configuration save failed: ',
config: 'Configuration',
readme: 'Documentation',
viewSource: 'View Source',
loadingReadme: 'Loading documentation...',
noReadme: 'This plugin does not provide README documentation',
fileUpload: {
tooLarge: 'File size exceeds 10MB limit',
success: 'File uploaded successfully',
@@ -351,6 +356,10 @@ const enUS = {
markAsRead: 'Mark as Read',
markAsReadSuccess: 'Marked as read',
markAsReadFailed: 'Mark as read failed',
filterByComponent: 'Component',
allComponents: 'All Components',
requestPlugin: 'Request Plugin',
viewDetails: 'View Details',
},
mcp: {
title: 'MCP',

View File

@@ -281,6 +281,11 @@ const jaJP = {
saveConfigSuccessDebugPlugin:
'設定を保存しました。手動でプラグインを再起動してください',
saveConfigError: '設定の保存に失敗しました:',
config: '設定',
readme: 'ドキュメント',
viewSource: 'ソースを表示',
loadingReadme: 'ドキュメントを読み込み中...',
noReadme: 'このプラグインはREADMEドキュメントを提供していません',
fileUpload: {
tooLarge: 'ファイルサイズが 10MB の制限を超えています',
success: 'ファイルのアップロードに成功しました',
@@ -353,6 +358,10 @@ const jaJP = {
markAsRead: '既読',
markAsReadSuccess: '既読に設定しました',
markAsReadFailed: '既読に設定に失敗しました',
filterByComponent: 'コンポーネント',
allComponents: '全部コンポーネント',
requestPlugin: 'プラグインをリクエスト',
viewDetails: '詳細を表示',
},
mcp: {
title: 'MCP',

View File

@@ -266,6 +266,11 @@ const zhHans = {
saveConfigSuccessNormal: '保存配置成功',
saveConfigSuccessDebugPlugin: '保存配置成功,请手动重启插件',
saveConfigError: '保存配置失败:',
config: '配置',
readme: '文档',
viewSource: '查看来源',
loadingReadme: '正在加载文档...',
noReadme: '该插件没有提供 README 文档',
fileUpload: {
tooLarge: '文件大小超过 10MB 限制',
success: '文件上传成功',
@@ -335,6 +340,10 @@ const zhHans = {
markAsRead: '已读',
markAsReadSuccess: '已标记为已读',
markAsReadFailed: '标记为已读失败',
filterByComponent: '组件',
allComponents: '全部组件',
requestPlugin: '请求插件',
viewDetails: '查看详情',
},
mcp: {
title: 'MCP',

View File

@@ -265,6 +265,11 @@ const zhHant = {
saveConfigSuccessNormal: '儲存配置成功',
saveConfigSuccessDebugPlugin: '儲存配置成功,請手動重啟插件',
saveConfigError: '儲存配置失敗:',
config: '配置',
readme: '文件',
viewSource: '查看來源',
loadingReadme: '正在載入文件...',
noReadme: '該插件沒有提供 README 文件',
fileUpload: {
tooLarge: '檔案大小超過 10MB 限制',
success: '檔案上傳成功',
@@ -333,6 +338,10 @@ const zhHant = {
markAsRead: '已讀',
markAsReadSuccess: '已標記為已讀',
markAsReadFailed: '標記為已讀失敗',
filterByComponent: '組件',
allComponents: '全部組件',
requestPlugin: '請求插件',
viewDetails: '查看詳情',
},
mcp: {
title: 'MCP',

10
web/src/i18next.d.ts vendored Normal file
View File

@@ -0,0 +1,10 @@
import 'react-i18next';
declare module 'react-i18next' {
interface CustomTypeOptions {
defaultNS: 'translation';
resources: {
translation: typeof import('./i18n/locales/zh-Hans').default;
};
}
}