Merge remote-tracking branch 'origin/master' into dev/4.11.x

# Conflicts:
#	pyproject.toml
#	uv.lock
#	web/src/app/home/components/dynamic-form/DynamicFormItemComponent.tsx
This commit is contained in:
Junyan Qin
2026-07-03 20:46:19 +08:00
52 changed files with 4826 additions and 739 deletions
@@ -138,6 +138,39 @@ class MonitoringRouterGroup(group.RouterGroup):
} }
) )
@self.route('/tool-calls', methods=['GET'], auth_type=group.AuthType.USER_TOKEN)
async def get_tool_calls() -> str:
"""Get tool call records"""
bot_ids = quart.request.args.getlist('botId')
pipeline_ids = quart.request.args.getlist('pipelineId')
session_ids = quart.request.args.getlist('sessionId')
start_time_str = quart.request.args.get('startTime')
end_time_str = quart.request.args.get('endTime')
limit = int(quart.request.args.get('limit', 100))
offset = int(quart.request.args.get('offset', 0))
start_time = parse_iso_datetime(start_time_str)
end_time = parse_iso_datetime(end_time_str)
tool_calls, total = await self.ap.monitoring_service.get_tool_calls(
bot_ids=bot_ids if bot_ids else None,
pipeline_ids=pipeline_ids if pipeline_ids else None,
session_ids=session_ids if session_ids else None,
start_time=start_time,
end_time=end_time,
limit=limit,
offset=offset,
)
return self.success(
data={
'tool_calls': tool_calls,
'total': total,
'limit': limit,
'offset': offset,
}
)
@self.route('/embedding-calls', methods=['GET'], auth_type=group.AuthType.USER_TOKEN) @self.route('/embedding-calls', methods=['GET'], auth_type=group.AuthType.USER_TOKEN)
async def get_embedding_calls() -> str: async def get_embedding_calls() -> str:
"""Get embedding call records""" """Get embedding call records"""
@@ -284,6 +317,16 @@ class MonitoringRouterGroup(group.RouterGroup):
offset=0, offset=0,
) )
# Get tool calls
tool_calls, tool_calls_total = await self.ap.monitoring_service.get_tool_calls(
bot_ids=bot_ids if bot_ids else None,
pipeline_ids=pipeline_ids if pipeline_ids else None,
start_time=start_time,
end_time=end_time,
limit=limit,
offset=0,
)
# Get sessions # Get sessions
sessions, sessions_total = await self.ap.monitoring_service.get_sessions( sessions, sessions_total = await self.ap.monitoring_service.get_sessions(
bot_ids=bot_ids if bot_ids else None, bot_ids=bot_ids if bot_ids else None,
@@ -318,12 +361,14 @@ class MonitoringRouterGroup(group.RouterGroup):
'overview': overview, 'overview': overview,
'messages': messages, 'messages': messages,
'llmCalls': llm_calls, 'llmCalls': llm_calls,
'toolCalls': tool_calls,
'embeddingCalls': embedding_calls, 'embeddingCalls': embedding_calls,
'sessions': sessions, 'sessions': sessions,
'errors': errors, 'errors': errors,
'totalCount': { 'totalCount': {
'messages': messages_total, 'messages': messages_total,
'llmCalls': llm_calls_total, 'llmCalls': llm_calls_total,
'toolCalls': tool_calls_total,
'embeddingCalls': embedding_calls_total, 'embeddingCalls': embedding_calls_total,
'sessions': sessions_total, 'sessions': sessions_total,
'errors': errors_total, 'errors': errors_total,
@@ -29,11 +29,11 @@ class MCPRouterGroup(group.RouterGroup):
traceback.print_exc() traceback.print_exc()
return self.http_status(500, -1, f'Failed to create MCP server: {str(e)}') return self.http_status(500, -1, f'Failed to create MCP server: {str(e)}')
@self.route('/servers/<server_name>', methods=['GET', 'PUT', 'DELETE'], auth_type=group.AuthType.USER_TOKEN) @self.route(
'/servers/<path:server_name>', methods=['GET', 'PUT', 'DELETE'], auth_type=group.AuthType.USER_TOKEN
)
async def _(server_name: str) -> str: async def _(server_name: str) -> str:
"""获取、更新或删除MCP服务器配置""" """获取、更新或删除MCP服务器配置"""
from urllib.parse import unquote
server_name = unquote(server_name) server_name = unquote(server_name)
server_data = await self.ap.mcp_service.get_mcp_server_by_name(server_name) server_data = await self.ap.mcp_service.get_mcp_server_by_name(server_name)
@@ -58,17 +58,15 @@ class MCPRouterGroup(group.RouterGroup):
except Exception as e: except Exception as e:
return self.http_status(500, -1, f'Failed to delete MCP server: {str(e)}') return self.http_status(500, -1, f'Failed to delete MCP server: {str(e)}')
@self.route('/servers/<server_name>/test', methods=['POST'], auth_type=group.AuthType.USER_TOKEN) @self.route('/servers/<path:server_name>/test', methods=['POST'], auth_type=group.AuthType.USER_TOKEN)
async def _(server_name: str) -> str: async def _(server_name: str) -> str:
"""测试MCP服务器连接""" """测试MCP服务器连接"""
from urllib.parse import unquote
server_name = unquote(server_name) server_name = unquote(server_name)
server_data = await quart.request.json server_data = await quart.request.json
task_id = await self.ap.mcp_service.test_mcp_server(server_name=server_name, server_data=server_data) task_id = await self.ap.mcp_service.test_mcp_server(server_name=server_name, server_data=server_data)
return self.success(data={'task_id': task_id}) return self.success(data={'task_id': task_id})
@self.route('/servers/<server_name>/resources', methods=['GET'], auth_type=group.AuthType.USER_TOKEN) @self.route('/servers/<path:server_name>/resources', methods=['GET'], auth_type=group.AuthType.USER_TOKEN)
async def _(server_name: str) -> str: async def _(server_name: str) -> str:
"""Get resources from an MCP server""" """Get resources from an MCP server"""
server_name = unquote(server_name) server_name = unquote(server_name)
@@ -86,7 +84,9 @@ class MCPRouterGroup(group.RouterGroup):
except Exception as e: except Exception as e:
return self.http_status(500, -1, f'Failed to get resources: {str(e)}') return self.http_status(500, -1, f'Failed to get resources: {str(e)}')
@self.route('/servers/<server_name>/resource-templates', methods=['GET'], auth_type=group.AuthType.USER_TOKEN) @self.route(
'/servers/<path:server_name>/resource-templates', methods=['GET'], auth_type=group.AuthType.USER_TOKEN
)
async def _(server_name: str) -> str: async def _(server_name: str) -> str:
"""Get resource templates from an MCP server""" """Get resource templates from an MCP server"""
server_name = unquote(server_name) server_name = unquote(server_name)
@@ -96,7 +96,20 @@ class MCPRouterGroup(group.RouterGroup):
except Exception as e: except Exception as e:
return self.http_status(500, -1, f'Failed to get resource templates: {str(e)}') return self.http_status(500, -1, f'Failed to get resource templates: {str(e)}')
@self.route('/servers/<server_name>/resources/read', methods=['POST'], auth_type=group.AuthType.USER_TOKEN) @self.route('/servers/<path:server_name>/logs', methods=['GET'], auth_type=group.AuthType.USER_TOKEN)
async def _(server_name: str) -> str:
"""Get logs from an MCP server"""
server_name = unquote(server_name)
try:
limit = int(quart.request.args.get('limit', 200))
except (TypeError, ValueError):
limit = 200
limit = min(limit, 500)
level = quart.request.args.get('level') or None
logs = await self.ap.mcp_service.get_mcp_server_logs(server_name, limit=limit, level=level)
return self.success(data={'logs': logs})
@self.route('/servers/<path:server_name>/resources/read', methods=['POST'], auth_type=group.AuthType.USER_TOKEN)
async def _(server_name: str) -> str: async def _(server_name: str) -> str:
"""Read a resource from an MCP server""" """Read a resource from an MCP server"""
server_name = unquote(server_name) server_name = unquote(server_name)
@@ -243,6 +243,7 @@ class MaintenanceService:
tables = { tables = {
'messages': persistence_monitoring.MonitoringMessage.id, 'messages': persistence_monitoring.MonitoringMessage.id,
'llm_calls': persistence_monitoring.MonitoringLLMCall.id, 'llm_calls': persistence_monitoring.MonitoringLLMCall.id,
'tool_calls': persistence_monitoring.MonitoringToolCall.id,
'embedding_calls': persistence_monitoring.MonitoringEmbeddingCall.id, 'embedding_calls': persistence_monitoring.MonitoringEmbeddingCall.id,
'errors': persistence_monitoring.MonitoringError.id, 'errors': persistence_monitoring.MonitoringError.id,
'sessions': persistence_monitoring.MonitoringSession.session_id, 'sessions': persistence_monitoring.MonitoringSession.session_id,
+40 -1
View File
@@ -48,6 +48,17 @@ class MCPService:
if total_extensions >= max_extensions: if total_extensions >= max_extensions:
raise ValueError(f'Maximum number of extensions ({max_extensions}) reached') raise ValueError(f'Maximum number of extensions ({max_extensions}) reached')
server_name = str(server_data.get('name') or '').strip()
if not server_name:
raise ValueError('MCP server name is required')
server_data['name'] = server_name
existing_result = await self.ap.persistence_mgr.execute_async(
sqlalchemy.select(persistence_mcp.MCPServer).where(persistence_mcp.MCPServer.name == server_name)
)
if existing_result.first() is not None:
raise ValueError(f'MCP server already exists: {server_name}')
server_data['uuid'] = str(uuid.uuid4()) server_data['uuid'] = str(uuid.uuid4())
await self.ap.persistence_mgr.execute_async(sqlalchemy.insert(persistence_mcp.MCPServer).values(server_data)) await self.ap.persistence_mgr.execute_async(sqlalchemy.insert(persistence_mcp.MCPServer).values(server_data))
@@ -177,10 +188,22 @@ class MCPService:
persisted_session = runtime_mcp_session persisted_session = runtime_mcp_session
async def _refresh_and_report() -> None: async def _refresh_and_report() -> None:
if persisted_session.status == MCPSessionStatus.ERROR: # Testing a persisted server should REUSE its live shared-session
# process, not rebuild it. Try a lightweight refresh (a real
# list_tools probe over the existing connection) first; only fall
# back to a full start() when the session has no live connection
# to probe (never connected, or the process is actually gone).
needs_start = persisted_session.status == MCPSessionStatus.ERROR or persisted_session.session is None
if needs_start:
await persisted_session.start() await persisted_session.start()
else: else:
try:
await persisted_session.refresh() await persisted_session.refresh()
except Exception:
# The live connection was stale/dropped: reconnect once
# (reusing the live managed process where possible) and
# re-probe, instead of reporting a false failure.
await persisted_session.start()
# Surface the discovered tools so the config page can render them # Surface the discovered tools so the config page can render them
# even for an already-hosted server. # even for an already-hosted server.
ctx.metadata['runtime_info'] = persisted_session.get_runtime_info_dict() ctx.metadata['runtime_info'] = persisted_session.get_runtime_info_dict()
@@ -221,3 +244,19 @@ class MCPService:
context=ctx, context=ctx,
) )
return wrapper.id return wrapper.id
async def get_mcp_server_logs(self, server_name: str, limit: int = 200, level: str | None = None) -> list[dict]:
"""Get recent log lines captured from the MCP server's stderr."""
session = self.ap.tool_mgr.mcp_tool_loader.get_session(server_name)
if not session:
return []
# Get logs from the session's buffer
logs = list(session._log_buffer)
# Filter by level if specified
if level:
logs = [log for log in logs if log.get('level') == level]
# Return the most recent 'limit' logs
return logs[-limit:]
@@ -2,6 +2,7 @@ from __future__ import annotations
import uuid import uuid
import datetime import datetime
import json
import sqlalchemy import sqlalchemy
from ....core import app from ....core import app
@@ -50,6 +51,12 @@ class MonitoringService:
persistence_monitoring.MonitoringLLMCall.timestamp, persistence_monitoring.MonitoringLLMCall.timestamp,
persistence_monitoring.MonitoringLLMCall.id, persistence_monitoring.MonitoringLLMCall.id,
), ),
(
'monitoring_tool_calls',
persistence_monitoring.MonitoringToolCall,
persistence_monitoring.MonitoringToolCall.timestamp,
persistence_monitoring.MonitoringToolCall.id,
),
( (
'monitoring_embedding_calls', 'monitoring_embedding_calls',
persistence_monitoring.MonitoringEmbeddingCall, persistence_monitoring.MonitoringEmbeddingCall,
@@ -131,6 +138,68 @@ class MonitoringService:
await autocommit_conn.execute(sqlalchemy.text('PRAGMA wal_checkpoint(TRUNCATE)')) await autocommit_conn.execute(sqlalchemy.text('PRAGMA wal_checkpoint(TRUNCATE)'))
await autocommit_conn.execute(sqlalchemy.text('VACUUM')) await autocommit_conn.execute(sqlalchemy.text('VACUUM'))
def _serialize_tool_payload(self, payload: object, max_length: int = 20000) -> str | None:
"""Serialize tool arguments/results for monitoring storage."""
if payload is None:
return None
if isinstance(payload, str):
text = payload
else:
try:
text = json.dumps(payload, ensure_ascii=False, default=str)
except Exception:
text = str(payload)
if len(text) <= max_length:
return text
return f'{text[:max_length]}... [truncated {len(text) - max_length} chars]'
async def _get_message_for_tool_context(
self,
message_id: str | None = None,
session_id: str | None = None,
):
if message_id:
result = await self.ap.persistence_mgr.execute_async(
sqlalchemy.select(persistence_monitoring.MonitoringMessage).where(
persistence_monitoring.MonitoringMessage.id == message_id
)
)
row = result.first()
if row:
return row[0]
if not session_id:
return None
user_query = (
sqlalchemy.select(persistence_monitoring.MonitoringMessage)
.where(
sqlalchemy.and_(
persistence_monitoring.MonitoringMessage.session_id == session_id,
persistence_monitoring.MonitoringMessage.role == 'user',
)
)
.order_by(persistence_monitoring.MonitoringMessage.timestamp.desc())
.limit(1)
)
result = await self.ap.persistence_mgr.execute_async(user_query)
row = result.first()
if row:
return row[0]
any_query = (
sqlalchemy.select(persistence_monitoring.MonitoringMessage)
.where(persistence_monitoring.MonitoringMessage.session_id == session_id)
.order_by(persistence_monitoring.MonitoringMessage.timestamp.desc())
.limit(1)
)
result = await self.ap.persistence_mgr.execute_async(any_query)
row = result.first()
return row[0] if row else None
# ========== Recording Methods ========== # ========== Recording Methods ==========
async def record_message( async def record_message(
@@ -220,6 +289,57 @@ class MonitoringService:
return call_id return call_id
async def record_tool_call(
self,
tool_name: str,
tool_source: str,
duration: int,
status: str = 'success',
bot_id: str | None = None,
bot_name: str | None = None,
pipeline_id: str | None = None,
pipeline_name: str | None = None,
session_id: str | None = None,
message_id: str | None = None,
arguments: object | None = None,
result: object | None = None,
error_message: str | None = None,
) -> str:
"""Record a tool call."""
context_message = await self._get_message_for_tool_context(message_id=message_id, session_id=session_id)
if context_message:
bot_id = bot_id or context_message.bot_id
bot_name = bot_name or context_message.bot_name
pipeline_id = pipeline_id or context_message.pipeline_id
pipeline_name = pipeline_name or context_message.pipeline_name
session_id = session_id or context_message.session_id
message_id = message_id or context_message.id
call_id = str(uuid.uuid4())
call_data = {
'id': call_id,
'timestamp': datetime.datetime.now(datetime.timezone.utc).replace(tzinfo=None),
'tool_name': tool_name,
'tool_source': tool_source,
'duration': max(0, duration),
'status': status,
'bot_id': bot_id or 'unknown',
'bot_name': bot_name or 'Unknown',
'pipeline_id': pipeline_id or 'unknown',
'pipeline_name': pipeline_name or 'Unknown',
'session_id': session_id,
'message_id': message_id,
'arguments': self._serialize_tool_payload(arguments),
'result': self._serialize_tool_payload(result),
'error_message': self._serialize_tool_payload(error_message),
}
await self.ap.persistence_mgr.execute_async(
sqlalchemy.insert(persistence_monitoring.MonitoringToolCall).values(call_data)
)
return call_id
async def record_embedding_call( async def record_embedding_call(
self, self,
model_name: str, model_name: str,
@@ -749,6 +869,58 @@ class MonitoringService:
total, total,
) )
async def get_tool_calls(
self,
bot_ids: list[str] | None = None,
pipeline_ids: list[str] | None = None,
session_ids: list[str] | None = None,
start_time: datetime.datetime | None = None,
end_time: datetime.datetime | None = None,
limit: int = 100,
offset: int = 0,
) -> tuple[list[dict], int]:
"""Get tool calls with filters"""
conditions = []
if bot_ids:
conditions.append(persistence_monitoring.MonitoringToolCall.bot_id.in_(bot_ids))
if pipeline_ids:
conditions.append(persistence_monitoring.MonitoringToolCall.pipeline_id.in_(pipeline_ids))
if session_ids:
conditions.append(persistence_monitoring.MonitoringToolCall.session_id.in_(session_ids))
if start_time:
conditions.append(persistence_monitoring.MonitoringToolCall.timestamp >= start_time)
if end_time:
conditions.append(persistence_monitoring.MonitoringToolCall.timestamp <= end_time)
count_query = sqlalchemy.select(sqlalchemy.func.count(persistence_monitoring.MonitoringToolCall.id))
if conditions:
count_query = count_query.where(sqlalchemy.and_(*conditions))
count_result = await self.ap.persistence_mgr.execute_async(count_query)
total = count_result.scalar() or 0
query = sqlalchemy.select(persistence_monitoring.MonitoringToolCall).order_by(
persistence_monitoring.MonitoringToolCall.timestamp.desc()
)
if conditions:
query = query.where(sqlalchemy.and_(*conditions))
query = query.limit(limit).offset(offset)
result = await self.ap.persistence_mgr.execute_async(query)
tool_calls_rows = result.all()
return (
[
self.ap.persistence_mgr.serialize_model(
persistence_monitoring.MonitoringToolCall, row[0] if isinstance(row, tuple) else row
)
for row in tool_calls_rows
],
total,
)
async def get_embedding_calls( async def get_embedding_calls(
self, self,
start_time: datetime.datetime | None = None, start_time: datetime.datetime | None = None,
@@ -971,6 +1143,34 @@ class MonitoringService:
else: else:
error_llm_calls += 1 error_llm_calls += 1
# Get tool calls for this session
tool_query = (
sqlalchemy.select(persistence_monitoring.MonitoringToolCall)
.where(persistence_monitoring.MonitoringToolCall.session_id == session_id)
.order_by(persistence_monitoring.MonitoringToolCall.timestamp.asc())
)
tool_result = await self.ap.persistence_mgr.execute_async(tool_query)
tool_rows = tool_result.all()
tool_calls = [
self.ap.persistence_mgr.serialize_model(
persistence_monitoring.MonitoringToolCall, row[0] if isinstance(row, tuple) else row
)
for row in tool_rows
]
total_tool_calls = len(tool_rows)
success_tool_calls = 0
error_tool_calls = 0
total_tool_duration = 0
for row in tool_rows:
tool_call = row[0] if isinstance(row, tuple) else row
total_tool_duration += tool_call.duration
if tool_call.status == 'success':
success_tool_calls += 1
else:
error_tool_calls += 1
# Get errors for this session # Get errors for this session
error_query = ( error_query = (
sqlalchemy.select(persistence_monitoring.MonitoringError) sqlalchemy.select(persistence_monitoring.MonitoringError)
@@ -1014,6 +1214,14 @@ class MonitoringService:
'total_tokens': total_tokens, 'total_tokens': total_tokens,
'average_duration_ms': int(total_duration / total_llm_calls) if total_llm_calls > 0 else 0, 'average_duration_ms': int(total_duration / total_llm_calls) if total_llm_calls > 0 else 0,
}, },
'tool_calls': tool_calls,
'tool_stats': {
'total_calls': total_tool_calls,
'success_calls': success_tool_calls,
'error_calls': error_tool_calls,
'total_duration_ms': total_tool_duration,
'average_duration_ms': int(total_tool_duration / total_tool_calls) if total_tool_calls > 0 else 0,
},
'errors': errors, 'errors': errors,
'session_duration_seconds': session_duration_seconds, 'session_duration_seconds': session_duration_seconds,
} }
@@ -49,6 +49,28 @@ class MonitoringLLMCall(Base):
message_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True, index=True) # Associated message ID message_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True, index=True) # Associated message ID
class MonitoringToolCall(Base):
"""Tool call records"""
__tablename__ = 'monitoring_tool_calls'
id = sqlalchemy.Column(sqlalchemy.String(255), primary_key=True)
timestamp = sqlalchemy.Column(sqlalchemy.DateTime, nullable=False, index=True)
tool_name = sqlalchemy.Column(sqlalchemy.String(255), nullable=False)
tool_source = sqlalchemy.Column(sqlalchemy.String(50), nullable=False) # native, plugin, mcp, skill
duration = sqlalchemy.Column(sqlalchemy.Integer, nullable=False) # milliseconds
status = sqlalchemy.Column(sqlalchemy.String(50), nullable=False) # success, error
bot_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=False, index=True)
bot_name = sqlalchemy.Column(sqlalchemy.String(255), nullable=False)
pipeline_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=False, index=True)
pipeline_name = sqlalchemy.Column(sqlalchemy.String(255), nullable=False)
session_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True, index=True)
message_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True, index=True)
arguments = sqlalchemy.Column(sqlalchemy.Text, nullable=True)
result = sqlalchemy.Column(sqlalchemy.Text, nullable=True)
error_message = sqlalchemy.Column(sqlalchemy.Text, nullable=True)
class MonitoringSession(Base): class MonitoringSession(Base):
"""Session tracking records""" """Session tracking records"""
@@ -0,0 +1,17 @@
from langbot.pkg.entity.persistence import monitoring as persistence_monitoring
from .. import migration
@migration.migration_class(26)
class DBMigrateMonitoringToolCalls(migration.DBMigration):
"""Add monitoring_tool_calls table"""
async def upgrade(self):
"""Upgrade"""
async with self.ap.persistence_mgr.get_db_engine().begin() as conn:
await conn.run_sync(persistence_monitoring.MonitoringToolCall.__table__.create, checkfirst=True)
async def downgrade(self):
"""Downgrade"""
async with self.ap.persistence_mgr.get_db_engine().begin() as conn:
await conn.run_sync(persistence_monitoring.MonitoringToolCall.__table__.drop, checkfirst=True)
@@ -21,7 +21,7 @@ class AiocqhttpAdapter(AiocqhttpAPIMixin, abstract_platform_adapter.AbstractPlat
bot: aiocqhttp.CQHttp = pydantic.Field(exclude=True) bot: aiocqhttp.CQHttp = pydantic.Field(exclude=True)
message_converter: AiocqhttpMessageConverter = AiocqhttpMessageConverter() message_converter: AiocqhttpMessageConverter = AiocqhttpMessageConverter()
event_converter: AiocqhttpEventConverter = AiocqhttpEventConverter() event_converter: AiocqhttpEventConverter = pydantic.Field(default_factory=AiocqhttpEventConverter)
config: dict config: dict
listeners: dict[ listeners: dict[
@@ -1,5 +1,7 @@
from __future__ import annotations from __future__ import annotations
import asyncio
import time
import typing import typing
import aiocqhttp import aiocqhttp
@@ -10,6 +12,103 @@ from langbot_plugin.api.entities.builtin.platform import entities as platform_en
from langbot_plugin.api.entities.builtin.platform import events as platform_events from langbot_plugin.api.entities.builtin.platform import events as platform_events
_GROUP_NAME_CACHE_TTL_SECONDS = 3600
_GROUP_NAME_NEGATIVE_CACHE_TTL_SECONDS = 60
_GROUP_NAME_LOOKUP_TIMEOUT_SECONDS = 2
_GROUP_MEMBER_INFO_CACHE_TTL_SECONDS = 86400
_GROUP_MEMBER_INFO_NEGATIVE_CACHE_TTL_SECONDS = 600
_GROUP_MEMBER_INFO_LOOKUP_TIMEOUT_SECONDS = 2
_group_name_cache: dict[typing.Union[int, str], tuple[str, float]] = {}
_group_name_negative_cache: dict[typing.Union[int, str], float] = {}
_group_member_info_cache: dict[tuple[typing.Union[int, str], typing.Union[int, str]], tuple[dict, float]] = {}
_group_member_info_negative_cache: dict[tuple[typing.Union[int, str], typing.Union[int, str]], float] = {}
def _get_field(data: dict, key: str, default: str = '') -> str:
value = data.get(key)
if value is None:
return default
return str(value)
def _get_group_member_name(sender: dict) -> str:
return _get_field(sender, 'card') or _get_field(sender, 'nickname') or _get_field(sender, 'user_id')
def _get_group_name_placeholder(group_id: typing.Union[int, str]) -> str:
return f'Group {group_id}'
async def _get_group_name(group_id: typing.Union[int, str], bot: aiocqhttp.CQHttp | None = None) -> str:
now = time.monotonic()
if group_id in _group_name_cache:
group_name, expires_at = _group_name_cache[group_id]
if expires_at > now:
return group_name
del _group_name_cache[group_id]
if group_id in _group_name_negative_cache:
expires_at = _group_name_negative_cache[group_id]
if expires_at > now:
return ''
del _group_name_negative_cache[group_id]
if bot is None:
return ''
try:
group_info = await asyncio.wait_for(
bot.get_group_info(group_id=group_id),
timeout=_GROUP_NAME_LOOKUP_TIMEOUT_SECONDS,
)
except Exception:
_group_name_negative_cache[group_id] = now + _GROUP_NAME_NEGATIVE_CACHE_TTL_SECONDS
return ''
group_name = _get_field(group_info, 'group_name') if isinstance(group_info, dict) else ''
if group_name:
_group_name_cache[group_id] = (group_name, now + _GROUP_NAME_CACHE_TTL_SECONDS)
_group_name_negative_cache.pop(group_id, None)
else:
_group_name_negative_cache[group_id] = now + _GROUP_NAME_NEGATIVE_CACHE_TTL_SECONDS
return group_name
async def _get_group_member_info(
group_id: typing.Union[int, str],
user_id: typing.Union[int, str],
bot: aiocqhttp.CQHttp | None = None,
) -> dict:
now = time.monotonic()
cache_key = (group_id, user_id)
if cache_key in _group_member_info_cache:
member_info, expires_at = _group_member_info_cache[cache_key]
if expires_at > now:
return member_info
del _group_member_info_cache[cache_key]
if cache_key in _group_member_info_negative_cache:
expires_at = _group_member_info_negative_cache[cache_key]
if expires_at > now:
return {}
del _group_member_info_negative_cache[cache_key]
if bot is None:
return {}
try:
member_info = await asyncio.wait_for(
bot.get_group_member_info(group_id=group_id, user_id=user_id),
timeout=_GROUP_MEMBER_INFO_LOOKUP_TIMEOUT_SECONDS,
)
except Exception:
_group_member_info_negative_cache[cache_key] = now + _GROUP_MEMBER_INFO_NEGATIVE_CACHE_TTL_SECONDS
return {}
if isinstance(member_info, dict) and member_info:
_group_member_info_cache[cache_key] = (
member_info,
now + _GROUP_MEMBER_INFO_CACHE_TTL_SECONDS,
)
_group_member_info_negative_cache.pop(cache_key, None)
return member_info
_group_member_info_negative_cache[cache_key] = now + _GROUP_MEMBER_INFO_NEGATIVE_CACHE_TTL_SECONDS
return {}
class AiocqhttpEventConverter(abstract_platform_adapter.AbstractEventConverter): class AiocqhttpEventConverter(abstract_platform_adapter.AbstractEventConverter):
@staticmethod @staticmethod
async def yiri2target(event: platform_events.Event, bot_account_id: int | str | None = None): async def yiri2target(event: platform_events.Event, bot_account_id: int | str | None = None):
@@ -25,9 +124,9 @@ class AiocqhttpEventConverter(abstract_platform_adapter.AbstractEventConverter):
if event_type == 'message': if event_type == 'message':
return await AiocqhttpEventConverter.message_to_eba(event, bot) return await AiocqhttpEventConverter.message_to_eba(event, bot)
if event_type == 'notice': if event_type == 'notice':
return AiocqhttpEventConverter.notice_to_eba(event, bot_user_id) return await AiocqhttpEventConverter.notice_to_eba(event, bot, bot_user_id)
if event_type == 'request': if event_type == 'request':
return AiocqhttpEventConverter.request_to_eba(event) return await AiocqhttpEventConverter.request_to_eba(event, bot)
if event_type == 'meta_event': if event_type == 'meta_event':
return AiocqhttpEventConverter.platform_specific(event, f'meta.{getattr(event, "detail_type", "")}') return AiocqhttpEventConverter.platform_specific(event, f'meta.{getattr(event, "detail_type", "")}')
return None return None
@@ -60,14 +159,14 @@ class AiocqhttpEventConverter(abstract_platform_adapter.AbstractEventConverter):
if message_type == 'group': if message_type == 'group':
chat_type = platform_entities.ChatType.GROUP chat_type = platform_entities.ChatType.GROUP
chat_id = getattr(event, 'group_id', '') chat_id = getattr(event, 'group_id', '')
group = AiocqhttpEventConverter.group_from_event(event) group = await AiocqhttpEventConverter.group_from_event(event, bot)
return platform_events.MessageReceivedEvent( return platform_events.MessageReceivedEvent(
type='message.received', type='message.received',
adapter_name='aiocqhttp', adapter_name='aiocqhttp',
message_id=getattr(event, 'message_id', ''), message_id=getattr(event, 'message_id', ''),
message_chain=message_chain, message_chain=message_chain,
sender=AiocqhttpEventConverter.user_from_sender(event), sender=await AiocqhttpEventConverter.user_from_sender(event, bot),
chat_type=chat_type, chat_type=chat_type,
chat_id=chat_id, chat_id=chat_id,
group=group, group=group,
@@ -76,8 +175,9 @@ class AiocqhttpEventConverter(abstract_platform_adapter.AbstractEventConverter):
) )
@staticmethod @staticmethod
def notice_to_eba( async def notice_to_eba(
event: aiocqhttp.Event, event: aiocqhttp.Event,
bot: aiocqhttp.CQHttp | None = None,
bot_user_id: int | str | None = None, bot_user_id: int | str | None = None,
) -> platform_events.EBAEvent: ) -> platform_events.EBAEvent:
notice_type = getattr(event, 'notice_type', getattr(event, 'detail_type', '')) notice_type = getattr(event, 'notice_type', getattr(event, 'detail_type', ''))
@@ -91,12 +191,14 @@ class AiocqhttpEventConverter(abstract_platform_adapter.AbstractEventConverter):
if notice_type == 'group_recall' if notice_type == 'group_recall'
else platform_entities.ChatType.PRIVATE, else platform_entities.ChatType.PRIVATE,
chat_id=getattr(event, 'group_id', getattr(event, 'user_id', '')), chat_id=getattr(event, 'group_id', getattr(event, 'user_id', '')),
group=AiocqhttpEventConverter.group_from_event(event) if notice_type == 'group_recall' else None, group=await AiocqhttpEventConverter.group_from_event(event, bot)
if notice_type == 'group_recall'
else None,
timestamp=float(getattr(event, 'time', 0) or 0), timestamp=float(getattr(event, 'time', 0) or 0),
source_platform_object=event, source_platform_object=event,
) )
if notice_type == 'group_increase': if notice_type == 'group_increase':
group = AiocqhttpEventConverter.group_from_event(event) group = await AiocqhttpEventConverter.group_from_event(event, bot)
user = AiocqhttpEventConverter.user(getattr(event, 'user_id', '')) user = AiocqhttpEventConverter.user(getattr(event, 'user_id', ''))
inviter_id = getattr(event, 'operator_id', None) inviter_id = getattr(event, 'operator_id', None)
if AiocqhttpEventConverter._is_bot_user(getattr(event, 'user_id', None), bot_user_id, event): if AiocqhttpEventConverter._is_bot_user(getattr(event, 'user_id', None), bot_user_id, event):
@@ -119,7 +221,7 @@ class AiocqhttpEventConverter(abstract_platform_adapter.AbstractEventConverter):
source_platform_object=event, source_platform_object=event,
) )
if notice_type == 'group_decrease': if notice_type == 'group_decrease':
group = AiocqhttpEventConverter.group_from_event(event) group = await AiocqhttpEventConverter.group_from_event(event, bot)
operator = AiocqhttpEventConverter.user(getattr(event, 'operator_id', None)) operator = AiocqhttpEventConverter.user(getattr(event, 'operator_id', None))
if AiocqhttpEventConverter._is_bot_user(getattr(event, 'user_id', None), bot_user_id, event): if AiocqhttpEventConverter._is_bot_user(getattr(event, 'user_id', None), bot_user_id, event):
return platform_events.BotRemovedFromGroupEvent( return platform_events.BotRemovedFromGroupEvent(
@@ -141,7 +243,7 @@ class AiocqhttpEventConverter(abstract_platform_adapter.AbstractEventConverter):
source_platform_object=event, source_platform_object=event,
) )
if notice_type == 'group_ban': if notice_type == 'group_ban':
group = AiocqhttpEventConverter.group_from_event(event) group = await AiocqhttpEventConverter.group_from_event(event, bot)
duration = int(getattr(event, 'duration', 0) or 0) duration = int(getattr(event, 'duration', 0) or 0)
operator = AiocqhttpEventConverter.user(getattr(event, 'operator_id', None)) operator = AiocqhttpEventConverter.user(getattr(event, 'operator_id', None))
if AiocqhttpEventConverter._is_bot_user(getattr(event, 'user_id', None), bot_user_id, event): if AiocqhttpEventConverter._is_bot_user(getattr(event, 'user_id', None), bot_user_id, event):
@@ -179,7 +281,10 @@ class AiocqhttpEventConverter(abstract_platform_adapter.AbstractEventConverter):
return AiocqhttpEventConverter.platform_specific(event, f'notice.{notice_type}') return AiocqhttpEventConverter.platform_specific(event, f'notice.{notice_type}')
@staticmethod @staticmethod
def request_to_eba(event: aiocqhttp.Event) -> platform_events.EBAEvent: async def request_to_eba(
event: aiocqhttp.Event,
bot: aiocqhttp.CQHttp | None = None,
) -> platform_events.EBAEvent:
request_type = getattr(event, 'request_type', getattr(event, 'detail_type', '')) request_type = getattr(event, 'request_type', getattr(event, 'detail_type', ''))
if request_type == 'friend': if request_type == 'friend':
return platform_events.FriendRequestReceivedEvent( return platform_events.FriendRequestReceivedEvent(
@@ -195,7 +300,7 @@ class AiocqhttpEventConverter(abstract_platform_adapter.AbstractEventConverter):
return platform_events.BotInvitedToGroupEvent( return platform_events.BotInvitedToGroupEvent(
type='bot.invited_to_group', type='bot.invited_to_group',
adapter_name='aiocqhttp', adapter_name='aiocqhttp',
group=AiocqhttpEventConverter.group_from_event(event), group=await AiocqhttpEventConverter.group_from_event(event, bot),
inviter=AiocqhttpEventConverter.user(getattr(event, 'user_id', '')), inviter=AiocqhttpEventConverter.user(getattr(event, 'user_id', '')),
request_id=getattr(event, 'flag', ''), request_id=getattr(event, 'flag', ''),
timestamp=float(getattr(event, 'time', 0) or 0), timestamp=float(getattr(event, 'time', 0) or 0),
@@ -204,13 +309,28 @@ class AiocqhttpEventConverter(abstract_platform_adapter.AbstractEventConverter):
return AiocqhttpEventConverter.platform_specific(event, f'request.{request_type}') return AiocqhttpEventConverter.platform_specific(event, f'request.{request_type}')
@staticmethod @staticmethod
def user_from_sender(event: aiocqhttp.Event) -> platform_entities.User: async def user_from_sender(
event: aiocqhttp.Event,
bot: aiocqhttp.CQHttp | None = None,
) -> platform_entities.User:
sender = getattr(event, 'sender', {}) or {} sender = getattr(event, 'sender', {}) or {}
nickname = sender.get('card') or sender.get('nickname') or '' user_id = sender.get('user_id', getattr(event, 'user_id', ''))
has_sender_display_name = bool(_get_field(sender, 'card') or _get_field(sender, 'nickname'))
nickname = _get_group_member_name(sender)
remark = sender.get('remark')
if (
getattr(event, 'message_type', getattr(event, 'detail_type', 'private')) == 'group'
and user_id
and (not has_sender_display_name or not remark)
):
member_info = await _get_group_member_info(getattr(event, 'group_id', ''), user_id, bot)
remark = _get_field(member_info, 'card') or _get_field(member_info, 'remark') or None
if not has_sender_display_name:
nickname = _get_group_member_name(member_info) or nickname
return platform_entities.User( return platform_entities.User(
id=sender.get('user_id', getattr(event, 'user_id', '')), id=user_id,
nickname=nickname, nickname=nickname,
remark=sender.get('remark'), remark=remark,
) )
@staticmethod @staticmethod
@@ -220,10 +340,19 @@ class AiocqhttpEventConverter(abstract_platform_adapter.AbstractEventConverter):
return platform_entities.User(id=user_id, nickname=nickname) return platform_entities.User(id=user_id, nickname=nickname)
@staticmethod @staticmethod
def group_from_event(event: aiocqhttp.Event) -> platform_entities.UserGroup: async def group_from_event(
event: aiocqhttp.Event,
bot: aiocqhttp.CQHttp | None = None,
) -> platform_entities.UserGroup:
group_id = getattr(event, 'group_id', '')
group_name = getattr(event, 'group_name', '') or ''
if group_id and not group_name:
group_name = await _get_group_name(group_id, bot)
if group_id and not group_name:
group_name = _get_group_name_placeholder(group_id)
return platform_entities.UserGroup( return platform_entities.UserGroup(
id=getattr(event, 'group_id', ''), id=group_id,
name=getattr(event, 'group_name', '') or '', name=group_name,
member_count=getattr(event, 'member_count', None), member_count=getattr(event, 'member_count', None),
) )
@@ -101,11 +101,13 @@ class WecomCSAdapter(WecomCSAPIMixin, abstract_platform_adapter.AbstractPlatform
if target_type not in ('person', 'private'): if target_type not in ('person', 'private'):
raise NotSupportedError(f'send_message:{target_type}') raise NotSupportedError(f'send_message:{target_type}')
external_userid, open_kfid = parse_private_chat_id(target_id) external_userid, open_kfid = parse_private_chat_id(target_id, self.bot_account_id)
content_list = await WecomCSMessageConverter.yiri2target(message, self.bot) content_list = await WecomCSMessageConverter.yiri2target(message, self.bot)
raw_results = [] raw_results = []
for content in content_list: for content in content_list:
raw_results.append(await self._send_content(open_kfid, external_userid, self._make_outbound_msgid(), content)) raw_results.append(
await self._send_content(open_kfid, external_userid, self._make_outbound_msgid(), content)
)
return platform_events.MessageResult(raw={'results': raw_results}) return platform_events.MessageResult(raw={'results': raw_results})
async def reply_message( async def reply_message(
@@ -17,7 +17,9 @@ class WecomCSEventConverter(abstract_platform_adapter.AbstractEventConverter):
return getattr(event, 'source_platform_object', None) return getattr(event, 'source_platform_object', None)
@staticmethod @staticmethod
async def target2legacy(event: WecomCSEvent, bot: WecomCSClient | None = None) -> platform_events.FriendMessage | None: async def target2legacy(
event: WecomCSEvent, bot: WecomCSClient | None = None
) -> platform_events.FriendMessage | None:
eba_event = await WecomCSEventConverter.target2yiri(event, bot) eba_event = await WecomCSEventConverter.target2yiri(event, bot)
if hasattr(eba_event, 'to_legacy_event'): if hasattr(eba_event, 'to_legacy_event'):
return eba_event.to_legacy_event() return eba_event.to_legacy_event()
@@ -30,7 +32,9 @@ class WecomCSEventConverter(abstract_platform_adapter.AbstractEventConverter):
return WecomCSEventConverter.platform_specific(event, f'wecomcs.{event.type or "unknown"}') return WecomCSEventConverter.platform_specific(event, f'wecomcs.{event.type or "unknown"}')
@staticmethod @staticmethod
async def message_to_eba(event: WecomCSEvent, bot: WecomCSClient | None = None) -> platform_events.MessageReceivedEvent: async def message_to_eba(
event: WecomCSEvent, bot: WecomCSClient | None = None
) -> platform_events.MessageReceivedEvent:
message_chain = await WecomCSMessageConverter.target2yiri(event) message_chain = await WecomCSMessageConverter.target2yiri(event)
sender = await WecomCSEventConverter.user_from_event(event, bot) sender = await WecomCSEventConverter.user_from_event(event, bot)
return platform_events.MessageReceivedEvent( return platform_events.MessageReceivedEvent(
@@ -12,8 +12,29 @@ def make_private_chat_id(user_id: str | int | None, open_kfid: str | int | None)
return f'{user}|{kfid}' return f'{user}|{kfid}'
def parse_private_chat_id(chat_id: str | int) -> tuple[str, str]: def _strip_legacy_user_prefix(user_id: str) -> str:
user_id, sep, open_kfid = str(chat_id).partition('|') if user_id.startswith('u'):
if not user_id or not sep or not open_kfid: return user_id[1:]
raise ValueError('WeComCS target_id must be formatted as "external_userid|open_kfid"') return user_id
def _looks_like_open_kfid(value: str) -> bool:
return value.startswith(('kf', 'wk', 'open_kfid'))
def parse_private_chat_id(chat_id: str | int, default_open_kfid: str | int | None = None) -> tuple[str, str]:
raw = str(chat_id)
left, sep, right = raw.partition('|')
if sep:
if _looks_like_open_kfid(left) or right.startswith('u'):
open_kfid, user_id = left, right
else:
user_id, open_kfid = left, right
else:
user_id = raw
open_kfid = str(default_open_kfid or '')
user_id = _strip_legacy_user_prefix(str(user_id or ''))
open_kfid = str(open_kfid or '')
if not user_id or not open_kfid:
raise ValueError('WeComCS target_id must include external_userid and open_kfid')
return user_id, open_kfid return user_id, open_kfid
+110 -6
View File
@@ -4,6 +4,7 @@ import asyncio
import traceback import traceback
import datetime import datetime
import json import json
import time
import aiocqhttp import aiocqhttp
import pydantic import pydantic
@@ -16,6 +17,14 @@ from ...utils import image
import langbot_plugin.api.definition.abstract.platform.event_logger as abstract_platform_logger import langbot_plugin.api.definition.abstract.platform.event_logger as abstract_platform_logger
_GROUP_NAME_CACHE_TTL_SECONDS = 3600
_GROUP_NAME_NEGATIVE_CACHE_TTL_SECONDS = 60
_GROUP_NAME_LOOKUP_TIMEOUT_SECONDS = 2
_GROUP_MEMBER_INFO_CACHE_TTL_SECONDS = 86400
_GROUP_MEMBER_INFO_NEGATIVE_CACHE_TTL_SECONDS = 600
_GROUP_MEMBER_INFO_LOOKUP_TIMEOUT_SECONDS = 2
def _normalize_base64_payload(value: str) -> str: def _normalize_base64_payload(value: str) -> str:
if value.startswith('base64://'): if value.startswith('base64://'):
return value.removeprefix('base64://') return value.removeprefix('base64://')
@@ -24,6 +33,21 @@ def _normalize_base64_payload(value: str) -> str:
return value return value
def _get_field(data: dict, key: str, default: str = '') -> str:
value = data.get(key)
if value is None:
return default
return str(value)
def _get_group_member_name(sender: dict) -> str:
return _get_field(sender, 'card') or _get_field(sender, 'nickname') or _get_field(sender, 'user_id')
def _get_group_name_placeholder(group_id: typing.Union[int, str]) -> str:
return f'Group {group_id}'
class AiocqhttpMessageConverter(abstract_platform_adapter.AbstractMessageConverter): class AiocqhttpMessageConverter(abstract_platform_adapter.AbstractMessageConverter):
@staticmethod @staticmethod
async def yiri2target( async def yiri2target(
@@ -335,16 +359,96 @@ class AiocqhttpMessageConverter(abstract_platform_adapter.AbstractMessageConvert
class AiocqhttpEventConverter(abstract_platform_adapter.AbstractEventConverter): class AiocqhttpEventConverter(abstract_platform_adapter.AbstractEventConverter):
def __init__(self):
self._group_name_cache: dict[typing.Union[int, str], tuple[str, float]] = {}
self._group_name_negative_cache: dict[typing.Union[int, str], float] = {}
self._group_member_info_cache: dict[
tuple[typing.Union[int, str], typing.Union[int, str]], tuple[dict, float]
] = {}
self._group_member_info_negative_cache: dict[tuple[typing.Union[int, str], typing.Union[int, str]], float] = {}
@staticmethod @staticmethod
async def yiri2target(event: platform_events.MessageEvent, bot_account_id: int): async def yiri2target(event: platform_events.MessageEvent, bot_account_id: int):
return event.source_platform_object return event.source_platform_object
@staticmethod async def _get_group_name(self, group_id: typing.Union[int, str], bot=None) -> str:
async def target2yiri(event: aiocqhttp.Event, bot=None): now = time.monotonic()
if group_id in self._group_name_cache:
group_name, expires_at = self._group_name_cache[group_id]
if expires_at > now:
return group_name
del self._group_name_cache[group_id]
if group_id in self._group_name_negative_cache:
expires_at = self._group_name_negative_cache[group_id]
if expires_at > now:
return ''
del self._group_name_negative_cache[group_id]
if bot is None:
return ''
try:
group_info = await asyncio.wait_for(
bot.get_group_info(group_id=group_id),
timeout=_GROUP_NAME_LOOKUP_TIMEOUT_SECONDS,
)
except Exception:
self._group_name_negative_cache[group_id] = now + _GROUP_NAME_NEGATIVE_CACHE_TTL_SECONDS
return ''
group_name = _get_field(group_info, 'group_name') if isinstance(group_info, dict) else ''
if group_name:
self._group_name_cache[group_id] = (group_name, now + _GROUP_NAME_CACHE_TTL_SECONDS)
self._group_name_negative_cache.pop(group_id, None)
else:
self._group_name_negative_cache[group_id] = now + _GROUP_NAME_NEGATIVE_CACHE_TTL_SECONDS
return group_name
async def _get_group_member_info(
self,
group_id: typing.Union[int, str],
user_id: typing.Union[int, str],
bot=None,
) -> dict:
now = time.monotonic()
cache_key = (group_id, user_id)
if cache_key in self._group_member_info_cache:
member_info, expires_at = self._group_member_info_cache[cache_key]
if expires_at > now:
return member_info
del self._group_member_info_cache[cache_key]
if cache_key in self._group_member_info_negative_cache:
expires_at = self._group_member_info_negative_cache[cache_key]
if expires_at > now:
return {}
del self._group_member_info_negative_cache[cache_key]
if bot is None:
return {}
try:
member_info = await asyncio.wait_for(
bot.get_group_member_info(group_id=group_id, user_id=user_id),
timeout=_GROUP_MEMBER_INFO_LOOKUP_TIMEOUT_SECONDS,
)
except Exception:
self._group_member_info_negative_cache[cache_key] = now + _GROUP_MEMBER_INFO_NEGATIVE_CACHE_TTL_SECONDS
return {}
if isinstance(member_info, dict) and member_info:
self._group_member_info_cache[cache_key] = (
member_info,
now + _GROUP_MEMBER_INFO_CACHE_TTL_SECONDS,
)
self._group_member_info_negative_cache.pop(cache_key, None)
return member_info
self._group_member_info_negative_cache[cache_key] = now + _GROUP_MEMBER_INFO_NEGATIVE_CACHE_TTL_SECONDS
return {}
async def target2yiri(self, event: aiocqhttp.Event, bot=None):
yiri_chain = await AiocqhttpMessageConverter.target2yiri(event.message, event.message_id, bot) yiri_chain = await AiocqhttpMessageConverter.target2yiri(event.message, event.message_id, bot)
if event.message_type == 'group': if event.message_type == 'group':
permission = 'MEMBER' permission = 'MEMBER'
group_name = await self._get_group_name(event.group_id, bot) or _get_group_name_placeholder(event.group_id)
special_title = _get_field(event.sender, 'title')
if not special_title:
member_info = await self._get_group_member_info(event.group_id, event.sender['user_id'], bot)
special_title = _get_field(member_info, 'title')
if 'role' in event.sender: if 'role' in event.sender:
if event.sender['role'] == 'admin': if event.sender['role'] == 'admin':
@@ -354,14 +458,14 @@ class AiocqhttpEventConverter(abstract_platform_adapter.AbstractEventConverter):
converted_event = platform_events.GroupMessage( converted_event = platform_events.GroupMessage(
sender=platform_entities.GroupMember( sender=platform_entities.GroupMember(
id=event.sender['user_id'], # message_seq 放哪? id=event.sender['user_id'], # message_seq 放哪?
member_name=event.sender['nickname'], member_name=_get_group_member_name(event.sender),
permission=permission, permission=permission,
group=platform_entities.Group( group=platform_entities.Group(
id=event.group_id, id=event.group_id,
name=event.sender['nickname'], name=group_name,
permission=platform_entities.Permission.Member, permission=platform_entities.Permission.Member,
), ),
special_title=event.sender['title'] if 'title' in event.sender else '', special_title=special_title,
), ),
message_chain=yiri_chain, message_chain=yiri_chain,
time=event.time, time=event.time,
@@ -385,7 +489,7 @@ class AiocqhttpAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter)
bot: aiocqhttp.CQHttp = pydantic.Field(exclude=True, default_factory=aiocqhttp.CQHttp) bot: aiocqhttp.CQHttp = pydantic.Field(exclude=True, default_factory=aiocqhttp.CQHttp)
message_converter: AiocqhttpMessageConverter = AiocqhttpMessageConverter() message_converter: AiocqhttpMessageConverter = AiocqhttpMessageConverter()
event_converter: AiocqhttpEventConverter = AiocqhttpEventConverter() event_converter: AiocqhttpEventConverter = pydantic.Field(default_factory=AiocqhttpEventConverter)
on_websocket_connection_event_cache: typing.List[typing.Callable[[aiocqhttp.Event], None]] = [] on_websocket_connection_event_cache: typing.List[typing.Callable[[aiocqhttp.Event], None]] = []
+23 -1
View File
@@ -2,6 +2,7 @@ from __future__ import annotations
import typing import typing
import asyncio import asyncio
import traceback import traceback
import uuid
import datetime import datetime
import pydantic import pydantic
@@ -182,7 +183,28 @@ class WecomCSAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
) )
async def send_message(self, target_type: str, target_id: str, message: platform_message.MessageChain): async def send_message(self, target_type: str, target_id: str, message: platform_message.MessageChain):
pass if target_type != 'person':
raise ValueError('WeCom customer service only supports sending messages to person targets')
open_kfid = self.bot_account_id
external_userid = target_id
if '|' in target_id:
open_kfid, external_userid = target_id.split('|', 1)
if external_userid.startswith('u'):
external_userid = external_userid[1:]
if not open_kfid:
raise ValueError('WeCom customer service open_kfid is required before sending messages')
content_list = await WecomMessageConverter.yiri2target(message, self.bot)
for content in content_list:
msgid = f'langbot_{uuid.uuid4().hex}'
if content['type'] == 'text':
await self.bot.send_text_msg(
open_kfid=open_kfid,
external_userid=external_userid,
msgid=msgid,
content=content['content'],
)
def set_bot_uuid(self, bot_uuid: str): def set_bot_uuid(self, bot_uuid: str):
"""设置 bot UUID(用于生成 webhook URL""" """设置 bot UUID(用于生成 webhook URL"""
+100 -7
View File
@@ -25,7 +25,7 @@ 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 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
from .mcp_stdio import BoxStdioSessionRuntime, MCPServerBoxConfig, MCPSessionErrorPhase # noqa: F401 from .mcp_stdio import BoxStdioSessionRuntime, MCPServerBoxConfig, MCPSessionErrorPhase, _ColdStartRetry # noqa: F401
# Synthesized LLM tools for MCP resources (not from server tools/list). # Synthesized LLM tools for MCP resources (not from server tools/list).
# Dispatched in MCPLoader.invoke_tool; placeholder func on LLMTool is never used. # Dispatched in MCPLoader.invoke_tool; placeholder func on LLMTool is never used.
@@ -185,6 +185,16 @@ class MCPSessionStatus(enum.Enum):
ERROR = 'error' ERROR = 'error'
class _TransportReconnect(Exception):
"""Internal signal: the Box stdio WS transport dropped but the managed
process is still alive. Triggers a lightweight transport reconnect that
reuses the live process, instead of a full process rebuild.
Reconnect attempts are NOT counted toward the fatal retry budget, so a
long-lived session can survive arbitrarily many transient drops.
"""
class RuntimeMCPSession: class RuntimeMCPSession:
"""运行时 MCP 会话""" """运行时 MCP 会话"""
@@ -254,6 +264,16 @@ class RuntimeMCPSession:
self._lifecycle_task = None self._lifecycle_task = None
self._shutdown_event = asyncio.Event() self._shutdown_event = asyncio.Event()
self._ready_event = asyncio.Event() self._ready_event = asyncio.Event()
# Set transiently when a WS transport drop should NOT stop the managed
# process (it will be re-attached on the next initialize()).
self._preserve_managed_process = False
# Log buffer for capturing stderr from Box managed process (maxlen=500 keeps
# recent lines without unbounded memory growth)
import collections as _collections
self._log_buffer: _collections.deque = _collections.deque(maxlen=500)
self._last_stderr_text: str = ''
self._box_stdio_runtime = BoxStdioSessionRuntime(self) self._box_stdio_runtime = BoxStdioSessionRuntime(self)
self.box_config = self._box_stdio_runtime.config self.box_config = self._box_stdio_runtime.config
@@ -399,11 +419,39 @@ class RuntimeMCPSession:
task.cancel() task.cancel()
for task in done: for task in done:
if task is monitor_task and not self._shutdown_event.is_set(): if task is monitor_task and not self._shutdown_event.is_set():
# The monitor completed. This is EITHER the managed
# process actually exiting OR just the WS transport
# dropping while the process stays alive in the Box
# runtime. Re-check the real process state so a
# transient transport drop reconnects (reusing the live
# process) instead of tearing the process down and
# running a full rebuild+backoff cycle.
process_still_running = False
try:
process_still_running = await self._box_stdio_runtime._managed_process_is_running()
except Exception:
process_still_running = False
if process_still_running:
self.ap.logger.info(
f'MCP server {self.server_name}: transport dropped but '
f'managed process is still running; reconnecting transport'
)
self.error_phase = MCPSessionErrorPhase.RELAY_CONNECT
# Preserve the live process across the finally-block
# cleanup: only the WS transport should be torn down.
self._preserve_managed_process = True
raise _TransportReconnect('Box managed process transport dropped; reconnecting')
self.error_phase = MCPSessionErrorPhase.RUNTIME self.error_phase = MCPSessionErrorPhase.RUNTIME
raise Exception('Box managed process exited unexpectedly') raise Exception('Box managed process exited unexpectedly')
else: else:
await self._shutdown_event.wait() await self._shutdown_event.wait()
except _ColdStartRetry:
# Cold-start in progress: set the preserve flag BEFORE the finally
# block runs so it does not stop the live managed process. The outer
# _lifecycle_loop_with_retry will reuse it on the next attempt.
self._preserve_managed_process = True
raise
except Exception as e: except Exception as e:
self.status = MCPSessionStatus.ERROR self.status = MCPSessionStatus.ERROR
self.error_message = str(e) self.error_message = str(e)
@@ -424,14 +472,55 @@ class RuntimeMCPSession:
except Exception as e: except Exception as e:
self.ap.logger.error(f'Error cleaning up MCP session {self.server_name}: {e}\n{traceback.format_exc()}') self.ap.logger.error(f'Error cleaning up MCP session {self.server_name}: {e}\n{traceback.format_exc()}')
finally: finally:
# On a transport-only reconnect the managed process is healthy
# and will be re-attached on the next initialize(); do NOT stop
# it. Any other exit path fully tears the session down.
if getattr(self, '_preserve_managed_process', False):
self._preserve_managed_process = False
else:
await self._cleanup_box_stdio_session() await self._cleanup_box_stdio_session()
async def _lifecycle_loop_with_retry(self): async def _lifecycle_loop_with_retry(self):
"""Wrap _lifecycle_loop with retry and exponential backoff.""" """Wrap _lifecycle_loop with retry and exponential backoff."""
for attempt in range(self._MAX_RETRIES + 1): attempt = 0
while attempt <= self._MAX_RETRIES:
try: try:
await self._lifecycle_loop() await self._lifecycle_loop()
return # Normal shutdown, don't retry return # Normal shutdown, don't retry
except _TransportReconnect as e:
# Transient WS transport drop while the managed process is still
# alive. Reconnect promptly WITHOUT consuming the fatal retry
# budget and WITHOUT stopping the process — initialize() will
# re-attach to the live process. This is what lets a long-lived
# stdio MCP survive repeated brief event-loop stalls / pings.
if self._shutdown_event.is_set():
return
self.ap.logger.info(
f'MCP session {self.server_name}: reconnecting transport ({self._describe_exception(e)})'
)
self.status = MCPSessionStatus.CONNECTING
self.error_message = None
self.error_phase = None
await asyncio.sleep(1)
continue
except _ColdStartRetry as e:
# The managed process is alive but still cold-starting (e.g.
# `npx -y <pkg>` is still installing) and cannot yet answer the
# handshake. Reuse the live process and retry the attach WITHOUT
# consuming the fatal retry budget or stopping the process, so a
# slow cold start is waited out instead of failing. Preserve the
# process across the finally-block cleanup.
if self._shutdown_event.is_set():
return
self._preserve_managed_process = True
self.ap.logger.debug(
f'MCP session {self.server_name}: waiting for cold start ({self._describe_exception(e)})'
)
self.status = MCPSessionStatus.CONNECTING
self.error_message = None
self.error_phase = None
await asyncio.sleep(2)
continue
except Exception as e: except Exception as e:
self.retry_count = attempt + 1 self.retry_count = attempt + 1
if self._shutdown_event.is_set(): if self._shutdown_event.is_set():
@@ -460,6 +549,7 @@ class RuntimeMCPSession:
self.error_message = None self.error_message = None
self.error_phase = None self.error_phase = None
await asyncio.sleep(delay) await asyncio.sleep(delay)
attempt += 1
@staticmethod @staticmethod
def _describe_exception(exc: BaseException) -> str: def _describe_exception(exc: BaseException) -> str:
@@ -927,11 +1017,14 @@ class RuntimeMCPSession:
return self._box_stdio_runtime.uses_box_stdio() return self._box_stdio_runtime.uses_box_stdio()
def _build_box_session_id(self) -> str: def _build_box_session_id(self) -> str:
# Transient test sessions get their own isolated Box session so a # Both live servers and transient config-page tests share ONE Box
# failing/short-lived test can never disturb the shared session that # session ('mcp-shared'). A test therefore reuses the already-running
# hosts live, already-connected MCP servers. # container (and, for an existing server, its live managed process)
if self.is_transient: # instead of paying a full per-test session cold-start + dependency
return f'mcp-test-{self.server_uuid}' # bootstrap. Isolation between a test and the live servers is provided
# at the *process* level: each server/test has its own process_id and a
# test only ever stops its own process_id (see cleanup_session), so it
# never disturbs another server's process or the shared session itself.
return 'mcp-shared' return 'mcp-shared'
def _rewrite_path(self, path: str, host_path: str | None) -> str: def _rewrite_path(self, path: str, host_path: str | None) -> str:
@@ -6,7 +6,7 @@ import os
import shutil import shutil
import shlex import shlex
import threading import threading
from contextlib import suppress from contextlib import suppress, AsyncExitStack
from typing import TYPE_CHECKING, Any from typing import TYPE_CHECKING, Any
import pydantic import pydantic
@@ -74,6 +74,35 @@ class MCPServerBoxConfig(pydantic.BaseModel):
model_config = pydantic.ConfigDict(extra='ignore') model_config = pydantic.ConfigDict(extra='ignore')
_HANDSHAKE_ATTEMPT_TIMEOUT_SEC = 10.0
class _TransferredStack:
"""Adapts an already-populated AsyncExitStack into an async context manager
so ownership of its resources can be transferred into another exit stack.
Entering is a no-op; exiting closes the wrapped stack (and thus the live WS
transport + ClientSession) when the owning session shuts down."""
def __init__(self, stack: AsyncExitStack):
self._stack = stack
async def __aenter__(self):
return self
async def __aexit__(self, exc_type, exc, tb):
await self._stack.aclose()
return False
class _ColdStartRetry(Exception):
"""Signal: the managed process is alive but not yet answering the MCP
handshake because it is still cold-starting (e.g. `npx -y <pkg>` is still
installing). The outer lifecycle retry treats this like a transient
reconnect: it reuses the live process and does not count toward the fatal
retry budget, so a slow cold start is waited out rather than failing.
"""
class BoxStdioSessionRuntime: class BoxStdioSessionRuntime:
"""Encapsulate Box-backed stdio MCP session orchestration.""" """Encapsulate Box-backed stdio MCP session orchestration."""
@@ -173,6 +202,12 @@ class BoxStdioSessionRuntime:
stderr_preview = (result.stderr or '')[:500] stderr_preview = (result.stderr or '')[:500]
raise Exception(f'Dependency install failed (exit code {result.exit_code}): {stderr_preview}') raise Exception(f'Dependency install failed (exit code {result.exit_code}): {stderr_preview}')
# Reuse an already-running managed process instead of rebuilding it.
# The Box runtime keeps the managed process alive across a transient
# WebSocket transport drop, so on a reconnect we only need to re-attach
# the WS below. Rebuilding here would needlessly stop a healthy process
# and re-run the (slow, network-touching) dependency bootstrap.
if not await self._managed_process_is_running():
try: try:
process_workspace = ( process_workspace = (
self._build_workspace(host_path=host_path, workdir=process_cwd, mount_path=process_cwd) self._build_workspace(host_path=host_path, workdir=process_cwd, mount_path=process_cwd)
@@ -192,9 +227,30 @@ class BoxStdioSessionRuntime:
except Exception: except Exception:
self.owner.error_phase = MCPSessionErrorPhase.PROCESS_START self.owner.error_phase = MCPSessionErrorPhase.PROCESS_START
raise raise
else:
self.ap.logger.info(
f'MCP server {self.server_name}: reusing live managed process '
f'process_id={self.process_id} (transport reconnect)'
)
try:
websocket_url = workspace.get_managed_process_websocket_url(self.process_id) websocket_url = workspace.get_managed_process_websocket_url(self.process_id)
# Attach the WS transport + MCP session ONCE, on the owner's exit stack,
# in the same task as the serve loop that follows. websocket_client and
# ClientSession use anyio task groups whose cancel scope is bound to the
# frame/stack that entered them, so they must live on the owner exit
# stack (not a deferred/transferred one) or the streams close the moment
# initialize() returns and the next request fails with "Connection
# closed".
#
# A slow (`npx -y <pkg>`) cold start makes this single attempt fail
# while the process is still alive — the package is still installing and
# cannot answer the handshake. We surface that to the outer retry loop
# as a _ColdStartRetry: it must NOT stop the process (it is healthy and
# will be reused) and must NOT consume the fatal retry budget. The next
# attempt re-attaches to the same live process; once it has finished
# cold start the handshake succeeds and stays healthy.
try:
transport = await self.owner.exit_stack.enter_async_context(websocket_client(websocket_url)) transport = await self.owner.exit_stack.enter_async_context(websocket_client(websocket_url))
read_stream, write_stream = transport read_stream, write_stream = transport
self.owner.session = await self.owner.exit_stack.enter_async_context( self.owner.session = await self.owner.exit_stack.enter_async_context(
@@ -202,12 +258,19 @@ class BoxStdioSessionRuntime:
) )
except Exception: except Exception:
self.owner.error_phase = MCPSessionErrorPhase.RELAY_CONNECT self.owner.error_phase = MCPSessionErrorPhase.RELAY_CONNECT
if not await self._managed_process_has_exited():
# Process is alive but not yet serving (cold start) — reconnect.
raise _ColdStartRetry(f'{self.server_name}: transport not ready during cold start')
raise raise
try: try:
await self.owner.session.initialize() await asyncio.wait_for(self.owner.session.initialize(), timeout=_HANDSHAKE_ATTEMPT_TIMEOUT_SEC)
except Exception: except Exception as exc:
self.owner.error_phase = MCPSessionErrorPhase.MCP_INIT self.owner.error_phase = MCPSessionErrorPhase.MCP_INIT
if not await self._managed_process_has_exited():
raise _ColdStartRetry(
f'{self.server_name}: handshake not ready during cold start ({type(exc).__name__})'
)
raise raise
async def monitor_process_health(self) -> None: async def monitor_process_health(self) -> None:
@@ -234,8 +297,74 @@ class BoxStdioSessionRuntime:
) )
if consecutive_errors >= self.owner._MONITOR_MAX_CONSECUTIVE_ERRORS: if consecutive_errors >= self.owner._MONITOR_MAX_CONSECUTIVE_ERRORS:
return return
# Capture stderr logs from the managed process
if isinstance(info, dict):
stderr_text = info.get('stderr', '') or info.get('stderr_preview', '')
else:
stderr_text = getattr(info, 'stderr', '') or getattr(info, 'stderr_preview', '')
if stderr_text and stderr_text != self.owner._last_stderr_text:
# Find new lines not in the previous snapshot
old_lines = set(self.owner._last_stderr_text.splitlines()) if self.owner._last_stderr_text else set()
new_lines = [l for l in stderr_text.splitlines() if l and l not in old_lines]
self.owner._last_stderr_text = stderr_text
import time as _time
for line in new_lines:
level = (
'error'
if any(k in line.upper() for k in ('ERROR', 'CRITICAL'))
else 'warning'
if 'WARNING' in line.upper()
else 'debug'
if 'DEBUG' in line.upper()
else 'info'
)
self.owner._log_buffer.append({'ts': _time.time(), 'level': level, 'text': line})
await asyncio.sleep(self.owner._MONITOR_POLL_INTERVAL) await asyncio.sleep(self.owner._MONITOR_POLL_INTERVAL)
async def _managed_process_is_running(self) -> bool:
"""Return True if this server's managed process exists and is running.
Used to decide whether initialize() must (re)start the process or can
simply re-attach the WebSocket transport to a process the Box runtime
kept alive across a transient transport drop.
"""
from langbot_plugin.box.models import BoxManagedProcessStatus
workspace = self._build_workspace()
try:
info = await workspace.get_managed_process(self.process_id)
except Exception:
return False
status = info.get('status', '') if isinstance(info, dict) else getattr(info, 'status', '')
return status in (BoxManagedProcessStatus.RUNNING.value, BoxManagedProcessStatus.RUNNING)
async def _managed_process_has_exited(self) -> bool:
"""Return True only if the process is DEFINITIVELY gone (reports EXITED).
Distinct from ``not _managed_process_is_running()``: a process that has
just been spawned may not yet report RUNNING, and a transient query
error is not proof of exit. During the cold-start handshake retry we
must NOT treat 'not yet running' or 'query failed' as a terminal
failure, or we bail out to the outer rebuild path and churn the
process (relay then rejects the early re-attach with HTTP 400). Only a
successful query that reports EXITED stops the retry loop.
"""
from langbot_plugin.box.models import BoxManagedProcessStatus
workspace = self._build_workspace()
try:
info = await workspace.get_managed_process(self.process_id)
except Exception:
# Unknown — treat as 'still coming up', not exited.
return False
status = info.get('status', '') if isinstance(info, dict) else getattr(info, 'status', '')
return status in (BoxManagedProcessStatus.EXITED.value, BoxManagedProcessStatus.EXITED)
async def _stage_host_path_to_shared_workspace(self, host_path: str) -> str: async def _stage_host_path_to_shared_workspace(self, host_path: str) -> str:
source_path = normalize_host_path(host_path) source_path = normalize_host_path(host_path)
if not source_path: if not source_path:
@@ -342,16 +471,20 @@ class BoxStdioSessionRuntime:
workspace = self._build_workspace(host_path=None) workspace = self._build_workspace(host_path=None)
# Transient test sessions own their isolated Box session, so tear the # Transient config-page tests now share the same 'mcp-shared' Box
# whole session down rather than leaking it. This cannot affect live # session as live servers, so we must NOT tear the session down here —
# servers because they live in the separate shared session. # that would kill every other MCP server in the container. A test is
# isolated at the process level: it ran under its own process_id, so we
# stop only that process, exactly like a live server does below. The
# shared session and all other servers' live processes are untouched.
# (Staged per-test workspace files are still cleaned up.)
if getattr(self.owner, 'is_transient', False): if getattr(self.owner, 'is_transient', False):
try: try:
await workspace.cleanup() await workspace.stop_managed_process(self.process_id)
except Exception as exc: except Exception as exc:
self.ap.logger.warning( self.ap.logger.warning(
f'MCP server {self.server_name}: failed to delete transient test session ' f'MCP server {self.server_name}: failed to stop transient test process '
f'{self.owner._build_box_session_id()}: {type(exc).__name__}: {exc}' f'process_id={self.process_id}: {type(exc).__name__}: {exc}'
) )
await self._cleanup_staged_workspace() await self._cleanup_staged_workspace()
return return
+114 -4
View File
@@ -1,6 +1,7 @@
from __future__ import annotations from __future__ import annotations
import typing import typing
import time
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
import langbot_plugin.api.entities.builtin.resource.tool as resource_tool import langbot_plugin.api.entities.builtin.resource.tool as resource_tool
@@ -175,21 +176,130 @@ class ToolManager:
return tools return tools
def _get_query_session_id(self, query: pipeline_query.Query) -> str | None:
launcher_type = getattr(query, 'launcher_type', None)
launcher_id = getattr(query, 'launcher_id', None)
if launcher_type is None or launcher_id is None:
return None
launcher_type_value = launcher_type.value if hasattr(launcher_type, 'value') else launcher_type
return f'{launcher_type_value}_{launcher_id}'
async def _record_tool_call(
self,
*,
name: str,
source: str,
parameters: dict,
query: pipeline_query.Query,
duration_ms: int,
status: str,
result: typing.Any = None,
error_message: str | None = None,
) -> None:
monitoring_service = getattr(self.ap, 'monitoring_service', None)
if not monitoring_service:
return
variables = getattr(query, 'variables', {}) or {}
message_id = variables.get('_monitoring_message_id') if isinstance(variables, dict) else None
bot_name = variables.get('_monitoring_bot_name') if isinstance(variables, dict) else None
pipeline_name = variables.get('_monitoring_pipeline_name') if isinstance(variables, dict) else None
try:
await monitoring_service.record_tool_call(
tool_name=name,
tool_source=source,
duration=duration_ms,
status=status,
bot_id=getattr(query, 'bot_uuid', None),
bot_name=bot_name,
pipeline_name=pipeline_name,
session_id=self._get_query_session_id(query),
message_id=message_id,
arguments=parameters,
result=result,
error_message=error_message,
)
except Exception as e:
self.ap.logger.warning(f'Failed to record tool call: {e}')
async def _invoke_tool_with_monitoring(
self,
*,
source: str,
name: str,
parameters: dict,
query: pipeline_query.Query,
invoke: typing.Callable[[], typing.Awaitable[typing.Any]],
) -> typing.Any:
start_time = time.perf_counter()
try:
result = await invoke()
except Exception as e:
duration_ms = int((time.perf_counter() - start_time) * 1000)
await self._record_tool_call(
name=name,
source=source,
parameters=parameters,
query=query,
duration_ms=duration_ms,
status='error',
error_message=str(e),
)
raise
duration_ms = int((time.perf_counter() - start_time) * 1000)
await self._record_tool_call(
name=name,
source=source,
parameters=parameters,
query=query,
duration_ms=duration_ms,
status='success',
result=result,
)
return result
async def execute_func_call(self, name: str, parameters: dict, query: pipeline_query.Query) -> typing.Any: async def execute_func_call(self, name: str, parameters: dict, query: pipeline_query.Query) -> typing.Any:
from langbot.pkg.telemetry import features as telemetry_features from langbot.pkg.telemetry import features as telemetry_features
if await self.native_tool_loader.has_tool(name): if await self.native_tool_loader.has_tool(name):
telemetry_features.increment(query, 'tool_calls', 'native') telemetry_features.increment(query, 'tool_calls', 'native')
return await self.native_tool_loader.invoke_tool(name, parameters, query) return await self._invoke_tool_with_monitoring(
source='native',
name=name,
parameters=parameters,
query=query,
invoke=lambda: self.native_tool_loader.invoke_tool(name, parameters, query),
)
if await self.plugin_tool_loader.has_tool(name): if await self.plugin_tool_loader.has_tool(name):
telemetry_features.increment(query, 'tool_calls', 'plugin') telemetry_features.increment(query, 'tool_calls', 'plugin')
return await self.plugin_tool_loader.invoke_tool(name, parameters, query) return await self._invoke_tool_with_monitoring(
source='plugin',
name=name,
parameters=parameters,
query=query,
invoke=lambda: self.plugin_tool_loader.invoke_tool(name, parameters, query),
)
if await self.mcp_tool_loader.has_tool(name): if await self.mcp_tool_loader.has_tool(name):
telemetry_features.increment(query, 'tool_calls', 'mcp') telemetry_features.increment(query, 'tool_calls', 'mcp')
return await self.mcp_tool_loader.invoke_tool(name, parameters, query) return await self._invoke_tool_with_monitoring(
source='mcp',
name=name,
parameters=parameters,
query=query,
invoke=lambda: self.mcp_tool_loader.invoke_tool(name, parameters, query),
)
if await self.skill_tool_loader.has_tool(name): if await self.skill_tool_loader.has_tool(name):
telemetry_features.increment(query, 'tool_calls', 'skill') telemetry_features.increment(query, 'tool_calls', 'skill')
return await self.skill_tool_loader.invoke_tool(name, parameters, query) return await self._invoke_tool_with_monitoring(
source='skill',
name=name,
parameters=parameters,
query=query,
invoke=lambda: self.skill_tool_loader.invoke_tool(name, parameters, query),
)
raise ToolNotFoundError(name) raise ToolNotFoundError(name)
async def shutdown(self): async def shutdown(self):
+1
View File
@@ -81,6 +81,7 @@ def fake_monitoring_app():
) )
app.monitoring_service.get_messages = AsyncMock(return_value=([{'id': 'msg-1', 'content': 'test'}], 100)) app.monitoring_service.get_messages = AsyncMock(return_value=([{'id': 'msg-1', 'content': 'test'}], 100))
app.monitoring_service.get_llm_calls = AsyncMock(return_value=([{'id': 'llm-1'}], 50)) app.monitoring_service.get_llm_calls = AsyncMock(return_value=([{'id': 'llm-1'}], 50))
app.monitoring_service.get_tool_calls = AsyncMock(return_value=([{'id': 'tool-1'}], 5))
app.monitoring_service.get_embedding_calls = AsyncMock(return_value=([{'id': 'emb-1'}], 10)) app.monitoring_service.get_embedding_calls = AsyncMock(return_value=([{'id': 'emb-1'}], 10))
app.monitoring_service.get_sessions = AsyncMock(return_value=([{'session_id': 'sess-1'}], 20)) app.monitoring_service.get_sessions = AsyncMock(return_value=([{'session_id': 'sess-1'}], 20))
app.monitoring_service.get_errors = AsyncMock(return_value=([{'id': 'err-1'}], 2)) app.monitoring_service.get_errors = AsyncMock(return_value=([{'id': 'err-1'}], 2))
@@ -280,6 +280,25 @@ class TestMCPServiceCreateMCPServer:
assert server_uuid is not None assert server_uuid is not None
assert len(server_uuid) == 36 # UUID format assert len(server_uuid) == 36 # UUID format
async def test_create_mcp_server_duplicate_name_raises(self):
"""Rejects duplicate MCP server names."""
# Setup
ap = SimpleNamespace()
ap.persistence_mgr = SimpleNamespace()
ap.instance_config = SimpleNamespace()
ap.instance_config.data = {'system': {'limitation': {'max_extensions': -1}}}
ap.tool_mgr = None
existing_server = _create_mock_mcp_server(name='Existing Server')
ap.persistence_mgr.execute_async = AsyncMock(return_value=_create_mock_result(first_item=existing_server))
ap.persistence_mgr.serialize_model = Mock(return_value={})
service = MCPService(ap)
# Execute & Verify
with pytest.raises(ValueError, match='MCP server already exists: Existing Server'):
await service.create_mcp_server({'name': 'Existing Server'})
async def test_create_mcp_server_loads_server(self): async def test_create_mcp_server_loads_server(self):
"""Loads server into tool_mgr when enabled.""" """Loads server into tool_mgr when enabled."""
# Setup # Setup
@@ -301,7 +320,7 @@ class TestMCPServiceCreateMCPServer:
nonlocal call_count nonlocal call_count
call_count += 1 call_count += 1
if call_count == 1: if call_count == 1:
return _create_mock_result([]) # Empty list for limit check return _create_mock_result([]) # Empty result for duplicate-name check
elif call_count == 2: elif call_count == 2:
return Mock() # Insert return Mock() # Insert
return _create_mock_result(first_item=server_entity) # Select created return _create_mock_result(first_item=server_entity) # Select created
@@ -0,0 +1,76 @@
from __future__ import annotations
import sys
import types
from importlib import import_module
from types import SimpleNamespace
from unittest.mock import AsyncMock
import pytest
import quart
core_app_module = types.ModuleType('langbot.pkg.core.app')
core_app_module.Application = object
sys.modules.setdefault('langbot.pkg.core.app', core_app_module)
pytestmark = pytest.mark.asyncio
async def _create_test_client(mcp_service: SimpleNamespace):
app = quart.Quart(__name__)
user_service = SimpleNamespace(
verify_jwt_token=AsyncMock(return_value='test@example.com'),
get_user_by_email=AsyncMock(return_value=SimpleNamespace(user='test@example.com')),
)
ap = SimpleNamespace(mcp_service=mcp_service, user_service=user_service)
MCPRouterGroup = import_module('langbot.pkg.api.http.controller.groups.resources.mcp').MCPRouterGroup
group = MCPRouterGroup(ap, app)
await group.initialize()
return app.test_client()
async def test_mcp_server_route_accepts_encoded_slash_name():
mcp_service = SimpleNamespace(
get_mcp_server_by_name=AsyncMock(
return_value={
'uuid': 'test-uuid',
'name': 'pab1it0/prometheus',
'enable': True,
'mode': 'stdio',
'extra_args': {},
}
)
)
client = await _create_test_client(mcp_service)
response = await client.get(
'/api/v1/mcp/servers/pab1it0%2Fprometheus',
headers={'Authorization': 'Bearer test-token'},
)
assert response.status_code == 200
mcp_service.get_mcp_server_by_name.assert_awaited_once_with('pab1it0/prometheus')
payload = await response.get_json()
assert payload['data']['server']['name'] == 'pab1it0/prometheus'
async def test_mcp_resource_route_accepts_encoded_slash_name():
mcp_service = SimpleNamespace(
get_mcp_server_by_name=AsyncMock(),
get_mcp_server_resources=AsyncMock(return_value=[]),
get_mcp_server_resource_templates=AsyncMock(return_value=[]),
get_runtime_info=AsyncMock(return_value={'resource_capabilities': {'subscribe': False}}),
)
client = await _create_test_client(mcp_service)
response = await client.get(
'/api/v1/mcp/servers/pab1it0%2Fprometheus/resources',
headers={'Authorization': 'Bearer test-token'},
)
assert response.status_code == 200
mcp_service.get_mcp_server_by_name.assert_not_awaited()
mcp_service.get_mcp_server_resources.assert_awaited_once_with('pab1it0/prometheus')
payload = await response.get_json()
assert payload['data']['resource_capabilities'] == {'subscribe': False}
@@ -283,8 +283,51 @@ async def test_aiocqhttp_event_converter_maps_private_and_group_messages():
assert isinstance(group_event.message_chain[1], platform_message.At) assert isinstance(group_event.message_chain[1], platform_message.At)
def test_aiocqhttp_event_converter_maps_notice_and_request_events(): @pytest.mark.asyncio
deleted = AiocqhttpEventConverter.notice_to_eba( async def test_aiocqhttp_event_converter_enriches_group_message_metadata():
class Bot:
group_info_calls = 0
member_info_calls = 0
async def get_group_info(self, group_id):
self.group_info_calls += 1
return {'group_id': group_id, 'group_name': 'Test Group'}
async def get_group_member_info(self, group_id, user_id):
self.member_info_calls += 1
return {'group_id': group_id, 'user_id': user_id, 'card': 'Group Card', 'nickname': 'QQ Nickname'}
group = onebot_event(
{
'post_type': 'message',
'message_type': 'group',
'sub_type': 'normal',
'time': 1710000000,
'self_id': 999,
'message_id': 12,
'group_id': 20002,
'user_id': 10002,
'message': [{'type': 'text', 'data': {'text': 'hello'}}],
'raw_message': 'hello',
'sender': {'user_id': 10002, 'nickname': '', 'card': '', 'role': 'member'},
}
)
bot = Bot()
first = await AiocqhttpEventConverter.target2yiri(group, bot)
second = await AiocqhttpEventConverter.target2yiri(group, bot)
assert first.group.name == 'Test Group'
assert first.sender.nickname == 'Group Card'
assert first.sender.remark == 'Group Card'
assert second.group.name == 'Test Group'
assert bot.group_info_calls == 1
assert bot.member_info_calls == 1
@pytest.mark.asyncio
async def test_aiocqhttp_event_converter_maps_notice_and_request_events():
deleted = await AiocqhttpEventConverter.notice_to_eba(
onebot_event( onebot_event(
{ {
'post_type': 'notice', 'post_type': 'notice',
@@ -301,7 +344,7 @@ def test_aiocqhttp_event_converter_maps_notice_and_request_events():
assert isinstance(deleted, platform_events.MessageDeletedEvent) assert isinstance(deleted, platform_events.MessageDeletedEvent)
assert deleted.message_id == 33 assert deleted.message_id == 33
joined = AiocqhttpEventConverter.notice_to_eba( joined = await AiocqhttpEventConverter.notice_to_eba(
onebot_event( onebot_event(
{ {
'post_type': 'notice', 'post_type': 'notice',
@@ -319,7 +362,7 @@ def test_aiocqhttp_event_converter_maps_notice_and_request_events():
assert isinstance(joined, platform_events.MemberJoinedEvent) assert isinstance(joined, platform_events.MemberJoinedEvent)
assert joined.join_type == 'invite' assert joined.join_type == 'invite'
bot_muted = AiocqhttpEventConverter.notice_to_eba( bot_muted = await AiocqhttpEventConverter.notice_to_eba(
onebot_event( onebot_event(
{ {
'post_type': 'notice', 'post_type': 'notice',
@@ -338,7 +381,7 @@ def test_aiocqhttp_event_converter_maps_notice_and_request_events():
assert isinstance(bot_muted, platform_events.BotMutedEvent) assert isinstance(bot_muted, platform_events.BotMutedEvent)
assert bot_muted.duration == 60 assert bot_muted.duration == 60
friend_request = AiocqhttpEventConverter.request_to_eba( friend_request = await AiocqhttpEventConverter.request_to_eba(
onebot_event( onebot_event(
{ {
'post_type': 'request', 'post_type': 'request',
@@ -354,7 +397,7 @@ def test_aiocqhttp_event_converter_maps_notice_and_request_events():
assert isinstance(friend_request, platform_events.FriendRequestReceivedEvent) assert isinstance(friend_request, platform_events.FriendRequestReceivedEvent)
assert friend_request.request_id == 'flag-1' assert friend_request.request_id == 'flag-1'
group_invite = AiocqhttpEventConverter.request_to_eba( group_invite = await AiocqhttpEventConverter.request_to_eba(
onebot_event( onebot_event(
{ {
'post_type': 'request', 'post_type': 'request',
@@ -371,7 +414,7 @@ def test_aiocqhttp_event_converter_maps_notice_and_request_events():
assert isinstance(group_invite, platform_events.BotInvitedToGroupEvent) assert isinstance(group_invite, platform_events.BotInvitedToGroupEvent)
assert group_invite.request_id == 'group-flag' assert group_invite.request_id == 'group-flag'
member_left = AiocqhttpEventConverter.notice_to_eba( member_left = await AiocqhttpEventConverter.notice_to_eba(
onebot_event( onebot_event(
{ {
'post_type': 'notice', 'post_type': 'notice',
@@ -389,7 +432,7 @@ def test_aiocqhttp_event_converter_maps_notice_and_request_events():
assert isinstance(member_left, platform_events.MemberLeftEvent) assert isinstance(member_left, platform_events.MemberLeftEvent)
assert member_left.is_kicked is True assert member_left.is_kicked is True
friend_added = AiocqhttpEventConverter.notice_to_eba( friend_added = await AiocqhttpEventConverter.notice_to_eba(
onebot_event( onebot_event(
{ {
'post_type': 'notice', 'post_type': 'notice',
@@ -1,7 +1,12 @@
import pytest import pytest
import aiocqhttp
import langbot_plugin.api.entities.builtin.platform.message as platform_message import langbot_plugin.api.entities.builtin.platform.message as platform_message
from langbot.pkg.platform.sources.aiocqhttp import AiocqhttpAdapter, AiocqhttpMessageConverter from langbot.pkg.platform.sources.aiocqhttp import (
AiocqhttpAdapter,
AiocqhttpEventConverter,
AiocqhttpMessageConverter,
)
async def _convert_single(component: platform_message.MessageComponent): async def _convert_single(component: platform_message.MessageComponent):
@@ -103,3 +108,431 @@ async def test_forward_image_base64_payload_is_normalized():
'type': 'image', 'type': 'image',
'data': {'file': 'base64://raw-forward-image'}, 'data': {'file': 'base64://raw-forward-image'},
} }
@pytest.mark.asyncio
async def test_group_message_member_name_prefers_group_card():
event = aiocqhttp.Event(
{
'post_type': 'message',
'message_type': 'group',
'message_id': 1000,
'message': '',
'time': 1776491725,
'group_id': 2000,
'sender': {
'user_id': 3000,
'nickname': 'QQ Nickname',
'card': 'Group Card',
'role': 'member',
'title': 'Special Title',
},
}
)
class Bot:
async def get_group_info(self, group_id):
assert group_id == 2000
return {'group_id': group_id, 'group_name': 'Test Group'}
converted = await AiocqhttpEventConverter().target2yiri(event, Bot())
assert converted.sender.member_name == 'Group Card'
assert converted.sender.group.id == 2000
assert converted.sender.group.name == 'Test Group'
assert converted.sender.special_title == 'Special Title'
@pytest.mark.asyncio
async def test_group_message_member_name_falls_back_to_nickname():
event = aiocqhttp.Event(
{
'post_type': 'message',
'message_type': 'group',
'message_id': 1000,
'message': '',
'time': 1776491725,
'group_id': 2000,
'sender': {
'user_id': 3000,
'nickname': 'QQ Nickname',
'card': '',
'role': 'member',
},
}
)
converted = await AiocqhttpEventConverter().target2yiri(event)
assert converted.sender.member_name == 'QQ Nickname'
@pytest.mark.asyncio
async def test_group_message_special_title_uses_group_member_info_when_sender_title_is_empty():
event = aiocqhttp.Event(
{
'post_type': 'message',
'message_type': 'group',
'message_id': 1000,
'message': '',
'time': 1776491725,
'group_id': 2000,
'sender': {
'user_id': 3000,
'nickname': 'QQ Nickname',
'card': 'Group Card',
'role': 'member',
'title': '',
},
}
)
class Bot:
async def get_group_info(self, group_id):
return {'group_id': group_id, 'group_name': 'Test Group'}
async def get_group_member_info(self, group_id, user_id):
assert group_id == 2000
assert user_id == 3000
return {'group_id': group_id, 'user_id': user_id, 'title': 'Member Title'}
converted = await AiocqhttpEventConverter().target2yiri(event, Bot())
assert converted.sender.special_title == 'Member Title'
@pytest.mark.asyncio
async def test_group_message_special_title_does_not_lookup_when_sender_title_exists():
event = aiocqhttp.Event(
{
'post_type': 'message',
'message_type': 'group',
'message_id': 1000,
'message': '',
'time': 1776491725,
'group_id': 2000,
'sender': {
'user_id': 3000,
'nickname': 'QQ Nickname',
'card': 'Group Card',
'role': 'member',
'title': 'Event Title',
},
}
)
class Bot:
async def get_group_info(self, group_id):
return {'group_id': group_id, 'group_name': 'Test Group'}
async def get_group_member_info(self, group_id, user_id):
raise AssertionError('get_group_member_info should not be called')
converted = await AiocqhttpEventConverter().target2yiri(event, Bot())
assert converted.sender.special_title == 'Event Title'
@pytest.mark.asyncio
async def test_group_message_special_title_member_info_failure_is_cached(monkeypatch):
event = aiocqhttp.Event(
{
'post_type': 'message',
'message_type': 'group',
'message_id': 1000,
'message': '',
'time': 1776491725,
'group_id': 2000,
'sender': {
'user_id': 3000,
'nickname': 'QQ Nickname',
'card': 'Group Card',
'role': 'member',
'title': '',
},
}
)
now = 1000.0
class Bot:
member_info_calls = 0
async def get_group_info(self, group_id):
return {'group_id': group_id, 'group_name': 'Test Group'}
async def get_group_member_info(self, group_id, user_id):
self.member_info_calls += 1
raise RuntimeError('api unavailable')
monkeypatch.setattr('langbot.pkg.platform.sources.aiocqhttp.time.monotonic', lambda: now)
bot = Bot()
converter = AiocqhttpEventConverter()
first = await converter.target2yiri(event, bot)
second = await converter.target2yiri(event, bot)
assert first.sender.special_title == ''
assert second.sender.special_title == ''
assert bot.member_info_calls == 1
@pytest.mark.asyncio
async def test_group_message_special_title_member_info_cache_expires(monkeypatch):
event = aiocqhttp.Event(
{
'post_type': 'message',
'message_type': 'group',
'message_id': 1000,
'message': '',
'time': 1776491725,
'group_id': 2000,
'sender': {
'user_id': 3000,
'nickname': 'QQ Nickname',
'card': 'Group Card',
'role': 'member',
'title': '',
},
}
)
now = 1000.0
class Bot:
member_info_calls = 0
async def get_group_info(self, group_id):
return {'group_id': group_id, 'group_name': 'Test Group'}
async def get_group_member_info(self, group_id, user_id):
self.member_info_calls += 1
return {
'group_id': group_id,
'user_id': user_id,
'title': f'Member Title {self.member_info_calls}',
}
monkeypatch.setattr('langbot.pkg.platform.sources.aiocqhttp.time.monotonic', lambda: now)
bot = Bot()
converter = AiocqhttpEventConverter()
first = await converter.target2yiri(event, bot)
now = 87401.0
second = await converter.target2yiri(event, bot)
assert first.sender.special_title == 'Member Title 1'
assert second.sender.special_title == 'Member Title 2'
assert bot.member_info_calls == 2
@pytest.mark.asyncio
async def test_group_message_special_title_retries_after_negative_cache_expires(monkeypatch):
event = aiocqhttp.Event(
{
'post_type': 'message',
'message_type': 'group',
'message_id': 1000,
'message': '',
'time': 1776491725,
'group_id': 2000,
'sender': {
'user_id': 3000,
'nickname': 'QQ Nickname',
'card': 'Group Card',
'role': 'member',
'title': '',
},
}
)
now = 1000.0
class Bot:
member_info_calls = 0
async def get_group_info(self, group_id):
return {'group_id': group_id, 'group_name': 'Test Group'}
async def get_group_member_info(self, group_id, user_id):
self.member_info_calls += 1
if self.member_info_calls == 1:
raise RuntimeError('api unavailable')
return {'group_id': group_id, 'user_id': user_id, 'title': 'Recovered Title'}
monkeypatch.setattr('langbot.pkg.platform.sources.aiocqhttp.time.monotonic', lambda: now)
bot = Bot()
converter = AiocqhttpEventConverter()
failed = await converter.target2yiri(event, bot)
now = 1601.0
recovered = await converter.target2yiri(event, bot)
assert failed.sender.special_title == ''
assert recovered.sender.special_title == 'Recovered Title'
assert bot.member_info_calls == 2
@pytest.mark.asyncio
async def test_group_message_group_name_is_cached(monkeypatch):
event = aiocqhttp.Event(
{
'post_type': 'message',
'message_type': 'group',
'message_id': 1000,
'message': '',
'time': 1776491725,
'group_id': 2000,
'sender': {
'user_id': 3000,
'nickname': 'QQ Nickname',
'card': 'Group Card',
'role': 'member',
},
}
)
class Bot:
calls = 0
async def get_group_info(self, group_id):
self.calls += 1
assert group_id == 2000
return {'group_id': group_id, 'group_name': 'Cached Group'}
monotonic = 1000.0
monkeypatch.setattr('langbot.pkg.platform.sources.aiocqhttp.time.monotonic', lambda: monotonic)
bot = Bot()
converter = AiocqhttpEventConverter()
first = await converter.target2yiri(event, bot)
second = await converter.target2yiri(event, bot)
assert first.sender.group.name == 'Cached Group'
assert second.sender.group.name == 'Cached Group'
assert bot.calls == 1
@pytest.mark.asyncio
async def test_group_message_group_name_cache_expires(monkeypatch):
event = aiocqhttp.Event(
{
'post_type': 'message',
'message_type': 'group',
'message_id': 1000,
'message': '',
'time': 1776491725,
'group_id': 2000,
'sender': {
'user_id': 3000,
'nickname': 'QQ Nickname',
'card': 'Group Card',
'role': 'member',
},
}
)
now = 1000.0
class Bot:
calls = 0
async def get_group_info(self, group_id):
self.calls += 1
return {'group_id': group_id, 'group_name': f'Group Name {self.calls}'}
monkeypatch.setattr('langbot.pkg.platform.sources.aiocqhttp.time.monotonic', lambda: now)
bot = Bot()
converter = AiocqhttpEventConverter()
first = await converter.target2yiri(event, bot)
now = 4601.0
second = await converter.target2yiri(event, bot)
assert first.sender.group.name == 'Group Name 1'
assert second.sender.group.name == 'Group Name 2'
assert bot.calls == 2
@pytest.mark.asyncio
async def test_group_message_group_name_uses_placeholder_when_lookup_fails(monkeypatch):
event = aiocqhttp.Event(
{
'post_type': 'message',
'message_type': 'group',
'message_id': 1000,
'message': '',
'time': 1776491725,
'group_id': 2000,
'sender': {
'user_id': 3000,
'nickname': 'QQ Nickname',
'card': 'Group Card',
'role': 'member',
},
}
)
now = 1000.0
class Bot:
calls = 0
async def get_group_info(self, group_id):
self.calls += 1
raise RuntimeError('api unavailable')
monkeypatch.setattr('langbot.pkg.platform.sources.aiocqhttp.time.monotonic', lambda: now)
bot = Bot()
converter = AiocqhttpEventConverter()
converted = await converter.target2yiri(event, bot)
cached_failure = await converter.target2yiri(event, bot)
assert converted.sender.group.name == 'Group 2000'
assert cached_failure.sender.group.name == 'Group 2000'
assert bot.calls == 1
@pytest.mark.asyncio
async def test_group_message_group_name_retries_after_negative_cache_expires(monkeypatch):
event = aiocqhttp.Event(
{
'post_type': 'message',
'message_type': 'group',
'message_id': 1000,
'message': '',
'time': 1776491725,
'group_id': 2000,
'sender': {
'user_id': 3000,
'nickname': 'QQ Nickname',
'card': 'Group Card',
'role': 'member',
},
}
)
now = 1000.0
class Bot:
calls = 0
async def get_group_info(self, group_id):
self.calls += 1
if self.calls == 1:
raise RuntimeError('api unavailable')
return {'group_id': group_id, 'group_name': 'Recovered Group'}
monkeypatch.setattr('langbot.pkg.platform.sources.aiocqhttp.time.monotonic', lambda: now)
bot = Bot()
converter = AiocqhttpEventConverter()
failed = await converter.target2yiri(event, bot)
now = 1061.0
recovered = await converter.target2yiri(event, bot)
assert failed.sender.group.name == 'Group 2000'
assert recovered.sender.group.name == 'Recovered Group'
assert bot.calls == 2
@@ -0,0 +1,91 @@
from types import SimpleNamespace
from unittest.mock import AsyncMock
import pytest
import langbot_plugin.api.definition.abstract.platform.event_logger as abstract_platform_logger
import langbot_plugin.api.entities.builtin.platform.message as platform_message
from langbot.pkg.platform.sources.wecomcs import WecomCSAdapter
class DummyLogger(abstract_platform_logger.AbstractEventLogger):
async def info(self, *args, **kwargs):
pass
async def debug(self, *args, **kwargs):
pass
async def warning(self, *args, **kwargs):
pass
async def error(self, *args, **kwargs):
pass
def make_adapter():
return WecomCSAdapter(
config={
'corpid': 'corp-id',
'secret': 'secret',
'token': 'token',
'EncodingAESKey': 'encoding-key',
},
logger=DummyLogger(),
)
@pytest.mark.asyncio
async def test_send_message_sends_text_to_customer_service_user():
adapter = make_adapter()
adapter.bot_account_id = 'kf-test'
adapter.bot = SimpleNamespace(send_text_msg=AsyncMock())
message = platform_message.MessageChain([platform_message.Plain(text='hello')])
await adapter.send_message('person', 'uexternal-user', message)
adapter.bot.send_text_msg.assert_awaited_once()
kwargs = adapter.bot.send_text_msg.await_args.kwargs
assert kwargs['open_kfid'] == 'kf-test'
assert kwargs['external_userid'] == 'external-user'
assert kwargs['content'] == 'hello'
assert kwargs['msgid'].startswith('langbot_')
@pytest.mark.asyncio
async def test_send_message_allows_explicit_open_kfid_in_target_id():
adapter = make_adapter()
adapter.bot = SimpleNamespace(send_text_msg=AsyncMock())
message = platform_message.MessageChain([platform_message.Plain(text='hello')])
await adapter.send_message('person', 'kf-explicit|uexternal-user', message)
kwargs = adapter.bot.send_text_msg.await_args.kwargs
assert kwargs['open_kfid'] == 'kf-explicit'
assert kwargs['external_userid'] == 'external-user'
@pytest.mark.asyncio
async def test_send_message_requires_open_kfid():
adapter = make_adapter()
adapter.bot = SimpleNamespace(send_text_msg=AsyncMock())
message = platform_message.MessageChain([platform_message.Plain(text='hello')])
with pytest.raises(ValueError, match='open_kfid is required'):
await adapter.send_message('person', 'uexternal-user', message)
adapter.bot.send_text_msg.assert_not_called()
@pytest.mark.asyncio
async def test_send_message_rejects_group_targets():
adapter = make_adapter()
adapter.bot_account_id = 'kf-test'
adapter.bot = SimpleNamespace(send_text_msg=AsyncMock())
message = platform_message.MessageChain([platform_message.Plain(text='hello')])
with pytest.raises(ValueError, match='only supports sending messages to person'):
await adapter.send_message('group', 'group-id', message)
adapter.bot.send_text_msg.assert_not_called()
@@ -243,6 +243,15 @@ async def test_wecomcs_send_reply_and_platform_api_use_underlying_client():
assert (open_kfid, external_userid, content) == ('kf-1', 'external-1', 'hello') assert (open_kfid, external_userid, content) == ('kf-1', 'external-1', 'hello')
assert msgid.startswith('lb-') assert msgid.startswith('lb-')
await adapter.send_message('person', 'kf-explicit|uexternal-legacy', message)
open_kfid, external_userid, _, content = adapter.bot.send_text_msg.await_args.args
assert (open_kfid, external_userid, content) == ('kf-explicit', 'external-legacy', 'hello')
adapter.bot_account_id = 'kf-default'
await adapter.send_message('person', 'uexternal-default', message)
open_kfid, external_userid, _, content = adapter.bot.send_text_msg.await_args.args
assert (open_kfid, external_userid, content) == ('kf-default', 'external-default', 'hello')
image = platform_message.MessageChain([platform_message.Image(base64='data:image/png;base64,AAAA')]) image = platform_message.MessageChain([platform_message.Image(base64='data:image/png;base64,AAAA')])
await adapter.send_message('person', 'external-1|kf-1', image) await adapter.send_message('person', 'external-1|kf-1', image)
adapter.bot.send_image_msg.assert_awaited_once() adapter.bot.send_image_msg.assert_awaited_once()
@@ -639,10 +639,13 @@ class TestGetRuntimeInfoDict:
assert info['box_session_id'] == 'mcp-shared' assert info['box_session_id'] == 'mcp-shared'
assert info['box_enabled'] is True assert info['box_enabled'] is True
def test_transient_test_session_is_isolated_from_shared(self, mcp_module): def test_transient_test_shares_session_but_isolated_by_process(self, mcp_module):
"""A transient test session (config-page "test", no persisted UUID) """A transient config-page "test" now shares the same 'mcp-shared' Box
must NOT share the live "mcp-shared" Box session. Regression: a failing session as live servers (so a test reuses the running container / live
test churned the shared session and tore down healthy live servers.""" process instead of a cold per-test session bootstrap). Isolation is at
the PROCESS level: the test runs under its own process_id and only ever
stops that process_id, so it cannot disturb another server's live
process or the shared session itself."""
ap = _make_ap() ap = _make_ap()
ap.box_service.available = True ap.box_service.available = True
transient = _make_session( transient = _make_session(
@@ -670,10 +673,12 @@ class TestGetRuntimeInfoDict:
) )
assert transient.is_transient is True assert transient.is_transient is True
assert live.is_transient is False assert live.is_transient is False
# Isolated session id for the test, shared for the live server. # Both share ONE Box session ...
assert transient._build_box_session_id() == 'mcp-test-gen-uuid-123' assert transient._build_box_session_id() == 'mcp-shared'
assert live._build_box_session_id() == 'mcp-shared' assert live._build_box_session_id() == 'mcp-shared'
assert transient._build_box_session_id() != live._build_box_session_id() assert transient._build_box_session_id() == live._build_box_session_id()
# ... but are isolated by distinct process_ids within that session.
assert transient._box_stdio_runtime.process_id != live._box_stdio_runtime.process_id
def test_stdio_session_refuses_when_box_unavailable(self, mcp_module): def test_stdio_session_refuses_when_box_unavailable(self, mcp_module):
"""Policy: when Box is configured but unavailable (disabled in config """Policy: when Box is configured but unavailable (disabled in config
@@ -824,3 +829,129 @@ async def test_init_box_stdio_server_stages_host_path_in_shared_workspace(mcp_mo
assert process_payload['command'] == 'python' assert process_payload['command'] == 'python'
assert process_payload['args'] == ['/workspace/.mcp/u1/workspace/server.py'] assert process_payload['args'] == ['/workspace/.mcp/u1/workspace/server.py']
assert process_payload['cwd'] == '/workspace/.mcp/u1/workspace' assert process_payload['cwd'] == '/workspace/.mcp/u1/workspace'
@pytest.mark.asyncio
async def test_stdio_handshake_raises_coldstart_retry_while_process_alive(mcp_module, tmp_path, monkeypatch):
"""During a slow (npx) cold start the handshake fails while the managed
process is still alive. initialize() must raise _ColdStartRetry (so the
outer lifecycle loop reuses the live process and retries without stopping it
or consuming the fatal budget), NOT a fatal error."""
from contextlib import asynccontextmanager
mcp_stdio_module = sys.modules['langbot.pkg.provider.tools.loaders.mcp_stdio']
class ColdClientSession:
def __init__(self, *_args):
pass
async def __aenter__(self):
return self
async def __aexit__(self, exc_type, exc, tb):
return False
async def initialize(self):
# Process still cold-starting: handshake fails.
raise Exception('Connection closed')
@asynccontextmanager
async def fake_websocket_client(_url: str):
yield ('read-stream', 'write-stream')
monkeypatch.setattr(mcp_stdio_module, 'ClientSession', ColdClientSession)
monkeypatch.setattr(mcp_stdio_module, 'websocket_client', fake_websocket_client)
monkeypatch.setattr(mcp_stdio_module, '_HANDSHAKE_ATTEMPT_TIMEOUT_SEC', 1.0, raising=False)
ap = _make_ap()
ap.box_service.available = True
ap.box_service.create_session = AsyncMock(return_value={})
ap.box_service.start_managed_process = AsyncMock(return_value={})
ap.box_service.get_managed_process_websocket_url = Mock(return_value='ws://box/p')
session = _make_session(
mcp_module,
{
'name': 'slow',
'uuid': 'slow-uuid',
'mode': 'stdio',
'command': 'npx',
'args': ['-y', 'some-mcp'],
},
ap=ap,
)
# Process is NOT exited (still cold-starting) and not yet running for reuse.
async def _not_exited():
return False
session._box_stdio_runtime._managed_process_has_exited = _not_exited
async def _not_running():
return False
session._box_stdio_runtime._managed_process_is_running = _not_running
with pytest.raises(mcp_stdio_module._ColdStartRetry):
await session._init_box_stdio_server()
# Process was started exactly once (the retry will reuse it, not rebuild).
assert ap.box_service.start_managed_process.await_count == 1
await session.exit_stack.aclose()
@pytest.mark.asyncio
async def test_stdio_handshake_raises_fatal_when_process_exited(mcp_module, tmp_path, monkeypatch):
"""If the handshake fails AND the process has definitively exited, that is a
real failure initialize() must NOT swallow it as a cold-start retry."""
from contextlib import asynccontextmanager
mcp_stdio_module = sys.modules['langbot.pkg.provider.tools.loaders.mcp_stdio']
class DeadClientSession:
def __init__(self, *_args):
pass
async def __aenter__(self):
return self
async def __aexit__(self, exc_type, exc, tb):
return False
async def initialize(self):
raise Exception('Connection closed')
@asynccontextmanager
async def fake_websocket_client(_url: str):
yield ('read-stream', 'write-stream')
monkeypatch.setattr(mcp_stdio_module, 'ClientSession', DeadClientSession)
monkeypatch.setattr(mcp_stdio_module, 'websocket_client', fake_websocket_client)
monkeypatch.setattr(mcp_stdio_module, '_HANDSHAKE_ATTEMPT_TIMEOUT_SEC', 1.0, raising=False)
ap = _make_ap()
ap.box_service.available = True
ap.box_service.create_session = AsyncMock(return_value={})
ap.box_service.start_managed_process = AsyncMock(return_value={})
ap.box_service.get_managed_process_websocket_url = Mock(return_value='ws://box/p')
session = _make_session(
mcp_module,
{'name': 'dead', 'uuid': 'dead-uuid', 'mode': 'stdio', 'command': 'npx', 'args': ['-y', 'x']},
ap=ap,
)
async def _exited():
return True
session._box_stdio_runtime._managed_process_has_exited = _exited
async def _not_running():
return False
session._box_stdio_runtime._managed_process_is_running = _not_running
with pytest.raises(Exception) as ei:
await session._init_box_stdio_server()
assert not isinstance(ei.value, mcp_stdio_module._ColdStartRetry)
await session.exit_stack.aclose()
+11 -9
View File
@@ -191,7 +191,8 @@ export default function BotDetailContent({ id }: { id: string }) {
onValueChange={setActiveTab} onValueChange={setActiveTab}
className="flex flex-1 flex-col min-h-0" className="flex flex-1 flex-col min-h-0"
> >
<TabsList className="shrink-0"> <div className="flex shrink-0 items-center gap-1">
<TabsList>
<TabsTrigger value="config" className="gap-1.5"> <TabsTrigger value="config" className="gap-1.5">
<Settings className="size-3.5" /> <Settings className="size-3.5" />
{t('bots.configuration')} {t('bots.configuration')}
@@ -203,14 +204,16 @@ export default function BotDetailContent({ id }: { id: string }) {
<TabsTrigger value="sessions" className="gap-1.5"> <TabsTrigger value="sessions" className="gap-1.5">
<Users className="size-3.5" /> <Users className="size-3.5" />
{t('bots.sessionMonitor.title')} {t('bots.sessionMonitor.title')}
</TabsTrigger>
</TabsList>
{activeTab === 'sessions' && ( {activeTab === 'sessions' && (
<button <button
type="button" type="button"
className="inline-flex items-center justify-center ml-0.5" aria-label={t('bots.sessionMonitor.refresh')}
onPointerDown={(e) => e.stopPropagation()} title={t('bots.sessionMonitor.refresh')}
onClick={(e) => { className="inline-flex h-8 w-8 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:pointer-events-none disabled:opacity-50"
e.stopPropagation(); disabled={isRefreshingSessions}
e.preventDefault(); onClick={() => {
if (isRefreshingSessions) return; if (isRefreshingSessions) return;
setIsRefreshingSessions(true); setIsRefreshingSessions(true);
const minDelay = new Promise((r) => setTimeout(r, 500)); const minDelay = new Promise((r) => setTimeout(r, 500));
@@ -222,14 +225,13 @@ export default function BotDetailContent({ id }: { id: string }) {
> >
<RefreshCw <RefreshCw
className={cn( className={cn(
'size-3 text-muted-foreground hover:text-foreground transition-colors', 'size-3.5',
isRefreshingSessions && 'animate-spin', isRefreshingSessions && 'animate-spin',
)} )}
/> />
</button> </button>
)} )}
</TabsTrigger> </div>
</TabsList>
{/* Tab: Configuration */} {/* Tab: Configuration */}
<TabsContent <TabsContent
@@ -3,6 +3,7 @@ import React, {
useEffect, useEffect,
useRef, useRef,
useCallback, useCallback,
useMemo,
forwardRef, forwardRef,
useImperativeHandle, useImperativeHandle,
} from 'react'; } from 'react';
@@ -15,11 +16,14 @@ import {
Bot, Bot,
Copy, Copy,
Check, Check,
ChevronDown,
ChevronRight,
Workflow, Workflow,
ThumbsUp, ThumbsUp,
ThumbsDown, ThumbsDown,
ShieldCheck, ShieldCheck,
ShieldOff, ShieldOff,
Wrench,
} from 'lucide-react'; } from 'lucide-react';
import { toast } from 'sonner'; import { toast } from 'sonner';
import BotAdminsDialog, { import BotAdminsDialog, {
@@ -76,6 +80,35 @@ interface SessionFeedback {
stream_id?: string | null; stream_id?: string | null;
} }
interface SessionToolCall {
id: string;
timestamp: string;
tool_name: string;
tool_source: string;
duration: number;
status: string;
message_id?: string | null;
arguments?: string | null;
result?: string | null;
error_message?: string | null;
}
type SessionTimelineItem =
| {
id: string;
type: 'message';
timestamp: number;
order: number;
message: SessionMessage;
}
| {
id: string;
type: 'tool';
timestamp: number;
order: number;
toolCall: SessionToolCall;
};
export interface BotSessionMonitorHandle { export interface BotSessionMonitorHandle {
refreshSessions: () => Promise<void>; refreshSessions: () => Promise<void>;
} }
@@ -100,6 +133,10 @@ const BotSessionMonitor = forwardRef<
const [feedbackMap, setFeedbackMap] = useState< const [feedbackMap, setFeedbackMap] = useState<
Record<string, SessionFeedback> Record<string, SessionFeedback>
>({}); >({});
const [toolCalls, setToolCalls] = useState<SessionToolCall[]>([]);
const [expandedToolCallIds, setExpandedToolCallIds] = useState<
Record<string, boolean>
>({});
const messagesContainerRef = useRef<HTMLDivElement>(null); const messagesContainerRef = useRef<HTMLDivElement>(null);
const { admins, reload: reloadAdmins } = useBotAdmins(botId); const { admins, reload: reloadAdmins } = useBotAdmins(botId);
const [adminsDialogOpen, setAdminsDialogOpen] = useState(false); const [adminsDialogOpen, setAdminsDialogOpen] = useState(false);
@@ -189,6 +226,7 @@ const BotSessionMonitor = forwardRef<
const loadMessages = useCallback( const loadMessages = useCallback(
async (sessionId: string) => { async (sessionId: string) => {
setLoadingMessages(true); setLoadingMessages(true);
setExpandedToolCallIds({});
try { try {
const messagesRes = await httpClient.getSessionMessages(sessionId); const messagesRes = await httpClient.getSessionMessages(sessionId);
const sorted = (messagesRes.messages ?? []).sort( const sorted = (messagesRes.messages ?? []).sort(
@@ -197,6 +235,18 @@ const BotSessionMonitor = forwardRef<
); );
setMessages(sorted); setMessages(sorted);
try {
const analysisRes = await httpClient.get<{
tool_calls?: SessionToolCall[];
}>(
`/api/v1/monitoring/sessions/${encodeURIComponent(sessionId)}/analysis`,
);
setToolCalls(analysisRes?.tool_calls ?? []);
} catch (analysisError) {
console.error('Failed to load session tool calls:', analysisError);
setToolCalls([]);
}
// Collect user message IDs for feedback matching // Collect user message IDs for feedback matching
const userMsgIds = new Set( const userMsgIds = new Set(
sorted.filter((m) => !m.role || m.role === 'user').map((m) => m.id), sorted.filter((m) => !m.role || m.role === 'user').map((m) => m.id),
@@ -240,11 +290,14 @@ const BotSessionMonitor = forwardRef<
loadMessages(selectedSessionId); loadMessages(selectedSessionId);
} else { } else {
setMessages([]); setMessages([]);
setToolCalls([]);
setExpandedToolCallIds({});
setFeedbackMap({});
} }
}, [selectedSessionId, loadMessages]); }, [selectedSessionId, loadMessages]);
useEffect(() => { useEffect(() => {
if (messages.length === 0) return; if (messages.length === 0 && toolCalls.length === 0) return;
// Wait for DOM to render the new messages before scrolling // Wait for DOM to render the new messages before scrolling
requestAnimationFrame(() => { requestAnimationFrame(() => {
const container = messagesContainerRef.current; const container = messagesContainerRef.current;
@@ -256,7 +309,7 @@ const BotSessionMonitor = forwardRef<
scrollTarget.scrollTop = scrollTarget.scrollHeight; scrollTarget.scrollTop = scrollTarget.scrollHeight;
} }
}); });
}, [messages]); }, [messages, toolCalls]);
const parseMessageChain = (content: string): MessageChainComponent[] => { const parseMessageChain = (content: string): MessageChainComponent[] => {
try { try {
@@ -431,6 +484,71 @@ const BotSessionMonitor = forwardRef<
return `${diffDays}d`; return `${diffDays}d`;
}; };
const formatDuration = (durationMs: number): string => {
if (!durationMs) return '0ms';
if (durationMs < 1000) return `${durationMs}ms`;
return `${(durationMs / 1000).toFixed(2)}s`;
};
const truncateToolDetail = (value?: string | null): string => {
if (!value) return '';
return value.length > 600 ? `${value.slice(0, 600)}...` : value;
};
const toggleToolCallDetails = (toolCallId: string) => {
setExpandedToolCallIds((previous) => ({
...previous,
[toolCallId]: !previous[toolCallId],
}));
};
const feedbackByMessageId = useMemo(() => {
const map: Record<string, SessionFeedback> = {};
for (let index = 0; index < messages.length; index++) {
const msg = messages[index];
if (isUserMessage(msg)) continue;
for (let previousIndex = index - 1; previousIndex >= 0; previousIndex--) {
const previousMessage = messages[previousIndex];
if (isUserMessage(previousMessage)) {
const feedback = feedbackMap[previousMessage.id];
if (feedback) {
map[msg.id] = feedback;
}
break;
}
}
}
return map;
}, [feedbackMap, messages]);
const timelineItems = useMemo<SessionTimelineItem[]>(() => {
const messageItems: SessionTimelineItem[] = messages.map(
(message, index) => ({
id: `message-${message.id}`,
type: 'message',
timestamp: parseTimestamp(message.timestamp).getTime(),
order: index * 2,
message,
}),
);
const toolItems: SessionTimelineItem[] = toolCalls.map(
(toolCall, index) => ({
id: `tool-${toolCall.id}`,
type: 'tool',
timestamp: parseTimestamp(toolCall.timestamp).getTime(),
order: index * 2 + 1,
toolCall,
}),
);
return [...messageItems, ...toolItems].sort(
(a, b) => a.timestamp - b.timestamp || a.order - b.order,
);
}, [messages, toolCalls]);
const selectedSession = sessions.find( const selectedSession = sessions.find(
(s) => s.session_id === selectedSessionId, (s) => s.session_id === selectedSessionId,
); );
@@ -612,29 +730,162 @@ const BotSessionMonitor = forwardRef<
<div className="text-center text-muted-foreground py-12 text-sm"> <div className="text-center text-muted-foreground py-12 text-sm">
{t('bots.sessionMonitor.loading')} {t('bots.sessionMonitor.loading')}
</div> </div>
) : messages.length === 0 ? ( ) : timelineItems.length === 0 ? (
<div className="text-center text-muted-foreground py-12 text-sm"> <div className="text-center text-muted-foreground py-12 text-sm">
{t('bots.sessionMonitor.noMessages')} {t('bots.sessionMonitor.noMessages')}
</div> </div>
) : ( ) : (
messages.map((msg, msgIndex) => { timelineItems.map((item) => {
if (item.type === 'tool') {
const call = item.toolCall;
const hasToolDetails = Boolean(
call.arguments || call.result || call.error_message,
);
const expandedToolCall = Boolean(
expandedToolCallIds[call.id],
);
const detailsId = `tool-call-details-${call.id}`;
return (
<div key={item.id} className="flex justify-start">
<div className="max-w-2xl rounded-xl rounded-bl-sm border border-border/60 bg-muted/25 px-2.5 py-1.5 text-xs text-muted-foreground">
<button
type="button"
className={cn(
'flex w-full items-center justify-between gap-3 rounded-md text-left outline-none transition-colors',
hasToolDetails &&
'cursor-pointer hover:bg-muted/40 focus-visible:ring-2 focus-visible:ring-ring',
)}
aria-expanded={
hasToolDetails ? expandedToolCall : undefined
}
aria-controls={
hasToolDetails ? detailsId : undefined
}
aria-disabled={!hasToolDetails}
onClick={() =>
hasToolDetails &&
toggleToolCallDetails(call.id)
}
>
<div className="flex min-w-0 flex-wrap items-center gap-1.5">
{hasToolDetails &&
(expandedToolCall ? (
<ChevronDown className="h-3.5 w-3.5 shrink-0 text-muted-foreground/70" />
) : (
<ChevronRight className="h-3.5 w-3.5 shrink-0 text-muted-foreground/70" />
))}
<Wrench className="h-3.5 w-3.5 shrink-0 text-muted-foreground/70" />
<span className="min-w-0 max-w-[18rem] truncate text-[13px] font-medium text-foreground/75">
{call.tool_name}
</span>
<span className="rounded border border-border/50 bg-background/60 px-1.5 py-0.5 text-[10px] leading-none text-muted-foreground">
{call.tool_source}
</span>
<span
className={cn(
'rounded px-1.5 py-0.5 text-[10px] font-medium leading-none',
call.status === 'success'
? 'bg-green-100/70 text-green-700 dark:bg-green-950/60 dark:text-green-300'
: 'bg-red-100/70 text-red-700 dark:bg-red-950/60 dark:text-red-300',
)}
>
{call.status}
</span>
</div>
<span className="shrink-0 text-[11px] tabular-nums text-muted-foreground/80">
{formatDuration(call.duration)}
</span>
</button>
{hasToolDetails && expandedToolCall && (
<div
id={detailsId}
className="mt-2 space-y-1.5"
>
{(call.arguments || call.result) && (
<div className="space-y-1.5">
{call.arguments && (
<div>
<div className="mb-1 text-[11px] font-medium text-muted-foreground">
{t(
'monitoring.toolCalls.arguments',
{
defaultValue: '参数',
},
)}
</div>
<pre className="whitespace-pre-wrap break-words rounded bg-background/80 p-2 font-mono text-[11px] leading-4 text-muted-foreground">
{truncateToolDetail(call.arguments)}
</pre>
</div>
)}
{call.result && (
<div>
<div className="mb-1 text-[11px] font-medium text-muted-foreground">
{t('monitoring.toolCalls.result', {
defaultValue: '结果',
})}
</div>
<pre className="whitespace-pre-wrap break-words rounded bg-background/80 p-2 font-mono text-[11px] leading-4 text-muted-foreground">
{truncateToolDetail(call.result)}
</pre>
</div>
)}
</div>
)}
{call.error_message && (
<div className="whitespace-pre-wrap break-words rounded bg-red-50 p-2 text-[11px] text-red-600 dark:bg-red-950/40 dark:text-red-400">
{call.error_message}
</div>
)}
</div>
)}
<div className="mt-1.5 flex items-center gap-1.5 text-[11px] text-muted-foreground">
<span>
{t('monitoring.toolCalls.title', {
defaultValue: '工具调用',
})}
</span>
<span className="tabular-nums">
{formatTime(call.timestamp)}
</span>
{hasToolDetails && (
<>
<span>·</span>
<span>
{expandedToolCall
? t(
'monitoring.toolCalls.hideDetails',
{
defaultValue: '隐藏详情',
},
)
: t(
'monitoring.toolCalls.showDetails',
{
defaultValue: '查看详情',
},
)}
</span>
</>
)}
</div>
</div>
</div>
);
}
const msg = item.message;
const isUser = isUserMessage(msg); const isUser = isUserMessage(msg);
const isDiscarded = const isDiscarded =
msg.status === 'discarded' || msg.status === 'discarded' ||
msg.pipeline_id === PIPELINE_DISCARD; msg.pipeline_id === PIPELINE_DISCARD;
// For bot replies, find feedback linked to the preceding user message const msgFeedback = feedbackByMessageId[msg.id];
let msgFeedback: SessionFeedback | undefined;
if (!isUser) {
for (let i = msgIndex - 1; i >= 0; i--) {
if (isUserMessage(messages[i])) {
msgFeedback = feedbackMap[messages[i].id];
break;
}
}
}
return ( return (
<div <div
key={msg.id} key={item.id}
className={cn( className={cn(
'flex', 'flex',
isUser ? 'justify-end' : 'justify-start', isUser ? 'justify-end' : 'justify-start',
@@ -105,6 +105,16 @@ function SelectOptionContent({
); );
} }
function hasUsableUuid<T extends { uuid?: string | null }>(
item: T,
): item is T & { uuid: string } {
return typeof item.uuid === 'string' && item.uuid.trim().length > 0;
}
function hasUsableOptionName(option: { name?: string | null }): boolean {
return typeof option.name === 'string' && option.name.trim().length > 0;
}
export default function DynamicFormItemComponent({ export default function DynamicFormItemComponent({
config, config,
field, field,
@@ -142,7 +152,7 @@ export default function DynamicFormItemComponent({
httpClient httpClient
.getProviderLLMModels() .getProviderLLMModels()
.then((resp) => { .then((resp) => {
setLlmModels(resp.models); setLlmModels(resp.models.filter(hasUsableUuid));
}) })
.catch((err) => { .catch((err) => {
toast.error(t('models.getModelListError') + err.msg); toast.error(t('models.getModelListError') + err.msg);
@@ -153,7 +163,7 @@ export default function DynamicFormItemComponent({
httpClient httpClient
.getProviderEmbeddingModels() .getProviderEmbeddingModels()
.then((resp) => { .then((resp) => {
setEmbeddingModels(resp.models); setEmbeddingModels(resp.models.filter(hasUsableUuid));
}) })
.catch((err) => { .catch((err) => {
toast.error(t('embedding.getModelListError') + err.msg); toast.error(t('embedding.getModelListError') + err.msg);
@@ -164,7 +174,7 @@ export default function DynamicFormItemComponent({
httpClient httpClient
.getProviderRerankModels() .getProviderRerankModels()
.then((resp) => { .then((resp) => {
setRerankModels(resp.models); setRerankModels(resp.models.filter(hasUsableUuid));
}) })
.catch((err) => { .catch((err) => {
toast.error('Failed to load rerank models: ' + err.msg); toast.error('Failed to load rerank models: ' + err.msg);
@@ -268,7 +278,7 @@ export default function DynamicFormItemComponent({
httpClient httpClient
.getKnowledgeBases() .getKnowledgeBases()
.then((resp) => { .then((resp) => {
setKnowledgeBases(resp.bases); setKnowledgeBases(resp.bases.filter(hasUsableUuid));
}) })
.catch((err) => { .catch((err) => {
toast.error(t('knowledge.getKnowledgeBaseListError') + err.msg); toast.error(t('knowledge.getKnowledgeBaseListError') + err.msg);
@@ -281,7 +291,7 @@ export default function DynamicFormItemComponent({
httpClient httpClient
.getBots() .getBots()
.then((resp) => { .then((resp) => {
setBots(resp.bots); setBots(resp.bots.filter(hasUsableUuid));
}) })
.catch((err) => { .catch((err) => {
toast.error(t('bots.getBotListError') + err.msg); toast.error(t('bots.getBotListError') + err.msg);
@@ -461,7 +471,7 @@ export default function DynamicFormItemComponent({
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectGroup> <SelectGroup>
{config.options?.map((option) => ( {config.options?.filter(hasUsableOptionName).map((option) => (
<SelectItem <SelectItem
key={option.name} key={option.name}
value={option.name} value={option.name}
@@ -1252,7 +1262,8 @@ export default function DynamicFormItemComponent({
case DynamicFormItemType.KNOWLEDGE_BASE_SELECTOR: case DynamicFormItemType.KNOWLEDGE_BASE_SELECTOR:
// Group KBs by Knowledge Engine name // Group KBs by Knowledge Engine name
const kbsByEngine = knowledgeBases.reduce( const validKnowledgeBases = knowledgeBases.filter(hasUsableUuid);
const kbsByEngine = validKnowledgeBases.reduce(
(acc, kb) => { (acc, kb) => {
const engineName = kb.knowledge_engine?.name const engineName = kb.knowledge_engine?.name
? extractI18nObject(kb.knowledge_engine.name) ? extractI18nObject(kb.knowledge_engine.name)
@@ -1263,7 +1274,7 @@ export default function DynamicFormItemComponent({
acc[engineName].push(kb); acc[engineName].push(kb);
return acc; return acc;
}, },
{} as Record<string, typeof knowledgeBases>, {} as Record<string, typeof validKnowledgeBases>,
); );
return ( return (
@@ -1271,7 +1282,7 @@ export default function DynamicFormItemComponent({
<SelectTrigger className="min-w-0 bg-[#ffffff] dark:bg-[#2a2a2e]"> <SelectTrigger className="min-w-0 bg-[#ffffff] dark:bg-[#2a2a2e]">
{field.value && field.value !== '__none__' ? ( {field.value && field.value !== '__none__' ? (
(() => { (() => {
const selectedKb = knowledgeBases.find( const selectedKb = validKnowledgeBases.find(
(kb) => kb.uuid === field.value, (kb) => kb.uuid === field.value,
); );
return ( return (
@@ -1300,7 +1311,7 @@ export default function DynamicFormItemComponent({
<SelectGroup key={engineName}> <SelectGroup key={engineName}>
<SelectLabel>{engineName}</SelectLabel> <SelectLabel>{engineName}</SelectLabel>
{kbs.map((base) => ( {kbs.map((base) => (
<SelectItem key={base.uuid} value={base.uuid ?? ''}> <SelectItem key={base.uuid} value={base.uuid}>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{base.emoji && ( {base.emoji && (
<span className="text-sm shrink-0">{base.emoji}</span> <span className="text-sm shrink-0">{base.emoji}</span>
@@ -1317,7 +1328,8 @@ export default function DynamicFormItemComponent({
case DynamicFormItemType.KNOWLEDGE_BASE_MULTI_SELECTOR: case DynamicFormItemType.KNOWLEDGE_BASE_MULTI_SELECTOR:
// Group KBs by Knowledge Engine name for multi-selector // Group KBs by Knowledge Engine name for multi-selector
const multiKbsByEngine = knowledgeBases.reduce( const validMultiKnowledgeBases = knowledgeBases.filter(hasUsableUuid);
const multiKbsByEngine = validMultiKnowledgeBases.reduce(
(acc, kb) => { (acc, kb) => {
const engineName = kb.knowledge_engine?.name const engineName = kb.knowledge_engine?.name
? extractI18nObject(kb.knowledge_engine.name) ? extractI18nObject(kb.knowledge_engine.name)
@@ -1328,7 +1340,7 @@ export default function DynamicFormItemComponent({
acc[engineName].push(kb); acc[engineName].push(kb);
return acc; return acc;
}, },
{} as Record<string, typeof knowledgeBases>, {} as Record<string, typeof validMultiKnowledgeBases>,
); );
return ( return (
@@ -1337,7 +1349,7 @@ export default function DynamicFormItemComponent({
{field.value && field.value.length > 0 ? ( {field.value && field.value.length > 0 ? (
<div className="min-w-0 space-y-2"> <div className="min-w-0 space-y-2">
{field.value.map((kbId: string) => { {field.value.map((kbId: string) => {
const currentKb = knowledgeBases.find( const currentKb = validMultiKnowledgeBases.find(
(base) => base.uuid === kbId, (base) => base.uuid === kbId,
); );
if (!currentKb) return null; if (!currentKb) return null;
@@ -1423,15 +1435,13 @@ export default function DynamicFormItemComponent({
{engineName} {engineName}
</div> </div>
{kbs.map((base) => { {kbs.map((base) => {
const isSelected = tempSelectedKBIds.includes( const isSelected = tempSelectedKBIds.includes(base.uuid);
base.uuid ?? '',
);
return ( return (
<div <div
key={base.uuid} key={base.uuid}
className="flex items-center gap-3 rounded-lg border p-3 hover:bg-accent cursor-pointer" className="flex items-center gap-3 rounded-lg border p-3 hover:bg-accent cursor-pointer"
onClick={() => { onClick={() => {
const kbId = base.uuid ?? ''; const kbId = base.uuid;
setTempSelectedKBIds((prev) => setTempSelectedKBIds((prev) =>
prev.includes(kbId) prev.includes(kbId)
? prev.filter((id) => id !== kbId) ? prev.filter((id) => id !== kbId)
@@ -1493,8 +1503,8 @@ export default function DynamicFormItemComponent({
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectGroup> <SelectGroup>
{bots.map((bot) => ( {bots.filter(hasUsableUuid).map((bot) => (
<SelectItem key={bot.uuid} value={bot.uuid ?? ''}> <SelectItem key={bot.uuid} value={bot.uuid}>
{bot.name} {bot.name}
</SelectItem> </SelectItem>
))} ))}
@@ -268,6 +268,48 @@ function saveListExpansionState(state: SidebarListExpansionState) {
// Maximum number of entity sub-items visible before "More" toggle // Maximum number of entity sub-items visible before "More" toggle
const MAX_VISIBLE_ITEMS = 5; const MAX_VISIBLE_ITEMS = 5;
const MCP_REFRESH_POLL_INTERVAL_MS = 1000;
const MCP_REFRESH_TIMEOUT_MS = 60000;
function sleep(ms: number) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
async function waitForMCPRefreshTask(taskId: number) {
const deadline = Date.now() + MCP_REFRESH_TIMEOUT_MS;
while (Date.now() < deadline) {
const task = await httpClient.getAsyncTask(taskId);
if (task.runtime.done) return task;
await sleep(MCP_REFRESH_POLL_INTERVAL_MS);
}
throw new Error(`Timed out waiting for MCP refresh task ${taskId}`);
}
async function refreshEnabledMCPConnections() {
const resp = await httpClient.getMCPServers();
const enabledServers = resp.servers.filter((server) => server.enable);
if (enabledServers.length === 0) return;
const taskResults = await Promise.allSettled(
enabledServers.map((server) => httpClient.testMCPServer(server.name, {})),
);
const taskIds: number[] = [];
for (const result of taskResults) {
if (
result.status === 'fulfilled' &&
typeof result.value.task_id === 'number'
) {
taskIds.push(result.value.task_id);
} else if (result.status === 'rejected') {
console.error('Failed to start MCP refresh task:', result.reason);
}
}
await Promise.allSettled(taskIds.map(waitForMCPRefreshTask));
}
// Sort entity items by updatedAt descending (most recent first), items without updatedAt go last // Sort entity items by updatedAt descending (most recent first), items without updatedAt go last
function sortByRecent(items: SidebarEntityItem[]): SidebarEntityItem[] { function sortByRecent(items: SidebarEntityItem[]): SidebarEntityItem[] {
@@ -356,11 +398,19 @@ function NavItems({
if (extRefreshing) return; if (extRefreshing) return;
setExtRefreshing(true); setExtRefreshing(true);
try { try {
await Promise.all([ const results = await Promise.allSettled([
sidebarData.refreshPlugins(), sidebarData.refreshPlugins(),
sidebarData.refreshMCPServers(),
sidebarData.refreshSkills(), sidebarData.refreshSkills(),
refreshEnabledMCPConnections(),
]); ]);
const mcpRefreshResult = results[2];
if (mcpRefreshResult.status === 'rejected') {
console.error(
'Failed to refresh MCP connections:',
mcpRefreshResult.reason,
);
}
await sidebarData.refreshMCPServers();
} finally { } finally {
setExtRefreshing(false); setExtRefreshing(false);
} }
@@ -157,6 +157,10 @@ export default function MCPDetailContent({ id }: { id: string }) {
navigate(`/home/mcp?id=${encodeURIComponent(serverName)}`); navigate(`/home/mcp?id=${encodeURIComponent(serverName)}`);
} }
const handlePersistedTestComplete = useCallback(async () => {
await refreshMCPServers();
}, [refreshMCPServers]);
function confirmDelete() { function confirmDelete() {
httpClient httpClient
.deleteMCPServer(id) .deleteMCPServer(id)
@@ -364,6 +368,7 @@ export default function MCPDetailContent({ id }: { id: string }) {
onRuntimeInfoChange={(runtimeInfo) => onRuntimeInfoChange={(runtimeInfo) =>
setDetailRuntimeStatus(runtimeInfo?.status ?? null) setDetailRuntimeStatus(runtimeInfo?.status ?? null)
} }
onPersistedTestComplete={handlePersistedTestComplete}
/> />
</div> </div>
</div> </div>
@@ -41,6 +41,7 @@ import {
} from '@/components/ui/card'; } from '@/components/ui/card';
import { httpClient } from '@/app/infra/http/HttpClient'; import { httpClient } from '@/app/infra/http/HttpClient';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import MCPLogs from '@/app/home/mcp/components/mcp-form/MCPLogs';
import MCPReadme from '@/app/home/mcp/components/mcp-form/MCPReadme'; import MCPReadme from '@/app/home/mcp/components/mcp-form/MCPReadme';
import { import {
MCPServerRuntimeInfo, MCPServerRuntimeInfo,
@@ -487,6 +488,7 @@ interface MCPFormProps {
onDirtyChange?: (dirty: boolean) => void; onDirtyChange?: (dirty: boolean) => void;
onTestingChange?: (testing: boolean) => void; onTestingChange?: (testing: boolean) => void;
onRuntimeInfoChange?: (runtimeInfo: MCPServerRuntimeInfo | null) => void; onRuntimeInfoChange?: (runtimeInfo: MCPServerRuntimeInfo | null) => void;
onPersistedTestComplete?: (serverName: string) => void | Promise<void>;
/** Reported when the form cannot be saved because the current mode is /** Reported when the form cannot be saved because the current mode is
* ``stdio`` and the Box sandbox is disabled/unavailable. Parents that * ``stdio`` and the Box sandbox is disabled/unavailable. Parents that
* render the Save button outside this component should disable it. */ * render the Save button outside this component should disable it. */
@@ -511,6 +513,7 @@ const MCPForm = forwardRef<MCPFormHandle, MCPFormProps>(function MCPForm(
onDirtyChange, onDirtyChange,
onTestingChange, onTestingChange,
onRuntimeInfoChange, onRuntimeInfoChange,
onPersistedTestComplete,
onSaveBlockedChange, onSaveBlockedChange,
layout = 'stacked', layout = 'stacked',
sideHeader, sideHeader,
@@ -750,6 +753,8 @@ const MCPForm = forwardRef<MCPFormHandle, MCPFormProps>(function MCPForm(
} }
try { try {
let serverConfig: MCPServer; let serverConfig: MCPServer;
const serverName =
isEditMode && initServerName ? initServerName : value.name;
if (value.mode === 'remote') { if (value.mode === 'remote') {
const headers: Record<string, string> = {}; const headers: Record<string, string> = {};
@@ -758,7 +763,7 @@ const MCPForm = forwardRef<MCPFormHandle, MCPFormProps>(function MCPForm(
}); });
serverConfig = { serverConfig = {
name: value.name, name: serverName,
mode: 'remote', mode: 'remote',
enable: true, enable: true,
extra_args: { extra_args: {
@@ -774,7 +779,7 @@ const MCPForm = forwardRef<MCPFormHandle, MCPFormProps>(function MCPForm(
}); });
serverConfig = { serverConfig = {
name: value.name, name: serverName,
mode: 'stdio', mode: 'stdio',
enable: true, enable: true,
extra_args: { extra_args: {
@@ -818,6 +823,10 @@ const MCPForm = forwardRef<MCPFormHandle, MCPFormProps>(function MCPForm(
// `uvx` with no package (exit 2 / "Connection closed", no detail). // `uvx` with no package (exit 2 / "Connection closed", no detail).
// The form values are kept in sync on every edit and on load, so they // The form values are kept in sync on every edit and on load, so they
// are always current. // are always current.
const serverName =
isEditMode && initServerName ? initServerName : form.getValues('name');
const shouldTestPersistedServer =
isEditMode && !!initServerName && !form.formState.isDirty;
const formExtraArgs = form.getValues('extra_args') ?? []; const formExtraArgs = form.getValues('extra_args') ?? [];
const formStdioArgs = form.getValues('args') ?? []; const formStdioArgs = form.getValues('args') ?? [];
let extraArgsData: MCPServerExtraArgsRemote | MCPServerExtraArgsStdio; let extraArgsData: MCPServerExtraArgsRemote | MCPServerExtraArgsStdio;
@@ -840,13 +849,21 @@ const MCPForm = forwardRef<MCPFormHandle, MCPFormProps>(function MCPForm(
}; };
} }
const { task_id } = await httpClient.testMCPServer('_', { const testTarget = shouldTestPersistedServer ? serverName : '_';
name: form.getValues('name'), const testPayload = shouldTestPersistedServer
? {}
: ({
name: serverName,
mode, mode,
enable: true, enable: true,
extra_args: extraArgsData, extra_args: extraArgsData,
} as MCPServer); } as MCPServer);
const { task_id } = await httpClient.testMCPServer(
testTarget,
testPayload,
);
if (!task_id) { if (!task_id) {
throw new Error(t('mcp.noTaskId')); throw new Error(t('mcp.noTaskId'));
} }
@@ -871,14 +888,18 @@ const MCPForm = forwardRef<MCPFormHandle, MCPFormProps>(function MCPForm(
resource_count: 0, resource_count: 0,
resources: [], resources: [],
}); });
if (shouldTestPersistedServer) {
await onPersistedTestComplete?.(serverName);
}
} else { } else {
if (isEditMode) { if (shouldTestPersistedServer) {
await loadServerForEdit(form.getValues('name')); await loadServerForEdit(serverName);
await onPersistedTestComplete?.(serverName);
} else { } else {
// Create mode has no persisted server to reload tools from. // Transient tests have no persisted server to reload tools from.
// The backend stashes the discovered runtime info (status + // The backend stashes the discovered runtime info (status +
// tools) in the test task's metadata before tearing the // tools) in the task metadata before tearing the transient
// transient session down — surface it so a successful test // session down — surface it so a successful test
// shows the tool list instead of "no tools found". // shows the tool list instead of "no tools found".
const runtimeInfoFromTest = taskResp.task_context?.metadata const runtimeInfoFromTest = taskResp.task_context?.metadata
?.runtime_info as MCPServerRuntimeInfo | undefined; ?.runtime_info as MCPServerRuntimeInfo | undefined;
@@ -1163,11 +1184,14 @@ const MCPForm = forwardRef<MCPFormHandle, MCPFormProps>(function MCPForm(
</Card> </Card>
); );
const persistedServerName =
isEditMode && initServerName ? initServerName : form.getValues('name');
const runtimePanel = ( const runtimePanel = (
<RuntimePanel <RuntimePanel
mcpTesting={mcpTesting} mcpTesting={mcpTesting}
runtimeInfo={runtimeInfo} runtimeInfo={runtimeInfo}
serverName={form.getValues('name')} serverName={persistedServerName}
t={t} t={t}
/> />
); );
@@ -1200,6 +1224,9 @@ const MCPForm = forwardRef<MCPFormHandle, MCPFormProps>(function MCPForm(
<TabsTrigger value="resources" className="flex-none px-4"> <TabsTrigger value="resources" className="flex-none px-4">
{resourcesTabLabel} {resourcesTabLabel}
</TabsTrigger> </TabsTrigger>
<TabsTrigger value="logs" className="flex-none px-4">
{t('mcp.tabLogs')}
</TabsTrigger>
</TabsList> </TabsList>
<TabsContent value="docs" className="mt-4 min-h-0 flex-1 overflow-y-auto"> <TabsContent value="docs" className="mt-4 min-h-0 flex-1 overflow-y-auto">
<MCPReadme readme={readme} /> <MCPReadme readme={readme} />
@@ -1211,7 +1238,7 @@ const MCPForm = forwardRef<MCPFormHandle, MCPFormProps>(function MCPForm(
<RuntimePanel <RuntimePanel
mcpTesting={mcpTesting} mcpTesting={mcpTesting}
runtimeInfo={runtimeInfo} runtimeInfo={runtimeInfo}
serverName={form.getValues('name')} serverName={persistedServerName}
content="tools" content="tools"
t={t} t={t}
/> />
@@ -1223,11 +1250,14 @@ const MCPForm = forwardRef<MCPFormHandle, MCPFormProps>(function MCPForm(
<RuntimePanel <RuntimePanel
mcpTesting={mcpTesting} mcpTesting={mcpTesting}
runtimeInfo={runtimeInfo} runtimeInfo={runtimeInfo}
serverName={form.getValues('name')} serverName={persistedServerName}
content="resources" content="resources"
t={t} t={t}
/> />
</TabsContent> </TabsContent>
<TabsContent value="logs" className="mt-4 min-h-0 flex-1 overflow-y-auto">
{persistedServerName && <MCPLogs serverName={persistedServerName} />}
</TabsContent>
</Tabs> </Tabs>
) : ( ) : (
runtimePanel runtimePanel
@@ -0,0 +1,149 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { httpClient } from '@/app/infra/http/HttpClient';
import { useTranslation } from 'react-i18next';
import { PluginLogEntry } from '@/app/infra/entities/plugin';
import { Button } from '@/components/ui/button';
import { Switch } from '@/components/ui/switch';
import { Label } from '@/components/ui/label';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { RefreshCw } from 'lucide-react';
const LEVEL_OPTIONS = ['ALL', 'DEBUG', 'INFO', 'WARNING', 'ERROR'] as const;
function levelClassName(level: string): string {
switch (level) {
case 'ERROR':
case 'CRITICAL':
return 'text-red-500';
case 'WARNING':
return 'text-amber-500';
case 'DEBUG':
return 'text-gray-400 dark:text-gray-500';
default:
return 'text-gray-700 dark:text-gray-300';
}
}
export default function MCPLogs({ serverName }: { serverName: string }) {
const { t } = useTranslation();
const [logs, setLogs] = useState<PluginLogEntry[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [level, setLevel] = useState<string>('ALL');
const [autoRefresh, setAutoRefresh] = useState(true);
const scrollRef = useRef<HTMLDivElement>(null);
const atBottomRef = useRef(true);
const fetchLogs = useCallback(() => {
setIsLoading(true);
httpClient
.getMcpServerLogs(serverName, 500, level === 'ALL' ? undefined : level)
.then((res) => {
setLogs(res.logs ?? []);
})
.catch(() => {
setLogs([]);
})
.finally(() => {
setIsLoading(false);
});
}, [serverName, level]);
useEffect(() => {
fetchLogs();
}, [fetchLogs]);
// Auto-refresh poll loop.
useEffect(() => {
if (!autoRefresh) return;
const timer = setInterval(fetchLogs, 3000);
return () => clearInterval(timer);
}, [autoRefresh, fetchLogs]);
// Keep view pinned to bottom when the user is already at the bottom.
useEffect(() => {
const el = scrollRef.current;
if (el && atBottomRef.current) {
el.scrollTop = el.scrollHeight;
}
}, [logs]);
function handleScroll() {
const el = scrollRef.current;
if (!el) return;
atBottomRef.current = el.scrollHeight - el.scrollTop - el.clientHeight < 40;
}
return (
<div className="flex h-full flex-col">
<div className="flex shrink-0 flex-wrap items-center gap-2 px-1 pb-3 sm:px-6">
<Select value={level} onValueChange={setLevel}>
<SelectTrigger className="h-8 w-[130px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
{LEVEL_OPTIONS.map((opt) => (
<SelectItem key={opt} value={opt}>
{opt === 'ALL' ? t('mcp.logsLevelAll') : opt}
</SelectItem>
))}
</SelectContent>
</Select>
<Button
type="button"
variant="outline"
size="sm"
className="h-8"
onClick={fetchLogs}
disabled={isLoading}
>
<RefreshCw
className={`mr-1.5 size-3.5 ${isLoading ? 'animate-spin' : ''}`}
/>
{t('mcp.logsRefresh')}
</Button>
<div className="flex items-center gap-2">
<Switch
id="mcp-logs-auto-refresh"
checked={autoRefresh}
onCheckedChange={setAutoRefresh}
/>
<Label
htmlFor="mcp-logs-auto-refresh"
className="cursor-pointer text-sm font-normal text-muted-foreground"
>
{t('mcp.logsAutoRefresh')}
</Label>
</div>
</div>
<div
ref={scrollRef}
onScroll={handleScroll}
className="min-h-0 flex-1 overflow-auto bg-gray-50 px-3 py-3 font-mono text-xs leading-relaxed dark:bg-gray-900/40 sm:px-6"
>
{logs.length === 0 ? (
<div className="py-8 text-center text-sm text-gray-500 dark:text-gray-400">
{t('mcp.logsEmpty')}
</div>
) : (
logs.map((entry, idx) => (
<div
key={`${entry.ts}-${idx}`}
className={`whitespace-pre-wrap break-all ${levelClassName(
entry.level,
)}`}
>
{entry.text}
</div>
))
)}
</div>
</div>
);
}
@@ -0,0 +1,649 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import {
AlertCircle,
Bot,
ChevronDown,
ChevronRight,
Clock,
Cpu,
Hash,
User,
Wrench,
} from 'lucide-react';
import { cn } from '@/lib/utils';
import { MessageContentRenderer } from './MessageContentRenderer';
import {
ConversationTurn,
hasRenderableMessageContent,
} from '../utils/conversationTurns';
import { MonitoringMessage } from '../types/monitoring';
interface ConversationTurnListProps {
turns: ConversationTurn[];
expandedTurnId: string | null;
onToggleTurn: (turnId: string) => void;
}
function shortId(id?: string) {
if (!id) return '-';
if (id.length <= 12) return id;
return `${id.slice(0, 8)}...${id.slice(-4)}`;
}
function formatDuration(ms: number) {
if (!ms) return '0ms';
if (ms < 1000) return `${ms}ms`;
return `${(ms / 1000).toFixed(2)}s`;
}
function truncateDetail(value?: string) {
if (!value) return '';
return value.length > 1200 ? `${value.slice(0, 1200)}...` : value;
}
function roleLabel(message: MonitoringMessage | undefined) {
const role = message?.role?.toLowerCase();
if (role === 'assistant') return 'assistant';
if (role === 'user') return 'user';
return 'message';
}
function statusClass(level: ConversationTurn['level']) {
if (level === 'error') {
return 'border-red-200 bg-red-50 text-red-700 dark:border-red-900 dark:bg-red-950/40 dark:text-red-300';
}
if (level === 'warning') {
return 'border-yellow-200 bg-yellow-50 text-yellow-700 dark:border-yellow-900 dark:bg-yellow-950/40 dark:text-yellow-300';
}
return 'border-green-200 bg-green-50 text-green-700 dark:border-green-900 dark:bg-green-950/40 dark:text-green-300';
}
function Metric({
icon,
label,
tone = 'default',
}: {
icon: React.ReactNode;
label: string;
tone?: 'default' | 'error';
}) {
return (
<span
className={cn(
'inline-flex h-7 items-center gap-1.5 rounded-md border px-2 text-xs font-medium',
tone === 'error'
? 'border-red-200 bg-red-50 text-red-700 dark:border-red-900 dark:bg-red-950/40 dark:text-red-300'
: 'border-border bg-background text-muted-foreground',
)}
>
{icon}
{label}
</span>
);
}
function MetaItem({ label, value }: { label: string; value?: string }) {
return (
<div className="min-w-0 rounded-md bg-background px-3 py-2">
<div className="text-xs text-muted-foreground">{label}</div>
<div className="truncate text-sm font-medium text-foreground">
{value || '-'}
</div>
</div>
);
}
function MessageLane({
label,
icon,
content,
empty,
maxLines,
}: {
label: string;
icon: React.ReactNode;
content?: string;
empty: string;
maxLines: number;
}) {
return (
<div className="grid grid-cols-[5.25rem_minmax(0,1fr)] items-start gap-3 text-sm sm:grid-cols-[6rem_minmax(0,1fr)]">
<div className="flex h-7 items-center gap-1.5 text-xs font-medium text-muted-foreground">
{icon}
<span>{label}</span>
</div>
<div className="min-w-0 rounded-md bg-muted/45 px-3 py-2 text-foreground">
{content && hasRenderableMessageContent(content) ? (
<MessageContentRenderer content={content} maxLines={maxLines} />
) : (
<span className="italic text-muted-foreground">{empty}</span>
)}
</div>
</div>
);
}
function ExpandedMessage({
message,
label,
}: {
message: MonitoringMessage;
label: string;
}) {
return (
<div className="border-t border-border/70 py-3 first:border-t-0 first:pt-0 last:pb-0">
<div className="mb-2 flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
<span className="rounded-md bg-muted px-2 py-1 font-medium text-foreground">
{label}
</span>
<span>{message.timestamp.toLocaleString()}</span>
<span className="font-mono">ID: {shortId(message.id)}</span>
</div>
<div className="text-sm leading-6 text-foreground">
<MessageContentRenderer content={message.messageContent} maxLines={4} />
</div>
</div>
);
}
export function ConversationTurnList({
turns,
expandedTurnId,
onToggleTurn,
}: ConversationTurnListProps) {
const { t } = useTranslation();
const [expandedToolCallIds, setExpandedToolCallIds] = React.useState<
Record<string, boolean>
>({});
const toggleToolCallDetails = (toolCallKey: string) => {
setExpandedToolCallIds((previous) => ({
...previous,
[toolCallKey]: !previous[toolCallKey],
}));
};
return (
<div className="space-y-4">
<div className="flex items-center justify-between text-sm text-muted-foreground">
<span className="font-medium text-foreground">
{t('monitoring.messageList.turns', {
defaultValue: '{{count}} 轮对话',
count: turns.length,
})}
</span>
</div>
{turns.map((turn) => {
const expanded = expandedTurnId === turn.id;
const firstAssistant = turn.assistantMessages[0];
const assistantOverflow = Math.max(
turn.assistantMessages.length - 1,
0,
);
return (
<div
key={turn.id}
className={cn(
'overflow-hidden rounded-xl border bg-card transition-colors',
turn.level === 'error' && 'border-red-200 dark:border-red-900',
)}
>
<div
role="button"
tabIndex={0}
className="cursor-pointer p-3 outline-none transition-colors hover:bg-accent/60 focus-visible:ring-2 focus-visible:ring-ring sm:p-5"
onClick={() => onToggleTurn(turn.id)}
onKeyDown={(event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
onToggleTurn(turn.id);
}
}}
>
<div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
<div className="min-w-0 flex-1">
<div className="mb-2 flex min-w-0 items-center gap-2">
{expanded ? (
<ChevronDown className="h-5 w-5 shrink-0 text-muted-foreground" />
) : (
<ChevronRight className="h-5 w-5 shrink-0 text-muted-foreground" />
)}
<span className="truncate font-mono text-xs text-muted-foreground">
Turn: {shortId(turn.id)}
</span>
</div>
<div className="mb-3 flex min-w-0 flex-wrap items-center gap-2">
<span className="truncate text-sm font-medium text-foreground">
{turn.botName}
</span>
<span className="text-muted-foreground"></span>
<span className="truncate text-sm text-muted-foreground">
{turn.pipelineName}
</span>
{turn.runnerName && (
<>
<span className="text-muted-foreground"></span>
<span className="truncate text-sm text-muted-foreground">
{turn.runnerName}
</span>
</>
)}
</div>
<div className="space-y-2">
<MessageLane
label={t('monitoring.messageList.userMessage', {
defaultValue: '用户',
})}
icon={<User className="h-3.5 w-3.5" />}
content={turn.userMessage?.messageContent}
empty={t('monitoring.messageList.noUserMessage', {
defaultValue: '未记录用户输入',
})}
maxLines={2}
/>
<MessageLane
label={
assistantOverflow > 0
? t('monitoring.messageList.assistantMessageCount', {
defaultValue: '助手 +{{count}}',
count: assistantOverflow,
})
: t('monitoring.messageList.assistantMessage', {
defaultValue: '助手',
})
}
icon={<Bot className="h-3.5 w-3.5" />}
content={firstAssistant?.messageContent}
empty={t('monitoring.messageList.noAssistantMessage', {
defaultValue: '未记录助手回复',
})}
maxLines={2}
/>
</div>
</div>
<div className="flex shrink-0 flex-col gap-2 lg:items-end">
<div className="text-xs text-muted-foreground">
{turn.lastActivityAt.toLocaleString()}
</div>
<div
className={cn(
'inline-flex h-7 items-center rounded-md border px-2 text-xs font-medium',
statusClass(turn.level),
)}
>
{turn.level}
</div>
<div className="flex flex-wrap gap-2 lg:justify-end">
<Metric
icon={<Cpu className="h-3.5 w-3.5" />}
label={`${turn.llmCalls.length} LLM`}
/>
{turn.toolCalls.length > 0 && (
<Metric
icon={<Wrench className="h-3.5 w-3.5" />}
label={`${turn.toolCalls.length} tools`}
/>
)}
<Metric
icon={<Hash className="h-3.5 w-3.5" />}
label={`${turn.totalTokens.toLocaleString()} tokens`}
/>
<Metric
icon={<Clock className="h-3.5 w-3.5" />}
label={formatDuration(turn.totalDuration)}
/>
{turn.errors.length > 0 && (
<Metric
icon={<AlertCircle className="h-3.5 w-3.5" />}
label={`${turn.errors.length} errors`}
tone="error"
/>
)}
</div>
</div>
</div>
</div>
{expanded && (
<div className="border-t bg-muted/40 p-3 sm:p-5">
<div className="space-y-5 border-l-2 border-border pl-4 sm:pl-6">
<div className="grid grid-cols-2 gap-2 lg:grid-cols-5">
<MetaItem
label={t('monitoring.messageList.platform', {
defaultValue: '平台',
})}
value={turn.platform}
/>
<MetaItem
label={t('monitoring.messageList.user', {
defaultValue: '用户',
})}
value={turn.userName || turn.userId}
/>
<MetaItem
label={t('monitoring.messageList.runner', {
defaultValue: '执行器',
})}
value={turn.runnerName}
/>
<MetaItem
label={t('monitoring.sessions.sessionId', {
defaultValue: '会话 ID',
})}
value={turn.sessionId}
/>
<MetaItem
label={t('monitoring.messageList.messageCount', {
defaultValue: '消息数',
})}
value={String(turn.messages.length)}
/>
</div>
<section>
<h4 className="mb-3 flex items-center gap-2 text-sm font-semibold text-foreground">
<Bot className="h-4 w-4" />
{t('monitoring.messageList.conversationTrace', {
defaultValue: '消息链路',
})}
</h4>
<div className="rounded-lg bg-background px-3 py-3">
{turn.messages.map((message) => (
<ExpandedMessage
key={message.id}
message={message}
label={t(
`monitoring.messageList.roles.${roleLabel(message)}`,
{
defaultValue:
roleLabel(message) === 'assistant'
? '助手'
: roleLabel(message) === 'user'
? '用户'
: '消息',
},
)}
/>
))}
</div>
</section>
<section>
<h4 className="mb-3 flex items-center gap-2 text-sm font-semibold text-foreground">
<Cpu className="h-4 w-4" />
{t('monitoring.llmCalls.title', {
defaultValue: 'LLM 调用',
})}{' '}
({turn.llmCalls.length})
</h4>
<div className="grid grid-cols-3 gap-2">
<MetaItem
label={t('monitoring.llmCalls.totalTokens', {
defaultValue: '总 Token',
})}
value={turn.totalTokens.toLocaleString()}
/>
<MetaItem
label={t('monitoring.llmCalls.inputTokens', {
defaultValue: '输入 Token',
})}
value={turn.inputTokens.toLocaleString()}
/>
<MetaItem
label={t('monitoring.llmCalls.duration', {
defaultValue: '耗时',
})}
value={formatDuration(turn.totalDuration)}
/>
</div>
<div className="mt-3 rounded-lg bg-background px-3 py-3">
{turn.llmCalls.length > 0 ? (
turn.llmCalls.map((call, index) => (
<div
key={call.id}
className="border-t border-border/70 py-3 first:border-t-0 first:pt-0 last:pb-0"
>
<div className="mb-2 flex flex-wrap items-center justify-between gap-2">
<div className="flex min-w-0 flex-wrap items-center gap-2">
<span className="text-sm font-medium text-foreground">
#{index + 1} {call.modelName}
</span>
<span
className={cn(
'rounded-md px-2 py-1 text-xs font-medium',
call.status === 'success'
? 'bg-green-100 text-green-700 dark:bg-green-950 dark:text-green-300'
: 'bg-red-100 text-red-700 dark:bg-red-950 dark:text-red-300',
)}
>
{call.status}
</span>
</div>
<span className="text-xs text-muted-foreground">
{formatDuration(call.duration)}
</span>
</div>
<div className="flex flex-wrap gap-x-8 gap-y-1 text-xs text-muted-foreground">
<span>In: {call.tokens.input}</span>
<span>Out: {call.tokens.output}</span>
<span>Total: {call.tokens.total}</span>
<span className="font-mono">
ID: {shortId(call.id)}
</span>
</div>
{call.errorMessage && (
<div className="mt-2 whitespace-pre-wrap break-words text-xs text-red-600 dark:text-red-400">
{call.errorMessage}
</div>
)}
</div>
))
) : (
<div className="py-4 text-center text-sm text-muted-foreground">
{t('monitoring.messageList.noLlmCalls', {
defaultValue: '未记录模型调用',
})}
</div>
)}
</div>
</section>
<section>
<h4 className="mb-3 flex items-center gap-2 text-sm font-semibold text-foreground">
<Wrench className="h-4 w-4" />
{t('monitoring.toolCalls.title', {
defaultValue: '工具调用',
})}{' '}
({turn.toolCalls.length})
</h4>
<div className="grid grid-cols-2 gap-2 lg:grid-cols-3">
<MetaItem
label={t('monitoring.toolCalls.totalCalls', {
defaultValue: '调用次数',
})}
value={String(turn.toolCalls.length)}
/>
<MetaItem
label={t('monitoring.toolCalls.duration', {
defaultValue: '工具耗时',
})}
value={formatDuration(turn.totalToolDuration)}
/>
<MetaItem
label={t('monitoring.toolCalls.errorCalls', {
defaultValue: '失败次数',
})}
value={String(
turn.toolCalls.filter(
(call) => call.status === 'error',
).length,
)}
/>
</div>
<div className="mt-3 rounded-lg bg-background px-3 py-3">
{turn.toolCalls.length > 0 ? (
turn.toolCalls.map((call, index) => {
const toolCallKey = `${turn.id}:${call.id}`;
const hasToolDetails = Boolean(
call.arguments || call.result || call.errorMessage,
);
const expandedToolCall = Boolean(
expandedToolCallIds[toolCallKey],
);
const detailsId = `monitoring-tool-call-details-${call.id}`;
return (
<div
key={call.id}
className="border-t border-border/70 py-2 first:border-t-0 first:pt-0 last:pb-0"
>
<button
type="button"
className={cn(
'flex w-full items-start justify-between gap-3 rounded-md px-2 py-2 text-left outline-none transition-colors',
hasToolDetails &&
'cursor-pointer hover:bg-muted/60 focus-visible:ring-2 focus-visible:ring-ring',
)}
aria-expanded={
hasToolDetails ? expandedToolCall : undefined
}
aria-controls={
hasToolDetails ? detailsId : undefined
}
aria-disabled={!hasToolDetails}
onClick={() =>
hasToolDetails &&
toggleToolCallDetails(toolCallKey)
}
>
<div className="flex min-w-0 flex-wrap items-center gap-2">
{hasToolDetails &&
(expandedToolCall ? (
<ChevronDown className="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground" />
) : (
<ChevronRight className="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground" />
))}
<span className="min-w-0 truncate text-sm font-medium text-foreground">
#{index + 1} {call.toolName}
</span>
<span className="rounded-md bg-muted px-2 py-1 text-xs font-medium text-muted-foreground">
{call.toolSource}
</span>
<span
className={cn(
'rounded-md px-2 py-1 text-xs font-medium',
call.status === 'success'
? 'bg-green-100 text-green-700 dark:bg-green-950 dark:text-green-300'
: 'bg-red-100 text-red-700 dark:bg-red-950 dark:text-red-300',
)}
>
{call.status}
</span>
<span className="font-mono text-xs text-muted-foreground">
ID: {shortId(call.id)}
</span>
</div>
<span className="shrink-0 text-xs text-muted-foreground">
{formatDuration(call.duration)}
</span>
</button>
{hasToolDetails && expandedToolCall && (
<div
id={detailsId}
className="mt-1 grid gap-2 px-2 pb-2 text-xs lg:grid-cols-2"
>
{call.arguments && (
<div className="min-w-0 rounded-md bg-muted/50 p-2">
<div className="mb-1 font-medium text-foreground">
{t('monitoring.toolCalls.arguments', {
defaultValue: '参数',
})}
</div>
<pre className="whitespace-pre-wrap break-words font-mono text-muted-foreground">
{truncateDetail(call.arguments)}
</pre>
</div>
)}
{call.result && (
<div className="min-w-0 rounded-md bg-muted/50 p-2">
<div className="mb-1 font-medium text-foreground">
{t('monitoring.toolCalls.result', {
defaultValue: '结果',
})}
</div>
<pre className="whitespace-pre-wrap break-words font-mono text-muted-foreground">
{truncateDetail(call.result)}
</pre>
</div>
)}
{call.errorMessage && (
<div className="min-w-0 whitespace-pre-wrap break-words rounded-md bg-red-50 p-2 text-red-600 dark:bg-red-950/40 dark:text-red-400 lg:col-span-2">
{call.errorMessage}
</div>
)}
</div>
)}
</div>
);
})
) : (
<div className="py-4 text-center text-sm text-muted-foreground">
{t('monitoring.toolCalls.noToolCalls', {
defaultValue: '未记录工具调用',
})}
</div>
)}
</div>
</section>
{turn.errors.length > 0 && (
<section>
<h4 className="mb-3 flex items-center gap-2 text-sm font-semibold text-red-700 dark:text-red-300">
<AlertCircle className="h-4 w-4" />
{t('monitoring.errors.title', {
defaultValue: '错误日志',
})}{' '}
({turn.errors.length})
</h4>
<div className="rounded-lg bg-background px-3 py-3">
{turn.errors.map((error) => (
<div
key={error.id}
className="border-t border-red-200/80 py-3 first:border-t-0 first:pt-0 last:pb-0 dark:border-red-900"
>
<div className="mb-2 flex flex-wrap items-center justify-between gap-2">
<span className="text-sm font-medium text-red-700 dark:text-red-300">
{error.errorType}
</span>
<span className="text-xs text-muted-foreground">
{error.timestamp.toLocaleString()}
</span>
</div>
<div className="whitespace-pre-wrap break-words text-sm text-red-600 dark:text-red-400">
{error.errorMessage}
</div>
</div>
))}
</div>
</section>
)}
</div>
</div>
)}
</div>
);
})}
</div>
);
}
@@ -106,6 +106,9 @@ export function useMonitoringData(filterState: FilterState) {
const llmCalls = Array.isArray(response?.llmCalls) const llmCalls = Array.isArray(response?.llmCalls)
? response.llmCalls ? response.llmCalls
: []; : [];
const toolCalls = Array.isArray(response?.toolCalls)
? response.toolCalls
: [];
const embeddingCalls = Array.isArray(response?.embeddingCalls) const embeddingCalls = Array.isArray(response?.embeddingCalls)
? response.embeddingCalls ? response.embeddingCalls
: []; : [];
@@ -116,6 +119,7 @@ export function useMonitoringData(filterState: FilterState) {
const totalCount = response?.totalCount ?? { const totalCount = response?.totalCount ?? {
messages: messages.length, messages: messages.length,
llmCalls: llmCalls.length, llmCalls: llmCalls.length,
toolCalls: toolCalls.length,
embeddingCalls: embeddingCalls.length, embeddingCalls: embeddingCalls.length,
sessions: sessions.length, sessions: sessions.length,
errors: errors.length, errors: errors.length,
@@ -145,8 +149,10 @@ export function useMonitoringData(filterState: FilterState) {
level: string; level: string;
platform?: string; platform?: string;
user_id?: string; user_id?: string;
user_name?: string;
runner_name?: string; runner_name?: string;
variables?: string; variables?: string;
role?: string;
}) => ({ }) => ({
id: msg.id, id: msg.id,
timestamp: parseUTCTimestamp(msg.timestamp), timestamp: parseUTCTimestamp(msg.timestamp),
@@ -160,8 +166,10 @@ export function useMonitoringData(filterState: FilterState) {
level: msg.level as 'info' | 'warning' | 'error' | 'debug', level: msg.level as 'info' | 'warning' | 'error' | 'debug',
platform: msg.platform, platform: msg.platform,
userId: msg.user_id, userId: msg.user_id,
userName: msg.user_name,
runnerName: msg.runner_name, runnerName: msg.runner_name,
variables: msg.variables, variables: msg.variables,
role: msg.role,
}), }),
), ),
llmCalls: llmCalls.map( llmCalls: llmCalls.map(
@@ -179,6 +187,7 @@ export function useMonitoringData(filterState: FilterState) {
bot_name: string; bot_name: string;
pipeline_id: string; pipeline_id: string;
pipeline_name: string; pipeline_name: string;
session_id?: string;
error_message?: string; error_message?: string;
message_id?: string; message_id?: string;
}) => ({ }) => ({
@@ -197,10 +206,46 @@ export function useMonitoringData(filterState: FilterState) {
botName: call.bot_name, botName: call.bot_name,
pipelineId: call.pipeline_id, pipelineId: call.pipeline_id,
pipelineName: call.pipeline_name, pipelineName: call.pipeline_name,
sessionId: call.session_id,
errorMessage: call.error_message, errorMessage: call.error_message,
messageId: call.message_id, messageId: call.message_id,
}), }),
), ),
toolCalls: toolCalls.map(
(call: {
id: string;
timestamp: string;
tool_name: string;
tool_source: string;
duration: number;
status: string;
bot_id: string;
bot_name: string;
pipeline_id: string;
pipeline_name: string;
session_id?: string;
message_id?: string;
arguments?: string;
result?: string;
error_message?: string;
}) => ({
id: call.id,
timestamp: parseUTCTimestamp(call.timestamp),
toolName: call.tool_name,
toolSource: call.tool_source,
duration: call.duration,
status: call.status as 'success' | 'error',
botId: call.bot_id,
botName: call.bot_name,
pipelineId: call.pipeline_id,
pipelineName: call.pipeline_name,
sessionId: call.session_id,
messageId: call.message_id,
arguments: call.arguments,
result: call.result,
errorMessage: call.error_message,
}),
),
embeddingCalls: embeddingCalls.map( embeddingCalls: embeddingCalls.map(
(call: { (call: {
id: string; id: string;
@@ -294,6 +339,7 @@ export function useMonitoringData(filterState: FilterState) {
totalCount: { totalCount: {
messages: totalCount.messages, messages: totalCount.messages,
llmCalls: totalCount.llmCalls, llmCalls: totalCount.llmCalls,
toolCalls: totalCount.toolCalls ?? toolCalls.length,
embeddingCalls: totalCount.embeddingCalls || 0, embeddingCalls: totalCount.embeddingCalls || 0,
sessions: totalCount.sessions, sessions: totalCount.sessions,
errors: totalCount.errors, errors: totalCount.errors,
@@ -317,6 +363,7 @@ export function useMonitoringData(filterState: FilterState) {
botName: call.botName, botName: call.botName,
pipelineId: call.pipelineId, pipelineId: call.pipelineId,
pipelineName: call.pipelineName, pipelineName: call.pipelineName,
sessionId: call.sessionId,
}), }),
); );
+25 -256
View File
@@ -18,60 +18,12 @@ import { ExportDropdown } from './components/ExportDropdown';
import { useMonitoringFilters } from './hooks/useMonitoringFilters'; import { useMonitoringFilters } from './hooks/useMonitoringFilters';
import { useMonitoringData } from './hooks/useMonitoringData'; import { useMonitoringData } from './hooks/useMonitoringData';
import { useFeedbackData } from './hooks/useFeedbackData'; import { useFeedbackData } from './hooks/useFeedbackData';
import { MessageDetailsCard } from './components/MessageDetailsCard'; import { ConversationTurnList } from './components/ConversationTurnList';
import { MessageContentRenderer } from './components/MessageContentRenderer';
import { FeedbackStatsCards } from './components/FeedbackCard'; import { FeedbackStatsCards } from './components/FeedbackCard';
import { FeedbackList } from './components/FeedbackList'; import { FeedbackList } from './components/FeedbackList';
import { MessageDetails } from './types/monitoring'; import { buildConversationTurns } from './utils/conversationTurns';
import { httpClient } from '@/app/infra/http/HttpClient';
import { LoadingSpinner, LoadingPage } from '@/components/ui/loading-spinner'; import { LoadingSpinner, LoadingPage } from '@/components/ui/loading-spinner';
interface RawMessageData {
id: string;
timestamp: string;
bot_id: string;
bot_name: string;
pipeline_id: string;
pipeline_name: string;
message_content: string;
session_id: string;
status: string;
level: string;
platform: string;
user_id: string;
runner_name: string;
variables: Record<string, unknown>;
}
interface RawLLMCallData {
id: string;
timestamp: string;
model_name: string;
status: string;
duration: number;
error_message: string | null;
input_tokens: number;
output_tokens: number;
total_tokens: number;
}
interface RawLLMStatsData {
total_calls: number;
total_input_tokens: number;
total_output_tokens: number;
total_tokens: number;
total_duration_ms: number;
average_duration_ms: number;
}
interface RawErrorData {
id: string;
timestamp: string;
error_type: string;
error_message: string;
stack_trace: string | null;
}
function MonitoringPageContent() { function MonitoringPageContent() {
const { t } = useTranslation(); const { t } = useTranslation();
const { filterState, setSelectedBots, setSelectedPipelines, setTimeRange } = const { filterState, setSelectedBots, setSelectedPipelines, setTimeRange } =
@@ -146,115 +98,37 @@ function MonitoringPageContent() {
setFeedbackRefreshKey((k) => k + 1); setFeedbackRefreshKey((k) => k + 1);
}, [refetch]); }, [refetch]);
const [expandedMessageId, setExpandedMessageId] = useState<string | null>( const conversationTurns = useMemo(
null, () =>
); buildConversationTurns(
const [messageDetails, setMessageDetails] = useState< data?.messages || [],
Record<string, MessageDetails> data?.llmCalls || [],
>({}); data?.errors || [],
const [loadingDetails, setLoadingDetails] = useState<Record<string, boolean>>( data?.toolCalls || [],
{}, ),
[data?.messages, data?.llmCalls, data?.errors, data?.toolCalls],
); );
// State for expanded errors // State for expanded errors
const [expandedErrorId, setExpandedErrorId] = useState<string | null>(null); const [expandedErrorId, setExpandedErrorId] = useState<string | null>(null);
const [expandedTurnId, setExpandedTurnId] = useState<string | null>(null);
// State for controlled tabs // State for controlled tabs
const [activeTab, setActiveTab] = useState<string>('messages'); const [activeTab, setActiveTab] = useState<string>('messages');
// Function to jump to a message record // Function to jump to a message record
const jumpToMessage = async (messageId: string) => { const jumpToMessage = (messageId: string) => {
setActiveTab('messages'); setActiveTab('messages');
// Small delay to ensure tab switch completes
setTimeout(() => { setTimeout(() => {
toggleMessageExpand(messageId); const turn = conversationTurns.find((item) =>
item.messages.some((message) => message.id === messageId),
);
setExpandedTurnId(turn?.id ?? messageId);
}, 100); }, 100);
}; };
const toggleMessageExpand = async (messageId: string) => { const toggleTurnExpand = (turnId: string) => {
if (expandedMessageId === messageId) { setExpandedTurnId((current) => (current === turnId ? null : turnId));
// Collapse
setExpandedMessageId(null);
} else {
// Expand
setExpandedMessageId(messageId);
// Fetch details if not already loaded
if (!messageDetails[messageId]) {
setLoadingDetails({ ...loadingDetails, [messageId]: true });
try {
// httpClient.get() returns the inner data directly (response.data.data)
const result = await httpClient.get<{
message_id: string;
found: boolean;
message: RawMessageData | null;
llm_calls: RawLLMCallData[];
llm_stats: RawLLMStatsData;
errors: RawErrorData[];
}>(`/api/v1/monitoring/messages/${messageId}/details`);
if (result) {
setMessageDetails((prev) => ({
...prev,
[messageId]: {
messageId: result.message_id,
found: result.found,
message: result.message
? {
id: result.message.id,
timestamp: new Date(result.message.timestamp),
botId: result.message.bot_id,
botName: result.message.bot_name,
pipelineId: result.message.pipeline_id,
pipelineName: result.message.pipeline_name,
messageContent: result.message.message_content,
sessionId: result.message.session_id,
status: result.message.status,
level: result.message.level,
platform: result.message.platform,
userId: result.message.user_id,
runnerName: result.message.runner_name,
variables: result.message.variables,
}
: undefined,
llmCalls: result.llm_calls.map((call: RawLLMCallData) => ({
id: call.id,
timestamp: new Date(call.timestamp),
modelName: call.model_name,
status: call.status,
duration: call.duration,
errorMessage: call.error_message,
tokens: {
input: call.input_tokens || 0,
output: call.output_tokens || 0,
total: call.total_tokens || 0,
},
})),
errors: result.errors.map((error: RawErrorData) => ({
id: error.id,
timestamp: new Date(error.timestamp),
errorType: error.error_type,
errorMessage: error.error_message,
stackTrace: error.stack_trace,
})),
llmStats: {
totalCalls: result.llm_stats.total_calls,
totalInputTokens: result.llm_stats.total_input_tokens,
totalOutputTokens: result.llm_stats.total_output_tokens,
totalTokens: result.llm_stats.total_tokens,
totalDurationMs: result.llm_stats.total_duration_ms,
averageDurationMs: result.llm_stats.average_duration_ms,
},
} as MessageDetails,
}));
}
} catch (error) {
console.error('Failed to fetch message details:', error);
} finally {
setLoadingDetails({ ...loadingDetails, [messageId]: false });
}
}
}
}; };
const toggleErrorExpand = (errorId: string) => { const toggleErrorExpand = (errorId: string) => {
@@ -342,120 +216,15 @@ function MonitoringPageContent() {
</div> </div>
)} )}
{!loading && {!loading && data && conversationTurns.length > 0 && (
data && <ConversationTurnList
data.messages && turns={conversationTurns}
data.messages.length > 0 && ( expandedTurnId={expandedTurnId}
<div className="space-y-4"> onToggleTurn={toggleTurnExpand}
{data.messages
.filter((msg) => {
// Filter out messages with empty content
const content = msg.messageContent?.trim();
return (
content && content !== '[]' && content !== '""'
);
})
.map((msg) => (
<div
key={msg.id}
className="border rounded-xl overflow-hidden transition-all duration-200"
>
{/* Message Header - Always Visible */}
<div
className="p-3 cursor-pointer hover:bg-accent transition-colors sm:p-5"
onClick={() => toggleMessageExpand(msg.id)}
>
<div className="flex items-start justify-between">
<div className="flex items-start flex-1">
{/* Expand Icon */}
<div className="mr-3 mt-0.5">
{expandedMessageId === msg.id ? (
<ChevronDown className="w-5 h-5 text-muted-foreground" />
) : (
<ChevronRight className="w-5 h-5 text-muted-foreground" />
)}
</div>
{/* Message Info */}
<div className="flex-1">
<div className="flex items-center gap-2 mb-1">
<span className="text-xs text-muted-foreground font-mono">
ID: {msg.id}
</span>
</div>
<div className="flex items-center gap-2 mb-2">
<span className="font-medium text-sm text-foreground">
{msg.botName}
</span>
<span className="text-muted-foreground">
</span>
<span className="text-sm text-muted-foreground">
{msg.pipelineName}
</span>
{msg.runnerName && (
<>
<span className="text-muted-foreground">
</span>
<span className="text-sm text-muted-foreground">
{msg.runnerName}
</span>
</>
)}
</div>
<div className="text-base text-foreground">
<MessageContentRenderer
content={msg.messageContent}
maxLines={3}
/>
</div>
</div>
</div>
{/* Status and Timestamp */}
<div className="flex flex-col items-end gap-2 ml-4">
<span className="text-xs text-muted-foreground whitespace-nowrap">
{msg.timestamp.toLocaleString()}
</span>
<span
className={`text-xs px-2 py-1 rounded ${
msg.level === 'error'
? 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200'
: msg.level === 'warning'
? 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200'
: 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200'
}`}
>
{msg.level}
</span>
</div>
</div>
</div>
{/* Expanded Details */}
{expandedMessageId === msg.id && (
<div className="border-t p-4 bg-muted">
{loadingDetails[msg.id] && (
<div className="py-4 flex justify-center">
<LoadingSpinner size="sm" text="" />
</div>
)}
{!loadingDetails[msg.id] &&
messageDetails[msg.id] && (
<MessageDetailsCard
details={messageDetails[msg.id]}
/> />
)} )}
</div>
)}
</div>
))}
</div>
)}
{!loading && {!loading && (!data || conversationTurns.length === 0) && (
(!data || !data.messages || data.messages.length === 0) && (
<div className="flex flex-col items-center justify-center text-muted-foreground py-16 gap-2"> <div className="flex flex-col items-center justify-center text-muted-foreground py-16 gap-2">
<MessageSquare className="h-[3rem] w-[3rem]" /> <MessageSquare className="h-[3rem] w-[3rem]" />
<div className="text-sm"> <div className="text-sm">
@@ -11,8 +11,10 @@ export interface MonitoringMessage {
level: 'info' | 'warning' | 'error' | 'debug'; level: 'info' | 'warning' | 'error' | 'debug';
platform?: string; platform?: string;
userId?: string; userId?: string;
userName?: string;
runnerName?: string; runnerName?: string;
variables?: string; variables?: string;
role?: 'user' | 'assistant' | string;
} }
export interface LLMCall { export interface LLMCall {
@@ -31,10 +33,29 @@ export interface LLMCall {
botName: string; botName: string;
pipelineId: string; pipelineId: string;
pipelineName: string; pipelineName: string;
sessionId?: string;
errorMessage?: string; errorMessage?: string;
messageId?: string; messageId?: string;
} }
export interface ToolCall {
id: string;
timestamp: Date;
toolName: string;
toolSource: 'native' | 'plugin' | 'mcp' | 'skill' | string;
duration: number;
status: 'success' | 'error';
botId: string;
botName: string;
pipelineId: string;
pipelineName: string;
sessionId?: string;
messageId?: string;
arguments?: string;
result?: string;
errorMessage?: string;
}
export interface EmbeddingCall { export interface EmbeddingCall {
id: string; id: string;
timestamp: Date; timestamp: Date;
@@ -199,6 +220,7 @@ export interface MonitoringData {
overview: OverviewMetrics; overview: OverviewMetrics;
messages: MonitoringMessage[]; messages: MonitoringMessage[];
llmCalls: LLMCall[]; llmCalls: LLMCall[];
toolCalls: ToolCall[];
embeddingCalls: EmbeddingCall[]; embeddingCalls: EmbeddingCall[];
modelCalls: ModelCall[]; modelCalls: ModelCall[];
sessions: SessionInfo[]; sessions: SessionInfo[];
@@ -208,6 +230,7 @@ export interface MonitoringData {
totalCount: { totalCount: {
messages: number; messages: number;
llmCalls: number; llmCalls: number;
toolCalls?: number;
embeddingCalls: number; embeddingCalls: number;
sessions: number; sessions: number;
errors: number; errors: number;
@@ -0,0 +1,294 @@
import {
ErrorLog,
LLMCall,
MonitoringMessage,
ToolCall,
} from '../types/monitoring';
type MessageRole = 'user' | 'assistant' | 'unknown';
export interface ConversationTurn {
id: string;
sessionId: string;
startedAt: Date;
lastActivityAt: Date;
botId: string;
botName: string;
pipelineId: string;
pipelineName: string;
runnerName?: string;
platform?: string;
userId?: string;
userName?: string;
userMessage?: MonitoringMessage;
assistantMessages: MonitoringMessage[];
messages: MonitoringMessage[];
llmCalls: LLMCall[];
toolCalls: ToolCall[];
errors: ErrorLog[];
status: 'success' | 'error' | 'pending';
level: 'info' | 'warning' | 'error' | 'debug';
inputTokens: number;
outputTokens: number;
totalTokens: number;
totalDuration: number;
totalToolDuration: number;
}
function normalizeRole(
message: MonitoringMessage,
llmMessageIds: Set<string>,
): MessageRole {
const role = message.role?.toLowerCase();
if (role === 'user' || role === 'assistant') {
return role;
}
if (llmMessageIds.has(message.id)) {
return 'user';
}
return 'unknown';
}
export function hasRenderableMessageContent(content?: string): boolean {
const trimmed = content?.trim();
if (!trimmed || trimmed === '[]' || trimmed === '""') {
return false;
}
try {
const parsed = JSON.parse(trimmed);
if (typeof parsed === 'string') {
return parsed.trim().length > 0;
}
if (Array.isArray(parsed)) {
return parsed.some(
(component) =>
typeof component !== 'object' ||
component === null ||
component.type !== 'Source',
);
}
} catch {
return true;
}
return true;
}
function createTurn(message: MonitoringMessage): ConversationTurn {
return {
id: message.id,
sessionId: message.sessionId,
startedAt: message.timestamp,
lastActivityAt: message.timestamp,
botId: message.botId,
botName: message.botName,
pipelineId: message.pipelineId,
pipelineName: message.pipelineName,
runnerName: message.runnerName,
platform: message.platform,
userId: message.userId,
userName: message.userName,
assistantMessages: [],
messages: [],
llmCalls: [],
toolCalls: [],
errors: [],
status: message.status,
level: message.level,
inputTokens: 0,
outputTokens: 0,
totalTokens: 0,
totalDuration: 0,
totalToolDuration: 0,
};
}
function updateTurnActivity(turn: ConversationTurn, timestamp: Date) {
if (timestamp.getTime() > turn.lastActivityAt.getTime()) {
turn.lastActivityAt = timestamp;
}
}
function addMessageToTurn(
turn: ConversationTurn,
message: MonitoringMessage,
role: MessageRole,
) {
turn.messages.push(message);
updateTurnActivity(turn, message.timestamp);
if (message.level === 'error') {
turn.level = 'error';
} else if (message.level === 'warning' && turn.level !== 'error') {
turn.level = 'warning';
}
if (message.status === 'error') {
turn.status = 'error';
} else if (message.status === 'pending' && turn.status !== 'error') {
turn.status = 'pending';
}
if (role === 'assistant') {
turn.assistantMessages.push(message);
return;
}
if (!turn.userMessage) {
turn.userMessage = message;
turn.userId = message.userId ?? turn.userId;
turn.userName = message.userName ?? turn.userName;
return;
}
turn.assistantMessages.push(message);
}
function findTurnBySessionTime(
sessionTurns: Map<string, ConversationTurn[]>,
sessionId: string | undefined,
timestamp: Date,
): ConversationTurn | undefined {
if (!sessionId) {
return undefined;
}
const turns = sessionTurns.get(sessionId);
if (!turns?.length) {
return undefined;
}
let nearest = turns[0];
const targetTime = timestamp.getTime();
for (const turn of turns) {
if (turn.startedAt.getTime() <= targetTime) {
nearest = turn;
} else {
break;
}
}
return nearest;
}
export function buildConversationTurns(
messages: MonitoringMessage[],
llmCalls: LLMCall[],
errors: ErrorLog[],
toolCalls: ToolCall[] = [],
): ConversationTurn[] {
const activityMessageIds = new Set([
...llmCalls
.map((call) => call.messageId)
.filter((messageId): messageId is string => Boolean(messageId)),
...toolCalls
.map((call) => call.messageId)
.filter((messageId): messageId is string => Boolean(messageId)),
]);
const visibleMessages = messages
.filter((message) => hasRenderableMessageContent(message.messageContent))
.sort((a, b) => a.timestamp.getTime() - b.timestamp.getTime());
const sessionTurns = new Map<string, ConversationTurn[]>();
const lastTurnBySession = new Map<string, ConversationTurn>();
const messageIdToTurn = new Map<string, ConversationTurn>();
for (const message of visibleMessages) {
const role = normalizeRole(message, activityMessageIds);
const previousTurn = lastTurnBySession.get(message.sessionId);
const shouldStartTurn = role === 'user' || !previousTurn;
const turn = shouldStartTurn ? createTurn(message) : previousTurn;
if (shouldStartTurn) {
const turns = sessionTurns.get(message.sessionId) ?? [];
turns.push(turn);
sessionTurns.set(message.sessionId, turns);
lastTurnBySession.set(message.sessionId, turn);
}
addMessageToTurn(turn, message, role);
messageIdToTurn.set(message.id, turn);
}
const allTurns = Array.from(sessionTurns.values()).flat();
for (const call of llmCalls) {
const turn =
(call.messageId ? messageIdToTurn.get(call.messageId) : undefined) ??
findTurnBySessionTime(sessionTurns, call.sessionId, call.timestamp);
if (!turn) {
continue;
}
turn.llmCalls.push(call);
turn.inputTokens += call.tokens.input;
turn.outputTokens += call.tokens.output;
turn.totalTokens += call.tokens.total;
turn.totalDuration += call.duration;
updateTurnActivity(turn, call.timestamp);
if (call.status === 'error') {
turn.status = 'error';
turn.level = 'error';
}
}
for (const call of toolCalls) {
const turn =
(call.messageId ? messageIdToTurn.get(call.messageId) : undefined) ??
findTurnBySessionTime(sessionTurns, call.sessionId, call.timestamp);
if (!turn) {
continue;
}
turn.toolCalls.push(call);
turn.totalToolDuration += call.duration;
updateTurnActivity(turn, call.timestamp);
if (call.status === 'error') {
turn.status = 'error';
turn.level = 'error';
}
}
for (const error of errors) {
const turn =
(error.messageId ? messageIdToTurn.get(error.messageId) : undefined) ??
findTurnBySessionTime(sessionTurns, error.sessionId, error.timestamp);
if (!turn) {
continue;
}
turn.errors.push(error);
turn.status = 'error';
turn.level = 'error';
updateTurnActivity(turn, error.timestamp);
}
for (const turn of allTurns) {
turn.messages.sort((a, b) => a.timestamp.getTime() - b.timestamp.getTime());
turn.assistantMessages.sort(
(a, b) => a.timestamp.getTime() - b.timestamp.getTime(),
);
turn.llmCalls.sort((a, b) => a.timestamp.getTime() - b.timestamp.getTime());
turn.toolCalls.sort(
(a, b) => a.timestamp.getTime() - b.timestamp.getTime(),
);
turn.errors.sort((a, b) => a.timestamp.getTime() - b.timestamp.getTime());
}
return allTurns.sort(
(a, b) => b.lastActivityAt.getTime() - a.lastActivityAt.getTime(),
);
}
@@ -12,63 +12,15 @@ import {
Monitor, Monitor,
} from 'lucide-react'; } from 'lucide-react';
import { useMonitoringData } from '@/app/home/monitoring/hooks/useMonitoringData'; import { useMonitoringData } from '@/app/home/monitoring/hooks/useMonitoringData';
import { MessageContentRenderer } from '@/app/home/monitoring/components/MessageContentRenderer'; import { ConversationTurnList } from '@/app/home/monitoring/components/ConversationTurnList';
import { buildConversationTurns } from '@/app/home/monitoring/utils/conversationTurns';
import { LoadingSpinner } from '@/components/ui/loading-spinner'; import { LoadingSpinner } from '@/components/ui/loading-spinner';
import { httpClient } from '@/app/infra/http/HttpClient';
import { MessageDetails } from '@/app/home/monitoring/types/monitoring';
import { parseUTCTimestamp } from '@/app/home/monitoring/utils/dateUtils';
interface PipelineMonitoringTabProps { interface PipelineMonitoringTabProps {
pipelineId: string; pipelineId: string;
onNavigateToMonitoring?: () => void; onNavigateToMonitoring?: () => void;
} }
interface RawMessageData {
id: string;
timestamp: string;
bot_id: string;
bot_name: string;
pipeline_id: string;
pipeline_name: string;
message_content: string;
session_id: string;
status: string;
level: string;
platform: string;
user_id: string;
runner_name: string;
variables: Record<string, unknown>;
}
interface RawLLMCallData {
id: string;
timestamp: string;
model_name: string;
status: string;
duration: number;
error_message: string | null;
input_tokens: number;
output_tokens: number;
total_tokens: number;
}
interface RawLLMStatsData {
total_calls: number;
total_input_tokens: number;
total_output_tokens: number;
total_tokens: number;
total_duration_ms: number;
average_duration_ms: number;
}
interface RawErrorData {
id: string;
timestamp: string;
error_type: string;
error_message: string;
stack_trace: string | null;
}
export default function PipelineMonitoringTab({ export default function PipelineMonitoringTab({
pipelineId, pipelineId,
onNavigateToMonitoring, onNavigateToMonitoring,
@@ -88,98 +40,24 @@ export default function PipelineMonitoringTab({
const { data, loading, refetch } = useMonitoringData(filterState); const { data, loading, refetch } = useMonitoringData(filterState);
const [expandedMessageId, setExpandedMessageId] = useState<string | null>( const conversationTurns = useMemo(
null, () =>
); data
const [messageDetails, setMessageDetails] = useState< ? buildConversationTurns(
Record<string, MessageDetails> data.messages,
>({}); data.llmCalls,
const [loadingDetails, setLoadingDetails] = useState<Record<string, boolean>>( data.errors,
{}, data.toolCalls,
)
: [],
[data],
); );
const [expandedTurnId, setExpandedTurnId] = useState<string | null>(null);
const [expandedErrorId, setExpandedErrorId] = useState<string | null>(null); const [expandedErrorId, setExpandedErrorId] = useState<string | null>(null);
const [activeTab, setActiveTab] = useState<string>('messages'); const [activeTab, setActiveTab] = useState<string>('messages');
const toggleMessageExpand = async (messageId: string) => { const toggleTurnExpand = (turnId: string) => {
if (expandedMessageId === messageId) { setExpandedTurnId((current) => (current === turnId ? null : turnId));
setExpandedMessageId(null);
} else {
setExpandedMessageId(messageId);
if (!messageDetails[messageId]) {
setLoadingDetails((prev) => ({ ...prev, [messageId]: true }));
try {
const result = await httpClient.get<{
message_id: string;
found: boolean;
message: RawMessageData | null;
llm_calls: RawLLMCallData[];
llm_stats: RawLLMStatsData;
errors: RawErrorData[];
}>(`/api/v1/monitoring/messages/${messageId}/details`);
if (result) {
setMessageDetails((prev) => ({
...prev,
[messageId]: {
messageId: result.message_id,
found: result.found,
message: result.message
? {
id: result.message.id,
timestamp: parseUTCTimestamp(result.message.timestamp),
botId: result.message.bot_id,
botName: result.message.bot_name,
pipelineId: result.message.pipeline_id,
pipelineName: result.message.pipeline_name,
messageContent: result.message.message_content,
sessionId: result.message.session_id,
status: result.message.status,
level: result.message.level,
platform: result.message.platform,
userId: result.message.user_id,
runnerName: result.message.runner_name,
variables: result.message.variables,
}
: undefined,
llmCalls: result.llm_calls.map((call: RawLLMCallData) => ({
id: call.id,
timestamp: parseUTCTimestamp(call.timestamp),
modelName: call.model_name,
status: call.status,
duration: call.duration,
errorMessage: call.error_message,
tokens: {
input: call.input_tokens || 0,
output: call.output_tokens || 0,
total: call.total_tokens || 0,
},
})),
errors: result.errors.map((error: RawErrorData) => ({
id: error.id,
timestamp: parseUTCTimestamp(error.timestamp),
errorType: error.error_type,
errorMessage: error.error_message,
stackTrace: error.stack_trace,
})),
llmStats: {
totalCalls: result.llm_stats.total_calls,
totalInputTokens: result.llm_stats.total_input_tokens,
totalOutputTokens: result.llm_stats.total_output_tokens,
totalTokens: result.llm_stats.total_tokens,
totalDurationMs: result.llm_stats.total_duration_ms,
averageDurationMs: result.llm_stats.average_duration_ms,
},
} as MessageDetails,
}));
}
} catch (error) {
console.error('Failed to fetch message details:', error);
} finally {
setLoadingDetails((prev) => ({ ...prev, [messageId]: false }));
}
}
}
}; };
const toggleErrorExpand = (errorId: string) => { const toggleErrorExpand = (errorId: string) => {
@@ -190,12 +68,16 @@ export default function PipelineMonitoringTab({
} }
}; };
const jumpToMessage = async (messageId: string) => { const jumpToMessage = (messageId: string) => {
setActiveTab('messages'); setActiveTab('messages');
// Small delay to ensure tab transition completes before expanding
setTimeout(() => { const turn = conversationTurns.find((item) =>
toggleMessageExpand(messageId); item.messages.some((message) => message.id === messageId),
}, 100); );
if (turn) {
setExpandedTurnId(turn.id);
}
}; };
return ( return (
@@ -295,135 +177,15 @@ export default function PipelineMonitoringTab({
</div> </div>
)} )}
{!loading && data && data.messages && data.messages.length > 0 && ( {!loading && data && conversationTurns.length > 0 && (
<div className="space-y-3"> <ConversationTurnList
{data.messages turns={conversationTurns}
.filter((msg) => { expandedTurnId={expandedTurnId}
const content = msg.messageContent?.trim(); onToggleTurn={toggleTurnExpand}
return content && content !== '[]' && content !== '""';
})
.map((msg) => (
<div
key={msg.id}
className="border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden hover:shadow-md transition-all duration-200"
>
<div
className="p-4 cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800/50 transition-colors"
onClick={() => toggleMessageExpand(msg.id)}
>
<div className="flex items-start justify-between">
<div className="flex items-start flex-1">
<div className="mr-2 mt-0.5">
{expandedMessageId === msg.id ? (
<ChevronDown className="w-4 h-4 text-gray-500" />
) : (
<ChevronRight className="w-4 h-4 text-gray-500" />
)}
</div>
<div className="flex-1">
<div className="flex items-center gap-2 mb-1">
<span
className={`text-xs px-2 py-0.5 rounded ${
msg.status === 'success'
? 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200'
: msg.status === 'error'
? 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200'
: 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200'
}`}
>
{msg.status}
</span>
<span className="text-xs text-gray-500 dark:text-gray-400">
{msg.botName}
</span>
</div>
<div className="text-sm text-gray-700 dark:text-gray-300 line-clamp-2">
<MessageContentRenderer
content={msg.messageContent}
/> />
</div>
</div>
</div>
<span className="text-xs text-gray-500 dark:text-gray-400 whitespace-nowrap ml-4">
{msg.timestamp.toLocaleString()}
</span>
</div>
</div>
{expandedMessageId === msg.id && (
<div className="border-t border-gray-200 dark:border-gray-700 p-4 bg-gray-50 dark:bg-gray-900">
{loadingDetails[msg.id] && (
<div className="flex justify-center py-8">
<LoadingSpinner
text={t('monitoring.messageList.loading')}
/>
</div>
)} )}
{!loadingDetails[msg.id] && {!loading && (!data || conversationTurns.length === 0) && (
messageDetails[msg.id] && (
<div className="space-y-4">
{messageDetails[msg.id].errors.length > 0 && (
<div className="bg-red-50 dark:bg-red-900/20 rounded-lg p-3">
<h4 className="text-sm font-semibold text-red-700 dark:text-red-400 mb-2">
{t('monitoring.errors.errorMessage')}
</h4>
{messageDetails[msg.id].errors.map(
(error) => (
<div
key={error.id}
className="text-sm space-y-2"
>
<div className="text-red-600 dark:text-red-400">
{error.errorType}:{' '}
{error.errorMessage}
</div>
{error.stackTrace && (
<pre className="text-xs text-gray-600 dark:text-gray-400 overflow-auto max-h-40 bg-white dark:bg-gray-900 p-2 rounded whitespace-pre-wrap break-words">
{error.stackTrace}
</pre>
)}
</div>
),
)}
</div>
)}
{messageDetails[msg.id].llmCalls.length > 0 && (
<div className="bg-blue-50 dark:bg-blue-900/20 rounded-lg p-3">
<h4 className="text-sm font-semibold text-blue-700 dark:text-blue-400 mb-2">
{t('monitoring.tabs.modelCalls')} (
{messageDetails[msg.id].llmCalls.length})
</h4>
<div className="text-xs text-gray-600 dark:text-gray-400 space-y-1">
<div>
{t('monitoring.llmCalls.totalTokens')}:{' '}
{
messageDetails[msg.id].llmStats
.totalTokens
}
</div>
<div>
{t('monitoring.llmCalls.duration')}:{' '}
{messageDetails[
msg.id
].llmStats.totalDurationMs.toFixed(0)}
ms
</div>
</div>
</div>
)}
</div>
)}
</div>
)}
</div>
))}
</div>
)}
{!loading &&
(!data || !data.messages || data.messages.length === 0) && (
<div className="text-center text-gray-500 dark:text-gray-400 py-16"> <div className="text-center text-gray-500 dark:text-gray-400 py-16">
<MessageCircle className="w-16 h-16 mx-auto mb-4 text-gray-300 dark:text-gray-600" /> <MessageCircle className="w-16 h-16 mx-auto mb-4 text-gray-300 dark:text-gray-600" />
<p className="text-base font-medium"> <p className="text-base font-medium">
+36
View File
@@ -706,6 +706,21 @@ export class BackendClient extends BaseHttpClient {
); );
} }
public getMcpServerLogs(
serverName: string,
limit: number = 200,
level?: string,
): Promise<{ logs: PluginLogEntry[] }> {
const params = new URLSearchParams();
params.set('limit', String(limit));
if (level) {
params.set('level', level);
}
return this.get(
`/api/v1/mcp/servers/${encodeURIComponent(serverName)}/logs?${params.toString()}`,
);
}
public getPluginAssetURL( public getPluginAssetURL(
author: string, author: string,
name: string, name: string,
@@ -1234,8 +1249,10 @@ export class BackendClient extends BaseHttpClient {
level: string; level: string;
platform?: string; platform?: string;
user_id?: string; user_id?: string;
user_name?: string;
runner_name?: string; runner_name?: string;
variables?: string; variables?: string;
role?: string;
}>; }>;
llmCalls: Array<{ llmCalls: Array<{
id: string; id: string;
@@ -1251,9 +1268,27 @@ export class BackendClient extends BaseHttpClient {
bot_name: string; bot_name: string;
pipeline_id: string; pipeline_id: string;
pipeline_name: string; pipeline_name: string;
session_id?: string;
error_message?: string; error_message?: string;
message_id?: string; message_id?: string;
}>; }>;
toolCalls: Array<{
id: string;
timestamp: string;
tool_name: string;
tool_source: string;
duration: number;
status: string;
bot_id: string;
bot_name: string;
pipeline_id: string;
pipeline_name: string;
session_id?: string;
message_id?: string;
arguments?: string;
result?: string;
error_message?: string;
}>;
embeddingCalls: Array<{ embeddingCalls: Array<{
id: string; id: string;
timestamp: string; timestamp: string;
@@ -1298,6 +1333,7 @@ export class BackendClient extends BaseHttpClient {
totalCount: { totalCount: {
messages: number; messages: number;
llmCalls: number; llmCalls: number;
toolCalls?: number;
embeddingCalls: number; embeddingCalls: number;
sessions: number; sessions: number;
errors: number; errors: number;
+29
View File
@@ -939,6 +939,12 @@ const enUS = {
tabTools: 'Tools', tabTools: 'Tools',
tabResources: 'Resources', tabResources: 'Resources',
tabDocs: 'Docs', tabDocs: 'Docs',
tabLogs: 'Logs',
logsLevelAll: 'All levels',
logsRefresh: 'Refresh',
logsAutoRefresh: 'Auto refresh',
logsEmpty:
'No logs yet. Runtime logs from the MCP server will appear here.',
noReadme: 'No documentation available', noReadme: 'No documentation available',
parseResultFailed: 'Failed to parse test result', parseResultFailed: 'Failed to parse test result',
noResultReturned: 'Test returned no result', noResultReturned: 'Test returned no result',
@@ -1440,6 +1446,20 @@ const enUS = {
level: 'Level', level: 'Level',
runner: 'Runner', runner: 'Runner',
viewConversation: 'View Conversation', viewConversation: 'View Conversation',
turns: '{{count}} conversation turns',
userMessage: 'User',
noUserMessage: 'No user input recorded',
assistantMessage: 'Assistant',
assistantMessageCount: 'Assistant +{{count}}',
noAssistantMessage: 'No assistant reply recorded',
messageCount: 'Messages',
conversationTrace: 'Conversation Trace',
noLlmCalls: 'No model calls recorded',
roles: {
user: 'User',
assistant: 'Assistant',
message: 'Message',
},
}, },
llmCalls: { llmCalls: {
title: 'LLM Calls', title: 'LLM Calls',
@@ -1454,6 +1474,15 @@ const enUS = {
avgDuration: 'Avg Duration', avgDuration: 'Avg Duration',
calls: 'Calls', calls: 'Calls',
}, },
toolCalls: {
title: 'Tool Calls',
totalCalls: 'Calls',
duration: 'Tool Duration',
errorCalls: 'Failed Calls',
arguments: 'Arguments',
result: 'Result',
noToolCalls: 'No tool calls recorded',
},
tokens: { tokens: {
totalTokens: 'Total Tokens', totalTokens: 'Total Tokens',
inputTokens: 'Input Tokens', inputTokens: 'Input Tokens',
+29
View File
@@ -901,6 +901,12 @@ const esES = {
tabTools: 'Herramientas', tabTools: 'Herramientas',
tabResources: 'Recursos', tabResources: 'Recursos',
tabDocs: 'Documentación', tabDocs: 'Documentación',
tabLogs: 'Registros',
logsLevelAll: 'Todos los niveles',
logsRefresh: 'Actualizar',
logsAutoRefresh: 'Actualización automática',
logsEmpty:
'Aún no hay registros. Los registros de ejecución del servidor MCP aparecerán aquí.',
noReadme: 'No hay documentación disponible', noReadme: 'No hay documentación disponible',
parseResultFailed: 'Error al analizar el resultado de la prueba', parseResultFailed: 'Error al analizar el resultado de la prueba',
noResultReturned: 'La prueba no devolvió resultados', noResultReturned: 'La prueba no devolvió resultados',
@@ -1422,6 +1428,20 @@ const esES = {
level: 'Nivel', level: 'Nivel',
runner: 'Ejecutor', runner: 'Ejecutor',
viewConversation: 'Ver conversación', viewConversation: 'Ver conversación',
turns: '{{count}} turnos de conversación',
userMessage: 'Usuario',
noUserMessage: 'No se registró entrada del usuario',
assistantMessage: 'Asistente',
assistantMessageCount: 'Asistente +{{count}}',
noAssistantMessage: 'No se registró respuesta del asistente',
messageCount: 'Mensajes',
conversationTrace: 'Flujo de conversación',
noLlmCalls: 'No se registraron llamadas al modelo',
roles: {
user: 'Usuario',
assistant: 'Asistente',
message: 'Mensaje',
},
}, },
llmCalls: { llmCalls: {
title: 'Llamadas LLM', title: 'Llamadas LLM',
@@ -1436,6 +1456,15 @@ const esES = {
avgDuration: 'Duración promedio', avgDuration: 'Duración promedio',
calls: 'Llamadas', calls: 'Llamadas',
}, },
toolCalls: {
title: 'Llamadas de herramientas',
totalCalls: 'Llamadas',
duration: 'Duración de herramientas',
errorCalls: 'Llamadas fallidas',
arguments: 'Argumentos',
result: 'Resultado',
noToolCalls: 'No se registraron llamadas de herramientas',
},
tokens: { tokens: {
totalTokens: 'Tokens totales', totalTokens: 'Tokens totales',
inputTokens: 'Tokens de entrada', inputTokens: 'Tokens de entrada',
+28
View File
@@ -927,6 +927,11 @@ const jaJP = {
tabTools: 'ツール', tabTools: 'ツール',
tabResources: 'リソース', tabResources: 'リソース',
tabDocs: 'ドキュメント', tabDocs: 'ドキュメント',
tabLogs: 'ログ',
logsLevelAll: 'すべてのレベル',
logsRefresh: '更新',
logsAutoRefresh: '自動更新',
logsEmpty: 'ログはありません。MCPサーバーの実行ログがここに表示されます。',
noReadme: 'ドキュメントがありません', noReadme: 'ドキュメントがありません',
parseResultFailed: 'テスト結果の解析に失敗しました', parseResultFailed: 'テスト結果の解析に失敗しました',
noResultReturned: 'テスト結果が返されませんでした', noResultReturned: 'テスト結果が返されませんでした',
@@ -1429,6 +1434,20 @@ const jaJP = {
level: 'レベル', level: 'レベル',
runner: 'ランナー', runner: 'ランナー',
viewConversation: '会話詳細を表示', viewConversation: '会話詳細を表示',
turns: '{{count}} 会話ターン',
userMessage: 'ユーザー',
noUserMessage: 'ユーザー入力は記録されていません',
assistantMessage: 'アシスタント',
assistantMessageCount: 'アシスタント +{{count}}',
noAssistantMessage: 'アシスタントの返信は記録されていません',
messageCount: 'メッセージ数',
conversationTrace: '会話トレース',
noLlmCalls: 'モデル呼び出しは記録されていません',
roles: {
user: 'ユーザー',
assistant: 'アシスタント',
message: 'メッセージ',
},
}, },
llmCalls: { llmCalls: {
title: 'LLM呼び出し', title: 'LLM呼び出し',
@@ -1443,6 +1462,15 @@ const jaJP = {
avgDuration: '平均期間', avgDuration: '平均期間',
calls: '呼び出し', calls: '呼び出し',
}, },
toolCalls: {
title: 'ツール呼び出し',
totalCalls: '呼び出し',
duration: 'ツール時間',
errorCalls: '失敗した呼び出し',
arguments: '引数',
result: '結果',
noToolCalls: 'ツール呼び出しは記録されていません',
},
tokens: { tokens: {
totalTokens: '総トークン数', totalTokens: '総トークン数',
inputTokens: '入力トークン', inputTokens: '入力トークン',
+29
View File
@@ -896,6 +896,12 @@ const ruRU = {
tabTools: 'Инструменты', tabTools: 'Инструменты',
tabResources: 'Ресурсы', tabResources: 'Ресурсы',
tabDocs: 'Документация', tabDocs: 'Документация',
tabLogs: 'Журнал',
logsLevelAll: 'Все уровни',
logsRefresh: 'Обновить',
logsAutoRefresh: 'Автообновление',
logsEmpty:
'Журналов пока нет. Здесь будут отображаться журналы выполнения MCP-сервера.',
noReadme: 'Документация отсутствует', noReadme: 'Документация отсутствует',
parseResultFailed: 'Не удалось разобрать результат теста', parseResultFailed: 'Не удалось разобрать результат теста',
noResultReturned: 'Тест не вернул результат', noResultReturned: 'Тест не вернул результат',
@@ -1396,6 +1402,20 @@ const ruRU = {
level: 'Уровень', level: 'Уровень',
runner: 'Обработчик', runner: 'Обработчик',
viewConversation: 'Просмотр диалога', viewConversation: 'Просмотр диалога',
turns: '{{count}} диалоговых ходов',
userMessage: 'Пользователь',
noUserMessage: 'Ввод пользователя не записан',
assistantMessage: 'Ассистент',
assistantMessageCount: 'Ассистент +{{count}}',
noAssistantMessage: 'Ответ ассистента не записан',
messageCount: 'Сообщения',
conversationTrace: 'Ход диалога',
noLlmCalls: 'Вызовы модели не записаны',
roles: {
user: 'Пользователь',
assistant: 'Ассистент',
message: 'Сообщение',
},
}, },
llmCalls: { llmCalls: {
title: 'Вызовы LLM', title: 'Вызовы LLM',
@@ -1410,6 +1430,15 @@ const ruRU = {
avgDuration: 'Средняя длительность', avgDuration: 'Средняя длительность',
calls: 'Вызовы', calls: 'Вызовы',
}, },
toolCalls: {
title: 'Вызовы инструментов',
totalCalls: 'Вызовы',
duration: 'Длительность инструментов',
errorCalls: 'Неудачные вызовы',
arguments: 'Аргументы',
result: 'Результат',
noToolCalls: 'Вызовы инструментов не записаны',
},
tokens: { tokens: {
totalTokens: 'Всего токенов', totalTokens: 'Всего токенов',
inputTokens: 'Входные токены', inputTokens: 'Входные токены',
+28
View File
@@ -874,6 +874,11 @@ const thTH = {
tabTools: 'เครื่องมือ', tabTools: 'เครื่องมือ',
tabResources: 'ทรัพยากร', tabResources: 'ทรัพยากร',
tabDocs: 'เอกสาร', tabDocs: 'เอกสาร',
tabLogs: 'บันทึก',
logsLevelAll: 'ทุกระดับ',
logsRefresh: 'รีเฟรช',
logsAutoRefresh: 'รีเฟรชอัตโนมัติ',
logsEmpty: 'ยังไม่มีบันทึก บันทึกการทำงานของ MCP Server จะแสดงที่นี่',
noReadme: 'ไม่มีเอกสาร', noReadme: 'ไม่มีเอกสาร',
parseResultFailed: 'ไม่สามารถแยกวิเคราะห์ผลการทดสอบได้', parseResultFailed: 'ไม่สามารถแยกวิเคราะห์ผลการทดสอบได้',
noResultReturned: 'การทดสอบไม่ส่งผลลัพธ์กลับมา', noResultReturned: 'การทดสอบไม่ส่งผลลัพธ์กลับมา',
@@ -1364,6 +1369,20 @@ const thTH = {
level: 'ระดับ', level: 'ระดับ',
runner: 'ตัวประมวลผล', runner: 'ตัวประมวลผล',
viewConversation: 'ดูการสนทนา', viewConversation: 'ดูการสนทนา',
turns: '{{count}} รอบการสนทนา',
userMessage: 'ผู้ใช้',
noUserMessage: 'ยังไม่มีการบันทึกข้อความจากผู้ใช้',
assistantMessage: 'ผู้ช่วย',
assistantMessageCount: 'ผู้ช่วย +{{count}}',
noAssistantMessage: 'ยังไม่มีการบันทึกคำตอบจากผู้ช่วย',
messageCount: 'จำนวนข้อความ',
conversationTrace: 'ลำดับการสนทนา',
noLlmCalls: 'ยังไม่มีการบันทึกการเรียกโมเดล',
roles: {
user: 'ผู้ใช้',
assistant: 'ผู้ช่วย',
message: 'ข้อความ',
},
}, },
llmCalls: { llmCalls: {
title: 'การเรียก LLM', title: 'การเรียก LLM',
@@ -1378,6 +1397,15 @@ const thTH = {
avgDuration: 'ระยะเวลาเฉลี่ย', avgDuration: 'ระยะเวลาเฉลี่ย',
calls: 'การเรียก', calls: 'การเรียก',
}, },
toolCalls: {
title: 'การเรียกใช้เครื่องมือ',
totalCalls: 'การเรียก',
duration: 'ระยะเวลาเครื่องมือ',
errorCalls: 'การเรียกที่ล้มเหลว',
arguments: 'อาร์กิวเมนต์',
result: 'ผลลัพธ์',
noToolCalls: 'ยังไม่มีการบันทึกการเรียกใช้เครื่องมือ',
},
tokens: { tokens: {
totalTokens: 'Token ทั้งหมด', totalTokens: 'Token ทั้งหมด',
inputTokens: 'Token อินพุต', inputTokens: 'Token อินพุต',
+29
View File
@@ -889,6 +889,12 @@ const viVN = {
tabTools: 'Công cụ', tabTools: 'Công cụ',
tabResources: 'Tài nguyên', tabResources: 'Tài nguyên',
tabDocs: 'Tài liệu', tabDocs: 'Tài liệu',
tabLogs: 'Nhật ký',
logsLevelAll: 'Tất cả cấp độ',
logsRefresh: 'Làm mới',
logsAutoRefresh: 'Tự động làm mới',
logsEmpty:
'Chưa có nhật ký. Nhật ký chạy của MCP Server sẽ hiển thị ở đây.',
noReadme: 'Không có tài liệu', noReadme: 'Không có tài liệu',
parseResultFailed: 'Phân tích kết quả kiểm tra thất bại', parseResultFailed: 'Phân tích kết quả kiểm tra thất bại',
noResultReturned: 'Kiểm tra không trả về kết quả', noResultReturned: 'Kiểm tra không trả về kết quả',
@@ -1389,6 +1395,20 @@ const viVN = {
level: 'Mức', level: 'Mức',
runner: 'Trình chạy', runner: 'Trình chạy',
viewConversation: 'Xem cuộc trò chuyện', viewConversation: 'Xem cuộc trò chuyện',
turns: '{{count}} lượt hội thoại',
userMessage: 'Người dùng',
noUserMessage: 'Chưa ghi nhận đầu vào người dùng',
assistantMessage: 'Trợ lý',
assistantMessageCount: 'Trợ lý +{{count}}',
noAssistantMessage: 'Chưa ghi nhận phản hồi của trợ lý',
messageCount: 'Số tin nhắn',
conversationTrace: 'Luồng hội thoại',
noLlmCalls: 'Chưa ghi nhận lệnh gọi mô hình',
roles: {
user: 'Người dùng',
assistant: 'Trợ lý',
message: 'Tin nhắn',
},
}, },
llmCalls: { llmCalls: {
title: 'Cuộc gọi LLM', title: 'Cuộc gọi LLM',
@@ -1403,6 +1423,15 @@ const viVN = {
avgDuration: 'Thời lượng trung bình', avgDuration: 'Thời lượng trung bình',
calls: 'Cuộc gọi', calls: 'Cuộc gọi',
}, },
toolCalls: {
title: 'Lượt gọi công cụ',
totalCalls: 'Lượt gọi',
duration: 'Thời lượng công cụ',
errorCalls: 'Lượt gọi thất bại',
arguments: 'Tham số',
result: 'Kết quả',
noToolCalls: 'Chưa ghi nhận lượt gọi công cụ',
},
tokens: { tokens: {
totalTokens: 'Tổng số Token', totalTokens: 'Tổng số Token',
inputTokens: 'Token đầu vào', inputTokens: 'Token đầu vào',
+28
View File
@@ -902,6 +902,11 @@ const zhHans = {
tabTools: '工具', tabTools: '工具',
tabResources: '资源', tabResources: '资源',
tabDocs: '文档', tabDocs: '文档',
tabLogs: '日志',
logsLevelAll: '全部级别',
logsRefresh: '刷新',
logsAutoRefresh: '自动刷新',
logsEmpty: '暂无日志。MCP 服务器的运行日志会显示在这里。',
noReadme: '暂无文档', noReadme: '暂无文档',
parseResultFailed: '解析测试结果失败', parseResultFailed: '解析测试结果失败',
noResultReturned: '测试未返回结果', noResultReturned: '测试未返回结果',
@@ -1374,6 +1379,20 @@ const zhHans = {
level: '级别', level: '级别',
runner: '执行器', runner: '执行器',
viewConversation: '显示对话详情', viewConversation: '显示对话详情',
turns: '{{count}} 轮对话',
userMessage: '用户',
noUserMessage: '未记录用户输入',
assistantMessage: '助手',
assistantMessageCount: '助手 +{{count}}',
noAssistantMessage: '未记录助手回复',
messageCount: '消息数',
conversationTrace: '消息链路',
noLlmCalls: '未记录模型调用',
roles: {
user: '用户',
assistant: '助手',
message: '消息',
},
}, },
llmCalls: { llmCalls: {
title: 'LLM调用', title: 'LLM调用',
@@ -1388,6 +1407,15 @@ const zhHans = {
avgDuration: '平均耗时', avgDuration: '平均耗时',
calls: '调用次数', calls: '调用次数',
}, },
toolCalls: {
title: '工具调用',
totalCalls: '调用次数',
duration: '工具耗时',
errorCalls: '失败次数',
arguments: '参数',
result: '结果',
noToolCalls: '未记录工具调用',
},
tokens: { tokens: {
totalTokens: '总 Token 数', totalTokens: '总 Token 数',
inputTokens: '输入 Token', inputTokens: '输入 Token',
+28
View File
@@ -847,6 +847,11 @@ const zhHant = {
tabTools: '工具', tabTools: '工具',
tabResources: '資源', tabResources: '資源',
tabDocs: '文件', tabDocs: '文件',
tabLogs: '日誌',
logsLevelAll: '全部級別',
logsRefresh: '重新整理',
logsAutoRefresh: '自動重新整理',
logsEmpty: '暫無日誌。MCP 服務器的運行日誌會顯示在這裡。',
noReadme: '暫無文件', noReadme: '暫無文件',
parseResultFailed: '解析測試結果失敗', parseResultFailed: '解析測試結果失敗',
noResultReturned: '測試未返回結果', noResultReturned: '測試未返回結果',
@@ -1318,6 +1323,20 @@ const zhHant = {
level: '級別', level: '級別',
runner: '執行器', runner: '執行器',
viewConversation: '顯示對話詳情', viewConversation: '顯示對話詳情',
turns: '{{count}} 輪對話',
userMessage: '使用者',
noUserMessage: '未記錄使用者輸入',
assistantMessage: '助手',
assistantMessageCount: '助手 +{{count}}',
noAssistantMessage: '未記錄助手回覆',
messageCount: '訊息數',
conversationTrace: '訊息鏈路',
noLlmCalls: '未記錄模型呼叫',
roles: {
user: '使用者',
assistant: '助手',
message: '訊息',
},
}, },
llmCalls: { llmCalls: {
title: 'LLM呼叫', title: 'LLM呼叫',
@@ -1332,6 +1351,15 @@ const zhHant = {
avgDuration: '平均持續時間', avgDuration: '平均持續時間',
calls: '呼叫次數', calls: '呼叫次數',
}, },
toolCalls: {
title: '工具呼叫',
totalCalls: '呼叫次數',
duration: '工具耗時',
errorCalls: '失敗次數',
arguments: '參數',
result: '結果',
noToolCalls: '未記錄工具呼叫',
},
tokens: { tokens: {
totalTokens: '總 Token 數', totalTokens: '總 Token 數',
inputTokens: '輸入 Token', inputTokens: '輸入 Token',
@@ -0,0 +1,179 @@
import { expect, test } from '@playwright/test';
import { installLangBotApiMocks } from './fixtures/langbot-api';
const botId = 'bot-tool-timeline';
const sessionId = 'person-tool-timeline-user';
const botName = 'Tool Timeline Bot';
const pipelineId = 'pipeline-tool-timeline';
const pipelineName = 'Tool Timeline Pipeline';
function at(minute: number, second = 0) {
return `2026-07-02T10:${String(minute).padStart(2, '0')}:${String(
second,
).padStart(2, '0')}Z`;
}
function sessionMessage(
id: string,
role: 'user' | 'assistant',
minute: number,
content: string,
) {
return {
id,
timestamp: at(minute),
bot_id: botId,
bot_name: botName,
pipeline_id: pipelineId,
pipeline_name: pipelineName,
message_content: content,
session_id: sessionId,
status: 'success',
level: 'info',
platform: role === 'user' ? 'person' : 'bot',
user_id: 'timeline-user',
user_name: 'Timeline User',
runner_name: role === 'assistant' ? 'local-agent' : null,
variables: '{}',
role,
};
}
function toolCall(
id: string,
minute: number,
toolName: string,
duration: number,
status: 'success' | 'error' = 'success',
) {
return {
id,
timestamp: at(minute, 30),
tool_name: toolName,
tool_source: 'native',
duration,
status,
bot_id: botId,
bot_name: botName,
pipeline_id: pipelineId,
pipeline_name: pipelineName,
session_id: sessionId,
message_id: 'user-message',
arguments: JSON.stringify({ target: toolName }),
result: status === 'success' ? JSON.stringify({ ok: true }) : null,
error_message: status === 'error' ? 'Tool execution failed' : null,
};
}
test.describe('bot session monitor tool timeline', () => {
test('renders tool calls as left-side agent events interleaved with messages', async ({
page,
}) => {
await installLangBotApiMocks(page, {
authenticated: true,
monitoringSessions: [
{
session_id: sessionId,
bot_id: botId,
bot_name: botName,
pipeline_id: pipelineId,
pipeline_name: pipelineName,
message_count: 3,
start_time: at(0),
last_activity: at(4),
is_active: true,
platform: 'person',
user_id: 'timeline-user',
user_name: 'Timeline User',
},
],
sessionMessages: {
[sessionId]: [
sessionMessage('user-message', 'user', 0, 'Need a timeline check'),
sessionMessage(
'assistant-step-1',
'assistant',
2,
'Agent step 1: inspected repository files',
),
sessionMessage(
'assistant-step-2',
'assistant',
4,
'Agent step 2: test suite finished',
),
],
},
sessionAnalyses: {
[sessionId]: {
session_id: sessionId,
found: true,
tool_calls: [
toolCall('tool-repo-read', 1, 'repo_file_read', 80),
toolCall('tool-test-run', 3, 'run_test_suite', 140),
],
},
},
});
await page.goto(`/home/bots?id=${botId}`);
await page.getByRole('tab', { name: /Sessions/ }).click();
await page.getByRole('button', { name: /Timeline User/ }).click();
await expect(page.getByText('Need a timeline check')).toBeVisible();
await expect(
page.getByText('repo_file_read', { exact: true }),
).toBeVisible();
await expect(
page.getByText('Agent step 1: inspected repository files'),
).toBeVisible();
await expect(
page.getByText('run_test_suite', { exact: true }),
).toBeVisible();
await expect(
page.getByText('Agent step 2: test suite finished'),
).toBeVisible();
await expect(page.getByText('{"target":"repo_file_read"}')).toHaveCount(0);
await expect(page.getByText('{"ok":true}')).toHaveCount(0);
await expect(
page.locator('div.flex.justify-start').filter({
hasText: 'repo_file_read',
}),
).toHaveCount(1);
await expect(
page.locator('div.flex.justify-start').filter({
hasText: 'run_test_suite',
}),
).toHaveCount(1);
await expect(
page.locator('div.flex.justify-end').filter({
hasText: 'repo_file_read',
}),
).toHaveCount(0);
await expect(
page.locator('div.flex.justify-end').filter({
hasText: 'run_test_suite',
}),
).toHaveCount(0);
const text = await page.locator('body').innerText();
expect(text.indexOf('Need a timeline check')).toBeLessThan(
text.indexOf('repo_file_read'),
);
expect(text.indexOf('repo_file_read')).toBeLessThan(
text.indexOf('Agent step 1: inspected repository files'),
);
expect(
text.indexOf('Agent step 1: inspected repository files'),
).toBeLessThan(text.indexOf('run_test_suite'));
expect(text.indexOf('run_test_suite')).toBeLessThan(
text.indexOf('Agent step 2: test suite finished'),
);
await page.getByText('repo_file_read', { exact: true }).click();
await expect(page.getByText('{"target":"repo_file_read"}')).toBeVisible();
await expect(page.getByText('{"ok":true}').first()).toBeVisible();
});
});
+25
View File
@@ -88,6 +88,31 @@ test.describe('frontend CRUD smoke flows', () => {
).toBeVisible(); ).toBeVisible();
}); });
test('opens pipeline AI capabilities with malformed model options', async ({
page,
}) => {
await installLangBotApiMocks(page, { authenticated: true });
await page.goto('/home/pipelines?id=pipeline-ai');
await expect(page.locator('input[name="basic.name"]')).toBeVisible();
await page.getByRole('button', { name: /^AI$/ }).click();
await expect(page.getByText('Runtime')).toBeVisible();
await expect(
page.locator('[data-slot="card-title"]').filter({
hasText: 'Built-in Agent',
}),
).toBeVisible();
await expect(
page.locator('label').filter({
hasText: 'Model',
}),
).toBeVisible();
await expect(page.getByText('A <Select.Item')).toHaveCount(0);
await expect(page.getByText('500')).toHaveCount(0);
});
test('creates, edits, and deletes a knowledge base', async ({ page }) => { test('creates, edits, and deletes a knowledge base', async ({ page }) => {
await installLangBotApiMocks(page, { authenticated: true }); await installLangBotApiMocks(page, { authenticated: true });
+169 -5
View File
@@ -72,7 +72,11 @@ interface LangBotApiMockState {
counters: Record<string, number>; counters: Record<string, number>;
knowledgeBases: KnowledgeBaseMock[]; knowledgeBases: KnowledgeBaseMock[];
mcpServers: MCPServerMock[]; mcpServers: MCPServerMock[];
monitoringData: unknown;
monitoringSessions: unknown[];
pipelines: PipelineMock[]; pipelines: PipelineMock[];
sessionAnalyses: Record<string, unknown>;
sessionMessages: Record<string, unknown[]>;
skills: SkillMock[]; skills: SkillMock[];
} }
@@ -122,12 +126,14 @@ function emptyMonitoringData() {
}, },
messages: [], messages: [],
llmCalls: [], llmCalls: [],
toolCalls: [],
embeddingCalls: [], embeddingCalls: [],
sessions: [], sessions: [],
errors: [], errors: [],
totalCount: { totalCount: {
messages: 0, messages: 0,
llmCalls: 0, llmCalls: 0,
toolCalls: 0,
embeddingCalls: 0, embeddingCalls: 0,
sessions: 0, sessions: 0,
errors: 0, errors: 0,
@@ -188,6 +194,102 @@ function makePipeline(
}; };
} }
function pipelineMetadata() {
return {
configs: [
{
name: 'ai',
label: {
en_US: 'AI Capabilities',
zh_Hans: 'AI 能力',
},
stages: [
{
name: 'runner',
label: {
en_US: 'Runtime',
zh_Hans: '运行方式',
},
config: [
{
id: 'runner',
name: 'runner',
label: {
en_US: 'Runner',
zh_Hans: '运行器',
},
type: 'select',
required: true,
default: 'local-agent',
options: [
{
name: 'local-agent',
label: {
en_US: 'Built-in Agent',
zh_Hans: '内置 Agent',
},
},
],
},
],
},
{
name: 'local-agent',
label: {
en_US: 'Built-in Agent',
zh_Hans: '内置 Agent',
},
config: [
{
id: 'model',
name: 'model',
label: {
en_US: 'Model',
zh_Hans: '模型',
},
type: 'model-fallback-selector',
required: true,
default: {
primary: 'llm-valid',
fallbacks: [],
},
},
],
},
],
},
],
};
}
function providerModelList() {
return {
models: [
{
uuid: '',
name: 'Broken Empty UUID Model',
provider_uuid: 'provider-empty',
provider: {
uuid: 'provider-empty',
name: 'Broken Provider',
requester: 'mock-provider',
},
},
{
uuid: 'llm-valid',
name: 'Valid Mock Model',
provider_uuid: 'provider-valid',
provider: {
uuid: 'provider-valid',
name: 'Mock Provider',
requester: 'mock-provider',
},
abilities: ['func_call'],
},
],
};
}
function knowledgeEngine() { function knowledgeEngine() {
return { return {
plugin_id: 'builtin/minimal-knowledge', plugin_id: 'builtin/minimal-knowledge',
@@ -389,8 +491,20 @@ async function handleBackendApi(route: Route, state: LangBotApiMockState) {
}); });
} }
if (path === '/api/v1/provider/models/llm') {
return fulfillJson(route, providerModelList());
}
if (path === '/api/v1/provider/models/embedding') {
return fulfillJson(route, { models: [] });
}
if (path === '/api/v1/provider/models/rerank') {
return fulfillJson(route, { models: [] });
}
if (path === '/api/v1/pipelines/_/metadata') { if (path === '/api/v1/pipelines/_/metadata') {
return fulfillJson(route, { configs: [] }); return fulfillJson(route, pipelineMetadata());
} }
if (path === '/api/v1/pipelines') { if (path === '/api/v1/pipelines') {
@@ -689,11 +803,43 @@ async function handleBackendApi(route: Route, state: LangBotApiMockState) {
} }
if (path === '/api/v1/monitoring/data') { if (path === '/api/v1/monitoring/data') {
return fulfillJson(route, emptyMonitoringData()); return fulfillJson(route, state.monitoringData);
}
if (path === '/api/v1/monitoring/sessions') {
return fulfillJson(route, {
sessions: state.monitoringSessions,
total: state.monitoringSessions.length,
});
}
if (path === '/api/v1/monitoring/messages') {
const sessionId = url.searchParams.get('sessionId') || '';
const messages = state.sessionMessages[sessionId] || [];
return fulfillJson(route, {
messages,
total: messages.length,
});
}
const sessionAnalysisMatch = path.match(
/^\/api\/v1\/monitoring\/sessions\/([^/]+)\/analysis$/,
);
if (sessionAnalysisMatch) {
const sessionId = decodeURIComponent(sessionAnalysisMatch[1]);
return fulfillJson(
route,
state.sessionAnalyses[sessionId] || {
session_id: sessionId,
found: true,
tool_calls: [],
},
);
} }
if (path === '/api/v1/monitoring/overview') { if (path === '/api/v1/monitoring/overview') {
return fulfillJson(route, emptyMonitoringData().overview); const data = state.monitoringData as { overview?: unknown };
return fulfillJson(route, data.overview || emptyMonitoringData().overview);
} }
if (path === '/api/v1/monitoring/token-statistics') { if (path === '/api/v1/monitoring/token-statistics') {
@@ -798,15 +944,33 @@ async function handleCloudApi(route: Route) {
export async function installLangBotApiMocks( export async function installLangBotApiMocks(
page: Page, page: Page,
options: { authenticated?: boolean; storage?: JsonRecord } = {}, options: {
authenticated?: boolean;
monitoringData?: unknown;
monitoringSessions?: unknown[];
sessionAnalyses?: Record<string, unknown>;
sessionMessages?: Record<string, unknown[]>;
storage?: JsonRecord;
} = {},
) { ) {
const { authenticated = false, storage = {} } = options; const {
authenticated = false,
monitoringData,
monitoringSessions,
sessionAnalyses,
sessionMessages,
storage = {},
} = options;
const state: LangBotApiMockState = { const state: LangBotApiMockState = {
bots: [], bots: [],
counters: {}, counters: {},
knowledgeBases: [], knowledgeBases: [],
mcpServers: [], mcpServers: [],
monitoringData: monitoringData || emptyMonitoringData(),
monitoringSessions: monitoringSessions || [],
pipelines: [], pipelines: [],
sessionAnalyses: sessionAnalyses || {},
sessionMessages: sessionMessages || {},
skills: [], skills: [],
}; };
+453
View File
@@ -0,0 +1,453 @@
import { expect, test } from '@playwright/test';
import { installLangBotApiMocks } from './fixtures/langbot-api';
import { buildConversationTurns } from '../../src/app/home/monitoring/utils/conversationTurns';
import {
ErrorLog,
LLMCall,
MonitoringMessage,
ToolCall,
} from '../../src/app/home/monitoring/types/monitoring';
const bot = {
id: 'bot-monitoring',
name: 'Monitoring Bot',
};
const pipeline = {
id: 'pipeline-monitoring',
name: 'Monitoring Pipeline',
};
function time(minute: number) {
return new Date(`2026-07-02T10:${String(minute).padStart(2, '0')}:00Z`);
}
function message(
id: string,
role: 'user' | 'assistant',
minute: number,
content: string,
sessionId = 'session-agent',
): MonitoringMessage {
return {
id,
timestamp: time(minute),
botId: bot.id,
botName: bot.name,
pipelineId: pipeline.id,
pipelineName: pipeline.name,
messageContent: content,
sessionId,
status: 'success',
level: 'info',
platform: role === 'user' ? 'person' : 'bot',
userId: 'user-1',
userName: 'Playwright User',
runnerName: 'local-agent',
variables: '{}',
role,
};
}
function llmCall(
id: string,
minute: number,
messageId: string | undefined,
input: number,
output: number,
duration: number,
sessionId = 'session-agent',
): LLMCall {
return {
id,
timestamp: time(minute),
modelName: 'gpt-5.5',
tokens: {
input,
output,
total: input + output,
},
duration,
status: 'success',
botId: bot.id,
botName: bot.name,
pipelineId: pipeline.id,
pipelineName: pipeline.name,
sessionId,
messageId,
};
}
function errorLog(id: string, minute: number, messageId: string): ErrorLog {
return {
id,
timestamp: time(minute),
errorType: 'ToolExecutionError',
errorMessage: 'Tool retry failed',
botId: bot.id,
botName: bot.name,
pipelineId: pipeline.id,
pipelineName: pipeline.name,
sessionId: 'session-agent',
messageId,
};
}
function toolCall(
id: string,
minute: number,
messageId: string | undefined,
toolName: string,
duration: number,
sessionId = 'session-agent',
status: 'success' | 'error' = 'success',
): ToolCall {
return {
id,
timestamp: time(minute),
toolName,
toolSource: 'native',
duration,
status,
botId: bot.id,
botName: bot.name,
pipelineId: pipeline.id,
pipelineName: pipeline.name,
sessionId,
messageId,
arguments: JSON.stringify({ query: toolName }),
result: status === 'success' ? JSON.stringify({ ok: true }) : undefined,
errorMessage: status === 'error' ? 'Tool failed' : undefined,
};
}
function rawMessage(message: MonitoringMessage) {
return {
id: message.id,
timestamp: message.timestamp.toISOString(),
bot_id: message.botId,
bot_name: message.botName,
pipeline_id: message.pipelineId,
pipeline_name: message.pipelineName,
message_content: message.messageContent,
session_id: message.sessionId,
status: message.status,
level: message.level,
platform: message.platform,
user_id: message.userId,
user_name: message.userName,
runner_name: message.runnerName,
variables: message.variables,
role: message.role,
};
}
function rawLlmCall(call: LLMCall) {
return {
id: call.id,
timestamp: call.timestamp.toISOString(),
model_name: call.modelName,
input_tokens: call.tokens.input,
output_tokens: call.tokens.output,
total_tokens: call.tokens.total,
duration: call.duration,
cost: call.cost,
status: call.status,
bot_id: call.botId,
bot_name: call.botName,
pipeline_id: call.pipelineId,
pipeline_name: call.pipelineName,
session_id: call.sessionId,
error_message: call.errorMessage,
message_id: call.messageId,
};
}
function rawError(error: ErrorLog) {
return {
id: error.id,
timestamp: error.timestamp.toISOString(),
error_type: error.errorType,
error_message: error.errorMessage,
bot_id: error.botId,
bot_name: error.botName,
pipeline_id: error.pipelineId,
pipeline_name: error.pipelineName,
session_id: error.sessionId,
stack_trace: error.stackTrace,
message_id: error.messageId,
};
}
function rawToolCall(call: ToolCall) {
return {
id: call.id,
timestamp: call.timestamp.toISOString(),
tool_name: call.toolName,
tool_source: call.toolSource,
duration: call.duration,
status: call.status,
bot_id: call.botId,
bot_name: call.botName,
pipeline_id: call.pipelineId,
pipeline_name: call.pipelineName,
session_id: call.sessionId,
message_id: call.messageId,
arguments: call.arguments,
result: call.result,
error_message: call.errorMessage,
};
}
function monitoringScenario() {
const messages = [
message(
'single-user',
'user',
1,
'Standalone question with no reply',
'session-single',
),
message('agent-user-1', 'user', 10, 'Need deployment plan'),
message('agent-assistant-1', 'assistant', 11, 'Agent step 1: inspect repo'),
message('agent-assistant-2', 'assistant', 12, 'Agent step 2: run tests'),
message(
'agent-assistant-3',
'assistant',
13,
'Final answer: deployment plan ready',
),
message('agent-user-2', 'user', 20, 'Continue with rollback plan'),
message('agent-assistant-4', 'assistant', 21, 'Rollback plan ready'),
];
const llmCalls = [
llmCall('agent-call-1', 10, 'agent-user-1', 100, 40, 120),
llmCall('agent-call-2', 11, 'agent-user-1', 200, 60, 220),
llmCall('agent-call-3', 12, 'agent-user-1', 300, 90, 260),
llmCall('agent-call-4', 20, 'agent-user-2', 50, 25, 80),
];
const errors = [errorLog('agent-error-1', 12, 'agent-user-1')];
const toolCalls = [
toolCall('agent-tool-1', 11, 'agent-user-1', 'repo_search', 90),
toolCall('agent-tool-2', 12, 'agent-user-1', 'run_tests', 150),
toolCall('agent-tool-3', 20, 'agent-user-2', 'rollback_lookup', 70),
];
return {
messages,
llmCalls,
toolCalls,
errors,
};
}
function rawMonitoringData() {
const scenario = monitoringScenario();
return {
overview: {
total_messages: scenario.messages.length,
llm_calls: scenario.llmCalls.length,
embedding_calls: 0,
model_calls: scenario.llmCalls.length,
success_rate: 100,
active_sessions: 2,
},
messages: scenario.messages.map(rawMessage),
llmCalls: scenario.llmCalls.map(rawLlmCall),
toolCalls: scenario.toolCalls.map(rawToolCall),
embeddingCalls: [],
sessions: [],
errors: scenario.errors.map(rawError),
totalCount: {
messages: scenario.messages.length,
llmCalls: scenario.llmCalls.length,
toolCalls: scenario.toolCalls.length,
embeddingCalls: 0,
sessions: 0,
errors: scenario.errors.length,
},
};
}
test.describe('monitoring conversation turn grouping', () => {
test('keeps a single user message as one observable turn', () => {
const userOnly = message(
'single-user-only',
'user',
1,
'No answer yet',
'session-user-only',
);
const turns = buildConversationTurns([userOnly], [], []);
expect(turns).toHaveLength(1);
expect(turns[0].id).toBe(userOnly.id);
expect(turns[0].userMessage?.messageContent).toBe('No answer yet');
expect(turns[0].assistantMessages).toHaveLength(0);
expect(turns[0].llmCalls).toHaveLength(0);
expect(turns[0].totalTokens).toBe(0);
});
test('groups multi-step agent execution and multiple replies into one user turn', () => {
const scenario = monitoringScenario();
const turns = buildConversationTurns(
scenario.messages,
scenario.llmCalls,
scenario.errors,
scenario.toolCalls,
);
const agentTurn = turns.find((turn) => turn.id === 'agent-user-1');
expect(agentTurn).toBeTruthy();
expect(agentTurn?.userMessage?.messageContent).toBe('Need deployment plan');
expect(
agentTurn?.assistantMessages.map((item) => item.messageContent),
).toEqual([
'Agent step 1: inspect repo',
'Agent step 2: run tests',
'Final answer: deployment plan ready',
]);
expect(agentTurn?.llmCalls).toHaveLength(3);
expect(agentTurn?.toolCalls).toHaveLength(2);
expect(agentTurn?.errors).toHaveLength(1);
expect(agentTurn?.totalTokens).toBe(790);
expect(agentTurn?.totalDuration).toBe(600);
expect(agentTurn?.totalToolDuration).toBe(240);
});
test('starts a new turn for each later user message in the same session', () => {
const firstUser = message('same-session-user-1', 'user', 1, 'First');
const firstReply = message(
'same-session-reply-1',
'assistant',
2,
'First reply',
);
const secondUser = message('same-session-user-2', 'user', 3, 'Second');
const secondReply = message(
'same-session-reply-2',
'assistant',
4,
'Second reply',
);
const turns = buildConversationTurns(
[firstUser, firstReply, secondUser, secondReply],
[
llmCall('same-session-call-1', 1, firstUser.id, 10, 5, 40),
llmCall('same-session-call-2', 3, secondUser.id, 20, 10, 50),
],
[],
);
expect(turns.map((turn) => turn.id)).toEqual([
'same-session-user-2',
'same-session-user-1',
]);
expect(
turns[0].assistantMessages.map((item) => item.messageContent),
).toEqual(['Second reply']);
expect(
turns[1].assistantMessages.map((item) => item.messageContent),
).toEqual(['First reply']);
});
test('attaches calls without message ids by session time', () => {
const user = message('fallback-user', 'user', 1, 'Use session fallback');
const assistant = message(
'fallback-assistant',
'assistant',
2,
'Fallback reply',
);
const call = llmCall('fallback-call', 2, undefined, 25, 5, 70);
const turns = buildConversationTurns([user, assistant], [call], []);
expect(turns).toHaveLength(1);
expect(turns[0].llmCalls).toHaveLength(1);
expect(turns[0].llmCalls[0].id).toBe(call.id);
expect(turns[0].totalTokens).toBe(30);
});
test('attaches tool calls without message ids by session time', () => {
const user = message('tool-fallback-user', 'user', 1, 'Use tool fallback');
const assistant = message(
'tool-fallback-assistant',
'assistant',
2,
'Tool fallback reply',
);
const call = toolCall(
'tool-fallback-call',
2,
undefined,
'memory_lookup',
45,
);
const turns = buildConversationTurns([user, assistant], [], [], [call]);
expect(turns).toHaveLength(1);
expect(turns[0].toolCalls).toHaveLength(1);
expect(turns[0].toolCalls[0].id).toBe(call.id);
expect(turns[0].totalToolDuration).toBe(45);
});
test('renders user-only, multi-agent, and multi-turn cases in the monitoring page', async ({
page,
}) => {
await installLangBotApiMocks(page, {
authenticated: true,
monitoringData: rawMonitoringData(),
});
await page.goto('/home/monitoring');
await expect(page.getByText('3 conversation turns')).toBeVisible();
await expect(
page.getByText('Standalone question with no reply'),
).toBeVisible();
await expect(page.getByText('No assistant reply recorded')).toBeVisible();
await expect(page.getByText('Need deployment plan')).toBeVisible();
await expect(page.getByText('Agent step 1: inspect repo')).toBeVisible();
await expect(page.getByText('Assistant +2')).toBeVisible();
await expect(page.getByText('3 LLM')).toBeVisible();
await expect(page.getByText('2 tools')).toBeVisible();
await expect(page.getByText('790 tokens')).toBeVisible();
await expect(page.getByText('1 errors')).toBeVisible();
await expect(page.getByText('Continue with rollback plan')).toBeVisible();
await expect(page.getByText('Rollback plan ready')).toBeVisible();
const agentTurn = page
.locator('div[role="button"]')
.filter({ hasText: 'Need deployment plan' });
await expect(agentTurn).toHaveCount(1);
await agentTurn.click();
await expect(page.getByText('Conversation Trace')).toBeVisible();
await expect(page.getByText('Agent step 2: run tests')).toBeVisible();
await expect(
page.getByText('Final answer: deployment plan ready'),
).toBeVisible();
await expect(page.getByText('LLM Calls (3)')).toBeVisible();
await expect(page.getByText('#3 gpt-5.5')).toBeVisible();
await expect(page.getByText('In: 300')).toBeVisible();
await expect(page.getByText('Out: 90')).toBeVisible();
await expect(page.getByText('Total: 390')).toBeVisible();
await expect(page.getByText('Tool Calls (2)')).toBeVisible();
await expect(page.getByText('#1 repo_search')).toBeVisible();
await expect(page.getByText('#2 run_tests')).toBeVisible();
await expect(page.getByText('Arguments')).toHaveCount(0);
await expect(page.getByText('Result')).toHaveCount(0);
await page.getByText('#1 repo_search').click();
await expect(page.getByText('Arguments').first()).toBeVisible();
await expect(page.getByText('Result').first()).toBeVisible();
await expect(page.getByText('Tool retry failed')).toBeVisible();
});
});
@@ -0,0 +1,195 @@
import { expect, test } from '@playwright/test';
import { installLangBotApiMocks } from './fixtures/langbot-api';
const bot = {
id: 'bot-pipeline-monitoring',
name: 'Pipeline Bot',
};
const pipeline = {
id: 'pipeline-monitoring',
name: 'Pipeline Under Test',
};
function at(minute: number) {
return `2026-07-02T10:${String(minute).padStart(2, '0')}:00Z`;
}
function message(
id: string,
role: 'user' | 'assistant',
minute: number,
content: string,
sessionId = 'session-pipeline-agent',
) {
return {
id,
timestamp: at(minute),
bot_id: bot.id,
bot_name: bot.name,
pipeline_id: pipeline.id,
pipeline_name: pipeline.name,
message_content: content,
session_id: sessionId,
status: 'success',
level: 'info',
platform: role === 'user' ? 'person' : 'bot',
user_id: 'pipeline-user',
user_name: 'Pipeline User',
runner_name: 'local-agent',
variables: '{}',
role,
};
}
function llmCall(
id: string,
minute: number,
messageId: string,
input: number,
output: number,
duration: number,
) {
return {
id,
timestamp: at(minute),
model_name: 'gpt-5.5',
input_tokens: input,
output_tokens: output,
total_tokens: input + output,
duration,
cost: 0,
status: 'success',
bot_id: bot.id,
bot_name: bot.name,
pipeline_id: pipeline.id,
pipeline_name: pipeline.name,
session_id: 'session-pipeline-agent',
message_id: messageId,
};
}
function toolCall(id: string, minute: number, messageId: string, name: string) {
return {
id,
timestamp: at(minute),
tool_name: name,
tool_source: 'native',
duration: 120,
status: 'success',
bot_id: bot.id,
bot_name: bot.name,
pipeline_id: pipeline.id,
pipeline_name: pipeline.name,
session_id: 'session-pipeline-agent',
message_id: messageId,
arguments: JSON.stringify({ query: name }),
result: JSON.stringify({ ok: true }),
};
}
function monitoringData() {
const messages = [
message(
'single-user',
'user',
1,
'Pipeline single user message without reply',
'session-pipeline-single',
),
message('agent-user', 'user', 10, 'Pipeline needs a deployment plan'),
message(
'agent-assistant-1',
'assistant',
11,
'Pipeline agent step 1: inspect repository',
),
message(
'agent-assistant-2',
'assistant',
12,
'Pipeline agent step 2: run tests',
),
message(
'agent-assistant-3',
'assistant',
13,
'Pipeline final answer: deployment ready',
),
];
const llmCalls = [
llmCall('pipeline-call-1', 10, 'agent-user', 100, 40, 180),
llmCall('pipeline-call-2', 11, 'agent-user', 140, 50, 220),
];
const toolCalls = [
toolCall('pipeline-tool-1', 11, 'agent-user', 'repo_search'),
toolCall('pipeline-tool-2', 12, 'agent-user', 'run_tests'),
];
return {
overview: {
total_messages: messages.length,
llm_calls: llmCalls.length,
embedding_calls: 0,
model_calls: llmCalls.length,
success_rate: 100,
active_sessions: 2,
},
messages,
llmCalls,
toolCalls,
embeddingCalls: [],
sessions: [],
errors: [],
totalCount: {
messages: messages.length,
llmCalls: llmCalls.length,
toolCalls: toolCalls.length,
embeddingCalls: 0,
sessions: 0,
errors: 0,
},
};
}
test.describe('pipeline monitoring conversation turns', () => {
test('uses conversation turns and folded tool calls in the pipeline dashboard', async ({
page,
}) => {
await installLangBotApiMocks(page, {
authenticated: true,
monitoringData: monitoringData(),
});
await page.goto(`/home/pipelines?id=${pipeline.id}`);
await page.getByRole('tab', { name: 'Dashboard' }).click();
await expect(page.getByText('2 conversation turns')).toBeVisible();
await expect(
page.getByText('Pipeline single user message without reply'),
).toBeVisible();
await expect(
page.getByText('Pipeline needs a deployment plan'),
).toBeVisible();
await expect(
page.getByText('Pipeline agent step 1: inspect repository'),
).toBeVisible();
await expect(page.getByText('Assistant +2')).toBeVisible();
await expect(page.getByText('2 tools')).toBeVisible();
const agentTurn = page
.locator('div[role="button"]')
.filter({ hasText: 'Pipeline needs a deployment plan' });
await expect(agentTurn).toHaveCount(1);
await agentTurn.click();
await expect(page.getByText('Tool Calls (2)')).toBeVisible();
await expect(page.getByText('#1 repo_search')).toBeVisible();
await expect(page.getByText('#2 run_tests')).toBeVisible();
await expect(page.getByText('Arguments')).toHaveCount(0);
await page.getByText('#1 repo_search').click();
await expect(page.getByText('Arguments')).toBeVisible();
await expect(page.getByText('Result')).toBeVisible();
});
});