mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-06-02 12:05:54 +00:00
Compare commits
47 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
60b50a35f1 | ||
|
|
abd02f04af | ||
|
|
14411a8af6 | ||
|
|
896fef8cce | ||
|
|
89c1972abe | ||
|
|
1627d04958 | ||
|
|
c959c99e45 | ||
|
|
0203faa8c1 | ||
|
|
35f76cb7ae | ||
|
|
c34232a26c | ||
|
|
b43dd95dc6 | ||
|
|
5331ba83d7 | ||
|
|
a2038b86f1 | ||
|
|
eb066f3485 | ||
|
|
bf98b82cf2 | ||
|
|
edd70b943d | ||
|
|
3cbc823085 | ||
|
|
48becf2c51 | ||
|
|
56c686cd5a | ||
|
|
208273c0dd | ||
|
|
2ff7ca3025 | ||
|
|
61a2361730 | ||
|
|
f80f997a89 | ||
|
|
18529a42c1 | ||
|
|
3e707b4b6e | ||
|
|
62f0a938a8 | ||
|
|
ad3a163d82 | ||
|
|
f5a4503610 | ||
|
|
ec012cf5ed | ||
|
|
d70eceb72c | ||
|
|
f271608114 | ||
|
|
793f0a9c10 | ||
|
|
4f2ec195fc | ||
|
|
e6bc009414 | ||
|
|
20dc8fb5ab | ||
|
|
9a71edfeb0 | ||
|
|
fe3fd664af | ||
|
|
6402755ac6 | ||
|
|
ac8fe049de | ||
|
|
955b391253 | ||
|
|
08c6672841 | ||
|
|
8917050fae | ||
|
|
21daef46f7 | ||
|
|
8ad60b5b64 | ||
|
|
7e17c96c30 | ||
|
|
f17b06767e | ||
|
|
70a29fc623 |
2
.github/ISSUE_TEMPLATE/bug-report.yml
vendored
2
.github/ISSUE_TEMPLATE/bug-report.yml
vendored
@@ -19,7 +19,7 @@ body:
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: 复现步骤
|
||||
description: 如何重现这个问题,越详细越好;提供越多信息,我们会越快解决问题。
|
||||
description: 提供越多信息,我们会越快解决问题,建议多提供配置截图;**如果你不认真填写(只一两句话概括),我们会很生气并且立即关闭 issue 或两年后才回复你**
|
||||
validations:
|
||||
required: false
|
||||
- type: textarea
|
||||
|
||||
2
.github/ISSUE_TEMPLATE/bug-report_en.yml
vendored
2
.github/ISSUE_TEMPLATE/bug-report_en.yml
vendored
@@ -19,7 +19,7 @@ body:
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Reproduction steps
|
||||
description: How to reproduce this problem, the more detailed the better; the more information you provide, the faster we will solve the problem.
|
||||
description: How to reproduce this problem, the more detailed the better; the more information you provide, the faster we will solve the problem. 【注意】请务必认真填写此部分,若不提供完整信息(如只有一两句话的概括),我们将不会回复!
|
||||
validations:
|
||||
required: false
|
||||
- type: textarea
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
3.12
|
||||
12
README.md
12
README.md
@@ -26,14 +26,12 @@
|
||||
<img src="https://img.shields.io/badge/python-3.10 ~ 3.13 -blue.svg" alt="python">
|
||||
[](https://gitcode.com/RockChinQ/LangBot)
|
||||
|
||||
[简体中文](README.md) / [English](README_EN.md) / [日本語](README_JP.md) / (PR for your language)
|
||||
简体中文 / [English](README_EN.md) / [日本語](README_JP.md) / (PR for your language)
|
||||
|
||||
</div>
|
||||
|
||||
</p>
|
||||
|
||||
> 近期 GeWeChat 项目归档,我们已经适配 WeChatPad 协议端,个微恢复正常使用,详情请查看文档。
|
||||
|
||||
## ✨ 特性
|
||||
|
||||
- 💬 大模型对话、Agent:支持多种大模型,适配群聊和私聊;具有多轮对话、工具调用、多模态能力,并深度适配 [Dify](https://dify.ai)。目前支持 QQ、QQ频道、企业微信、个人微信、飞书、Discord、Telegram 等平台。
|
||||
@@ -121,7 +119,9 @@ docker compose up -d
|
||||
| [Anthropic](https://www.anthropic.com/) | ✅ | |
|
||||
| [xAI](https://x.ai/) | ✅ | |
|
||||
| [智谱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) | ✅ | |
|
||||
| [Dify](https://dify.ai) | ✅ | LLMOps 平台 |
|
||||
| [Ollama](https://ollama.com/) | ✅ | 本地大模型运行平台 |
|
||||
@@ -154,3 +154,9 @@ docker compose up -d
|
||||
<a href="https://github.com/RockChinQ/LangBot/graphs/contributors">
|
||||
<img src="https://contrib.rocks/image?repo=RockChinQ/LangBot" />
|
||||
</a>
|
||||
|
||||
## 😎 保持更新
|
||||
|
||||
点击仓库右上角 Star 和 Watch 按钮,获取最新动态。
|
||||
|
||||

|
||||
|
||||
10
README_EN.md
10
README_EN.md
@@ -24,7 +24,7 @@
|
||||
[](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_EN.md) / [日本語](README_JP.md) / (PR for your language)
|
||||
[简体中文](README.md) / English / [日本語](README_JP.md) / (PR for your language)
|
||||
|
||||
</div>
|
||||
|
||||
@@ -116,8 +116,10 @@ Directly use the released version to run, see the [Manual Deployment](https://do
|
||||
| [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 |
|
||||
@@ -135,3 +137,9 @@ Thank you for the following [code contributors](https://github.com/RockChinQ/Lan
|
||||
<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.
|
||||
|
||||

|
||||
10
README_JP.md
10
README_JP.md
@@ -23,7 +23,7 @@
|
||||
[](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_EN.md) / [日本語](README_JP.md) / (PR for your language)
|
||||
[简体中文](README_CN.md) / [English](README.md) / [日本語](README_JP.md) / (PR for your language)
|
||||
|
||||
</div>
|
||||
|
||||
@@ -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実行プラットフォーム |
|
||||
@@ -134,3 +136,9 @@ LangBot への貢献に対して、以下の [コード貢献者](https://github
|
||||
<a href="https://github.com/RockChinQ/LangBot/graphs/contributors">
|
||||
<img src="https://contrib.rocks/image?repo=RockChinQ/LangBot" />
|
||||
</a>
|
||||
|
||||
## 😎 最新情報を入手
|
||||
|
||||
リポジトリの右上にある Star と Watch ボタンをクリックして、最新の更新を取得してください。
|
||||
|
||||

|
||||
@@ -1,4 +1,4 @@
|
||||
from v1 import client
|
||||
from v1 import client # type: ignore
|
||||
|
||||
import asyncio
|
||||
|
||||
@@ -8,19 +8,13 @@ import json
|
||||
|
||||
class TestDifyClient:
|
||||
async def test_chat_messages(self):
|
||||
cln = client.AsyncDifyServiceClient(
|
||||
api_key=os.getenv('DIFY_API_KEY'), base_url=os.getenv('DIFY_BASE_URL')
|
||||
)
|
||||
cln = client.AsyncDifyServiceClient(api_key=os.getenv('DIFY_API_KEY'), base_url=os.getenv('DIFY_BASE_URL'))
|
||||
|
||||
async for chunk in cln.chat_messages(
|
||||
inputs={}, query='调用工具查看现在几点?', user='test'
|
||||
):
|
||||
async for chunk in cln.chat_messages(inputs={}, query='调用工具查看现在几点?', user='test'):
|
||||
print(json.dumps(chunk, ensure_ascii=False, indent=4))
|
||||
|
||||
async def test_upload_file(self):
|
||||
cln = client.AsyncDifyServiceClient(
|
||||
api_key=os.getenv('DIFY_API_KEY'), base_url=os.getenv('DIFY_BASE_URL')
|
||||
)
|
||||
cln = client.AsyncDifyServiceClient(api_key=os.getenv('DIFY_API_KEY'), base_url=os.getenv('DIFY_BASE_URL'))
|
||||
|
||||
file_bytes = open('img.png', 'rb').read()
|
||||
|
||||
@@ -32,9 +26,7 @@ class TestDifyClient:
|
||||
print(json.dumps(resp, ensure_ascii=False, indent=4))
|
||||
|
||||
async def test_workflow_run(self):
|
||||
cln = client.AsyncDifyServiceClient(
|
||||
api_key=os.getenv('DIFY_API_KEY'), base_url=os.getenv('DIFY_BASE_URL')
|
||||
)
|
||||
cln = client.AsyncDifyServiceClient(api_key=os.getenv('DIFY_API_KEY'), base_url=os.getenv('DIFY_BASE_URL'))
|
||||
|
||||
# resp = await cln.workflow_run(inputs={}, user="test")
|
||||
# # print(json.dumps(resp, ensure_ascii=False, indent=4))
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import asyncio
|
||||
import dingtalk_stream
|
||||
import dingtalk_stream # type: ignore
|
||||
from dingtalk_stream import AckMessage
|
||||
|
||||
|
||||
@@ -27,9 +27,3 @@ class EchoTextHandler(dingtalk_stream.ChatbotHandler):
|
||||
await asyncio.sleep(0.1) # 异步等待,避免阻塞
|
||||
|
||||
return self.incoming_message
|
||||
|
||||
|
||||
async def get_dingtalk_client(client_id, client_secret):
|
||||
from api import DingTalkClient # 延迟导入,避免循环导入
|
||||
|
||||
return DingTalkClient(client_id, client_secret)
|
||||
|
||||
@@ -2,7 +2,7 @@ import base64
|
||||
import json
|
||||
import time
|
||||
from typing import Callable
|
||||
import dingtalk_stream
|
||||
import dingtalk_stream # type: ignore
|
||||
from .EchoHandler import EchoTextHandler
|
||||
from .dingtalkevent import DingTalkEvent
|
||||
import httpx
|
||||
@@ -49,8 +49,8 @@ class DingTalkClient:
|
||||
self.access_token = response_data.get('accessToken')
|
||||
expires_in = int(response_data.get('expireIn', 7200))
|
||||
self.access_token_expiry_time = time.time() + expires_in - 60
|
||||
except Exception as e:
|
||||
await self.logger.error("failed to get access token in dingtalk")
|
||||
except Exception:
|
||||
await self.logger.error('failed to get access token in dingtalk')
|
||||
|
||||
async def is_token_expired(self):
|
||||
"""检查token是否过期"""
|
||||
@@ -75,7 +75,7 @@ class DingTalkClient:
|
||||
result = response.json()
|
||||
download_url = result.get('downloadUrl')
|
||||
else:
|
||||
await self.logger.error(f"failed to get download url: {response.json()}")
|
||||
await self.logger.error(f'failed to get download url: {response.json()}')
|
||||
|
||||
if download_url:
|
||||
return await self.download_url_to_base64(download_url)
|
||||
@@ -86,10 +86,11 @@ class DingTalkClient:
|
||||
|
||||
if response.status_code == 200:
|
||||
file_bytes = response.content
|
||||
base64_str = base64.b64encode(file_bytes).decode('utf-8') # 返回字符串格式
|
||||
return base64_str
|
||||
mime_type = response.headers.get('Content-Type', 'application/octet-stream')
|
||||
base64_str = base64.b64encode(file_bytes).decode('utf-8')
|
||||
return f'data:{mime_type};base64,{base64_str}'
|
||||
else:
|
||||
await self.logger.error(f"failed to get files: {response.json()}")
|
||||
await self.logger.error(f'failed to get files: {response.json()}')
|
||||
|
||||
async def get_audio_url(self, download_code: str):
|
||||
if not await self.check_access_token():
|
||||
@@ -105,7 +106,7 @@ class DingTalkClient:
|
||||
if download_url:
|
||||
return await self.download_url_to_base64(download_url)
|
||||
else:
|
||||
await self.logger.error(f"failed to get audio: {response.json()}")
|
||||
await self.logger.error(f'failed to get audio: {response.json()}')
|
||||
else:
|
||||
raise Exception(f'Error: {response.status_code}, {response.text}')
|
||||
|
||||
@@ -117,12 +118,12 @@ class DingTalkClient:
|
||||
if event:
|
||||
await self._handle_message(event)
|
||||
|
||||
async def send_message(self, content: str, incoming_message,at:bool):
|
||||
async def send_message(self, content: str, incoming_message, at: bool):
|
||||
if self.markdown_card:
|
||||
if at:
|
||||
self.EchoTextHandler.reply_markdown(
|
||||
title='@'+incoming_message.sender_nick+' '+content,
|
||||
text='@'+incoming_message.sender_nick+' '+content,
|
||||
title='@' + incoming_message.sender_nick + ' ' + content,
|
||||
text='@' + incoming_message.sender_nick + ' ' + content,
|
||||
incoming_message=incoming_message,
|
||||
)
|
||||
else:
|
||||
@@ -192,9 +193,9 @@ class DingTalkClient:
|
||||
copy_message_data = message_data.copy()
|
||||
del copy_message_data['IncomingMessage']
|
||||
# print("message_data:", json.dumps(copy_message_data, indent=4, ensure_ascii=False))
|
||||
except Exception as e:
|
||||
except Exception:
|
||||
if self.logger:
|
||||
await self.logger.error(f"Error in get_message: {traceback.format_exc()}")
|
||||
await self.logger.error(f'Error in get_message: {traceback.format_exc()}')
|
||||
else:
|
||||
traceback.print_exc()
|
||||
|
||||
@@ -223,8 +224,8 @@ class DingTalkClient:
|
||||
if response.status_code == 200:
|
||||
return
|
||||
except Exception:
|
||||
await self.logger.error(f"failed to send proactive massage to person: {traceback.format_exc()}")
|
||||
raise Exception(f"failed to send proactive massage to person: {traceback.format_exc()}")
|
||||
await self.logger.error(f'failed to send proactive massage to person: {traceback.format_exc()}')
|
||||
raise Exception(f'failed to send proactive massage to person: {traceback.format_exc()}')
|
||||
|
||||
async def send_proactive_message_to_group(self, target_id: str, content: str):
|
||||
if not await self.check_access_token():
|
||||
@@ -249,8 +250,8 @@ class DingTalkClient:
|
||||
if response.status_code == 200:
|
||||
return
|
||||
except Exception:
|
||||
await self.logger.error(f"failed to send proactive massage to group: {traceback.format_exc()}")
|
||||
raise Exception(f"failed to send proactive massage to group: {traceback.format_exc()}")
|
||||
await self.logger.error(f'failed to send proactive massage to group: {traceback.format_exc()}')
|
||||
raise Exception(f'failed to send proactive massage to group: {traceback.format_exc()}')
|
||||
|
||||
async def start(self):
|
||||
"""启动 WebSocket 连接,监听消息"""
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
from typing import Dict, Any, Optional
|
||||
import dingtalk_stream
|
||||
import dingtalk_stream # type: ignore
|
||||
|
||||
|
||||
class DingTalkEvent(dict):
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# 微信公众号的加解密算法与企业微信一样,所以直接使用企业微信的加解密算法文件
|
||||
import time
|
||||
import traceback
|
||||
from ..wecom_api.WXBizMsgCrypt3 import WXBizMsgCrypt
|
||||
from libs.wecom_api.WXBizMsgCrypt3 import WXBizMsgCrypt
|
||||
import xml.etree.ElementTree as ET
|
||||
from quart import Quart, request
|
||||
import hashlib
|
||||
@@ -55,7 +55,7 @@ class OAClient:
|
||||
echostr = request.args.get('echostr', '')
|
||||
msg_signature = request.args.get('msg_signature', '')
|
||||
if msg_signature is None:
|
||||
await self.logger.error(f'msg_signature不在请求体中')
|
||||
await self.logger.error('msg_signature不在请求体中')
|
||||
raise Exception('msg_signature不在请求体中')
|
||||
|
||||
if request.method == 'GET':
|
||||
@@ -66,7 +66,7 @@ class OAClient:
|
||||
if check_signature == signature:
|
||||
return echostr # 验证成功返回echostr
|
||||
else:
|
||||
await self.logger.error(f'拒绝请求')
|
||||
await self.logger.error('拒绝请求')
|
||||
raise Exception('拒绝请求')
|
||||
elif request.method == 'POST':
|
||||
encryt_msg = await request.data
|
||||
@@ -75,9 +75,9 @@ class OAClient:
|
||||
xml_msg = xml_msg.decode('utf-8')
|
||||
|
||||
if ret != 0:
|
||||
await self.logger.error(f'消息解密失败')
|
||||
await self.logger.error('消息解密失败')
|
||||
raise Exception('消息解密失败')
|
||||
|
||||
|
||||
message_data = await self.get_message(xml_msg)
|
||||
if message_data:
|
||||
event = OAEvent.from_payload(message_data)
|
||||
@@ -214,7 +214,7 @@ class OAClientForLongerResponse:
|
||||
msg_signature = request.args.get('msg_signature', '')
|
||||
|
||||
if msg_signature is None:
|
||||
await self.logger.error(f'msg_signature不在请求体中')
|
||||
await self.logger.error('msg_signature不在请求体中')
|
||||
raise Exception('msg_signature不在请求体中')
|
||||
|
||||
if request.method == 'GET':
|
||||
@@ -229,9 +229,8 @@ class OAClientForLongerResponse:
|
||||
xml_msg = xml_msg.decode('utf-8')
|
||||
|
||||
if ret != 0:
|
||||
await self.logger.error(f'消息解密失败')
|
||||
await self.logger.error('消息解密失败')
|
||||
raise Exception('消息解密失败')
|
||||
|
||||
|
||||
# 解析 XML
|
||||
root = ET.fromstring(xml_msg)
|
||||
|
||||
8
main.py
8
main.py
@@ -1,4 +1,5 @@
|
||||
import asyncio
|
||||
import argparse
|
||||
# LangBot 终端启动入口
|
||||
# 在此层级解决依赖项检查。
|
||||
# LangBot/main.py
|
||||
@@ -16,6 +17,10 @@ asciiart = r"""
|
||||
|
||||
|
||||
async def main_entry(loop: asyncio.AbstractEventLoop):
|
||||
parser = argparse.ArgumentParser(description='LangBot')
|
||||
parser.add_argument('--skip-plugin-deps-check', action='store_true', help='跳过插件依赖项检查', default=False)
|
||||
args = parser.parse_args()
|
||||
|
||||
print(asciiart)
|
||||
|
||||
import sys
|
||||
@@ -39,7 +44,8 @@ async def main_entry(loop: asyncio.AbstractEventLoop):
|
||||
sys.exit(0)
|
||||
|
||||
# check plugin deps
|
||||
await deps.precheck_plugin_deps()
|
||||
if not args.skip_plugin_deps_check:
|
||||
await deps.precheck_plugin_deps()
|
||||
|
||||
# 检查pydantic版本,如果没有 pydantic.v1,则把 pydantic 映射为 v1
|
||||
import pydantic.version
|
||||
|
||||
@@ -2,7 +2,7 @@ from __future__ import annotations
|
||||
|
||||
import quart
|
||||
|
||||
from .. import group
|
||||
from ... import group
|
||||
|
||||
|
||||
@group.group_class('pipelines', '/api/v1/pipelines')
|
||||
79
pkg/api/http/controller/groups/pipelines/webchat.py
Normal file
79
pkg/api/http/controller/groups/pipelines/webchat.py
Normal file
@@ -0,0 +1,79 @@
|
||||
import quart
|
||||
|
||||
from ... import group
|
||||
|
||||
|
||||
@group.group_class('webchat', '/api/v1/pipelines/<pipeline_uuid>/chat')
|
||||
class WebChatDebugRouterGroup(group.RouterGroup):
|
||||
async def initialize(self) -> None:
|
||||
@self.route('/send', methods=['POST'])
|
||||
async def send_message(pipeline_uuid: str) -> str:
|
||||
"""发送调试消息到流水线"""
|
||||
try:
|
||||
data = await quart.request.get_json()
|
||||
session_type = data.get('session_type', 'person')
|
||||
message_chain_obj = data.get('message', [])
|
||||
|
||||
if not message_chain_obj:
|
||||
return self.http_status(400, -1, 'message is required')
|
||||
|
||||
if session_type not in ['person', 'group']:
|
||||
return self.http_status(400, -1, 'session_type must be person or group')
|
||||
|
||||
webchat_adapter = self.ap.platform_mgr.webchat_proxy_bot.adapter
|
||||
|
||||
if not webchat_adapter:
|
||||
return self.http_status(404, -1, 'WebChat adapter not found')
|
||||
|
||||
result = await webchat_adapter.send_webchat_message(pipeline_uuid, session_type, message_chain_obj)
|
||||
|
||||
return self.success(
|
||||
data={
|
||||
'message': result,
|
||||
}
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
return self.http_status(500, -1, f'Internal server error: {str(e)}')
|
||||
|
||||
@self.route('/messages/<session_type>', methods=['GET'])
|
||||
async def get_messages(pipeline_uuid: str, session_type: str) -> str:
|
||||
"""获取调试消息历史"""
|
||||
try:
|
||||
if session_type not in ['person', 'group']:
|
||||
return self.http_status(400, -1, 'session_type must be person or group')
|
||||
|
||||
webchat_adapter = self.ap.platform_mgr.webchat_proxy_bot.adapter
|
||||
|
||||
if not webchat_adapter:
|
||||
return self.http_status(404, -1, 'WebChat adapter not found')
|
||||
|
||||
messages = webchat_adapter.get_webchat_messages(pipeline_uuid, session_type)
|
||||
|
||||
return self.success(data={'messages': messages})
|
||||
|
||||
except Exception as e:
|
||||
return self.http_status(500, -1, f'Internal server error: {str(e)}')
|
||||
|
||||
@self.route('/reset/<session_type>', methods=['POST'])
|
||||
async def reset_session(session_type: str) -> str:
|
||||
"""重置调试会话"""
|
||||
try:
|
||||
if session_type not in ['person', 'group']:
|
||||
return self.http_status(400, -1, 'session_type must be person or group')
|
||||
|
||||
webchat_adapter = None
|
||||
for bot in self.ap.platform_mgr.bots:
|
||||
if hasattr(bot.adapter, '__class__') and bot.adapter.__class__.__name__ == 'WebChatAdapter':
|
||||
webchat_adapter = bot.adapter
|
||||
break
|
||||
|
||||
if not webchat_adapter:
|
||||
return self.http_status(404, -1, 'WebChat adapter not found')
|
||||
|
||||
webchat_adapter.reset_debug_session(session_type)
|
||||
|
||||
return self.success(data={'message': 'Session reset successfully'})
|
||||
|
||||
except Exception as e:
|
||||
return self.http_status(500, -1, f'Internal server error: {str(e)}')
|
||||
@@ -13,10 +13,12 @@ from . import groups
|
||||
from . import group
|
||||
from .groups import provider as groups_provider
|
||||
from .groups import platform as groups_platform
|
||||
from .groups import pipelines as groups_pipelines
|
||||
|
||||
importutil.import_modules_in_pkg(groups)
|
||||
importutil.import_modules_in_pkg(groups_provider)
|
||||
importutil.import_modules_in_pkg(groups_platform)
|
||||
importutil.import_modules_in_pkg(groups_pipelines)
|
||||
|
||||
|
||||
class HTTPController:
|
||||
|
||||
@@ -93,6 +93,11 @@ class BotService:
|
||||
if runtime_bot.enable:
|
||||
await runtime_bot.run()
|
||||
|
||||
# update all conversation that use this bot
|
||||
for session in self.ap.sess_mgr.session_list:
|
||||
if session.using_conversation is not None and session.using_conversation.bot_uuid == bot_uuid:
|
||||
session.using_conversation = None
|
||||
|
||||
async def delete_bot(self, bot_uuid: str) -> None:
|
||||
"""删除机器人"""
|
||||
await self.ap.platform_mgr.remove_bot(bot_uuid)
|
||||
|
||||
@@ -112,6 +112,11 @@ class PipelineService:
|
||||
await self.ap.pipeline_mgr.remove_pipeline(pipeline_uuid)
|
||||
await self.ap.pipeline_mgr.load_pipeline(pipeline)
|
||||
|
||||
# update all conversation that use this pipeline
|
||||
for session in self.ap.sess_mgr.session_list:
|
||||
if session.using_conversation is not None and session.using_conversation.pipeline_uuid == pipeline_uuid:
|
||||
session.using_conversation = None
|
||||
|
||||
async def delete_pipeline(self, pipeline_uuid: str) -> None:
|
||||
await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.delete(persistence_pipeline.LegacyPipeline).where(
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from __future__ import print_function
|
||||
from __future__ import annotations
|
||||
|
||||
import traceback
|
||||
import asyncio
|
||||
|
||||
@@ -137,6 +137,12 @@ class Conversation(pydantic.BaseModel):
|
||||
|
||||
use_funcs: typing.Optional[list[tools_entities.LLMFunction]]
|
||||
|
||||
pipeline_uuid: str
|
||||
"""流水线UUID。"""
|
||||
|
||||
bot_uuid: str
|
||||
"""机器人UUID。"""
|
||||
|
||||
uuid: typing.Optional[str] = None
|
||||
"""该对话的 uuid,在创建时不会自动生成。而是当使用 Dify API 等由外部管理对话信息的服务时,用于绑定外部的会话。具体如何使用,取决于 Runner。"""
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
0
pkg/entity/errors/__init__.py
Normal file
0
pkg/entity/errors/__init__.py
Normal file
9
pkg/entity/errors/platform.py
Normal file
9
pkg/entity/errors/platform.py
Normal 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'
|
||||
9
pkg/entity/errors/provider.py
Normal file
9
pkg/entity/errors/provider.py
Normal 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'
|
||||
@@ -66,13 +66,15 @@ class PersistenceManager:
|
||||
|
||||
# write default pipeline
|
||||
result = await self.execute_async(sqlalchemy.select(pipeline.LegacyPipeline))
|
||||
default_pipeline_uuid = None
|
||||
if result.first() is None:
|
||||
self.ap.logger.info('Creating default pipeline...')
|
||||
|
||||
pipeline_config = json.load(open('templates/default-pipeline-config.json', 'r', encoding='utf-8'))
|
||||
|
||||
default_pipeline_uuid = str(uuid.uuid4())
|
||||
pipeline_data = {
|
||||
'uuid': str(uuid.uuid4()),
|
||||
'uuid': default_pipeline_uuid,
|
||||
'for_version': self.ap.ver_mgr.get_current_version(),
|
||||
'stages': pipeline_service.default_stage_order,
|
||||
'is_default': True,
|
||||
@@ -82,6 +84,7 @@ class PersistenceManager:
|
||||
}
|
||||
|
||||
await self.execute_async(sqlalchemy.insert(pipeline.LegacyPipeline).values(pipeline_data))
|
||||
|
||||
# =================================
|
||||
|
||||
# run migrations
|
||||
|
||||
@@ -28,7 +28,12 @@ class DBMigrateCombineQuoteMsgConfig(migration.DBMigration):
|
||||
await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.update(persistence_pipeline.LegacyPipeline)
|
||||
.where(persistence_pipeline.LegacyPipeline.uuid == serialized_pipeline['uuid'])
|
||||
.values({'config': config, 'for_version': self.ap.ver_mgr.get_current_version()})
|
||||
.values(
|
||||
{
|
||||
'config': config,
|
||||
'for_version': self.ap.ver_mgr.get_current_version(),
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
async def downgrade(self):
|
||||
|
||||
49
pkg/persistence/migrations/dbm003_n8n_config.py
Normal file
49
pkg/persistence/migrations/dbm003_n8n_config.py
Normal file
@@ -0,0 +1,49 @@
|
||||
from .. import migration
|
||||
|
||||
import sqlalchemy
|
||||
|
||||
from ...entity.persistence import pipeline as persistence_pipeline
|
||||
|
||||
|
||||
@migration.migration_class(3)
|
||||
class DBMigrateN8nConfig(migration.DBMigration):
|
||||
"""N8n配置"""
|
||||
|
||||
async def upgrade(self):
|
||||
"""升级"""
|
||||
# read all pipelines
|
||||
pipelines = await self.ap.persistence_mgr.execute_async(sqlalchemy.select(persistence_pipeline.LegacyPipeline))
|
||||
|
||||
for pipeline in pipelines:
|
||||
serialized_pipeline = self.ap.persistence_mgr.serialize_model(persistence_pipeline.LegacyPipeline, pipeline)
|
||||
|
||||
config = serialized_pipeline['config']
|
||||
|
||||
if 'n8n-service-api' not in config['ai']:
|
||||
config['ai']['n8n-service-api'] = {
|
||||
'webhook-url': 'http://your-n8n-webhook-url',
|
||||
'auth-type': 'none',
|
||||
'basic-username': '',
|
||||
'basic-password': '',
|
||||
'jwt-secret': '',
|
||||
'jwt-algorithm': 'HS256',
|
||||
'header-name': '',
|
||||
'header-value': '',
|
||||
'timeout': 120,
|
||||
'output-key': 'response',
|
||||
}
|
||||
|
||||
await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.update(persistence_pipeline.LegacyPipeline)
|
||||
.where(persistence_pipeline.LegacyPipeline.uuid == serialized_pipeline['uuid'])
|
||||
.values(
|
||||
{
|
||||
'config': config,
|
||||
'for_version': self.ap.ver_mgr.get_current_version(),
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
async def downgrade(self):
|
||||
"""降级"""
|
||||
pass
|
||||
@@ -66,6 +66,8 @@ class ContentFilterStage(stage.PipelineStage):
|
||||
|
||||
if query.pipeline_config['safety']['content-filter']['scope'] == 'output-msg':
|
||||
return entities.StageProcessResult(result_type=entities.ResultType.CONTINUE, new_query=query)
|
||||
if not message.strip():
|
||||
return entities.StageProcessResult(result_type=entities.ResultType.CONTINUE, new_query=query)
|
||||
else:
|
||||
for filter in self.filter_chain:
|
||||
if filter_entities.EnableStage.PRE in filter.enable_stages:
|
||||
|
||||
@@ -51,11 +51,10 @@ class Controller:
|
||||
# find pipeline
|
||||
# Here firstly find the bot, then find the pipeline, in case the bot adapter's config is not the latest one.
|
||||
# Like aiocqhttp, once a client is connected, even the adapter was updated and restarted, the existing client connection will not be affected.
|
||||
bot = await self.ap.platform_mgr.get_bot_by_uuid(selected_query.bot_uuid)
|
||||
if bot:
|
||||
pipeline = await self.ap.pipeline_mgr.get_pipeline_by_uuid(
|
||||
bot.bot_entity.use_pipeline_uuid
|
||||
)
|
||||
pipeline_uuid = selected_query.pipeline_uuid
|
||||
|
||||
if pipeline_uuid:
|
||||
pipeline = await self.ap.pipeline_mgr.get_pipeline_by_uuid(pipeline_uuid)
|
||||
if pipeline:
|
||||
await pipeline.run(selected_query)
|
||||
|
||||
|
||||
@@ -35,6 +35,7 @@ class QueryPool:
|
||||
message_event: platform_events.MessageEvent,
|
||||
message_chain: platform_message.MessageChain,
|
||||
adapter: msadapter.MessagePlatformAdapter,
|
||||
pipeline_uuid: typing.Optional[str] = None,
|
||||
) -> entities.Query:
|
||||
async with self.condition:
|
||||
query = entities.Query(
|
||||
@@ -48,6 +49,7 @@ class QueryPool:
|
||||
resp_messages=[],
|
||||
resp_message_chain=[],
|
||||
adapter=adapter,
|
||||
pipeline_uuid=pipeline_uuid,
|
||||
)
|
||||
self.queries.append(query)
|
||||
self.query_id_counter += 1
|
||||
|
||||
@@ -45,6 +45,8 @@ class PreProcessor(stage.PipelineStage):
|
||||
query,
|
||||
session,
|
||||
query.pipeline_config['ai']['local-agent']['prompt'],
|
||||
query.pipeline_uuid,
|
||||
query.bot_uuid,
|
||||
)
|
||||
|
||||
conversation.use_llm_model = llm_model
|
||||
|
||||
@@ -5,7 +5,6 @@ import asyncio
|
||||
import traceback
|
||||
import sqlalchemy
|
||||
|
||||
|
||||
# FriendMessage, Image, MessageChain, Plain
|
||||
from . import adapter as msadapter
|
||||
|
||||
@@ -16,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 之后,插件的兼容性问题
|
||||
@@ -78,6 +79,7 @@ class RuntimeBot:
|
||||
message_event=event,
|
||||
message_chain=event.message_chain,
|
||||
adapter=adapter,
|
||||
pipeline_uuid=self.bot_entity.use_pipeline_uuid,
|
||||
)
|
||||
|
||||
async def on_group_message(
|
||||
@@ -102,6 +104,7 @@ class RuntimeBot:
|
||||
message_event=event,
|
||||
message_chain=event.message_chain,
|
||||
adapter=adapter,
|
||||
pipeline_uuid=self.bot_entity.use_pipeline_uuid,
|
||||
)
|
||||
|
||||
self.adapter.register_listener(platform_events.FriendMessage, on_friend_message)
|
||||
@@ -144,6 +147,8 @@ class PlatformManager:
|
||||
|
||||
bots: list[RuntimeBot]
|
||||
|
||||
webchat_proxy_bot: RuntimeBot
|
||||
|
||||
adapter_components: list[engine.Component]
|
||||
|
||||
adapter_dict: dict[str, type[msadapter.MessagePlatformAdapter]]
|
||||
@@ -161,6 +166,31 @@ class PlatformManager:
|
||||
adapter_dict[component.metadata.name] = component.get_python_component_class()
|
||||
self.adapter_dict = adapter_dict
|
||||
|
||||
webchat_adapter_class = self.adapter_dict['webchat']
|
||||
|
||||
# initialize webchat adapter
|
||||
webchat_logger = EventLogger(name='webchat-adapter', ap=self.ap)
|
||||
webchat_adapter_inst = webchat_adapter_class(
|
||||
{},
|
||||
self.ap,
|
||||
webchat_logger,
|
||||
)
|
||||
|
||||
self.webchat_proxy_bot = RuntimeBot(
|
||||
ap=self.ap,
|
||||
bot_entity=persistence_bot.Bot(
|
||||
uuid='webchat-proxy-bot',
|
||||
name='WebChat',
|
||||
description='',
|
||||
adapter='webchat',
|
||||
adapter_config={},
|
||||
enable=True,
|
||||
),
|
||||
adapter=webchat_adapter_inst,
|
||||
logger=webchat_logger,
|
||||
)
|
||||
await self.webchat_proxy_bot.initialize()
|
||||
|
||||
await self.load_bots_from_db()
|
||||
|
||||
def get_running_adapters(self) -> list[msadapter.MessagePlatformAdapter]:
|
||||
@@ -177,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,
|
||||
@@ -191,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,
|
||||
@@ -220,7 +258,9 @@ class PlatformManager:
|
||||
return
|
||||
|
||||
def get_available_adapters_info(self) -> list[dict]:
|
||||
return [component.to_plain_dict() for component in self.adapter_components]
|
||||
return [
|
||||
component.to_plain_dict() for component in self.adapter_components if component.metadata.name != 'webchat'
|
||||
]
|
||||
|
||||
def get_available_adapter_info_by_name(self, name: str) -> dict | None:
|
||||
for component in self.adapter_components:
|
||||
@@ -273,6 +313,8 @@ class PlatformManager:
|
||||
|
||||
async def run(self):
|
||||
# This method will only be called when the application launching
|
||||
await self.webchat_proxy_bot.run()
|
||||
|
||||
for bot in self.bots:
|
||||
if bot.enable:
|
||||
await bot.run()
|
||||
|
||||
@@ -61,6 +61,16 @@ class AiocqhttpMessageConverter(adapter.MessageConverter):
|
||||
elif type(msg) is platform_message.Forward:
|
||||
for node in msg.node_list:
|
||||
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:
|
||||
msg_list.append(aiocqhttp.MessageSegment.text(str(msg)))
|
||||
@@ -69,8 +79,45 @@ class AiocqhttpMessageConverter(adapter.MessageConverter):
|
||||
|
||||
@staticmethod
|
||||
async def target2yiri(message: str, message_id: int = -1,bot=None):
|
||||
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'])
|
||||
@@ -114,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)
|
||||
@@ -134,6 +188,30 @@ class AiocqhttpMessageConverter(adapter.MessageConverter):
|
||||
reply_msg = platform_message.Quote(message_id=msg.data["id"],sender_id=msg_datas["user_id"],origin=reply_list)
|
||||
yiri_msg_list.append(reply_msg)
|
||||
|
||||
elif msg.type == 'file':
|
||||
# file_name = msg.data['file']
|
||||
file_id = msg.data['file_id']
|
||||
file_data = await bot.get_file(file_id=file_id)
|
||||
file_name = file_data.get('file_name')
|
||||
file_path = file_data.get('file')
|
||||
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='骰子'))
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -147,6 +225,8 @@ class AiocqhttpMessageConverter(adapter.MessageConverter):
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
class AiocqhttpEventConverter(adapter.EventConverter):
|
||||
@staticmethod
|
||||
async def yiri2target(event: platform_events.MessageEvent, bot_account_id: int):
|
||||
@@ -157,6 +237,7 @@ class AiocqhttpEventConverter(adapter.EventConverter):
|
||||
yiri_chain = await AiocqhttpMessageConverter.target2yiri(event.message, event.message_id,bot)
|
||||
|
||||
|
||||
|
||||
if event.message_type == 'group':
|
||||
permission = 'MEMBER'
|
||||
|
||||
@@ -235,6 +316,7 @@ class AiocqhttpAdapter(adapter.MessagePlatformAdapter):
|
||||
aiocq_msg = (await AiocqhttpMessageConverter.yiri2target(message))[0]
|
||||
|
||||
if target_type == 'group':
|
||||
|
||||
await self.bot.send_group_msg(group_id=int(target_id), message=aiocq_msg)
|
||||
elif target_type == 'person':
|
||||
await self.bot.send_private_msg(user_id=int(target_id), message=aiocq_msg)
|
||||
@@ -270,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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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: ""
|
||||
|
||||
209
pkg/platform/sources/webchat.py
Normal file
209
pkg/platform/sources/webchat.py
Normal file
@@ -0,0 +1,209 @@
|
||||
import asyncio
|
||||
import logging
|
||||
import typing
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
from .. import adapter as msadapter
|
||||
from ..types import events as platform_events, message as platform_message, entities as platform_entities
|
||||
from ...core import app
|
||||
from ..logger import EventLogger
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class WebChatMessage(BaseModel):
|
||||
id: int
|
||||
role: str
|
||||
content: str
|
||||
message_chain: list[dict]
|
||||
timestamp: str
|
||||
|
||||
|
||||
class WebChatSession:
|
||||
id: str
|
||||
message_lists: dict[str, list[WebChatMessage]] = {}
|
||||
resp_waiters: dict[int, asyncio.Future[WebChatMessage]]
|
||||
|
||||
def __init__(self, id: str):
|
||||
self.id = id
|
||||
self.message_lists = {}
|
||||
self.resp_waiters = {}
|
||||
|
||||
def get_message_list(self, pipeline_uuid: str) -> list[WebChatMessage]:
|
||||
if pipeline_uuid not in self.message_lists:
|
||||
self.message_lists[pipeline_uuid] = []
|
||||
|
||||
return self.message_lists[pipeline_uuid]
|
||||
|
||||
|
||||
class WebChatAdapter(msadapter.MessagePlatformAdapter):
|
||||
"""WebChat调试适配器,用于流水线调试"""
|
||||
|
||||
webchat_person_session: WebChatSession
|
||||
webchat_group_session: WebChatSession
|
||||
|
||||
listeners: typing.Dict[
|
||||
typing.Type[platform_events.Event],
|
||||
typing.Callable[[platform_events.Event, msadapter.MessagePlatformAdapter], None],
|
||||
] = {}
|
||||
|
||||
def __init__(self, config: dict, ap: app.Application, logger: EventLogger):
|
||||
self.ap = ap
|
||||
self.logger = logger
|
||||
self.config = config
|
||||
|
||||
self.webchat_person_session = WebChatSession(id='webchatperson')
|
||||
self.webchat_group_session = WebChatSession(id='webchatgroup')
|
||||
|
||||
self.bot_account_id = 'webchatbot'
|
||||
|
||||
async def send_message(
|
||||
self,
|
||||
target_type: str,
|
||||
target_id: str,
|
||||
message: platform_message.MessageChain,
|
||||
) -> dict:
|
||||
"""发送消息到调试会话"""
|
||||
session_key = target_id
|
||||
|
||||
if session_key not in self.debug_messages:
|
||||
self.debug_messages[session_key] = []
|
||||
|
||||
message_data = {
|
||||
'id': len(self.debug_messages[session_key]) + 1,
|
||||
'type': 'bot',
|
||||
'content': str(message),
|
||||
'timestamp': datetime.now().isoformat(),
|
||||
'message_chain': [component.__dict__ for component in message],
|
||||
}
|
||||
|
||||
self.debug_messages[session_key].append(message_data)
|
||||
|
||||
await self.logger.info(f'Send message to {session_key}: {message}')
|
||||
|
||||
return message_data
|
||||
|
||||
async def reply_message(
|
||||
self,
|
||||
message_source: platform_events.MessageEvent,
|
||||
message: platform_message.MessageChain,
|
||||
quote_origin: bool = False,
|
||||
) -> dict:
|
||||
"""回复消息"""
|
||||
message_data = WebChatMessage(
|
||||
id=-1,
|
||||
role='assistant',
|
||||
content=str(message),
|
||||
message_chain=[component.__dict__ for component in message],
|
||||
timestamp=datetime.now().isoformat(),
|
||||
)
|
||||
|
||||
# notify waiter
|
||||
if isinstance(message_source, platform_events.FriendMessage):
|
||||
self.webchat_person_session.resp_waiters[message_source.message_chain.message_id].set_result(message_data)
|
||||
elif isinstance(message_source, platform_events.GroupMessage):
|
||||
self.webchat_group_session.resp_waiters[message_source.message_chain.message_id].set_result(message_data)
|
||||
|
||||
return message_data.model_dump()
|
||||
|
||||
def register_listener(
|
||||
self,
|
||||
event_type: typing.Type[platform_events.Event],
|
||||
func: typing.Callable[[platform_events.Event, msadapter.MessagePlatformAdapter], typing.Awaitable[None]],
|
||||
):
|
||||
"""注册事件监听器"""
|
||||
self.listeners[event_type] = func
|
||||
|
||||
def unregister_listener(
|
||||
self,
|
||||
event_type: typing.Type[platform_events.Event],
|
||||
func: typing.Callable[[platform_events.Event, msadapter.MessagePlatformAdapter], typing.Awaitable[None]],
|
||||
):
|
||||
"""取消注册事件监听器"""
|
||||
del self.listeners[event_type]
|
||||
|
||||
async def run_async(self):
|
||||
"""运行适配器"""
|
||||
await self.logger.info('WebChat调试适配器已启动')
|
||||
|
||||
try:
|
||||
while True:
|
||||
await asyncio.sleep(1)
|
||||
except asyncio.CancelledError:
|
||||
await self.logger.info('WebChat调试适配器已停止')
|
||||
raise
|
||||
|
||||
async def kill(self):
|
||||
"""停止适配器"""
|
||||
await self.logger.info('WebChat调试适配器正在停止')
|
||||
|
||||
async def send_webchat_message(
|
||||
self, pipeline_uuid: str, session_type: str, message_chain_obj: typing.List[dict]
|
||||
) -> dict:
|
||||
"""发送调试消息到流水线"""
|
||||
if session_type == 'person':
|
||||
use_session = self.webchat_person_session
|
||||
else:
|
||||
use_session = self.webchat_group_session
|
||||
|
||||
message_chain = platform_message.MessageChain.parse_obj(message_chain_obj)
|
||||
|
||||
message_id = len(use_session.get_message_list(pipeline_uuid)) + 1
|
||||
|
||||
use_session.get_message_list(pipeline_uuid).append(
|
||||
WebChatMessage(
|
||||
id=message_id,
|
||||
role='user',
|
||||
content=str(message_chain),
|
||||
message_chain=message_chain_obj,
|
||||
timestamp=datetime.now().isoformat(),
|
||||
)
|
||||
)
|
||||
|
||||
message_chain.insert(0, platform_message.Source(id=message_id, time=datetime.now().timestamp()))
|
||||
|
||||
if session_type == 'person':
|
||||
sender = platform_entities.Friend(id='webchatperson', nickname='User')
|
||||
event = platform_events.FriendMessage(
|
||||
sender=sender, message_chain=message_chain, time=datetime.now().timestamp()
|
||||
)
|
||||
else:
|
||||
group = platform_entities.Group(
|
||||
id='webchatgroup', name='Group', permission=platform_entities.Permission.Member
|
||||
)
|
||||
sender = platform_entities.GroupMember(
|
||||
id='webchatperson',
|
||||
member_name='User',
|
||||
group=group,
|
||||
permission=platform_entities.Permission.Member,
|
||||
)
|
||||
event = platform_events.GroupMessage(
|
||||
sender=sender, message_chain=message_chain, time=datetime.now().timestamp()
|
||||
)
|
||||
|
||||
self.ap.platform_mgr.webchat_proxy_bot.bot_entity.use_pipeline_uuid = pipeline_uuid
|
||||
|
||||
if event.__class__ in self.listeners:
|
||||
await self.listeners[event.__class__](event, self)
|
||||
|
||||
# set waiter
|
||||
waiter = asyncio.Future[WebChatMessage]()
|
||||
use_session.resp_waiters[message_id] = waiter
|
||||
waiter.add_done_callback(lambda future: use_session.resp_waiters.pop(message_id))
|
||||
|
||||
resp_message = await waiter
|
||||
|
||||
resp_message.id = len(use_session.get_message_list(pipeline_uuid)) + 1
|
||||
|
||||
use_session.get_message_list(pipeline_uuid).append(resp_message)
|
||||
|
||||
return resp_message.model_dump()
|
||||
|
||||
def get_webchat_messages(self, pipeline_uuid: str, session_type: str) -> list[dict]:
|
||||
"""获取调试消息历史"""
|
||||
if session_type == 'person':
|
||||
return [message.model_dump() for message in self.webchat_person_session.get_message_list(pipeline_uuid)]
|
||||
else:
|
||||
return [message.model_dump() for message in self.webchat_group_session.get_message_list(pipeline_uuid)]
|
||||
16
pkg/platform/sources/webchat.yaml
Normal file
16
pkg/platform/sources/webchat.yaml
Normal file
@@ -0,0 +1,16 @@
|
||||
apiVersion: v1
|
||||
kind: MessagePlatformAdapter
|
||||
metadata:
|
||||
name: webchat
|
||||
label:
|
||||
en_US: "WebChat Debug"
|
||||
zh_Hans: "网页聊天调试"
|
||||
description:
|
||||
en_US: "WebChat adapter for pipeline debugging"
|
||||
zh_Hans: "用于流水线调试的网页聊天适配器"
|
||||
icon: ""
|
||||
spec: {}
|
||||
execution:
|
||||
python:
|
||||
path: "webchat.py"
|
||||
attr: "WebChatAdapter"
|
||||
BIN
pkg/platform/sources/wechatpad.png
Normal file
BIN
pkg/platform/sources/wechatpad.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 466 KiB |
@@ -235,6 +235,7 @@ class WeChatPadMessageConverter(adapter.MessageConverter):
|
||||
'57': self._handler_compound_quote,
|
||||
'5': self._handler_compound_link,
|
||||
'6': self._handler_compound_file,
|
||||
'74': self._handler_compound_file,
|
||||
'33': self._handler_compound_mini_program,
|
||||
'36': self._handler_compound_mini_program,
|
||||
'2000': partial(self._handler_compound_unsupported, text="[转账消息]"),
|
||||
@@ -320,10 +321,41 @@ class WeChatPadMessageConverter(adapter.MessageConverter):
|
||||
xml_data: ET.Element
|
||||
) -> platform_message.MessageChain:
|
||||
"""处理文件消息 (data_type=6)"""
|
||||
xml_data_str = ET.tostring(xml_data, encoding='unicode')
|
||||
return platform_message.MessageChain([
|
||||
platform_message.WeChatForwardFile(xml_data=xml_data_str)
|
||||
])
|
||||
file_data = xml_data.find('.//appmsg')
|
||||
|
||||
if file_data.findtext('.//type', "") == "74":
|
||||
return None
|
||||
|
||||
else:
|
||||
xml_data_str = ET.tostring(xml_data, encoding='unicode')
|
||||
# print(xml_data_str)
|
||||
|
||||
# 提取img标签的属性
|
||||
# print(xml_data)
|
||||
file_name = file_data.find('title').text
|
||||
file_id = file_data.find('md5').text
|
||||
# file_szie = file_data.find('totallen')
|
||||
|
||||
# print(file_data)
|
||||
if file_data is not None:
|
||||
aeskey = xml_data.findtext('.//appattach/aeskey')
|
||||
cdnthumburl = xml_data.findtext('.//appattach/cdnattachurl')
|
||||
# cdnmidimgurl = img_tag.get('cdnmidimgurl')
|
||||
|
||||
# print(aeskey,cdnthumburl)
|
||||
|
||||
file_data = self.bot.cdn_download(aeskey=aeskey, file_type=5, file_url=cdnthumburl)
|
||||
|
||||
file_base64 = file_data["Data"]['FileData']
|
||||
# print(file_data)
|
||||
file_size = file_data["Data"]['TotalSize']
|
||||
|
||||
# print(file_base64)
|
||||
return platform_message.MessageChain([
|
||||
platform_message.WeChatFile(file_id=file_id, file_name=file_name, file_size=file_size,
|
||||
file_base64=file_base64),
|
||||
platform_message.WeChatForwardFile(xml_data=xml_data_str)
|
||||
])
|
||||
|
||||
async def _handler_compound_link(
|
||||
self,
|
||||
|
||||
@@ -8,6 +8,7 @@ metadata:
|
||||
description:
|
||||
en_US: WeChatPad Adapter
|
||||
zh_CN: WeChatPad 适配器
|
||||
icon: wechatpad.png
|
||||
spec:
|
||||
config:
|
||||
- name: wechatpad_url
|
||||
|
||||
@@ -800,16 +800,48 @@ class File(MessageComponent):
|
||||
|
||||
type: str = 'File'
|
||||
"""消息组件类型。"""
|
||||
id: str
|
||||
id: str = ''
|
||||
"""文件识别 ID。"""
|
||||
name: str
|
||||
"""文件名称。"""
|
||||
size: int
|
||||
size: int = 0
|
||||
"""文件大小。"""
|
||||
url: str
|
||||
"""文件路径"""
|
||||
|
||||
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]
|
||||
|
||||
# ================ 个人微信专用组件 ================
|
||||
|
||||
@@ -922,3 +954,22 @@ class WeChatForwardQuote(MessageComponent):
|
||||
|
||||
def __str__(self):
|
||||
return self.app_msg
|
||||
|
||||
|
||||
class WeChatFile(MessageComponent):
|
||||
"""文件。"""
|
||||
|
||||
type: str = 'File'
|
||||
"""消息组件类型。"""
|
||||
file_id: str = ''
|
||||
"""文件识别 ID。"""
|
||||
file_name: str = ''
|
||||
"""文件名称。"""
|
||||
file_size: int = 0
|
||||
"""文件大小。"""
|
||||
file_path: str = ''
|
||||
"""文件地址"""
|
||||
file_base64: str = ''
|
||||
"""base64"""
|
||||
def __str__(self):
|
||||
return f'[文件]{self.file_name}'
|
||||
@@ -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()
|
||||
|
||||
BIN
pkg/provider/modelmgr/requesters/302ai.png
Normal file
BIN
pkg/provider/modelmgr/requesters/302ai.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 9.3 KiB |
17
pkg/provider/modelmgr/requesters/302aichatcmpl.py
Normal file
17
pkg/provider/modelmgr/requesters/302aichatcmpl.py
Normal 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,
|
||||
}
|
||||
28
pkg/provider/modelmgr/requesters/302aichatcmpl.yaml
Normal file
28
pkg/provider/modelmgr/requesters/302aichatcmpl.yaml
Normal 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
|
||||
BIN
pkg/provider/modelmgr/requesters/compshare.png
Normal file
BIN
pkg/provider/modelmgr/requesters/compshare.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 59 KiB |
17
pkg/provider/modelmgr/requesters/compsharechatcmpl.py
Normal file
17
pkg/provider/modelmgr/requesters/compsharechatcmpl.py
Normal 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,
|
||||
}
|
||||
28
pkg/provider/modelmgr/requesters/compsharechatcmpl.yaml
Normal file
28
pkg/provider/modelmgr/requesters/compsharechatcmpl.yaml
Normal 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
|
||||
@@ -1,87 +1,14 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import typing
|
||||
import google.genai
|
||||
from google.genai import types
|
||||
|
||||
from .. import errors, requester
|
||||
from ....core import entities as core_entities
|
||||
from ... import entities as llm_entities
|
||||
from ...tools import entities as tools_entities
|
||||
from . import chatcmpl
|
||||
|
||||
|
||||
class GeminiChatCompletions(requester.LLMAPIRequester):
|
||||
class GeminiChatCompletions(chatcmpl.OpenAIChatCompletions):
|
||||
"""Google Gemini API 请求器"""
|
||||
|
||||
default_config: dict[str, typing.Any] = {
|
||||
'base_url': 'https://generativelanguage.googleapis.com',
|
||||
'base_url': 'https://generativelanguage.googleapis.com/v1beta/openai',
|
||||
'timeout': 120,
|
||||
}
|
||||
|
||||
async def initialize(self):
|
||||
"""初始化 Gemini API 客户端"""
|
||||
pass
|
||||
|
||||
async def invoke_llm(
|
||||
self,
|
||||
query: core_entities.Query,
|
||||
model: requester.RuntimeLLMModel,
|
||||
messages: typing.List[llm_entities.Message],
|
||||
funcs: typing.List[tools_entities.LLMFunction] = None,
|
||||
extra_args: dict[str, typing.Any] = {},
|
||||
) -> llm_entities.Message:
|
||||
"""调用 Gemini API 生成回复"""
|
||||
try:
|
||||
self.client = google.genai.Client(
|
||||
api_key=model.token_mgr.get_token(),
|
||||
http_options=types.HttpOptions(api_version='v1alpha'),
|
||||
)
|
||||
contents = []
|
||||
|
||||
system_content = None
|
||||
|
||||
for message in messages:
|
||||
role = message.role
|
||||
parts = []
|
||||
|
||||
if isinstance(message.content, str):
|
||||
parts.append(types.Part.from_text(text=message.content))
|
||||
elif isinstance(message.content, list):
|
||||
for content in message.content:
|
||||
if content.type == 'text':
|
||||
parts.append(types.Part.from_text(text=content.text))
|
||||
# elif content.type == 'image_url':
|
||||
# parts.append(types.Part.from_image_url(url=content.image_url))
|
||||
|
||||
if role == 'system':
|
||||
system_content = parts
|
||||
else:
|
||||
content = types.Content(role=role, parts=parts)
|
||||
contents.append(content)
|
||||
|
||||
response = self.client.models.generate_content(
|
||||
model=model.model_entity.name,
|
||||
contents=contents,
|
||||
config=types.GenerateContentConfig(
|
||||
system_instruction=system_content,
|
||||
**extra_args,
|
||||
),
|
||||
)
|
||||
|
||||
return llm_entities.Message(
|
||||
role='assistant',
|
||||
content=response.candidates[0].content.parts[0].text,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
error_message = str(e).lower()
|
||||
if 'invalid api key' in error_message:
|
||||
raise errors.RequesterError(f'无效的 API 密钥: {str(e)}')
|
||||
elif 'not found' in error_message:
|
||||
raise errors.RequesterError(f'请求路径错误或模型无效: {str(e)}')
|
||||
elif any(keyword in error_message for keyword in ['rate limit', 'quota', 'permission denied']):
|
||||
raise errors.RequesterError(f'请求过于频繁或余额不足: {str(e)}')
|
||||
elif 'timeout' in error_message:
|
||||
raise errors.RequesterError(f'请求超时: {str(e)}')
|
||||
else:
|
||||
raise errors.RequesterError(f'Gemini API 请求错误: {str(e)}')
|
||||
|
||||
@@ -14,7 +14,7 @@ spec:
|
||||
zh_Hans: 基础 URL
|
||||
type: string
|
||||
required: true
|
||||
default: "https://generativelanguage.googleapis.com"
|
||||
default: "https://generativelanguage.googleapis.com/v1beta/openai"
|
||||
- name: timeout
|
||||
label:
|
||||
en_US: Timeout
|
||||
|
||||
3
pkg/provider/modelmgr/requesters/ppio.svg
Normal file
3
pkg/provider/modelmgr/requesters/ppio.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg width="60" height="60" viewBox="0 0 60 60" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M29.7888 0.215881C13.3449 0.215881 0 13.5422 0 29.986C0 38.0916 3.24782 45.4527 8.51506 50.8223V30.0139C8.51506 24.3372 10.7299 18.9769 14.7408 14.966C18.7704 10.9365 24.112 8.74025 29.7981 8.74025H29.9749L29.7888 8.75886C41.5423 8.75886 51.0718 18.2883 51.0718 30.0326C51.0718 31.0562 50.9973 32.0613 50.8577 33.057L38.8343 20.9964C36.4333 18.5954 33.2134 17.2646 29.8074 17.2646C26.4013 17.2646 23.1907 18.5954 20.7805 20.9964C18.3609 23.4159 17.0394 26.6172 17.0394 30.0326C17.0394 33.4479 18.3702 36.6492 20.7805 39.0688C23.1814 41.4697 26.4013 42.8005 29.8074 42.8005C33.2134 42.8005 36.424 41.4697 38.8343 39.0688C41.077 36.826 42.3706 33.8946 42.5474 30.7584L49.6014 37.8403C46.4839 45.7319 38.797 51.3249 29.7981 51.3249C25.1357 51.3249 20.6874 49.8359 17.0301 47.072V56.9178C20.9014 58.7604 25.2195 59.7841 29.7794 59.7841C46.2233 59.7841 59.5682 46.4578 59.5682 30.0139C59.5868 13.5515 46.2512 0.225187 29.7981 0.225187L29.7888 0.215881Z" fill="#0062E2"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
@@ -5,6 +5,7 @@ metadata:
|
||||
label:
|
||||
en_US: ppio
|
||||
zh_Hans: 派欧云
|
||||
icon: ppio.svg
|
||||
spec:
|
||||
config:
|
||||
- name: base_url
|
||||
|
||||
159
pkg/provider/runners/n8nsvapi.py
Normal file
159
pkg/provider/runners/n8nsvapi.py
Normal file
@@ -0,0 +1,159 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import typing
|
||||
import json
|
||||
import uuid
|
||||
import aiohttp
|
||||
|
||||
from .. import runner
|
||||
from ...core import app, entities as core_entities
|
||||
from .. import entities as llm_entities
|
||||
|
||||
|
||||
class N8nAPIError(Exception):
|
||||
"""N8n API 请求失败"""
|
||||
|
||||
def __init__(self, message: str):
|
||||
self.message = message
|
||||
super().__init__(self.message)
|
||||
|
||||
|
||||
@runner.runner_class('n8n-service-api')
|
||||
class N8nServiceAPIRunner(runner.RequestRunner):
|
||||
"""N8n Service API 工作流请求器"""
|
||||
|
||||
def __init__(self, ap: app.Application, pipeline_config: dict):
|
||||
self.ap = ap
|
||||
self.pipeline_config = pipeline_config
|
||||
|
||||
# 获取webhook URL
|
||||
self.webhook_url = self.pipeline_config['ai']['n8n-service-api']['webhook-url']
|
||||
|
||||
# 获取超时设置,默认为120秒
|
||||
self.timeout = self.pipeline_config['ai']['n8n-service-api'].get('timeout', 120)
|
||||
|
||||
# 获取输出键名,默认为response
|
||||
self.output_key = self.pipeline_config['ai']['n8n-service-api'].get('output-key', 'response')
|
||||
|
||||
# 获取认证类型,默认为none
|
||||
self.auth_type = self.pipeline_config['ai']['n8n-service-api'].get('auth-type', 'none')
|
||||
|
||||
# 根据认证类型获取相应的认证信息
|
||||
if self.auth_type == 'basic':
|
||||
self.basic_username = self.pipeline_config['ai']['n8n-service-api'].get('basic-username', '')
|
||||
self.basic_password = self.pipeline_config['ai']['n8n-service-api'].get('basic-password', '')
|
||||
elif self.auth_type == 'jwt':
|
||||
self.jwt_secret = self.pipeline_config['ai']['n8n-service-api'].get('jwt-secret', '')
|
||||
self.jwt_algorithm = self.pipeline_config['ai']['n8n-service-api'].get('jwt-algorithm', 'HS256')
|
||||
elif self.auth_type == 'header':
|
||||
self.header_name = self.pipeline_config['ai']['n8n-service-api'].get('header-name', '')
|
||||
self.header_value = self.pipeline_config['ai']['n8n-service-api'].get('header-value', '')
|
||||
|
||||
async def _preprocess_user_message(self, query: core_entities.Query) -> str:
|
||||
"""预处理用户消息,提取纯文本
|
||||
|
||||
Returns:
|
||||
str: 纯文本消息
|
||||
"""
|
||||
plain_text = ''
|
||||
|
||||
if isinstance(query.user_message.content, list):
|
||||
for ce in query.user_message.content:
|
||||
if ce.type == 'text':
|
||||
plain_text += ce.text
|
||||
# 注意:n8n webhook目前不支持直接处理图片,如需支持可在此扩展
|
||||
elif isinstance(query.user_message.content, str):
|
||||
plain_text = query.user_message.content
|
||||
|
||||
return plain_text
|
||||
|
||||
async def _call_webhook(self, query: core_entities.Query) -> typing.AsyncGenerator[llm_entities.Message, None]:
|
||||
"""调用n8n webhook"""
|
||||
# 生成会话ID(如果不存在)
|
||||
if not query.session.using_conversation.uuid:
|
||||
query.session.using_conversation.uuid = str(uuid.uuid4())
|
||||
|
||||
# 预处理用户消息
|
||||
plain_text = await self._preprocess_user_message(query)
|
||||
|
||||
# 准备请求数据
|
||||
payload = {
|
||||
# 基本消息内容
|
||||
'message': plain_text,
|
||||
'user_message_text': plain_text,
|
||||
'conversation_id': query.session.using_conversation.uuid,
|
||||
'session_id': query.variables.get('session_id', ''),
|
||||
'user_id': f'{query.session.launcher_type.value}_{query.session.launcher_id}',
|
||||
'msg_create_time': query.variables.get('msg_create_time', ''),
|
||||
}
|
||||
|
||||
# 添加所有变量到payload
|
||||
payload.update(query.variables)
|
||||
|
||||
try:
|
||||
# 准备请求头和认证信息
|
||||
headers = {}
|
||||
auth = None
|
||||
|
||||
# 根据认证类型设置相应的认证信息
|
||||
if self.auth_type == 'basic':
|
||||
# 使用Basic认证
|
||||
auth = aiohttp.BasicAuth(self.basic_username, self.basic_password)
|
||||
self.ap.logger.debug(f'using basic auth: {self.basic_username}')
|
||||
elif self.auth_type == 'jwt':
|
||||
# 使用JWT认证
|
||||
import jwt
|
||||
import time
|
||||
|
||||
# 创建JWT令牌
|
||||
payload_jwt = {
|
||||
'exp': int(time.time()) + 3600, # 1小时过期
|
||||
'iat': int(time.time()),
|
||||
'sub': 'n8n-webhook',
|
||||
}
|
||||
token = jwt.encode(payload_jwt, self.jwt_secret, algorithm=self.jwt_algorithm)
|
||||
|
||||
# 添加到Authorization头
|
||||
headers['Authorization'] = f'Bearer {token}'
|
||||
self.ap.logger.debug('using jwt auth')
|
||||
elif self.auth_type == 'header':
|
||||
# 使用自定义请求头认证
|
||||
headers[self.header_name] = self.header_value
|
||||
self.ap.logger.debug(f'using header auth: {self.header_name}')
|
||||
else:
|
||||
self.ap.logger.debug('no auth')
|
||||
|
||||
# 调用webhook
|
||||
async with aiohttp.ClientSession() as session:
|
||||
async with session.post(
|
||||
self.webhook_url, json=payload, headers=headers, auth=auth, timeout=self.timeout
|
||||
) as response:
|
||||
if response.status != 200:
|
||||
error_text = await response.text()
|
||||
self.ap.logger.error(f'n8n webhook call failed: {response.status}, {error_text}')
|
||||
raise Exception(f'n8n webhook call failed: {response.status}, {error_text}')
|
||||
|
||||
# 解析响应
|
||||
response_data = await response.json()
|
||||
self.ap.logger.debug(f'n8n webhook response: {response_data}')
|
||||
|
||||
# 从响应中提取输出
|
||||
if self.output_key in response_data:
|
||||
output_content = response_data[self.output_key]
|
||||
else:
|
||||
# 如果没有指定的输出键,则使用整个响应
|
||||
output_content = json.dumps(response_data, ensure_ascii=False)
|
||||
|
||||
# 返回消息
|
||||
yield llm_entities.Message(
|
||||
role='assistant',
|
||||
content=output_content,
|
||||
)
|
||||
except Exception as e:
|
||||
self.ap.logger.error(f'n8n webhook call exception: {str(e)}')
|
||||
raise N8nAPIError(f'n8n webhook call exception: {str(e)}')
|
||||
|
||||
async def run(self, query: core_entities.Query) -> typing.AsyncGenerator[llm_entities.Message, None]:
|
||||
"""运行请求"""
|
||||
async for msg in self._call_webhook(query):
|
||||
yield msg
|
||||
@@ -41,6 +41,8 @@ class SessionManager:
|
||||
query: core_entities.Query,
|
||||
session: core_entities.Session,
|
||||
prompt_config: list[dict],
|
||||
pipeline_uuid: str,
|
||||
bot_uuid: str,
|
||||
) -> core_entities.Conversation:
|
||||
"""获取对话或创建对话"""
|
||||
|
||||
@@ -58,13 +60,15 @@ class SessionManager:
|
||||
messages=prompt_messages,
|
||||
)
|
||||
|
||||
if session.using_conversation is None:
|
||||
if session.using_conversation is None or session.using_conversation.pipeline_uuid != pipeline_uuid:
|
||||
conversation = core_entities.Conversation(
|
||||
prompt=prompt,
|
||||
messages=[],
|
||||
use_funcs=await self.ap.tool_mgr.get_all_functions(
|
||||
plugin_enabled=True,
|
||||
),
|
||||
pipeline_uuid=pipeline_uuid,
|
||||
bot_uuid=bot_uuid,
|
||||
)
|
||||
session.conversations.append(conversation)
|
||||
session.using_conversation = conversation
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
semantic_version = 'v4.0.4'
|
||||
semantic_version = 'v4.0.8'
|
||||
|
||||
required_database_version = 2
|
||||
required_database_version = 3
|
||||
"""标记本版本所需要的数据库结构版本,用于判断数据库迁移"""
|
||||
|
||||
debug_mode = False
|
||||
|
||||
@@ -204,7 +204,9 @@ async def get_slack_image_to_base64(pic_url: str, bot_token: str):
|
||||
try:
|
||||
async with aiohttp.ClientSession() as session:
|
||||
async with session.get(pic_url, headers=headers) as resp:
|
||||
image_data = await resp.read()
|
||||
return base64.b64encode(image_data).decode('utf-8')
|
||||
mime_type = resp.headers.get("Content-Type", "application/octet-stream")
|
||||
file_bytes = await resp.read()
|
||||
base64_str = base64.b64encode(file_bytes).decode("utf-8")
|
||||
return f"data:{mime_type};base64,{base64_str}"
|
||||
except Exception as e:
|
||||
raise (e)
|
||||
raise (e)
|
||||
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "langbot"
|
||||
version = "4.0.3"
|
||||
version = "4.0.7"
|
||||
description = "高稳定、支持扩展、多模态 - 大模型原生即时通信机器人平台"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10.1"
|
||||
@@ -45,11 +45,10 @@ dependencies = [
|
||||
"websockets>=15.0.1",
|
||||
"python-socks>=2.7.1", # dingtalk missing dependency
|
||||
"taskgroup==0.0.0a4", # graingert/taskgroup#20
|
||||
"pip>=25.1.1", # pkg.core.bootutils.deps
|
||||
"google-genai>=1.15.0",
|
||||
"google-generativeai>=0.8.5",
|
||||
"pip>=25.1.1",
|
||||
"ruff>=0.11.9",
|
||||
"pre-commit>=4.2.0",
|
||||
"uv>=0.7.11",
|
||||
]
|
||||
keywords = [
|
||||
"bot",
|
||||
@@ -181,3 +180,4 @@ skip-magic-trailing-comma = false
|
||||
|
||||
# Like Black, automatically detect the appropriate line ending.
|
||||
line-ending = "auto"
|
||||
|
||||
|
||||
@@ -58,6 +58,18 @@
|
||||
"api-key": "your-api-key",
|
||||
"app-id": "your-app-id",
|
||||
"references-quote": "参考资料来自:"
|
||||
},
|
||||
"n8n-service-api": {
|
||||
"webhook-url": "http://your-n8n-webhook-url",
|
||||
"auth-type": "none",
|
||||
"basic-username": "",
|
||||
"basic-password": "",
|
||||
"jwt-secret": "",
|
||||
"jwt-algorithm": "HS256",
|
||||
"header-name": "",
|
||||
"header-value": "",
|
||||
"timeout": 120,
|
||||
"output-key": "response"
|
||||
}
|
||||
},
|
||||
"output": {
|
||||
|
||||
@@ -31,6 +31,10 @@ stages:
|
||||
label:
|
||||
en_US: Aliyun Dashscope App API
|
||||
zh_Hans: 阿里云百炼平台 API
|
||||
- name: n8n-service-api
|
||||
label:
|
||||
en_US: n8n Workflow API
|
||||
zh_Hans: n8n 工作流 API
|
||||
- name: local-agent
|
||||
label:
|
||||
en_US: Local Agent
|
||||
@@ -170,3 +174,127 @@ stages:
|
||||
type: string
|
||||
required: false
|
||||
default: '参考资料来自:'
|
||||
- name: n8n-service-api
|
||||
label:
|
||||
en_US: n8n Workflow API
|
||||
zh_Hans: n8n 工作流 API
|
||||
description:
|
||||
en_US: Configure the n8n workflow API of the pipeline
|
||||
zh_Hans: 配置 n8n 工作流 API
|
||||
config:
|
||||
- name: webhook-url
|
||||
label:
|
||||
en_US: Webhook URL
|
||||
zh_Hans: Webhook URL
|
||||
description:
|
||||
en_US: The webhook URL of the n8n workflow
|
||||
zh_Hans: n8n 工作流的 webhook URL
|
||||
type: string
|
||||
required: true
|
||||
- name: auth-type
|
||||
label:
|
||||
en_US: Authentication Type
|
||||
zh_Hans: 认证类型
|
||||
description:
|
||||
en_US: The authentication type for the webhook call
|
||||
zh_Hans: webhook 调用的认证类型
|
||||
type: select
|
||||
required: true
|
||||
default: 'none'
|
||||
options:
|
||||
- name: 'none'
|
||||
label:
|
||||
en_US: None
|
||||
zh_Hans: 无认证
|
||||
- name: 'basic'
|
||||
label:
|
||||
en_US: Basic Auth
|
||||
zh_Hans: 基本认证
|
||||
- name: 'jwt'
|
||||
label:
|
||||
en_US: JWT
|
||||
zh_Hans: JWT认证
|
||||
- name: 'header'
|
||||
label:
|
||||
en_US: Header Auth
|
||||
zh_Hans: 请求头认证
|
||||
- name: basic-username
|
||||
label:
|
||||
en_US: Username
|
||||
zh_Hans: 用户名
|
||||
description:
|
||||
en_US: The username for Basic Auth
|
||||
zh_Hans: 基本认证的用户名
|
||||
type: string
|
||||
required: false
|
||||
default: ''
|
||||
- name: basic-password
|
||||
label:
|
||||
en_US: Password
|
||||
zh_Hans: 密码
|
||||
description:
|
||||
en_US: The password for Basic Auth
|
||||
zh_Hans: 基本认证的密码
|
||||
type: string
|
||||
required: false
|
||||
default: ''
|
||||
- name: jwt-secret
|
||||
label:
|
||||
en_US: Secret
|
||||
zh_Hans: 密钥
|
||||
description:
|
||||
en_US: The secret for JWT authentication
|
||||
zh_Hans: JWT认证的密钥
|
||||
type: string
|
||||
required: false
|
||||
default: ''
|
||||
- name: jwt-algorithm
|
||||
label:
|
||||
en_US: Algorithm
|
||||
zh_Hans: 算法
|
||||
description:
|
||||
en_US: The algorithm for JWT authentication
|
||||
zh_Hans: JWT认证的算法
|
||||
type: string
|
||||
required: false
|
||||
default: 'HS256'
|
||||
- name: header-name
|
||||
label:
|
||||
en_US: Header Name
|
||||
zh_Hans: 请求头名称
|
||||
description:
|
||||
en_US: The header name for Header Auth
|
||||
zh_Hans: 请求头认证的名称
|
||||
type: string
|
||||
required: false
|
||||
default: ''
|
||||
- name: header-value
|
||||
label:
|
||||
en_US: Header Value
|
||||
zh_Hans: 请求头值
|
||||
description:
|
||||
en_US: The header value for Header Auth
|
||||
zh_Hans: 请求头认证的值
|
||||
type: string
|
||||
required: false
|
||||
default: ''
|
||||
- name: timeout
|
||||
label:
|
||||
en_US: Timeout
|
||||
zh_Hans: 超时时间
|
||||
description:
|
||||
en_US: The timeout in seconds for the webhook call
|
||||
zh_Hans: webhook 调用的超时时间(秒)
|
||||
type: integer
|
||||
required: false
|
||||
default: 120
|
||||
- name: output-key
|
||||
label:
|
||||
en_US: Output Key
|
||||
zh_Hans: 输出键名
|
||||
description:
|
||||
en_US: The key name of the output in the webhook response
|
||||
zh_Hans: webhook 响应中输出内容的键名
|
||||
type: string
|
||||
required: false
|
||||
default: 'response'
|
||||
|
||||
283
web/package-lock.json
generated
283
web/package-lock.json
generated
@@ -15,6 +15,8 @@
|
||||
"@radix-ui/react-dialog": "^1.1.13",
|
||||
"@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-switch": "^1.2.4",
|
||||
@@ -1255,6 +1257,215 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-popover": {
|
||||
"version": "1.1.14",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-popover/-/react-popover-1.1.14.tgz",
|
||||
"integrity": "sha512-ODz16+1iIbGUfFEfKx2HTPKizg2MN39uIOV8MXeHnmdd3i/N9Wt7vU46wbHsqA0xoaQyXVcs0KIlBdOA2Y95bw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/primitive": "1.1.2",
|
||||
"@radix-ui/react-compose-refs": "1.1.2",
|
||||
"@radix-ui/react-context": "1.1.2",
|
||||
"@radix-ui/react-dismissable-layer": "1.1.10",
|
||||
"@radix-ui/react-focus-guards": "1.1.2",
|
||||
"@radix-ui/react-focus-scope": "1.1.7",
|
||||
"@radix-ui/react-id": "1.1.1",
|
||||
"@radix-ui/react-popper": "1.2.7",
|
||||
"@radix-ui/react-portal": "1.1.9",
|
||||
"@radix-ui/react-presence": "1.1.4",
|
||||
"@radix-ui/react-primitive": "2.1.3",
|
||||
"@radix-ui/react-slot": "1.2.3",
|
||||
"@radix-ui/react-use-controllable-state": "1.2.2",
|
||||
"aria-hidden": "^1.2.4",
|
||||
"react-remove-scroll": "^2.6.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"@types/react-dom": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-popover/node_modules/@radix-ui/react-arrow": {
|
||||
"version": "1.1.7",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.7.tgz",
|
||||
"integrity": "sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-primitive": "2.1.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"@types/react-dom": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-popover/node_modules/@radix-ui/react-dismissable-layer": {
|
||||
"version": "1.1.10",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.10.tgz",
|
||||
"integrity": "sha512-IM1zzRV4W3HtVgftdQiiOmA0AdJlCtMLe00FXaHwgt3rAnNsIyDqshvkIW3hj/iu5hu8ERP7KIYki6NkqDxAwQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/primitive": "1.1.2",
|
||||
"@radix-ui/react-compose-refs": "1.1.2",
|
||||
"@radix-ui/react-primitive": "2.1.3",
|
||||
"@radix-ui/react-use-callback-ref": "1.1.1",
|
||||
"@radix-ui/react-use-escape-keydown": "1.1.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"@types/react-dom": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-popover/node_modules/@radix-ui/react-focus-scope": {
|
||||
"version": "1.1.7",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-1.1.7.tgz",
|
||||
"integrity": "sha512-t2ODlkXBQyn7jkl6TNaw/MtVEVvIGelJDCG41Okq/KwUsJBwQ4XVZsHAVUkK4mBv3ewiAS3PGuUWuY2BoK4ZUw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-compose-refs": "1.1.2",
|
||||
"@radix-ui/react-primitive": "2.1.3",
|
||||
"@radix-ui/react-use-callback-ref": "1.1.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"@types/react-dom": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-popover/node_modules/@radix-ui/react-popper": {
|
||||
"version": "1.2.7",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.2.7.tgz",
|
||||
"integrity": "sha512-IUFAccz1JyKcf/RjB552PlWwxjeCJB8/4KxT7EhBHOJM+mN7LdW+B3kacJXILm32xawcMMjb2i0cIZpo+f9kiQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@floating-ui/react-dom": "^2.0.0",
|
||||
"@radix-ui/react-arrow": "1.1.7",
|
||||
"@radix-ui/react-compose-refs": "1.1.2",
|
||||
"@radix-ui/react-context": "1.1.2",
|
||||
"@radix-ui/react-primitive": "2.1.3",
|
||||
"@radix-ui/react-use-callback-ref": "1.1.1",
|
||||
"@radix-ui/react-use-layout-effect": "1.1.1",
|
||||
"@radix-ui/react-use-rect": "1.1.1",
|
||||
"@radix-ui/react-use-size": "1.1.1",
|
||||
"@radix-ui/rect": "1.1.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"@types/react-dom": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-popover/node_modules/@radix-ui/react-portal": {
|
||||
"version": "1.1.9",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.9.tgz",
|
||||
"integrity": "sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-primitive": "2.1.3",
|
||||
"@radix-ui/react-use-layout-effect": "1.1.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"@types/react-dom": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-popover/node_modules/@radix-ui/react-primitive": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz",
|
||||
"integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-slot": "1.2.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"@types/react-dom": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-popover/node_modules/@radix-ui/react-slot": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
|
||||
"integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-compose-refs": "1.1.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-popper": {
|
||||
"version": "1.2.6",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.2.6.tgz",
|
||||
@@ -1389,6 +1600,78 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-scroll-area": {
|
||||
"version": "1.2.9",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-scroll-area/-/react-scroll-area-1.2.9.tgz",
|
||||
"integrity": "sha512-YSjEfBXnhUELsO2VzjdtYYD4CfQjvao+lhhrX5XsHD7/cyUNzljF1FHEbgTPN7LH2MClfwRMIsYlqTYpKTTe2A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/number": "1.1.1",
|
||||
"@radix-ui/primitive": "1.1.2",
|
||||
"@radix-ui/react-compose-refs": "1.1.2",
|
||||
"@radix-ui/react-context": "1.1.2",
|
||||
"@radix-ui/react-direction": "1.1.1",
|
||||
"@radix-ui/react-presence": "1.1.4",
|
||||
"@radix-ui/react-primitive": "2.1.3",
|
||||
"@radix-ui/react-use-callback-ref": "1.1.1",
|
||||
"@radix-ui/react-use-layout-effect": "1.1.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"@types/react-dom": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-scroll-area/node_modules/@radix-ui/react-primitive": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz",
|
||||
"integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-slot": "1.2.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"@types/react-dom": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-scroll-area/node_modules/@radix-ui/react-slot": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
|
||||
"integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-compose-refs": "1.1.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-select": {
|
||||
"version": "2.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-select/-/react-select-2.2.4.tgz",
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev --turbopack",
|
||||
"dev:local": "NEXT_PUBLIC_API_BASE_URL=http://localhost:5300 next dev --turbopack",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "next lint",
|
||||
@@ -20,15 +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",
|
||||
|
||||
262
web/src/app/home/bots/BotDetailDialog.tsx
Normal file
262
web/src/app/home/bots/BotDetailDialog.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -7,17 +7,12 @@ import { toast } from 'sonner';
|
||||
|
||||
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, {
|
||||
@@ -92,25 +87,6 @@ export default function BotCard({
|
||||
e.stopPropagation();
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
className={`${styles.botLogsIcon}`}
|
||||
onClick={(e) => {
|
||||
onClickLogIcon();
|
||||
e.stopPropagation();
|
||||
}}
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="w-[24px] h-[24px] z-10"
|
||||
>
|
||||
<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"
|
||||
fill="#9A9A9A"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -113,4 +113,4 @@
|
||||
height: 100%;
|
||||
width: 3rem;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)} />
|
||||
@@ -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) => {
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { z } from 'zod';
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from '@/components/ui/form';
|
||||
import { IDynamicFormItemSchema } from '@/app/infra/entities/form/dynamic';
|
||||
import DynamicFormItemComponent from '@/app/home/components/dynamic-form/DynamicFormItemComponent';
|
||||
import { i18nObj } from '@/i18n/I18nProvider';
|
||||
|
||||
/**
|
||||
* N8n认证表单组件
|
||||
* 根据选择的认证类型动态显示相应的表单项
|
||||
*/
|
||||
export default function N8nAuthFormComponent({
|
||||
itemConfigList,
|
||||
onSubmit,
|
||||
initialValues,
|
||||
}: {
|
||||
itemConfigList: IDynamicFormItemSchema[];
|
||||
onSubmit?: (val: object) => unknown;
|
||||
initialValues?: Record<string, string>;
|
||||
}) {
|
||||
// 当前选择的认证类型
|
||||
const [authType, setAuthType] = useState<string>(
|
||||
initialValues?.['auth-type'] || 'none',
|
||||
);
|
||||
|
||||
// 根据 itemConfigList 动态生成 zod schema
|
||||
const formSchema = z.object(
|
||||
itemConfigList.reduce(
|
||||
(acc, item) => {
|
||||
let fieldSchema;
|
||||
switch (item.type) {
|
||||
case 'integer':
|
||||
fieldSchema = z.number();
|
||||
break;
|
||||
case 'float':
|
||||
fieldSchema = z.number();
|
||||
break;
|
||||
case 'boolean':
|
||||
fieldSchema = z.boolean();
|
||||
break;
|
||||
case 'string':
|
||||
fieldSchema = z.string();
|
||||
break;
|
||||
case 'array[string]':
|
||||
fieldSchema = z.array(z.string());
|
||||
break;
|
||||
case 'select':
|
||||
fieldSchema = z.string();
|
||||
break;
|
||||
case 'llm-model-selector':
|
||||
fieldSchema = z.string();
|
||||
break;
|
||||
case 'prompt-editor':
|
||||
fieldSchema = z.array(
|
||||
z.object({
|
||||
content: z.string(),
|
||||
role: z.string(),
|
||||
}),
|
||||
);
|
||||
break;
|
||||
default:
|
||||
fieldSchema = z.string();
|
||||
}
|
||||
|
||||
if (
|
||||
item.required &&
|
||||
(fieldSchema instanceof z.ZodString ||
|
||||
fieldSchema instanceof z.ZodArray)
|
||||
) {
|
||||
fieldSchema = fieldSchema.min(1, { message: '此字段为必填项' });
|
||||
}
|
||||
|
||||
return {
|
||||
...acc,
|
||||
[item.name]: fieldSchema,
|
||||
};
|
||||
},
|
||||
{} as Record<string, z.ZodTypeAny>,
|
||||
),
|
||||
);
|
||||
|
||||
type FormValues = z.infer<typeof formSchema>;
|
||||
|
||||
const form = useForm<FormValues>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: itemConfigList.reduce((acc, item) => {
|
||||
// 优先使用 initialValues,如果没有则使用默认值
|
||||
const value = initialValues?.[item.name] ?? item.default;
|
||||
return {
|
||||
...acc,
|
||||
[item.name]: value,
|
||||
};
|
||||
}, {} as FormValues),
|
||||
});
|
||||
|
||||
// 当 initialValues 变化时更新表单值
|
||||
useEffect(() => {
|
||||
if (initialValues) {
|
||||
// 合并默认值和初始值
|
||||
const mergedValues = itemConfigList.reduce(
|
||||
(acc, item) => {
|
||||
acc[item.name] = initialValues[item.name] ?? item.default;
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, string>,
|
||||
);
|
||||
|
||||
Object.entries(mergedValues).forEach(([key, value]) => {
|
||||
form.setValue(key as keyof FormValues, value);
|
||||
});
|
||||
|
||||
// 更新认证类型
|
||||
setAuthType((mergedValues['auth-type'] as string) || 'none');
|
||||
}
|
||||
}, [initialValues, form, itemConfigList]);
|
||||
|
||||
// 监听表单值变化
|
||||
useEffect(() => {
|
||||
const subscription = form.watch((value, { name }) => {
|
||||
// 如果认证类型变化,更新状态
|
||||
if (name === 'auth-type') {
|
||||
setAuthType(value['auth-type'] as string);
|
||||
}
|
||||
|
||||
// 获取完整的表单值,确保包含所有默认值
|
||||
const formValues = form.getValues();
|
||||
const finalValues = itemConfigList.reduce(
|
||||
(acc, item) => {
|
||||
acc[item.name] = formValues[item.name] ?? item.default;
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, string>,
|
||||
);
|
||||
|
||||
onSubmit?.(finalValues);
|
||||
});
|
||||
return () => subscription.unsubscribe();
|
||||
}, [form, onSubmit, itemConfigList]);
|
||||
|
||||
// 根据认证类型过滤表单项
|
||||
const filteredConfigList = itemConfigList.filter((config) => {
|
||||
// 始终显示webhook-url、auth-type、timeout和output-key
|
||||
if (
|
||||
['webhook-url', 'auth-type', 'timeout', 'output-key'].includes(
|
||||
config.name,
|
||||
)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// 根据认证类型显示相应的表单项
|
||||
if (authType === 'basic' && config.name.startsWith('basic-')) {
|
||||
return true;
|
||||
}
|
||||
if (authType === 'jwt' && config.name.startsWith('jwt-')) {
|
||||
return true;
|
||||
}
|
||||
if (authType === 'header' && config.name.startsWith('header-')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
});
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<div className="space-y-4">
|
||||
{filteredConfigList.map((config) => (
|
||||
<FormField
|
||||
key={config.id}
|
||||
control={form.control}
|
||||
name={config.name as keyof FormValues}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{i18nObj(config.label)}{' '}
|
||||
{config.required && <span className="text-red-500">*</span>}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<DynamicFormItemComponent config={config} field={field} />
|
||||
</FormControl>
|
||||
{config.description && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{i18nObj(config.description)}
|
||||
</p>
|
||||
)}
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
import {
|
||||
DynamicFormItemType,
|
||||
IDynamicFormItemSchema,
|
||||
} from '@/app/infra/entities/form/dynamic';
|
||||
import { DynamicFormItemConfig } from '@/app/home/components/dynamic-form/DynamicFormItemConfig';
|
||||
|
||||
export const testDynamicConfigList: IDynamicFormItemSchema[] = [
|
||||
new DynamicFormItemConfig({
|
||||
default: '',
|
||||
id: '111',
|
||||
label: {
|
||||
zh_Hans: '测试字段string',
|
||||
en_US: 'eng test',
|
||||
},
|
||||
name: 'string_test',
|
||||
required: false,
|
||||
type: DynamicFormItemType.STRING,
|
||||
}),
|
||||
new DynamicFormItemConfig({
|
||||
default: '',
|
||||
id: '222',
|
||||
label: {
|
||||
zh_Hans: '测试字段int',
|
||||
en_US: 'int eng test',
|
||||
},
|
||||
name: 'int_test',
|
||||
required: true,
|
||||
type: DynamicFormItemType.INT,
|
||||
}),
|
||||
new DynamicFormItemConfig({
|
||||
default: '',
|
||||
id: '333',
|
||||
label: {
|
||||
zh_Hans: '测试字段boolean',
|
||||
en_US: 'boolean eng test',
|
||||
},
|
||||
name: 'boolean_test',
|
||||
required: false,
|
||||
type: DynamicFormItemType.BOOLEAN,
|
||||
}),
|
||||
];
|
||||
@@ -65,7 +65,7 @@
|
||||
}
|
||||
|
||||
.sidebarChildContainer {
|
||||
width: 9rem;
|
||||
width: 9.8rem;
|
||||
height: 3rem;
|
||||
padding-left: 1.6rem;
|
||||
font-size: 1rem;
|
||||
|
||||
214
web/src/app/home/pipelines/PipelineDetailDialog.tsx
Normal file
214
web/src/app/home/pipelines/PipelineDetailDialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { X } from 'lucide-react';
|
||||
|
||||
interface AtBadgeProps {
|
||||
targetName: string;
|
||||
readonly?: boolean;
|
||||
onRemove?: () => void;
|
||||
}
|
||||
|
||||
export default function AtBadge({
|
||||
targetName,
|
||||
readonly = false,
|
||||
onRemove,
|
||||
}: AtBadgeProps) {
|
||||
return (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="flex items-center gap-1 px-2 py-1 text-sm bg-blue-100 text-blue-600 hover:bg-blue-200"
|
||||
>
|
||||
@{targetName}
|
||||
{!readonly && onRemove && (
|
||||
<button
|
||||
onClick={onRemove}
|
||||
className="ml-1 hover:text-blue-800 focus:outline-none"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
)}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { useTranslation } from 'react-i18next';
|
||||
|
||||
export default function PipelineCard({ cardVO }: { cardVO: PipelineCardVO }) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div className={`${styles.cardContainer}`}>
|
||||
<div className={`${styles.basicInfoContainer}`}>
|
||||
|
||||
@@ -67,9 +67,11 @@
|
||||
|
||||
.operationContainer {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem;
|
||||
width: 5rem;
|
||||
width: 8rem;
|
||||
}
|
||||
|
||||
.operationDefaultBadge {
|
||||
@@ -97,4 +99,9 @@
|
||||
font-size: 1.4rem;
|
||||
font-weight: bold;
|
||||
max-width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.debugButtonIcon {
|
||||
width: 1.2rem;
|
||||
height: 1.2rem;
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
} from '@/app/infra/entities/pipeline';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import DynamicFormComponent from '@/app/home/components/dynamic-form/DynamicFormComponent';
|
||||
import N8nAuthFormComponent from '@/app/home/components/dynamic-form/N8nAuthFormComponent';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
@@ -21,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';
|
||||
|
||||
@@ -40,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({
|
||||
@@ -97,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),
|
||||
@@ -244,6 +251,37 @@ export default function PipelineFormComponent({
|
||||
if (stage.name !== currentRunner) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 对于n8n-service-api配置,使用N8nAuthFormComponent处理表单联动
|
||||
if (stage.name === 'n8n-service-api') {
|
||||
return (
|
||||
<div key={stage.name} className="space-y-4 mb-6">
|
||||
<div className="text-lg font-medium">{i18nObj(stage.label)}</div>
|
||||
{stage.description && (
|
||||
<div className="text-sm text-gray-500">
|
||||
{i18nObj(stage.description)}
|
||||
</div>
|
||||
)}
|
||||
<N8nAuthFormComponent
|
||||
itemConfigList={stage.config}
|
||||
initialValues={
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(form.watch(formName) as Record<string, any>)?.[stage.name] ||
|
||||
{}
|
||||
}
|
||||
onSubmit={(values) => {
|
||||
const currentValues =
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(form.getValues(formName) as Record<string, any>) || {};
|
||||
form.setValue(formName, {
|
||||
...currentValues,
|
||||
[stage.name]: values,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -274,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;
|
||||
|
||||
@@ -1,23 +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 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('');
|
||||
@@ -29,7 +24,6 @@ export default function PluginConfigPage() {
|
||||
safety: {},
|
||||
output: {},
|
||||
});
|
||||
const [disableForm, setDisableForm] = useState(false);
|
||||
const [selectedPipelineIsDefault, setSelectedPipelineIsDefault] =
|
||||
useState(false);
|
||||
|
||||
@@ -88,66 +82,71 @@ export default function PluginConfigPage() {
|
||||
trigger: value.pipeline.config.trigger,
|
||||
});
|
||||
setSelectedPipelineIsDefault(value.pipeline.is_default ?? false);
|
||||
setDisableForm(false);
|
||||
});
|
||||
}
|
||||
|
||||
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} />
|
||||
</div>
|
||||
|
||||
@@ -72,7 +72,7 @@ export default function PluginForm({
|
||||
};
|
||||
|
||||
if (!pluginInfo || !pluginConfig) {
|
||||
return <div>加载中...</div>;
|
||||
return <div>{t('plugins.loading')}</div>;
|
||||
}
|
||||
|
||||
function deletePlugin() {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { IDynamicFormItemSchema } from '@/app/infra/entities/form/dynamic';
|
||||
import { PipelineConfigTab } from '@/app/infra/entities/pipeline';
|
||||
import { I18nLabel } from '@/app/infra/entities/common';
|
||||
import { Message } from '@/app/infra/entities/message';
|
||||
|
||||
export interface ApiResponse<T> {
|
||||
code: number;
|
||||
@@ -203,77 +204,10 @@ export interface MarketPluginResponse {
|
||||
}
|
||||
|
||||
interface GetPipelineConfig {
|
||||
ai: {
|
||||
'dashscope-app-api': {
|
||||
'api-key': string;
|
||||
'app-id': string;
|
||||
'app-type': 'agent' | 'workflow';
|
||||
'references-quote'?: string;
|
||||
};
|
||||
'dify-service-api': {
|
||||
'api-key': string;
|
||||
'app-type': 'chat' | 'agent' | 'workflow';
|
||||
'base-url': string;
|
||||
'thinking-convert': 'plain' | 'original' | 'remove';
|
||||
timeout?: number;
|
||||
};
|
||||
'local-agent': {
|
||||
'max-round': number;
|
||||
model: string;
|
||||
prompt: Array<{
|
||||
content: string;
|
||||
role: string;
|
||||
}>;
|
||||
};
|
||||
runner: {
|
||||
runner: 'local-agent' | 'dify-service-api' | 'dashscope-app-api';
|
||||
};
|
||||
};
|
||||
output: {
|
||||
'force-delay': {
|
||||
max: number;
|
||||
min: number;
|
||||
};
|
||||
'long-text-processing': {
|
||||
'font-path': string;
|
||||
strategy: 'forward' | 'image';
|
||||
threshold: number;
|
||||
};
|
||||
misc: {
|
||||
'at-sender': boolean;
|
||||
'hide-exception': boolean;
|
||||
'quote-origin': boolean;
|
||||
'track-function-calls': boolean;
|
||||
};
|
||||
};
|
||||
safety: {
|
||||
'content-filter': {
|
||||
'check-sensitive-words': boolean;
|
||||
scope: 'all' | 'income-msg' | 'output-msg';
|
||||
};
|
||||
'rate-limit': {
|
||||
limitation: number;
|
||||
strategy: 'drop' | 'wait';
|
||||
'window-length': number;
|
||||
};
|
||||
};
|
||||
trigger: {
|
||||
'access-control': {
|
||||
blacklist: string[];
|
||||
mode: 'blacklist' | 'whitelist';
|
||||
whitelist: string[];
|
||||
};
|
||||
'group-respond-rules': {
|
||||
at: boolean;
|
||||
prefix: string[];
|
||||
random: number;
|
||||
regexp: string[];
|
||||
};
|
||||
'ignore-rules': {
|
||||
prefix: string[];
|
||||
regexp: string[];
|
||||
};
|
||||
};
|
||||
ai: object;
|
||||
output: object;
|
||||
safety: object;
|
||||
trigger: object;
|
||||
}
|
||||
|
||||
interface GetPipeline {
|
||||
@@ -295,3 +229,11 @@ export interface GetPipelineResponseData {
|
||||
export interface GetPipelineMetadataResponseData {
|
||||
configs: PipelineConfigTab[];
|
||||
}
|
||||
|
||||
export interface ApiRespWebChatMessage {
|
||||
message: Message;
|
||||
}
|
||||
|
||||
export interface ApiRespWebChatMessages {
|
||||
messages: Message[];
|
||||
}
|
||||
|
||||
7
web/src/app/infra/entities/message/index.ts
Normal file
7
web/src/app/infra/entities/message/index.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export interface Message {
|
||||
id: number;
|
||||
role: 'user' | 'assistant';
|
||||
content: string;
|
||||
message_chain: object[];
|
||||
timestamp: string;
|
||||
}
|
||||
@@ -29,6 +29,8 @@ import {
|
||||
GetPipelineResponseData,
|
||||
GetPipelineMetadataResponseData,
|
||||
AsyncTask,
|
||||
ApiRespWebChatMessage,
|
||||
ApiRespWebChatMessages,
|
||||
} from '@/app/infra/entities/api';
|
||||
import { GetBotLogsRequest } from '@/app/infra/http/requestParam/bots/GetBotLogsRequest';
|
||||
import { GetBotLogsResponse } from '@/app/infra/http/requestParam/bots/GetBotLogsResponse';
|
||||
@@ -301,6 +303,43 @@ class HttpClient {
|
||||
return this.delete(`/api/v1/pipelines/${uuid}`);
|
||||
}
|
||||
|
||||
// ============ Debug WebChat API ============
|
||||
public sendWebChatMessage(
|
||||
sessionType: string,
|
||||
messageChain: object[],
|
||||
pipelineId: string,
|
||||
timeout: number = 15000,
|
||||
): Promise<ApiRespWebChatMessage> {
|
||||
return this.post(
|
||||
`/api/v1/pipelines/${pipelineId}/chat/send`,
|
||||
{
|
||||
session_type: sessionType,
|
||||
message: messageChain,
|
||||
},
|
||||
{
|
||||
timeout,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
public getWebChatHistoryMessages(
|
||||
pipelineId: string,
|
||||
sessionType: string,
|
||||
): Promise<ApiRespWebChatMessages> {
|
||||
return this.get(
|
||||
`/api/v1/pipelines/${pipelineId}/chat/messages/${sessionType}`,
|
||||
);
|
||||
}
|
||||
|
||||
public resetWebChatSession(
|
||||
pipelineId: string,
|
||||
sessionType: string,
|
||||
): Promise<{ message: string }> {
|
||||
return this.post(
|
||||
`/api/v1/pipelines/${pipelineId}/chat/reset/${sessionType}`,
|
||||
);
|
||||
}
|
||||
|
||||
// ============ Platform API ============
|
||||
public getAdapters(): Promise<ApiRespPlatformAdapters> {
|
||||
return this.get('/api/v1/platform/adapters');
|
||||
@@ -455,9 +494,15 @@ class HttpClient {
|
||||
}
|
||||
}
|
||||
|
||||
// export const httpClient = new HttpClient('https://event-log.langbot.dev');
|
||||
// export const httpClient = new HttpClient('http://localhost:5300');
|
||||
export const httpClient = new HttpClient('/');
|
||||
const getBaseURL = (): string => {
|
||||
if (typeof window !== 'undefined' && process.env.NEXT_PUBLIC_API_BASE_URL) {
|
||||
return process.env.NEXT_PUBLIC_API_BASE_URL;
|
||||
}
|
||||
|
||||
return '/';
|
||||
};
|
||||
|
||||
export const httpClient = new HttpClient(getBaseURL());
|
||||
|
||||
// 临时写法,未来两种Client都继承自HttpClient父类,不允许共享方法
|
||||
export const spaceClient = new HttpClient('https://space.langbot.app');
|
||||
|
||||
@@ -61,19 +61,37 @@ export default function Login() {
|
||||
}, []);
|
||||
|
||||
const judgeLanguage = () => {
|
||||
// here's for user have never set the language
|
||||
// judge the language by the browser
|
||||
const language = navigator.language;
|
||||
if (language) {
|
||||
let lang = 'zh-Hans';
|
||||
if (language === 'zh-CN') {
|
||||
lang = 'zh-Hans';
|
||||
} else {
|
||||
lang = 'en-US';
|
||||
}
|
||||
if (i18n.language === 'zh-CN' || i18n.language === 'zh-Hans') {
|
||||
setCurrentLanguage('zh-Hans');
|
||||
localStorage.setItem('langbot_language', 'zh-Hans');
|
||||
} else if (i18n.language === 'ja' || i18n.language === 'ja-JP') {
|
||||
setCurrentLanguage('ja-JP');
|
||||
localStorage.setItem('langbot_language', 'ja-JP');
|
||||
} else {
|
||||
setCurrentLanguage('en-US');
|
||||
localStorage.setItem('langbot_language', 'en-US');
|
||||
}
|
||||
// check if the language is already set
|
||||
const lang = localStorage.getItem('langbot_language');
|
||||
if (lang) {
|
||||
i18n.changeLanguage(lang);
|
||||
setCurrentLanguage(lang);
|
||||
localStorage.setItem('langbot_language', lang);
|
||||
return;
|
||||
} else {
|
||||
const language = navigator.language;
|
||||
if (language) {
|
||||
let lang = 'zh-Hans';
|
||||
if (language === 'zh-CN') {
|
||||
lang = 'zh-Hans';
|
||||
} else if (language === 'ja' || language === 'ja-JP') {
|
||||
lang = 'ja-JP';
|
||||
} else {
|
||||
lang = 'en-US';
|
||||
}
|
||||
i18n.changeLanguage(lang);
|
||||
setCurrentLanguage(lang);
|
||||
localStorage.setItem('langbot_language', lang);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -146,6 +164,7 @@ export default function Login() {
|
||||
<SelectContent>
|
||||
<SelectItem value="zh-Hans">简体中文</SelectItem>
|
||||
<SelectItem value="en-US">English</SelectItem>
|
||||
<SelectItem value="ja-JP">日本語</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
@@ -8,6 +8,13 @@ import {
|
||||
CardTitle,
|
||||
CardDescription,
|
||||
} from '@/components/ui/card';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import * as z from 'zod';
|
||||
@@ -19,23 +26,28 @@ import {
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from '@/components/ui/form';
|
||||
import { useEffect } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { httpClient } from '@/app/infra/http/HttpClient';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { Mail, Lock } from 'lucide-react';
|
||||
import { Mail, Lock, Globe } from 'lucide-react';
|
||||
import langbotIcon from '@/app/assets/langbot-logo.webp';
|
||||
import { toast } from 'sonner';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import i18n from '@/i18n';
|
||||
|
||||
const formSchema = z.object({
|
||||
email: z.string().email('请输入有效的邮箱地址'),
|
||||
password: z.string().min(1, '请输入密码'),
|
||||
});
|
||||
const formSchema = (t: (key: string) => string) =>
|
||||
z.object({
|
||||
email: z.string().email(t('common.invalidEmail')),
|
||||
password: z.string().min(1, t('common.emptyPassword')),
|
||||
});
|
||||
|
||||
export default function Register() {
|
||||
const router = useRouter();
|
||||
const { t } = useTranslation();
|
||||
const [currentLanguage, setCurrentLanguage] = useState<string>(i18n.language);
|
||||
|
||||
const form = useForm<z.infer<typeof formSchema>>({
|
||||
resolver: zodResolver(formSchema),
|
||||
const form = useForm<z.infer<ReturnType<typeof formSchema>>>({
|
||||
resolver: zodResolver(formSchema(t)),
|
||||
defaultValues: {
|
||||
email: '',
|
||||
password: '',
|
||||
@@ -43,9 +55,53 @@ export default function Register() {
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
judgeLanguage();
|
||||
getIsInitialized();
|
||||
}, []);
|
||||
|
||||
const judgeLanguage = () => {
|
||||
if (i18n.language === 'zh-CN' || i18n.language === 'zh-Hans') {
|
||||
setCurrentLanguage('zh-Hans');
|
||||
localStorage.setItem('langbot_language', 'zh-Hans');
|
||||
} else if (i18n.language === 'ja' || i18n.language === 'ja-JP') {
|
||||
setCurrentLanguage('ja-JP');
|
||||
localStorage.setItem('langbot_language', 'ja-JP');
|
||||
} else {
|
||||
setCurrentLanguage('en-US');
|
||||
localStorage.setItem('langbot_language', 'en-US');
|
||||
}
|
||||
// check if the language is already set
|
||||
const lang = localStorage.getItem('langbot_language');
|
||||
console.log('lang: ', lang);
|
||||
if (lang) {
|
||||
i18n.changeLanguage(lang);
|
||||
setCurrentLanguage(lang);
|
||||
} else {
|
||||
const language = navigator.language;
|
||||
if (language) {
|
||||
let lang = 'zh-Hans';
|
||||
if (language === 'zh-CN') {
|
||||
lang = 'zh-Hans';
|
||||
} else if (language === 'ja' || language === 'ja-JP') {
|
||||
lang = 'ja-JP';
|
||||
} else {
|
||||
lang = 'en-US';
|
||||
}
|
||||
console.log('language: ', lang);
|
||||
i18n.changeLanguage(lang);
|
||||
setCurrentLanguage(lang);
|
||||
localStorage.setItem('langbot_language', lang);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleLanguageChange = (value: string) => {
|
||||
console.log('handleLanguageChange: ', value);
|
||||
i18n.changeLanguage(value);
|
||||
setCurrentLanguage(value);
|
||||
localStorage.setItem('langbot_language', value);
|
||||
};
|
||||
|
||||
function getIsInitialized() {
|
||||
httpClient
|
||||
.checkIfInited()
|
||||
@@ -59,7 +115,7 @@ export default function Register() {
|
||||
});
|
||||
}
|
||||
|
||||
function onSubmit(values: z.infer<typeof formSchema>) {
|
||||
function onSubmit(values: z.infer<ReturnType<typeof formSchema>>) {
|
||||
handleRegister(values.email, values.password);
|
||||
}
|
||||
|
||||
@@ -68,31 +124,47 @@ export default function Register() {
|
||||
.initUser(username, password)
|
||||
.then((res) => {
|
||||
console.log('init user success: ', res);
|
||||
toast.success('初始化成功 请登录');
|
||||
toast.success(t('register.initSuccess'));
|
||||
router.push('/login');
|
||||
})
|
||||
.catch((err) => {
|
||||
console.log('init user error: ', err);
|
||||
toast.error('初始化失败:' + err.message);
|
||||
toast.error(t('register.initFailed') + err.message);
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-50">
|
||||
<Card className="w-[360px]">
|
||||
<Card className="w-[375px]">
|
||||
<CardHeader>
|
||||
<div className="flex justify-end mb-6">
|
||||
<Select
|
||||
value={currentLanguage}
|
||||
onValueChange={handleLanguageChange}
|
||||
>
|
||||
<SelectTrigger className="w-[140px]">
|
||||
<Globe className="h-4 w-4 mr-2" />
|
||||
<SelectValue placeholder={t('common.language')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="zh-Hans">简体中文</SelectItem>
|
||||
<SelectItem value="en-US">English</SelectItem>
|
||||
<SelectItem value="ja-JP">日本語</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<img
|
||||
src={langbotIcon.src}
|
||||
alt="LangBot"
|
||||
className="w-16 h-16 mb-4 mx-auto"
|
||||
/>
|
||||
<CardTitle className="text-2xl text-center">
|
||||
初始化 LangBot 👋
|
||||
{t('register.title')}
|
||||
</CardTitle>
|
||||
<CardDescription className="text-center">
|
||||
这是您首次启动 LangBot
|
||||
{t('register.description')}
|
||||
<br />
|
||||
您填写的邮箱和密码将作为初始管理员账号
|
||||
{t('register.adminAccountNote')}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
@@ -103,12 +175,12 @@ export default function Register() {
|
||||
name="email"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>邮箱</FormLabel>
|
||||
<FormLabel>{t('common.email')}</FormLabel>
|
||||
<FormControl>
|
||||
<div className="relative">
|
||||
<Mail className="absolute left-3 top-3 h-4 w-4 text-gray-400" />
|
||||
<Input
|
||||
placeholder="输入邮箱地址"
|
||||
placeholder={t('common.enterEmail')}
|
||||
className="pl-10"
|
||||
{...field}
|
||||
/>
|
||||
@@ -124,13 +196,13 @@ export default function Register() {
|
||||
name="password"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>密码</FormLabel>
|
||||
<FormLabel>{t('common.password')}</FormLabel>
|
||||
<FormControl>
|
||||
<div className="relative">
|
||||
<Lock className="absolute left-3 top-3 h-4 w-4 text-gray-400" />
|
||||
<Input
|
||||
type="password"
|
||||
placeholder="输入密码"
|
||||
placeholder={t('common.enterPassword')}
|
||||
className="pl-10"
|
||||
{...field}
|
||||
/>
|
||||
@@ -142,7 +214,7 @@ export default function Register() {
|
||||
/>
|
||||
|
||||
<Button type="submit" className="w-full mt-4 cursor-pointer">
|
||||
注册
|
||||
{t('register.register')}
|
||||
</Button>
|
||||
</form>
|
||||
</Form>
|
||||
|
||||
109
web/src/components/ui/breadcrumb.tsx
Normal file
109
web/src/components/ui/breadcrumb.tsx
Normal 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,
|
||||
};
|
||||
48
web/src/components/ui/popover.tsx
Normal file
48
web/src/components/ui/popover.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import * as PopoverPrimitive from '@radix-ui/react-popover';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
function Popover({
|
||||
...props
|
||||
}: React.ComponentProps<typeof PopoverPrimitive.Root>) {
|
||||
return <PopoverPrimitive.Root data-slot="popover" {...props} />;
|
||||
}
|
||||
|
||||
function PopoverTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof PopoverPrimitive.Trigger>) {
|
||||
return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />;
|
||||
}
|
||||
|
||||
function PopoverContent({
|
||||
className,
|
||||
align = 'center',
|
||||
sideOffset = 4,
|
||||
...props
|
||||
}: React.ComponentProps<typeof PopoverPrimitive.Content>) {
|
||||
return (
|
||||
<PopoverPrimitive.Portal>
|
||||
<PopoverPrimitive.Content
|
||||
data-slot="popover-content"
|
||||
align={align}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-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-72 origin-(--radix-popover-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</PopoverPrimitive.Portal>
|
||||
);
|
||||
}
|
||||
|
||||
function PopoverAnchor({
|
||||
...props
|
||||
}: React.ComponentProps<typeof PopoverPrimitive.Anchor>) {
|
||||
return <PopoverPrimitive.Anchor data-slot="popover-anchor" {...props} />;
|
||||
}
|
||||
|
||||
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor };
|
||||
58
web/src/components/ui/scroll-area.tsx
Normal file
58
web/src/components/ui/scroll-area.tsx
Normal file
@@ -0,0 +1,58 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import * as ScrollAreaPrimitive from '@radix-ui/react-scroll-area';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
function ScrollArea({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ScrollAreaPrimitive.Root>) {
|
||||
return (
|
||||
<ScrollAreaPrimitive.Root
|
||||
data-slot="scroll-area"
|
||||
className={cn('relative', className)}
|
||||
{...props}
|
||||
>
|
||||
<ScrollAreaPrimitive.Viewport
|
||||
data-slot="scroll-area-viewport"
|
||||
className="focus-visible:ring-ring/50 size-full rounded-[inherit] transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:outline-1"
|
||||
>
|
||||
{children}
|
||||
</ScrollAreaPrimitive.Viewport>
|
||||
<ScrollBar />
|
||||
<ScrollAreaPrimitive.Corner />
|
||||
</ScrollAreaPrimitive.Root>
|
||||
);
|
||||
}
|
||||
|
||||
function ScrollBar({
|
||||
className,
|
||||
orientation = 'vertical',
|
||||
...props
|
||||
}: React.ComponentProps<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>) {
|
||||
return (
|
||||
<ScrollAreaPrimitive.ScrollAreaScrollbar
|
||||
data-slot="scroll-area-scrollbar"
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
'flex touch-none p-px transition-colors select-none',
|
||||
orientation === 'vertical' &&
|
||||
'h-full w-2.5 border-l border-l-transparent',
|
||||
orientation === 'horizontal' &&
|
||||
'h-2.5 flex-col border-t border-t-transparent',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ScrollAreaPrimitive.ScrollAreaThumb
|
||||
data-slot="scroll-area-thumb"
|
||||
className="bg-border relative flex-1 rounded-full"
|
||||
/>
|
||||
</ScrollAreaPrimitive.ScrollAreaScrollbar>
|
||||
);
|
||||
}
|
||||
|
||||
export { ScrollArea, ScrollBar };
|
||||
28
web/src/components/ui/separator.tsx
Normal file
28
web/src/components/ui/separator.tsx
Normal 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 };
|
||||
139
web/src/components/ui/sheet.tsx
Normal file
139
web/src/components/ui/sheet.tsx
Normal 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,
|
||||
};
|
||||
726
web/src/components/ui/sidebar.tsx
Normal file
726
web/src/components/ui/sidebar.tsx
Normal 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,
|
||||
};
|
||||
13
web/src/components/ui/skeleton.tsx
Normal file
13
web/src/components/ui/skeleton.tsx
Normal 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 };
|
||||
61
web/src/components/ui/tooltip.tsx
Normal file
61
web/src/components/ui/tooltip.tsx
Normal 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 };
|
||||
21
web/src/hooks/use-mobile.ts
Normal file
21
web/src/hooks/use-mobile.ts
Normal 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;
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import LanguageDetector from 'i18next-browser-languagedetector';
|
||||
|
||||
import enUS from './locales/en-US';
|
||||
import zhHans from './locales/zh-Hans';
|
||||
import jaJP from './locales/ja-JP';
|
||||
|
||||
i18n
|
||||
.use(LanguageDetector)
|
||||
@@ -18,6 +19,9 @@ i18n
|
||||
'zh-Hans': {
|
||||
translation: zhHans,
|
||||
},
|
||||
'ja-JP': {
|
||||
translation: jaJP,
|
||||
},
|
||||
},
|
||||
fallbackLng: 'zh-Hans',
|
||||
debug: process.env.NODE_ENV === 'development',
|
||||
|
||||
@@ -128,6 +128,9 @@ const enUS = {
|
||||
earlier: 'Earlier',
|
||||
dateFormat: '{{month}}/{{day}}',
|
||||
setBotEnableError: 'Failed to set bot enable status',
|
||||
log: 'Log',
|
||||
configuration: 'Configuration',
|
||||
logs: 'Logs',
|
||||
},
|
||||
plugins: {
|
||||
title: 'Plugins',
|
||||
@@ -183,6 +186,9 @@ const enUS = {
|
||||
'Pipelines define the processing flow for message events, used to bind to bots',
|
||||
createPipeline: 'Create Pipeline',
|
||||
editPipeline: 'Edit Pipeline',
|
||||
chat: 'Chat',
|
||||
configuration: 'Configuration',
|
||||
debugChat: 'Debug Chat',
|
||||
getPipelineListError: 'Failed to get pipeline list: ',
|
||||
daysAgo: 'days ago',
|
||||
today: 'Today',
|
||||
@@ -202,6 +208,36 @@ 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',
|
||||
sessionType: 'Session Type',
|
||||
privateChat: 'Private Chat',
|
||||
groupChat: 'Group Chat',
|
||||
send: 'Send',
|
||||
reset: 'Reset Conversation',
|
||||
inputPlaceholder: 'Send {{type}} message...',
|
||||
noMessages: 'No messages',
|
||||
userMessage: 'User',
|
||||
botMessage: 'Bot',
|
||||
sendFailed: 'Send failed',
|
||||
resetSuccess: 'Conversation reset successfully',
|
||||
resetFailed: 'Reset failed',
|
||||
loadMessagesFailed: 'Failed to load messages',
|
||||
loadPipelinesFailed: 'Failed to load pipelines',
|
||||
atTips: 'Mention the bot',
|
||||
},
|
||||
},
|
||||
register: {
|
||||
title: 'Initialize LangBot 👋',
|
||||
description: 'This is your first time starting LangBot',
|
||||
adminAccountNote:
|
||||
'The email and password you fill in will be used as the initial administrator account',
|
||||
register: 'Register',
|
||||
initSuccess: 'Initialization successful, please login',
|
||||
initFailed: 'Initialization failed: ',
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
245
web/src/i18n/locales/ja-JP.ts
Normal file
245
web/src/i18n/locales/ja-JP.ts
Normal file
@@ -0,0 +1,245 @@
|
||||
const jaJP = {
|
||||
common: {
|
||||
login: 'ログイン',
|
||||
logout: 'ログアウト',
|
||||
email: 'メールアドレス',
|
||||
password: 'パスワード',
|
||||
welcome: 'LangBot へおかえりなさい 👋',
|
||||
continueToLogin: 'ログインしてください',
|
||||
loginSuccess: 'ログインに成功しました',
|
||||
loginFailed:
|
||||
'ログインに失敗しました。メールアドレスまたはパスワードをご確認ください',
|
||||
enterEmail: 'メールアドレスを入力',
|
||||
enterPassword: 'パスワードを入力',
|
||||
invalidEmail: '有効なメールアドレスを入力してください',
|
||||
emptyPassword: 'パスワードを入力してください',
|
||||
language: '言語',
|
||||
helpDocs: 'ヘルプドキュメント',
|
||||
create: '作成',
|
||||
edit: '編集',
|
||||
delete: '削除',
|
||||
add: '追加',
|
||||
select: '選択してください',
|
||||
cancel: 'キャンセル',
|
||||
submit: '送信',
|
||||
error: 'エラー',
|
||||
success: '成功',
|
||||
save: '保存',
|
||||
saving: '保存中...',
|
||||
confirm: '確認',
|
||||
confirmDelete: '削除の確認',
|
||||
deleteConfirmation: '本当に削除しますか?',
|
||||
selectOption: 'オプションを選択',
|
||||
required: '必須',
|
||||
enable: '有効にする',
|
||||
name: '名前',
|
||||
description: '説明',
|
||||
close: '閉じる',
|
||||
deleteSuccess: '削除に成功しました',
|
||||
deleteError: '削除に失敗しました:',
|
||||
addRound: 'ラウンドを追加',
|
||||
copySuccess: 'コピーに成功しました',
|
||||
test: 'テスト',
|
||||
},
|
||||
notFound: {
|
||||
title: 'ページが見つかりません',
|
||||
description:
|
||||
'お探しのページは存在しないようです。入力したURLが正しいか確認するか、ホームページに戻ってください。',
|
||||
back: '戻る',
|
||||
home: 'ホームに戻る',
|
||||
help: 'ヘルプドキュメントを見る',
|
||||
},
|
||||
models: {
|
||||
title: 'モデル設定',
|
||||
description: 'パイプラインで使用できるモデルを設定・管理',
|
||||
createModel: 'モデルを作成',
|
||||
editModel: 'モデルを編集',
|
||||
getModelListError: 'モデルリストの取得に失敗しました:',
|
||||
modelName: 'モデル名',
|
||||
modelProvider: 'モデルプロバイダー',
|
||||
modelBaseURL: 'ベースURL',
|
||||
modelAbilities: 'モデル機能',
|
||||
saveSuccess: '保存に成功しました',
|
||||
saveError: '保存に失敗しました:',
|
||||
createSuccess: '作成に成功しました',
|
||||
createError: '作成に失敗しました:',
|
||||
deleteSuccess: '削除に成功しました',
|
||||
deleteError: '削除に失敗しました:',
|
||||
deleteConfirmation: '本当にこのモデルを削除しますか?',
|
||||
modelNameRequired: 'モデル名は必須です',
|
||||
modelProviderRequired: 'モデルプロバイダーは必須です',
|
||||
requestURLRequired: 'リクエストURLは必須です',
|
||||
apiKeyRequired: 'APIキーは必須です',
|
||||
keyNameRequired: 'キー名は必須です',
|
||||
mustBeValidNumber: '有効な数値である必要があります',
|
||||
mustBeTrueOrFalse: 'true または false である必要があります',
|
||||
requestURL: 'リクエストURL',
|
||||
apiKey: 'APIキー',
|
||||
abilities: '機能',
|
||||
selectModelAbilities: 'モデル機能を選択',
|
||||
visionAbility: '視覚機能',
|
||||
functionCallAbility: '関数呼び出し',
|
||||
extraParameters: '追加パラメータ',
|
||||
addParameter: 'パラメータを追加',
|
||||
keyName: 'キー名',
|
||||
type: 'タイプ',
|
||||
value: '値',
|
||||
string: '文字列',
|
||||
number: '数値',
|
||||
boolean: 'ブール値',
|
||||
extraParametersDescription:
|
||||
'リクエストボディに追加されるパラメータ(max_tokens、temperature、top_p など)',
|
||||
selectModelProvider: 'モデルプロバイダーを選択',
|
||||
modelProviderDescription: 'プロバイダーが提供するモデル名をご入力ください',
|
||||
selectModel: 'モデルを選択してください',
|
||||
testSuccess: 'テストに成功しました',
|
||||
testError: 'テストに失敗しました。モデル設定を確認してください',
|
||||
},
|
||||
bots: {
|
||||
title: 'ボット',
|
||||
description:
|
||||
'ボットの作成と管理を行います。LangBotと各プラットフォームを接続するためのエントリーポイントです',
|
||||
createBot: 'ボットを作成',
|
||||
editBot: 'ボットを編集',
|
||||
getBotListError: 'ボットリストの取得に失敗しました:',
|
||||
botName: 'ボット名',
|
||||
botDescription: 'ボットの説明',
|
||||
botNameRequired: 'ボット名は必須です',
|
||||
botDescriptionRequired: 'ボットの説明は必須です',
|
||||
adapterRequired: 'アダプターは必須です',
|
||||
defaultDescription: 'ボット',
|
||||
getBotConfigError: 'ボット設定の取得に失敗しました:',
|
||||
saveSuccess: '保存に成功しました',
|
||||
saveError: '保存に失敗しました:',
|
||||
createSuccess:
|
||||
'作成が完了しました。有効化するか、パイプラインの設定を行ってください',
|
||||
createError: '作成に失敗しました:',
|
||||
deleteSuccess: '削除に成功しました',
|
||||
deleteError: '削除に失敗しました:',
|
||||
deleteConfirmation: '本当にこのボットを削除しますか?',
|
||||
platformAdapter: 'プラットフォーム/アダプター選択',
|
||||
selectAdapter: 'アダプターを選択',
|
||||
adapterConfig: 'アダプター設定',
|
||||
bindPipeline: 'パイプラインを紐付け',
|
||||
selectPipeline: 'パイプラインを選択',
|
||||
botLogTitle: 'ボットログ',
|
||||
enableAutoRefresh: '自動更新を有効にする',
|
||||
session: 'セッション',
|
||||
yesterday: '昨日',
|
||||
earlier: 'それ以前',
|
||||
dateFormat: '{{month}}月{{day}}日',
|
||||
setBotEnableError: 'ボットの有効状態の設定に失敗しました',
|
||||
log: 'ログ',
|
||||
configuration: '設定',
|
||||
logs: 'ログ',
|
||||
},
|
||||
plugins: {
|
||||
title: 'プラグイン',
|
||||
description: 'LangBotの機能を拡張するプラグインをインストール・設定',
|
||||
createPlugin: 'プラグインを作成',
|
||||
editPlugin: 'プラグインを編集',
|
||||
installed: 'インストール済み',
|
||||
marketplace: 'プラグインマーケット',
|
||||
arrange: '並び替え',
|
||||
install: 'インストール',
|
||||
installFromGithub: 'GitHubからプラグインをインストール',
|
||||
onlySupportGithub: '現在はGitHubからのインストールのみサポートしています',
|
||||
enterGithubLink: 'プラグインのGitHubリンクを入力してください',
|
||||
installing: 'プラグインをインストール中...',
|
||||
installSuccess: 'プラグインのインストールに成功しました',
|
||||
installFailed: 'プラグインのインストールに失敗しました:',
|
||||
searchPlugin: 'プラグインを検索',
|
||||
sortBy: '並び順',
|
||||
mostStars: 'スター数順',
|
||||
recentlyAdded: '最近追加',
|
||||
recentlyUpdated: '最近更新',
|
||||
noMatchingPlugins: '一致するプラグインが見つかりません',
|
||||
loading: '読み込み中...',
|
||||
getPluginListError: 'プラグインリストの取得に失敗しました:',
|
||||
noPluginInstalled: 'プラグインがインストールされていません',
|
||||
pluginConfig: 'プラグイン設定',
|
||||
pluginSort: 'プラグインの並び替え',
|
||||
pluginSortDescription:
|
||||
'プラグインの順序は、同一イベント内での処理順序に影響します。カードをドラッグして並び替えが可能です',
|
||||
pluginSortSuccess: 'プラグインの並び替えに成功しました',
|
||||
pluginSortError: 'プラグインの並び替えに失敗しました:',
|
||||
pluginNoConfig: 'プラグインに設定項目がありません。',
|
||||
deleting: '削除中...',
|
||||
deletePlugin: 'プラグインを削除',
|
||||
cancel: 'キャンセル',
|
||||
saveConfig: '設定を保存',
|
||||
saving: '保存中...',
|
||||
confirmDeletePlugin:
|
||||
'プラグイン「{{author}}/{{name}}」を削除してもよろしいですか?',
|
||||
confirmDelete: '削除を確認',
|
||||
deleteError: '削除に失敗しました:',
|
||||
close: '閉じる',
|
||||
deleteConfirm: '削除の確認',
|
||||
modifyFailed: '変更に失敗しました:',
|
||||
eventCount: 'イベント:{{count}}',
|
||||
toolCount: 'ツール:{{count}}',
|
||||
starCount: 'スター:{{count}}',
|
||||
},
|
||||
pipelines: {
|
||||
title: 'パイプライン',
|
||||
description:
|
||||
'メッセージイベントの処理フローを定義し、ボットに紐付けて使用するパイプラインです',
|
||||
createPipeline: 'パイプラインを作成',
|
||||
editPipeline: 'パイプラインを編集',
|
||||
chat: 'チャット',
|
||||
configuration: '設定',
|
||||
debugChat: 'チャットデバッグ',
|
||||
getPipelineListError: 'パイプラインリストの取得に失敗しました:',
|
||||
daysAgo: '日前',
|
||||
today: '今日',
|
||||
updateTime: '更新日時',
|
||||
defaultBadge: 'デフォルト',
|
||||
basicInfo: '基本情報',
|
||||
aiCapabilities: 'AI機能',
|
||||
triggerConditions: 'トリガー条件',
|
||||
safetyControls: '安全制御',
|
||||
outputProcessing: '出力処理',
|
||||
nameRequired: '名前は必須です',
|
||||
descriptionRequired: '説明は必須です',
|
||||
createSuccess:
|
||||
'作成が完了しました。パイプラインの詳細パラメータを設定してください',
|
||||
createError: '作成に失敗しました:',
|
||||
saveSuccess: '保存に成功しました',
|
||||
saveError: '保存に失敗しました:',
|
||||
deleteConfirmation:
|
||||
'本当にこのパイプラインを削除しますか?このパイプラインに紐付けられたボットは動作しなくなります。',
|
||||
defaultPipelineCannotDelete: 'デフォルトパイプラインは削除できません',
|
||||
deleteSuccess: '削除に成功しました',
|
||||
deleteError: '削除に失敗しました:',
|
||||
debugDialog: {
|
||||
title: 'パイプラインのチャット',
|
||||
selectPipeline: 'パイプラインを選択',
|
||||
sessionType: 'セッションタイプ',
|
||||
privateChat: 'プライベートチャット',
|
||||
groupChat: 'グループチャット',
|
||||
send: '送信',
|
||||
reset: '会話をリセット',
|
||||
inputPlaceholder: '{{type}}メッセージを送信...',
|
||||
noMessages: 'メッセージがありません',
|
||||
userMessage: 'ユーザー',
|
||||
botMessage: 'ボット',
|
||||
sendFailed: '送信に失敗しました',
|
||||
resetSuccess: '会話がリセットされました',
|
||||
resetFailed: 'リセットに失敗しました',
|
||||
loadMessagesFailed: 'メッセージの読み込みに失敗しました',
|
||||
loadPipelinesFailed: 'パイプラインの読み込みに失敗しました',
|
||||
atTips: 'ボットをメンション',
|
||||
},
|
||||
},
|
||||
register: {
|
||||
title: 'LangBot を初期化 👋',
|
||||
description: 'これはLangBotの初回起動です',
|
||||
adminAccountNote:
|
||||
'入力したメールアドレスとパスワードが初期管理者アカウントになります',
|
||||
register: '登録',
|
||||
initSuccess: '初期化に成功しました。ログインしてください',
|
||||
initFailed: '初期化に失敗しました:',
|
||||
},
|
||||
};
|
||||
|
||||
export default jaJP;
|
||||
@@ -26,7 +26,7 @@ const zhHans = {
|
||||
save: '保存',
|
||||
saving: '保存中...',
|
||||
confirm: '确认',
|
||||
confirmDelete: '删除确认',
|
||||
confirmDelete: '确认删除',
|
||||
deleteConfirmation: '你确定要删除这个吗?',
|
||||
selectOption: '选择一个选项',
|
||||
required: '必填',
|
||||
@@ -126,6 +126,9 @@ const zhHans = {
|
||||
earlier: '更久之前',
|
||||
dateFormat: '{{month}}月{{day}}日',
|
||||
setBotEnableError: '设置机器人启用状态失败',
|
||||
log: '日志',
|
||||
configuration: '配置',
|
||||
logs: '日志',
|
||||
},
|
||||
plugins: {
|
||||
title: '插件管理',
|
||||
@@ -178,6 +181,9 @@ const zhHans = {
|
||||
description: '流水线定义了对消息事件的处理流程,用于绑定到机器人',
|
||||
createPipeline: '创建流水线',
|
||||
editPipeline: '编辑流水线',
|
||||
chat: '对话',
|
||||
configuration: '配置',
|
||||
debugChat: '对话调试',
|
||||
getPipelineListError: '获取流水线列表失败:',
|
||||
daysAgo: '天前',
|
||||
today: '今天',
|
||||
@@ -197,6 +203,35 @@ const zhHans = {
|
||||
deleteConfirmation:
|
||||
'你确定要删除这个流水线吗?已绑定此流水线的机器人将无法使用。',
|
||||
defaultPipelineCannotDelete: '默认流水线不可删除',
|
||||
deleteSuccess: '删除成功',
|
||||
deleteError: '删除失败:',
|
||||
debugDialog: {
|
||||
title: '流水线对话',
|
||||
selectPipeline: '选择流水线',
|
||||
sessionType: '会话类型',
|
||||
privateChat: '私聊',
|
||||
groupChat: '群聊',
|
||||
send: '发送',
|
||||
reset: '重置对话',
|
||||
inputPlaceholder: '发送 {{type}} 消息...',
|
||||
noMessages: '暂无消息',
|
||||
userMessage: '用户',
|
||||
botMessage: '机器人',
|
||||
sendFailed: '发送失败',
|
||||
resetSuccess: '对话已重置',
|
||||
resetFailed: '重置失败',
|
||||
loadMessagesFailed: '加载消息失败',
|
||||
loadPipelinesFailed: '加载流水线失败',
|
||||
atTips: '提及机器人',
|
||||
},
|
||||
},
|
||||
register: {
|
||||
title: '初始化 LangBot 👋',
|
||||
description: '这是您首次启动 LangBot',
|
||||
adminAccountNote: '您填写的邮箱和密码将作为初始管理员账号',
|
||||
register: '注册',
|
||||
initSuccess: '初始化成功 请登录',
|
||||
initFailed: '初始化失败:',
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user