Compare commits

...

100 Commits

Author SHA1 Message Date
Junyan Qin
777b766fff chore: bump version 4.4.0 2025-11-04 22:05:49 +08:00
Guanchao Wang
1adaa93034 Fix/mcp (#1746)
* fix: mcp session cannot be enabled

* fix: error message

* perf: ui

* perf: ui

---------

Co-authored-by: Junyan Qin <rockchinq@gmail.com>
2025-11-04 22:02:40 +08:00
Junyan Qin
9853eccd89 chore: bump langbot-plugin to 0.1.6 2025-11-04 21:11:33 +08:00
Copilot
7699ba3cae feat: add supports for install plugin from GitHub repo releases
Add GitHub release installation for plugins
2025-11-04 21:09:14 +08:00
Junyan Qin (Chin)
9ac8b1a6fd feat: ui for mcp (#1600)
* feat: code by huntun

* chore: revert group.py

* refactor: api

* feat: adjust ui

* chore: stash

* feat: add dialog

* feat: add mcp from sse on frontend

* feat: add mcp db

* feat: semi frontend

* feat: change sse frontend

* fix: page out of control

* fix: mcp card

* fix: mcp refactor

* fix: delete description

* feat: add mcp servers

* fix: status icon

* feat: mcp-ui

* perf: remove title from mcp mgm page

* fix: delete mcp market

* feat: add i18n

* fix: run lint

* feat: add i18n

* fix: delete print function

* fix: mcp test error

* fix: i18n and mcp test

* refactor(mcp): bridge controller and db operation with service layer

* fix: try & catch & error

* fix: error message in mcp card

* feat: no longer register tool loader as component for type hints

* perf: make startup async

* feat: completely remove the fucking mcp market components and refs

* refactor: mcp server datastructure

* perf: tidy dir

* feat: perf mcp server api datastruct

* perf: ui

* perf: mcp server status checking logic

* perf: mcp server testing and refreshing

* perf: no mcp server tips

* perf: update sidebar title

* chore: update

* chore: bump langbot-plugin to 0.1.3

* chore: bump version v4.3.4

* chore: release v4.3.5

* Fix: Correct data type mismatch in AtBotRule (#1705)

Fix can't '@' in QQ group.

* chore: bump version 4.3.6

* feat: update for new events fields

* Fix/qqo (#1709)

* fix: qq official

* fix: appid

* chore: add `codecov.yml`

* chore: bump langbot-plugin to 0.1.4b2

* chore: bump version 4.3.7b1

* fix: return empty data when plugin system disabled (#1710)

* chore: bump version 4.3.7

* fix: bad Plain component init in wechatpad (#1712)

* perf: allow not set llm model (#1703)

* perf: output pipeline error in en

* fix: datetime serialization error in emit_event (#1713)

* chore: bump version 4.3.8

* perf: add component list in plugin detail dialog

* perf: store pipeline sort method

* Feat/coze runner (#1714)

* feat:add coze api client and coze runner and coze config

* del print

* fix:Change the default setting of the plugin system to true

* fix:del multimodal-support config, default multimodal-support,and in cozeapi.py Obtain timeout and auto-save-history config

* chore: add comment for coze.com

---------

Co-authored-by: Junyan Qin <rockchinq@gmail.com>

* chore: bump version 4.3.9

* feat: 实现企业微信智能机器人流式响应

- 重构 WecomBotClient,支持流式会话管理和队列机制
- 新增 StreamSession 和 StreamSessionManager 类管理流式上下文
- 实现 reply_message_chunk 接口支持流式输出
- 优化消息处理流程,支持异步流式响应

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

Co-Authored-By: Claude <noreply@anthropic.com>

* refactor: split WeCom callback handlers

* fix: langchain error

* fix: add langchain test splitter module

* perf: config reset logic (#1742)

* fix: inherit settings from existing settings

* feat: add optional data cleanup checkbox to plugin uninstall dialog (#1743)

* Initial plan

* Add checkbox for plugin config/storage deletion

- Add delete_data parameter to backend API endpoint
- Update delete_plugin flow to clean up settings and binary storage
- Add checkbox in uninstall dialog using shadcn/ui
- Add translations for checkbox label in all languages

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

* perf: param list

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: RockChinQ <45992437+RockChinQ@users.noreply.github.com>
Co-authored-by: Junyan Qin <rockchinq@gmail.com>

* chore: fix linter errors

---------

Co-authored-by: Copilot <198982749+Copilot@users.noreply.github.com>

---------

Co-authored-by: WangCham <651122857@qq.com>
Co-authored-by: wangcham <wangcham233@gmail.com>
Co-authored-by: Thetail001 <56257172+Thetail001@users.noreply.github.com>
Co-authored-by: fdc310 <82008029+fdc310@users.noreply.github.com>
Co-authored-by: Alfons <alfonsxh@gmail.com>
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Copilot <198982749+Copilot@users.noreply.github.com>
2025-11-04 18:49:16 +08:00
Junyan Qin (Chin)
f476c4724d Merge branch 'master' into feat/mcp-ui 2025-11-04 18:48:30 +08:00
Junyan Qin (Chin)
3d12632c9f perf: config reset logic (#1742)
* fix: inherit settings from existing settings

* feat: add optional data cleanup checkbox to plugin uninstall dialog (#1743)

* Initial plan

* Add checkbox for plugin config/storage deletion

- Add delete_data parameter to backend API endpoint
- Update delete_plugin flow to clean up settings and binary storage
- Add checkbox in uninstall dialog using shadcn/ui
- Add translations for checkbox label in all languages

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

* perf: param list

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: RockChinQ <45992437+RockChinQ@users.noreply.github.com>
Co-authored-by: Junyan Qin <rockchinq@gmail.com>

* chore: fix linter errors

---------

Co-authored-by: Copilot <198982749+Copilot@users.noreply.github.com>
2025-11-04 18:47:38 +08:00
WangCham
350e59fa6b fix: add langchain test splitter module 2025-11-04 18:47:38 +08:00
WangCham
b3d5b3fc8f fix: langchain error 2025-11-04 18:47:38 +08:00
Alfonsxh
4a02c531b2 refactor: split WeCom callback handlers 2025-11-04 18:47:38 +08:00
Alfons
2dd2abedde feat: 实现企业微信智能机器人流式响应
- 重构 WecomBotClient,支持流式会话管理和队列机制
- 新增 StreamSession 和 StreamSessionManager 类管理流式上下文
- 实现 reply_message_chunk 接口支持流式输出
- 优化消息处理流程,支持异步流式响应

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-04 18:47:37 +08:00
Junyan Qin
0d59c04151 chore: bump version 4.3.9 2025-11-04 18:47:37 +08:00
fdc310
08e0ede655 Feat/coze runner (#1714)
* feat:add coze api client and coze runner and coze config

* del print

* fix:Change the default setting of the plugin system to true

* fix:del multimodal-support config, default multimodal-support,and in cozeapi.py Obtain timeout and auto-save-history config

* chore: add comment for coze.com

---------

Co-authored-by: Junyan Qin <rockchinq@gmail.com>
2025-11-04 18:47:37 +08:00
Junyan Qin
bcf89ca434 perf: store pipeline sort method 2025-11-04 18:47:37 +08:00
Junyan Qin
5e2f677d0b perf: add component list in plugin detail dialog 2025-11-04 18:47:37 +08:00
Junyan Qin
4df372052d chore: bump version 4.3.8 2025-11-04 18:47:01 +08:00
Junyan Qin
2c5a0a00ba fix: datetime serialization error in emit_event (#1713) 2025-11-04 18:47:01 +08:00
Junyan Qin
f3295b0fdd perf: output pipeline error in en 2025-11-04 18:47:01 +08:00
Junyan Qin
431d515c26 perf: allow not set llm model (#1703) 2025-11-04 18:47:01 +08:00
Junyan Qin
d9e6198992 fix: bad Plain component init in wechatpad (#1712) 2025-11-04 18:47:00 +08:00
Junyan Qin
3951cbf266 chore: bump version 4.3.7 2025-11-04 18:47:00 +08:00
Junyan Qin (Chin)
c47c4994ae fix: return empty data when plugin system disabled (#1710) 2025-11-04 18:47:00 +08:00
Junyan Qin
a6072c2abb chore: bump version 4.3.7b1 2025-11-04 18:47:00 +08:00
Junyan Qin
360422f25e chore: bump langbot-plugin to 0.1.4b2 2025-11-04 18:47:00 +08:00
Junyan Qin
f135c946bd chore: add codecov.yml 2025-11-04 18:46:59 +08:00
Guanchao Wang
750cc24900 Fix/qqo (#1709)
* fix: qq official

* fix: appid
2025-11-04 18:46:59 +08:00
Junyan Qin
46062bf4b9 feat: update for new events fields 2025-11-04 18:46:59 +08:00
Junyan Qin
869b2176a7 chore: bump version 4.3.6 2025-11-04 18:46:59 +08:00
Thetail001
7138c101e3 Fix: Correct data type mismatch in AtBotRule (#1705)
Fix can't '@' in QQ group.
2025-11-04 18:46:59 +08:00
Junyan Qin
04e26225cd chore: release v4.3.5 2025-11-04 18:46:58 +08:00
Junyan Qin
f9f2de570f chore: bump version v4.3.4 2025-11-04 18:46:58 +08:00
Junyan Qin
1dd598c7be chore: bump langbot-plugin to 0.1.3 2025-11-04 18:46:58 +08:00
Junyan Qin
c0f04e4f20 chore: update 2025-11-04 18:35:21 +08:00
Junyan Qin
d3279b9823 perf: update sidebar title 2025-11-04 18:33:13 +08:00
Junyan Qin
2ad1f97e12 perf: no mcp server tips 2025-11-04 18:27:37 +08:00
Junyan Qin
1046f3c2aa perf: mcp server testing and refreshing 2025-11-04 18:14:59 +08:00
Junyan Qin
1afecf01e4 perf: mcp server status checking logic 2025-11-04 17:32:05 +08:00
Junyan Qin
3ee7736361 perf: ui 2025-11-04 17:09:28 +08:00
Junyan Qin
0666778fea feat: perf mcp server api datastruct 2025-11-04 16:45:55 +08:00
Junyan Qin
8df90558ab perf: tidy dir 2025-11-04 16:29:16 +08:00
Junyan Qin
c1c03f11b4 refactor: mcp server datastructure 2025-11-04 16:13:03 +08:00
Junyan Qin (Chin)
da9afcd0ad perf: config reset logic (#1742)
* fix: inherit settings from existing settings

* feat: add optional data cleanup checkbox to plugin uninstall dialog (#1743)

* Initial plan

* Add checkbox for plugin config/storage deletion

- Add delete_data parameter to backend API endpoint
- Update delete_plugin flow to clean up settings and binary storage
- Add checkbox in uninstall dialog using shadcn/ui
- Add translations for checkbox label in all languages

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

* perf: param list

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: RockChinQ <45992437+RockChinQ@users.noreply.github.com>
Co-authored-by: Junyan Qin <rockchinq@gmail.com>

* chore: fix linter errors

---------

Co-authored-by: Copilot <198982749+Copilot@users.noreply.github.com>
2025-11-04 15:33:44 +08:00
Junyan Qin
bc1fbfa190 feat: completely remove the fucking mcp market components and refs 2025-11-03 20:23:53 +08:00
Junyan Qin
f3199dda20 perf: make startup async 2025-11-03 20:16:45 +08:00
Junyan Qin
4d0a28a1a7 feat: no longer register tool loader as component for type hints 2025-11-03 17:25:56 +08:00
wangcham
76831579ad fix: error message in mcp card 2025-11-02 13:57:37 +00:00
wangcham
c2d752f9e9 fix: try & catch & error 2025-11-02 12:37:00 +00:00
Junyan Qin
4c0917556f refactor(mcp): bridge controller and db operation with service layer 2025-11-02 13:05:55 +08:00
wangcham
e17b0cf5c5 fix: i18n and mcp test 2025-10-30 15:17:06 +00:00
wangcham
f2647316a5 fix: mcp test error 2025-10-30 15:01:25 +00:00
Guanchao Wang
78cc157657 Merge pull request #1735 from langbot-app/fix/text_splitter
fix: langchain error
2025-10-30 12:55:10 +08:00
WangCham
f576f990de fix: add langchain test splitter module 2025-10-30 12:52:11 +08:00
WangCham
254feb6a3a fix: langchain error 2025-10-30 12:37:09 +08:00
wangcham
4c5139e9ff fix: delete print function 2025-10-29 14:35:09 +00:00
wangcham
a055e37d3a feat: add i18n 2025-10-29 14:00:45 +00:00
Guanchao Wang
bef5d6627b Merge pull request #1731 from Alfonsxh/master
feat: 实现企业微信智能机器人流式响应
2025-10-29 21:50:52 +08:00
Alfonsxh
69767ebdb4 refactor: split WeCom callback handlers 2025-10-28 18:33:35 +08:00
Alfons
53ecd0933e feat: 实现企业微信智能机器人流式响应
- 重构 WecomBotClient,支持流式会话管理和队列机制
- 新增 StreamSession 和 StreamSessionManager 类管理流式上下文
- 实现 reply_message_chunk 接口支持流式输出
- 优化消息处理流程,支持异步流式响应

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-28 18:12:35 +08:00
WangCham
d32f783392 fix: run lint 2025-10-28 16:14:31 +08:00
WangCham
4d3610cdf7 feat: add i18n 2025-10-28 14:14:46 +08:00
WangCham
166eebabff fix: delete mcp market 2025-10-28 13:11:09 +08:00
Junyan Qin
9f2f1cd577 perf: remove title from mcp mgm page 2025-10-26 23:39:34 +09:00
wangcham
d86b884cab feat: mcp-ui 2025-10-25 02:28:20 +00:00
wangcham
8345edd9f7 fix: status icon 2025-10-25 01:58:52 +00:00
wangcham
e3821b3f09 feat: add mcp servers 2025-10-24 17:48:44 +00:00
WangCham
72ca62eae4 fix: delete description 2025-10-24 20:37:48 +08:00
wangcham
075091ed06 fix: mcp refactor 2025-10-23 15:47:44 +00:00
wangcham
d0a3dee083 fix: mcp card 2025-10-23 22:30:53 +08:00
wangcham
6ba9b6973d fix: page out of control 2025-10-22 13:37:53 +00:00
WangCham
345eccf04c feat: change sse frontend 2025-10-22 19:09:39 +08:00
Junyan Qin
127a38b15c chore: bump version 4.3.9 2025-10-22 18:52:45 +08:00
WangCham
760db38c11 feat: semi frontend 2025-10-21 16:18:03 +08:00
fdc310
e4729337c8 Feat/coze runner (#1714)
* feat:add coze api client and coze runner and coze config

* del print

* fix:Change the default setting of the plugin system to true

* fix:del multimodal-support config, default multimodal-support,and in cozeapi.py Obtain timeout and auto-save-history config

* chore: add comment for coze.com

---------

Co-authored-by: Junyan Qin <rockchinq@gmail.com>
2025-10-17 18:13:03 +08:00
WangCham
7be226d3fa feat: add mcp db 2025-10-15 18:42:05 +08:00
wangcham
68372a4b7a feat: add mcp from sse on frontend 2025-10-13 12:51:58 +00:00
WangCham
d65f862c36 feat: add dialog 2025-10-13 18:21:46 +08:00
Junyan Qin
5fa75330cf perf: store pipeline sort method 2025-10-12 21:11:30 +08:00
Junyan Qin
547e3d098e perf: add component list in plugin detail dialog 2025-10-12 19:57:42 +08:00
Junyan Qin
0f39a31648 chore: stash 2025-10-11 19:10:56 +08:00
Junyan Qin
f1ddddfe00 chore: bump version 4.3.8 2025-10-10 22:50:57 +08:00
Junyan Qin
4e61302156 fix: datetime serialization error in emit_event (#1713) 2025-10-10 22:37:39 +08:00
Junyan Qin
9e3cf418ba perf: output pipeline error in en 2025-10-10 17:55:49 +08:00
Junyan Qin
3e29ec7892 perf: allow not set llm model (#1703) 2025-10-10 16:34:01 +08:00
Junyan Qin
f452742cd2 fix: bad Plain component init in wechatpad (#1712) 2025-10-10 14:48:21 +08:00
Junyan Qin
b560432b0b chore: bump version 4.3.7 2025-10-08 14:36:48 +08:00
Junyan Qin (Chin)
99e5478ced fix: return empty data when plugin system disabled (#1710) 2025-10-07 16:24:38 +08:00
Junyan Qin
09dba91a37 chore: bump version 4.3.7b1 2025-10-07 15:30:33 +08:00
Junyan Qin
18ec4adac9 chore: bump langbot-plugin to 0.1.4b2 2025-10-07 15:25:49 +08:00
Junyan Qin
8bedaa468a chore: add codecov.yml 2025-10-07 00:15:56 +08:00
Guanchao Wang
0ab366fcac Fix/qqo (#1709)
* fix: qq official

* fix: appid
2025-10-07 00:06:07 +08:00
Junyan Qin
d664039e54 feat: update for new events fields 2025-10-06 23:22:38 +08:00
Junyan Qin
6535ba4f72 chore: bump version 4.3.6 2025-10-04 00:22:08 +08:00
Thetail001
3b181cff93 Fix: Correct data type mismatch in AtBotRule (#1705)
Fix can't '@' in QQ group.
2025-10-04 00:20:27 +08:00
Junyan Qin
d1274366a0 chore: release v4.3.5 2025-10-02 10:30:19 +08:00
Junyan Qin
35a4b0f55f chore: bump version v4.3.4 2025-10-02 10:26:48 +08:00
Junyan Qin
a3552893aa Merge branch 'master' into feat/mcp-ui 2025-10-01 11:07:16 +08:00
Junyan Qin
0b527868bc feat: adjust ui 2025-09-30 00:21:13 +08:00
Junyan Qin
0f35458cf7 refactor: api 2025-09-29 23:57:05 +08:00
Junyan Qin
70ad92ca16 chore: revert group.py 2025-09-29 23:57:05 +08:00
Junyan Qin
c0d56aa905 feat: code by huntun 2025-09-29 23:57:04 +08:00
64 changed files with 4359 additions and 491 deletions

3
.gitignore vendored
View File

@@ -44,5 +44,6 @@ test.py
.venv/
uv.lock
/test
plugins.bak
coverage.xml
.coverage
.coverage

4
codecov.yml Normal file
View File

@@ -0,0 +1,4 @@
coverage:
status:
project: off
patch: off

View File

View File

@@ -0,0 +1,192 @@
import json
import asyncio
import aiohttp
import io
from typing import Dict, List, Any, AsyncGenerator
import os
from pathlib import Path
class AsyncCozeAPIClient:
def __init__(self, api_key: str, api_base: str = "https://api.coze.cn"):
self.api_key = api_key
self.api_base = api_base
self.session = None
async def __aenter__(self):
"""支持异步上下文管理器"""
await self.coze_session()
return self
async def __aexit__(self, exc_type, exc_val, exc_tb):
"""退出时自动关闭会话"""
await self.close()
async def coze_session(self):
"""确保HTTP session存在"""
if self.session is None:
connector = aiohttp.TCPConnector(
ssl=False if self.api_base.startswith("http://") else True,
limit=100,
limit_per_host=30,
keepalive_timeout=30,
enable_cleanup_closed=True,
)
timeout = aiohttp.ClientTimeout(
total=120, # 默认超时时间
connect=30,
sock_read=120,
)
headers = {
"Authorization": f"Bearer {self.api_key}",
"Accept": "text/event-stream",
}
self.session = aiohttp.ClientSession(
headers=headers, timeout=timeout, connector=connector
)
return self.session
async def close(self):
"""显式关闭会话"""
if self.session and not self.session.closed:
await self.session.close()
self.session = None
async def upload(
self,
file,
) -> str:
# 处理 Path 对象
if isinstance(file, Path):
if not file.exists():
raise ValueError(f"File not found: {file}")
with open(file, "rb") as f:
file = f.read()
# 处理文件路径字符串
elif isinstance(file, str):
if not os.path.isfile(file):
raise ValueError(f"File not found: {file}")
with open(file, "rb") as f:
file = f.read()
# 处理文件对象
elif hasattr(file, 'read'):
file = file.read()
session = await self.coze_session()
url = f"{self.api_base}/v1/files/upload"
try:
file_io = io.BytesIO(file)
async with session.post(
url,
data={
"file": file_io,
},
timeout=aiohttp.ClientTimeout(total=60),
) as response:
if response.status == 401:
raise Exception("Coze API 认证失败,请检查 API Key 是否正确")
response_text = await response.text()
if response.status != 200:
raise Exception(
f"文件上传失败,状态码: {response.status}, 响应: {response_text}"
)
try:
result = await response.json()
except json.JSONDecodeError:
raise Exception(f"文件上传响应解析失败: {response_text}")
if result.get("code") != 0:
raise Exception(f"文件上传失败: {result.get('msg', '未知错误')}")
file_id = result["data"]["id"]
return file_id
except asyncio.TimeoutError:
raise Exception("文件上传超时")
except Exception as e:
raise Exception(f"文件上传失败: {str(e)}")
async def chat_messages(
self,
bot_id: str,
user_id: str,
additional_messages: List[Dict] | None = None,
conversation_id: str | None = None,
auto_save_history: bool = True,
stream: bool = True,
timeout: float = 120,
) -> AsyncGenerator[Dict[str, Any], None]:
"""发送聊天消息并返回流式响应
Args:
bot_id: Bot ID
user_id: 用户ID
additional_messages: 额外消息列表
conversation_id: 会话ID
auto_save_history: 是否自动保存历史
stream: 是否流式响应
timeout: 超时时间
"""
session = await self.coze_session()
url = f"{self.api_base}/v3/chat"
payload = {
"bot_id": bot_id,
"user_id": user_id,
"stream": stream,
"auto_save_history": auto_save_history,
}
if additional_messages:
payload["additional_messages"] = additional_messages
params = {}
if conversation_id:
params["conversation_id"] = conversation_id
try:
async with session.post(
url,
json=payload,
params=params,
timeout=aiohttp.ClientTimeout(total=timeout),
) as response:
if response.status == 401:
raise Exception("Coze API 认证失败,请检查 API Key 是否正确")
if response.status != 200:
raise Exception(f"Coze API 流式请求失败,状态码: {response.status}")
async for chunk in response.content:
chunk = chunk.decode("utf-8")
if chunk != '\n':
if chunk.startswith("event:"):
chunk_type = chunk.replace("event:", "", 1).strip()
elif chunk.startswith("data:"):
chunk_data = chunk.replace("data:", "", 1).strip()
else:
yield {"event": chunk_type, "data": json.loads(chunk_data)}
except asyncio.TimeoutError:
raise Exception(f"Coze API 流式请求超时 ({timeout}秒)")
except Exception as e:
raise Exception(f"Coze API 流式请求失败: {str(e)}")

View File

@@ -1,189 +1,452 @@
import asyncio
import base64
import json
import time
import traceback
import uuid
import xml.etree.ElementTree as ET
from dataclasses import dataclass, field
from typing import Any, Callable, Optional
from urllib.parse import unquote
import hashlib
import traceback
import httpx
from libs.wecom_ai_bot_api.WXBizMsgCrypt3 import WXBizMsgCrypt
from quart import Quart, request, Response, jsonify
import langbot_plugin.api.entities.builtin.platform.message as platform_message
import asyncio
from libs.wecom_ai_bot_api import wecombotevent
from typing import Callable
import base64
from Crypto.Cipher import AES
from quart import Quart, request, Response, jsonify
from libs.wecom_ai_bot_api import wecombotevent
from libs.wecom_ai_bot_api.WXBizMsgCrypt3 import WXBizMsgCrypt
from pkg.platform.logger import EventLogger
@dataclass
class StreamChunk:
"""描述单次推送给企业微信的流式片段。"""
# 需要返回给企业微信的文本内容
content: str
# 标记是否为最终片段,对应企业微信协议里的 finish 字段
is_final: bool = False
# 预留额外元信息,未来支持多模态扩展时可使用
meta: dict[str, Any] = field(default_factory=dict)
@dataclass
class StreamSession:
"""维护一次企业微信流式会话的上下文。"""
# 企业微信要求的 stream_id用于标识后续刷新请求
stream_id: str
# 原始消息的 msgid便于与流水线消息对应
msg_id: str
# 群聊会话标识(单聊时为空)
chat_id: Optional[str]
# 触发消息的发送者
user_id: Optional[str]
# 会话创建时间
created_at: float = field(default_factory=time.time)
# 最近一次被访问的时间cleanup 依据该值判断过期
last_access: float = field(default_factory=time.time)
# 将流水线增量结果缓存到队列,刷新请求逐条消费
queue: asyncio.Queue = field(default_factory=asyncio.Queue)
# 是否已经完成(收到最终片段)
finished: bool = False
# 缓存最近一次片段,处理重试或超时兜底
last_chunk: Optional[StreamChunk] = None
class StreamSessionManager:
"""管理 stream 会话的生命周期,并负责队列的生产消费。"""
def __init__(self, logger: EventLogger, ttl: int = 60) -> None:
self.logger = logger
self.ttl = ttl # 超时时间(秒),超过该时间未被访问的会话会被清理由 cleanup
self._sessions: dict[str, StreamSession] = {} # stream_id -> StreamSession 映射
self._msg_index: dict[str, str] = {} # msgid -> stream_id 映射,便于流水线根据消息 ID 找到会话
def get_stream_id_by_msg(self, msg_id: str) -> Optional[str]:
if not msg_id:
return None
return self._msg_index.get(msg_id)
def get_session(self, stream_id: str) -> Optional[StreamSession]:
return self._sessions.get(stream_id)
def create_or_get(self, msg_json: dict[str, Any]) -> tuple[StreamSession, bool]:
"""根据企业微信回调创建或获取会话。
Args:
msg_json: 企业微信解密后的回调 JSON。
Returns:
Tuple[StreamSession, bool]: `StreamSession` 为会话实例,`bool` 指示是否为新建会话。
Example:
在首次回调中调用,得到 `is_new=True` 后再触发流水线。
"""
msg_id = msg_json.get('msgid', '')
if msg_id and msg_id in self._msg_index:
stream_id = self._msg_index[msg_id]
session = self._sessions.get(stream_id)
if session:
session.last_access = time.time()
return session, False
stream_id = str(uuid.uuid4())
session = StreamSession(
stream_id=stream_id,
msg_id=msg_id,
chat_id=msg_json.get('chatid'),
user_id=msg_json.get('from', {}).get('userid'),
)
if msg_id:
self._msg_index[msg_id] = stream_id
self._sessions[stream_id] = session
return session, True
async def publish(self, stream_id: str, chunk: StreamChunk) -> bool:
"""向 stream 队列写入新的增量片段。
Args:
stream_id: 企业微信分配的流式会话 ID。
chunk: 待发送的增量片段。
Returns:
bool: 当流式队列存在并成功入队时返回 True。
Example:
在收到模型增量后调用 `await manager.publish('sid', StreamChunk('hello'))`。
"""
session = self._sessions.get(stream_id)
if not session:
return False
session.last_access = time.time()
session.last_chunk = chunk
try:
session.queue.put_nowait(chunk)
except asyncio.QueueFull:
# 默认无界队列,此处兜底防御
await session.queue.put(chunk)
if chunk.is_final:
session.finished = True
return True
async def consume(self, stream_id: str, timeout: float = 0.5) -> Optional[StreamChunk]:
"""从队列中取出一个片段,若超时返回 None。
Args:
stream_id: 企业微信流式会话 ID。
timeout: 取片段的最长等待时间(秒)。
Returns:
Optional[StreamChunk]: 成功时返回片段,超时或会话不存在时返回 None。
Example:
企业微信刷新到达时调用,若队列有数据则立即返回 `StreamChunk`。
"""
session = self._sessions.get(stream_id)
if not session:
return None
session.last_access = time.time()
try:
chunk = await asyncio.wait_for(session.queue.get(), timeout)
session.last_access = time.time()
if chunk.is_final:
session.finished = True
return chunk
except asyncio.TimeoutError:
if session.finished and session.last_chunk:
return session.last_chunk
return None
def mark_finished(self, stream_id: str) -> None:
session = self._sessions.get(stream_id)
if session:
session.finished = True
session.last_access = time.time()
def cleanup(self) -> None:
"""定期清理过期会话,防止队列与映射无上限累积。"""
now = time.time()
expired: list[str] = []
for stream_id, session in self._sessions.items():
if now - session.last_access > self.ttl:
expired.append(stream_id)
for stream_id in expired:
session = self._sessions.pop(stream_id, None)
if not session:
continue
msg_id = session.msg_id
if msg_id and self._msg_index.get(msg_id) == stream_id:
self._msg_index.pop(msg_id, None)
class WecomBotClient:
def __init__(self,Token:str,EnCodingAESKey:str,Corpid:str,logger:EventLogger):
self.Token=Token
self.EnCodingAESKey=EnCodingAESKey
self.Corpid=Corpid
def __init__(self, Token: str, EnCodingAESKey: str, Corpid: str, logger: EventLogger):
"""企业微信智能机器人客户端。
Args:
Token: 企业微信回调验证使用的 token。
EnCodingAESKey: 企业微信消息加解密密钥。
Corpid: 企业 ID。
logger: 日志记录器。
Example:
>>> client = WecomBotClient(Token='token', EnCodingAESKey='aeskey', Corpid='corp', logger=logger)
"""
self.Token = Token
self.EnCodingAESKey = EnCodingAESKey
self.Corpid = Corpid
self.ReceiveId = ''
self.app = Quart(__name__)
self.app.add_url_rule(
'/callback/command',
'handle_callback',
self.handle_callback_request,
methods=['POST','GET']
methods=['POST', 'GET']
)
self._message_handlers = {
'example': [],
}
self.user_stream_map = {}
self.logger = logger
self.generated_content = {}
self.msg_id_map = {}
self.generated_content: dict[str, str] = {}
self.msg_id_map: dict[str, int] = {}
self.stream_sessions = StreamSessionManager(logger=logger)
self.stream_poll_timeout = 0.5
async def sha1_signature(token: str, timestamp: str, nonce: str, encrypt: str) -> str:
raw = "".join(sorted([token, timestamp, nonce, encrypt]))
return hashlib.sha1(raw.encode("utf-8")).hexdigest()
async def handle_callback_request(self):
@staticmethod
def _build_stream_payload(stream_id: str, content: str, finish: bool) -> dict[str, Any]:
"""按照企业微信协议拼装返回报文。
Args:
stream_id: 企业微信会话 ID。
content: 推送的文本内容。
finish: 是否为最终片段。
Returns:
dict[str, Any]: 可直接加密返回的 payload。
Example:
组装 `{'msgtype': 'stream', 'stream': {'id': 'sid', ...}}` 结构。
"""
return {
'msgtype': 'stream',
'stream': {
'id': stream_id,
'finish': finish,
'content': content,
},
}
async def _encrypt_and_reply(self, payload: dict[str, Any], nonce: str) -> tuple[Response, int]:
"""对响应进行加密封装并返回给企业微信。
Args:
payload: 待加密的响应内容。
nonce: 企业微信回调参数中的 nonce。
Returns:
Tuple[Response, int]: Quart Response 对象及状态码。
Example:
在首包或刷新场景中调用以生成加密响应。
"""
reply_plain_str = json.dumps(payload, ensure_ascii=False)
reply_timestamp = str(int(time.time()))
ret, encrypt_text = self.wxcpt.EncryptMsg(reply_plain_str, nonce, reply_timestamp)
if ret != 0:
await self.logger.error(f'加密失败: {ret}')
return jsonify({'error': 'encrypt_failed'}), 500
root = ET.fromstring(encrypt_text)
encrypt = root.find('Encrypt').text
resp = {
'encrypt': encrypt,
}
return jsonify(resp), 200
async def _dispatch_event(self, event: wecombotevent.WecomBotEvent) -> None:
"""异步触发流水线处理,避免阻塞首包响应。
Args:
event: 由企业微信消息转换的内部事件对象。
"""
try:
self.wxcpt=WXBizMsgCrypt(self.Token,self.EnCodingAESKey,'')
if request.method == "GET":
msg_signature = unquote(request.args.get("msg_signature", ""))
timestamp = unquote(request.args.get("timestamp", ""))
nonce = unquote(request.args.get("nonce", ""))
echostr = unquote(request.args.get("echostr", ""))
if not all([msg_signature, timestamp, nonce, echostr]):
await self.logger.error("请求参数缺失")
return Response("缺少参数", status=400)
ret, decrypted_str = self.wxcpt.VerifyURL(msg_signature, timestamp, nonce, echostr)
if ret != 0:
await self.logger.error("验证URL失败")
return Response("验证失败", status=403)
return Response(decrypted_str, mimetype="text/plain")
elif request.method == "POST":
msg_signature = unquote(request.args.get("msg_signature", ""))
timestamp = unquote(request.args.get("timestamp", ""))
nonce = unquote(request.args.get("nonce", ""))
try:
timeout = 3
interval = 0.1
start_time = time.monotonic()
encrypted_json = await request.get_json()
encrypted_msg = encrypted_json.get("encrypt", "")
if not encrypted_msg:
await self.logger.error("请求体中缺少 'encrypt' 字段")
xml_post_data = f"<xml><Encrypt><![CDATA[{encrypted_msg}]]></Encrypt></xml>"
ret, decrypted_xml = self.wxcpt.DecryptMsg(xml_post_data, msg_signature, timestamp, nonce)
if ret != 0:
await self.logger.error("解密失败")
msg_json = json.loads(decrypted_xml)
from_user_id = msg_json.get("from", {}).get("userid")
chatid = msg_json.get("chatid", "")
message_data = await self.get_message(msg_json)
if message_data:
try:
event = wecombotevent.WecomBotEvent(message_data)
if event:
await self._handle_message(event)
except Exception as e:
await self.logger.error(traceback.format_exc())
print(traceback.format_exc())
start_time = time.time()
try:
if msg_json.get('chattype','') == 'single':
if from_user_id in self.user_stream_map:
stream_id = self.user_stream_map[from_user_id]
else:
stream_id =str(uuid.uuid4())
self.user_stream_map[from_user_id] = stream_id
else:
if chatid in self.user_stream_map:
stream_id = self.user_stream_map[chatid]
else:
stream_id = str(uuid.uuid4())
self.user_stream_map[chatid] = stream_id
except Exception as e:
await self.logger.error(traceback.format_exc())
print(traceback.format_exc())
while True:
content = self.generated_content.pop(msg_json['msgid'],None)
if content:
reply_plain = {
"msgtype": "stream",
"stream": {
"id": stream_id,
"finish": True,
"content": content
}
}
reply_plain_str = json.dumps(reply_plain, ensure_ascii=False)
reply_timestamp = str(int(time.time()))
ret, encrypt_text = self.wxcpt.EncryptMsg(reply_plain_str, nonce, reply_timestamp)
if ret != 0:
await self.logger.error("加密失败"+str(ret))
root = ET.fromstring(encrypt_text)
encrypt = root.find("Encrypt").text
resp = {
"encrypt": encrypt,
}
return jsonify(resp), 200
if time.time() - start_time > timeout:
break
await asyncio.sleep(interval)
if self.msg_id_map.get(message_data['msgid'], 1) == 3:
await self.logger.error('请求失效暂不支持智能机器人超过7秒的请求如有需求请联系 LangBot 团队。')
return ''
except Exception as e:
await self.logger.error(traceback.format_exc())
print(traceback.format_exc())
except Exception as e:
await self._handle_message(event)
except Exception:
await self.logger.error(traceback.format_exc())
print(traceback.format_exc())
async def get_message(self,msg_json):
async def _handle_post_initial_response(self, msg_json: dict[str, Any], nonce: str) -> tuple[Response, int]:
"""处理企业微信首次推送的消息,返回 stream_id 并开启流水线。
Args:
msg_json: 解密后的企业微信消息 JSON。
nonce: 企业微信回调参数 nonce。
Returns:
Tuple[Response, int]: Quart Response 及状态码。
Example:
首次回调时调用,立即返回带 `stream_id` 的响应。
"""
session, is_new = self.stream_sessions.create_or_get(msg_json)
message_data = await self.get_message(msg_json)
if message_data:
message_data['stream_id'] = session.stream_id
try:
event = wecombotevent.WecomBotEvent(message_data)
except Exception:
await self.logger.error(traceback.format_exc())
else:
if is_new:
asyncio.create_task(self._dispatch_event(event))
payload = self._build_stream_payload(session.stream_id, '', False)
return await self._encrypt_and_reply(payload, nonce)
async def _handle_post_followup_response(self, msg_json: dict[str, Any], nonce: str) -> tuple[Response, int]:
"""处理企业微信的流式刷新请求,按需返回增量片段。
Args:
msg_json: 解密后的企业微信刷新请求。
nonce: 企业微信回调参数 nonce。
Returns:
Tuple[Response, int]: Quart Response 及状态码。
Example:
在刷新请求中调用,按需返回增量片段。
"""
stream_info = msg_json.get('stream', {})
stream_id = stream_info.get('id', '')
if not stream_id:
await self.logger.error('刷新请求缺少 stream.id')
return await self._encrypt_and_reply(self._build_stream_payload('', '', True), nonce)
session = self.stream_sessions.get_session(stream_id)
chunk = await self.stream_sessions.consume(stream_id, timeout=self.stream_poll_timeout)
if not chunk:
cached_content = None
if session and session.msg_id:
cached_content = self.generated_content.pop(session.msg_id, None)
if cached_content is not None:
chunk = StreamChunk(content=cached_content, is_final=True)
else:
payload = self._build_stream_payload(stream_id, '', False)
return await self._encrypt_and_reply(payload, nonce)
payload = self._build_stream_payload(stream_id, chunk.content, chunk.is_final)
if chunk.is_final:
self.stream_sessions.mark_finished(stream_id)
return await self._encrypt_and_reply(payload, nonce)
async def handle_callback_request(self):
"""企业微信回调入口。
Returns:
Quart Response: 根据请求类型返回验证、首包或刷新结果。
Example:
作为 Quart 路由处理函数直接注册并使用。
"""
try:
self.wxcpt = WXBizMsgCrypt(self.Token, self.EnCodingAESKey, '')
await self.logger.info(f'{request.method} {request.url} {str(request.args)}')
if request.method == 'GET':
return await self._handle_get_callback()
if request.method == 'POST':
return await self._handle_post_callback()
return Response('', status=405)
except Exception:
await self.logger.error(traceback.format_exc())
return Response('Internal Server Error', status=500)
async def _handle_get_callback(self) -> tuple[Response, int] | Response:
"""处理企业微信的 GET 验证请求。"""
msg_signature = unquote(request.args.get('msg_signature', ''))
timestamp = unquote(request.args.get('timestamp', ''))
nonce = unquote(request.args.get('nonce', ''))
echostr = unquote(request.args.get('echostr', ''))
if not all([msg_signature, timestamp, nonce, echostr]):
await self.logger.error('请求参数缺失')
return Response('缺少参数', status=400)
ret, decrypted_str = self.wxcpt.VerifyURL(msg_signature, timestamp, nonce, echostr)
if ret != 0:
await self.logger.error('验证URL失败')
return Response('验证失败', status=403)
return Response(decrypted_str, mimetype='text/plain')
async def _handle_post_callback(self) -> tuple[Response, int] | Response:
"""处理企业微信的 POST 回调请求。"""
self.stream_sessions.cleanup()
msg_signature = unquote(request.args.get('msg_signature', ''))
timestamp = unquote(request.args.get('timestamp', ''))
nonce = unquote(request.args.get('nonce', ''))
encrypted_json = await request.get_json()
encrypted_msg = (encrypted_json or {}).get('encrypt', '')
if not encrypted_msg:
await self.logger.error("请求体中缺少 'encrypt' 字段")
return Response('Bad Request', status=400)
xml_post_data = f"<xml><Encrypt><![CDATA[{encrypted_msg}]]></Encrypt></xml>"
ret, decrypted_xml = self.wxcpt.DecryptMsg(xml_post_data, msg_signature, timestamp, nonce)
if ret != 0:
await self.logger.error('解密失败')
return Response('解密失败', status=400)
msg_json = json.loads(decrypted_xml)
if msg_json.get('msgtype') == 'stream':
return await self._handle_post_followup_response(msg_json, nonce)
return await self._handle_post_initial_response(msg_json, nonce)
async def get_message(self, msg_json):
message_data = {}
if msg_json.get('chattype','') == 'single':
if msg_json.get('chattype', '') == 'single':
message_data['type'] = 'single'
elif msg_json.get('chattype','') == 'group':
elif msg_json.get('chattype', '') == 'group':
message_data['type'] = 'group'
if msg_json.get('msgtype') == 'text':
message_data['content'] = msg_json.get('text',{}).get('content')
message_data['content'] = msg_json.get('text', {}).get('content')
elif msg_json.get('msgtype') == 'image':
picurl = msg_json.get('image', {}).get('url','')
base64 = await self.download_url_to_base64(picurl,self.EnCodingAESKey)
message_data['picurl'] = base64
picurl = msg_json.get('image', {}).get('url', '')
base64 = await self.download_url_to_base64(picurl, self.EnCodingAESKey)
message_data['picurl'] = base64
elif msg_json.get('msgtype') == 'mixed':
items = msg_json.get('mixed', {}).get('msg_item', [])
texts = []
@@ -197,8 +460,8 @@ class WecomBotClient:
if texts:
message_data['content'] = "".join(texts) # 拼接所有 text
if picurl:
base64 = await self.download_url_to_base64(picurl,self.EnCodingAESKey)
message_data['picurl'] = base64 # 只保留第一个 image
base64 = await self.download_url_to_base64(picurl, self.EnCodingAESKey)
message_data['picurl'] = base64 # 只保留第一个 image
message_data['userid'] = msg_json.get('from', {}).get('userid', '')
message_data['msgid'] = msg_json.get('msgid', '')
@@ -207,7 +470,7 @@ class WecomBotClient:
message_data['aibotid'] = msg_json.get('aibotid', '')
return message_data
async def _handle_message(self, event: wecombotevent.WecomBotEvent):
"""
处理消息事件。
@@ -223,10 +486,46 @@ class WecomBotClient:
for handler in self._message_handlers[msg_type]:
await handler(event)
except Exception:
print(traceback.format_exc())
print(traceback.format_exc())
async def push_stream_chunk(self, msg_id: str, content: str, is_final: bool = False) -> bool:
"""将流水线片段推送到 stream 会话。
Args:
msg_id: 原始企业微信消息 ID。
content: 模型产生的片段内容。
is_final: 是否为最终片段。
Returns:
bool: 当成功写入流式队列时返回 True。
Example:
在流水线 `reply_message_chunk` 中调用,将增量推送至企业微信。
"""
# 根据 msg_id 找到对应 stream 会话,如果不存在说明当前消息非流式
stream_id = self.stream_sessions.get_stream_id_by_msg(msg_id)
if not stream_id:
return False
chunk = StreamChunk(content=content, is_final=is_final)
await self.stream_sessions.publish(stream_id, chunk)
if is_final:
self.stream_sessions.mark_finished(stream_id)
return True
async def set_message(self, msg_id: str, content: str):
self.generated_content[msg_id] = content
"""兼容旧逻辑:若无法流式返回则缓存最终结果。
Args:
msg_id: 企业微信消息 ID。
content: 最终回复的文本内容。
Example:
在非流式场景下缓存最终结果以备刷新时返回。
"""
handled = await self.push_stream_chunk(msg_id, content, is_final=True)
if not handled:
self.generated_content[msg_id] = content
def on_message(self, msg_type: str):
def decorator(func: Callable[[wecombotevent.WecomBotEvent], None]):
@@ -237,7 +536,6 @@ class WecomBotClient:
return decorator
async def download_url_to_base64(self, download_url, encoding_aes_key):
async with httpx.AsyncClient() as client:
response = await client.get(download_url)
@@ -247,26 +545,22 @@ class WecomBotClient:
encrypted_bytes = response.content
aes_key = base64.b64decode(encoding_aes_key + "=") # base64 补齐
iv = aes_key[:16]
cipher = AES.new(aes_key, AES.MODE_CBC, iv)
decrypted = cipher.decrypt(encrypted_bytes)
pad_len = decrypted[-1]
decrypted = decrypted[:-pad_len]
if decrypted.startswith(b"\xff\xd8"): # JPEG
if decrypted.startswith(b"\xff\xd8"): # JPEG
mime_type = "image/jpeg"
elif decrypted.startswith(b"\x89PNG"): # PNG
mime_type = "image/png"
elif decrypted.startswith((b"GIF87a", b"GIF89a")): # GIF
mime_type = "image/gif"
elif decrypted.startswith(b"BM"): # BMP
elif decrypted.startswith(b"BM"): # BMP
mime_type = "image/bmp"
elif decrypted.startswith(b"II*\x00") or decrypted.startswith(b"MM\x00*"): # TIFF
mime_type = "image/tiff"
@@ -276,15 +570,9 @@ class WecomBotClient:
# 转 base64
base64_str = base64.b64encode(decrypted).decode("utf-8")
return f"data:{mime_type};base64,{base64_str}"
async def run_task(self, host: str, port: int, *args, **kwargs):
"""
启动 Quart 应用。
"""
await self.app.run_task(host=host, port=port, *args, **kwargs)

View File

@@ -2,6 +2,8 @@ from __future__ import annotations
import base64
import quart
import re
import httpx
from .....core import taskmgr
from .. import group
@@ -45,9 +47,12 @@ class PluginsRouterGroup(group.RouterGroup):
return self.http_status(404, -1, 'plugin not found')
return self.success(data={'plugin': plugin})
elif quart.request.method == 'DELETE':
delete_data = quart.request.args.get('delete_data', 'false').lower() == 'true'
ctx = taskmgr.TaskContext.new()
wrapper = self.ap.task_mgr.create_user_task(
self.ap.plugin_connector.delete_plugin(author, plugin_name, task_context=ctx),
self.ap.plugin_connector.delete_plugin(
author, plugin_name, delete_data=delete_data, task_context=ctx
),
kind='plugin-operation',
name=f'plugin-remove-{plugin_name}',
label=f'Removing plugin {plugin_name}',
@@ -89,23 +94,145 @@ class PluginsRouterGroup(group.RouterGroup):
return quart.Response(icon_data, mimetype=mime_type)
@self.route('/github/releases', methods=['POST'], auth_type=group.AuthType.USER_TOKEN)
async def _() -> str:
"""Get releases from a GitHub repository URL"""
data = await quart.request.json
repo_url = data.get('repo_url', '')
# Parse GitHub repository URL to extract owner and repo
# Supports: https://github.com/owner/repo or github.com/owner/repo
pattern = r'github\.com/([^/]+)/([^/]+?)(?:\.git)?(?:/.*)?$'
match = re.search(pattern, repo_url)
if not match:
return self.http_status(400, -1, 'Invalid GitHub repository URL')
owner, repo = match.groups()
try:
# Fetch releases from GitHub API
url = f'https://api.github.com/repos/{owner}/{repo}/releases'
async with httpx.AsyncClient(
trust_env=True,
follow_redirects=True,
timeout=10,
) as client:
response = await client.get(url)
response.raise_for_status()
releases = response.json()
# Format releases data for frontend
formatted_releases = []
for release in releases:
formatted_releases.append(
{
'id': release['id'],
'tag_name': release['tag_name'],
'name': release['name'],
'published_at': release['published_at'],
'prerelease': release['prerelease'],
'draft': release['draft'],
}
)
return self.success(data={'releases': formatted_releases, 'owner': owner, 'repo': repo})
except httpx.RequestError as e:
return self.http_status(500, -1, f'Failed to fetch releases: {str(e)}')
@self.route(
'/github/release-assets',
methods=['POST'],
auth_type=group.AuthType.USER_TOKEN,
)
async def _() -> str:
"""Get assets from a specific GitHub release"""
data = await quart.request.json
owner = data.get('owner', '')
repo = data.get('repo', '')
release_id = data.get('release_id', '')
if not all([owner, repo, release_id]):
return self.http_status(400, -1, 'Missing required parameters')
try:
# Fetch release assets from GitHub API
url = f'https://api.github.com/repos/{owner}/{repo}/releases/{release_id}'
async with httpx.AsyncClient(
trust_env=True,
follow_redirects=True,
timeout=10,
) as client:
response = await client.get(
url,
)
response.raise_for_status()
release = response.json()
# Format assets data for frontend
formatted_assets = []
for asset in release.get('assets', []):
formatted_assets.append(
{
'id': asset['id'],
'name': asset['name'],
'size': asset['size'],
'download_url': asset['browser_download_url'],
'content_type': asset['content_type'],
}
)
# add zipball as a downloadable asset
# formatted_assets.append(
# {
# "id": 0,
# "name": "Source code (zip)",
# "size": -1,
# "download_url": release["zipball_url"],
# "content_type": "application/zip",
# }
# )
return self.success(data={'assets': formatted_assets})
except httpx.RequestError as e:
return self.http_status(500, -1, f'Failed to fetch release assets: {str(e)}')
@self.route('/install/github', methods=['POST'], auth_type=group.AuthType.USER_TOKEN)
async def _() -> str:
"""Install plugin from GitHub release asset"""
data = await quart.request.json
asset_url = data.get('asset_url', '')
owner = data.get('owner', '')
repo = data.get('repo', '')
release_tag = data.get('release_tag', '')
if not asset_url:
return self.http_status(400, -1, 'Missing asset_url parameter')
ctx = taskmgr.TaskContext.new()
short_source_str = data['source'][-8:]
install_info = {
'asset_url': asset_url,
'owner': owner,
'repo': repo,
'release_tag': release_tag,
'github_url': f'https://github.com/{owner}/{repo}',
}
wrapper = self.ap.task_mgr.create_user_task(
self.ap.plugin_mgr.install_plugin(data['source'], task_context=ctx),
self.ap.plugin_connector.install_plugin(PluginInstallSource.GITHUB, install_info, task_context=ctx),
kind='plugin-operation',
name='plugin-install-github',
label=f'Installing plugin from github ...{short_source_str}',
label=f'Installing plugin from GitHub {owner}/{repo}@{release_tag}',
context=ctx,
)
return self.success(data={'task_id': wrapper.id})
@self.route('/install/marketplace', methods=['POST'], auth_type=group.AuthType.USER_TOKEN)
@self.route(
'/install/marketplace',
methods=['POST'],
auth_type=group.AuthType.USER_TOKEN,
)
async def _() -> str:
data = await quart.request.json

View File

@@ -0,0 +1,62 @@
from __future__ import annotations
import quart
import traceback
from ... import group
@group.group_class('mcp', '/api/v1/mcp')
class MCPRouterGroup(group.RouterGroup):
async def initialize(self) -> None:
@self.route('/servers', methods=['GET', 'POST'], auth_type=group.AuthType.USER_TOKEN)
async def _() -> str:
"""获取MCP服务器列表"""
if quart.request.method == 'GET':
servers = await self.ap.mcp_service.get_mcp_servers(contain_runtime_info=True)
return self.success(data={'servers': servers})
elif quart.request.method == 'POST':
data = await quart.request.json
try:
uuid = await self.ap.mcp_service.create_mcp_server(data)
return self.success(data={'uuid': uuid})
except Exception as e:
traceback.print_exc()
return self.http_status(500, -1, f'Failed to create MCP server: {str(e)}')
@self.route('/servers/<server_name>', methods=['GET', 'PUT', 'DELETE'], auth_type=group.AuthType.USER_TOKEN)
async def _(server_name: str) -> str:
"""获取、更新或删除MCP服务器配置"""
server_data = await self.ap.mcp_service.get_mcp_server_by_name(server_name)
if server_data is None:
return self.http_status(404, -1, 'Server not found')
if quart.request.method == 'GET':
return self.success(data={'server': server_data})
elif quart.request.method == 'PUT':
data = await quart.request.json
try:
await self.ap.mcp_service.update_mcp_server(server_data['uuid'], data)
return self.success()
except Exception as e:
return self.http_status(500, -1, f'Failed to update MCP server: {str(e)}')
elif quart.request.method == 'DELETE':
try:
await self.ap.mcp_service.delete_mcp_server(server_data['uuid'])
return self.success()
except Exception as e:
return self.http_status(500, -1, f'Failed to delete MCP server: {str(e)}')
@self.route('/servers/<server_name>/test', methods=['POST'], auth_type=group.AuthType.USER_TOKEN)
async def _(server_name: str) -> str:
"""测试MCP服务器连接"""
server_data = await quart.request.json
task_id = await self.ap.mcp_service.test_mcp_server(server_name=server_name, server_data=server_data)
return self.success(data={'task_id': task_id})

View File

@@ -15,12 +15,14 @@ from .groups import provider as groups_provider
from .groups import platform as groups_platform
from .groups import pipelines as groups_pipelines
from .groups import knowledge as groups_knowledge
from .groups import resources as groups_resources
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)
importutil.import_modules_in_pkg(groups_knowledge)
importutil.import_modules_in_pkg(groups_resources)
class HTTPController:

158
pkg/api/http/service/mcp.py Normal file
View File

@@ -0,0 +1,158 @@
from __future__ import annotations
import sqlalchemy
import uuid
import asyncio
from ....core import app
from ....entity.persistence import mcp as persistence_mcp
from ....core import taskmgr
from ....provider.tools.loaders.mcp import RuntimeMCPSession, MCPSessionStatus
class MCPService:
ap: app.Application
def __init__(self, ap: app.Application) -> None:
self.ap = ap
async def get_runtime_info(self, server_name: str) -> dict | None:
session = self.ap.tool_mgr.mcp_tool_loader.get_session(server_name)
if session:
return session.get_runtime_info_dict()
return None
async def get_mcp_servers(self, contain_runtime_info: bool = False) -> list[dict]:
result = await self.ap.persistence_mgr.execute_async(sqlalchemy.select(persistence_mcp.MCPServer))
servers = result.all()
serialized_servers = [
self.ap.persistence_mgr.serialize_model(persistence_mcp.MCPServer, server) for server in servers
]
if contain_runtime_info:
for server in serialized_servers:
runtime_info = await self.get_runtime_info(server['name'])
server['runtime_info'] = runtime_info if runtime_info else None
return serialized_servers
async def create_mcp_server(self, server_data: dict) -> str:
server_data['uuid'] = str(uuid.uuid4())
await self.ap.persistence_mgr.execute_async(sqlalchemy.insert(persistence_mcp.MCPServer).values(server_data))
result = await self.ap.persistence_mgr.execute_async(
sqlalchemy.select(persistence_mcp.MCPServer).where(persistence_mcp.MCPServer.uuid == server_data['uuid'])
)
server_entity = result.first()
if server_entity:
server_config = self.ap.persistence_mgr.serialize_model(persistence_mcp.MCPServer, server_entity)
if self.ap.tool_mgr.mcp_tool_loader:
task = asyncio.create_task(self.ap.tool_mgr.mcp_tool_loader.host_mcp_server(server_config))
self.ap.tool_mgr.mcp_tool_loader._hosted_mcp_tasks.append(task)
return server_data['uuid']
async def get_mcp_server_by_name(self, server_name: str) -> dict | None:
result = await self.ap.persistence_mgr.execute_async(
sqlalchemy.select(persistence_mcp.MCPServer).where(persistence_mcp.MCPServer.name == server_name)
)
server = result.first()
if server is None:
return None
runtime_info = await self.get_runtime_info(server.name)
server_data = self.ap.persistence_mgr.serialize_model(persistence_mcp.MCPServer, server)
server_data['runtime_info'] = runtime_info if runtime_info else None
return server_data
async def update_mcp_server(self, server_uuid: str, server_data: dict) -> None:
result = await self.ap.persistence_mgr.execute_async(
sqlalchemy.select(persistence_mcp.MCPServer).where(persistence_mcp.MCPServer.uuid == server_uuid)
)
old_server = result.first()
old_server_name = old_server.name if old_server else None
old_enable = old_server.enable if old_server else False
await self.ap.persistence_mgr.execute_async(
sqlalchemy.update(persistence_mcp.MCPServer)
.where(persistence_mcp.MCPServer.uuid == server_uuid)
.values(server_data)
)
if self.ap.tool_mgr.mcp_tool_loader:
new_enable = server_data.get('enable', False)
need_remove = old_server_name and old_server_name in self.ap.tool_mgr.mcp_tool_loader.sessions
need_start = new_enable
if old_enable and not new_enable:
if need_remove:
await self.ap.tool_mgr.mcp_tool_loader.remove_mcp_server(old_server_name)
elif not old_enable and new_enable:
result = await self.ap.persistence_mgr.execute_async(
sqlalchemy.select(persistence_mcp.MCPServer).where(persistence_mcp.MCPServer.uuid == server_uuid)
)
updated_server = result.first()
if updated_server:
server_config = self.ap.persistence_mgr.serialize_model(persistence_mcp.MCPServer, updated_server)
task = asyncio.create_task(self.ap.tool_mgr.mcp_tool_loader.host_mcp_server(server_config))
self.ap.tool_mgr.mcp_tool_loader._hosted_mcp_tasks.append(task)
elif old_enable and new_enable:
if need_remove:
await self.ap.tool_mgr.mcp_tool_loader.remove_mcp_server(old_server_name)
result = await self.ap.persistence_mgr.execute_async(
sqlalchemy.select(persistence_mcp.MCPServer).where(persistence_mcp.MCPServer.uuid == server_uuid)
)
updated_server = result.first()
if updated_server:
server_config = self.ap.persistence_mgr.serialize_model(persistence_mcp.MCPServer, updated_server)
task = asyncio.create_task(self.ap.tool_mgr.mcp_tool_loader.host_mcp_server(server_config))
self.ap.tool_mgr.mcp_tool_loader._hosted_mcp_tasks.append(task)
async def delete_mcp_server(self, server_uuid: str) -> None:
result = await self.ap.persistence_mgr.execute_async(
sqlalchemy.select(persistence_mcp.MCPServer).where(persistence_mcp.MCPServer.uuid == server_uuid)
)
server = result.first()
server_name = server.name if server else None
await self.ap.persistence_mgr.execute_async(
sqlalchemy.delete(persistence_mcp.MCPServer).where(persistence_mcp.MCPServer.uuid == server_uuid)
)
if server_name and self.ap.tool_mgr.mcp_tool_loader:
if server_name in self.ap.tool_mgr.mcp_tool_loader.sessions:
await self.ap.tool_mgr.mcp_tool_loader.remove_mcp_server(server_name)
async def test_mcp_server(self, server_name: str, server_data: dict) -> int:
"""测试 MCP 服务器连接并返回任务 ID"""
runtime_mcp_session: RuntimeMCPSession | None = None
if server_name != '_':
runtime_mcp_session = self.ap.tool_mgr.mcp_tool_loader.get_session(server_name)
if runtime_mcp_session is None:
raise ValueError(f'Server not found: {server_name}')
if runtime_mcp_session.status == MCPSessionStatus.ERROR:
coroutine = runtime_mcp_session.start()
else:
coroutine = runtime_mcp_session.refresh()
else:
runtime_mcp_session = await self.ap.tool_mgr.mcp_tool_loader.load_mcp_server(server_config=server_data)
coroutine = runtime_mcp_session.start()
ctx = taskmgr.TaskContext.new()
wrapper = self.ap.task_mgr.create_user_task(
coroutine,
kind='mcp-operation',
name=f'mcp-test-{server_name}',
label=f'Testing MCP server {server_name}',
context=ctx,
)
return wrapper.id

View File

@@ -22,6 +22,7 @@ from ..api.http.service import model as model_service
from ..api.http.service import pipeline as pipeline_service
from ..api.http.service import bot as bot_service
from ..api.http.service import knowledge as knowledge_service
from ..api.http.service import mcp as mcp_service
from ..discover import engine as discover_engine
from ..storage import mgr as storagemgr
from ..utils import logcache
@@ -119,6 +120,8 @@ class Application:
knowledge_service: knowledge_service.KnowledgeService = None
mcp_service: mcp_service.MCPService = None
def __init__(self):
pass

View File

@@ -19,6 +19,7 @@ from ...api.http.service import model as model_service
from ...api.http.service import pipeline as pipeline_service
from ...api.http.service import bot as bot_service
from ...api.http.service import knowledge as knowledge_service
from ...api.http.service import mcp as mcp_service
from ...discover import engine as discover_engine
from ...storage import mgr as storagemgr
from ...utils import logcache
@@ -126,5 +127,8 @@ class BuildAppStage(stage.BootingStage):
knowledge_service_inst = knowledge_service.KnowledgeService(ap)
ap.knowledge_service = knowledge_service_inst
mcp_service_inst = mcp_service.MCPService(ap)
ap.mcp_service = mcp_service_inst
ctrl = controller.Controller(ap)
ap.ctrl = ctrl

View File

@@ -156,7 +156,7 @@ class TaskWrapper:
'state': self.task._state,
'exception': self.assume_exception().__str__() if self.assume_exception() is not None else None,
'exception_traceback': exception_traceback,
'result': self.assume_result().__str__() if self.assume_result() is not None else None,
'result': self.assume_result() if self.assume_result() is not None else None,
},
}

View File

@@ -0,0 +1,20 @@
import sqlalchemy
from .base import Base
class MCPServer(Base):
__tablename__ = 'mcp_servers'
uuid = sqlalchemy.Column(sqlalchemy.String(255), primary_key=True, unique=True)
name = sqlalchemy.Column(sqlalchemy.String(255), nullable=False)
enable = sqlalchemy.Column(sqlalchemy.Boolean, nullable=False, default=False)
mode = sqlalchemy.Column(sqlalchemy.String(255), nullable=False) # stdio, sse
extra_args = sqlalchemy.Column(sqlalchemy.JSON, nullable=False, default={})
created_at = sqlalchemy.Column(sqlalchemy.DateTime, nullable=False, server_default=sqlalchemy.func.now())
updated_at = sqlalchemy.Column(
sqlalchemy.DateTime,
nullable=False,
server_default=sqlalchemy.func.now(),
onupdate=sqlalchemy.func.now(),
)

View File

@@ -213,7 +213,7 @@ class RuntimePipeline:
await self._execute_from_stage(0, query)
except Exception as e:
inst_name = query.current_stage_name if query.current_stage_name else 'unknown'
self.ap.logger.error(f'处理请求时出错 query_id={query.query_id} stage={inst_name} : {e}')
self.ap.logger.error(f'Error processing query {query.query_id} stage={inst_name} : {e}')
self.ap.logger.error(f'Traceback: {traceback.format_exc()}')
finally:
self.ap.logger.debug(f'Query {query.query_id} processed')

View File

@@ -35,11 +35,17 @@ class PreProcessor(stage.PipelineStage):
session = await self.ap.sess_mgr.get_session(query)
# When not local-agent, llm_model is None
llm_model = (
await self.ap.model_mgr.get_model_by_uuid(query.pipeline_config['ai']['local-agent']['model'])
if selected_runner == 'local-agent'
else None
)
try:
llm_model = (
await self.ap.model_mgr.get_model_by_uuid(query.pipeline_config['ai']['local-agent']['model'])
if selected_runner == 'local-agent'
else None
)
except ValueError:
self.ap.logger.warning(
f'LLM model {query.pipeline_config["ai"]["local-agent"]["model"] + " "}not found or not configured'
)
llm_model = None
conversation = await self.ap.sess_mgr.get_conversation(
query,
@@ -54,7 +60,7 @@ class PreProcessor(stage.PipelineStage):
query.prompt = conversation.prompt.copy()
query.messages = conversation.messages.copy()
if selected_runner == 'local-agent':
if selected_runner == 'local-agent' and llm_model:
query.use_funcs = []
query.use_llm_model_uuid = llm_model.model_entity.uuid
@@ -72,7 +78,11 @@ class PreProcessor(stage.PipelineStage):
# Check if this model supports vision, if not, remove all images
# TODO this checking should be performed in runner, and in this stage, the image should be reserved
if selected_runner == 'local-agent' and not llm_model.model_entity.abilities.__contains__('vision'):
if (
selected_runner == 'local-agent'
and llm_model
and not llm_model.model_entity.abilities.__contains__('vision')
):
for msg in query.messages:
if isinstance(msg.content, list):
for me in msg.content:
@@ -89,7 +99,9 @@ class PreProcessor(stage.PipelineStage):
content_list.append(provider_message.ContentElement.from_text(me.text))
plain_text += me.text
elif isinstance(me, platform_message.Image):
if selected_runner != 'local-agent' or llm_model.model_entity.abilities.__contains__('vision'):
if selected_runner != 'local-agent' or (
llm_model and llm_model.model_entity.abilities.__contains__('vision')
):
if me.base64 is not None:
content_list.append(provider_message.ContentElement.from_image_base64(me.base64))
elif isinstance(me, platform_message.File):
@@ -100,7 +112,9 @@ class PreProcessor(stage.PipelineStage):
if isinstance(msg, platform_message.Plain):
content_list.append(provider_message.ContentElement.from_text(msg.text))
elif isinstance(msg, platform_message.Image):
if selected_runner != 'local-agent' or llm_model.model_entity.abilities.__contains__('vision'):
if selected_runner != 'local-agent' or (
llm_model and llm_model.model_entity.abilities.__contains__('vision')
):
if msg.base64 is not None:
content_list.append(provider_message.ContentElement.from_image_base64(msg.base64))

View File

@@ -9,7 +9,6 @@ from .. import handler
from ... import entities
from ....provider import runner as runner_module
import langbot_plugin.api.entities.builtin.platform.message as platform_message
import langbot_plugin.api.entities.events as events
from ....utils import importutil
from ....provider import runners
@@ -47,18 +46,19 @@ class ChatMessageHandler(handler.MessageHandler):
event_ctx = await self.ap.plugin_connector.emit_event(event)
is_create_card = False # 判断下是否需要创建流式卡片
if event_ctx.is_prevented_default():
if event_ctx.event.reply is not None:
mc = platform_message.MessageChain(event_ctx.event.reply)
if event_ctx.event.reply_message_chain is not None:
mc = event_ctx.event.reply_message_chain
query.resp_messages.append(mc)
yield entities.StageProcessResult(result_type=entities.ResultType.CONTINUE, new_query=query)
else:
yield entities.StageProcessResult(result_type=entities.ResultType.INTERRUPT, new_query=query)
else:
if event_ctx.event.alter is not None:
if event_ctx.event.user_message_alter is not None:
# if isinstance(event_ctx.event, str): # 现在暂时不考虑多模态alter
query.user_message.content = event_ctx.event.alter
query.user_message.content = event_ctx.event.user_message_alter
text_length = 0
try:

View File

@@ -5,7 +5,6 @@ import typing
from .. import handler
from ... import entities
import langbot_plugin.api.entities.builtin.provider.message as provider_message
import langbot_plugin.api.entities.builtin.platform.message as platform_message
import langbot_plugin.api.entities.builtin.provider.session as provider_session
import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query
import langbot_plugin.api.entities.events as events
@@ -49,8 +48,8 @@ class CommandHandler(handler.MessageHandler):
event_ctx = await self.ap.plugin_connector.emit_event(event)
if event_ctx.is_prevented_default():
if event_ctx.event.reply is not None:
mc = platform_message.MessageChain(event_ctx.event.reply)
if event_ctx.event.reply_message_chain is not None:
mc = event_ctx.event.reply_message_chain
query.resp_messages.append(mc)
@@ -59,11 +58,6 @@ class CommandHandler(handler.MessageHandler):
yield entities.StageProcessResult(result_type=entities.ResultType.INTERRUPT, new_query=query)
else:
if event_ctx.event.alter is not None:
query.message_chain = platform_message.MessageChain(
[platform_message.Plain(text=event_ctx.event.alter)]
)
session = await self.ap.sess_mgr.get_session(query)
async for ret in self.ap.cmd_mgr.execute(
@@ -80,8 +74,12 @@ class CommandHandler(handler.MessageHandler):
self.ap.logger.info(f'Command({query.query_id}) error: {self.cut_str(str(ret.error))}')
yield entities.StageProcessResult(result_type=entities.ResultType.CONTINUE, new_query=query)
elif (ret.text is not None or ret.image_url is not None or ret.image_base64 is not None
or ret.file_url is not None):
elif (
ret.text is not None
or ret.image_url is not None
or ret.image_base64 is not None
or ret.file_url is not None
):
content: list[provider_message.ContentElement] = []
if ret.text is not None:

View File

@@ -21,7 +21,7 @@ class AtBotRule(rule_model.GroupRespondRule):
def remove_at(message_chain: platform_message.MessageChain):
nonlocal found
for component in message_chain.root:
if isinstance(component, platform_message.At) and component.target == query.adapter.bot_account_id:
if isinstance(component, platform_message.At) and str(component.target) == str(query.adapter.bot_account_id):
message_chain.remove(component)
found = True
break

View File

@@ -80,8 +80,8 @@ class ResponseWrapper(stage.PipelineStage):
new_query=query,
)
else:
if event_ctx.event.reply is not None:
query.resp_message_chain.append(platform_message.MessageChain(event_ctx.event.reply))
if event_ctx.event.reply_message_chain is not None:
query.resp_message_chain.append(event_ctx.event.reply_message_chain)
else:
query.resp_message_chain.append(result.get_content_platform_message_chain())
@@ -123,10 +123,8 @@ class ResponseWrapper(stage.PipelineStage):
new_query=query,
)
else:
if event_ctx.event.reply is not None:
query.resp_message_chain.append(
platform_message.MessageChain(text=event_ctx.event.reply)
)
if event_ctx.event.reply_message_chain is not None:
query.resp_message_chain.append(event_ctx.event.reply_message_chain)
else:
query.resp_message_chain.append(

View File

@@ -22,6 +22,7 @@ import langbot_plugin.api.definition.abstract.platform.event_logger as abstract_
from ..logger import EventLogger
# 语音功能相关异常定义
class VoiceConnectionError(Exception):
"""语音连接基础异常"""

View File

@@ -139,19 +139,15 @@ class QQOfficialAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter
event_converter: QQOfficialEventConverter = QQOfficialEventConverter()
def __init__(self, config: dict, logger: EventLogger):
self.config = config
self.logger = logger
bot = QQOfficialClient(
app_id=config['appid'], secret=config['secret'], token=config['token'], logger=logger
)
required_keys = [
'appid',
'secret',
]
missing_keys = [key for key in required_keys if key not in config]
if missing_keys:
raise command_errors.ParamNotEnoughError('QQ官方机器人缺少相关配置项请查看文档或联系管理员')
self.bot = QQOfficialClient(
app_id=config['appid'], secret=config['secret'], token=config['token'], logger=self.logger
super().__init__(
config=config,
logger=logger,
bot=bot,
bot_account_id=config['appid'],
)
async def reply_message(

View File

@@ -139,7 +139,7 @@ class WeChatPadMessageConverter(abstract_platform_adapter.AbstractMessageConvert
pattern = r'@\S{1,20}'
content_no_preifx = re.sub(pattern, '', content_no_preifx)
return platform_message.MessageChain([platform_message.Plain(content_no_preifx)])
return platform_message.MessageChain([platform_message.Plain(text=content_no_preifx)])
async def _handler_image(self, message: Optional[dict], content_no_preifx: str) -> platform_message.MessageChain:
"""处理图像消息 (msg_type=3)"""
@@ -265,7 +265,7 @@ class WeChatPadMessageConverter(abstract_platform_adapter.AbstractMessageConvert
# 文本消息
try:
if '<msg>' not in quote_data:
quote_data_message_list.append(platform_message.Plain(quote_data))
quote_data_message_list.append(platform_message.Plain(text=quote_data))
else:
# 引用消息展开
quote_data_xml = ET.fromstring(quote_data)
@@ -280,7 +280,7 @@ class WeChatPadMessageConverter(abstract_platform_adapter.AbstractMessageConvert
quote_data_message_list.extend(await self._handler_compound(None, quote_data))
except Exception as e:
self.logger.error(f'处理引用消息异常 expcetion:{e}')
quote_data_message_list.append(platform_message.Plain(quote_data))
quote_data_message_list.append(platform_message.Plain(text=quote_data))
message_list.append(
platform_message.Quote(
sender_id=sender_id,
@@ -290,7 +290,7 @@ class WeChatPadMessageConverter(abstract_platform_adapter.AbstractMessageConvert
if len(user_data) > 0:
pattern = r'@\S{1,20}'
user_data = re.sub(pattern, '', user_data)
message_list.append(platform_message.Plain(user_data))
message_list.append(platform_message.Plain(text=user_data))
return platform_message.MessageChain(message_list)
@@ -543,7 +543,6 @@ class WeChatPadAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter)
] = {}
def __init__(self, config: dict, logger: EventLogger):
quart_app = quart.Quart(__name__)
message_converter = WeChatPadMessageConverter(config, logger)
@@ -551,15 +550,14 @@ class WeChatPadAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter)
bot = WeChatPadClient(config['wechatpad_url'], config['token'])
super().__init__(
config=config,
logger = logger,
quart_app = quart_app,
message_converter =message_converter,
event_converter = event_converter,
logger=logger,
quart_app=quart_app,
message_converter=message_converter,
event_converter=event_converter,
listeners={},
bot_account_id ='',
name="WeChatPad",
bot_account_id='',
name='WeChatPad',
bot=bot,
)
async def ws_message(self, data):

View File

@@ -117,6 +117,50 @@ class WecomBotAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
content = await self.message_converter.yiri2target(message)
await self.bot.set_message(message_source.source_platform_object.message_id, content)
async def reply_message_chunk(
self,
message_source: platform_events.MessageEvent,
bot_message,
message: platform_message.MessageChain,
quote_origin: bool = False,
is_final: bool = False,
):
"""将流水线增量输出写入企业微信 stream 会话。
Args:
message_source: 流水线提供的原始消息事件。
bot_message: 当前片段对应的模型元信息(未使用)。
message: 需要回复的消息链。
quote_origin: 是否引用原消息(企业微信暂不支持)。
is_final: 标记当前片段是否为最终回复。
Returns:
dict: 包含 `stream` 键,标识写入是否成功。
Example:
在流水线 `reply_message_chunk` 调用中自动触发,无需手动调用。
"""
# 转换为纯文本(智能机器人当前协议仅支持文本流)
content = await self.message_converter.yiri2target(message)
msg_id = message_source.source_platform_object.message_id
# 将片段推送到 WecomBotClient 中的队列,返回值用于判断是否走降级逻辑
success = await self.bot.push_stream_chunk(msg_id, content, is_final=is_final)
if not success and is_final:
# 未命中流式队列时使用旧有 set_message 兜底
await self.bot.set_message(msg_id, content)
return {'stream': success}
async def is_stream_output_supported(self) -> bool:
"""智能机器人侧默认开启流式能力。
Returns:
bool: 恒定返回 True。
Example:
流水线执行阶段会调用此方法以确认是否启用流式。"""
return True
async def send_message(self, target_type, target_id, message):
pass

View File

@@ -6,19 +6,24 @@ from typing import Any
import typing
import os
import sys
import httpx
from async_lru import alru_cache
from ..core import app
from . import handler
from ..utils import platform
from langbot_plugin.runtime.io.controllers.stdio import client as stdio_client_controller
from langbot_plugin.runtime.io.controllers.stdio import (
client as stdio_client_controller,
)
from langbot_plugin.runtime.io.controllers.ws import client as ws_client_controller
from langbot_plugin.api.entities import events
from langbot_plugin.api.entities import context
import langbot_plugin.runtime.io.connection as base_connection
from langbot_plugin.api.definition.components.manifest import ComponentManifest
from langbot_plugin.api.entities.builtin.command import context as command_context
from langbot_plugin.api.entities.builtin.command import (
context as command_context,
errors as command_errors,
)
from langbot_plugin.runtime.plugin.mgr import PluginInstallSource
from ..core import taskmgr
@@ -58,7 +63,7 @@ class PluginRuntimeConnector:
async def heartbeat_loop(self):
while True:
await asyncio.sleep(10)
await asyncio.sleep(20)
try:
await self.ping_plugin_runtime()
self.ap.logger.debug('Heartbeat to plugin runtime success.')
@@ -71,7 +76,9 @@ class PluginRuntimeConnector:
return
async def new_connection_callback(connection: base_connection.Connection):
async def disconnect_callback(rchandler: handler.RuntimeConnectionHandler) -> bool:
async def disconnect_callback(
rchandler: handler.RuntimeConnectionHandler,
) -> bool:
if platform.get_platform() == 'docker' or platform.use_websocket_to_connect_plugin_runtime():
self.ap.logger.error('Disconnected from plugin runtime, trying to reconnect...')
await self.runtime_disconnect_callback(self)
@@ -98,7 +105,8 @@ class PluginRuntimeConnector:
)
async def make_connection_failed_callback(
ctrl: ws_client_controller.WebSocketClientController, exc: Exception = None
ctrl: ws_client_controller.WebSocketClientController,
exc: Exception = None,
) -> None:
if exc is not None:
self.ap.logger.error(f'Failed to connect to plugin runtime({ws_url}): {exc}')
@@ -150,6 +158,25 @@ class PluginRuntimeConnector:
install_info['plugin_file_key'] = file_key
del install_info['plugin_file']
self.ap.logger.info(f'Transfered file {file_key} to plugin runtime')
elif install_source == PluginInstallSource.GITHUB:
# download and transfer file
try:
async with httpx.AsyncClient(
trust_env=True,
follow_redirects=True,
timeout=20,
) as client:
response = await client.get(
install_info['asset_url'],
)
response.raise_for_status()
file_bytes = response.content
file_key = await self.handler.send_file(file_bytes, 'lbpkg')
install_info['plugin_file_key'] = file_key
self.ap.logger.info(f'Transfered file {file_key} to plugin runtime')
except Exception as e:
self.ap.logger.error(f'Failed to download file from GitHub: {e}')
raise Exception(f'Failed to download file from GitHub: {e}')
async for ret in self.handler.install_plugin(install_source.value, install_info):
current_action = ret.get('current_action', None)
@@ -163,7 +190,10 @@ class PluginRuntimeConnector:
task_context.trace(trace)
async def upgrade_plugin(
self, plugin_author: str, plugin_name: str, task_context: taskmgr.TaskContext | None = None
self,
plugin_author: str,
plugin_name: str,
task_context: taskmgr.TaskContext | None = None,
) -> dict[str, Any]:
async for ret in self.handler.upgrade_plugin(plugin_author, plugin_name):
current_action = ret.get('current_action', None)
@@ -177,7 +207,11 @@ class PluginRuntimeConnector:
task_context.trace(trace)
async def delete_plugin(
self, plugin_author: str, plugin_name: str, task_context: taskmgr.TaskContext | None = None
self,
plugin_author: str,
plugin_name: str,
delete_data: bool = False,
task_context: taskmgr.TaskContext | None = None,
) -> dict[str, Any]:
async for ret in self.handler.delete_plugin(plugin_author, plugin_name):
current_action = ret.get('current_action', None)
@@ -190,7 +224,16 @@ class PluginRuntimeConnector:
if task_context is not None:
task_context.trace(trace)
# Clean up plugin settings and binary storage if requested
if delete_data:
if task_context is not None:
task_context.trace('Cleaning up plugin configuration and storage...')
await self.handler.cleanup_plugin_data(plugin_author, plugin_name)
async def list_plugins(self) -> list[dict[str, Any]]:
if not self.is_enable_plugin:
return []
return await self.handler.list_plugins()
async def get_plugin_info(self, author: str, plugin_name: str) -> dict[str, Any]:
@@ -211,21 +254,31 @@ class PluginRuntimeConnector:
if not self.is_enable_plugin:
return event_ctx
event_ctx_result = await self.handler.emit_event(event_ctx.model_dump(serialize_as_any=True))
event_ctx_result = await self.handler.emit_event(event_ctx.model_dump(serialize_as_any=False))
event_ctx = context.EventContext.model_validate(event_ctx_result['event_context'])
return event_ctx
async def list_tools(self) -> list[ComponentManifest]:
if not self.is_enable_plugin:
return []
list_tools_data = await self.handler.list_tools()
return [ComponentManifest.model_validate(tool) for tool in list_tools_data]
async def call_tool(self, tool_name: str, parameters: dict[str, Any]) -> dict[str, Any]:
if not self.is_enable_plugin:
return {'error': 'Tool not found: plugin system is disabled'}
return await self.handler.call_tool(tool_name, parameters)
async def list_commands(self) -> list[ComponentManifest]:
if not self.is_enable_plugin:
return []
list_commands_data = await self.handler.list_commands()
return [ComponentManifest.model_validate(command) for command in list_commands_data]
@@ -233,6 +286,9 @@ class PluginRuntimeConnector:
async def execute_command(
self, command_ctx: command_context.ExecuteContext
) -> typing.AsyncGenerator[command_context.CommandReturn, None]:
if not self.is_enable_plugin:
yield command_context.CommandReturn(error=command_errors.CommandNotFoundError(command_ctx.command))
gen = self.handler.execute_command(command_ctx.model_dump(serialize_as_any=True))
async for ret in gen:

View File

@@ -56,7 +56,9 @@ class RuntimeConnectionHandler(handler.Handler):
.where(persistence_plugin.PluginSetting.plugin_name == plugin_name)
)
if result.first() is not None:
setting = result.first()
if setting is not None:
# delete plugin setting
await self.ap.persistence_mgr.execute_async(
sqlalchemy.delete(persistence_plugin.PluginSetting)
@@ -71,6 +73,10 @@ class RuntimeConnectionHandler(handler.Handler):
plugin_name=plugin_name,
install_source=install_source,
install_info=install_info,
# inherit from existing setting
enabled=setting.enabled if setting is not None else True,
priority=setting.priority if setting is not None else 0,
config=setting.config if setting is not None else {}, # noqa: F821
)
)
@@ -573,6 +579,23 @@ class RuntimeConnectionHandler(handler.Handler):
'mime_type': mime_type,
}
async def cleanup_plugin_data(self, plugin_author: str, plugin_name: str) -> None:
"""Cleanup plugin settings and binary storage"""
# Delete plugin settings
await self.ap.persistence_mgr.execute_async(
sqlalchemy.delete(persistence_plugin.PluginSetting)
.where(persistence_plugin.PluginSetting.plugin_author == plugin_author)
.where(persistence_plugin.PluginSetting.plugin_name == plugin_name)
)
# Delete all binary storage for this plugin
owner = f'{plugin_author}/{plugin_name}'
await self.ap.persistence_mgr.execute_async(
sqlalchemy.delete(persistence_bstorage.BinaryStorage)
.where(persistence_bstorage.BinaryStorage.owner_type == 'plugin')
.where(persistence_bstorage.BinaryStorage.owner == owner)
)
async def call_tool(self, tool_name: str, parameters: dict[str, Any]) -> dict[str, Any]:
"""Call tool"""
result = await self.call_action(

View File

@@ -59,7 +59,7 @@ class ModelManager:
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}')
self.ap.logger.warning(f'Requester {e.requester_name} not found, skipping llm 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()}')
@@ -67,7 +67,14 @@ class ModelManager:
result = await self.ap.persistence_mgr.execute_async(sqlalchemy.select(persistence_model.EmbeddingModel))
embedding_models = result.all()
for embedding_model in embedding_models:
await self.load_embedding_model(embedding_model)
try:
await self.load_embedding_model(embedding_model)
except provider_errors.RequesterNotFoundError as e:
self.ap.logger.warning(
f'Requester {e.requester_name} not found, skipping embedding model {embedding_model.uuid}'
)
except Exception as e:
self.ap.logger.error(f'Failed to load model {embedding_model.uuid}: {e}\n{traceback.format_exc()}')
async def init_runtime_llm_model(
self,
@@ -107,6 +114,9 @@ class ModelManager:
elif isinstance(model_info, dict):
model_info = persistence_model.EmbeddingModel(**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()

View File

@@ -0,0 +1,312 @@
from __future__ import annotations
import typing
import json
import uuid
import base64
from .. import runner
from ...core import app
import langbot_plugin.api.entities.builtin.provider.message as provider_message
from ...utils import image
import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query
from libs.coze_server_api.client import AsyncCozeAPIClient
@runner.runner_class('coze-api')
class CozeAPIRunner(runner.RequestRunner):
"""Coze API 对话请求器"""
def __init__(self, ap: app.Application, pipeline_config: dict):
self.pipeline_config = pipeline_config
self.ap = ap
self.agent_token = pipeline_config["ai"]['coze-api']['api-key']
self.bot_id = pipeline_config["ai"]['coze-api'].get('bot-id')
self.chat_timeout = pipeline_config["ai"]['coze-api'].get('timeout')
self.auto_save_history = pipeline_config["ai"]['coze-api'].get('auto_save_history')
self.api_base = pipeline_config["ai"]['coze-api'].get('api-base')
self.coze = AsyncCozeAPIClient(
self.agent_token,
self.api_base
)
def _process_thinking_content(
self,
content: str,
) -> tuple[str, str]:
"""处理思维链内容
Args:
content: 原始内容
Returns:
(处理后的内容, 提取的思维链内容)
"""
remove_think = self.pipeline_config.get('output', {}).get('misc', {}).get('remove-think', False)
thinking_content = ''
# 从 content 中提取 <think> 标签内容
if content and '<think>' in content and '</think>' in content:
import re
think_pattern = r'<think>(.*?)</think>'
think_matches = re.findall(think_pattern, content, re.DOTALL)
if think_matches:
thinking_content = '\n'.join(think_matches)
# 移除 content 中的 <think> 标签
content = re.sub(think_pattern, '', content, flags=re.DOTALL).strip()
# 根据 remove_think 参数决定是否保留思维链
if remove_think:
return content, ''
else:
# 如果有思维链内容,将其以 <think> 格式添加到 content 开头
if thinking_content:
content = f'<think>\n{thinking_content}\n</think>\n{content}'.strip()
return content, thinking_content
async def _preprocess_user_message(self, query: pipeline_query.Query) -> list[dict]:
"""预处理用户消息转换为Coze消息格式
Returns:
list[dict]: Coze消息列表
"""
messages = []
if isinstance(query.user_message.content, list):
# 多模态消息处理
content_parts = []
for ce in query.user_message.content:
if ce.type == 'text':
content_parts.append({"type": "text", "text": ce.text})
elif ce.type == 'image_base64':
image_b64, image_format = await image.extract_b64_and_format(ce.image_base64)
file_bytes = base64.b64decode(image_b64)
file_id = await self._get_file_id(file_bytes)
content_parts.append({"type": "image", "file_id": file_id})
elif ce.type == 'file':
# 处理文件上传到Coze
file_id = await self._get_file_id(ce.file)
content_parts.append({"type": "file", "file_id": file_id})
# 创建多模态消息
if content_parts:
messages.append({
"role": "user",
"content": json.dumps(content_parts),
"content_type": "object_string",
"meta_data": None
})
elif isinstance(query.user_message.content, str):
# 纯文本消息
messages.append({
"role": "user",
"content": query.user_message.content,
"content_type": "text",
"meta_data": None
})
return messages
async def _get_file_id(self, file) -> str:
"""上传文件到Coze服务
Args:
file: 文件
Returns:
str: 文件ID
"""
file_id = await self.coze.upload(file=file)
return file_id
async def _chat_messages(
self, query: pipeline_query.Query
) -> typing.AsyncGenerator[provider_message.Message, None]:
"""调用聊天助手(非流式)
注意由于cozepy没有提供非流式API这里使用流式API并在结束后一次性返回完整内容
"""
user_id = f'{query.launcher_id}_{query.sender_id}'
# 预处理用户消息
additional_messages = await self._preprocess_user_message(query)
# 获取会话ID
conversation_id = None
# 收集完整内容
full_content = ''
full_reasoning = ''
try:
# 调用Coze API流式接口
async for chunk in self.coze.chat_messages(
bot_id=self.bot_id,
user_id=user_id,
additional_messages=additional_messages,
conversation_id=conversation_id,
timeout=self.chat_timeout,
auto_save_history=self.auto_save_history,
stream=True
):
self.ap.logger.debug(f'coze-chat-stream: {chunk}')
event_type = chunk.get('event')
data = chunk.get('data', {})
if event_type == 'conversation.message.delta':
# 收集内容
if 'content' in data:
full_content += data.get('content', '')
# 收集推理内容(如果有)
if 'reasoning_content' in data:
full_reasoning += data.get('reasoning_content', '')
elif event_type == 'done':
# 保存会话ID
if 'conversation_id' in data:
conversation_id = data.get('conversation_id')
elif event_type == 'error':
# 处理错误
error_msg = f"Coze API错误: {data.get('message', '未知错误')}"
yield provider_message.Message(
role='assistant',
content=error_msg,
)
return
# 处理思维链内容
content, thinking_content = self._process_thinking_content(full_content)
if full_reasoning:
remove_think = self.pipeline_config.get('output', {}).get('misc', {}).get('remove-think', False)
if not remove_think:
content = f'<think>\n{full_reasoning}\n</think>\n{content}'.strip()
# 一次性返回完整内容
yield provider_message.Message(
role='assistant',
content=content,
)
# 保存会话ID
if conversation_id and query.session.using_conversation:
query.session.using_conversation.uuid = conversation_id
except Exception as e:
self.ap.logger.error(f'Coze API错误: {str(e)}')
yield provider_message.Message(
role='assistant',
content=f'Coze API调用失败: {str(e)}',
)
async def _chat_messages_chunk(
self, query: pipeline_query.Query
) -> typing.AsyncGenerator[provider_message.MessageChunk, None]:
"""调用聊天助手(流式)"""
user_id = f'{query.launcher_id}_{query.sender_id}'
# 预处理用户消息
additional_messages = await self._preprocess_user_message(query)
# 获取会话ID
conversation_id = None
start_reasoning = False
stop_reasoning = False
message_idx = 1
is_final = False
full_content = ''
remove_think = self.pipeline_config.get('output', {}).get('misc', {}).get('remove-think', False)
try:
# 调用Coze API流式接口
async for chunk in self.coze.chat_messages(
bot_id=self.bot_id,
user_id=user_id,
additional_messages=additional_messages,
conversation_id=conversation_id,
timeout=self.chat_timeout,
auto_save_history=self.auto_save_history,
stream=True
):
self.ap.logger.debug(f'coze-chat-stream-chunk: {chunk}')
event_type = chunk.get('event')
data = chunk.get('data', {})
content = ""
if event_type == 'conversation.message.delta':
message_idx += 1
# 处理内容增量
if "reasoning_content" in data and not remove_think:
reasoning_content = data.get('reasoning_content', '')
if reasoning_content and not start_reasoning:
content = f"<think/>\n"
start_reasoning = True
content += reasoning_content
if 'content' in data:
if data.get('content', ''):
content += data.get('content', '')
if not stop_reasoning and start_reasoning:
content = f"</think>\n{content}"
stop_reasoning = True
elif event_type == 'done':
# 保存会话ID
if 'conversation_id' in data:
conversation_id = data.get('conversation_id')
if query.session.using_conversation:
query.session.using_conversation.uuid = conversation_id
is_final = True
elif event_type == 'error':
# 处理错误
error_msg = f"Coze API错误: {data.get('message', '未知错误')}"
yield provider_message.MessageChunk(
role='assistant',
content=error_msg,
finish_reason='error'
)
return
full_content += content
if message_idx % 8 == 0 or is_final:
if full_content:
yield provider_message.MessageChunk(
role='assistant',
content=full_content,
is_final=is_final
)
except Exception as e:
self.ap.logger.error(f'Coze API流式调用错误: {str(e)}')
yield provider_message.MessageChunk(
role='assistant',
content=f'Coze API流式调用失败: {str(e)}',
finish_reason='error'
)
async def run(self, query: pipeline_query.Query) -> typing.AsyncGenerator[provider_message.Message, None]:
"""运行"""
msg_seq = 0
if await query.adapter.is_stream_output_supported():
async for msg in self._chat_messages_chunk(query):
if isinstance(msg, provider_message.MessageChunk):
msg_seq += 1
msg.msg_sequence = msg_seq
yield msg
else:
async for msg in self._chat_messages(query):
yield msg

View File

@@ -1,7 +1,11 @@
from __future__ import annotations
import enum
import typing
from contextlib import AsyncExitStack
import traceback
import sqlalchemy
import asyncio
from mcp import ClientSession, StdioServerParameters
from mcp.client.stdio import stdio_client
@@ -10,6 +14,13 @@ from mcp.client.sse import sse_client
from .. import loader
from ....core import app
import langbot_plugin.api.entities.builtin.resource.tool as resource_tool
from ....entity.persistence import mcp as persistence_mcp
class MCPSessionStatus(enum.Enum):
CONNECTING = 'connecting'
CONNECTED = 'connected'
ERROR = 'error'
class RuntimeMCPSession:
@@ -27,16 +38,34 @@ class RuntimeMCPSession:
functions: list[resource_tool.LLMTool] = []
def __init__(self, server_name: str, server_config: dict, ap: app.Application):
enable: bool
# connected: bool
status: MCPSessionStatus
_lifecycle_task: asyncio.Task | None
_shutdown_event: asyncio.Event
_ready_event: asyncio.Event
def __init__(self, server_name: str, server_config: dict, enable: bool, ap: app.Application):
self.server_name = server_name
self.server_config = server_config
self.ap = ap
self.enable = enable
self.session = None
self.exit_stack = AsyncExitStack()
self.functions = []
self.status = MCPSessionStatus.CONNECTING
self._lifecycle_task = None
self._shutdown_event = asyncio.Event()
self._ready_event = asyncio.Event()
async def _init_stdio_python_server(self):
server_params = StdioServerParameters(
command=self.server_config['command'],
@@ -58,6 +87,7 @@ class RuntimeMCPSession:
self.server_config['url'],
headers=self.server_config.get('headers', {}),
timeout=self.server_config.get('timeout', 10),
sse_read_timeout=self.server_config.get('ssereadtimeout', 30),
)
)
@@ -67,19 +97,65 @@ class RuntimeMCPSession:
await self.session.initialize()
async def initialize(self):
self.ap.logger.debug(f'初始化 MCP 会话: {self.server_name} {self.server_config}')
async def _lifecycle_loop(self):
"""在后台任务中管理整个MCP会话的生命周期"""
try:
if self.server_config['mode'] == 'stdio':
await self._init_stdio_python_server()
elif self.server_config['mode'] == 'sse':
await self._init_sse_server()
else:
raise ValueError(f'无法识别 MCP 服务器类型: {self.server_name}: {self.server_config}')
if self.server_config['mode'] == 'stdio':
await self._init_stdio_python_server()
elif self.server_config['mode'] == 'sse':
await self._init_sse_server()
else:
raise ValueError(f'无法识别 MCP 服务器类型: {self.server_name}: {self.server_config}')
await self.refresh()
self.status = MCPSessionStatus.CONNECTED
# 通知start()方法连接已建立
self._ready_event.set()
# 等待shutdown信号
await self._shutdown_event.wait()
except Exception as e:
self.status = MCPSessionStatus.ERROR
self.ap.logger.error(f'Error in MCP session lifecycle {self.server_name}: {e}\n{traceback.format_exc()}')
# 即使出错也要设置ready事件让start()方法知道初始化已完成
self._ready_event.set()
finally:
# 在同一个任务中清理所有资源
try:
if self.exit_stack:
await self.exit_stack.aclose()
self.functions.clear()
self.session = None
except Exception as e:
self.ap.logger.error(f'Error cleaning up MCP session {self.server_name}: {e}\n{traceback.format_exc()}')
async def start(self):
if not self.enable:
return
# 创建后台任务来管理生命周期
self._lifecycle_task = asyncio.create_task(self._lifecycle_loop())
# 等待连接建立或失败(带超时)
try:
await asyncio.wait_for(self._ready_event.wait(), timeout=30.0)
except asyncio.TimeoutError:
self.status = MCPSessionStatus.ERROR
raise Exception('Connection timeout after 30 seconds')
# 检查是否有错误
if self.status == MCPSessionStatus.ERROR:
raise Exception('Connection failed, please check URL')
async def refresh(self):
self.functions.clear()
tools = await self.session.list_tools()
self.ap.logger.debug(f'获取 MCP 工具: {tools}')
self.ap.logger.debug(f'Refresh MCP tools: {tools}')
for tool in tools.tools:
@@ -101,58 +177,212 @@ class RuntimeMCPSession:
)
)
def get_tools(self) -> list[resource_tool.LLMTool]:
return self.functions
def get_runtime_info_dict(self) -> dict:
return {
'status': self.status.value,
'tool_count': len(self.get_tools()),
'tools': [
{
'name': tool.name,
'description': tool.description,
}
for tool in self.get_tools()
],
}
async def shutdown(self):
"""关闭工具"""
await self.session._exit_stack.aclose()
"""关闭会话并清理资源"""
try:
# 设置shutdown事件通知lifecycle任务退出
self._shutdown_event.set()
# 等待lifecycle任务完成带超时
if self._lifecycle_task and not self._lifecycle_task.done():
try:
await asyncio.wait_for(self._lifecycle_task, timeout=5.0)
except asyncio.TimeoutError:
self.ap.logger.warning(f'MCP session {self.server_name} shutdown timeout, cancelling task')
self._lifecycle_task.cancel()
try:
await self._lifecycle_task
except asyncio.CancelledError:
pass
self.ap.logger.info(f'MCP session {self.server_name} shutdown complete')
except Exception as e:
self.ap.logger.error(f'Error shutting down MCP session {self.server_name}: {e}\n{traceback.format_exc()}')
@loader.loader_class('mcp')
# @loader.loader_class('mcp')
class MCPLoader(loader.ToolLoader):
"""MCP 工具加载器。
在此加载器中管理所有与 MCP Server 的连接。
"""
sessions: dict[str, RuntimeMCPSession] = {}
sessions: dict[str, RuntimeMCPSession]
_last_listed_functions: list[resource_tool.LLMTool] = []
_last_listed_functions: list[resource_tool.LLMTool]
_hosted_mcp_tasks: list[asyncio.Task]
def __init__(self, ap: app.Application):
super().__init__(ap)
self.sessions = {}
self._last_listed_functions = []
self._hosted_mcp_tasks = []
async def initialize(self):
for server_config in self.ap.instance_config.data.get('mcp', {}).get('servers', []):
if not server_config['enable']:
continue
session = RuntimeMCPSession(server_config['name'], server_config, self.ap)
await session.initialize()
# self.ap.event_loop.create_task(session.initialize())
await self.load_mcp_servers_from_db()
async def load_mcp_servers_from_db(self):
self.ap.logger.info('Loading MCP servers from db...')
self.sessions = {}
result = await self.ap.persistence_mgr.execute_async(sqlalchemy.select(persistence_mcp.MCPServer))
servers = result.all()
for server in servers:
config = self.ap.persistence_mgr.serialize_model(persistence_mcp.MCPServer, server)
task = asyncio.create_task(self.host_mcp_server(config))
self._hosted_mcp_tasks.append(task)
async def host_mcp_server(self, server_config: dict):
self.ap.logger.debug(f'Loading MCP server {server_config}')
try:
session = await self.load_mcp_server(server_config)
self.sessions[server_config['name']] = session
except Exception as e:
self.ap.logger.error(
f'Failed to load MCP server from db: {server_config["name"]}({server_config["uuid"]}): {e}\n{traceback.format_exc()}'
)
return
self.ap.logger.debug(f'Starting MCP server {server_config["name"]}({server_config["uuid"]})')
try:
await session.start()
except Exception as e:
self.ap.logger.error(
f'Failed to start MCP server {server_config["name"]}({server_config["uuid"]}): {e}\n{traceback.format_exc()}'
)
return
self.ap.logger.debug(f'Started MCP server {server_config["name"]}({server_config["uuid"]})')
async def load_mcp_server(self, server_config: dict) -> RuntimeMCPSession:
"""加载 MCP 服务器到运行时
Args:
server_config: 服务器配置字典,必须包含:
- name: 服务器名称
- mode: 连接模式 (stdio/sse)
- enable: 是否启用
- extra_args: 额外的配置参数 (可选)
"""
name = server_config['name']
mode = server_config['mode']
enable = server_config['enable']
extra_args = server_config.get('extra_args', {})
mixed_config = {
'name': name,
'mode': mode,
'enable': enable,
**extra_args,
}
session = RuntimeMCPSession(name, mixed_config, enable, self.ap)
return session
async def get_tools(self) -> list[resource_tool.LLMTool]:
all_functions = []
for session in self.sessions.values():
all_functions.extend(session.functions)
all_functions.extend(session.get_tools())
self._last_listed_functions = all_functions
return all_functions
async def has_tool(self, name: str) -> bool:
return name in [f.name for f in self._last_listed_functions]
"""检查工具是否存在"""
for session in self.sessions.values():
for function in session.get_tools():
if function.name == name:
return True
return False
async def invoke_tool(self, name: str, parameters: dict) -> typing.Any:
for server_name, session in self.sessions.items():
for function in session.functions:
"""执行工具调用"""
for session in self.sessions.values():
for function in session.get_tools():
if function.name == name:
return await function.func(**parameters)
self.ap.logger.debug(f'Invoking MCP tool: {name} with parameters: {parameters}')
try:
result = await function.func(**parameters)
self.ap.logger.debug(f'MCP tool {name} executed successfully')
return result
except Exception as e:
self.ap.logger.error(f'Error invoking MCP tool {name}: {e}\n{traceback.format_exc()}')
raise
raise ValueError(f'未找到工具: {name}')
raise ValueError(f'Tool not found: {name}')
async def remove_mcp_server(self, server_name: str):
"""移除 MCP 服务器"""
if server_name not in self.sessions:
self.ap.logger.warning(f'MCP server {server_name} not found in sessions, skipping removal')
return
session = self.sessions.pop(server_name)
await session.shutdown()
self.ap.logger.info(f'Removed MCP server: {server_name}')
def get_session(self, server_name: str) -> RuntimeMCPSession | None:
"""获取指定名称的 MCP 会话"""
return self.sessions.get(server_name)
def has_session(self, server_name: str) -> bool:
"""检查是否存在指定名称的 MCP 会话"""
return server_name in self.sessions
def get_all_server_names(self) -> list[str]:
"""获取所有已加载的 MCP 服务器名称"""
return list(self.sessions.keys())
def get_server_tool_count(self, server_name: str) -> int:
"""获取指定服务器的工具数量"""
session = self.get_session(server_name)
return len(session.get_tools()) if session else 0
def get_all_servers_info(self) -> dict[str, dict]:
"""获取所有服务器的信息"""
info = {}
for server_name, session in self.sessions.items():
info[server_name] = {
'name': server_name,
'mode': session.server_config.get('mode'),
'enable': session.enable,
'tools_count': len(session.get_tools()),
'tool_names': [f.name for f in session.get_tools()],
}
return info
async def shutdown(self):
"""关闭工具"""
for session in self.sessions.values():
await session.shutdown()
"""关闭所有工具"""
self.ap.logger.info('Shutting down all MCP sessions...')
for server_name, session in list(self.sessions.items()):
try:
await session.shutdown()
self.ap.logger.debug(f'Shutdown MCP session: {server_name}')
except Exception as e:
self.ap.logger.error(f'Error shutting down MCP session {server_name}: {e}\n{traceback.format_exc()}')
self.sessions.clear()
self.ap.logger.info('All MCP sessions shutdown complete')

View File

@@ -7,7 +7,7 @@ from .. import loader
import langbot_plugin.api.entities.builtin.resource.tool as resource_tool
@loader.loader_class('plugin-tool-loader')
# @loader.loader_class('plugin-tool-loader')
class PluginToolLoader(loader.ToolLoader):
"""插件工具加载器。

View File

@@ -3,9 +3,9 @@ from __future__ import annotations
import typing
from ...core import app
from . import loader as tools_loader
from ...utils import importutil
from . import loaders
from .loaders import mcp as mcp_loader, plugin as plugin_loader
import langbot_plugin.api.entities.builtin.resource.tool as resource_tool
importutil.import_modules_in_pkg(loaders)
@@ -16,25 +16,24 @@ class ToolManager:
ap: app.Application
loaders: list[tools_loader.ToolLoader]
plugin_tool_loader: plugin_loader.PluginToolLoader
mcp_tool_loader: mcp_loader.MCPLoader
def __init__(self, ap: app.Application):
self.ap = ap
self.all_functions = []
self.loaders = []
async def initialize(self):
for loader_cls in tools_loader.preregistered_loaders:
loader_inst = loader_cls(self.ap)
await loader_inst.initialize()
self.loaders.append(loader_inst)
self.plugin_tool_loader = plugin_loader.PluginToolLoader(self.ap)
await self.plugin_tool_loader.initialize()
self.mcp_tool_loader = mcp_loader.MCPLoader(self.ap)
await self.mcp_tool_loader.initialize()
async def get_all_tools(self) -> list[resource_tool.LLMTool]:
"""获取所有函数"""
all_functions: list[resource_tool.LLMTool] = []
for loader in self.loaders:
all_functions.extend(await loader.get_tools())
all_functions.extend(await self.plugin_tool_loader.get_tools())
all_functions.extend(await self.mcp_tool_loader.get_tools())
return all_functions
@@ -93,13 +92,14 @@ class ToolManager:
async def execute_func_call(self, name: str, parameters: dict) -> typing.Any:
"""执行函数调用"""
for loader in self.loaders:
if await loader.has_tool(name):
return await loader.invoke_tool(name, parameters)
if await self.plugin_tool_loader.has_tool(name):
return await self.plugin_tool_loader.invoke_tool(name, parameters)
elif await self.mcp_tool_loader.has_tool(name):
return await self.mcp_tool_loader.invoke_tool(name, parameters)
else:
raise ValueError(f'未找到工具: {name}')
async def shutdown(self):
"""关闭所有工具"""
for loader in self.loaders:
await loader.shutdown()
await self.plugin_tool_loader.shutdown()
await self.mcp_tool_loader.shutdown()

View File

@@ -4,6 +4,7 @@ import json
from typing import List
from pkg.rag.knowledge.services import base_service
from pkg.core import app
from langchain_text_splitters import RecursiveCharacterTextSplitter
class Chunker(base_service.BaseService):
@@ -27,21 +28,6 @@ class Chunker(base_service.BaseService):
"""
if not text:
return []
# words = text.split()
# chunks = []
# current_chunk = []
# for word in words:
# current_chunk.append(word)
# if len(current_chunk) > self.chunk_size:
# chunks.append(" ".join(current_chunk[:self.chunk_size]))
# current_chunk = current_chunk[self.chunk_size - self.chunk_overlap:]
# if current_chunk:
# chunks.append(" ".join(current_chunk))
# A more robust chunking strategy (e.g., using recursive character text splitter)
from langchain.text_splitter import RecursiveCharacterTextSplitter
text_splitter = RecursiveCharacterTextSplitter(
chunk_size=self.chunk_size,

View File

@@ -1,4 +1,4 @@
semantic_version = 'v4.3.3'
semantic_version = 'v4.4.0'
required_database_version = 8
"""Tag the version of the database schema, used to check if the database needs to be migrated"""

View File

@@ -1,6 +1,6 @@
[project]
name = "langbot"
version = "4.3.3"
version = "4.4.0"
description = "Easy-to-use global IM bot platform designed for LLM era"
readme = "README.md"
requires-python = ">=3.10.1,<4.0"
@@ -60,9 +60,10 @@ dependencies = [
"ebooklib>=0.18",
"html2text>=2024.2.26",
"langchain>=0.2.0",
"langchain-text-splitters>=0.0.1",
"chromadb>=0.4.24",
"qdrant-client (>=1.15.1,<2.0.0)",
"langbot-plugin==0.1.3",
"langbot-plugin==0.1.6",
"asyncpg>=0.30.0",
"line-bot-sdk>=3.19.0",
"tboxsdk>=0.0.10",

View File

@@ -10,8 +10,6 @@ command:
concurrency:
pipeline: 20
session: 1
mcp:
servers: []
proxy:
http: ''
https: ''
@@ -41,4 +39,4 @@ plugin:
enable: true
runtime_ws_url: 'ws://langbot_plugin_runtime:5400/control/ws'
enable_marketplace: true
cloud_service_url: 'https://space.langbot.app'
cloud_service_url: 'https://space.langbot.app'

View File

@@ -43,6 +43,10 @@ stages:
label:
en_US: Langflow API
zh_Hans: Langflow API
- name: coze-api
label:
en_US: Coze API
zh_Hans: 扣子 API
- name: local-agent
label:
en_US: Local Agent
@@ -380,4 +384,57 @@ stages:
zh_Hans: 可选的流程调整参数
type: json
required: false
default: '{}'
default: '{}'
- name: coze-api
label:
en_US: coze API
zh_Hans: 扣子 API
description:
en_US: Configure the Coze API of the pipeline
zh_Hans: 配置Coze API
config:
- name: api-key
label:
en_US: API Key
zh_Hans: API 密钥
description:
en_US: The API key for the Coze server
zh_Hans: Coze服务器的 API 密钥
type: string
required: true
- name: bot-id
label:
en_US: Bot ID
zh_Hans: 机器人 ID
description:
en_US: The ID of the bot to run
zh_Hans: 要运行的机器人 ID
type: string
required: true
- name: api-base
label:
en_US: API Base URL
zh_Hans: API 基础 URL
description:
en_US: The base URL for the Coze API, please use https://api.coze.com for global Coze edition(coze.com).
zh_Hans: Coze API 的基础 URL请使用 https://api.coze.com 用于全球 Coze 版coze.com
type: string
default: "https://api.coze.cn"
- name: auto-save-history
label:
en_US: Auto Save History
zh_Hans: 自动保存历史
description:
en_US: Whether to automatically save conversation history
zh_Hans: 是否自动保存对话历史
type: boolean
default: true
- name: timeout
label:
en_US: Request Timeout
zh_Hans: 请求超时
description:
en_US: Timeout in seconds for API requests
zh_Hans: API 请求超时时间(秒)
type: number
default: 120

View File

@@ -23,6 +23,7 @@
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@hookform/resolvers": "^5.0.1",
"@radix-ui/react-alert-dialog": "^1.1.15",
"@radix-ui/react-checkbox": "^1.3.1",
"@radix-ui/react-context-menu": "^2.2.15",
"@radix-ui/react-dialog": "^1.1.14",

View File

@@ -115,7 +115,6 @@ export default function BotForm({
useEffect(() => {
setBotFormValues();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
function setBotFormValues() {

View File

@@ -65,7 +65,6 @@ export default function HomeSidebar({
console.error('Failed to fetch GitHub star count:', error);
});
return () => console.log('sidebar.unmounted');
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
function handleChildClick(child: SidebarChildVO) {

View File

@@ -29,7 +29,17 @@ export default function PluginConfigPage() {
const [sortOrderValue, setSortOrderValue] = useState<string>('DESC');
useEffect(() => {
getPipelines();
// Load sort preference from localStorage
const savedSortBy = localStorage.getItem('pipeline_sort_by');
const savedSortOrder = localStorage.getItem('pipeline_sort_order');
if (savedSortBy && savedSortOrder) {
setSortByValue(savedSortBy);
setSortOrderValue(savedSortOrder);
getPipelines(savedSortBy, savedSortOrder);
} else {
getPipelines();
}
}, []);
function getPipelines(
@@ -91,6 +101,11 @@ export default function PluginConfigPage() {
const [newSortBy, newSortOrder] = value.split(',').map((s) => s.trim());
setSortByValue(newSortBy);
setSortOrderValue(newSortOrder);
// Save sort preference to localStorage
localStorage.setItem('pipeline_sort_by', newSortBy);
localStorage.setItem('pipeline_sort_order', newSortOrder);
getPipelines(newSortBy, newSortOrder);
}
@@ -135,6 +150,12 @@ export default function PluginConfigPage() {
>
{t('pipelines.newestCreated')}
</SelectItem>
<SelectItem
value="created_at,ASC"
className="text-gray-900 dark:text-gray-100"
>
{t('pipelines.earliestCreated')}
</SelectItem>
<SelectItem
value="updated_at,DESC"
className="text-gray-900 dark:text-gray-100"

View File

@@ -0,0 +1,75 @@
import { PluginComponent } from '@/app/infra/entities/plugin';
import { TFunction } from 'i18next';
import { Wrench, AudioWaveform, Hash } from 'lucide-react';
import { Badge } from '@/components/ui/badge';
export default function PluginComponentList({
components,
showComponentName,
showTitle,
useBadge,
t,
}: {
components: PluginComponent[];
showComponentName: boolean;
showTitle: boolean;
useBadge: boolean;
t: TFunction;
}) {
const componentKindCount: Record<string, number> = {};
for (const component of components) {
const kind = component.manifest.manifest.kind;
if (componentKindCount[kind]) {
componentKindCount[kind]++;
} else {
componentKindCount[kind] = 1;
}
}
const kindIconMap: Record<string, React.ReactNode> = {
Tool: <Wrench className="w-5 h-5" />,
EventListener: <AudioWaveform className="w-5 h-5" />,
Command: <Hash className="w-5 h-5" />,
};
const componentKindList = Object.keys(componentKindCount);
return (
<>
{showTitle && <div>{t('plugins.componentsList')}</div>}
{componentKindList.length > 0 && (
<>
{componentKindList.map((kind) => {
return (
<>
{useBadge && (
<Badge variant="outline">
{kindIconMap[kind]}
{showComponentName &&
t('plugins.componentName.' + kind) + ' '}
{componentKindCount[kind]}
</Badge>
)}
{!useBadge && (
<div
key={kind}
className="flex flex-row items-center justify-start gap-[0.2rem]"
>
{kindIconMap[kind]}
{showComponentName &&
t('plugins.componentName.' + kind) + ' '}
{componentKindCount[kind]}
</div>
)}
</>
);
})}
</>
)}
{componentKindList.length === 0 && <div>{t('plugins.noComponents')}</div>}
</>
);
}

View File

@@ -1,9 +1,9 @@
'use client';
import { useState, useEffect, forwardRef, useImperativeHandle } from 'react';
import { PluginCardVO } from '@/app/home/plugins/plugin-installed/PluginCardVO';
import PluginCardComponent from '@/app/home/plugins/plugin-installed/plugin-card/PluginCardComponent';
import PluginForm from '@/app/home/plugins/plugin-installed/plugin-form/PluginForm';
import { PluginCardVO } from '@/app/home/plugins/components/plugin-installed/PluginCardVO';
import PluginCardComponent from '@/app/home/plugins/components/plugin-installed/plugin-card/PluginCardComponent';
import PluginForm from '@/app/home/plugins/components/plugin-installed/plugin-form/PluginForm';
import styles from '@/app/home/plugins/plugins.module.css';
import { httpClient } from '@/app/infra/http/HttpClient';
import {
@@ -15,6 +15,7 @@ import {
DialogFooter,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
import { useTranslation } from 'react-i18next';
import { extractI18nObject } from '@/i18n/I18nProvider';
import { toast } from 'sonner';
@@ -43,6 +44,7 @@ const PluginInstalledComponent = forwardRef<PluginInstalledComponentRef>(
PluginOperationType.DELETE,
);
const [targetPlugin, setTargetPlugin] = useState<PluginCardVO | null>(null);
const [deleteData, setDeleteData] = useState<boolean>(false);
const asyncTask = useAsyncTask({
onSuccess: () => {
@@ -61,7 +63,6 @@ const PluginInstalledComponent = forwardRef<PluginInstalledComponentRef>(
useEffect(() => {
initData();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
function initData() {
@@ -109,6 +110,7 @@ const PluginInstalledComponent = forwardRef<PluginInstalledComponentRef>(
setTargetPlugin(plugin);
setOperationType(PluginOperationType.DELETE);
setShowOperationModal(true);
setDeleteData(false);
asyncTask.reset();
}
@@ -124,7 +126,11 @@ const PluginInstalledComponent = forwardRef<PluginInstalledComponentRef>(
const apiCall =
operationType === PluginOperationType.DELETE
? httpClient.removePlugin(targetPlugin.author, targetPlugin.name)
? httpClient.removePlugin(
targetPlugin.author,
targetPlugin.name,
deleteData,
)
: httpClient.upgradePlugin(targetPlugin.author, targetPlugin.name);
apiCall
@@ -162,16 +168,35 @@ const PluginInstalledComponent = forwardRef<PluginInstalledComponentRef>(
</DialogHeader>
<DialogDescription>
{asyncTask.status === AsyncTaskStatus.WAIT_INPUT && (
<div>
{operationType === PluginOperationType.DELETE
? t('plugins.confirmDeletePlugin', {
author: targetPlugin?.author ?? '',
name: targetPlugin?.name ?? '',
})
: t('plugins.confirmUpdatePlugin', {
author: targetPlugin?.author ?? '',
name: targetPlugin?.name ?? '',
})}
<div className="flex flex-col gap-4">
<div>
{operationType === PluginOperationType.DELETE
? t('plugins.confirmDeletePlugin', {
author: targetPlugin?.author ?? '',
name: targetPlugin?.name ?? '',
})
: t('plugins.confirmUpdatePlugin', {
author: targetPlugin?.author ?? '',
name: targetPlugin?.name ?? '',
})}
</div>
{operationType === PluginOperationType.DELETE && (
<div className="flex items-center space-x-2">
<Checkbox
id="delete-data"
checked={deleteData}
onCheckedChange={(checked) =>
setDeleteData(checked === true)
}
/>
<label
htmlFor="delete-data"
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70 cursor-pointer"
>
{t('plugins.deleteDataCheckbox')}
</label>
</div>
)}
</div>
)}
{asyncTask.status === AsyncTaskStatus.RUNNING && (

View File

@@ -1,21 +1,10 @@
import { PluginCardVO } from '@/app/home/plugins/plugin-installed/PluginCardVO';
import { PluginCardVO } from '@/app/home/plugins/components/plugin-installed/PluginCardVO';
import { useState } from 'react';
import { Badge } from '@/components/ui/badge';
import { useTranslation } from 'react-i18next';
import { TFunction } from 'i18next';
import {
AudioWaveform,
Wrench,
Hash,
BugIcon,
ExternalLink,
Ellipsis,
Trash,
ArrowUp,
} from 'lucide-react';
import { BugIcon, ExternalLink, Ellipsis, Trash, ArrowUp } from 'lucide-react';
import { getCloudServiceClientSync } from '@/app/infra/http';
import { httpClient } from '@/app/infra/http/HttpClient';
import { PluginComponent } from '@/app/infra/entities/plugin';
import { Button } from '@/components/ui/button';
import {
DropdownMenu,
@@ -23,49 +12,7 @@ import {
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
function getComponentList(components: PluginComponent[], t: TFunction) {
const componentKindCount: Record<string, number> = {};
for (const component of components) {
const kind = component.manifest.manifest.kind;
if (componentKindCount[kind]) {
componentKindCount[kind]++;
} else {
componentKindCount[kind] = 1;
}
}
const kindIconMap: Record<string, React.ReactNode> = {
Tool: <Wrench className="w-5 h-5" />,
EventListener: <AudioWaveform className="w-5 h-5" />,
Command: <Hash className="w-5 h-5" />,
};
const componentKindList = Object.keys(componentKindCount);
return (
<>
<div>{t('plugins.componentsList')}</div>
{componentKindList.length > 0 && (
<>
{componentKindList.map((kind) => {
return (
<div
key={kind}
className="flex flex-row items-center justify-start gap-[0.4rem]"
>
{kindIconMap[kind]} {componentKindCount[kind]}
</div>
);
})}
</>
)}
{componentKindList.length === 0 && <div>{t('plugins.noComponents')}</div>}
</>
);
}
import PluginComponentList from '@/app/home/plugins/components/plugin-installed/PluginComponentList';
export default function PluginCardComponent({
cardVO,
@@ -180,7 +127,13 @@ export default function PluginCardComponent({
</div>
<div className="w-full flex flex-row items-start justify-start gap-[0.6rem]">
{getComponentList(cardVO.components, t)}
<PluginComponentList
components={cardVO.components}
showComponentName={false}
showTitle={true}
useBadge={false}
t={t}
/>
</div>
</div>

View File

@@ -7,6 +7,7 @@ import { Button } from '@/components/ui/button';
import { toast } from 'sonner';
import { extractI18nObject } from '@/i18n/I18nProvider';
import { useTranslation } from 'react-i18next';
import PluginComponentList from '@/app/home/plugins/components/plugin-installed/PluginComponentList';
export default function PluginForm({
pluginAuthor,
@@ -78,6 +79,17 @@ export default function PluginForm({
},
)}
</div>
<div className="mb-4 flex flex-row items-center justify-start gap-[0.4rem]">
<PluginComponentList
components={pluginInfo.components}
showComponentName={true}
showTitle={false}
useBadge={true}
t={t}
/>
</div>
{pluginInfo.manifest.manifest.spec.config.length > 0 && (
<DynamicFormComponent
itemConfigList={pluginInfo.manifest.manifest.spec.config}

View File

@@ -172,7 +172,6 @@ function MarketPageContent({
// 初始加载
useEffect(() => {
fetchPlugins(1, false, true);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// 搜索功能

View File

@@ -0,0 +1,29 @@
import { MCPServer, MCPSessionStatus } from '@/app/infra/entities/api';
export class MCPCardVO {
name: string;
mode: 'stdio' | 'sse';
enable: boolean;
status: MCPSessionStatus;
tools: number;
error?: string;
constructor(data: MCPServer) {
this.name = data.name;
this.mode = data.mode;
this.enable = data.enable;
// Determine status from runtime_info
if (!data.runtime_info) {
this.status = MCPSessionStatus.ERROR;
this.tools = 0;
} else if (data.runtime_info.status === MCPSessionStatus.CONNECTED) {
this.status = data.runtime_info.status;
this.tools = data.runtime_info.tool_count || 0;
} else {
this.status = data.runtime_info.status;
this.tools = 0;
this.error = data.runtime_info.error_message;
}
}
}

View File

@@ -0,0 +1,113 @@
'use client';
import { useEffect, useState, useRef } from 'react';
import MCPCardComponent from '@/app/home/plugins/mcp-server/mcp-card/MCPCardComponent';
import { MCPCardVO } from '@/app/home/plugins/mcp-server/MCPCardVO';
import { useTranslation } from 'react-i18next';
import { MCPSessionStatus } from '@/app/infra/entities/api';
import { httpClient } from '@/app/infra/http/HttpClient';
export default function MCPComponent({
onEditServer,
}: {
askInstallServer?: (githubURL: string) => void;
onEditServer?: (serverName: string) => void;
}) {
const { t } = useTranslation();
const [installedServers, setInstalledServers] = useState<MCPCardVO[]>([]);
const [loading, setLoading] = useState(false);
const pollingIntervalRef = useRef<NodeJS.Timeout | null>(null);
useEffect(() => {
fetchInstalledServers();
return () => {
// Cleanup: clear polling interval when component unmounts
if (pollingIntervalRef.current) {
clearInterval(pollingIntervalRef.current);
}
};
}, []);
// Check if any server is connecting and start/stop polling accordingly
useEffect(() => {
const hasConnecting = installedServers.some(
(server) => server.status === MCPSessionStatus.CONNECTING,
);
if (hasConnecting && !pollingIntervalRef.current) {
// Start polling every 3 seconds
pollingIntervalRef.current = setInterval(() => {
fetchInstalledServers();
}, 3000);
} else if (!hasConnecting && pollingIntervalRef.current) {
// Stop polling when no server is connecting
clearInterval(pollingIntervalRef.current);
pollingIntervalRef.current = null;
}
return () => {
if (pollingIntervalRef.current) {
clearInterval(pollingIntervalRef.current);
pollingIntervalRef.current = null;
}
};
}, [installedServers]);
function fetchInstalledServers() {
setLoading(true);
httpClient
.getMCPServers()
.then((resp) => {
const servers = resp.servers.map((server) => new MCPCardVO(server));
setInstalledServers(servers);
setLoading(false);
})
.catch((error) => {
console.error('Failed to fetch MCP servers:', error);
setLoading(false);
});
}
return (
<div className="w-full h-full">
{/* 已安装的服务器列表 */}
<div className="w-full px-[0.8rem] pt-[0rem] gap-4">
{loading ? (
<div className="flex flex-col items-center justify-center text-gray-500 h-[calc(100vh-16rem)] w-full gap-2">
{t('mcp.loading')}
</div>
) : installedServers.length === 0 ? (
<div className="flex flex-col items-center justify-center text-gray-500 h-[calc(100vh-16rem)] w-full gap-2">
<svg
className="h-[3rem] w-[3rem]"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
>
<path d="M4.5 7.65311V16.3469L12 20.689L19.5 16.3469V7.65311L12 3.311L4.5 7.65311ZM12 1L21.5 6.5V17.5L12 23L2.5 17.5V6.5L12 1ZM6.49896 9.97065L11 12.5765V17.625H13V12.5765L17.501 9.97066L16.499 8.2398L12 10.8445L7.50104 8.2398L6.49896 9.97065Z"></path>
</svg>
<div className="text-lg mb-2">{t('mcp.noServerInstalled')}</div>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 pt-[2rem]">
{installedServers.map((server, index) => (
<div key={`${server.name}-${index}`}>
<MCPCardComponent
cardVO={server}
onCardClick={() => {
if (onEditServer) {
onEditServer(server.name);
}
}}
onRefresh={fetchInstalledServers}
/>
</div>
))}
</div>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,172 @@
import { MCPCardVO } from '@/app/home/plugins/mcp-server/MCPCardVO';
import { useState, useEffect } from 'react';
import { httpClient } from '@/app/infra/http/HttpClient';
import { Switch } from '@/components/ui/switch';
import { Button } from '@/components/ui/button';
import { toast } from 'sonner';
import { useTranslation } from 'react-i18next';
import { RefreshCcw, Wrench, Ban, AlertCircle, Loader2 } from 'lucide-react';
import { MCPSessionStatus } from '@/app/infra/entities/api';
export default function MCPCardComponent({
cardVO,
onCardClick,
onRefresh,
}: {
cardVO: MCPCardVO;
onCardClick: () => void;
onRefresh: () => void;
}) {
const { t } = useTranslation();
const [enabled, setEnabled] = useState(cardVO.enable);
const [switchEnable, setSwitchEnable] = useState(true);
const [testing, setTesting] = useState(false);
const [toolsCount, setToolsCount] = useState(cardVO.tools);
const [status, setStatus] = useState(cardVO.status);
useEffect(() => {
setStatus(cardVO.status);
setToolsCount(cardVO.tools);
setEnabled(cardVO.enable);
}, [cardVO.status, cardVO.tools, cardVO.enable]);
function handleEnable(checked: boolean) {
setSwitchEnable(false);
httpClient
.toggleMCPServer(cardVO.name, checked)
.then(() => {
setEnabled(checked);
toast.success(t('mcp.saveSuccess'));
onRefresh();
setSwitchEnable(true);
})
.catch((err) => {
toast.error(t('mcp.modifyFailed') + err.message);
setSwitchEnable(true);
});
}
function handleTest(e: React.MouseEvent) {
e.stopPropagation();
setTesting(true);
httpClient
.testMCPServer(cardVO.name, {})
.then((resp) => {
const taskId = resp.task_id;
const interval = setInterval(() => {
httpClient.getAsyncTask(taskId).then((taskResp) => {
if (taskResp.runtime.done) {
clearInterval(interval);
setTesting(false);
if (taskResp.runtime.exception) {
toast.error(
t('mcp.refreshFailed') + taskResp.runtime.exception,
);
} else {
toast.success(t('mcp.refreshSuccess'));
}
// Refresh to get updated runtime_info
onRefresh();
}
});
}, 1000);
})
.catch((err) => {
toast.error(t('mcp.refreshFailed') + err.message);
setTesting(false);
});
}
return (
<div
className="w-[100%] h-[10rem] bg-white dark:bg-[#1f1f22] rounded-[10px] shadow-[0px_2px_2px_0_rgba(0,0,0,0.2)] dark:shadow-none p-[1.2rem] cursor-pointer transition-all duration-200 hover:shadow-[0px_2px_8px_0_rgba(0,0,0,0.1)] dark:hover:shadow-none"
onClick={onCardClick}
>
<div className="w-full h-full flex flex-row items-start justify-start gap-[1.2rem]">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
width="64"
height="64"
fill="rgba(70,146,221,1)"
>
<path d="M17.6567 14.8284L16.2425 13.4142L17.6567 12C19.2188 10.4379 19.2188 7.90524 17.6567 6.34314C16.0946 4.78105 13.5619 4.78105 11.9998 6.34314L10.5856 7.75736L9.17139 6.34314L10.5856 4.92893C12.9287 2.58578 16.7277 2.58578 19.0709 4.92893C21.414 7.27208 21.414 11.0711 19.0709 13.4142L17.6567 14.8284ZM14.8282 17.6569L13.414 19.0711C11.0709 21.4142 7.27189 21.4142 4.92875 19.0711C2.5856 16.7279 2.5856 12.9289 4.92875 10.5858L6.34296 9.17157L7.75717 10.5858L6.34296 12C4.78086 13.5621 4.78086 16.0948 6.34296 17.6569C7.90506 19.2189 10.4377 19.2189 11.9998 17.6569L13.414 16.2426L14.8282 17.6569ZM14.8282 7.75736L16.2425 9.17157L9.17139 16.2426L7.75717 14.8284L14.8282 7.75736Z"></path>
</svg>
<div className="w-full h-full flex flex-col items-start justify-between gap-[0.6rem]">
<div className="flex flex-col items-start justify-start">
<div className="text-[1.2rem] text-black dark:text-[#f0f0f0] font-medium">
{cardVO.name}
</div>
</div>
<div className="w-full flex flex-row items-start justify-start gap-[0.6rem]">
{!enabled ? (
// 未启用 - 橙色
<div className="flex flex-row items-center gap-[0.4rem]">
<Ban className="w-4 h-4 text-orange-500 dark:text-orange-400" />
<div className="text-sm text-orange-500 dark:text-orange-400 font-medium">
{t('mcp.statusDisabled')}
</div>
</div>
) : status === MCPSessionStatus.CONNECTED ? (
// 连接成功 - 显示工具数量
<div className="flex h-full flex-row items-center justify-center gap-[0.4rem]">
<Wrench className="w-5 h-5" />
<div className="text-base text-black dark:text-[#f0f0f0] font-medium">
{t('mcp.toolCount', { count: toolsCount })}
</div>
</div>
) : status === MCPSessionStatus.CONNECTING ? (
// 连接中 - 蓝色加载
<div className="flex flex-row items-center gap-[0.4rem]">
<Loader2 className="w-4 h-4 text-blue-500 dark:text-blue-400 animate-spin" />
<div className="text-sm text-blue-500 dark:text-blue-400 font-medium">
{t('mcp.connecting')}
</div>
</div>
) : (
// 连接失败 - 红色
<div className="flex flex-row items-center gap-[0.4rem]">
<AlertCircle className="w-4 h-4 text-red-500 dark:text-red-400" />
<div className="text-sm text-red-500 dark:text-red-400 font-medium">
{t('mcp.connectionFailedStatus')}
</div>
</div>
)}
</div>
</div>
<div className="flex flex-col items-center justify-between h-full">
<div
className="flex items-center justify-center"
onClick={(e) => e.stopPropagation()}
>
<Switch
className="cursor-pointer"
checked={enabled}
onCheckedChange={handleEnable}
disabled={!switchEnable}
/>
</div>
<div className="flex items-center justify-center gap-[0.4rem]">
<Button
variant="ghost"
size="sm"
className="p-1 h-8 w-8"
onClick={(e) => handleTest(e)}
disabled={testing}
>
<RefreshCcw className="w-4 h-4" />
</Button>
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,68 @@
'use client';
import React from 'react';
import { useTranslation } from 'react-i18next';
import { toast } from 'sonner';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
DialogDescription,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { httpClient } from '@/app/infra/http/HttpClient';
interface MCPDeleteConfirmDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
serverName: string | null;
onSuccess?: () => void;
}
export default function MCPDeleteConfirmDialog({
open,
onOpenChange,
serverName,
onSuccess,
}: MCPDeleteConfirmDialogProps) {
const { t } = useTranslation();
async function handleDelete() {
if (!serverName) return;
try {
await httpClient.deleteMCPServer(serverName);
toast.success(t('mcp.deleteSuccess'));
onOpenChange(false);
if (onSuccess) {
onSuccess();
}
} catch (error) {
console.error('Failed to delete server:', error);
toast.error(t('mcp.deleteFailed'));
}
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t('mcp.confirmDeleteTitle')}</DialogTitle>
</DialogHeader>
<DialogDescription>{t('mcp.confirmDeleteServer')}</DialogDescription>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
{t('common.cancel')}
</Button>
<Button variant="destructive" onClick={handleDelete}>
{t('common.confirm')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,673 @@
'use client';
import React, { useState, useEffect, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import { Resolver, useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { toast } from 'sonner';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from '@/components/ui/dialog';
import {
Card,
CardHeader,
CardTitle,
CardDescription,
} from '@/components/ui/card';
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@/components/ui/form';
import {
Select,
SelectTrigger,
SelectValue,
SelectContent,
SelectItem,
} from '@/components/ui/select';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import { httpClient } from '@/app/infra/http/HttpClient';
import {
MCPServerRuntimeInfo,
MCPTool,
MCPServer,
MCPSessionStatus,
} from '@/app/infra/entities/api';
// Status Display Component - 在测试中、连接中或连接失败时使用
function StatusDisplay({
testing,
runtimeInfo,
t,
}: {
testing: boolean;
runtimeInfo: MCPServerRuntimeInfo;
t: (key: string) => string;
}) {
if (testing) {
return (
<div className="flex items-center gap-2 text-blue-600">
<svg
className="w-5 h-5 animate-spin"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
/>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
</svg>
<span className="font-medium">{t('mcp.testing')}</span>
</div>
);
}
// 连接中
if (runtimeInfo.status === MCPSessionStatus.CONNECTING) {
return (
<div className="flex items-center gap-2 text-blue-600">
<svg
className="w-5 h-5 animate-spin"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
/>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
</svg>
<span className="font-medium">{t('mcp.connecting')}</span>
</div>
);
}
// 连接失败
return (
<div className="space-y-1">
<div className="flex items-center gap-2 text-red-600">
<svg
className="w-5 h-5"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
<span className="font-medium">{t('mcp.connectionFailed')}</span>
</div>
{/* {runtimeInfo.error_message && (
<div className="text-sm text-red-500 pl-7">
{runtimeInfo.error_message}
</div>
)} */}
</div>
);
}
// Tools List Component
function ToolsList({ tools }: { tools: MCPTool[] }) {
return (
<div className="space-y-2 max-h-[300px] overflow-y-auto">
{tools.map((tool, index) => (
<Card key={index} className="py-3 shadow-none">
<CardHeader>
<CardTitle className="text-sm">{tool.name}</CardTitle>
{tool.description && (
<CardDescription className="text-xs">
{tool.description}
</CardDescription>
)}
</CardHeader>
</Card>
))}
</div>
);
}
const getFormSchema = (t: (key: string) => string) =>
z.object({
name: z
.string({ required_error: t('mcp.nameRequired') })
.min(1, { message: t('mcp.nameRequired') }),
timeout: z
.number({ invalid_type_error: t('mcp.timeoutMustBeNumber') })
.positive({ message: t('mcp.timeoutMustBePositive') })
.default(30),
ssereadtimeout: z
.number({ invalid_type_error: t('mcp.sseTimeoutMustBeNumber') })
.positive({ message: t('mcp.timeoutMustBePositive') })
.default(300),
url: z
.string({ required_error: t('mcp.urlRequired') })
.min(1, { message: t('mcp.urlRequired') }),
extra_args: z
.array(
z.object({
key: z.string(),
type: z.enum(['string', 'number', 'boolean']),
value: z.string(),
}),
)
.optional(),
});
type FormValues = z.infer<ReturnType<typeof getFormSchema>> & {
timeout: number;
ssereadtimeout: number;
};
interface MCPFormDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
serverName?: string | null;
isEditMode?: boolean;
onSuccess?: () => void;
onDelete?: () => void;
}
export default function MCPFormDialog({
open,
onOpenChange,
serverName,
isEditMode = false,
onSuccess,
onDelete,
}: MCPFormDialogProps) {
const { t } = useTranslation();
const formSchema = getFormSchema(t);
const form = useForm<FormValues>({
resolver: zodResolver(formSchema) as unknown as Resolver<FormValues>,
defaultValues: {
name: '',
url: '',
timeout: 30,
ssereadtimeout: 300,
extra_args: [],
},
});
const [extraArgs, setExtraArgs] = useState<
{ key: string; type: 'string' | 'number' | 'boolean'; value: string }[]
>([]);
const [mcpTesting, setMcpTesting] = useState(false);
const [runtimeInfo, setRuntimeInfo] = useState<MCPServerRuntimeInfo | null>(
null,
);
const pollingIntervalRef = useRef<NodeJS.Timeout | null>(null);
// Load server data when editing
useEffect(() => {
if (open && isEditMode && serverName) {
loadServerForEdit(serverName);
} else if (open && !isEditMode) {
// Reset form when creating new server
form.reset();
setExtraArgs([]);
setRuntimeInfo(null);
}
// Cleanup polling interval when dialog closes
return () => {
if (pollingIntervalRef.current) {
clearInterval(pollingIntervalRef.current);
pollingIntervalRef.current = null;
}
};
}, [open, isEditMode, serverName]);
// Poll for updates when runtime_info status is CONNECTING
useEffect(() => {
if (
!open ||
!isEditMode ||
!serverName ||
!runtimeInfo ||
runtimeInfo.status !== MCPSessionStatus.CONNECTING
) {
// Stop polling if conditions are not met
if (pollingIntervalRef.current) {
clearInterval(pollingIntervalRef.current);
pollingIntervalRef.current = null;
}
return;
}
// Start polling if not already running
if (!pollingIntervalRef.current) {
pollingIntervalRef.current = setInterval(() => {
loadServerForEdit(serverName);
}, 3000);
}
return () => {
if (pollingIntervalRef.current) {
clearInterval(pollingIntervalRef.current);
pollingIntervalRef.current = null;
}
};
}, [open, isEditMode, serverName, runtimeInfo?.status]);
async function loadServerForEdit(serverName: string) {
try {
const resp = await httpClient.getMCPServer(serverName);
const server = resp.server ?? resp;
const extraArgs = server.extra_args;
form.setValue('name', server.name);
form.setValue('url', extraArgs.url);
form.setValue('timeout', extraArgs.timeout);
form.setValue('ssereadtimeout', extraArgs.ssereadtimeout);
if (extraArgs.headers) {
const headers = Object.entries(extraArgs.headers).map(
([key, value]) => ({
key,
type: 'string' as const,
value: String(value),
}),
);
setExtraArgs(headers);
form.setValue('extra_args', headers);
}
// Set runtime_info from server data
if (server.runtime_info) {
setRuntimeInfo(server.runtime_info);
} else {
setRuntimeInfo(null);
}
} catch (error) {
console.error('Failed to load server:', error);
toast.error(t('mcp.loadFailed'));
}
}
async function handleFormSubmit(value: z.infer<typeof formSchema>) {
// Convert extra_args to headers - all values must be strings according to MCPServerExtraArgsSSE
const headers: Record<string, string> = {};
value.extra_args?.forEach((arg) => {
// Convert all values to strings to match MCPServerExtraArgsSSE.headers type
headers[arg.key] = String(arg.value);
});
try {
const serverConfig: Omit<
MCPServer,
'uuid' | 'created_at' | 'updated_at' | 'runtime_info'
> = {
name: value.name,
mode: 'sse' as const,
enable: true,
extra_args: {
url: value.url,
headers: headers,
timeout: value.timeout,
ssereadtimeout: value.ssereadtimeout,
},
};
if (isEditMode && serverName) {
await httpClient.updateMCPServer(serverName, serverConfig);
toast.success(t('mcp.updateSuccess'));
} else {
await httpClient.createMCPServer(serverConfig);
toast.success(t('mcp.createSuccess'));
}
handleDialogClose(false);
onSuccess?.();
} catch (error) {
console.error('Failed to save MCP server:', error);
toast.error(isEditMode ? t('mcp.updateFailed') : t('mcp.createFailed'));
}
}
async function testMcp() {
setMcpTesting(true);
try {
const { task_id } = await httpClient.testMCPServer('_', {
name: form.getValues('name'),
mode: 'sse',
enable: true,
extra_args: {
url: form.getValues('url'),
timeout: form.getValues('timeout'),
ssereadtimeout: form.getValues('ssereadtimeout'),
headers: Object.fromEntries(
extraArgs.map((arg) => [arg.key, arg.value]),
),
},
});
if (!task_id) {
throw new Error(t('mcp.noTaskId'));
}
const interval = setInterval(async () => {
try {
const taskResp = await httpClient.getAsyncTask(task_id);
if (taskResp.runtime?.done) {
clearInterval(interval);
setMcpTesting(false);
if (taskResp.runtime.exception) {
const errorMsg =
taskResp.runtime.exception || t('mcp.unknownError');
toast.error(`${t('mcp.testError')}: ${errorMsg}`);
setRuntimeInfo({
status: MCPSessionStatus.ERROR,
error_message: errorMsg,
tool_count: 0,
tools: [],
});
} else {
if (isEditMode) {
await loadServerForEdit(form.getValues('name'));
}
toast.success(t('mcp.testSuccess'));
}
}
} catch (err) {
clearInterval(interval);
setMcpTesting(false);
const errorMsg = (err as Error).message || t('mcp.getTaskFailed');
toast.error(`${t('mcp.testError')}: ${errorMsg}`);
}
}, 1000);
} catch (err) {
setMcpTesting(false);
const errorMsg = (err as Error).message || t('mcp.unknownError');
toast.error(`${t('mcp.testError')}: ${errorMsg}`);
}
}
const addExtraArg = () => {
const newArgs = [
...extraArgs,
{ key: '', type: 'string' as const, value: '' },
];
setExtraArgs(newArgs);
form.setValue('extra_args', newArgs);
};
const removeExtraArg = (index: number) => {
const newArgs = extraArgs.filter((_, i) => i !== index);
setExtraArgs(newArgs);
form.setValue('extra_args', newArgs);
};
const updateExtraArg = (
index: number,
field: 'key' | 'type' | 'value',
value: string,
) => {
const newArgs = [...extraArgs];
newArgs[index] = { ...newArgs[index], [field]: value };
setExtraArgs(newArgs);
form.setValue('extra_args', newArgs);
};
const handleDialogClose = (open: boolean) => {
onOpenChange(open);
if (!open) {
form.reset();
setExtraArgs([]);
setRuntimeInfo(null);
}
};
return (
<Dialog open={open} onOpenChange={handleDialogClose}>
<DialogContent>
<DialogHeader>
<DialogTitle>
{isEditMode ? t('mcp.editServer') : t('mcp.createServer')}
</DialogTitle>
</DialogHeader>
{isEditMode && runtimeInfo && (
<div className="mb-0 space-y-3">
{/* 测试中或连接失败时显示状态 */}
{(mcpTesting ||
runtimeInfo.status !== MCPSessionStatus.CONNECTED) && (
<div className="p-3 rounded-lg border">
<StatusDisplay
testing={mcpTesting}
runtimeInfo={runtimeInfo}
t={t}
/>
</div>
)}
{/* 连接成功时只显示工具列表 */}
{!mcpTesting &&
runtimeInfo.status === MCPSessionStatus.CONNECTED &&
runtimeInfo.tools?.length > 0 && (
<>
<div className="text-sm font-medium">
{t('mcp.toolCount', {
count: runtimeInfo.tools?.length || 0,
})}
</div>
<ToolsList tools={runtimeInfo.tools} />
</>
)}
</div>
)}
<Form {...form}>
<form
onSubmit={form.handleSubmit(handleFormSubmit)}
className="space-y-4"
>
<div className="space-y-4">
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>{t('mcp.name')}</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="url"
render={({ field }) => (
<FormItem>
<FormLabel>{t('mcp.url')}</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="timeout"
render={({ field }) => (
<FormItem>
<FormLabel>{t('mcp.timeout')}</FormLabel>
<FormControl>
<Input
type="number"
placeholder={t('mcp.timeout')}
{...field}
onChange={(e) => field.onChange(Number(e.target.value))}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="ssereadtimeout"
render={({ field }) => (
<FormItem>
<FormLabel>{t('mcp.sseTimeout')}</FormLabel>
<FormControl>
<Input
type="number"
placeholder={t('mcp.sseTimeoutDescription')}
{...field}
onChange={(e) => field.onChange(Number(e.target.value))}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormItem>
<FormLabel>{t('models.extraParameters')}</FormLabel>
<div className="space-y-2">
{extraArgs.map((arg, index) => (
<div key={index} className="flex gap-2">
<Input
placeholder={t('models.keyName')}
value={arg.key}
onChange={(e) =>
updateExtraArg(index, 'key', e.target.value)
}
/>
<Select
value={arg.type}
onValueChange={(value) =>
updateExtraArg(index, 'type', value)
}
>
<SelectTrigger className="w-[120px] bg-[#ffffff] dark:bg-[#2a2a2e]">
<SelectValue placeholder={t('models.type')} />
</SelectTrigger>
<SelectContent className="bg-[#ffffff] dark:bg-[#2a2a2e]">
<SelectItem value="string">
{t('models.string')}
</SelectItem>
<SelectItem value="number">
{t('models.number')}
</SelectItem>
<SelectItem value="boolean">
{t('models.boolean')}
</SelectItem>
</SelectContent>
</Select>
<Input
placeholder={t('models.value')}
value={arg.value}
onChange={(e) =>
updateExtraArg(index, 'value', e.target.value)
}
/>
<button
type="button"
className="p-2 hover:bg-gray-100 rounded"
onClick={() => removeExtraArg(index)}
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
className="w-5 h-5 text-red-500"
>
<path d="M7 4V2H17V4H22V6H20V21C20 21.5523 19.5523 22 19 22H5C4.44772 22 4 21.5523 4 21V6H2V4H7ZM6 6V20H18V6H6ZM9 9H11V17H9V9ZM13 9H15V17H13V9Z"></path>
</svg>
</button>
</div>
))}
<Button type="button" variant="outline" onClick={addExtraArg}>
{t('models.addParameter')}
</Button>
</div>
<FormDescription>
{t('mcp.extraParametersDescription')}
</FormDescription>
<FormMessage />
</FormItem>
<DialogFooter>
{isEditMode && onDelete && (
<Button
type="button"
variant="destructive"
onClick={onDelete}
>
{t('common.delete')}
</Button>
)}
<Button type="submit">
{isEditMode ? t('common.save') : t('common.submit')}
</Button>
<Button
type="button"
variant="outline"
onClick={() => testMcp()}
disabled={mcpTesting}
>
{t('common.test')}
</Button>
<Button
type="button"
variant="outline"
onClick={() => handleDialogClose(false)}
>
{t('common.cancel')}
</Button>
</DialogFooter>
</div>
</form>
</Form>
</DialogContent>
</Dialog>
);
}

View File

@@ -1,12 +1,20 @@
'use client';
import PluginInstalledComponent, {
PluginInstalledComponentRef,
} from '@/app/home/plugins/plugin-installed/PluginInstalledComponent';
import MarketPage from '@/app/home/plugins/plugin-market/PluginMarketComponent';
// import PluginSortDialog from '@/app/home/plugins/plugin-sort/PluginSortDialog';
} from '@/app/home/plugins/components/plugin-installed/PluginInstalledComponent';
import MarketPage from '@/app/home/plugins/components/plugin-market/PluginMarketComponent';
import MCPServerComponent from '@/app/home/plugins/mcp-server/MCPServerComponent';
import MCPFormDialog from '@/app/home/plugins/mcp-server/mcp-form/MCPFormDialog';
import MCPDeleteConfirmDialog from '@/app/home/plugins/mcp-server/mcp-form/MCPDeleteConfirmDialog';
import styles from './plugins.module.css';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Button } from '@/components/ui/button';
import {
Card,
CardHeader,
CardTitle,
CardDescription,
} from '@/components/ui/card';
import {
PlusIcon,
ChevronDownIcon,
@@ -14,6 +22,8 @@ import {
StoreIcon,
Download,
Power,
Github,
ChevronLeft,
} from 'lucide-react';
import {
DropdownMenu,
@@ -29,7 +39,7 @@ import {
DialogFooter,
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { useState, useRef, useCallback, useEffect } from 'react';
import React, { useState, useRef, useCallback, useEffect } from 'react';
import { httpClient } from '@/app/infra/http/HttpClient';
import { toast } from 'sonner';
import { useTranslation } from 'react-i18next';
@@ -39,28 +49,62 @@ import { ApiRespPluginSystemStatus } from '@/app/infra/entities/api';
enum PluginInstallStatus {
WAIT_INPUT = 'wait_input',
SELECT_RELEASE = 'select_release',
SELECT_ASSET = 'select_asset',
ASK_CONFIRM = 'ask_confirm',
INSTALLING = 'installing',
ERROR = 'error',
}
interface GithubRelease {
id: number;
tag_name: string;
name: string;
published_at: string;
prerelease: boolean;
draft: boolean;
}
interface GithubAsset {
id: number;
name: string;
size: number;
download_url: string;
content_type: string;
}
export default function PluginConfigPage() {
const { t } = useTranslation();
const [modalOpen, setModalOpen] = useState(false);
// const [sortModalOpen, setSortModalOpen] = useState(false);
const [activeTab, setActiveTab] = useState('installed');
const [modalOpen, setModalOpen] = useState(false);
const [installSource, setInstallSource] = useState<string>('local');
const [installInfo, setInstallInfo] = useState<Record<string, any>>({}); // eslint-disable-line @typescript-eslint/no-explicit-any
const [mcpSSEModalOpen, setMcpSSEModalOpen] = useState(false);
const [pluginInstallStatus, setPluginInstallStatus] =
useState<PluginInstallStatus>(PluginInstallStatus.WAIT_INPUT);
const [installError, setInstallError] = useState<string | null>(null);
const [githubURL, setGithubURL] = useState('');
const [githubReleases, setGithubReleases] = useState<GithubRelease[]>([]);
const [selectedRelease, setSelectedRelease] = useState<GithubRelease | null>(
null,
);
const [githubAssets, setGithubAssets] = useState<GithubAsset[]>([]);
const [selectedAsset, setSelectedAsset] = useState<GithubAsset | null>(null);
const [githubOwner, setGithubOwner] = useState('');
const [githubRepo, setGithubRepo] = useState('');
const [fetchingReleases, setFetchingReleases] = useState(false);
const [fetchingAssets, setFetchingAssets] = useState(false);
const [isDragOver, setIsDragOver] = useState(false);
const [pluginSystemStatus, setPluginSystemStatus] =
useState<ApiRespPluginSystemStatus | null>(null);
const [statusLoading, setStatusLoading] = useState(true);
const pluginInstalledRef = useRef<PluginInstalledComponentRef>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const [showDeleteConfirmModal, setShowDeleteConfirmModal] = useState(false);
const [editingServerName, setEditingServerName] = useState<string | null>(
null,
);
const [isEditMode, setIsEditMode] = useState(false);
const [refreshKey, setRefreshKey] = useState(0);
useEffect(() => {
const fetchPluginSystemStatus = async () => {
@@ -79,26 +123,30 @@ export default function PluginConfigPage() {
fetchPluginSystemStatus();
}, [t]);
function formatFileSize(bytes: number): string {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return Math.round((bytes / Math.pow(k, i)) * 100) / 100 + ' ' + sizes[i];
}
function watchTask(taskId: number) {
let alreadySuccess = false;
console.log('taskId:', taskId);
// 每秒拉取一次任务状态
const interval = setInterval(() => {
httpClient.getAsyncTask(taskId).then((resp) => {
console.log('task status:', resp);
if (resp.runtime.done) {
clearInterval(interval);
if (resp.runtime.exception) {
setInstallError(resp.runtime.exception);
setPluginInstallStatus(PluginInstallStatus.ERROR);
} else {
// success
if (!alreadySuccess) {
toast.success(t('plugins.installSuccess'));
alreadySuccess = true;
}
setGithubURL('');
resetGithubState();
setModalOpen(false);
pluginInstalledRef.current?.refreshPluginList();
}
@@ -107,8 +155,96 @@ export default function PluginConfigPage() {
}, 1000);
}
const pluginInstalledRef = useRef<PluginInstalledComponentRef>(null);
function resetGithubState() {
setGithubURL('');
setGithubReleases([]);
setSelectedRelease(null);
setGithubAssets([]);
setSelectedAsset(null);
setGithubOwner('');
setGithubRepo('');
setFetchingReleases(false);
setFetchingAssets(false);
}
async function fetchGithubReleases() {
if (!githubURL.trim()) {
toast.error(t('plugins.enterRepoUrl'));
return;
}
setFetchingReleases(true);
setInstallError(null);
try {
const result = await httpClient.getGithubReleases(githubURL);
setGithubReleases(result.releases);
setGithubOwner(result.owner);
setGithubRepo(result.repo);
if (result.releases.length === 0) {
toast.warning(t('plugins.noReleasesFound'));
} else {
setPluginInstallStatus(PluginInstallStatus.SELECT_RELEASE);
}
} catch (error: unknown) {
console.error('Failed to fetch GitHub releases:', error);
const errorMessage =
error instanceof Error ? error.message : String(error);
setInstallError(errorMessage || t('plugins.fetchReleasesError'));
setPluginInstallStatus(PluginInstallStatus.ERROR);
} finally {
setFetchingReleases(false);
}
}
async function handleReleaseSelect(release: GithubRelease) {
setSelectedRelease(release);
setFetchingAssets(true);
setInstallError(null);
try {
const result = await httpClient.getGithubReleaseAssets(
githubOwner,
githubRepo,
release.id,
);
setGithubAssets(result.assets);
if (result.assets.length === 0) {
toast.warning(t('plugins.noAssetsFound'));
} else {
setPluginInstallStatus(PluginInstallStatus.SELECT_ASSET);
}
} catch (error: unknown) {
console.error('Failed to fetch GitHub release assets:', error);
const errorMessage =
error instanceof Error ? error.message : String(error);
setInstallError(errorMessage || t('plugins.fetchAssetsError'));
setPluginInstallStatus(PluginInstallStatus.ERROR);
} finally {
setFetchingAssets(false);
}
}
function handleAssetSelect(asset: GithubAsset) {
setSelectedAsset(asset);
setPluginInstallStatus(PluginInstallStatus.ASK_CONFIRM);
}
function handleModalConfirm() {
installPlugin(installSource, installInfo as Record<string, any>); // eslint-disable-line @typescript-eslint/no-explicit-any
if (installSource === 'github' && selectedAsset && selectedRelease) {
installPlugin('github', {
asset_url: selectedAsset.download_url,
owner: githubOwner,
repo: githubRepo,
release_tag: selectedRelease.tag_name,
});
} else {
installPlugin(installSource, installInfo as Record<string, any>); // eslint-disable-line @typescript-eslint/no-explicit-any
}
}
function installPlugin(
@@ -118,7 +254,12 @@ export default function PluginConfigPage() {
setPluginInstallStatus(PluginInstallStatus.INSTALLING);
if (installSource === 'github') {
httpClient
.installPluginFromGithub(installInfo.url)
.installPluginFromGithub(
installInfo.asset_url,
installInfo.owner,
installInfo.repo,
installInfo.release_tag,
)
.then((resp) => {
const taskId = resp.task_id;
watchTask(taskId);
@@ -177,7 +318,7 @@ export default function PluginConfigPage() {
setInstallError(null);
installPlugin('local', { file });
},
[t, pluginSystemStatus],
[t, pluginSystemStatus, installPlugin],
);
const handleFileSelect = useCallback(() => {
@@ -192,7 +333,7 @@ export default function PluginConfigPage() {
if (file) {
uploadPluginFile(file);
}
// 清空input值以便可以重复选择同一个文件
event.target.value = '';
},
[uploadPluginFile],
@@ -234,7 +375,6 @@ export default function PluginConfigPage() {
[uploadPluginFile, isPluginSystemReady, t],
);
// 插件系统未启用的状态显示
const renderPluginDisabledState = () => (
<div className="flex flex-col items-center justify-center h-[60vh] text-center pt-[10vh]">
<Power className="w-16 h-16 text-gray-400 mb-4" />
@@ -247,7 +387,6 @@ export default function PluginConfigPage() {
</div>
);
// 插件系统连接异常的状态显示
const renderPluginConnectionErrorState = () => (
<div className="flex flex-col items-center justify-center h-[60vh] text-center pt-[10vh]">
<svg
@@ -269,7 +408,6 @@ export default function PluginConfigPage() {
</div>
);
// 加载状态显示
const renderLoadingState = () => (
<div className="flex flex-col items-center justify-center h-[60vh] pt-[10vh]">
<p className="text-gray-500 dark:text-gray-400">
@@ -278,7 +416,6 @@ export default function PluginConfigPage() {
</div>
);
// 根据状态返回不同的内容
if (statusLoading) {
return renderLoadingState();
}
@@ -316,40 +453,69 @@ export default function PluginConfigPage() {
{t('plugins.marketplace')}
</TabsTrigger>
)}
<TabsTrigger
value="mcp-servers"
className="px-6 py-4 cursor-pointer"
>
{t('mcp.title')}
</TabsTrigger>
</TabsList>
<div className="flex flex-row justify-end items-center">
{/* <Button
variant="outline"
className="px-6 py-4 cursor-pointer mr-2"
onClick={() => {
// setSortModalOpen(true);
}}
>
{t('plugins.arrange')}
</Button> */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="default" className="px-6 py-4 cursor-pointer">
<PlusIcon className="w-4 h-4" />
{t('plugins.install')}
{activeTab === 'mcp-servers'
? t('mcp.add')
: t('plugins.install')}
<ChevronDownIcon className="ml-2 w-4 h-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={handleFileSelect}>
<UploadIcon className="w-4 h-4" />
{t('plugins.uploadLocal')}
</DropdownMenuItem>
{systemInfo.enable_marketplace && (
<DropdownMenuItem
onClick={() => {
setActiveTab('market');
}}
>
<StoreIcon className="w-4 h-4" />
{t('plugins.marketplace')}
</DropdownMenuItem>
{activeTab === 'mcp-servers' ? (
<>
<DropdownMenuItem
onClick={() => {
setActiveTab('mcp-servers');
setIsEditMode(false);
setEditingServerName(null);
setMcpSSEModalOpen(true);
}}
>
<PlusIcon className="w-4 h-4" />
{t('mcp.createServer')}
</DropdownMenuItem>
</>
) : (
<>
{systemInfo.enable_marketplace && (
<DropdownMenuItem
onClick={() => {
setActiveTab('market');
}}
>
<StoreIcon className="w-4 h-4" />
{t('plugins.marketplace')}
</DropdownMenuItem>
)}
<DropdownMenuItem onClick={handleFileSelect}>
<UploadIcon className="w-4 h-4" />
{t('plugins.uploadLocal')}
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => {
setInstallSource('github');
setPluginInstallStatus(PluginInstallStatus.WAIT_INPUT);
setInstallError(null);
resetGithubState();
setModalOpen(true);
}}
>
<Github className="w-4 h-4" />
{t('plugins.installFromGithub')}
</DropdownMenuItem>
</>
)}
</DropdownMenuContent>
</DropdownMenu>
@@ -372,51 +538,259 @@ export default function PluginConfigPage() {
}}
/>
</TabsContent>
<TabsContent value="mcp-servers">
<MCPServerComponent
key={refreshKey}
onEditServer={(serverName) => {
setEditingServerName(serverName);
setIsEditMode(true);
setMcpSSEModalOpen(true);
}}
/>
</TabsContent>
</Tabs>
<Dialog open={modalOpen} onOpenChange={setModalOpen}>
<DialogContent className="w-[500px] p-6 bg-white dark:bg-[#1a1a1e]">
<Dialog
open={modalOpen}
onOpenChange={(open) => {
setModalOpen(open);
if (!open) {
resetGithubState();
setInstallError(null);
}
}}
>
<DialogContent className="w-[500px] max-h-[80vh] p-6 bg-white dark:bg-[#1a1a1e] overflow-y-auto">
<DialogHeader>
<DialogTitle className="flex items-center gap-4">
<Download className="size-6" />
{installSource === 'github' ? (
<Github className="size-6" />
) : (
<Download className="size-6" />
)}
<span>{t('plugins.installPlugin')}</span>
</DialogTitle>
</DialogHeader>
{pluginInstallStatus === PluginInstallStatus.WAIT_INPUT && (
<div className="mt-4">
<p className="mb-2">{t('plugins.onlySupportGithub')}</p>
<Input
placeholder={t('plugins.enterGithubLink')}
value={githubURL}
onChange={(e) => setGithubURL(e.target.value)}
className="mb-4"
/>
</div>
)}
{pluginInstallStatus === PluginInstallStatus.ASK_CONFIRM && (
<div className="mt-4">
<p className="mb-2">
{t('plugins.askConfirm', {
name: installInfo.plugin_name,
version: installInfo.plugin_version,
})}
</p>
</div>
)}
{/* GitHub Install Flow */}
{installSource === 'github' &&
pluginInstallStatus === PluginInstallStatus.WAIT_INPUT && (
<div className="mt-4">
<p className="mb-2">{t('plugins.enterRepoUrl')}</p>
<Input
placeholder={t('plugins.repoUrlPlaceholder')}
value={githubURL}
onChange={(e) => setGithubURL(e.target.value)}
className="mb-4"
/>
{fetchingReleases && (
<p className="text-sm text-gray-500">
{t('plugins.fetchingReleases')}
</p>
)}
</div>
)}
{installSource === 'github' &&
pluginInstallStatus === PluginInstallStatus.SELECT_RELEASE && (
<div className="mt-4">
<div className="flex items-center justify-between mb-4">
<p className="font-medium">{t('plugins.selectRelease')}</p>
<Button
variant="ghost"
size="sm"
onClick={() => {
setPluginInstallStatus(PluginInstallStatus.WAIT_INPUT);
setGithubReleases([]);
}}
>
<ChevronLeft className="w-4 h-4 mr-1" />
{t('plugins.backToRepoUrl')}
</Button>
</div>
<div className="max-h-[400px] overflow-y-auto space-y-2 pb-2">
{githubReleases.map((release) => (
<Card
key={release.id}
className="cursor-pointer hover:shadow-sm transition-shadow duration-200 shadow-none py-4"
onClick={() => handleReleaseSelect(release)}
>
<CardHeader className="flex flex-row items-start justify-between px-3 space-y-0">
<div className="flex-1">
<CardTitle className="text-sm">
{release.name || release.tag_name}
</CardTitle>
<CardDescription className="text-xs mt-1">
{t('plugins.releaseTag', { tag: release.tag_name })}{' '}
{' '}
{t('plugins.publishedAt', {
date: new Date(
release.published_at,
).toLocaleDateString(),
})}
</CardDescription>
</div>
{release.prerelease && (
<span className="text-xs bg-yellow-100 dark:bg-yellow-900 text-yellow-800 dark:text-yellow-200 px-2 py-0.5 rounded ml-2 shrink-0">
{t('plugins.prerelease')}
</span>
)}
</CardHeader>
</Card>
))}
</div>
{fetchingAssets && (
<p className="text-sm text-gray-500 mt-4">
{t('plugins.loading')}
</p>
)}
</div>
)}
{installSource === 'github' &&
pluginInstallStatus === PluginInstallStatus.SELECT_ASSET && (
<div className="mt-4">
<div className="flex items-center justify-between mb-4">
<p className="font-medium">{t('plugins.selectAsset')}</p>
<Button
variant="ghost"
size="sm"
onClick={() => {
setPluginInstallStatus(
PluginInstallStatus.SELECT_RELEASE,
);
setGithubAssets([]);
setSelectedAsset(null);
}}
>
<ChevronLeft className="w-4 h-4 mr-1" />
{t('plugins.backToReleases')}
</Button>
</div>
{selectedRelease && (
<div className="mb-4 p-2 bg-gray-50 dark:bg-gray-900 rounded">
<div className="text-sm font-medium">
{selectedRelease.name || selectedRelease.tag_name}
</div>
<div className="text-xs text-gray-500">
{selectedRelease.tag_name}
</div>
</div>
)}
<div className="max-h-[400px] overflow-y-auto space-y-2 pb-2">
{githubAssets.map((asset) => (
<Card
key={asset.id}
className="cursor-pointer hover:shadow-sm transition-shadow duration-200 shadow-none py-3"
onClick={() => handleAssetSelect(asset)}
>
<CardHeader className="px-3">
<CardTitle className="text-sm">{asset.name}</CardTitle>
<CardDescription className="text-xs">
{t('plugins.assetSize', {
size: formatFileSize(asset.size),
})}
</CardDescription>
</CardHeader>
</Card>
))}
</div>
</div>
)}
{/* Marketplace Install Confirm */}
{installSource === 'marketplace' &&
pluginInstallStatus === PluginInstallStatus.ASK_CONFIRM && (
<div className="mt-4">
<p className="mb-2">
{t('plugins.askConfirm', {
name: installInfo.plugin_name,
version: installInfo.plugin_version,
})}
</p>
</div>
)}
{/* GitHub Install Confirm */}
{installSource === 'github' &&
pluginInstallStatus === PluginInstallStatus.ASK_CONFIRM && (
<div className="mt-4">
<div className="flex items-center justify-between mb-4">
<p className="font-medium">{t('plugins.confirmInstall')}</p>
<Button
variant="ghost"
size="sm"
onClick={() => {
setPluginInstallStatus(PluginInstallStatus.SELECT_ASSET);
setSelectedAsset(null);
}}
>
<ChevronLeft className="w-4 h-4 mr-1" />
{t('plugins.backToAssets')}
</Button>
</div>
{selectedRelease && selectedAsset && (
<div className="p-3 bg-gray-50 dark:bg-gray-900 rounded space-y-2">
<div>
<span className="text-sm font-medium">Repository: </span>
<span className="text-sm">
{githubOwner}/{githubRepo}
</span>
</div>
<div>
<span className="text-sm font-medium">Release: </span>
<span className="text-sm">
{selectedRelease.tag_name}
</span>
</div>
<div>
<span className="text-sm font-medium">File: </span>
<span className="text-sm">{selectedAsset.name}</span>
</div>
</div>
)}
</div>
)}
{/* Installing State */}
{pluginInstallStatus === PluginInstallStatus.INSTALLING && (
<div className="mt-4">
<p className="mb-2">{t('plugins.installing')}</p>
</div>
)}
{/* Error State */}
{pluginInstallStatus === PluginInstallStatus.ERROR && (
<div className="mt-4">
<p className="mb-2">{t('plugins.installFailed')}</p>
<p className="mb-2 text-red-500">{installError}</p>
</div>
)}
<DialogFooter>
{(pluginInstallStatus === PluginInstallStatus.WAIT_INPUT ||
pluginInstallStatus === PluginInstallStatus.ASK_CONFIRM) && (
{pluginInstallStatus === PluginInstallStatus.WAIT_INPUT &&
installSource === 'github' && (
<>
<Button
variant="outline"
onClick={() => {
setModalOpen(false);
resetGithubState();
}}
>
{t('common.cancel')}
</Button>
<Button
onClick={fetchGithubReleases}
disabled={!githubURL.trim() || fetchingReleases}
>
{fetchingReleases
? t('plugins.loading')
: t('common.confirm')}
</Button>
</>
)}
{pluginInstallStatus === PluginInstallStatus.ASK_CONFIRM && (
<>
<Button variant="outline" onClick={() => setModalOpen(false)}>
{t('common.cancel')}
@@ -435,7 +809,6 @@ export default function PluginConfigPage() {
</DialogContent>
</Dialog>
{/* 拖拽提示覆盖层 */}
{isDragOver && (
<div className="fixed inset-0 bg-gray-500 bg-opacity-50 flex items-center justify-center z-50 pointer-events-none">
<div className="bg-white rounded-lg p-8 shadow-lg border-2 border-dashed border-gray-500">
@@ -449,13 +822,32 @@ export default function PluginConfigPage() {
</div>
)}
{/* <PluginSortDialog
open={sortModalOpen}
onOpenChange={setSortModalOpen}
onSortComplete={() => {
pluginInstalledRef.current?.refreshPluginList();
<MCPFormDialog
open={mcpSSEModalOpen}
onOpenChange={setMcpSSEModalOpen}
serverName={editingServerName}
isEditMode={isEditMode}
onSuccess={() => {
setEditingServerName(null);
setIsEditMode(false);
setRefreshKey((prev) => prev + 1);
}}
/> */}
onDelete={() => {
setShowDeleteConfirmModal(true);
}}
/>
<MCPDeleteConfirmDialog
open={showDeleteConfirmModal}
onOpenChange={setShowDeleteConfirmModal}
serverName={editingServerName}
onSuccess={() => {
setMcpSSEModalOpen(false);
setEditingServerName(null);
setIsEditMode(false);
setRefreshKey((prev) => prev + 1);
}}
/>
</div>
);
}

View File

@@ -308,3 +308,49 @@ export interface RetrieveResult {
export interface ApiRespKnowledgeBaseRetrieve {
results: RetrieveResult[];
}
// MCP
export interface ApiRespMCPServers {
servers: MCPServer[];
}
export interface ApiRespMCPServer {
server: MCPServer;
}
export interface MCPServerExtraArgsSSE {
url: string;
headers: Record<string, string>;
timeout: number;
ssereadtimeout: number;
}
export enum MCPSessionStatus {
CONNECTING = 'connecting',
CONNECTED = 'connected',
ERROR = 'error',
}
export interface MCPServerRuntimeInfo {
status: MCPSessionStatus;
error_message: string;
tool_count: number;
tools: MCPTool[];
}
export interface MCPServer {
uuid?: string;
name: string;
mode: 'stdio' | 'sse';
enable: boolean;
extra_args: MCPServerExtraArgsSSE;
runtime_info?: MCPServerRuntimeInfo;
created_at?: string;
updated_at?: string;
}
export interface MCPTool {
name: string;
description: string;
parameters?: object;
}

View File

@@ -33,6 +33,9 @@ import {
ApiRespProviderEmbeddingModel,
EmbeddingModel,
ApiRespPluginSystemStatus,
ApiRespMCPServers,
ApiRespMCPServer,
MCPServer,
} from '@/app/infra/entities/api';
import { GetBotLogsRequest } from '@/app/infra/http/requestParam/bots/GetBotLogsRequest';
import { GetBotLogsResponse } from '@/app/infra/http/requestParam/bots/GetBotLogsResponse';
@@ -451,9 +454,52 @@ export class BackendClient extends BaseHttpClient {
}
public installPluginFromGithub(
source: string,
assetUrl: string,
owner: string,
repo: string,
releaseTag: string,
): Promise<AsyncTaskCreatedResp> {
return this.post('/api/v1/plugins/install/github', { source });
return this.post('/api/v1/plugins/install/github', {
asset_url: assetUrl,
owner,
repo,
release_tag: releaseTag,
});
}
public getGithubReleases(repoUrl: string): Promise<{
releases: Array<{
id: number;
tag_name: string;
name: string;
published_at: string;
prerelease: boolean;
draft: boolean;
}>;
owner: string;
repo: string;
}> {
return this.post('/api/v1/plugins/github/releases', { repo_url: repoUrl });
}
public getGithubReleaseAssets(
owner: string,
repo: string,
releaseId: number,
): Promise<{
assets: Array<{
id: number;
name: string;
size: number;
download_url: string;
content_type: string;
}>;
}> {
return this.post('/api/v1/plugins/github/release-assets', {
owner,
repo,
release_id: releaseId,
});
}
public installPluginFromLocal(file: File): Promise<AsyncTaskCreatedResp> {
@@ -477,8 +523,11 @@ export class BackendClient extends BaseHttpClient {
public removePlugin(
author: string,
name: string,
deleteData: boolean = false,
): Promise<AsyncTaskCreatedResp> {
return this.delete(`/api/v1/plugins/${author}/${name}`);
return this.delete(
`/api/v1/plugins/${author}/${name}?delete_data=${deleteData}`,
);
}
public upgradePlugin(
@@ -488,6 +537,58 @@ export class BackendClient extends BaseHttpClient {
return this.post(`/api/v1/plugins/${author}/${name}/upgrade`);
}
// ============ MCP API ============
public getMCPServers(): Promise<ApiRespMCPServers> {
return this.get('/api/v1/mcp/servers');
}
public getMCPServer(serverName: string): Promise<ApiRespMCPServer> {
return this.get(`/api/v1/mcp/servers/${serverName}`);
}
public createMCPServer(server: MCPServer): Promise<AsyncTaskCreatedResp> {
return this.post('/api/v1/mcp/servers', server);
}
public updateMCPServer(
serverName: string,
server: Partial<MCPServer>,
): Promise<AsyncTaskCreatedResp> {
return this.put(`/api/v1/mcp/servers/${serverName}`, server);
}
public deleteMCPServer(serverName: string): Promise<AsyncTaskCreatedResp> {
return this.delete(`/api/v1/mcp/servers/${serverName}`);
}
public toggleMCPServer(
serverName: string,
target_enabled: boolean,
): Promise<AsyncTaskCreatedResp> {
return this.put(`/api/v1/mcp/servers/${serverName}`, {
enable: target_enabled,
});
}
public testMCPServer(
serverName: string,
serverData: object,
): Promise<AsyncTaskCreatedResp> {
return this.post(`/api/v1/mcp/servers/${serverName}/test`, serverData);
}
public installMCPServerFromGithub(
source: string,
): Promise<AsyncTaskCreatedResp> {
return this.post('/api/v1/mcp/install/github', { source });
}
public installMCPServerFromSSE(
source: object,
): Promise<AsyncTaskCreatedResp> {
return this.post('/api/v1/mcp/servers', { source });
}
// ============ System API ============
public getSystemInfo(): Promise<ApiRespSystemInfo> {
return this.get('/api/v1/system/info');

View File

@@ -0,0 +1,141 @@
'use client';
import * as React from 'react';
import * as AlertDialogPrimitive from '@radix-ui/react-alert-dialog';
import { cn } from '@/lib/utils';
import { buttonVariants } from '@/components/ui/button';
const AlertDialog = AlertDialogPrimitive.Root;
const AlertDialogTrigger = AlertDialogPrimitive.Trigger;
const AlertDialogPortal = AlertDialogPrimitive.Portal;
const AlertDialogOverlay = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Overlay
className={cn(
'fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
className,
)}
{...props}
ref={ref}
/>
));
AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName;
const AlertDialogContent = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Content>
>(({ className, ...props }, ref) => (
<AlertDialogPortal>
<AlertDialogOverlay />
<AlertDialogPrimitive.Content
ref={ref}
className={cn(
'fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 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-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg',
className,
)}
{...props}
/>
</AlertDialogPortal>
));
AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName;
const AlertDialogHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
'flex flex-col space-y-2 text-center sm:text-left',
className,
)}
{...props}
/>
);
AlertDialogHeader.displayName = 'AlertDialogHeader';
const AlertDialogFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
'flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2',
className,
)}
{...props}
/>
);
AlertDialogFooter.displayName = 'AlertDialogFooter';
const AlertDialogTitle = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Title
ref={ref}
className={cn('text-lg font-semibold', className)}
{...props}
/>
));
AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName;
const AlertDialogDescription = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Description
ref={ref}
className={cn('text-sm text-muted-foreground', className)}
{...props}
/>
));
AlertDialogDescription.displayName =
AlertDialogPrimitive.Description.displayName;
const AlertDialogAction = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Action>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Action>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Action
ref={ref}
className={cn(buttonVariants(), className)}
{...props}
/>
));
AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName;
const AlertDialogCancel = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Cancel>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Cancel>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Cancel
ref={ref}
className={cn(
buttonVariants({ variant: 'outline' }),
'mt-2 sm:mt-0',
className,
)}
{...props}
/>
));
AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName;
export {
AlertDialog,
AlertDialogPortal,
AlertDialogOverlay,
AlertDialogTrigger,
AlertDialogContent,
AlertDialogHeader,
AlertDialogFooter,
AlertDialogTitle,
AlertDialogDescription,
AlertDialogAction,
AlertDialogCancel,
};

View File

@@ -7,9 +7,71 @@ import { XIcon } from 'lucide-react';
import { cn } from '@/lib/utils';
function Dialog({
onOpenChange,
open,
...props
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
return <DialogPrimitive.Root data-slot="dialog" {...props} />;
const handleOpenChange = React.useCallback(
(isOpen: boolean) => {
onOpenChange?.(isOpen);
// 当对话框关闭时,确保清理 body 样式
if (!isOpen) {
// 立即清理
document.body.style.removeProperty('pointer-events');
document.body.style.removeProperty('overflow');
// 延迟再次清理,确保覆盖 Radix 的设置
setTimeout(() => {
document.body.style.removeProperty('pointer-events');
document.body.style.removeProperty('overflow');
}, 0);
setTimeout(() => {
document.body.style.removeProperty('pointer-events');
document.body.style.removeProperty('overflow');
}, 50);
setTimeout(() => {
document.body.style.removeProperty('pointer-events');
document.body.style.removeProperty('overflow');
}, 150);
}
},
[onOpenChange],
);
// 使用 effect 监控 open 状态变化
React.useEffect(() => {
if (open === false) {
const cleanup = () => {
document.body.style.removeProperty('pointer-events');
document.body.style.removeProperty('overflow');
};
cleanup();
const timer1 = setTimeout(cleanup, 0);
const timer2 = setTimeout(cleanup, 50);
const timer3 = setTimeout(cleanup, 150);
const timer4 = setTimeout(cleanup, 300);
return () => {
clearTimeout(timer1);
clearTimeout(timer2);
clearTimeout(timer3);
clearTimeout(timer4);
};
}
}, [open]);
return (
<DialogPrimitive.Root
data-slot="dialog"
open={open}
{...props}
onOpenChange={handleOpenChange}
/>
);
}
function DialogTrigger({
@@ -60,7 +122,6 @@ function DialogContent({
'bg-background 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 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg',
className,
)}
onInteractOutside={() => {}}
{...props}
>
{children}

View File

@@ -151,7 +151,7 @@ const enUS = {
logs: 'Logs',
},
plugins: {
title: 'Plugins',
title: 'Extensions',
description:
'Install and configure plugins to extend LangBot functionality',
createPlugin: 'Create Plugin',
@@ -199,16 +199,20 @@ const enUS = {
saveConfig: 'Save Config',
saving: 'Saving...',
confirmDeletePlugin:
'Are you sure you want to delete the plugin ({{author}}/{{name}})? This will also delete the plugin configuration.',
'Are you sure you want to delete the plugin ({{author}}/{{name}})?',
deleteDataCheckbox:
'Also delete plugin configuration and persistence storage',
confirmDelete: 'Confirm Delete',
deleteError: 'Delete failed: ',
close: 'Close',
deleteConfirm: 'Delete Confirmation',
deleteSuccess: 'Delete successful',
modifyFailed: 'Modify failed: ',
eventCount: 'Events: {{count}}',
toolCount: 'Tools: {{count}}',
starCount: 'Stars: {{count}}',
componentName: {
Tool: 'Tool',
EventListener: 'Event Listener',
Command: 'Command',
},
uploadLocal: 'Upload Local',
debugging: 'Debugging',
uploadLocalPlugin: 'Upload Local Plugin',
@@ -238,6 +242,26 @@ const enUS = {
saveConfigSuccessDebugPlugin:
'Configuration saved successfully, please manually restart the plugin',
saveConfigError: 'Configuration save failed: ',
installFromGithub: 'From GitHub',
enterRepoUrl: 'Enter GitHub repository URL',
repoUrlPlaceholder: 'e.g., https://github.com/owner/repo',
fetchingReleases: 'Fetching releases...',
selectRelease: 'Select Release',
noReleasesFound: 'No releases found',
fetchReleasesError: 'Failed to fetch releases: ',
selectAsset: 'Select file to install',
noAssetsFound: 'No .lbpkg files available in this release',
fetchAssetsError: 'Failed to fetch assets: ',
backToReleases: 'Back to releases',
backToRepoUrl: 'Back to repository URL',
backToAssets: 'Back to assets',
releaseTag: 'Tag: {{tag}}',
releaseName: 'Name: {{name}}',
publishedAt: 'Published at: {{date}}',
prerelease: 'Pre-release',
assetSize: 'Size: {{size}}',
confirmInstall: 'Confirm Install',
installFromGithubDesc: 'Install plugin from GitHub Release',
},
market: {
searchPlaceholder: 'Search plugins...',
@@ -282,6 +306,84 @@ const enUS = {
markAsReadSuccess: 'Marked as read',
markAsReadFailed: 'Mark as read failed',
},
mcp: {
title: 'MCP',
createServer: 'Add MCP Server',
editServer: 'Edit MCP Server',
deleteServer: 'Delete MCP Server',
confirmDeleteServer: 'Are you sure you want to delete this MCP server?',
confirmDeleteTitle: 'Delete MCP Server',
getServerListError: 'Failed to get MCP server list: ',
serverName: 'Server Name',
serverMode: 'Connection Mode',
stdio: 'Stdio Mode',
sse: 'SSE Mode',
noServerInstalled: 'No MCP servers configured',
serverNameRequired: 'Server name cannot be empty',
commandRequired: 'Command cannot be empty',
urlRequired: 'URL cannot be empty',
timeoutMustBePositive: 'Timeout must be a positive number',
command: 'Command',
args: 'Arguments',
env: 'Environment Variables',
url: 'URL',
headers: 'Headers',
timeout: 'Timeout',
addArgument: 'Add Argument',
addEnvVar: 'Add Environment Variable',
addHeader: 'Add Header',
keyName: 'Key Name',
value: 'Value',
testing: 'Testing...',
connecting: 'Connecting...',
testSuccess: 'Test successful',
testFailed: 'Test failed: ',
testError: 'Test error',
refreshSuccess: 'Refresh successful',
refreshFailed: 'Refresh failed: ',
connectionSuccess: 'Connection successful',
connectionFailed: 'Connection failed, please check URL',
connectionFailedStatus: 'Connection Failed',
toolsFound: 'tools',
unknownError: 'Unknown error',
noToolsFound: 'No tools found',
parseResultFailed: 'Failed to parse test result',
noResultReturned: 'Test returned no result',
getTaskFailed: 'Failed to get task status',
noTaskId: 'No task ID obtained',
deleteSuccess: 'Deleted successfully',
deleteFailed: 'Delete failed: ',
deleteError: 'Delete failed: ',
saveSuccess: 'Saved successfully',
saveError: 'Save failed: ',
createSuccess: 'Created successfully',
createFailed: 'Creation failed: ',
createError: 'Creation failed: ',
loadFailed: 'Load failed',
modifyFailed: 'Modify failed: ',
toolCount: 'Tools: {{count}}',
statusConnected: 'Connected',
statusDisconnected: 'Disconnected',
statusError: 'Connection Error',
statusDisabled: 'Disabled',
loading: 'Loading...',
starCount: 'Stars: {{count}}',
install: 'Install',
installFromGithub: 'Install MCP Server from GitHub',
add: 'Add',
name: 'Name',
nameRequired: 'Name cannot be empty',
sseTimeout: 'SSE Timeout',
sseTimeoutDescription: 'Timeout for establishing SSE connection',
extraParametersDescription:
'Additional parameters for configuring specific MCP server behavior',
timeoutMustBeNumber: 'Timeout must be a number',
timeoutNonNegative: 'Timeout cannot be negative',
sseTimeoutMustBeNumber: 'SSE timeout must be a number',
sseTimeoutNonNegative: 'SSE timeout cannot be negative',
updateSuccess: 'Updated successfully',
updateFailed: 'Update failed: ',
},
pipelines: {
title: 'Pipelines',
description:
@@ -298,6 +400,7 @@ const enUS = {
defaultBadge: 'Default',
sortBy: 'Sort by',
newestCreated: 'Newest Created',
earliestCreated: 'Earliest Created',
recentlyEdited: 'Recently Edited',
earliestEdited: 'Earliest Edited',
basicInfo: 'Basic',

View File

@@ -153,7 +153,7 @@ const jaJP = {
logs: 'ログ',
},
plugins: {
title: 'プラグイン',
title: '拡張機能',
description: 'LangBotの機能を拡張するプラグインをインストール・設定',
createPlugin: 'プラグインを作成',
editPlugin: 'プラグインを編集',
@@ -200,16 +200,19 @@ const jaJP = {
saveConfig: '設定を保存',
saving: '保存中...',
confirmDeletePlugin:
'プラグイン「{{author}}/{{name}}」を削除してもよろしいですか?この操作により、プラグインの設定も削除されます。',
'プラグイン「{{author}}/{{name}}」を削除してもよろしいですか?',
deleteDataCheckbox: 'プラグイン設定と永続化ストレージも削除する',
confirmDelete: '削除を確認',
deleteError: '削除に失敗しました:',
close: '閉じる',
deleteConfirm: '削除の確認',
deleteSuccess: '削除に成功しました',
modifyFailed: '変更に失敗しました:',
eventCount: 'イベント:{{count}}',
toolCount: 'ツール:{{count}}',
starCount: 'スター:{{count}}',
componentName: {
Tool: 'ツール',
EventListener: 'イベント監視器',
Command: 'コマンド',
},
uploadLocal: 'ローカルアップロード',
debugging: 'デバッグ中',
uploadLocalPlugin: 'ローカルプラグインのアップロード',
@@ -239,6 +242,26 @@ const jaJP = {
saveConfigSuccessDebugPlugin:
'設定を保存しました。手動でプラグインを再起動してください',
saveConfigError: '設定の保存に失敗しました:',
installFromGithub: 'GitHubから',
enterRepoUrl: 'GitHubリポジトリのURLを入力してください',
repoUrlPlaceholder: '例: https://github.com/owner/repo',
fetchingReleases: 'リリース一覧を取得中...',
selectRelease: 'リリースを選択',
noReleasesFound: 'リリースが見つかりません',
fetchReleasesError: 'リリース一覧の取得に失敗しました:',
selectAsset: 'インストールするファイルを選択',
noAssetsFound: 'このリリースには利用可能な .lbpkg ファイルがありません',
fetchAssetsError: 'ファイル一覧の取得に失敗しました:',
backToReleases: 'リリース一覧に戻る',
backToRepoUrl: 'リポジトリURLに戻る',
backToAssets: 'ファイル選択に戻る',
releaseTag: 'タグ: {{tag}}',
releaseName: '名前: {{name}}',
publishedAt: '公開日: {{date}}',
prerelease: 'プレリリース',
assetSize: 'サイズ: {{size}}',
confirmInstall: 'インストールを確認',
installFromGithubDesc: 'GitHubリリースからプラグインをインストール',
},
market: {
searchPlaceholder: 'プラグインを検索...',
@@ -284,6 +307,84 @@ const jaJP = {
markAsReadSuccess: '既読に設定しました',
markAsReadFailed: '既読に設定に失敗しました',
},
mcp: {
title: 'MCP',
createServer: 'MCPサーバーを追加',
editServer: 'MCPサーバーを編集',
deleteServer: 'MCPサーバーを削除',
confirmDeleteServer: 'このMCPサーバーを削除してもよろしいですか',
confirmDeleteTitle: 'MCPサーバーを削除',
getServerListError: 'MCPサーバーリストの取得に失敗しました',
serverName: 'サーバー名',
serverMode: '接続モード',
stdio: 'Stdioモード',
sse: 'SSEモード',
noServerInstalled: 'MCPサーバーが設定されていません',
serverNameRequired: 'サーバー名は必須です',
commandRequired: 'コマンドは必須です',
urlRequired: 'URLは必須です',
timeoutMustBePositive: 'タイムアウトは正の数でなければなりません',
command: 'コマンド',
args: '引数',
env: '環境変数',
url: 'URL',
headers: 'ヘッダー',
timeout: 'タイムアウト',
addArgument: '引数を追加',
addEnvVar: '環境変数を追加',
addHeader: 'ヘッダーを追加',
keyName: 'キー名',
value: '値',
testing: 'テスト中...',
connecting: '接続中...',
testSuccess: '刷新に成功しました',
testFailed: '刷新に失敗しました:',
testError: '刷新エラー',
refreshSuccess: '刷新に成功しました',
refreshFailed: '刷新に失敗しました:',
connectionSuccess: '接続に成功しました',
connectionFailed: '接続に失敗しましたURLを確認してください',
connectionFailedStatus: '接続失敗',
toolsFound: '個のツール',
unknownError: '不明なエラー',
noToolsFound: 'ツールが見つかりません',
parseResultFailed: 'テスト結果の解析に失敗しました',
noResultReturned: 'テスト結果が返されませんでした',
getTaskFailed: 'タスクステータスの取得に失敗しました',
noTaskId: 'タスクIDを取得できませんでした',
deleteSuccess: '削除に成功しました',
deleteFailed: '削除に失敗しました:',
deleteError: '削除に失敗しました:',
saveSuccess: '保存に成功しました',
saveError: '保存に失敗しました:',
createSuccess: '作成に成功しました',
createFailed: '作成に失敗しました:',
createError: '作成に失敗しました:',
loadFailed: '読み込みに失敗しました',
modifyFailed: '変更に失敗しました:',
toolCount: 'ツール:{{count}}',
statusConnected: '接続済み',
statusDisconnected: '未接続',
statusError: '接続エラー',
statusDisabled: '無効',
loading: '読み込み中...',
starCount: 'スター:{{count}}',
install: 'インストール',
installFromGithub: 'GitHubからMCPサーバーをインストール',
add: '追加',
name: '名前',
nameRequired: '名前は必須です',
sseTimeout: 'SSEタイムアウト',
sseTimeoutDescription: 'SSE接続を確立するためのタイムアウト',
extraParametersDescription:
'MCPサーバーの特定の動作を設定するための追加パラメータ',
timeoutMustBeNumber: 'タイムアウトは数値である必要があります',
timeoutNonNegative: 'タイムアウトは負の数にできません',
sseTimeoutMustBeNumber: 'SSEタイムアウトは数値である必要があります',
sseTimeoutNonNegative: 'SSEタイムアウトは負の数にできません',
updateSuccess: '更新に成功しました',
updateFailed: '更新に失敗しました:',
},
pipelines: {
title: 'パイプライン',
description:
@@ -300,6 +401,7 @@ const jaJP = {
defaultBadge: 'デフォルト',
sortBy: '並び順',
newestCreated: '最新作成',
earliestCreated: '最古作成',
recentlyEdited: '最近編集',
earliestEdited: '最古編集',
basicInfo: '基本情報',

View File

@@ -148,7 +148,7 @@ const zhHans = {
logs: '日志',
},
plugins: {
title: '插件管理',
title: '插件扩展',
description: '安装和配置用于扩展 LangBot 功能的插件',
createPlugin: '创建插件',
editPlugin: '编辑插件',
@@ -191,17 +191,19 @@ const zhHans = {
cancel: '取消',
saveConfig: '保存配置',
saving: '保存中...',
confirmDeletePlugin:
'你确定要删除插件({{author}}/{{name}})吗?这将同时删除插件配置',
confirmDeletePlugin: '你确定要删除插件({{author}}/{{name}})吗?',
deleteDataCheckbox: '同时删除插件配置和持久化存储',
confirmDelete: '确认删除',
deleteError: '删除失败:',
close: '关闭',
deleteConfirm: '删除确认',
deleteSuccess: '删除成功',
modifyFailed: '修改失败:',
eventCount: '事件:{{count}}',
toolCount: '工具:{{count}}',
starCount: '星标:{{count}}',
componentName: {
Tool: '工具',
EventListener: '事件监听器',
Command: '命令',
},
uploadLocal: '本地上传',
debugging: '调试中',
uploadLocalPlugin: '上传本地插件',
@@ -228,6 +230,26 @@ const zhHans = {
saveConfigSuccessNormal: '保存配置成功',
saveConfigSuccessDebugPlugin: '保存配置成功,请手动重启插件',
saveConfigError: '保存配置失败:',
installFromGithub: '来自 GitHub',
enterRepoUrl: '请输入 GitHub 仓库地址',
repoUrlPlaceholder: '例如: https://github.com/owner/repo',
fetchingReleases: '正在获取 Release 列表...',
selectRelease: '选择 Release',
noReleasesFound: '未找到 Release',
fetchReleasesError: '获取 Release 列表失败:',
selectAsset: '选择要安装的文件',
noAssetsFound: '该 Release 没有可用的 .lbpkg 文件',
fetchAssetsError: '获取文件列表失败:',
backToReleases: '返回 Release 列表',
backToRepoUrl: '返回仓库地址',
backToAssets: '返回文件选择',
releaseTag: 'Tag: {{tag}}',
releaseName: '名称: {{name}}',
publishedAt: '发布于: {{date}}',
prerelease: '预发布',
assetSize: '大小: {{size}}',
confirmInstall: '确认安装',
installFromGithubDesc: '从 GitHub Release 安装插件',
},
market: {
searchPlaceholder: '搜索插件...',
@@ -270,6 +292,83 @@ const zhHans = {
markAsReadSuccess: '已标记为已读',
markAsReadFailed: '标记为已读失败',
},
mcp: {
title: 'MCP',
createServer: '添加 MCP 服务器',
editServer: '修改 MCP 服务器',
deleteServer: '删除 MCP 服务器',
confirmDeleteServer: '你确定要删除此 MCP 服务器吗?',
confirmDeleteTitle: '删除 MCP 服务器',
getServerListError: '获取 MCP 服务器列表失败:',
serverName: '服务器名称',
serverMode: '连接模式',
stdio: 'Stdio模式',
sse: 'SSE模式',
noServerInstalled: '暂未配置任何 MCP 服务器',
serverNameRequired: '服务器名称不能为空',
commandRequired: '命令不能为空',
urlRequired: 'URL 不能为空',
timeoutMustBePositive: '超时时间必须是正数',
command: '命令',
args: '参数',
env: '环境变量',
url: 'URL地址',
headers: '请求头',
timeout: '超时时间',
addArgument: '添加参数',
addEnvVar: '添加环境变量',
addHeader: '添加请求头',
keyName: '键名',
value: '值',
testing: '测试中...',
connecting: '连接中...',
testSuccess: '测试成功',
testFailed: '测试失败:',
testError: '刷新出错',
refreshSuccess: '刷新成功',
refreshFailed: '刷新失败:',
connectionSuccess: '连接成功',
connectionFailed: '连接失败请检查URL',
connectionFailedStatus: '连接失败',
toolsFound: '个工具',
unknownError: '未知错误',
noToolsFound: '未找到任何工具',
parseResultFailed: '解析测试结果失败',
noResultReturned: '测试未返回结果',
getTaskFailed: '获取任务状态失败',
noTaskId: '未获取到任务ID',
deleteSuccess: '删除成功',
deleteFailed: '删除失败:',
deleteError: '删除失败:',
saveSuccess: '保存成功',
saveError: '保存失败:',
createSuccess: '创建成功',
createFailed: '创建失败:',
createError: '创建失败:',
loadFailed: '加载失败',
modifyFailed: '修改失败:',
toolCount: '工具:{{count}}',
statusConnected: '已打开',
statusDisconnected: '未打开',
statusError: '连接错误',
statusDisabled: '已禁用',
loading: '加载中...',
starCount: '星标:{{count}}',
install: '安装',
installFromGithub: '从Github安装MCP服务器',
add: '添加',
name: '名称',
nameRequired: '名称不能为空',
sseTimeout: 'SSE超时时间',
sseTimeoutDescription: '用于建立SSE连接的超时时间',
extraParametersDescription: '额外参数用于配置MCP服务器的特定行为',
timeoutMustBeNumber: '超时时间必须是数字',
timeoutNonNegative: '超时时间不能为负数',
sseTimeoutMustBeNumber: 'SSE超时时间必须是数字',
sseTimeoutNonNegative: 'SSE超时时间不能为负数',
updateSuccess: '更新成功',
updateFailed: '更新失败:',
},
pipelines: {
title: '流水线',
description: '流水线定义了对消息事件的处理流程,用于绑定到机器人',
@@ -285,6 +384,7 @@ const zhHans = {
defaultBadge: '默认',
sortBy: '排序方式',
newestCreated: '最新创建',
earliestCreated: '最早创建',
recentlyEdited: '最近编辑',
earliestEdited: '最早编辑',
basicInfo: '基础信息',

View File

@@ -148,7 +148,7 @@ const zhHant = {
logs: '日誌',
},
plugins: {
title: '外掛管理',
title: '外掛擴展',
description: '安裝和設定用於擴展 LangBot 功能的外掛',
createPlugin: '建立外掛',
editPlugin: '編輯外掛',
@@ -156,7 +156,7 @@ const zhHant = {
marketplace: 'Marketplace',
arrange: '編排',
install: '安裝',
installFromGithub: ' GitHub 安裝外掛',
installFromGithub: '來自 GitHub',
onlySupportGithub: '目前僅支援從 GitHub 安裝',
enterGithubLink: '請輸入外掛的Github連結',
installing: '正在安裝外掛...',
@@ -192,14 +192,17 @@ const zhHant = {
saveConfig: '儲存設定',
saving: '儲存中...',
confirmDeletePlugin: '您確定要刪除外掛({{author}}/{{name}})嗎?',
deleteDataCheckbox: '同時刪除外掛設定和持久化儲存',
confirmDelete: '確認刪除',
deleteError: '刪除失敗:',
close: '關閉',
deleteConfirm: '刪除確認',
modifyFailed: '修改失敗:',
eventCount: '事件:{{count}}',
toolCount: '工具:{{count}}',
starCount: '星標:{{count}}',
componentName: {
Tool: '工具',
EventListener: '事件監聽器',
Command: '命令',
},
uploadLocal: '本地上傳',
debugging: '調試中',
uploadLocalPlugin: '上傳本地插件',
@@ -226,6 +229,25 @@ const zhHant = {
saveConfigSuccessNormal: '儲存配置成功',
saveConfigSuccessDebugPlugin: '儲存配置成功,請手動重啟插件',
saveConfigError: '儲存配置失敗:',
enterRepoUrl: '請輸入 GitHub 倉庫地址',
repoUrlPlaceholder: '例如: https://github.com/owner/repo',
fetchingReleases: '正在獲取 Release 列表...',
selectRelease: '選擇 Release',
noReleasesFound: '未找到 Release',
fetchReleasesError: '獲取 Release 列表失敗:',
selectAsset: '選擇要安裝的文件',
noAssetsFound: '該 Release 沒有可用的 .lbpkg 文件',
fetchAssetsError: '獲取文件列表失敗:',
backToReleases: '返回 Release 列表',
backToRepoUrl: '返回倉庫地址',
backToAssets: '返回文件選擇',
releaseTag: 'Tag: {{tag}}',
releaseName: '名稱: {{name}}',
publishedAt: '發佈於: {{date}}',
prerelease: '預發佈',
assetSize: '大小: {{size}}',
confirmInstall: '確認安裝',
installFromGithubDesc: '從 GitHub Release 安裝插件',
},
market: {
searchPlaceholder: '搜尋插件...',
@@ -268,6 +290,83 @@ const zhHant = {
markAsReadSuccess: '已標記為已讀',
markAsReadFailed: '標記為已讀失敗',
},
mcp: {
title: 'MCP',
createServer: '新增MCP伺服器',
editServer: '編輯MCP伺服器',
deleteServer: '刪除MCP伺服器',
confirmDeleteServer: '您確定要刪除此MCP伺服器嗎',
confirmDeleteTitle: '刪除MCP伺服器',
getServerListError: '取得MCP伺服器清單失敗',
serverName: '伺服器名稱',
serverMode: '連接模式',
stdio: 'Stdio模式',
sse: 'SSE模式',
noServerInstalled: '暫未設定任何MCP伺服器',
serverNameRequired: '伺服器名稱不能為空',
commandRequired: '命令不能為空',
urlRequired: 'URL不能為空',
timeoutMustBePositive: '逾時時間必須是正數',
command: '命令',
args: '參數',
env: '環境變數',
url: 'URL位址',
headers: '請求標頭',
timeout: '逾時時間',
addArgument: '新增參數',
addEnvVar: '新增環境變數',
addHeader: '新增請求標頭',
keyName: '鍵名',
value: '值',
testing: '測試中...',
connecting: '連接中...',
testSuccess: '測試成功',
testFailed: '刷新失敗:',
testError: '刷新出錯',
refreshSuccess: '刷新成功',
refreshFailed: '刷新失敗:',
connectionSuccess: '連接成功',
connectionFailed: '連接失敗請檢查URL',
connectionFailedStatus: '連接失敗',
toolsFound: '個工具',
unknownError: '未知錯誤',
noToolsFound: '未找到任何工具',
parseResultFailed: '解析測試結果失敗',
noResultReturned: '測試未返回結果',
getTaskFailed: '獲取任務狀態失敗',
noTaskId: '未獲取到任務ID',
deleteSuccess: '刪除成功',
deleteFailed: '刪除失敗:',
deleteError: '刪除失敗:',
saveSuccess: '儲存成功',
saveError: '儲存失敗:',
createSuccess: '建立成功',
createFailed: '建立失敗:',
createError: '建立失敗:',
loadFailed: '載入失敗',
modifyFailed: '修改失敗:',
toolCount: '工具:{{count}}',
statusConnected: '已開啟',
statusDisconnected: '未開啟',
statusError: '連接錯誤',
statusDisabled: '已停用',
loading: '載入中...',
starCount: '星標:{{count}}',
install: '安裝',
installFromGithub: '從Github安裝MCP伺服器',
add: '新增',
name: '名稱',
nameRequired: '名稱不能為空',
sseTimeout: 'SSE逾時時間',
sseTimeoutDescription: '用於建立SSE連接的逾時時間',
extraParametersDescription: '額外參數用於設定MCP伺服器的特定行為',
timeoutMustBeNumber: '逾時時間必須是數字',
timeoutNonNegative: '逾時時間不能為負數',
sseTimeoutMustBeNumber: 'SSE逾時時間必須是數字',
sseTimeoutNonNegative: 'SSE逾時時間不能為負數',
updateSuccess: '更新成功',
updateFailed: '更新失敗:',
},
pipelines: {
title: '流程線',
description: '流程線定義了對訊息事件的處理流程,用於綁定到機器人',
@@ -283,6 +382,7 @@ const zhHant = {
defaultBadge: '預設',
sortBy: '排序方式',
newestCreated: '最新建立',
earliestCreated: '最早建立',
recentlyEdited: '最近編輯',
earliestEdited: '最早編輯',
basicInfo: '基本資訊',