mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-06-02 20:14:36 +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
|
||||
environment:
|
||||
- TZ=Asia/Shanghai
|
||||
command: ["uv", "run", "-m", "langbot_plugin.cli.__init__", "rt"]
|
||||
command: ["uv", "run", "--no-sync", "-m", "langbot_plugin.cli.__init__", "rt"]
|
||||
networks:
|
||||
- langbot_network
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "langbot"
|
||||
version = "4.7.0"
|
||||
version = "4.7.2"
|
||||
description = "Production-grade platform for building IM bots"
|
||||
readme = "README.md"
|
||||
license-files = ["LICENSE"]
|
||||
@@ -23,7 +23,7 @@ dependencies = [
|
||||
"pynacl>=1.5.0", # Required for Discord voice support
|
||||
"gewechat-client>=0.1.5",
|
||||
"lark-oapi>=1.4.15",
|
||||
"mcp>=1.8.1",
|
||||
"mcp>=1.20.0",
|
||||
"nakuru-project-idk>=0.0.2.1",
|
||||
"ollama>=0.4.8",
|
||||
"openai>1.0.0",
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
"""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 ..rag.knowledge import kbmgr as rag_mgr
|
||||
from ..vector import mgr as vectordb_mgr
|
||||
from ..telemetry import telemetry as telemetry_module
|
||||
|
||||
|
||||
class Application:
|
||||
@@ -140,6 +141,8 @@ class Application:
|
||||
|
||||
webhook_service: webhook_service.WebhookService = None
|
||||
|
||||
telemetry: telemetry_module.TelemetryManager = None
|
||||
|
||||
def __init__(self):
|
||||
pass
|
||||
|
||||
|
||||
@@ -31,6 +31,8 @@ from ...storage import mgr as storagemgr
|
||||
from ...utils import logcache
|
||||
from ...vector import mgr as vectordb_mgr
|
||||
from .. import taskmgr
|
||||
from ...telemetry import telemetry as telemetry_module
|
||||
|
||||
|
||||
|
||||
@stage.stage_class('BuildAppStage')
|
||||
@@ -102,6 +104,11 @@ class BuildAppStage(stage.BootingStage):
|
||||
ap.persistence_mgr = persistence_mgr_inst
|
||||
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)
|
||||
await cmd_mgr_inst.initialize()
|
||||
ap.cmd_mgr = cmd_mgr_inst
|
||||
|
||||
@@ -9,7 +9,7 @@ class MCPServer(Base):
|
||||
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
|
||||
mode = sqlalchemy.Column(sqlalchemy.String(255), nullable=False) # stdio, sse, http
|
||||
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(
|
||||
|
||||
@@ -3,6 +3,8 @@ from __future__ import annotations
|
||||
import uuid
|
||||
import typing
|
||||
import traceback
|
||||
import time
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
from .. import handler
|
||||
@@ -10,7 +12,7 @@ from ... import entities
|
||||
from ....provider import runner as runner_module
|
||||
|
||||
import langbot_plugin.api.entities.events as events
|
||||
from ....utils import importutil
|
||||
from ....utils import importutil, constants
|
||||
from ....provider import runners
|
||||
import langbot_plugin.api.entities.builtin.provider.session as provider_session
|
||||
import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query
|
||||
@@ -84,6 +86,9 @@ class ChatMessageHandler(handler.MessageHandler):
|
||||
break
|
||||
else:
|
||||
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:
|
||||
resp_message_id = uuid.uuid4()
|
||||
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)
|
||||
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()
|
||||
|
||||
hide_exception_info = query.pipeline_config['output']['misc']['hide-exception']
|
||||
@@ -153,5 +159,47 @@ class ChatMessageHandler(handler.MessageHandler):
|
||||
debug_notice=traceback.format_exc(),
|
||||
)
|
||||
finally:
|
||||
# TODO statistics
|
||||
pass
|
||||
# Telemetry reporting: collect minimal per-query execution info and send asynchronously
|
||||
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 ...entity.persistence import model as persistence_model
|
||||
from ...entity.errors import provider as provider_errors
|
||||
from async_lru import alru_cache
|
||||
|
||||
|
||||
class ModelManager:
|
||||
@@ -349,6 +350,7 @@ class ModelManager:
|
||||
|
||||
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:
|
||||
"""Get LLM model by uuid"""
|
||||
for model in self.llm_models:
|
||||
@@ -356,6 +358,7 @@ class ModelManager:
|
||||
return model
|
||||
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:
|
||||
"""Get embedding model by uuid"""
|
||||
for model in self.embedding_models:
|
||||
|
||||
@@ -529,7 +529,7 @@ class DifyServiceAPIRunner(runner.RequestRunner):
|
||||
think_end = True
|
||||
elif think_end or not think_start:
|
||||
pending_agent_message += chunk['answer']
|
||||
if think_start:
|
||||
if think_start and not think_end:
|
||||
continue
|
||||
|
||||
else:
|
||||
|
||||
@@ -215,16 +215,24 @@ class LocalAgentRunner(runner.RequestRunner):
|
||||
parameters = json.loads(func.arguments)
|
||||
|
||||
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:
|
||||
msg = provider_message.MessageChunk(
|
||||
role='tool',
|
||||
content=json.dumps(func_ret, ensure_ascii=False),
|
||||
content=tool_content,
|
||||
tool_call_id=tool_call.id,
|
||||
)
|
||||
else:
|
||||
msg = provider_message.Message(
|
||||
role='tool',
|
||||
content=json.dumps(func_ret, ensure_ascii=False),
|
||||
content=tool_content,
|
||||
tool_call_id=tool_call.id,
|
||||
)
|
||||
|
||||
|
||||
@@ -7,14 +7,18 @@ import traceback
|
||||
from langbot_plugin.api.entities.events import pipeline_query
|
||||
import sqlalchemy
|
||||
import asyncio
|
||||
import httpx
|
||||
|
||||
import uuid as uuid_module
|
||||
from mcp import ClientSession, StdioServerParameters
|
||||
from mcp.client.stdio import stdio_client
|
||||
from mcp.client.sse import sse_client
|
||||
from mcp.client.streamable_http import streamable_http_client
|
||||
|
||||
from .. import loader
|
||||
from ....core import app
|
||||
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
|
||||
|
||||
|
||||
@@ -35,7 +39,7 @@ class RuntimeMCPSession:
|
||||
|
||||
server_config: dict
|
||||
|
||||
session: ClientSession
|
||||
session: ClientSession | None
|
||||
|
||||
exit_stack: AsyncExitStack
|
||||
|
||||
@@ -52,6 +56,8 @@ class RuntimeMCPSession:
|
||||
|
||||
_ready_event: asyncio.Event
|
||||
|
||||
error_message: str | None = None
|
||||
|
||||
def __init__(self, server_name: str, server_config: dict, enable: bool, ap: app.Application):
|
||||
self.server_name = server_name
|
||||
self.server_uuid = server_config.get('uuid', '')
|
||||
@@ -100,6 +106,24 @@ class RuntimeMCPSession:
|
||||
|
||||
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):
|
||||
"""在后台任务中管理整个MCP会话的生命周期"""
|
||||
try:
|
||||
@@ -107,6 +131,8 @@ class RuntimeMCPSession:
|
||||
await self._init_stdio_python_server()
|
||||
elif self.server_config['mode'] == 'sse':
|
||||
await self._init_sse_server()
|
||||
elif self.server_config['mode'] == 'http':
|
||||
await self._init_streamable_http_server()
|
||||
else:
|
||||
raise ValueError(f'无法识别 MCP 服务器类型: {self.server_name}: {self.server_config}')
|
||||
|
||||
@@ -122,6 +148,7 @@ class RuntimeMCPSession:
|
||||
|
||||
except Exception as e:
|
||||
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()}')
|
||||
# 即使出错也要设置ready事件,让start()方法知道初始化已完成
|
||||
self._ready_event.set()
|
||||
@@ -154,6 +181,9 @@ class RuntimeMCPSession:
|
||||
raise Exception('Connection failed, please check URL')
|
||||
|
||||
async def refresh(self):
|
||||
if not self.session:
|
||||
return
|
||||
|
||||
self.functions.clear()
|
||||
|
||||
tools = await self.session.list_tools()
|
||||
@@ -163,18 +193,36 @@ class RuntimeMCPSession:
|
||||
for tool in tools.tools:
|
||||
|
||||
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)
|
||||
if result.isError:
|
||||
raise Exception(result.content[0].text)
|
||||
return result.content[0].text
|
||||
error_texts = []
|
||||
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
|
||||
|
||||
self.functions.append(
|
||||
resource_tool.LLMTool(
|
||||
name=tool.name,
|
||||
human_desc=tool.description,
|
||||
description=tool.description,
|
||||
human_desc=tool.description or "",
|
||||
description=tool.description or "",
|
||||
parameters=tool.inputSchema,
|
||||
func=func,
|
||||
)
|
||||
@@ -186,6 +234,7 @@ class RuntimeMCPSession:
|
||||
def get_runtime_info_dict(self) -> dict:
|
||||
return {
|
||||
'status': self.status.value,
|
||||
'error_message': self.error_message,
|
||||
'tool_count': len(self.get_tools()),
|
||||
'tools': [
|
||||
{
|
||||
@@ -287,6 +336,14 @@ class MCPLoader(loader.ToolLoader):
|
||||
- enable: 是否启用
|
||||
- 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']
|
||||
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))
|
||||
|
||||
# get embeddings
|
||||
embeddings_list: list[list[float]] = await embedding_model.provider.requester.invoke_embedding(
|
||||
model=embedding_model,
|
||||
input_text=chunks,
|
||||
extra_args={}, # TODO: add extra args
|
||||
)
|
||||
# get embeddings (batch size limit: 64 for OpenAI)
|
||||
MAX_BATCH_SIZE = 64
|
||||
embeddings_list: list[list[float]] = []
|
||||
|
||||
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
|
||||
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', {})
|
||||
uri = milvus_config.get('uri', './data/milvus.db')
|
||||
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.')
|
||||
|
||||
elif vdb_type == 'pgvector':
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
from __future__ import annotations
|
||||
import asyncio
|
||||
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.core import app
|
||||
|
||||
@@ -9,7 +10,7 @@ from langbot.pkg.core import app
|
||||
class MilvusVectorDatabase(VectorDatabase):
|
||||
"""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
|
||||
|
||||
Args:
|
||||
@@ -21,30 +22,76 @@ class MilvusVectorDatabase(VectorDatabase):
|
||||
self.ap = ap
|
||||
self.uri = uri
|
||||
self.token = token
|
||||
self.db_name = db_name
|
||||
self.client = None
|
||||
self._collections = {}
|
||||
self._collections: set[str] = set()
|
||||
self._initialize_client()
|
||||
|
||||
def _initialize_client(self):
|
||||
"""Initialize Milvus client connection"""
|
||||
try:
|
||||
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:
|
||||
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}")
|
||||
except Exception as e:
|
||||
self.ap.logger.error(f"Failed to connect to Milvus: {e}")
|
||||
raise
|
||||
|
||||
async def get_or_create_collection(self, collection: str):
|
||||
"""Get or create a Milvus collection
|
||||
@staticmethod
|
||||
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:
|
||||
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:
|
||||
return self._collections[collection]
|
||||
return collection
|
||||
|
||||
# Check if collection exists
|
||||
has_collection = await asyncio.to_thread(
|
||||
@@ -52,12 +99,13 @@ class MilvusVectorDatabase(VectorDatabase):
|
||||
)
|
||||
|
||||
if not has_collection:
|
||||
# Create collection with custom schema to support string IDs
|
||||
from pymilvus import CollectionSchema, FieldSchema, DataType
|
||||
# Default dimension if not specified (for backward compatibility)
|
||||
if vector_size is None:
|
||||
vector_size = 1536
|
||||
|
||||
fields = [
|
||||
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="file_id", 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",
|
||||
)
|
||||
|
||||
# Create index for vector field (required for loading/searching)
|
||||
index_params = {
|
||||
"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")
|
||||
await self._ensure_vector_index(collection)
|
||||
self.ap.logger.info(f"Created Milvus collection '{collection}' with dimension={vector_size}, index=AUTOINDEX")
|
||||
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._collections[collection] = collection
|
||||
self._collections.add(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(
|
||||
self,
|
||||
collection: str,
|
||||
@@ -107,7 +171,14 @@ class MilvusVectorDatabase(VectorDatabase):
|
||||
embeddings_list: List of embedding vectors
|
||||
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
|
||||
data = []
|
||||
@@ -156,6 +227,7 @@ class MilvusVectorDatabase(VectorDatabase):
|
||||
Returns:
|
||||
Dictionary with search results in Chroma-compatible format
|
||||
"""
|
||||
collection = self._normalize_collection_name(collection)
|
||||
await self.get_or_create_collection(collection)
|
||||
|
||||
# Perform search
|
||||
@@ -214,6 +286,7 @@ class MilvusVectorDatabase(VectorDatabase):
|
||||
collection: Collection name
|
||||
file_id: File ID to filter deletion
|
||||
"""
|
||||
collection = self._normalize_collection_name(collection)
|
||||
await self.get_or_create_collection(collection)
|
||||
|
||||
# Delete entities matching the file_id
|
||||
@@ -232,8 +305,9 @@ class MilvusVectorDatabase(VectorDatabase):
|
||||
Args:
|
||||
collection: Collection name to delete
|
||||
"""
|
||||
if collection in self._collections:
|
||||
del self._collections[collection]
|
||||
collection = self._normalize_collection_name(collection)
|
||||
|
||||
self._collections.discard(collection)
|
||||
|
||||
# Check if collection exists before attempting deletion
|
||||
has_collection = await asyncio.to_thread(
|
||||
|
||||
@@ -51,6 +51,7 @@ vdb:
|
||||
milvus:
|
||||
uri: 'http://127.0.0.1:19530'
|
||||
token: ''
|
||||
db_name: ''
|
||||
pgvector:
|
||||
host: '127.0.0.1'
|
||||
port: 5433
|
||||
@@ -78,3 +79,4 @@ space:
|
||||
# OAuth authorization page URL (user will be redirected here)
|
||||
oauth_authorize_url: 'https://space.langbot.app/auth/authorize'
|
||||
disable_models_service: false
|
||||
disable_telemetry: false
|
||||
|
||||
@@ -206,8 +206,23 @@ export default function ModelsDialog({
|
||||
}
|
||||
}
|
||||
|
||||
function handleSpaceLogin() {
|
||||
window.location.href = '/auth/space';
|
||||
async function handleSpaceLogin() {
|
||||
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(
|
||||
|
||||
@@ -2,7 +2,7 @@ import { MCPServer, MCPSessionStatus } from '@/app/infra/entities/api';
|
||||
|
||||
export class MCPCardVO {
|
||||
name: string;
|
||||
mode: 'stdio' | 'sse';
|
||||
mode: 'stdio' | 'sse' | 'http';
|
||||
enable: boolean;
|
||||
status: MCPSessionStatus;
|
||||
tools: number;
|
||||
|
||||
@@ -3,6 +3,7 @@ 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 { Badge } from '@/components/ui/badge';
|
||||
import { toast } from 'sonner';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { RefreshCcw, Wrench, Ban, AlertCircle, Loader2 } from 'lucide-react';
|
||||
@@ -98,9 +99,14 @@ export default function MCPCardComponent({
|
||||
</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 className="flex flex-col items-start justify-start gap-[0.3rem]">
|
||||
<div className="flex flex-row items-center gap-[0.5rem]">
|
||||
<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>
|
||||
|
||||
|
||||
@@ -43,6 +43,9 @@ import {
|
||||
MCPTool,
|
||||
MCPServer,
|
||||
MCPSessionStatus,
|
||||
MCPServerExtraArgsSSE,
|
||||
MCPServerExtraArgsHttp,
|
||||
MCPServerExtraArgsStdio,
|
||||
} from '@/app/infra/entities/api';
|
||||
import { CustomApiError } from '@/app/infra/entities/common';
|
||||
|
||||
@@ -133,11 +136,11 @@ function StatusDisplay({
|
||||
</svg>
|
||||
<span className="font-medium">{t('mcp.connectionFailed')}</span>
|
||||
</div>
|
||||
{/* {runtimeInfo.error_message && (
|
||||
{runtimeInfo.error_message && (
|
||||
<div className="text-sm text-red-500 pl-7">
|
||||
{runtimeInfo.error_message}
|
||||
</div>
|
||||
)} */}
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -163,31 +166,52 @@ function ToolsList({ tools }: { tools: MCPTool[] }) {
|
||||
}
|
||||
|
||||
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(),
|
||||
});
|
||||
z
|
||||
.object({
|
||||
name: z
|
||||
.string({ required_error: t('mcp.nameRequired') })
|
||||
.min(1, { message: t('mcp.nameRequired') }),
|
||||
mode: z.enum(['sse', 'stdio', 'http']),
|
||||
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().optional(),
|
||||
command: z.string().optional(),
|
||||
args: z.array(z.object({ value: z.string() })).optional(),
|
||||
extra_args: z
|
||||
.array(
|
||||
z.object({
|
||||
key: z.string(),
|
||||
type: z.enum(['string', 'number', 'boolean']),
|
||||
value: z.string(),
|
||||
}),
|
||||
)
|
||||
.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>> & {
|
||||
timeout: number;
|
||||
@@ -218,7 +242,10 @@ export default function MCPFormDialog({
|
||||
resolver: zodResolver(formSchema) as unknown as Resolver<FormValues>,
|
||||
defaultValues: {
|
||||
name: '',
|
||||
mode: 'sse',
|
||||
url: '',
|
||||
command: '',
|
||||
args: [],
|
||||
timeout: 30,
|
||||
ssereadtimeout: 300,
|
||||
extra_args: [],
|
||||
@@ -228,20 +255,33 @@ export default function MCPFormDialog({
|
||||
const [extraArgs, setExtraArgs] = useState<
|
||||
{ key: string; type: 'string' | 'number' | 'boolean'; value: string }[]
|
||||
>([]);
|
||||
const [stdioArgs, setStdioArgs] = useState<{ value: string }[]>([]);
|
||||
const [mcpTesting, setMcpTesting] = useState(false);
|
||||
const [runtimeInfo, setRuntimeInfo] = useState<MCPServerRuntimeInfo | null>(
|
||||
null,
|
||||
);
|
||||
const pollingIntervalRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
const watchMode = form.watch('mode');
|
||||
|
||||
// Load server data when editing
|
||||
useEffect(() => {
|
||||
if (open && isEditMode && serverName) {
|
||||
loadServerForEdit(serverName);
|
||||
} else if (open && !isEditMode) {
|
||||
// Reset form when creating new server
|
||||
form.reset();
|
||||
form.reset({
|
||||
name: '',
|
||||
mode: 'sse',
|
||||
url: '',
|
||||
command: '',
|
||||
args: [],
|
||||
timeout: 30,
|
||||
ssereadtimeout: 300,
|
||||
extra_args: [],
|
||||
});
|
||||
setExtraArgs([]);
|
||||
setStdioArgs([]);
|
||||
setRuntimeInfo(null);
|
||||
}
|
||||
|
||||
@@ -291,25 +331,49 @@ export default function MCPFormDialog({
|
||||
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);
|
||||
form.setValue('mode', server.mode);
|
||||
|
||||
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);
|
||||
if (server.mode === 'sse' || server.mode === 'http') {
|
||||
form.setValue('url', server.extra_args.url);
|
||||
form.setValue('timeout', server.extra_args.timeout);
|
||||
|
||||
if (server.mode === 'sse') {
|
||||
form.setValue('ssereadtimeout', server.extra_args.ssereadtimeout);
|
||||
}
|
||||
|
||||
if (server.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) {
|
||||
setRuntimeInfo(server.runtime_info);
|
||||
} else {
|
||||
@@ -322,28 +386,60 @@ export default function MCPFormDialog({
|
||||
}
|
||||
|
||||
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,
|
||||
},
|
||||
};
|
||||
let serverConfig: MCPServer;
|
||||
|
||||
if (value.mode === 'sse' || value.mode === 'http') {
|
||||
const headers: Record<string, string> = {};
|
||||
value.extra_args?.forEach((arg) => {
|
||||
headers[arg.key] = String(arg.value);
|
||||
});
|
||||
|
||||
if (value.mode === 'sse') {
|
||||
serverConfig = {
|
||||
name: value.name,
|
||||
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) {
|
||||
await httpClient.updateMCPServer(serverName, serverConfig);
|
||||
@@ -365,19 +461,44 @@ export default function MCPFormDialog({
|
||||
setMcpTesting(true);
|
||||
|
||||
try {
|
||||
const { task_id } = await httpClient.testMCPServer('_', {
|
||||
name: form.getValues('name'),
|
||||
mode: 'sse',
|
||||
enable: true,
|
||||
extra_args: {
|
||||
url: form.getValues('url'),
|
||||
const mode = form.getValues('mode');
|
||||
let extraArgsData:
|
||||
| MCPServerExtraArgsSSE
|
||||
| MCPServerExtraArgsHttp
|
||||
| MCPServerExtraArgsStdio;
|
||||
|
||||
if (mode === 'sse') {
|
||||
extraArgsData = {
|
||||
url: form.getValues('url')!,
|
||||
timeout: form.getValues('timeout'),
|
||||
ssereadtimeout: form.getValues('ssereadtimeout'),
|
||||
headers: Object.fromEntries(
|
||||
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) {
|
||||
throw new Error(t('mcp.noTaskId'));
|
||||
}
|
||||
@@ -448,11 +569,31 @@ export default function MCPFormDialog({
|
||||
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) => {
|
||||
onOpenChange(open);
|
||||
if (!open) {
|
||||
form.reset();
|
||||
setExtraArgs([]);
|
||||
setStdioArgs([]);
|
||||
setRuntimeInfo(null);
|
||||
}
|
||||
};
|
||||
@@ -518,58 +659,155 @@ export default function MCPFormDialog({
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="url"
|
||||
name="mode"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('mcp.url')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
<FormLabel>{t('mcp.serverMode')}</FormLabel>
|
||||
<Select
|
||||
onValueChange={field.onChange}
|
||||
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 />
|
||||
</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' || watchMode === 'http') && (
|
||||
<>
|
||||
<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>
|
||||
)}
|
||||
/>
|
||||
|
||||
{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>
|
||||
<FormLabel>{t('mcp.sseTimeout')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="number"
|
||||
placeholder={t('mcp.sseTimeoutDescription')}
|
||||
{...field}
|
||||
onChange={(e) => field.onChange(Number(e.target.value))}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
<FormLabel>{t('mcp.args')}</FormLabel>
|
||||
<div className="space-y-2">
|
||||
{stdioArgs.map((arg, index) => (
|
||||
<div key={index} className="flex gap-2">
|
||||
<Input
|
||||
placeholder={t('mcp.args')}
|
||||
value={arg.value}
|
||||
onChange={(e) =>
|
||||
updateStdioArg(index, e.target.value)
|
||||
}
|
||||
/>
|
||||
<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>
|
||||
<FormLabel>{t('models.extraParameters')}</FormLabel>
|
||||
<FormLabel>
|
||||
{watchMode === 'sse' || watchMode === 'http'
|
||||
? t('mcp.headers')
|
||||
: t('mcp.env')}
|
||||
</FormLabel>
|
||||
<div className="space-y-2">
|
||||
{extraArgs.map((arg, index) => (
|
||||
<div key={index} className="flex gap-2">
|
||||
@@ -580,7 +818,12 @@ export default function MCPFormDialog({
|
||||
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}
|
||||
onValueChange={(value) =>
|
||||
updateExtraArg(index, 'type', value)
|
||||
@@ -593,14 +836,8 @@ export default function MCPFormDialog({
|
||||
<SelectItem value="string">
|
||||
{t('models.string')}
|
||||
</SelectItem>
|
||||
<SelectItem value="number">
|
||||
{t('models.number')}
|
||||
</SelectItem>
|
||||
<SelectItem value="boolean">
|
||||
{t('models.boolean')}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Select> */}
|
||||
<Input
|
||||
placeholder={t('models.value')}
|
||||
value={arg.value}
|
||||
@@ -625,7 +862,9 @@ export default function MCPFormDialog({
|
||||
</div>
|
||||
))}
|
||||
<Button type="button" variant="outline" onClick={addExtraArg}>
|
||||
{t('models.addParameter')}
|
||||
{watchMode === 'sse' || watchMode === 'http'
|
||||
? t('mcp.addHeader')
|
||||
: t('mcp.addEnvVar')}
|
||||
</Button>
|
||||
</div>
|
||||
<FormDescription>
|
||||
|
||||
@@ -365,6 +365,18 @@ export interface MCPServerExtraArgsSSE {
|
||||
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 {
|
||||
CONNECTING = 'connecting',
|
||||
CONNECTED = 'connected',
|
||||
@@ -373,21 +385,42 @@ export enum MCPSessionStatus {
|
||||
|
||||
export interface MCPServerRuntimeInfo {
|
||||
status: MCPSessionStatus;
|
||||
error_message: string;
|
||||
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 type MCPServer =
|
||||
| {
|
||||
uuid?: string;
|
||||
name: string;
|
||||
mode: 'sse';
|
||||
enable: boolean;
|
||||
extra_args: MCPServerExtraArgsSSE;
|
||||
runtime_info?: MCPServerRuntimeInfo;
|
||||
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 {
|
||||
name: string;
|
||||
|
||||
@@ -467,8 +467,10 @@ const enUS = {
|
||||
getServerListError: 'Failed to get MCP server list: ',
|
||||
serverName: 'Server Name',
|
||||
serverMode: 'Connection Mode',
|
||||
selectMode: 'Select Mode',
|
||||
stdio: 'Stdio Mode',
|
||||
sse: 'SSE Mode',
|
||||
http: 'HTTP Mode',
|
||||
noServerInstalled: 'No MCP servers configured',
|
||||
serverNameRequired: 'Server name cannot be empty',
|
||||
commandRequired: 'Command cannot be empty',
|
||||
|
||||
@@ -474,6 +474,8 @@ const jaJP = {
|
||||
serverMode: '接続モード',
|
||||
stdio: 'Stdioモード',
|
||||
sse: 'SSEモード',
|
||||
http: 'HTTPモード',
|
||||
selectMode: '接続モードを選択',
|
||||
noServerInstalled: 'MCPサーバーが設定されていません',
|
||||
serverNameRequired: 'サーバー名は必須です',
|
||||
commandRequired: 'コマンドは必須です',
|
||||
|
||||
@@ -446,8 +446,10 @@ const zhHans = {
|
||||
getServerListError: '获取 MCP 服务器列表失败:',
|
||||
serverName: '服务器名称',
|
||||
serverMode: '连接模式',
|
||||
selectMode: '选择模式',
|
||||
stdio: 'Stdio模式',
|
||||
sse: 'SSE模式',
|
||||
http: 'HTTP模式',
|
||||
noServerInstalled: '暂未配置任何 MCP 服务器',
|
||||
serverNameRequired: '服务器名称不能为空',
|
||||
commandRequired: '命令不能为空',
|
||||
|
||||
@@ -445,6 +445,8 @@ const zhHant = {
|
||||
serverMode: '連接模式',
|
||||
stdio: 'Stdio模式',
|
||||
sse: 'SSE模式',
|
||||
selectMode: '選擇連接模式',
|
||||
http: 'HTTP模式',
|
||||
noServerInstalled: '暫未設定任何MCP伺服器',
|
||||
serverNameRequired: '伺服器名稱不能為空',
|
||||
commandRequired: '命令不能為空',
|
||||
|
||||
Reference in New Issue
Block a user