mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-06-05 05:16:03 +00:00
Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
558587883b | ||
|
|
2e6a1daf4f | ||
|
|
1fc5e75f93 | ||
|
|
a332206ba3 | ||
|
|
8e620dc635 | ||
|
|
c9a21ebace | ||
|
|
a05cdcac50 | ||
|
|
ecfb2bfb34 | ||
|
|
e17dba0a98 | ||
|
|
6b138943ce | ||
|
|
eb0e6aff68 | ||
|
|
4d0095626a | ||
|
|
aa0a501ade |
@@ -14,7 +14,7 @@ services:
|
|||||||
restart: on-failure
|
restart: on-failure
|
||||||
environment:
|
environment:
|
||||||
- TZ=Asia/Shanghai
|
- TZ=Asia/Shanghai
|
||||||
command: ["uv", "run", "-m", "langbot_plugin.cli.__init__", "rt"]
|
command: ["uv", "run", "--no-sync", "-m", "langbot_plugin.cli.__init__", "rt"]
|
||||||
networks:
|
networks:
|
||||||
- langbot_network
|
- langbot_network
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "langbot"
|
name = "langbot"
|
||||||
version = "4.7.0"
|
version = "4.7.2"
|
||||||
description = "Production-grade platform for building IM bots"
|
description = "Production-grade platform for building IM bots"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
license-files = ["LICENSE"]
|
license-files = ["LICENSE"]
|
||||||
@@ -23,7 +23,7 @@ dependencies = [
|
|||||||
"pynacl>=1.5.0", # Required for Discord voice support
|
"pynacl>=1.5.0", # Required for Discord voice support
|
||||||
"gewechat-client>=0.1.5",
|
"gewechat-client>=0.1.5",
|
||||||
"lark-oapi>=1.4.15",
|
"lark-oapi>=1.4.15",
|
||||||
"mcp>=1.8.1",
|
"mcp>=1.20.0",
|
||||||
"nakuru-project-idk>=0.0.2.1",
|
"nakuru-project-idk>=0.0.2.1",
|
||||||
"ollama>=0.4.8",
|
"ollama>=0.4.8",
|
||||||
"openai>1.0.0",
|
"openai>1.0.0",
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
"""LangBot - Production-grade platform for building IM bots"""
|
"""LangBot - Production-grade platform for building IM bots"""
|
||||||
|
|
||||||
__version__ = '4.7.0'
|
__version__ = '4.7.2'
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ from . import taskmgr
|
|||||||
from . import entities as core_entities
|
from . import entities as core_entities
|
||||||
from ..rag.knowledge import kbmgr as rag_mgr
|
from ..rag.knowledge import kbmgr as rag_mgr
|
||||||
from ..vector import mgr as vectordb_mgr
|
from ..vector import mgr as vectordb_mgr
|
||||||
|
from ..telemetry import telemetry as telemetry_module
|
||||||
|
|
||||||
|
|
||||||
class Application:
|
class Application:
|
||||||
@@ -140,6 +141,8 @@ class Application:
|
|||||||
|
|
||||||
webhook_service: webhook_service.WebhookService = None
|
webhook_service: webhook_service.WebhookService = None
|
||||||
|
|
||||||
|
telemetry: telemetry_module.TelemetryManager = None
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|||||||
@@ -31,6 +31,8 @@ from ...storage import mgr as storagemgr
|
|||||||
from ...utils import logcache
|
from ...utils import logcache
|
||||||
from ...vector import mgr as vectordb_mgr
|
from ...vector import mgr as vectordb_mgr
|
||||||
from .. import taskmgr
|
from .. import taskmgr
|
||||||
|
from ...telemetry import telemetry as telemetry_module
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@stage.stage_class('BuildAppStage')
|
@stage.stage_class('BuildAppStage')
|
||||||
@@ -102,6 +104,11 @@ class BuildAppStage(stage.BootingStage):
|
|||||||
ap.persistence_mgr = persistence_mgr_inst
|
ap.persistence_mgr = persistence_mgr_inst
|
||||||
await persistence_mgr_inst.initialize()
|
await persistence_mgr_inst.initialize()
|
||||||
|
|
||||||
|
# Telemetry manager: attach to app so other components can call via self.ap.telemetry
|
||||||
|
telemetry_inst = telemetry_module.TelemetryManager(ap)
|
||||||
|
await telemetry_inst.initialize()
|
||||||
|
ap.telemetry = telemetry_inst
|
||||||
|
|
||||||
cmd_mgr_inst = cmdmgr.CommandManager(ap)
|
cmd_mgr_inst = cmdmgr.CommandManager(ap)
|
||||||
await cmd_mgr_inst.initialize()
|
await cmd_mgr_inst.initialize()
|
||||||
ap.cmd_mgr = cmd_mgr_inst
|
ap.cmd_mgr = cmd_mgr_inst
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ class MCPServer(Base):
|
|||||||
uuid = sqlalchemy.Column(sqlalchemy.String(255), primary_key=True, unique=True)
|
uuid = sqlalchemy.Column(sqlalchemy.String(255), primary_key=True, unique=True)
|
||||||
name = sqlalchemy.Column(sqlalchemy.String(255), nullable=False)
|
name = sqlalchemy.Column(sqlalchemy.String(255), nullable=False)
|
||||||
enable = sqlalchemy.Column(sqlalchemy.Boolean, nullable=False, default=False)
|
enable = sqlalchemy.Column(sqlalchemy.Boolean, nullable=False, default=False)
|
||||||
mode = sqlalchemy.Column(sqlalchemy.String(255), nullable=False) # stdio, sse
|
mode = sqlalchemy.Column(sqlalchemy.String(255), nullable=False) # stdio, sse, http
|
||||||
extra_args = sqlalchemy.Column(sqlalchemy.JSON, nullable=False, default={})
|
extra_args = sqlalchemy.Column(sqlalchemy.JSON, nullable=False, default={})
|
||||||
created_at = sqlalchemy.Column(sqlalchemy.DateTime, nullable=False, server_default=sqlalchemy.func.now())
|
created_at = sqlalchemy.Column(sqlalchemy.DateTime, nullable=False, server_default=sqlalchemy.func.now())
|
||||||
updated_at = sqlalchemy.Column(
|
updated_at = sqlalchemy.Column(
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ from __future__ import annotations
|
|||||||
import uuid
|
import uuid
|
||||||
import typing
|
import typing
|
||||||
import traceback
|
import traceback
|
||||||
|
import time
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
|
||||||
from .. import handler
|
from .. import handler
|
||||||
@@ -10,7 +12,7 @@ from ... import entities
|
|||||||
from ....provider import runner as runner_module
|
from ....provider import runner as runner_module
|
||||||
|
|
||||||
import langbot_plugin.api.entities.events as events
|
import langbot_plugin.api.entities.events as events
|
||||||
from ....utils import importutil
|
from ....utils import importutil, constants
|
||||||
from ....provider import runners
|
from ....provider import runners
|
||||||
import langbot_plugin.api.entities.builtin.provider.session as provider_session
|
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.builtin.pipeline.query as pipeline_query
|
||||||
@@ -84,6 +86,9 @@ class ChatMessageHandler(handler.MessageHandler):
|
|||||||
break
|
break
|
||||||
else:
|
else:
|
||||||
raise ValueError(f'Request Runner not found: {query.pipeline_config["ai"]["runner"]["runner"]}')
|
raise ValueError(f'Request Runner not found: {query.pipeline_config["ai"]["runner"]["runner"]}')
|
||||||
|
# Mark start time for telemetry
|
||||||
|
start_ts = time.time()
|
||||||
|
|
||||||
if is_stream:
|
if is_stream:
|
||||||
resp_message_id = uuid.uuid4()
|
resp_message_id = uuid.uuid4()
|
||||||
chunk_count = 0 # Track streaming chunks to reduce excessive logging
|
chunk_count = 0 # Track streaming chunks to reduce excessive logging
|
||||||
@@ -140,7 +145,8 @@ class ChatMessageHandler(handler.MessageHandler):
|
|||||||
|
|
||||||
query.session.using_conversation.messages.extend(query.resp_messages)
|
query.session.using_conversation.messages.extend(query.resp_messages)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.ap.logger.error(f'Conversation({query.query_id}) Request Failed: {type(e).__name__} {str(e)}')
|
error_info = f'{traceback.format_exc()}'
|
||||||
|
self.ap.logger.error(f'Conversation({query.query_id}) Request Failed: {error_info}')
|
||||||
traceback.print_exc()
|
traceback.print_exc()
|
||||||
|
|
||||||
hide_exception_info = query.pipeline_config['output']['misc']['hide-exception']
|
hide_exception_info = query.pipeline_config['output']['misc']['hide-exception']
|
||||||
@@ -153,5 +159,47 @@ class ChatMessageHandler(handler.MessageHandler):
|
|||||||
debug_notice=traceback.format_exc(),
|
debug_notice=traceback.format_exc(),
|
||||||
)
|
)
|
||||||
finally:
|
finally:
|
||||||
# TODO statistics
|
# Telemetry reporting: collect minimal per-query execution info and send asynchronously
|
||||||
pass
|
try:
|
||||||
|
end_ts = time.time()
|
||||||
|
duration_ms = None
|
||||||
|
if 'start_ts' in locals():
|
||||||
|
duration_ms = int((end_ts - start_ts) * 1000)
|
||||||
|
|
||||||
|
adapter_name = query.adapter.__class__.__name__ if hasattr(query, 'adapter') else None
|
||||||
|
runner_name = (
|
||||||
|
query.pipeline_config.get('ai', {}).get('runner', {}).get('runner')
|
||||||
|
if query.pipeline_config
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
|
||||||
|
# Model name if using localagent
|
||||||
|
model_name = None
|
||||||
|
try:
|
||||||
|
if runner_name == 'local-agent' and getattr(query, 'use_llm_model_uuid', None):
|
||||||
|
m = await self.ap.model_mgr.get_model_by_uuid(query.use_llm_model_uuid)
|
||||||
|
if m and getattr(m, 'model_entity', None):
|
||||||
|
model_name = getattr(m.model_entity, 'name', None)
|
||||||
|
except Exception:
|
||||||
|
model_name = None
|
||||||
|
|
||||||
|
pipeline_plugins = query.variables.get('_pipeline_bound_plugins', None)
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
'query_id': query.query_id,
|
||||||
|
'adapter': adapter_name,
|
||||||
|
'runner': runner_name,
|
||||||
|
'duration_ms': duration_ms,
|
||||||
|
'model_name': model_name,
|
||||||
|
'version': constants.semantic_version,
|
||||||
|
'instance_id': constants.instance_id,
|
||||||
|
'pipeline_plugins': pipeline_plugins,
|
||||||
|
'error': locals().get('error_info', None),
|
||||||
|
'timestamp': datetime.utcnow().isoformat(),
|
||||||
|
}
|
||||||
|
|
||||||
|
# Send telemetry asynchronously and do not block pipeline via app's telemetry manager
|
||||||
|
await self.ap.telemetry.start_send_task(payload)
|
||||||
|
except Exception as ex:
|
||||||
|
# Ensure telemetry issues do not affect normal flow
|
||||||
|
self.ap.logger.warning(f'Failed to send telemetry: {ex}')
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ from ...discover import engine
|
|||||||
from . import token
|
from . import token
|
||||||
from ...entity.persistence import model as persistence_model
|
from ...entity.persistence import model as persistence_model
|
||||||
from ...entity.errors import provider as provider_errors
|
from ...entity.errors import provider as provider_errors
|
||||||
|
from async_lru import alru_cache
|
||||||
|
|
||||||
|
|
||||||
class ModelManager:
|
class ModelManager:
|
||||||
@@ -349,6 +350,7 @@ class ModelManager:
|
|||||||
|
|
||||||
await self.load_embedding_model_with_provider(model_entity, provider_entity)
|
await self.load_embedding_model_with_provider(model_entity, provider_entity)
|
||||||
|
|
||||||
|
@alru_cache(ttl=60 * 5)
|
||||||
async def get_model_by_uuid(self, uuid: str) -> requester.RuntimeLLMModel:
|
async def get_model_by_uuid(self, uuid: str) -> requester.RuntimeLLMModel:
|
||||||
"""Get LLM model by uuid"""
|
"""Get LLM model by uuid"""
|
||||||
for model in self.llm_models:
|
for model in self.llm_models:
|
||||||
@@ -356,6 +358,7 @@ class ModelManager:
|
|||||||
return model
|
return model
|
||||||
raise ValueError(f'LLM model {uuid} not found')
|
raise ValueError(f'LLM model {uuid} not found')
|
||||||
|
|
||||||
|
@alru_cache(ttl=60 * 5)
|
||||||
async def get_embedding_model_by_uuid(self, uuid: str) -> requester.RuntimeEmbeddingModel:
|
async def get_embedding_model_by_uuid(self, uuid: str) -> requester.RuntimeEmbeddingModel:
|
||||||
"""Get embedding model by uuid"""
|
"""Get embedding model by uuid"""
|
||||||
for model in self.embedding_models:
|
for model in self.embedding_models:
|
||||||
|
|||||||
@@ -529,7 +529,7 @@ class DifyServiceAPIRunner(runner.RequestRunner):
|
|||||||
think_end = True
|
think_end = True
|
||||||
elif think_end or not think_start:
|
elif think_end or not think_start:
|
||||||
pending_agent_message += chunk['answer']
|
pending_agent_message += chunk['answer']
|
||||||
if think_start:
|
if think_start and not think_end:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
else:
|
else:
|
||||||
|
|||||||
@@ -215,16 +215,24 @@ class LocalAgentRunner(runner.RequestRunner):
|
|||||||
parameters = json.loads(func.arguments)
|
parameters = json.loads(func.arguments)
|
||||||
|
|
||||||
func_ret = await self.ap.tool_mgr.execute_func_call(func.name, parameters, query=query)
|
func_ret = await self.ap.tool_mgr.execute_func_call(func.name, parameters, query=query)
|
||||||
|
|
||||||
|
# Handle return value content
|
||||||
|
tool_content = None
|
||||||
|
if isinstance(func_ret, list) and len(func_ret) > 0 and isinstance(func_ret[0], provider_message.ContentElement):
|
||||||
|
tool_content = func_ret
|
||||||
|
else:
|
||||||
|
tool_content = json.dumps(func_ret, ensure_ascii=False)
|
||||||
|
|
||||||
if is_stream:
|
if is_stream:
|
||||||
msg = provider_message.MessageChunk(
|
msg = provider_message.MessageChunk(
|
||||||
role='tool',
|
role='tool',
|
||||||
content=json.dumps(func_ret, ensure_ascii=False),
|
content=tool_content,
|
||||||
tool_call_id=tool_call.id,
|
tool_call_id=tool_call.id,
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
msg = provider_message.Message(
|
msg = provider_message.Message(
|
||||||
role='tool',
|
role='tool',
|
||||||
content=json.dumps(func_ret, ensure_ascii=False),
|
content=tool_content,
|
||||||
tool_call_id=tool_call.id,
|
tool_call_id=tool_call.id,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -7,14 +7,18 @@ import traceback
|
|||||||
from langbot_plugin.api.entities.events import pipeline_query
|
from langbot_plugin.api.entities.events import pipeline_query
|
||||||
import sqlalchemy
|
import sqlalchemy
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
import uuid as uuid_module
|
||||||
from mcp import ClientSession, StdioServerParameters
|
from mcp import ClientSession, StdioServerParameters
|
||||||
from mcp.client.stdio import stdio_client
|
from mcp.client.stdio import stdio_client
|
||||||
from mcp.client.sse import sse_client
|
from mcp.client.sse import sse_client
|
||||||
|
from mcp.client.streamable_http import streamable_http_client
|
||||||
|
|
||||||
from .. import loader
|
from .. import loader
|
||||||
from ....core import app
|
from ....core import app
|
||||||
import langbot_plugin.api.entities.builtin.resource.tool as resource_tool
|
import langbot_plugin.api.entities.builtin.resource.tool as resource_tool
|
||||||
|
import langbot_plugin.api.entities.builtin.provider.message as provider_message
|
||||||
from ....entity.persistence import mcp as persistence_mcp
|
from ....entity.persistence import mcp as persistence_mcp
|
||||||
|
|
||||||
|
|
||||||
@@ -35,7 +39,7 @@ class RuntimeMCPSession:
|
|||||||
|
|
||||||
server_config: dict
|
server_config: dict
|
||||||
|
|
||||||
session: ClientSession
|
session: ClientSession | None
|
||||||
|
|
||||||
exit_stack: AsyncExitStack
|
exit_stack: AsyncExitStack
|
||||||
|
|
||||||
@@ -52,6 +56,8 @@ class RuntimeMCPSession:
|
|||||||
|
|
||||||
_ready_event: asyncio.Event
|
_ready_event: asyncio.Event
|
||||||
|
|
||||||
|
error_message: str | None = None
|
||||||
|
|
||||||
def __init__(self, server_name: str, server_config: dict, enable: bool, ap: app.Application):
|
def __init__(self, server_name: str, server_config: dict, enable: bool, ap: app.Application):
|
||||||
self.server_name = server_name
|
self.server_name = server_name
|
||||||
self.server_uuid = server_config.get('uuid', '')
|
self.server_uuid = server_config.get('uuid', '')
|
||||||
@@ -100,6 +106,24 @@ class RuntimeMCPSession:
|
|||||||
|
|
||||||
await self.session.initialize()
|
await self.session.initialize()
|
||||||
|
|
||||||
|
async def _init_streamable_http_server(self):
|
||||||
|
transport = await self.exit_stack.enter_async_context(
|
||||||
|
streamable_http_client(
|
||||||
|
self.server_config['url'],
|
||||||
|
http_client=httpx.AsyncClient(
|
||||||
|
headers=self.server_config.get('headers', {}),
|
||||||
|
timeout=self.server_config.get('timeout', 10),
|
||||||
|
follow_redirects=True,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
read, write, _ = transport
|
||||||
|
|
||||||
|
self.session = await self.exit_stack.enter_async_context(ClientSession(read, write))
|
||||||
|
|
||||||
|
await self.session.initialize()
|
||||||
|
|
||||||
async def _lifecycle_loop(self):
|
async def _lifecycle_loop(self):
|
||||||
"""在后台任务中管理整个MCP会话的生命周期"""
|
"""在后台任务中管理整个MCP会话的生命周期"""
|
||||||
try:
|
try:
|
||||||
@@ -107,6 +131,8 @@ class RuntimeMCPSession:
|
|||||||
await self._init_stdio_python_server()
|
await self._init_stdio_python_server()
|
||||||
elif self.server_config['mode'] == 'sse':
|
elif self.server_config['mode'] == 'sse':
|
||||||
await self._init_sse_server()
|
await self._init_sse_server()
|
||||||
|
elif self.server_config['mode'] == 'http':
|
||||||
|
await self._init_streamable_http_server()
|
||||||
else:
|
else:
|
||||||
raise ValueError(f'无法识别 MCP 服务器类型: {self.server_name}: {self.server_config}')
|
raise ValueError(f'无法识别 MCP 服务器类型: {self.server_name}: {self.server_config}')
|
||||||
|
|
||||||
@@ -122,6 +148,7 @@ class RuntimeMCPSession:
|
|||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.status = MCPSessionStatus.ERROR
|
self.status = MCPSessionStatus.ERROR
|
||||||
|
self.error_message = str(e)
|
||||||
self.ap.logger.error(f'Error in MCP session lifecycle {self.server_name}: {e}\n{traceback.format_exc()}')
|
self.ap.logger.error(f'Error in MCP session lifecycle {self.server_name}: {e}\n{traceback.format_exc()}')
|
||||||
# 即使出错也要设置ready事件,让start()方法知道初始化已完成
|
# 即使出错也要设置ready事件,让start()方法知道初始化已完成
|
||||||
self._ready_event.set()
|
self._ready_event.set()
|
||||||
@@ -154,6 +181,9 @@ class RuntimeMCPSession:
|
|||||||
raise Exception('Connection failed, please check URL')
|
raise Exception('Connection failed, please check URL')
|
||||||
|
|
||||||
async def refresh(self):
|
async def refresh(self):
|
||||||
|
if not self.session:
|
||||||
|
return
|
||||||
|
|
||||||
self.functions.clear()
|
self.functions.clear()
|
||||||
|
|
||||||
tools = await self.session.list_tools()
|
tools = await self.session.list_tools()
|
||||||
@@ -163,18 +193,36 @@ class RuntimeMCPSession:
|
|||||||
for tool in tools.tools:
|
for tool in tools.tools:
|
||||||
|
|
||||||
async def func(*, _tool=tool, **kwargs):
|
async def func(*, _tool=tool, **kwargs):
|
||||||
|
if not self.session:
|
||||||
|
raise Exception("MCP session is not connected")
|
||||||
|
|
||||||
result = await self.session.call_tool(_tool.name, kwargs)
|
result = await self.session.call_tool(_tool.name, kwargs)
|
||||||
if result.isError:
|
if result.isError:
|
||||||
raise Exception(result.content[0].text)
|
error_texts = []
|
||||||
return result.content[0].text
|
for content in result.content:
|
||||||
|
if content.type == 'text':
|
||||||
|
error_texts.append(content.text)
|
||||||
|
raise Exception("\n".join(error_texts) if error_texts else "Unknown error from MCP tool")
|
||||||
|
|
||||||
|
result_contents: list[provider_message.ContentElement] = []
|
||||||
|
for content in result.content:
|
||||||
|
if content.type == 'text':
|
||||||
|
result_contents.append(provider_message.ContentElement.from_text(content.text))
|
||||||
|
elif content.type == 'image':
|
||||||
|
result_contents.append(provider_message.ContentElement.from_image_base64(content.image_base64))
|
||||||
|
elif content.type == 'resource':
|
||||||
|
# TODO: Handle resource content
|
||||||
|
pass
|
||||||
|
|
||||||
|
return result_contents
|
||||||
|
|
||||||
func.__name__ = tool.name
|
func.__name__ = tool.name
|
||||||
|
|
||||||
self.functions.append(
|
self.functions.append(
|
||||||
resource_tool.LLMTool(
|
resource_tool.LLMTool(
|
||||||
name=tool.name,
|
name=tool.name,
|
||||||
human_desc=tool.description,
|
human_desc=tool.description or "",
|
||||||
description=tool.description,
|
description=tool.description or "",
|
||||||
parameters=tool.inputSchema,
|
parameters=tool.inputSchema,
|
||||||
func=func,
|
func=func,
|
||||||
)
|
)
|
||||||
@@ -186,6 +234,7 @@ class RuntimeMCPSession:
|
|||||||
def get_runtime_info_dict(self) -> dict:
|
def get_runtime_info_dict(self) -> dict:
|
||||||
return {
|
return {
|
||||||
'status': self.status.value,
|
'status': self.status.value,
|
||||||
|
'error_message': self.error_message,
|
||||||
'tool_count': len(self.get_tools()),
|
'tool_count': len(self.get_tools()),
|
||||||
'tools': [
|
'tools': [
|
||||||
{
|
{
|
||||||
@@ -287,6 +336,14 @@ class MCPLoader(loader.ToolLoader):
|
|||||||
- enable: 是否启用
|
- enable: 是否启用
|
||||||
- extra_args: 额外的配置参数 (可选)
|
- extra_args: 额外的配置参数 (可选)
|
||||||
"""
|
"""
|
||||||
|
uuid_ = server_config.get('uuid')
|
||||||
|
if not uuid_:
|
||||||
|
self.ap.logger.warning(
|
||||||
|
'Server UUID is None for MCP server, maybe testing in the config page.'
|
||||||
|
)
|
||||||
|
uuid_ = str(uuid_module.uuid4())
|
||||||
|
server_config['uuid'] = uuid_
|
||||||
|
|
||||||
|
|
||||||
name = server_config['name']
|
name = server_config['name']
|
||||||
uuid = server_config['uuid']
|
uuid = server_config['uuid']
|
||||||
|
|||||||
@@ -32,12 +32,18 @@ class Embedder(BaseService):
|
|||||||
|
|
||||||
await self.ap.persistence_mgr.execute_async(sqlalchemy.insert(persistence_rag.Chunk).values(chunk_dicts))
|
await self.ap.persistence_mgr.execute_async(sqlalchemy.insert(persistence_rag.Chunk).values(chunk_dicts))
|
||||||
|
|
||||||
# get embeddings
|
# get embeddings (batch size limit: 64 for OpenAI)
|
||||||
embeddings_list: list[list[float]] = await embedding_model.provider.requester.invoke_embedding(
|
MAX_BATCH_SIZE = 64
|
||||||
model=embedding_model,
|
embeddings_list: list[list[float]] = []
|
||||||
input_text=chunks,
|
|
||||||
extra_args={}, # TODO: add extra args
|
for i in range(0, len(chunks), MAX_BATCH_SIZE):
|
||||||
)
|
batch = chunks[i:i + MAX_BATCH_SIZE]
|
||||||
|
batch_embeddings = await embedding_model.provider.requester.invoke_embedding(
|
||||||
|
model=embedding_model,
|
||||||
|
input_text=batch,
|
||||||
|
extra_args={}, # TODO: add extra args
|
||||||
|
)
|
||||||
|
embeddings_list.extend(batch_embeddings)
|
||||||
|
|
||||||
# save embeddings to vdb
|
# save embeddings to vdb
|
||||||
await self.ap.vector_db_mgr.vector_db.add_embeddings(kb_id, chunk_ids, embeddings_list, chunk_dicts)
|
await self.ap.vector_db_mgr.vector_db.add_embeddings(kb_id, chunk_ids, embeddings_list, chunk_dicts)
|
||||||
|
|||||||
0
src/langbot/pkg/telemetry/__init__.py
Normal file
0
src/langbot/pkg/telemetry/__init__.py
Normal file
121
src/langbot/pkg/telemetry/telemetry.py
Normal file
121
src/langbot/pkg/telemetry/telemetry.py
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import httpx
|
||||||
|
from ..core import app as core_app
|
||||||
|
|
||||||
|
|
||||||
|
class TelemetryManager:
|
||||||
|
"""TelemetryManager handles sending telemetry for a given application instance.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
telemetry = TelemetryManager(ap)
|
||||||
|
await telemetry.send({ ... })
|
||||||
|
"""
|
||||||
|
|
||||||
|
send_tasks: list[asyncio.Task] = []
|
||||||
|
|
||||||
|
def __init__(self, ap: core_app.Application):
|
||||||
|
self.ap = ap
|
||||||
|
|
||||||
|
self.telemetry_config = {}
|
||||||
|
|
||||||
|
async def initialize(self):
|
||||||
|
self.telemetry_config = self.ap.instance_config.data.get('space', {})
|
||||||
|
|
||||||
|
async def start_send_task(self, payload: dict):
|
||||||
|
task = asyncio.create_task(self.send(payload))
|
||||||
|
self.send_tasks.append(task)
|
||||||
|
|
||||||
|
async def send(self, payload: dict):
|
||||||
|
"""Send telemetry payload to configured telemetry server (non-blocking).
|
||||||
|
|
||||||
|
Expects ap.instance_config.data.telemetry to have:
|
||||||
|
- enabled: bool
|
||||||
|
- server: str (base URL, e.g. https://space.example.com)
|
||||||
|
- timeout_seconds: optional int, overall request timeout (default 10)
|
||||||
|
|
||||||
|
Posts to {server.rstrip('/')}/api/v1/telemetry as JSON. Failures are logged but do not raise.
|
||||||
|
"""
|
||||||
|
|
||||||
|
try:
|
||||||
|
cfg = self.telemetry_config
|
||||||
|
if not cfg:
|
||||||
|
return
|
||||||
|
if cfg.get('disable_telemetry', False):
|
||||||
|
return
|
||||||
|
server = cfg.get('url', '')
|
||||||
|
if not server:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Normalize URL
|
||||||
|
url = server.rstrip('/') + '/api/v1/telemetry'
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Sanitize payload so string fields are strings and not nulls
|
||||||
|
sanitized = dict(payload)
|
||||||
|
if 'query_id' in sanitized:
|
||||||
|
try:
|
||||||
|
sanitized['query_id'] = '' if sanitized['query_id'] is None else str(sanitized['query_id'])
|
||||||
|
except Exception:
|
||||||
|
sanitized['query_id'] = str(sanitized.get('query_id', ''))
|
||||||
|
|
||||||
|
for sfield in ('adapter', 'runner', 'model_name', 'version', 'error', 'timestamp'):
|
||||||
|
v = sanitized.get(sfield)
|
||||||
|
sanitized[sfield] = '' if v is None else str(v)
|
||||||
|
|
||||||
|
if 'duration_ms' in sanitized:
|
||||||
|
try:
|
||||||
|
sanitized['duration_ms'] = (
|
||||||
|
int(sanitized['duration_ms']) if sanitized['duration_ms'] is not None else 0
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
sanitized['duration_ms'] = 0
|
||||||
|
|
||||||
|
async with httpx.AsyncClient(timeout=httpx.Timeout(10)) as client:
|
||||||
|
try:
|
||||||
|
# Use asyncio.wait_for to ensure we always bound the total time
|
||||||
|
resp = await asyncio.wait_for(client.post(url, json=sanitized), timeout=10 + 1)
|
||||||
|
|
||||||
|
if resp.status_code >= 400:
|
||||||
|
self.ap.logger.warning(
|
||||||
|
f'Telemetry post to {url} returned status {resp.status_code} - {resp.text}'
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
# Detect application-level errors inside HTTP 200 responses
|
||||||
|
app_err = False
|
||||||
|
try:
|
||||||
|
j = resp.json()
|
||||||
|
if isinstance(j, dict) and j.get('code') is not None and int(j.get('code')) >= 400:
|
||||||
|
app_err = True
|
||||||
|
self.ap.logger.warning(
|
||||||
|
f'Telemetry post to {url} returned application error code {j.get("code")} - {j.get("msg")}'
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
if app_err:
|
||||||
|
self.ap.logger.warning(
|
||||||
|
f'Telemetry post to {url} returned app-level error - response: {resp.text[:200]}'
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
self.ap.logger.debug(
|
||||||
|
f'Telemetry posted to {url}, status {resp.status_code} - response: {resp.text[:200]}'
|
||||||
|
)
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
self.ap.logger.warning(f'Telemetry post to {url} timed out')
|
||||||
|
except Exception as e:
|
||||||
|
self.ap.logger.warning(f'Failed to post telemetry to {url}: {e}', exc_info=True)
|
||||||
|
except Exception as e:
|
||||||
|
try:
|
||||||
|
self.ap.logger.warning(
|
||||||
|
f'Failed to create HTTP client for telemetry or sanitize payload: {e}', exc_info=True
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
except Exception as e:
|
||||||
|
# Never raise from telemetry; surface as warning for visibility
|
||||||
|
try:
|
||||||
|
self.ap.logger.warning(f'Unexpected telemetry error: {e}', exc_info=True)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
@@ -37,7 +37,8 @@ class VectorDBManager:
|
|||||||
milvus_config = kb_config.get('milvus', {})
|
milvus_config = kb_config.get('milvus', {})
|
||||||
uri = milvus_config.get('uri', './data/milvus.db')
|
uri = milvus_config.get('uri', './data/milvus.db')
|
||||||
token = milvus_config.get('token')
|
token = milvus_config.get('token')
|
||||||
self.vector_db = MilvusVectorDatabase(self.ap, uri=uri, token=token)
|
db_name = milvus_config.get('db_name', 'default')
|
||||||
|
self.vector_db = MilvusVectorDatabase(self.ap, uri=uri, token=token, db_name=db_name)
|
||||||
self.ap.logger.info('Initialized Milvus vector database backend.')
|
self.ap.logger.info('Initialized Milvus vector database backend.')
|
||||||
|
|
||||||
elif vdb_type == 'pgvector':
|
elif vdb_type == 'pgvector':
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
import asyncio
|
import asyncio
|
||||||
from typing import Any, Dict
|
from typing import Any, Dict
|
||||||
from pymilvus import MilvusClient, DataType
|
from pymilvus import MilvusClient, DataType, CollectionSchema, FieldSchema
|
||||||
|
from pymilvus.milvus_client.index import IndexParams
|
||||||
from langbot.pkg.vector.vdb import VectorDatabase
|
from langbot.pkg.vector.vdb import VectorDatabase
|
||||||
from langbot.pkg.core import app
|
from langbot.pkg.core import app
|
||||||
|
|
||||||
@@ -9,7 +10,7 @@ from langbot.pkg.core import app
|
|||||||
class MilvusVectorDatabase(VectorDatabase):
|
class MilvusVectorDatabase(VectorDatabase):
|
||||||
"""Milvus vector database implementation"""
|
"""Milvus vector database implementation"""
|
||||||
|
|
||||||
def __init__(self, ap: app.Application, uri: str = "milvus.db", token: str = None):
|
def __init__(self, ap: app.Application, uri: str = "milvus.db", token: str = None, db_name: str = None):
|
||||||
"""Initialize Milvus vector database
|
"""Initialize Milvus vector database
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
@@ -21,30 +22,76 @@ class MilvusVectorDatabase(VectorDatabase):
|
|||||||
self.ap = ap
|
self.ap = ap
|
||||||
self.uri = uri
|
self.uri = uri
|
||||||
self.token = token
|
self.token = token
|
||||||
|
self.db_name = db_name
|
||||||
self.client = None
|
self.client = None
|
||||||
self._collections = {}
|
self._collections: set[str] = set()
|
||||||
self._initialize_client()
|
self._initialize_client()
|
||||||
|
|
||||||
def _initialize_client(self):
|
def _initialize_client(self):
|
||||||
"""Initialize Milvus client connection"""
|
"""Initialize Milvus client connection"""
|
||||||
try:
|
try:
|
||||||
if self.token:
|
if self.token:
|
||||||
self.client = MilvusClient(uri=self.uri, token=self.token)
|
self.client = MilvusClient(uri=self.uri, token=self.token, db_name=self.db_name)
|
||||||
else:
|
else:
|
||||||
self.client = MilvusClient(uri=self.uri)
|
self.client = MilvusClient(uri=self.uri, db_name=self.db_name)
|
||||||
self.ap.logger.info(f"Connected to Milvus at {self.uri}")
|
self.ap.logger.info(f"Connected to Milvus at {self.uri}")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.ap.logger.error(f"Failed to connect to Milvus: {e}")
|
self.ap.logger.error(f"Failed to connect to Milvus: {e}")
|
||||||
raise
|
raise
|
||||||
|
|
||||||
async def get_or_create_collection(self, collection: str):
|
@staticmethod
|
||||||
"""Get or create a Milvus collection
|
def _normalize_collection_name(collection: str) -> str:
|
||||||
|
"""Normalize collection name to comply with Milvus naming requirements.
|
||||||
|
|
||||||
|
Milvus requirements:
|
||||||
|
- First character must be an underscore or letter
|
||||||
|
- Can only contain numbers, letters and underscores
|
||||||
|
|
||||||
|
Args:
|
||||||
|
collection: Original collection name (e.g., UUID with hyphens)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Normalized collection name that complies with Milvus requirements
|
||||||
|
"""
|
||||||
|
# Replace hyphens with underscores
|
||||||
|
normalized = collection.replace('-', '_')
|
||||||
|
|
||||||
|
# If first character is not a letter or underscore, prepend 'kb_'
|
||||||
|
if normalized and not (normalized[0].isalpha() or normalized[0] == '_'):
|
||||||
|
normalized = 'kb_' + normalized
|
||||||
|
|
||||||
|
return normalized
|
||||||
|
|
||||||
|
async def _ensure_vector_index(self, collection: str) -> None:
|
||||||
|
"""Ensure the vector field has an index.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
collection: Normalized collection name
|
||||||
|
"""
|
||||||
|
index_params = IndexParams()
|
||||||
|
index_params.add_index(
|
||||||
|
field_name="vector",
|
||||||
|
index_type="AUTOINDEX",
|
||||||
|
metric_type="COSINE",
|
||||||
|
)
|
||||||
|
await asyncio.to_thread(
|
||||||
|
self.client.create_index,
|
||||||
|
collection_name=collection,
|
||||||
|
index_params=index_params
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _get_or_create_collection_internal(self, collection: str, vector_size: int = None):
|
||||||
|
"""Internal method to get or create a Milvus collection with proper configuration.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
collection: Collection name (corresponds to knowledge base UUID)
|
collection: Collection name (corresponds to knowledge base UUID)
|
||||||
|
vector_size: Dimension of the vectors (if None, defaults to 1536)
|
||||||
"""
|
"""
|
||||||
|
# Normalize collection name for Milvus compatibility
|
||||||
|
collection = self._normalize_collection_name(collection)
|
||||||
|
|
||||||
if collection in self._collections:
|
if collection in self._collections:
|
||||||
return self._collections[collection]
|
return collection
|
||||||
|
|
||||||
# Check if collection exists
|
# Check if collection exists
|
||||||
has_collection = await asyncio.to_thread(
|
has_collection = await asyncio.to_thread(
|
||||||
@@ -52,12 +99,13 @@ class MilvusVectorDatabase(VectorDatabase):
|
|||||||
)
|
)
|
||||||
|
|
||||||
if not has_collection:
|
if not has_collection:
|
||||||
# Create collection with custom schema to support string IDs
|
# Default dimension if not specified (for backward compatibility)
|
||||||
from pymilvus import CollectionSchema, FieldSchema, DataType
|
if vector_size is None:
|
||||||
|
vector_size = 1536
|
||||||
|
|
||||||
fields = [
|
fields = [
|
||||||
FieldSchema(name="id", dtype=DataType.VARCHAR, is_primary=True, max_length=255),
|
FieldSchema(name="id", dtype=DataType.VARCHAR, is_primary=True, max_length=255),
|
||||||
FieldSchema(name="vector", dtype=DataType.FLOAT_VECTOR, dim=1536),
|
FieldSchema(name="vector", dtype=DataType.FLOAT_VECTOR, dim=vector_size),
|
||||||
FieldSchema(name="text", dtype=DataType.VARCHAR, max_length=65535),
|
FieldSchema(name="text", dtype=DataType.VARCHAR, max_length=65535),
|
||||||
FieldSchema(name="file_id", dtype=DataType.VARCHAR, max_length=255),
|
FieldSchema(name="file_id", dtype=DataType.VARCHAR, max_length=255),
|
||||||
FieldSchema(name="chunk_uuid", dtype=DataType.VARCHAR, max_length=255),
|
FieldSchema(name="chunk_uuid", dtype=DataType.VARCHAR, max_length=255),
|
||||||
@@ -72,26 +120,42 @@ class MilvusVectorDatabase(VectorDatabase):
|
|||||||
metric_type="COSINE",
|
metric_type="COSINE",
|
||||||
)
|
)
|
||||||
|
|
||||||
# Create index for vector field (required for loading/searching)
|
await self._ensure_vector_index(collection)
|
||||||
index_params = {
|
self.ap.logger.info(f"Created Milvus collection '{collection}' with dimension={vector_size}, index=AUTOINDEX")
|
||||||
"metric_type": "COSINE",
|
|
||||||
"index_type": "AUTOINDEX",
|
|
||||||
"params": {}
|
|
||||||
}
|
|
||||||
await asyncio.to_thread(
|
|
||||||
self.client.create_index,
|
|
||||||
collection_name=collection,
|
|
||||||
field_name="vector",
|
|
||||||
index_params=index_params
|
|
||||||
)
|
|
||||||
|
|
||||||
self.ap.logger.info(f"Created Milvus collection '{collection}' with index")
|
|
||||||
else:
|
else:
|
||||||
|
# Ensure index exists for existing collection
|
||||||
|
await self._ensure_index_if_missing(collection)
|
||||||
self.ap.logger.info(f"Milvus collection '{collection}' already exists")
|
self.ap.logger.info(f"Milvus collection '{collection}' already exists")
|
||||||
|
|
||||||
self._collections[collection] = collection
|
self._collections.add(collection)
|
||||||
return collection
|
return collection
|
||||||
|
|
||||||
|
async def _ensure_index_if_missing(self, collection: str) -> None:
|
||||||
|
"""Check if index exists for collection and create if missing.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
collection: Normalized collection name
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
indexes = await asyncio.to_thread(
|
||||||
|
self.client.list_indexes,
|
||||||
|
collection_name=collection
|
||||||
|
)
|
||||||
|
if "vector" not in indexes:
|
||||||
|
await self._ensure_vector_index(collection)
|
||||||
|
self.ap.logger.info(f"Created index for existing Milvus collection '{collection}'")
|
||||||
|
except Exception as e:
|
||||||
|
self.ap.logger.warning(f"Could not verify/create index for collection '{collection}': {e}")
|
||||||
|
|
||||||
|
async def get_or_create_collection(self, collection: str):
|
||||||
|
"""Get or create a Milvus collection (without vector size - will use default).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
collection: Collection name (corresponds to knowledge base UUID)
|
||||||
|
"""
|
||||||
|
collection = self._normalize_collection_name(collection)
|
||||||
|
return await self._get_or_create_collection_internal(collection)
|
||||||
|
|
||||||
async def add_embeddings(
|
async def add_embeddings(
|
||||||
self,
|
self,
|
||||||
collection: str,
|
collection: str,
|
||||||
@@ -107,7 +171,14 @@ class MilvusVectorDatabase(VectorDatabase):
|
|||||||
embeddings_list: List of embedding vectors
|
embeddings_list: List of embedding vectors
|
||||||
metadatas: List of metadata dictionaries for each vector
|
metadatas: List of metadata dictionaries for each vector
|
||||||
"""
|
"""
|
||||||
await self.get_or_create_collection(collection)
|
collection = self._normalize_collection_name(collection)
|
||||||
|
|
||||||
|
if not embeddings_list:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Ensure collection exists with correct dimension
|
||||||
|
vector_size = len(embeddings_list[0])
|
||||||
|
await self._get_or_create_collection_internal(collection, vector_size)
|
||||||
|
|
||||||
# Prepare data in Milvus format
|
# Prepare data in Milvus format
|
||||||
data = []
|
data = []
|
||||||
@@ -156,6 +227,7 @@ class MilvusVectorDatabase(VectorDatabase):
|
|||||||
Returns:
|
Returns:
|
||||||
Dictionary with search results in Chroma-compatible format
|
Dictionary with search results in Chroma-compatible format
|
||||||
"""
|
"""
|
||||||
|
collection = self._normalize_collection_name(collection)
|
||||||
await self.get_or_create_collection(collection)
|
await self.get_or_create_collection(collection)
|
||||||
|
|
||||||
# Perform search
|
# Perform search
|
||||||
@@ -214,6 +286,7 @@ class MilvusVectorDatabase(VectorDatabase):
|
|||||||
collection: Collection name
|
collection: Collection name
|
||||||
file_id: File ID to filter deletion
|
file_id: File ID to filter deletion
|
||||||
"""
|
"""
|
||||||
|
collection = self._normalize_collection_name(collection)
|
||||||
await self.get_or_create_collection(collection)
|
await self.get_or_create_collection(collection)
|
||||||
|
|
||||||
# Delete entities matching the file_id
|
# Delete entities matching the file_id
|
||||||
@@ -232,8 +305,9 @@ class MilvusVectorDatabase(VectorDatabase):
|
|||||||
Args:
|
Args:
|
||||||
collection: Collection name to delete
|
collection: Collection name to delete
|
||||||
"""
|
"""
|
||||||
if collection in self._collections:
|
collection = self._normalize_collection_name(collection)
|
||||||
del self._collections[collection]
|
|
||||||
|
self._collections.discard(collection)
|
||||||
|
|
||||||
# Check if collection exists before attempting deletion
|
# Check if collection exists before attempting deletion
|
||||||
has_collection = await asyncio.to_thread(
|
has_collection = await asyncio.to_thread(
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ vdb:
|
|||||||
milvus:
|
milvus:
|
||||||
uri: 'http://127.0.0.1:19530'
|
uri: 'http://127.0.0.1:19530'
|
||||||
token: ''
|
token: ''
|
||||||
|
db_name: ''
|
||||||
pgvector:
|
pgvector:
|
||||||
host: '127.0.0.1'
|
host: '127.0.0.1'
|
||||||
port: 5433
|
port: 5433
|
||||||
@@ -78,3 +79,4 @@ space:
|
|||||||
# OAuth authorization page URL (user will be redirected here)
|
# OAuth authorization page URL (user will be redirected here)
|
||||||
oauth_authorize_url: 'https://space.langbot.app/auth/authorize'
|
oauth_authorize_url: 'https://space.langbot.app/auth/authorize'
|
||||||
disable_models_service: false
|
disable_models_service: false
|
||||||
|
disable_telemetry: false
|
||||||
|
|||||||
@@ -206,8 +206,23 @@ export default function ModelsDialog({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleSpaceLogin() {
|
async function handleSpaceLogin() {
|
||||||
window.location.href = '/auth/space';
|
try {
|
||||||
|
const token = localStorage.getItem('token');
|
||||||
|
if (!token) {
|
||||||
|
toast.error(t('common.error'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const currentOrigin = window.location.origin;
|
||||||
|
const redirectUri = `${currentOrigin}/auth/space/callback?mode=bind`;
|
||||||
|
const response = await httpClient.getSpaceAuthorizeUrl(
|
||||||
|
redirectUri,
|
||||||
|
token,
|
||||||
|
);
|
||||||
|
window.location.href = response.authorize_url;
|
||||||
|
} catch {
|
||||||
|
toast.error(t('common.spaceLoginFailed'));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleAddModel(
|
async function handleAddModel(
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { MCPServer, MCPSessionStatus } from '@/app/infra/entities/api';
|
|||||||
|
|
||||||
export class MCPCardVO {
|
export class MCPCardVO {
|
||||||
name: string;
|
name: string;
|
||||||
mode: 'stdio' | 'sse';
|
mode: 'stdio' | 'sse' | 'http';
|
||||||
enable: boolean;
|
enable: boolean;
|
||||||
status: MCPSessionStatus;
|
status: MCPSessionStatus;
|
||||||
tools: number;
|
tools: number;
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { useState, useEffect } from 'react';
|
|||||||
import { httpClient } from '@/app/infra/http/HttpClient';
|
import { httpClient } from '@/app/infra/http/HttpClient';
|
||||||
import { Switch } from '@/components/ui/switch';
|
import { Switch } from '@/components/ui/switch';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { RefreshCcw, Wrench, Ban, AlertCircle, Loader2 } from 'lucide-react';
|
import { RefreshCcw, Wrench, Ban, AlertCircle, Loader2 } from 'lucide-react';
|
||||||
@@ -98,9 +99,14 @@ export default function MCPCardComponent({
|
|||||||
</svg>
|
</svg>
|
||||||
|
|
||||||
<div className="w-full h-full flex flex-col items-start justify-between gap-[0.6rem]">
|
<div className="w-full h-full flex flex-col items-start justify-between gap-[0.6rem]">
|
||||||
<div className="flex flex-col items-start justify-start">
|
<div className="flex flex-col items-start justify-start gap-[0.3rem]">
|
||||||
<div className="text-[1.2rem] text-black dark:text-[#f0f0f0] font-medium">
|
<div className="flex flex-row items-center gap-[0.5rem]">
|
||||||
{cardVO.name}
|
<div className="text-[1.2rem] text-black dark:text-[#f0f0f0] font-medium">
|
||||||
|
{cardVO.name}
|
||||||
|
</div>
|
||||||
|
<Badge variant="secondary" className="text-[0.65rem] px-1.5 py-0">
|
||||||
|
{cardVO.mode.toUpperCase()}
|
||||||
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -43,6 +43,9 @@ import {
|
|||||||
MCPTool,
|
MCPTool,
|
||||||
MCPServer,
|
MCPServer,
|
||||||
MCPSessionStatus,
|
MCPSessionStatus,
|
||||||
|
MCPServerExtraArgsSSE,
|
||||||
|
MCPServerExtraArgsHttp,
|
||||||
|
MCPServerExtraArgsStdio,
|
||||||
} from '@/app/infra/entities/api';
|
} from '@/app/infra/entities/api';
|
||||||
import { CustomApiError } from '@/app/infra/entities/common';
|
import { CustomApiError } from '@/app/infra/entities/common';
|
||||||
|
|
||||||
@@ -133,11 +136,11 @@ function StatusDisplay({
|
|||||||
</svg>
|
</svg>
|
||||||
<span className="font-medium">{t('mcp.connectionFailed')}</span>
|
<span className="font-medium">{t('mcp.connectionFailed')}</span>
|
||||||
</div>
|
</div>
|
||||||
{/* {runtimeInfo.error_message && (
|
{runtimeInfo.error_message && (
|
||||||
<div className="text-sm text-red-500 pl-7">
|
<div className="text-sm text-red-500 pl-7">
|
||||||
{runtimeInfo.error_message}
|
{runtimeInfo.error_message}
|
||||||
</div>
|
</div>
|
||||||
)} */}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -163,31 +166,52 @@ function ToolsList({ tools }: { tools: MCPTool[] }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const getFormSchema = (t: (key: string) => string) =>
|
const getFormSchema = (t: (key: string) => string) =>
|
||||||
z.object({
|
z
|
||||||
name: z
|
.object({
|
||||||
.string({ required_error: t('mcp.nameRequired') })
|
name: z
|
||||||
.min(1, { message: t('mcp.nameRequired') }),
|
.string({ required_error: t('mcp.nameRequired') })
|
||||||
timeout: z
|
.min(1, { message: t('mcp.nameRequired') }),
|
||||||
.number({ invalid_type_error: t('mcp.timeoutMustBeNumber') })
|
mode: z.enum(['sse', 'stdio', 'http']),
|
||||||
.positive({ message: t('mcp.timeoutMustBePositive') })
|
timeout: z
|
||||||
.default(30),
|
.number({ invalid_type_error: t('mcp.timeoutMustBeNumber') })
|
||||||
ssereadtimeout: z
|
.positive({ message: t('mcp.timeoutMustBePositive') })
|
||||||
.number({ invalid_type_error: t('mcp.sseTimeoutMustBeNumber') })
|
.default(30),
|
||||||
.positive({ message: t('mcp.timeoutMustBePositive') })
|
ssereadtimeout: z
|
||||||
.default(300),
|
.number({ invalid_type_error: t('mcp.sseTimeoutMustBeNumber') })
|
||||||
url: z
|
.positive({ message: t('mcp.timeoutMustBePositive') })
|
||||||
.string({ required_error: t('mcp.urlRequired') })
|
.default(300),
|
||||||
.min(1, { message: t('mcp.urlRequired') }),
|
url: z.string().optional(),
|
||||||
extra_args: z
|
command: z.string().optional(),
|
||||||
.array(
|
args: z.array(z.object({ value: z.string() })).optional(),
|
||||||
z.object({
|
extra_args: z
|
||||||
key: z.string(),
|
.array(
|
||||||
type: z.enum(['string', 'number', 'boolean']),
|
z.object({
|
||||||
value: z.string(),
|
key: z.string(),
|
||||||
}),
|
type: z.enum(['string', 'number', 'boolean']),
|
||||||
)
|
value: z.string(),
|
||||||
.optional(),
|
}),
|
||||||
});
|
)
|
||||||
|
.optional(),
|
||||||
|
})
|
||||||
|
.superRefine((data, ctx) => {
|
||||||
|
if (data.mode === 'sse' || data.mode === 'http') {
|
||||||
|
if (!data.url || data.url.length === 0) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
message: t('mcp.urlRequired'),
|
||||||
|
path: ['url'],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else if (data.mode === 'stdio') {
|
||||||
|
if (!data.command || data.command.length === 0) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
message: t('mcp.commandRequired'),
|
||||||
|
path: ['command'],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
type FormValues = z.infer<ReturnType<typeof getFormSchema>> & {
|
type FormValues = z.infer<ReturnType<typeof getFormSchema>> & {
|
||||||
timeout: number;
|
timeout: number;
|
||||||
@@ -218,7 +242,10 @@ export default function MCPFormDialog({
|
|||||||
resolver: zodResolver(formSchema) as unknown as Resolver<FormValues>,
|
resolver: zodResolver(formSchema) as unknown as Resolver<FormValues>,
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
name: '',
|
name: '',
|
||||||
|
mode: 'sse',
|
||||||
url: '',
|
url: '',
|
||||||
|
command: '',
|
||||||
|
args: [],
|
||||||
timeout: 30,
|
timeout: 30,
|
||||||
ssereadtimeout: 300,
|
ssereadtimeout: 300,
|
||||||
extra_args: [],
|
extra_args: [],
|
||||||
@@ -228,20 +255,33 @@ export default function MCPFormDialog({
|
|||||||
const [extraArgs, setExtraArgs] = useState<
|
const [extraArgs, setExtraArgs] = useState<
|
||||||
{ key: string; type: 'string' | 'number' | 'boolean'; value: string }[]
|
{ key: string; type: 'string' | 'number' | 'boolean'; value: string }[]
|
||||||
>([]);
|
>([]);
|
||||||
|
const [stdioArgs, setStdioArgs] = useState<{ value: string }[]>([]);
|
||||||
const [mcpTesting, setMcpTesting] = useState(false);
|
const [mcpTesting, setMcpTesting] = useState(false);
|
||||||
const [runtimeInfo, setRuntimeInfo] = useState<MCPServerRuntimeInfo | null>(
|
const [runtimeInfo, setRuntimeInfo] = useState<MCPServerRuntimeInfo | null>(
|
||||||
null,
|
null,
|
||||||
);
|
);
|
||||||
const pollingIntervalRef = useRef<NodeJS.Timeout | null>(null);
|
const pollingIntervalRef = useRef<NodeJS.Timeout | null>(null);
|
||||||
|
|
||||||
|
const watchMode = form.watch('mode');
|
||||||
|
|
||||||
// Load server data when editing
|
// Load server data when editing
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (open && isEditMode && serverName) {
|
if (open && isEditMode && serverName) {
|
||||||
loadServerForEdit(serverName);
|
loadServerForEdit(serverName);
|
||||||
} else if (open && !isEditMode) {
|
} else if (open && !isEditMode) {
|
||||||
// Reset form when creating new server
|
// Reset form when creating new server
|
||||||
form.reset();
|
form.reset({
|
||||||
|
name: '',
|
||||||
|
mode: 'sse',
|
||||||
|
url: '',
|
||||||
|
command: '',
|
||||||
|
args: [],
|
||||||
|
timeout: 30,
|
||||||
|
ssereadtimeout: 300,
|
||||||
|
extra_args: [],
|
||||||
|
});
|
||||||
setExtraArgs([]);
|
setExtraArgs([]);
|
||||||
|
setStdioArgs([]);
|
||||||
setRuntimeInfo(null);
|
setRuntimeInfo(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -291,25 +331,49 @@ export default function MCPFormDialog({
|
|||||||
const resp = await httpClient.getMCPServer(serverName);
|
const resp = await httpClient.getMCPServer(serverName);
|
||||||
const server = resp.server ?? resp;
|
const server = resp.server ?? resp;
|
||||||
|
|
||||||
const extraArgs = server.extra_args;
|
|
||||||
form.setValue('name', server.name);
|
form.setValue('name', server.name);
|
||||||
form.setValue('url', extraArgs.url);
|
form.setValue('mode', server.mode);
|
||||||
form.setValue('timeout', extraArgs.timeout);
|
|
||||||
form.setValue('ssereadtimeout', extraArgs.ssereadtimeout);
|
|
||||||
|
|
||||||
if (extraArgs.headers) {
|
if (server.mode === 'sse' || server.mode === 'http') {
|
||||||
const headers = Object.entries(extraArgs.headers).map(
|
form.setValue('url', server.extra_args.url);
|
||||||
([key, value]) => ({
|
form.setValue('timeout', server.extra_args.timeout);
|
||||||
key,
|
|
||||||
type: 'string' as const,
|
if (server.mode === 'sse') {
|
||||||
value: String(value),
|
form.setValue('ssereadtimeout', server.extra_args.ssereadtimeout);
|
||||||
}),
|
}
|
||||||
);
|
|
||||||
setExtraArgs(headers);
|
if (server.extra_args.headers) {
|
||||||
form.setValue('extra_args', headers);
|
const headers = Object.entries(server.extra_args.headers).map(
|
||||||
|
([key, value]) => ({
|
||||||
|
key,
|
||||||
|
type: 'string' as const,
|
||||||
|
value: String(value),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
setExtraArgs(headers);
|
||||||
|
form.setValue('extra_args', headers);
|
||||||
|
}
|
||||||
|
} else if (server.mode === 'stdio') {
|
||||||
|
form.setValue('command', server.extra_args.command);
|
||||||
|
const args = (server.extra_args.args || []).map((arg: string) => ({
|
||||||
|
value: arg,
|
||||||
|
}));
|
||||||
|
setStdioArgs(args);
|
||||||
|
form.setValue('args', args);
|
||||||
|
|
||||||
|
if (server.extra_args.env) {
|
||||||
|
const envs = Object.entries(server.extra_args.env).map(
|
||||||
|
([key, value]) => ({
|
||||||
|
key,
|
||||||
|
type: 'string' as const,
|
||||||
|
value: String(value),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
setExtraArgs(envs);
|
||||||
|
form.setValue('extra_args', envs);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set runtime_info from server data
|
|
||||||
if (server.runtime_info) {
|
if (server.runtime_info) {
|
||||||
setRuntimeInfo(server.runtime_info);
|
setRuntimeInfo(server.runtime_info);
|
||||||
} else {
|
} else {
|
||||||
@@ -322,28 +386,60 @@ export default function MCPFormDialog({
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function handleFormSubmit(value: z.infer<typeof formSchema>) {
|
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 {
|
try {
|
||||||
const serverConfig: Omit<
|
let serverConfig: MCPServer;
|
||||||
MCPServer,
|
|
||||||
'uuid' | 'created_at' | 'updated_at' | 'runtime_info'
|
if (value.mode === 'sse' || value.mode === 'http') {
|
||||||
> = {
|
const headers: Record<string, string> = {};
|
||||||
name: value.name,
|
value.extra_args?.forEach((arg) => {
|
||||||
mode: 'sse' as const,
|
headers[arg.key] = String(arg.value);
|
||||||
enable: true,
|
});
|
||||||
extra_args: {
|
|
||||||
url: value.url,
|
if (value.mode === 'sse') {
|
||||||
headers: headers,
|
serverConfig = {
|
||||||
timeout: value.timeout,
|
name: value.name,
|
||||||
ssereadtimeout: value.ssereadtimeout,
|
mode: 'sse',
|
||||||
},
|
enable: true,
|
||||||
};
|
extra_args: {
|
||||||
|
url: value.url!,
|
||||||
|
headers: headers,
|
||||||
|
timeout: value.timeout,
|
||||||
|
ssereadtimeout: value.ssereadtimeout,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
serverConfig = {
|
||||||
|
name: value.name,
|
||||||
|
mode: 'http',
|
||||||
|
enable: true,
|
||||||
|
extra_args: {
|
||||||
|
url: value.url!,
|
||||||
|
headers: headers,
|
||||||
|
timeout: value.timeout,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Convert extra_args to env
|
||||||
|
const env: Record<string, string> = {};
|
||||||
|
value.extra_args?.forEach((arg) => {
|
||||||
|
env[arg.key] = String(arg.value);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Convert args object array to string array
|
||||||
|
const args = value.args?.map((arg) => arg.value) || [];
|
||||||
|
|
||||||
|
serverConfig = {
|
||||||
|
name: value.name,
|
||||||
|
mode: 'stdio',
|
||||||
|
enable: true,
|
||||||
|
extra_args: {
|
||||||
|
command: value.command!,
|
||||||
|
args: args,
|
||||||
|
env: env,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
if (isEditMode && serverName) {
|
if (isEditMode && serverName) {
|
||||||
await httpClient.updateMCPServer(serverName, serverConfig);
|
await httpClient.updateMCPServer(serverName, serverConfig);
|
||||||
@@ -365,19 +461,44 @@ export default function MCPFormDialog({
|
|||||||
setMcpTesting(true);
|
setMcpTesting(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { task_id } = await httpClient.testMCPServer('_', {
|
const mode = form.getValues('mode');
|
||||||
name: form.getValues('name'),
|
let extraArgsData:
|
||||||
mode: 'sse',
|
| MCPServerExtraArgsSSE
|
||||||
enable: true,
|
| MCPServerExtraArgsHttp
|
||||||
extra_args: {
|
| MCPServerExtraArgsStdio;
|
||||||
url: form.getValues('url'),
|
|
||||||
|
if (mode === 'sse') {
|
||||||
|
extraArgsData = {
|
||||||
|
url: form.getValues('url')!,
|
||||||
timeout: form.getValues('timeout'),
|
timeout: form.getValues('timeout'),
|
||||||
ssereadtimeout: form.getValues('ssereadtimeout'),
|
|
||||||
headers: Object.fromEntries(
|
headers: Object.fromEntries(
|
||||||
extraArgs.map((arg) => [arg.key, arg.value]),
|
extraArgs.map((arg) => [arg.key, arg.value]),
|
||||||
),
|
),
|
||||||
},
|
ssereadtimeout: form.getValues('ssereadtimeout'),
|
||||||
});
|
};
|
||||||
|
} else if (mode === 'http') {
|
||||||
|
extraArgsData = {
|
||||||
|
url: form.getValues('url')!,
|
||||||
|
timeout: form.getValues('timeout'),
|
||||||
|
headers: Object.fromEntries(
|
||||||
|
extraArgs.map((arg) => [arg.key, arg.value]),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
extraArgsData = {
|
||||||
|
command: form.getValues('command')!,
|
||||||
|
args: stdioArgs.map((arg) => arg.value),
|
||||||
|
env: Object.fromEntries(extraArgs.map((arg) => [arg.key, arg.value])),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const { task_id } = await httpClient.testMCPServer('_', {
|
||||||
|
name: form.getValues('name'),
|
||||||
|
mode: mode,
|
||||||
|
enable: true,
|
||||||
|
extra_args: extraArgsData,
|
||||||
|
} as MCPServer);
|
||||||
|
|
||||||
if (!task_id) {
|
if (!task_id) {
|
||||||
throw new Error(t('mcp.noTaskId'));
|
throw new Error(t('mcp.noTaskId'));
|
||||||
}
|
}
|
||||||
@@ -448,11 +569,31 @@ export default function MCPFormDialog({
|
|||||||
form.setValue('extra_args', newArgs);
|
form.setValue('extra_args', newArgs);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const addStdioArg = () => {
|
||||||
|
const newArgs = [...stdioArgs, { value: '' }];
|
||||||
|
setStdioArgs(newArgs);
|
||||||
|
form.setValue('args', newArgs);
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeStdioArg = (index: number) => {
|
||||||
|
const newArgs = stdioArgs.filter((_, i) => i !== index);
|
||||||
|
setStdioArgs(newArgs);
|
||||||
|
form.setValue('args', newArgs);
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateStdioArg = (index: number, value: string) => {
|
||||||
|
const newArgs = [...stdioArgs];
|
||||||
|
newArgs[index] = { value };
|
||||||
|
setStdioArgs(newArgs);
|
||||||
|
form.setValue('args', newArgs);
|
||||||
|
};
|
||||||
|
|
||||||
const handleDialogClose = (open: boolean) => {
|
const handleDialogClose = (open: boolean) => {
|
||||||
onOpenChange(open);
|
onOpenChange(open);
|
||||||
if (!open) {
|
if (!open) {
|
||||||
form.reset();
|
form.reset();
|
||||||
setExtraArgs([]);
|
setExtraArgs([]);
|
||||||
|
setStdioArgs([]);
|
||||||
setRuntimeInfo(null);
|
setRuntimeInfo(null);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -518,58 +659,155 @@ export default function MCPFormDialog({
|
|||||||
|
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="url"
|
name="mode"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>{t('mcp.url')}</FormLabel>
|
<FormLabel>{t('mcp.serverMode')}</FormLabel>
|
||||||
<FormControl>
|
<Select
|
||||||
<Input {...field} />
|
onValueChange={field.onChange}
|
||||||
</FormControl>
|
defaultValue={field.value}
|
||||||
|
value={field.value}
|
||||||
|
>
|
||||||
|
<FormControl>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder={t('mcp.selectMode')} />
|
||||||
|
</SelectTrigger>
|
||||||
|
</FormControl>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="http">{t('mcp.http')}</SelectItem>
|
||||||
|
<SelectItem value="stdio">{t('mcp.stdio')}</SelectItem>
|
||||||
|
<SelectItem value="sse">{t('mcp.sse')}</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<FormField
|
{(watchMode === 'sse' || watchMode === 'http') && (
|
||||||
control={form.control}
|
<>
|
||||||
name="timeout"
|
<FormField
|
||||||
render={({ field }) => (
|
control={form.control}
|
||||||
<FormItem>
|
name="url"
|
||||||
<FormLabel>{t('mcp.timeout')}</FormLabel>
|
render={({ field }) => (
|
||||||
<FormControl>
|
<FormItem>
|
||||||
<Input
|
<FormLabel>{t('mcp.url')}</FormLabel>
|
||||||
type="number"
|
<FormControl>
|
||||||
placeholder={t('mcp.timeout')}
|
<Input {...field} />
|
||||||
{...field}
|
</FormControl>
|
||||||
onChange={(e) => field.onChange(Number(e.target.value))}
|
<FormMessage />
|
||||||
/>
|
</FormItem>
|
||||||
</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>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{watchMode === 'sse' && (
|
||||||
|
<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>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{watchMode === 'stdio' && (
|
||||||
|
<>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="command"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>{t('mcp.command')}</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="ssereadtimeout"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>{t('mcp.sseTimeout')}</FormLabel>
|
<FormLabel>{t('mcp.args')}</FormLabel>
|
||||||
<FormControl>
|
<div className="space-y-2">
|
||||||
<Input
|
{stdioArgs.map((arg, index) => (
|
||||||
type="number"
|
<div key={index} className="flex gap-2">
|
||||||
placeholder={t('mcp.sseTimeoutDescription')}
|
<Input
|
||||||
{...field}
|
placeholder={t('mcp.args')}
|
||||||
onChange={(e) => field.onChange(Number(e.target.value))}
|
value={arg.value}
|
||||||
/>
|
onChange={(e) =>
|
||||||
</FormControl>
|
updateStdioArg(index, e.target.value)
|
||||||
<FormMessage />
|
}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="p-2 hover:bg-gray-100 rounded"
|
||||||
|
onClick={() => removeStdioArg(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={addStdioArg}
|
||||||
|
>
|
||||||
|
{t('mcp.addArgument')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
</>
|
||||||
/>
|
)}
|
||||||
|
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>{t('models.extraParameters')}</FormLabel>
|
<FormLabel>
|
||||||
|
{watchMode === 'sse' || watchMode === 'http'
|
||||||
|
? t('mcp.headers')
|
||||||
|
: t('mcp.env')}
|
||||||
|
</FormLabel>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{extraArgs.map((arg, index) => (
|
{extraArgs.map((arg, index) => (
|
||||||
<div key={index} className="flex gap-2">
|
<div key={index} className="flex gap-2">
|
||||||
@@ -580,7 +818,12 @@ export default function MCPFormDialog({
|
|||||||
updateExtraArg(index, 'key', e.target.value)
|
updateExtraArg(index, 'key', e.target.value)
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<Select
|
{/* Only show type select for SSE headers if needed, but usually headers are strings. Env vars are definitely strings.
|
||||||
|
The original code had type selector. Let's keep it for compatibility or remove if not needed.
|
||||||
|
Headers are strings. Env vars are strings.
|
||||||
|
Let's hide the type selector as it was confusing anyway, or force it to string.
|
||||||
|
*/}
|
||||||
|
{/* <Select
|
||||||
value={arg.type}
|
value={arg.type}
|
||||||
onValueChange={(value) =>
|
onValueChange={(value) =>
|
||||||
updateExtraArg(index, 'type', value)
|
updateExtraArg(index, 'type', value)
|
||||||
@@ -593,14 +836,8 @@ export default function MCPFormDialog({
|
|||||||
<SelectItem value="string">
|
<SelectItem value="string">
|
||||||
{t('models.string')}
|
{t('models.string')}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
<SelectItem value="number">
|
|
||||||
{t('models.number')}
|
|
||||||
</SelectItem>
|
|
||||||
<SelectItem value="boolean">
|
|
||||||
{t('models.boolean')}
|
|
||||||
</SelectItem>
|
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select> */}
|
||||||
<Input
|
<Input
|
||||||
placeholder={t('models.value')}
|
placeholder={t('models.value')}
|
||||||
value={arg.value}
|
value={arg.value}
|
||||||
@@ -625,7 +862,9 @@ export default function MCPFormDialog({
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
<Button type="button" variant="outline" onClick={addExtraArg}>
|
<Button type="button" variant="outline" onClick={addExtraArg}>
|
||||||
{t('models.addParameter')}
|
{watchMode === 'sse' || watchMode === 'http'
|
||||||
|
? t('mcp.addHeader')
|
||||||
|
: t('mcp.addEnvVar')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<FormDescription>
|
<FormDescription>
|
||||||
|
|||||||
@@ -365,6 +365,18 @@ export interface MCPServerExtraArgsSSE {
|
|||||||
ssereadtimeout: number;
|
ssereadtimeout: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface MCPServerExtraArgsStdio {
|
||||||
|
command: string;
|
||||||
|
args: string[];
|
||||||
|
env: Record<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MCPServerExtraArgsHttp {
|
||||||
|
url: string;
|
||||||
|
headers: Record<string, string>;
|
||||||
|
timeout: number;
|
||||||
|
}
|
||||||
|
|
||||||
export enum MCPSessionStatus {
|
export enum MCPSessionStatus {
|
||||||
CONNECTING = 'connecting',
|
CONNECTING = 'connecting',
|
||||||
CONNECTED = 'connected',
|
CONNECTED = 'connected',
|
||||||
@@ -373,21 +385,42 @@ export enum MCPSessionStatus {
|
|||||||
|
|
||||||
export interface MCPServerRuntimeInfo {
|
export interface MCPServerRuntimeInfo {
|
||||||
status: MCPSessionStatus;
|
status: MCPSessionStatus;
|
||||||
error_message: string;
|
error_message?: string;
|
||||||
tool_count: number;
|
tool_count: number;
|
||||||
tools: MCPTool[];
|
tools: MCPTool[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface MCPServer {
|
export type MCPServer =
|
||||||
uuid?: string;
|
| {
|
||||||
name: string;
|
uuid?: string;
|
||||||
mode: 'stdio' | 'sse';
|
name: string;
|
||||||
enable: boolean;
|
mode: 'sse';
|
||||||
extra_args: MCPServerExtraArgsSSE;
|
enable: boolean;
|
||||||
runtime_info?: MCPServerRuntimeInfo;
|
extra_args: MCPServerExtraArgsSSE;
|
||||||
created_at?: string;
|
runtime_info?: MCPServerRuntimeInfo;
|
||||||
updated_at?: string;
|
created_at?: string;
|
||||||
}
|
updated_at?: string;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
uuid?: string;
|
||||||
|
name: string;
|
||||||
|
mode: 'http';
|
||||||
|
enable: boolean;
|
||||||
|
extra_args: MCPServerExtraArgsHttp;
|
||||||
|
runtime_info?: MCPServerRuntimeInfo;
|
||||||
|
created_at?: string;
|
||||||
|
updated_at?: string;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
uuid?: string;
|
||||||
|
name: string;
|
||||||
|
mode: 'stdio';
|
||||||
|
enable: boolean;
|
||||||
|
extra_args: MCPServerExtraArgsStdio;
|
||||||
|
runtime_info?: MCPServerRuntimeInfo;
|
||||||
|
created_at?: string;
|
||||||
|
updated_at?: string;
|
||||||
|
};
|
||||||
|
|
||||||
export interface MCPTool {
|
export interface MCPTool {
|
||||||
name: string;
|
name: string;
|
||||||
|
|||||||
@@ -467,8 +467,10 @@ const enUS = {
|
|||||||
getServerListError: 'Failed to get MCP server list: ',
|
getServerListError: 'Failed to get MCP server list: ',
|
||||||
serverName: 'Server Name',
|
serverName: 'Server Name',
|
||||||
serverMode: 'Connection Mode',
|
serverMode: 'Connection Mode',
|
||||||
|
selectMode: 'Select Mode',
|
||||||
stdio: 'Stdio Mode',
|
stdio: 'Stdio Mode',
|
||||||
sse: 'SSE Mode',
|
sse: 'SSE Mode',
|
||||||
|
http: 'HTTP Mode',
|
||||||
noServerInstalled: 'No MCP servers configured',
|
noServerInstalled: 'No MCP servers configured',
|
||||||
serverNameRequired: 'Server name cannot be empty',
|
serverNameRequired: 'Server name cannot be empty',
|
||||||
commandRequired: 'Command cannot be empty',
|
commandRequired: 'Command cannot be empty',
|
||||||
|
|||||||
@@ -474,6 +474,8 @@ const jaJP = {
|
|||||||
serverMode: '接続モード',
|
serverMode: '接続モード',
|
||||||
stdio: 'Stdioモード',
|
stdio: 'Stdioモード',
|
||||||
sse: 'SSEモード',
|
sse: 'SSEモード',
|
||||||
|
http: 'HTTPモード',
|
||||||
|
selectMode: '接続モードを選択',
|
||||||
noServerInstalled: 'MCPサーバーが設定されていません',
|
noServerInstalled: 'MCPサーバーが設定されていません',
|
||||||
serverNameRequired: 'サーバー名は必須です',
|
serverNameRequired: 'サーバー名は必須です',
|
||||||
commandRequired: 'コマンドは必須です',
|
commandRequired: 'コマンドは必須です',
|
||||||
|
|||||||
@@ -446,8 +446,10 @@ const zhHans = {
|
|||||||
getServerListError: '获取 MCP 服务器列表失败:',
|
getServerListError: '获取 MCP 服务器列表失败:',
|
||||||
serverName: '服务器名称',
|
serverName: '服务器名称',
|
||||||
serverMode: '连接模式',
|
serverMode: '连接模式',
|
||||||
|
selectMode: '选择模式',
|
||||||
stdio: 'Stdio模式',
|
stdio: 'Stdio模式',
|
||||||
sse: 'SSE模式',
|
sse: 'SSE模式',
|
||||||
|
http: 'HTTP模式',
|
||||||
noServerInstalled: '暂未配置任何 MCP 服务器',
|
noServerInstalled: '暂未配置任何 MCP 服务器',
|
||||||
serverNameRequired: '服务器名称不能为空',
|
serverNameRequired: '服务器名称不能为空',
|
||||||
commandRequired: '命令不能为空',
|
commandRequired: '命令不能为空',
|
||||||
|
|||||||
@@ -445,6 +445,8 @@ const zhHant = {
|
|||||||
serverMode: '連接模式',
|
serverMode: '連接模式',
|
||||||
stdio: 'Stdio模式',
|
stdio: 'Stdio模式',
|
||||||
sse: 'SSE模式',
|
sse: 'SSE模式',
|
||||||
|
selectMode: '選擇連接模式',
|
||||||
|
http: 'HTTP模式',
|
||||||
noServerInstalled: '暫未設定任何MCP伺服器',
|
noServerInstalled: '暫未設定任何MCP伺服器',
|
||||||
serverNameRequired: '伺服器名稱不能為空',
|
serverNameRequired: '伺服器名稱不能為空',
|
||||||
commandRequired: '命令不能為空',
|
commandRequired: '命令不能為空',
|
||||||
|
|||||||
Reference in New Issue
Block a user