mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-08-31 06:37:14 +00:00
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:
@@ -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,
|
||||||
|
|||||||
@@ -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:
|
||||||
await persisted_session.refresh()
|
try:
|
||||||
|
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
|
||||||
|
|||||||
@@ -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]] = []
|
||||||
|
|
||||||
|
|||||||
@@ -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)"""
|
||||||
|
|||||||
@@ -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:
|
||||||
await self._cleanup_box_stdio_session()
|
# 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()
|
||||||
|
|
||||||
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,28 +202,55 @@ 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}')
|
||||||
|
|
||||||
try:
|
# Reuse an already-running managed process instead of rebuilding it.
|
||||||
process_workspace = (
|
# The Box runtime keeps the managed process alive across a transient
|
||||||
self._build_workspace(host_path=host_path, workdir=process_cwd, mount_path=process_cwd)
|
# WebSocket transport drop, so on a reconnect we only need to re-attach
|
||||||
if host_path
|
# the WS below. Rebuilding here would needlessly stop a healthy process
|
||||||
else workspace
|
# and re-run the (slow, network-touching) dependency bootstrap.
|
||||||
|
if not await self._managed_process_is_running():
|
||||||
|
try:
|
||||||
|
process_workspace = (
|
||||||
|
self._build_workspace(host_path=host_path, workdir=process_cwd, mount_path=process_cwd)
|
||||||
|
if host_path
|
||||||
|
else workspace
|
||||||
|
)
|
||||||
|
payload = process_workspace.build_process_payload(
|
||||||
|
self.server_config['command'],
|
||||||
|
self.server_config.get('args', []),
|
||||||
|
env=self.server_config.get('env', {}),
|
||||||
|
cwd=process_cwd,
|
||||||
|
)
|
||||||
|
if install_cmd:
|
||||||
|
payload = self._wrap_process_payload_with_python_env(payload, process_cwd)
|
||||||
|
payload['process_id'] = self.process_id
|
||||||
|
await workspace.box_service.start_managed_process(workspace.session_id, payload)
|
||||||
|
except Exception:
|
||||||
|
self.owner.error_phase = MCPSessionErrorPhase.PROCESS_START
|
||||||
|
raise
|
||||||
|
else:
|
||||||
|
self.ap.logger.info(
|
||||||
|
f'MCP server {self.server_name}: reusing live managed process '
|
||||||
|
f'process_id={self.process_id} (transport reconnect)'
|
||||||
)
|
)
|
||||||
payload = process_workspace.build_process_payload(
|
|
||||||
self.server_config['command'],
|
|
||||||
self.server_config.get('args', []),
|
|
||||||
env=self.server_config.get('env', {}),
|
|
||||||
cwd=process_cwd,
|
|
||||||
)
|
|
||||||
if install_cmd:
|
|
||||||
payload = self._wrap_process_payload_with_python_env(payload, process_cwd)
|
|
||||||
payload['process_id'] = self.process_id
|
|
||||||
await workspace.box_service.start_managed_process(workspace.session_id, payload)
|
|
||||||
except Exception:
|
|
||||||
self.owner.error_phase = MCPSessionErrorPhase.PROCESS_START
|
|
||||||
raise
|
|
||||||
|
|
||||||
|
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:
|
try:
|
||||||
websocket_url = workspace.get_managed_process_websocket_url(self.process_id)
|
|
||||||
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
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -191,45 +191,47 @@ 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">
|
||||||
<TabsTrigger value="config" className="gap-1.5">
|
<TabsList>
|
||||||
<Settings className="size-3.5" />
|
<TabsTrigger value="config" className="gap-1.5">
|
||||||
{t('bots.configuration')}
|
<Settings className="size-3.5" />
|
||||||
</TabsTrigger>
|
{t('bots.configuration')}
|
||||||
<TabsTrigger value="logs" className="gap-1.5">
|
</TabsTrigger>
|
||||||
<FileText className="size-3.5" />
|
<TabsTrigger value="logs" className="gap-1.5">
|
||||||
{t('bots.logs')}
|
<FileText className="size-3.5" />
|
||||||
</TabsTrigger>
|
{t('bots.logs')}
|
||||||
<TabsTrigger value="sessions" className="gap-1.5">
|
</TabsTrigger>
|
||||||
<Users className="size-3.5" />
|
<TabsTrigger value="sessions" className="gap-1.5">
|
||||||
{t('bots.sessionMonitor.title')}
|
<Users className="size-3.5" />
|
||||||
{activeTab === 'sessions' && (
|
{t('bots.sessionMonitor.title')}
|
||||||
<button
|
</TabsTrigger>
|
||||||
type="button"
|
</TabsList>
|
||||||
className="inline-flex items-center justify-center ml-0.5"
|
{activeTab === 'sessions' && (
|
||||||
onPointerDown={(e) => e.stopPropagation()}
|
<button
|
||||||
onClick={(e) => {
|
type="button"
|
||||||
e.stopPropagation();
|
aria-label={t('bots.sessionMonitor.refresh')}
|
||||||
e.preventDefault();
|
title={t('bots.sessionMonitor.refresh')}
|
||||||
if (isRefreshingSessions) return;
|
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"
|
||||||
setIsRefreshingSessions(true);
|
disabled={isRefreshingSessions}
|
||||||
const minDelay = new Promise((r) => setTimeout(r, 500));
|
onClick={() => {
|
||||||
Promise.all([
|
if (isRefreshingSessions) return;
|
||||||
sessionMonitorRef.current?.refreshSessions(),
|
setIsRefreshingSessions(true);
|
||||||
minDelay,
|
const minDelay = new Promise((r) => setTimeout(r, 500));
|
||||||
]).finally(() => setIsRefreshingSessions(false));
|
Promise.all([
|
||||||
}}
|
sessionMonitorRef.current?.refreshSessions(),
|
||||||
>
|
minDelay,
|
||||||
<RefreshCw
|
]).finally(() => setIsRefreshingSessions(false));
|
||||||
className={cn(
|
}}
|
||||||
'size-3 text-muted-foreground hover:text-foreground transition-colors',
|
>
|
||||||
isRefreshingSessions && 'animate-spin',
|
<RefreshCw
|
||||||
)}
|
className={cn(
|
||||||
/>
|
'size-3.5',
|
||||||
</button>
|
isRefreshingSessions && 'animate-spin',
|
||||||
)}
|
)}
|
||||||
</TabsTrigger>
|
/>
|
||||||
</TabsList>
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* 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,12 +849,20 @@ const MCPForm = forwardRef<MCPFormHandle, MCPFormProps>(function MCPForm(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const { task_id } = await httpClient.testMCPServer('_', {
|
const testTarget = shouldTestPersistedServer ? serverName : '_';
|
||||||
name: form.getValues('name'),
|
const testPayload = shouldTestPersistedServer
|
||||||
mode,
|
? {}
|
||||||
enable: true,
|
: ({
|
||||||
extra_args: extraArgsData,
|
name: serverName,
|
||||||
} as MCPServer);
|
mode,
|
||||||
|
enable: true,
|
||||||
|
extra_args: extraArgsData,
|
||||||
|
} 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,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -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,127 +216,22 @@ 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 */}
|
{!loading && (!data || conversationTurns.length === 0) && (
|
||||||
<div className="flex-1">
|
<div className="flex flex-col items-center justify-center text-muted-foreground py-16 gap-2">
|
||||||
<div className="flex items-center gap-2 mb-1">
|
<MessageSquare className="h-[3rem] w-[3rem]" />
|
||||||
<span className="text-xs text-muted-foreground font-mono">
|
<div className="text-sm">
|
||||||
ID: {msg.id}
|
{t('monitoring.messageList.noMessages')}
|
||||||
</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>
|
</div>
|
||||||
)}
|
</div>
|
||||||
|
)}
|
||||||
{!loading &&
|
|
||||||
(!data || !data.messages || data.messages.length === 0) && (
|
|
||||||
<div className="flex flex-col items-center justify-center text-muted-foreground py-16 gap-2">
|
|
||||||
<MessageSquare className="h-[3rem] w-[3rem]" />
|
|
||||||
<div className="text-sm">
|
|
||||||
{t('monitoring.messageList.noMessages')}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
|
|||||||
@@ -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,142 +177,22 @@ 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] &&
|
|
||||||
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 &&
|
{!loading && (!data || conversationTurns.length === 0) && (
|
||||||
(!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">
|
{t('monitoring.messageList.noMessages')}
|
||||||
{t('monitoring.messageList.noMessages')}
|
</p>
|
||||||
</p>
|
</div>
|
||||||
</div>
|
)}
|
||||||
)}
|
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
{/* Errors Tab */}
|
{/* Errors Tab */}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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: '入力トークン',
|
||||||
|
|||||||
@@ -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: 'Входные токены',
|
||||||
|
|||||||
@@ -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 อินพุต',
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 });
|
||||||
|
|
||||||
|
|||||||
@@ -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: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user