Compare commits

..

14 Commits

Author SHA1 Message Date
Junyan Qin
60b50a35f1 chore: release v4.0.8 2025-07-03 15:07:19 +08:00
Junyan Qin (Chin)
abd02f04af Feat/compshare requester (#1561)
* feat: add compshare requester

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

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

* perf: dialog style when creating bot

* perf: bot creation dialog

* feat: combine pipeline dialogs

* perf: ui

* perf: move buttons

* perf: ui layout in pipeline detail dialog

* perf: remove debug button from pipeline card

* perf: open pipeline dialog after creating

* perf: placeholder in send input

* perf: no close dialog when save done

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

* add wechatpad image

---------

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

* add face annotation

* add face_code_dict

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

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

View File

@@ -1 +0,0 @@
3.12

143
README.md
View File

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

View File

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

145
README_EN.md Normal file
View File

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

View File

@@ -115,7 +115,9 @@ LangBotはBTPanelにリストされています。BTPanelをインストール
| [Anthropic](https://www.anthropic.com/) | ✅ | |
| [xAI](https://x.ai/) | ✅ | |
| [Zhipu AI](https://open.bigmodel.cn/) | ✅ | |
| [CompShare](https://www.compshare.cn/) | ✅ | 大模型とGPUリソースプラットフォーム |
| [PPIO](https://ppinfra.com/user/register?invited_by=QJKFYD&utm_source=github_langbot) | ✅ | 大模型とGPUリソースプラットフォーム |
| [302 AI](https://share.302.ai/SuTG99) | ✅ | LLMゲートウェイ(MaaS) |
| [Google Gemini](https://aistudio.google.com/prompts/new_chat) | ✅ | |
| [Dify](https://dify.ai) | ✅ | LLMOpsプラットフォーム |
| [Ollama](https://ollama.com/) | ✅ | ローカルLLM実行プラットフォーム |

View File

@@ -1,4 +1,4 @@
from __future__ import print_function
from __future__ import annotations
import traceback
import asyncio

View File

@@ -1,5 +1,7 @@
from __future__ import annotations
import asyncio
from .. import stage, app, note
from ...utils import importutil
@@ -20,11 +22,15 @@ class ShowNotesStage(stage.BootingStage):
try:
note_inst = note_cls(ap)
if await note_inst.need_show():
async for ret in note_inst.yield_note():
if not ret:
continue
msg, level = ret
if msg:
ap.logger.log(level, msg)
async def ayield_note(note_inst: note.LaunchNote):
async for ret in note_inst.yield_note():
if not ret:
continue
msg, level = ret
if msg:
ap.logger.log(level, msg)
asyncio.create_task(ayield_note(note_inst))
except Exception:
continue

View File

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

View File

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

View File

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

View File

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

View File

@@ -22,7 +22,7 @@ class DingTalkMessageConverter(adapter.MessageConverter):
at = True
if type(msg) is platform_message.Plain:
content += msg.text
return content,at
return content, at
@staticmethod
async def target2yiri(event: DingTalkEvent, bot_name: str):
@@ -116,15 +116,6 @@ class DingTalkAdapter(adapter.MessagePlatformAdapter):
self.bot_account_id = self.config['robot_name']
self.bot = DingTalkClient(
client_id=config['client_id'],
client_secret=config['client_secret'],
robot_name=config['robot_name'],
robot_code=config['robot_code'],
markdown_card=config['markdown_card'],
logger=self.logger,
)
async def reply_message(
self,
message_source: platform_events.MessageEvent,
@@ -136,8 +127,8 @@ class DingTalkAdapter(adapter.MessagePlatformAdapter):
)
incoming_message = event.incoming_message
content,at = await DingTalkMessageConverter.yiri2target(message)
await self.bot.send_message(content, incoming_message,at)
content, at = await DingTalkMessageConverter.yiri2target(message)
await self.bot.send_message(content, incoming_message, at)
async def send_message(self, target_type: str, target_id: str, message: platform_message.MessageChain):
content = await DingTalkMessageConverter.yiri2target(message)
@@ -157,8 +148,8 @@ class DingTalkAdapter(adapter.MessagePlatformAdapter):
await self.event_converter.target2yiri(event, self.config['robot_name']),
self,
)
except Exception as e:
await self.logger.error(f"Error in dingtalk callback: {traceback.format_exc()}")
except Exception:
await self.logger.error(f'Error in dingtalk callback: {traceback.format_exc()}')
if event_type == platform_events.FriendMessage:
self.bot.on_message('FriendMessage')(on_message)
@@ -166,6 +157,15 @@ class DingTalkAdapter(adapter.MessagePlatformAdapter):
self.bot.on_message('GroupMessage')(on_message)
async def run_async(self):
config = self.config
self.bot = DingTalkClient(
client_id=config['client_id'],
client_secret=config['client_secret'],
robot_name=config['robot_name'],
robot_code=config['robot_code'],
markdown_card=config['markdown_card'],
logger=self.logger,
)
await self.bot.start()
async def kill(self) -> bool:

View File

@@ -8,6 +8,7 @@ import base64
import uuid
import os
import datetime
import io
import aiohttp
@@ -35,28 +36,88 @@ class DiscordMessageConverter(adapter.MessageConverter):
for ele in message_chain:
if isinstance(ele, platform_message.Image):
image_bytes = None
filename = f'{uuid.uuid4()}.png' # 默认文件名
if ele.base64:
image_bytes = base64.b64decode(ele.base64)
# 处理base64编码的图片
if ele.base64.startswith('data:'):
# 从data URL中提取文件类型
data_header = ele.base64.split(',')[0]
if 'jpeg' in data_header or 'jpg' in data_header:
filename = f'{uuid.uuid4()}.jpg'
elif 'gif' in data_header:
filename = f'{uuid.uuid4()}.gif'
elif 'webp' in data_header:
filename = f'{uuid.uuid4()}.webp'
# 去掉data:image/xxx;base64,前缀
base64_data = ele.base64.split(',')[1]
else:
base64_data = ele.base64
image_bytes = base64.b64decode(base64_data)
elif ele.url:
# 从URL下载图片
async with aiohttp.ClientSession() as session:
async with session.get(ele.url) as response:
image_bytes = await response.read()
# 从URL或Content-Type推断文件类型
content_type = response.headers.get('Content-Type', '')
if 'jpeg' in content_type or 'jpg' in content_type:
filename = f'{uuid.uuid4()}.jpg'
elif 'gif' in content_type:
filename = f'{uuid.uuid4()}.gif'
elif 'webp' in content_type:
filename = f'{uuid.uuid4()}.webp'
elif ele.url.lower().endswith(('.jpg', '.jpeg')):
filename = f'{uuid.uuid4()}.jpg'
elif ele.url.lower().endswith('.gif'):
filename = f'{uuid.uuid4()}.gif'
elif ele.url.lower().endswith('.webp'):
filename = f'{uuid.uuid4()}.webp'
elif ele.path:
with open(ele.path, 'rb') as f:
image_bytes = f.read()
# 从文件路径读取图片
# 确保路径没有空字节
clean_path = ele.path.replace('\x00', '')
clean_path = os.path.abspath(clean_path)
if not os.path.exists(clean_path):
continue # 跳过不存在的文件
try:
with open(clean_path, 'rb') as f:
image_bytes = f.read()
# 从文件路径获取文件名,保持原始扩展名
original_filename = os.path.basename(clean_path)
if original_filename and '.' in original_filename:
# 保持原始文件名的扩展名
ext = original_filename.split('.')[-1].lower()
filename = f'{uuid.uuid4()}.{ext}'
else:
# 如果没有扩展名,尝试从文件内容检测
if image_bytes.startswith(b'\xff\xd8\xff'):
filename = f'{uuid.uuid4()}.jpg'
elif image_bytes.startswith(b'GIF'):
filename = f'{uuid.uuid4()}.gif'
elif image_bytes.startswith(b'RIFF') and b'WEBP' in image_bytes[:20]:
filename = f'{uuid.uuid4()}.webp'
# 默认保持PNG
except Exception as e:
print(f"Error reading image file {clean_path}: {e}")
continue # 跳过读取失败的文件
image_files.append(discord.File(fp=image_bytes, filename=f'{uuid.uuid4()}.png'))
if image_bytes:
# 使用BytesIO创建文件对象避免路径问题
import io
image_files.append(discord.File(fp=io.BytesIO(image_bytes), filename=filename))
elif isinstance(ele, platform_message.Plain):
text_string += ele.text
elif isinstance(ele, platform_message.Forward):
for node in ele.node_list:
(
text_string,
image_files,
node_text,
node_images,
) = await DiscordMessageConverter.yiri2target(node.message_chain)
text_string += text_string
image_files.extend(image_files)
text_string += node_text
image_files.extend(node_images)
return text_string, image_files
@@ -199,7 +260,27 @@ class DiscordAdapter(adapter.MessagePlatformAdapter):
self.bot = MyClient(intents=intents, **args)
async def send_message(self, target_type: str, target_id: str, message: platform_message.MessageChain):
pass
msg_to_send, image_files = await self.message_converter.yiri2target(message)
try:
# 获取频道对象
channel = self.bot.get_channel(int(target_id))
if channel is None:
# 如果本地缓存中没有尝试从API获取
channel = await self.bot.fetch_channel(int(target_id))
args = {
'content': msg_to_send,
}
if len(image_files) > 0:
args['files'] = image_files
await channel.send(**args)
except Exception as e:
await self.logger.error(f"Discord send_message failed: {e}")
raise e
async def reply_message(
self,

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 466 KiB

View File

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

View File

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

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.3 KiB

View File

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

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

View File

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

View File

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

View File

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

View File

@@ -21,17 +21,19 @@
"@dnd-kit/sortable": "^10.0.0",
"@hookform/resolvers": "^5.0.1",
"@radix-ui/react-checkbox": "^1.3.1",
"@radix-ui/react-dialog": "^1.1.13",
"@radix-ui/react-dialog": "^1.1.14",
"@radix-ui/react-hover-card": "^1.1.13",
"@radix-ui/react-label": "^2.1.6",
"@radix-ui/react-popover": "^1.1.14",
"@radix-ui/react-scroll-area": "^1.2.9",
"@radix-ui/react-select": "^2.2.4",
"@radix-ui/react-slot": "^1.2.2",
"@radix-ui/react-separator": "^1.1.7",
"@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-switch": "^1.2.4",
"@radix-ui/react-tabs": "^1.1.11",
"@radix-ui/react-toggle": "^1.1.8",
"@radix-ui/react-toggle-group": "^1.1.9",
"@radix-ui/react-tooltip": "^1.2.7",
"@tailwindcss/postcss": "^4.1.5",
"axios": "^1.8.4",
"class-variance-authority": "^0.7.1",

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,25 +1,18 @@
'use client';
import { useState, useEffect } from 'react';
import CreateCardComponent from '@/app/infra/basic-component/create-card-component/CreateCardComponent';
import PipelineFormComponent from './components/pipeline-form/PipelineFormComponent';
import { httpClient } from '@/app/infra/http/HttpClient';
import { PipelineCardVO } from '@/app/home/pipelines/components/pipeline-card/PipelineCardVO';
import PipelineCard from '@/app/home/pipelines/components/pipeline-card/PipelineCard';
import { PipelineFormEntity } from '@/app/infra/entities/pipeline';
import styles from './pipelineConfig.module.css';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { toast } from 'sonner';
import { useTranslation } from 'react-i18next';
import DebugDialog from './debug-dialog/DebugDialog';
import PipelineDialog from './PipelineDetailDialog';
export default function PluginConfigPage() {
const { t } = useTranslation();
const [modalOpen, setModalOpen] = useState<boolean>(false);
const [dialogOpen, setDialogOpen] = useState<boolean>(false);
const [isEditForm, setIsEditForm] = useState(false);
const [pipelineList, setPipelineList] = useState<PipelineCardVO[]>([]);
const [selectedPipelineId, setSelectedPipelineId] = useState('');
@@ -31,11 +24,8 @@ export default function PluginConfigPage() {
safety: {},
output: {},
});
const [disableForm, setDisableForm] = useState(false);
const [selectedPipelineIsDefault, setSelectedPipelineIsDefault] =
useState(false);
const [debugDialogOpen, setDebugDialogOpen] = useState(false);
const [debugPipelineId, setDebugPipelineId] = useState('');
useEffect(() => {
getPipelines();
@@ -92,83 +82,77 @@ export default function PluginConfigPage() {
trigger: value.pipeline.config.trigger,
});
setSelectedPipelineIsDefault(value.pipeline.is_default ?? false);
setDisableForm(false);
});
}
const handleDebug = (pipelineId: string) => {
setDebugPipelineId(pipelineId);
setDebugDialogOpen(true);
const handlePipelineClick = (pipelineId: string) => {
setSelectedPipelineId(pipelineId);
setIsEditForm(true);
setDialogOpen(true);
getSelectedPipelineForm(pipelineId);
};
const handleCreateNew = () => {
setIsEditForm(false);
setSelectedPipelineId('');
setSelectedPipelineFormValue({
basic: {},
ai: {},
trigger: {},
safety: {},
output: {},
});
setSelectedPipelineIsDefault(false);
setDialogOpen(true);
};
return (
<div className={styles.configPageContainer}>
<Dialog open={modalOpen} onOpenChange={setModalOpen}>
<DialogContent className="w-[700px] max-h-[80vh] p-0 flex flex-col">
<DialogHeader className="px-6 pt-6 pb-4">
<DialogTitle>
{isEditForm
? t('pipelines.editPipeline')
: t('pipelines.createPipeline')}
</DialogTitle>
</DialogHeader>
<div className="flex-1 overflow-y-auto px-6">
<PipelineFormComponent
onNewPipelineCreated={(pipelineId) => {
setDisableForm(true);
setIsEditForm(true);
setModalOpen(true);
setSelectedPipelineId(pipelineId);
getSelectedPipelineForm(pipelineId);
}}
onFinish={() => {
getPipelines();
setModalOpen(false);
}}
isEditMode={isEditForm}
pipelineId={selectedPipelineId}
disableForm={disableForm}
initValues={selectedPipelineFormValue}
isDefaultPipeline={selectedPipelineIsDefault}
/>
</div>
</DialogContent>
</Dialog>
<PipelineDialog
open={dialogOpen}
onOpenChange={setDialogOpen}
pipelineId={selectedPipelineId || undefined}
isEditMode={isEditForm}
isDefaultPipeline={selectedPipelineIsDefault}
initValues={selectedPipelineFormValue}
onFinish={() => {
getPipelines();
}}
onNewPipelineCreated={(pipelineId) => {
getPipelines();
setSelectedPipelineId(pipelineId);
setIsEditForm(true);
setDialogOpen(true);
getSelectedPipelineForm(pipelineId);
}}
onDeletePipeline={() => {
getPipelines();
setDialogOpen(false);
}}
onCancel={() => {
setDialogOpen(false);
}}
/>
<div className={styles.pipelineListContainer}>
<CreateCardComponent
width={'100%'}
height={'10rem'}
plusSize={'90px'}
onClick={() => {
setIsEditForm(false);
setModalOpen(true);
}}
onClick={handleCreateNew}
/>
{pipelineList.map((pipeline) => {
return (
<div
key={pipeline.id}
onClick={() => {
setDisableForm(true);
setIsEditForm(true);
setModalOpen(true);
setSelectedPipelineId(pipeline.id);
getSelectedPipelineForm(pipeline.id);
}}
onClick={() => handlePipelineClick(pipeline.id)}
>
<PipelineCard cardVO={pipeline} onDebug={handleDebug} />
<PipelineCard cardVO={pipeline} />
</div>
);
})}
</div>
<DebugDialog
open={debugDialogOpen}
onOpenChange={setDebugDialogOpen}
pipelineId={debugPipelineId}
/>
</div>
);
}

View File

@@ -0,0 +1,109 @@
import * as React from 'react';
import { Slot } from '@radix-ui/react-slot';
import { ChevronRight, MoreHorizontal } from 'lucide-react';
import { cn } from '@/lib/utils';
function Breadcrumb({ ...props }: React.ComponentProps<'nav'>) {
return <nav aria-label="breadcrumb" data-slot="breadcrumb" {...props} />;
}
function BreadcrumbList({ className, ...props }: React.ComponentProps<'ol'>) {
return (
<ol
data-slot="breadcrumb-list"
className={cn(
'text-muted-foreground flex flex-wrap items-center gap-1.5 text-sm break-words sm:gap-2.5',
className,
)}
{...props}
/>
);
}
function BreadcrumbItem({ className, ...props }: React.ComponentProps<'li'>) {
return (
<li
data-slot="breadcrumb-item"
className={cn('inline-flex items-center gap-1.5', className)}
{...props}
/>
);
}
function BreadcrumbLink({
asChild,
className,
...props
}: React.ComponentProps<'a'> & {
asChild?: boolean;
}) {
const Comp = asChild ? Slot : 'a';
return (
<Comp
data-slot="breadcrumb-link"
className={cn('hover:text-foreground transition-colors', className)}
{...props}
/>
);
}
function BreadcrumbPage({ className, ...props }: React.ComponentProps<'span'>) {
return (
<span
data-slot="breadcrumb-page"
role="link"
aria-disabled="true"
aria-current="page"
className={cn('text-foreground font-normal', className)}
{...props}
/>
);
}
function BreadcrumbSeparator({
children,
className,
...props
}: React.ComponentProps<'li'>) {
return (
<li
data-slot="breadcrumb-separator"
role="presentation"
aria-hidden="true"
className={cn('[&>svg]:size-3.5', className)}
{...props}
>
{children ?? <ChevronRight />}
</li>
);
}
function BreadcrumbEllipsis({
className,
...props
}: React.ComponentProps<'span'>) {
return (
<span
data-slot="breadcrumb-ellipsis"
role="presentation"
aria-hidden="true"
className={cn('flex size-9 items-center justify-center', className)}
{...props}
>
<MoreHorizontal className="size-4" />
<span className="sr-only">More</span>
</span>
);
}
export {
Breadcrumb,
BreadcrumbList,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbPage,
BreadcrumbSeparator,
BreadcrumbEllipsis,
};

View File

@@ -0,0 +1,28 @@
'use client';
import * as React from 'react';
import * as SeparatorPrimitive from '@radix-ui/react-separator';
import { cn } from '@/lib/utils';
function Separator({
className,
orientation = 'horizontal',
decorative = true,
...props
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
return (
<SeparatorPrimitive.Root
data-slot="separator"
decorative={decorative}
orientation={orientation}
className={cn(
'bg-border shrink-0 data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px',
className,
)}
{...props}
/>
);
}
export { Separator };

View File

@@ -0,0 +1,139 @@
'use client';
import * as React from 'react';
import * as SheetPrimitive from '@radix-ui/react-dialog';
import { XIcon } from 'lucide-react';
import { cn } from '@/lib/utils';
function Sheet({ ...props }: React.ComponentProps<typeof SheetPrimitive.Root>) {
return <SheetPrimitive.Root data-slot="sheet" {...props} />;
}
function SheetTrigger({
...props
}: React.ComponentProps<typeof SheetPrimitive.Trigger>) {
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />;
}
function SheetClose({
...props
}: React.ComponentProps<typeof SheetPrimitive.Close>) {
return <SheetPrimitive.Close data-slot="sheet-close" {...props} />;
}
function SheetPortal({
...props
}: React.ComponentProps<typeof SheetPrimitive.Portal>) {
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />;
}
function SheetOverlay({
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Overlay>) {
return (
<SheetPrimitive.Overlay
data-slot="sheet-overlay"
className={cn(
'data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50',
className,
)}
{...props}
/>
);
}
function SheetContent({
className,
children,
side = 'right',
...props
}: React.ComponentProps<typeof SheetPrimitive.Content> & {
side?: 'top' | 'right' | 'bottom' | 'left';
}) {
return (
<SheetPortal>
<SheetOverlay />
<SheetPrimitive.Content
data-slot="sheet-content"
className={cn(
'bg-background data-[state=open]:animate-in data-[state=closed]:animate-out fixed z-50 flex flex-col gap-4 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500',
side === 'right' &&
'data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right inset-y-0 right-0 h-full w-3/4 border-l sm:max-w-sm',
side === 'left' &&
'data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left inset-y-0 left-0 h-full w-3/4 border-r sm:max-w-sm',
side === 'top' &&
'data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top inset-x-0 top-0 h-auto border-b',
side === 'bottom' &&
'data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom inset-x-0 bottom-0 h-auto border-t',
className,
)}
{...props}
>
{children}
<SheetPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-secondary absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none">
<XIcon className="size-4" />
<span className="sr-only">Close</span>
</SheetPrimitive.Close>
</SheetPrimitive.Content>
</SheetPortal>
);
}
function SheetHeader({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="sheet-header"
className={cn('flex flex-col gap-1.5 p-4', className)}
{...props}
/>
);
}
function SheetFooter({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="sheet-footer"
className={cn('mt-auto flex flex-col gap-2 p-4', className)}
{...props}
/>
);
}
function SheetTitle({
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Title>) {
return (
<SheetPrimitive.Title
data-slot="sheet-title"
className={cn('text-foreground font-semibold', className)}
{...props}
/>
);
}
function SheetDescription({
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Description>) {
return (
<SheetPrimitive.Description
data-slot="sheet-description"
className={cn('text-muted-foreground text-sm', className)}
{...props}
/>
);
}
export {
Sheet,
SheetTrigger,
SheetClose,
SheetContent,
SheetHeader,
SheetFooter,
SheetTitle,
SheetDescription,
};

View File

@@ -0,0 +1,726 @@
'use client';
import * as React from 'react';
import { Slot } from '@radix-ui/react-slot';
import { cva, VariantProps } from 'class-variance-authority';
import { PanelLeftIcon } from 'lucide-react';
import { useIsMobile } from '@/hooks/use-mobile';
import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Separator } from '@/components/ui/separator';
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
} from '@/components/ui/sheet';
import { Skeleton } from '@/components/ui/skeleton';
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@/components/ui/tooltip';
const SIDEBAR_COOKIE_NAME = 'sidebar_state';
const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7;
const SIDEBAR_WIDTH = '16rem';
const SIDEBAR_WIDTH_MOBILE = '18rem';
const SIDEBAR_WIDTH_ICON = '3rem';
const SIDEBAR_KEYBOARD_SHORTCUT = 'b';
type SidebarContextProps = {
state: 'expanded' | 'collapsed';
open: boolean;
setOpen: (open: boolean) => void;
openMobile: boolean;
setOpenMobile: (open: boolean) => void;
isMobile: boolean;
toggleSidebar: () => void;
};
const SidebarContext = React.createContext<SidebarContextProps | null>(null);
function useSidebar() {
const context = React.useContext(SidebarContext);
if (!context) {
throw new Error('useSidebar must be used within a SidebarProvider.');
}
return context;
}
function SidebarProvider({
defaultOpen = true,
open: openProp,
onOpenChange: setOpenProp,
className,
style,
children,
...props
}: React.ComponentProps<'div'> & {
defaultOpen?: boolean;
open?: boolean;
onOpenChange?: (open: boolean) => void;
}) {
const isMobile = useIsMobile();
const [openMobile, setOpenMobile] = React.useState(false);
// This is the internal state of the sidebar.
// We use openProp and setOpenProp for control from outside the component.
const [_open, _setOpen] = React.useState(defaultOpen);
const open = openProp ?? _open;
const setOpen = React.useCallback(
(value: boolean | ((value: boolean) => boolean)) => {
const openState = typeof value === 'function' ? value(open) : value;
if (setOpenProp) {
setOpenProp(openState);
} else {
_setOpen(openState);
}
// This sets the cookie to keep the sidebar state.
document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`;
},
[setOpenProp, open],
);
// Helper to toggle the sidebar.
const toggleSidebar = React.useCallback(() => {
return isMobile ? setOpenMobile((open) => !open) : setOpen((open) => !open);
}, [isMobile, setOpen, setOpenMobile]);
// Adds a keyboard shortcut to toggle the sidebar.
React.useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (
event.key === SIDEBAR_KEYBOARD_SHORTCUT &&
(event.metaKey || event.ctrlKey)
) {
event.preventDefault();
toggleSidebar();
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [toggleSidebar]);
// We add a state so that we can do data-state="expanded" or "collapsed".
// This makes it easier to style the sidebar with Tailwind classes.
const state = open ? 'expanded' : 'collapsed';
const contextValue = React.useMemo<SidebarContextProps>(
() => ({
state,
open,
setOpen,
isMobile,
openMobile,
setOpenMobile,
toggleSidebar,
}),
[state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar],
);
return (
<SidebarContext.Provider value={contextValue}>
<TooltipProvider delayDuration={0}>
<div
data-slot="sidebar-wrapper"
style={
{
'--sidebar-width': SIDEBAR_WIDTH,
'--sidebar-width-icon': SIDEBAR_WIDTH_ICON,
...style,
} as React.CSSProperties
}
className={cn(
'group/sidebar-wrapper has-data-[variant=inset]:bg-sidebar flex min-h-svh w-full',
className,
)}
{...props}
>
{children}
</div>
</TooltipProvider>
</SidebarContext.Provider>
);
}
function Sidebar({
side = 'left',
variant = 'sidebar',
collapsible = 'offcanvas',
className,
children,
...props
}: React.ComponentProps<'div'> & {
side?: 'left' | 'right';
variant?: 'sidebar' | 'floating' | 'inset';
collapsible?: 'offcanvas' | 'icon' | 'none';
}) {
const { isMobile, state, openMobile, setOpenMobile } = useSidebar();
if (collapsible === 'none') {
return (
<div
data-slot="sidebar"
className={cn(
'bg-sidebar text-sidebar-foreground flex h-full w-(--sidebar-width) flex-col',
className,
)}
{...props}
>
{children}
</div>
);
}
if (isMobile) {
return (
<Sheet open={openMobile} onOpenChange={setOpenMobile} {...props}>
<SheetContent
data-sidebar="sidebar"
data-slot="sidebar"
data-mobile="true"
className="bg-sidebar text-sidebar-foreground w-(--sidebar-width) p-0 [&>button]:hidden"
style={
{
'--sidebar-width': SIDEBAR_WIDTH_MOBILE,
} as React.CSSProperties
}
side={side}
>
<SheetHeader className="sr-only">
<SheetTitle>Sidebar</SheetTitle>
<SheetDescription>Displays the mobile sidebar.</SheetDescription>
</SheetHeader>
<div className="flex h-full w-full flex-col">{children}</div>
</SheetContent>
</Sheet>
);
}
return (
<div
className="group peer text-sidebar-foreground hidden md:block"
data-state={state}
data-collapsible={state === 'collapsed' ? collapsible : ''}
data-variant={variant}
data-side={side}
data-slot="sidebar"
>
{/* This is what handles the sidebar gap on desktop */}
<div
data-slot="sidebar-gap"
className={cn(
'relative w-(--sidebar-width) bg-transparent transition-[width] duration-200 ease-linear',
'group-data-[collapsible=offcanvas]:w-0',
'group-data-[side=right]:rotate-180',
variant === 'floating' || variant === 'inset'
? 'group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4)))]'
: 'group-data-[collapsible=icon]:w-(--sidebar-width-icon)',
)}
/>
<div
data-slot="sidebar-container"
className={cn(
'fixed inset-y-0 z-10 hidden h-svh w-(--sidebar-width) transition-[left,right,width] duration-200 ease-linear md:flex',
side === 'left'
? 'left-0 group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)]'
: 'right-0 group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)]',
// Adjust the padding for floating and inset variants.
variant === 'floating' || variant === 'inset'
? 'p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4))+2px)]'
: 'group-data-[collapsible=icon]:w-(--sidebar-width-icon) group-data-[side=left]:border-r group-data-[side=right]:border-l',
className,
)}
{...props}
>
<div
data-sidebar="sidebar"
data-slot="sidebar-inner"
className="bg-sidebar group-data-[variant=floating]:border-sidebar-border flex h-full w-full flex-col group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:border group-data-[variant=floating]:shadow-sm"
>
{children}
</div>
</div>
</div>
);
}
function SidebarTrigger({
className,
onClick,
...props
}: React.ComponentProps<typeof Button>) {
const { toggleSidebar } = useSidebar();
return (
<Button
data-sidebar="trigger"
data-slot="sidebar-trigger"
variant="ghost"
size="icon"
className={cn('size-7', className)}
onClick={(event) => {
onClick?.(event);
toggleSidebar();
}}
{...props}
>
<PanelLeftIcon />
<span className="sr-only">Toggle Sidebar</span>
</Button>
);
}
function SidebarRail({ className, ...props }: React.ComponentProps<'button'>) {
const { toggleSidebar } = useSidebar();
return (
<button
data-sidebar="rail"
data-slot="sidebar-rail"
aria-label="Toggle Sidebar"
tabIndex={-1}
onClick={toggleSidebar}
title="Toggle Sidebar"
className={cn(
'hover:after:bg-sidebar-border absolute inset-y-0 z-20 hidden w-4 -translate-x-1/2 transition-all ease-linear group-data-[side=left]:-right-4 group-data-[side=right]:left-0 after:absolute after:inset-y-0 after:left-1/2 after:w-[2px] sm:flex',
'in-data-[side=left]:cursor-w-resize in-data-[side=right]:cursor-e-resize',
'[[data-side=left][data-state=collapsed]_&]:cursor-e-resize [[data-side=right][data-state=collapsed]_&]:cursor-w-resize',
'hover:group-data-[collapsible=offcanvas]:bg-sidebar group-data-[collapsible=offcanvas]:translate-x-0 group-data-[collapsible=offcanvas]:after:left-full',
'[[data-side=left][data-collapsible=offcanvas]_&]:-right-2',
'[[data-side=right][data-collapsible=offcanvas]_&]:-left-2',
className,
)}
{...props}
/>
);
}
function SidebarInset({ className, ...props }: React.ComponentProps<'main'>) {
return (
<main
data-slot="sidebar-inset"
className={cn(
'bg-background relative flex w-full flex-1 flex-col',
'md:peer-data-[variant=inset]:m-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow-sm md:peer-data-[variant=inset]:peer-data-[state=collapsed]:ml-2',
className,
)}
{...props}
/>
);
}
function SidebarInput({
className,
...props
}: React.ComponentProps<typeof Input>) {
return (
<Input
data-slot="sidebar-input"
data-sidebar="input"
className={cn('bg-background h-8 w-full shadow-none', className)}
{...props}
/>
);
}
function SidebarHeader({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="sidebar-header"
data-sidebar="header"
className={cn('flex flex-col gap-2 p-2', className)}
{...props}
/>
);
}
function SidebarFooter({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="sidebar-footer"
data-sidebar="footer"
className={cn('flex flex-col gap-2 p-2', className)}
{...props}
/>
);
}
function SidebarSeparator({
className,
...props
}: React.ComponentProps<typeof Separator>) {
return (
<Separator
data-slot="sidebar-separator"
data-sidebar="separator"
className={cn('bg-sidebar-border mx-2 w-auto', className)}
{...props}
/>
);
}
function SidebarContent({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="sidebar-content"
data-sidebar="content"
className={cn(
'flex min-h-0 flex-1 flex-col gap-2 overflow-auto group-data-[collapsible=icon]:overflow-hidden',
className,
)}
{...props}
/>
);
}
function SidebarGroup({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="sidebar-group"
data-sidebar="group"
className={cn('relative flex w-full min-w-0 flex-col p-2', className)}
{...props}
/>
);
}
function SidebarGroupLabel({
className,
asChild = false,
...props
}: React.ComponentProps<'div'> & { asChild?: boolean }) {
const Comp = asChild ? Slot : 'div';
return (
<Comp
data-slot="sidebar-group-label"
data-sidebar="group-label"
className={cn(
'text-sidebar-foreground/70 ring-sidebar-ring flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium outline-hidden transition-[margin,opacity] duration-200 ease-linear focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0',
'group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0',
className,
)}
{...props}
/>
);
}
function SidebarGroupAction({
className,
asChild = false,
...props
}: React.ComponentProps<'button'> & { asChild?: boolean }) {
const Comp = asChild ? Slot : 'button';
return (
<Comp
data-slot="sidebar-group-action"
data-sidebar="group-action"
className={cn(
'text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground absolute top-3.5 right-3 flex aspect-square w-5 items-center justify-center rounded-md p-0 outline-hidden transition-transform focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0',
// Increases the hit area of the button on mobile.
'after:absolute after:-inset-2 md:after:hidden',
'group-data-[collapsible=icon]:hidden',
className,
)}
{...props}
/>
);
}
function SidebarGroupContent({
className,
...props
}: React.ComponentProps<'div'>) {
return (
<div
data-slot="sidebar-group-content"
data-sidebar="group-content"
className={cn('w-full text-sm', className)}
{...props}
/>
);
}
function SidebarMenu({ className, ...props }: React.ComponentProps<'ul'>) {
return (
<ul
data-slot="sidebar-menu"
data-sidebar="menu"
className={cn('flex w-full min-w-0 flex-col gap-1', className)}
{...props}
/>
);
}
function SidebarMenuItem({ className, ...props }: React.ComponentProps<'li'>) {
return (
<li
data-slot="sidebar-menu-item"
data-sidebar="menu-item"
className={cn('group/menu-item relative', className)}
{...props}
/>
);
}
const sidebarMenuButtonVariants = cva(
'peer/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm outline-hidden ring-sidebar-ring transition-[width,height,padding] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 group-has-data-[sidebar=menu-action]/menu-item:pr-8 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:bg-sidebar-accent data-[active=true]:font-medium data-[active=true]:text-sidebar-accent-foreground data-[state=open]:hover:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground group-data-[collapsible=icon]:size-8! group-data-[collapsible=icon]:p-2! [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0',
{
variants: {
variant: {
default: 'hover:bg-sidebar-accent hover:text-sidebar-accent-foreground',
outline:
'bg-background shadow-[0_0_0_1px_hsl(var(--sidebar-border))] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:shadow-[0_0_0_1px_hsl(var(--sidebar-accent))]',
},
size: {
default: 'h-8 text-sm',
sm: 'h-7 text-xs',
lg: 'h-12 text-sm group-data-[collapsible=icon]:p-0!',
},
},
defaultVariants: {
variant: 'default',
size: 'default',
},
},
);
function SidebarMenuButton({
asChild = false,
isActive = false,
variant = 'default',
size = 'default',
tooltip,
className,
...props
}: React.ComponentProps<'button'> & {
asChild?: boolean;
isActive?: boolean;
tooltip?: string | React.ComponentProps<typeof TooltipContent>;
} & VariantProps<typeof sidebarMenuButtonVariants>) {
const Comp = asChild ? Slot : 'button';
const { isMobile, state } = useSidebar();
const button = (
<Comp
data-slot="sidebar-menu-button"
data-sidebar="menu-button"
data-size={size}
data-active={isActive}
className={cn(sidebarMenuButtonVariants({ variant, size }), className)}
{...props}
/>
);
if (!tooltip) {
return button;
}
if (typeof tooltip === 'string') {
tooltip = {
children: tooltip,
};
}
return (
<Tooltip>
<TooltipTrigger asChild>{button}</TooltipTrigger>
<TooltipContent
side="right"
align="center"
hidden={state !== 'collapsed' || isMobile}
{...tooltip}
/>
</Tooltip>
);
}
function SidebarMenuAction({
className,
asChild = false,
showOnHover = false,
...props
}: React.ComponentProps<'button'> & {
asChild?: boolean;
showOnHover?: boolean;
}) {
const Comp = asChild ? Slot : 'button';
return (
<Comp
data-slot="sidebar-menu-action"
data-sidebar="menu-action"
className={cn(
'text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground peer-hover/menu-button:text-sidebar-accent-foreground absolute top-1.5 right-1 flex aspect-square w-5 items-center justify-center rounded-md p-0 outline-hidden transition-transform focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0',
// Increases the hit area of the button on mobile.
'after:absolute after:-inset-2 md:after:hidden',
'peer-data-[size=sm]/menu-button:top-1',
'peer-data-[size=default]/menu-button:top-1.5',
'peer-data-[size=lg]/menu-button:top-2.5',
'group-data-[collapsible=icon]:hidden',
showOnHover &&
'peer-data-[active=true]/menu-button:text-sidebar-accent-foreground group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 data-[state=open]:opacity-100 md:opacity-0',
className,
)}
{...props}
/>
);
}
function SidebarMenuBadge({
className,
...props
}: React.ComponentProps<'div'>) {
return (
<div
data-slot="sidebar-menu-badge"
data-sidebar="menu-badge"
className={cn(
'text-sidebar-foreground pointer-events-none absolute right-1 flex h-5 min-w-5 items-center justify-center rounded-md px-1 text-xs font-medium tabular-nums select-none',
'peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[active=true]/menu-button:text-sidebar-accent-foreground',
'peer-data-[size=sm]/menu-button:top-1',
'peer-data-[size=default]/menu-button:top-1.5',
'peer-data-[size=lg]/menu-button:top-2.5',
'group-data-[collapsible=icon]:hidden',
className,
)}
{...props}
/>
);
}
function SidebarMenuSkeleton({
className,
showIcon = false,
...props
}: React.ComponentProps<'div'> & {
showIcon?: boolean;
}) {
// Random width between 50 to 90%.
const width = React.useMemo(() => {
return `${Math.floor(Math.random() * 40) + 50}%`;
}, []);
return (
<div
data-slot="sidebar-menu-skeleton"
data-sidebar="menu-skeleton"
className={cn('flex h-8 items-center gap-2 rounded-md px-2', className)}
{...props}
>
{showIcon && (
<Skeleton
className="size-4 rounded-md"
data-sidebar="menu-skeleton-icon"
/>
)}
<Skeleton
className="h-4 max-w-(--skeleton-width) flex-1"
data-sidebar="menu-skeleton-text"
style={
{
'--skeleton-width': width,
} as React.CSSProperties
}
/>
</div>
);
}
function SidebarMenuSub({ className, ...props }: React.ComponentProps<'ul'>) {
return (
<ul
data-slot="sidebar-menu-sub"
data-sidebar="menu-sub"
className={cn(
'border-sidebar-border mx-3.5 flex min-w-0 translate-x-px flex-col gap-1 border-l px-2.5 py-0.5',
'group-data-[collapsible=icon]:hidden',
className,
)}
{...props}
/>
);
}
function SidebarMenuSubItem({
className,
...props
}: React.ComponentProps<'li'>) {
return (
<li
data-slot="sidebar-menu-sub-item"
data-sidebar="menu-sub-item"
className={cn('group/menu-sub-item relative', className)}
{...props}
/>
);
}
function SidebarMenuSubButton({
asChild = false,
size = 'md',
isActive = false,
className,
...props
}: React.ComponentProps<'a'> & {
asChild?: boolean;
size?: 'sm' | 'md';
isActive?: boolean;
}) {
const Comp = asChild ? Slot : 'a';
return (
<Comp
data-slot="sidebar-menu-sub-button"
data-sidebar="menu-sub-button"
data-size={size}
data-active={isActive}
className={cn(
'text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground active:bg-sidebar-accent active:text-sidebar-accent-foreground [&>svg]:text-sidebar-accent-foreground flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 outline-hidden focus-visible:ring-2 disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0',
'data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground',
size === 'sm' && 'text-xs',
size === 'md' && 'text-sm',
'group-data-[collapsible=icon]:hidden',
className,
)}
{...props}
/>
);
}
export {
Sidebar,
SidebarContent,
SidebarFooter,
SidebarGroup,
SidebarGroupAction,
SidebarGroupContent,
SidebarGroupLabel,
SidebarHeader,
SidebarInput,
SidebarInset,
SidebarMenu,
SidebarMenuAction,
SidebarMenuBadge,
SidebarMenuButton,
SidebarMenuItem,
SidebarMenuSkeleton,
SidebarMenuSub,
SidebarMenuSubButton,
SidebarMenuSubItem,
SidebarProvider,
SidebarRail,
SidebarSeparator,
SidebarTrigger,
useSidebar,
};

View File

@@ -0,0 +1,13 @@
import { cn } from '@/lib/utils';
function Skeleton({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="skeleton"
className={cn('bg-accent animate-pulse rounded-md', className)}
{...props}
/>
);
}
export { Skeleton };

View File

@@ -0,0 +1,61 @@
'use client';
import * as React from 'react';
import * as TooltipPrimitive from '@radix-ui/react-tooltip';
import { cn } from '@/lib/utils';
function TooltipProvider({
delayDuration = 0,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Provider>) {
return (
<TooltipPrimitive.Provider
data-slot="tooltip-provider"
delayDuration={delayDuration}
{...props}
/>
);
}
function Tooltip({
...props
}: React.ComponentProps<typeof TooltipPrimitive.Root>) {
return (
<TooltipProvider>
<TooltipPrimitive.Root data-slot="tooltip" {...props} />
</TooltipProvider>
);
}
function TooltipTrigger({
...props
}: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />;
}
function TooltipContent({
className,
sideOffset = 0,
children,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Content>) {
return (
<TooltipPrimitive.Portal>
<TooltipPrimitive.Content
data-slot="tooltip-content"
sideOffset={sideOffset}
className={cn(
'bg-primary text-primary-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-fit origin-(--radix-tooltip-content-transform-origin) rounded-md px-3 py-1.5 text-xs text-balance',
className,
)}
{...props}
>
{children}
<TooltipPrimitive.Arrow className="bg-primary fill-primary z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px]" />
</TooltipPrimitive.Content>
</TooltipPrimitive.Portal>
);
}
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };

View File

@@ -0,0 +1,21 @@
import * as React from 'react';
const MOBILE_BREAKPOINT = 768;
export function useIsMobile() {
const [isMobile, setIsMobile] = React.useState<boolean | undefined>(
undefined,
);
React.useEffect(() => {
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`);
const onChange = () => {
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);
};
mql.addEventListener('change', onChange);
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);
return () => mql.removeEventListener('change', onChange);
}, []);
return !!isMobile;
}

View File

@@ -129,6 +129,8 @@ const enUS = {
dateFormat: '{{month}}/{{day}}',
setBotEnableError: 'Failed to set bot enable status',
log: 'Log',
configuration: 'Configuration',
logs: 'Logs',
},
plugins: {
title: 'Plugins',
@@ -185,6 +187,8 @@ const enUS = {
createPipeline: 'Create Pipeline',
editPipeline: 'Edit Pipeline',
chat: 'Chat',
configuration: 'Configuration',
debugChat: 'Debug Chat',
getPipelineListError: 'Failed to get pipeline list: ',
daysAgo: 'days ago',
today: 'Today',
@@ -204,6 +208,8 @@ const enUS = {
deleteConfirmation:
'Are you sure you want to delete this pipeline? Bots bound to this pipeline will not work.',
defaultPipelineCannotDelete: 'Default pipeline cannot be deleted',
deleteSuccess: 'Deleted successfully',
deleteError: 'Delete failed: ',
debugDialog: {
title: 'Pipeline Chat',
selectPipeline: 'Select Pipeline',
@@ -212,7 +218,7 @@ const enUS = {
groupChat: 'Group Chat',
send: 'Send',
reset: 'Reset Conversation',
inputPlaceholder: 'Enter message...',
inputPlaceholder: 'Send {{type}} message...',
noMessages: 'No messages',
userMessage: 'User',
botMessage: 'Bot',

View File

@@ -130,6 +130,8 @@ const jaJP = {
dateFormat: '{{month}}月{{day}}日',
setBotEnableError: 'ボットの有効状態の設定に失敗しました',
log: 'ログ',
configuration: '設定',
logs: 'ログ',
},
plugins: {
title: 'プラグイン',
@@ -185,6 +187,8 @@ const jaJP = {
createPipeline: 'パイプラインを作成',
editPipeline: 'パイプラインを編集',
chat: 'チャット',
configuration: '設定',
debugChat: 'チャットデバッグ',
getPipelineListError: 'パイプラインリストの取得に失敗しました:',
daysAgo: '日前',
today: '今日',
@@ -205,6 +209,8 @@ const jaJP = {
deleteConfirmation:
'本当にこのパイプラインを削除しますか?このパイプラインに紐付けられたボットは動作しなくなります。',
defaultPipelineCannotDelete: 'デフォルトパイプラインは削除できません',
deleteSuccess: '削除に成功しました',
deleteError: '削除に失敗しました:',
debugDialog: {
title: 'パイプラインのチャット',
selectPipeline: 'パイプラインを選択',
@@ -213,7 +219,7 @@ const jaJP = {
groupChat: 'グループチャット',
send: '送信',
reset: '会話をリセット',
inputPlaceholder: 'メッセージを入力...',
inputPlaceholder: '{{type}}メッセージを送信...',
noMessages: 'メッセージがありません',
userMessage: 'ユーザー',
botMessage: 'ボット',

View File

@@ -26,7 +26,7 @@ const zhHans = {
save: '保存',
saving: '保存中...',
confirm: '确认',
confirmDelete: '删除确认',
confirmDelete: '确认删除',
deleteConfirmation: '你确定要删除这个吗?',
selectOption: '选择一个选项',
required: '必填',
@@ -127,6 +127,8 @@ const zhHans = {
dateFormat: '{{month}}月{{day}}日',
setBotEnableError: '设置机器人启用状态失败',
log: '日志',
configuration: '配置',
logs: '日志',
},
plugins: {
title: '插件管理',
@@ -180,6 +182,8 @@ const zhHans = {
createPipeline: '创建流水线',
editPipeline: '编辑流水线',
chat: '对话',
configuration: '配置',
debugChat: '对话调试',
getPipelineListError: '获取流水线列表失败:',
daysAgo: '天前',
today: '今天',
@@ -199,6 +203,8 @@ const zhHans = {
deleteConfirmation:
'你确定要删除这个流水线吗?已绑定此流水线的机器人将无法使用。',
defaultPipelineCannotDelete: '默认流水线不可删除',
deleteSuccess: '删除成功',
deleteError: '删除失败:',
debugDialog: {
title: '流水线对话',
selectPipeline: '选择流水线',
@@ -207,7 +213,7 @@ const zhHans = {
groupChat: '群聊',
send: '发送',
reset: '重置对话',
inputPlaceholder: '输入消息...',
inputPlaceholder: '发送 {{type}} 消息...',
noMessages: '暂无消息',
userMessage: '用户',
botMessage: '机器人',