diff --git a/web/src/app/home/monitoring/components/ConversationTurnList.tsx b/web/src/app/home/monitoring/components/ConversationTurnList.tsx new file mode 100644 index 000000000..41a114158 --- /dev/null +++ b/web/src/app/home/monitoring/components/ConversationTurnList.tsx @@ -0,0 +1,476 @@ +import React from 'react'; +import { useTranslation } from 'react-i18next'; +import { + AlertCircle, + Bot, + ChevronDown, + ChevronRight, + Clock, + Cpu, + Hash, + User, +} 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 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 ( + + {icon} + {label} + + ); +} + +function MetaItem({ label, value }: { label: string; value?: string }) { + return ( +
+
{label}
+
+ {value || '-'} +
+
+ ); +} + +function MessageLane({ + label, + icon, + content, + empty, + maxLines, +}: { + label: string; + icon: React.ReactNode; + content?: string; + empty: string; + maxLines: number; +}) { + return ( +
+
+ {icon} + {label} +
+
+ {content && hasRenderableMessageContent(content) ? ( + + ) : ( + {empty} + )} +
+
+ ); +} + +function ExpandedMessage({ + message, + label, +}: { + message: MonitoringMessage; + label: string; +}) { + return ( +
+
+ + {label} + + {message.timestamp.toLocaleString()} + ID: {shortId(message.id)} +
+
+ +
+
+ ); +} + +export function ConversationTurnList({ + turns, + expandedTurnId, + onToggleTurn, +}: ConversationTurnListProps) { + const { t } = useTranslation(); + + return ( +
+
+ + {t('monitoring.messageList.turns', { + defaultValue: '{{count}} 轮对话', + count: turns.length, + })} + +
+ + {turns.map((turn) => { + const expanded = expandedTurnId === turn.id; + const firstAssistant = turn.assistantMessages[0]; + const assistantOverflow = Math.max( + turn.assistantMessages.length - 1, + 0, + ); + + return ( +
+
onToggleTurn(turn.id)} + onKeyDown={(event) => { + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault(); + onToggleTurn(turn.id); + } + }} + > +
+
+
+ {expanded ? ( + + ) : ( + + )} + + Turn: {shortId(turn.id)} + +
+ +
+ + {turn.botName} + + + + {turn.pipelineName} + + {turn.runnerName && ( + <> + + + {turn.runnerName} + + + )} +
+ +
+ } + content={turn.userMessage?.messageContent} + empty={t('monitoring.messageList.noUserMessage', { + defaultValue: '未记录用户输入', + })} + maxLines={2} + /> + 0 + ? t('monitoring.messageList.assistantMessageCount', { + defaultValue: '助手 +{{count}}', + count: assistantOverflow, + }) + : t('monitoring.messageList.assistantMessage', { + defaultValue: '助手', + }) + } + icon={} + content={firstAssistant?.messageContent} + empty={t('monitoring.messageList.noAssistantMessage', { + defaultValue: '未记录助手回复', + })} + maxLines={2} + /> +
+
+ +
+
+ {turn.lastActivityAt.toLocaleString()} +
+
+ {turn.level} +
+
+ } + label={`${turn.llmCalls.length} LLM`} + /> + } + label={`${turn.totalTokens.toLocaleString()} tokens`} + /> + } + label={formatDuration(turn.totalDuration)} + /> + {turn.errors.length > 0 && ( + } + label={`${turn.errors.length} errors`} + tone="error" + /> + )} +
+
+
+
+ + {expanded && ( +
+
+
+ + + + + +
+ +
+

+ + {t('monitoring.messageList.conversationTrace', { + defaultValue: '消息链路', + })} +

+
+ {turn.messages.map((message) => ( + + ))} +
+
+ +
+

+ + {t('monitoring.llmCalls.title', { + defaultValue: 'LLM 调用', + })}{' '} + ({turn.llmCalls.length}) +

+ +
+ + + +
+ +
+ {turn.llmCalls.length > 0 ? ( + turn.llmCalls.map((call, index) => ( +
+
+
+ + #{index + 1} {call.modelName} + + + {call.status} + +
+ + {formatDuration(call.duration)} + +
+
+ In: {call.tokens.input} + Out: {call.tokens.output} + Total: {call.tokens.total} + + ID: {shortId(call.id)} + +
+ {call.errorMessage && ( +
+ {call.errorMessage} +
+ )} +
+ )) + ) : ( +
+ {t('monitoring.messageList.noLlmCalls', { + defaultValue: '未记录模型调用', + })} +
+ )} +
+
+ + {turn.errors.length > 0 && ( +
+

+ + {t('monitoring.errors.title', { + defaultValue: '错误日志', + })}{' '} + ({turn.errors.length}) +

+
+ {turn.errors.map((error) => ( +
+
+ + {error.errorType} + + + {error.timestamp.toLocaleString()} + +
+
+ {error.errorMessage} +
+
+ ))} +
+
+ )} +
+
+ )} +
+ ); + })} +
+ ); +} diff --git a/web/src/app/home/monitoring/hooks/useMonitoringData.ts b/web/src/app/home/monitoring/hooks/useMonitoringData.ts index 7d69e586c..d88d25ab4 100644 --- a/web/src/app/home/monitoring/hooks/useMonitoringData.ts +++ b/web/src/app/home/monitoring/hooks/useMonitoringData.ts @@ -145,8 +145,10 @@ export function useMonitoringData(filterState: FilterState) { level: string; platform?: string; user_id?: string; + user_name?: string; runner_name?: string; variables?: string; + role?: string; }) => ({ id: msg.id, timestamp: parseUTCTimestamp(msg.timestamp), @@ -160,8 +162,10 @@ export function useMonitoringData(filterState: FilterState) { level: msg.level as 'info' | 'warning' | 'error' | 'debug', platform: msg.platform, userId: msg.user_id, + userName: msg.user_name, runnerName: msg.runner_name, variables: msg.variables, + role: msg.role, }), ), llmCalls: llmCalls.map( @@ -179,6 +183,7 @@ export function useMonitoringData(filterState: FilterState) { bot_name: string; pipeline_id: string; pipeline_name: string; + session_id?: string; error_message?: string; message_id?: string; }) => ({ @@ -197,6 +202,7 @@ export function useMonitoringData(filterState: FilterState) { botName: call.bot_name, pipelineId: call.pipeline_id, pipelineName: call.pipeline_name, + sessionId: call.session_id, errorMessage: call.error_message, messageId: call.message_id, }), @@ -317,6 +323,7 @@ export function useMonitoringData(filterState: FilterState) { botName: call.botName, pipelineId: call.pipelineId, pipelineName: call.pipelineName, + sessionId: call.sessionId, }), ); diff --git a/web/src/app/home/monitoring/page.tsx b/web/src/app/home/monitoring/page.tsx index 17437140d..5c7eff221 100644 --- a/web/src/app/home/monitoring/page.tsx +++ b/web/src/app/home/monitoring/page.tsx @@ -18,60 +18,12 @@ import { ExportDropdown } from './components/ExportDropdown'; import { useMonitoringFilters } from './hooks/useMonitoringFilters'; import { useMonitoringData } from './hooks/useMonitoringData'; import { useFeedbackData } from './hooks/useFeedbackData'; -import { MessageDetailsCard } from './components/MessageDetailsCard'; -import { MessageContentRenderer } from './components/MessageContentRenderer'; +import { ConversationTurnList } from './components/ConversationTurnList'; import { FeedbackStatsCards } from './components/FeedbackCard'; import { FeedbackList } from './components/FeedbackList'; -import { MessageDetails } from './types/monitoring'; -import { httpClient } from '@/app/infra/http/HttpClient'; +import { buildConversationTurns } from './utils/conversationTurns'; 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; -} - -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() { const { t } = useTranslation(); const { filterState, setSelectedBots, setSelectedPipelines, setTimeRange } = @@ -146,115 +98,36 @@ function MonitoringPageContent() { setFeedbackRefreshKey((k) => k + 1); }, [refetch]); - const [expandedMessageId, setExpandedMessageId] = useState( - null, - ); - const [messageDetails, setMessageDetails] = useState< - Record - >({}); - const [loadingDetails, setLoadingDetails] = useState>( - {}, + const conversationTurns = useMemo( + () => + buildConversationTurns( + data?.messages || [], + data?.llmCalls || [], + data?.errors || [], + ), + [data?.messages, data?.llmCalls, data?.errors], ); // State for expanded errors const [expandedErrorId, setExpandedErrorId] = useState(null); + const [expandedTurnId, setExpandedTurnId] = useState(null); // State for controlled tabs const [activeTab, setActiveTab] = useState('messages'); // Function to jump to a message record - const jumpToMessage = async (messageId: string) => { + const jumpToMessage = (messageId: string) => { setActiveTab('messages'); - // Small delay to ensure tab switch completes setTimeout(() => { - toggleMessageExpand(messageId); + const turn = conversationTurns.find((item) => + item.messages.some((message) => message.id === messageId), + ); + setExpandedTurnId(turn?.id ?? messageId); }, 100); }; - const toggleMessageExpand = async (messageId: string) => { - if (expandedMessageId === messageId) { - // 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 toggleTurnExpand = (turnId: string) => { + setExpandedTurnId((current) => (current === turnId ? null : turnId)); }; const toggleErrorExpand = (errorId: string) => { @@ -342,127 +215,22 @@ function MonitoringPageContent() { )} - {!loading && - data && - data.messages && - data.messages.length > 0 && ( -
- {data.messages - .filter((msg) => { - // Filter out messages with empty content - const content = msg.messageContent?.trim(); - return ( - content && content !== '[]' && content !== '""' - ); - }) - .map((msg) => ( -
- {/* Message Header - Always Visible */} -
toggleMessageExpand(msg.id)} - > -
-
- {/* Expand Icon */} -
- {expandedMessageId === msg.id ? ( - - ) : ( - - )} -
+ {!loading && data && conversationTurns.length > 0 && ( + + )} - {/* Message Info */} -
-
- - ID: {msg.id} - -
-
- - {msg.botName} - - - → - - - {msg.pipelineName} - - {msg.runnerName && ( - <> - - → - - - {msg.runnerName} - - - )} -
-
- -
-
-
- - {/* Status and Timestamp */} -
- - {msg.timestamp.toLocaleString()} - - - {msg.level} - -
-
-
- - {/* Expanded Details */} - {expandedMessageId === msg.id && ( -
- {loadingDetails[msg.id] && ( -
- -
- )} - {!loadingDetails[msg.id] && - messageDetails[msg.id] && ( - - )} -
- )} -
- ))} + {!loading && (!data || conversationTurns.length === 0) && ( +
+ +
+ {t('monitoring.messageList.noMessages')}
- )} - - {!loading && - (!data || !data.messages || data.messages.length === 0) && ( -
- -
- {t('monitoring.messageList.noMessages')} -
-
- )} +
+ )}
diff --git a/web/src/app/home/monitoring/types/monitoring.ts b/web/src/app/home/monitoring/types/monitoring.ts index 564bd97ee..2fc52137b 100644 --- a/web/src/app/home/monitoring/types/monitoring.ts +++ b/web/src/app/home/monitoring/types/monitoring.ts @@ -11,8 +11,10 @@ export interface MonitoringMessage { level: 'info' | 'warning' | 'error' | 'debug'; platform?: string; userId?: string; + userName?: string; runnerName?: string; variables?: string; + role?: 'user' | 'assistant' | string; } export interface LLMCall { @@ -31,6 +33,7 @@ export interface LLMCall { botName: string; pipelineId: string; pipelineName: string; + sessionId?: string; errorMessage?: string; messageId?: string; } diff --git a/web/src/app/home/monitoring/utils/conversationTurns.ts b/web/src/app/home/monitoring/utils/conversationTurns.ts new file mode 100644 index 000000000..49b49fb6b --- /dev/null +++ b/web/src/app/home/monitoring/utils/conversationTurns.ts @@ -0,0 +1,259 @@ +import { ErrorLog, LLMCall, MonitoringMessage } 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[]; + errors: ErrorLog[]; + status: 'success' | 'error' | 'pending'; + level: 'info' | 'warning' | 'error' | 'debug'; + inputTokens: number; + outputTokens: number; + totalTokens: number; + totalDuration: number; +} + +function normalizeRole( + message: MonitoringMessage, + llmMessageIds: Set, +): 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: [], + errors: [], + status: message.status, + level: message.level, + inputTokens: 0, + outputTokens: 0, + totalTokens: 0, + totalDuration: 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, + 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[], +): ConversationTurn[] { + const llmMessageIds = new Set( + llmCalls + .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(); + const lastTurnBySession = new Map(); + const messageIdToTurn = new Map(); + + for (const message of visibleMessages) { + const role = normalizeRole(message, llmMessageIds); + 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 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.errors.sort((a, b) => a.timestamp.getTime() - b.timestamp.getTime()); + } + + return allTurns.sort( + (a, b) => b.lastActivityAt.getTime() - a.lastActivityAt.getTime(), + ); +} diff --git a/web/src/i18n/locales/en-US.ts b/web/src/i18n/locales/en-US.ts index 9f95f650f..61e20ed90 100644 --- a/web/src/i18n/locales/en-US.ts +++ b/web/src/i18n/locales/en-US.ts @@ -1329,6 +1329,20 @@ const enUS = { level: 'Level', runner: 'Runner', 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: { title: 'LLM Calls', diff --git a/web/src/i18n/locales/es-ES.ts b/web/src/i18n/locales/es-ES.ts index 2f5497446..751b596d6 100644 --- a/web/src/i18n/locales/es-ES.ts +++ b/web/src/i18n/locales/es-ES.ts @@ -1363,6 +1363,20 @@ const esES = { level: 'Nivel', runner: 'Ejecutor', 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: { title: 'Llamadas LLM', diff --git a/web/src/i18n/locales/ja-JP.ts b/web/src/i18n/locales/ja-JP.ts index 25853109b..282b599d2 100644 --- a/web/src/i18n/locales/ja-JP.ts +++ b/web/src/i18n/locales/ja-JP.ts @@ -1336,6 +1336,20 @@ const jaJP = { level: 'レベル', runner: 'ランナー', viewConversation: '会話詳細を表示', + turns: '{{count}} 会話ターン', + userMessage: 'ユーザー', + noUserMessage: 'ユーザー入力は記録されていません', + assistantMessage: 'アシスタント', + assistantMessageCount: 'アシスタント +{{count}}', + noAssistantMessage: 'アシスタントの返信は記録されていません', + messageCount: 'メッセージ数', + conversationTrace: '会話トレース', + noLlmCalls: 'モデル呼び出しは記録されていません', + roles: { + user: 'ユーザー', + assistant: 'アシスタント', + message: 'メッセージ', + }, }, llmCalls: { title: 'LLM呼び出し', diff --git a/web/src/i18n/locales/ru-RU.ts b/web/src/i18n/locales/ru-RU.ts index e6b061973..a4f23072d 100644 --- a/web/src/i18n/locales/ru-RU.ts +++ b/web/src/i18n/locales/ru-RU.ts @@ -1339,6 +1339,20 @@ const ruRU = { level: 'Уровень', runner: 'Обработчик', viewConversation: 'Просмотр диалога', + turns: '{{count}} диалоговых ходов', + userMessage: 'Пользователь', + noUserMessage: 'Ввод пользователя не записан', + assistantMessage: 'Ассистент', + assistantMessageCount: 'Ассистент +{{count}}', + noAssistantMessage: 'Ответ ассистента не записан', + messageCount: 'Сообщения', + conversationTrace: 'Ход диалога', + noLlmCalls: 'Вызовы модели не записаны', + roles: { + user: 'Пользователь', + assistant: 'Ассистент', + message: 'Сообщение', + }, }, llmCalls: { title: 'Вызовы LLM', diff --git a/web/src/i18n/locales/th-TH.ts b/web/src/i18n/locales/th-TH.ts index fa63fc81e..86367fde4 100644 --- a/web/src/i18n/locales/th-TH.ts +++ b/web/src/i18n/locales/th-TH.ts @@ -1307,6 +1307,20 @@ const thTH = { level: 'ระดับ', runner: 'ตัวประมวลผล', viewConversation: 'ดูการสนทนา', + turns: '{{count}} รอบการสนทนา', + userMessage: 'ผู้ใช้', + noUserMessage: 'ยังไม่มีการบันทึกข้อความจากผู้ใช้', + assistantMessage: 'ผู้ช่วย', + assistantMessageCount: 'ผู้ช่วย +{{count}}', + noAssistantMessage: 'ยังไม่มีการบันทึกคำตอบจากผู้ช่วย', + messageCount: 'จำนวนข้อความ', + conversationTrace: 'ลำดับการสนทนา', + noLlmCalls: 'ยังไม่มีการบันทึกการเรียกโมเดล', + roles: { + user: 'ผู้ใช้', + assistant: 'ผู้ช่วย', + message: 'ข้อความ', + }, }, llmCalls: { title: 'การเรียก LLM', diff --git a/web/src/i18n/locales/vi-VN.ts b/web/src/i18n/locales/vi-VN.ts index 9c4405d29..735d12797 100644 --- a/web/src/i18n/locales/vi-VN.ts +++ b/web/src/i18n/locales/vi-VN.ts @@ -1332,6 +1332,20 @@ const viVN = { level: 'Mức', runner: 'Trình chạy', 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: { title: 'Cuộc gọi LLM', diff --git a/web/src/i18n/locales/zh-Hans.ts b/web/src/i18n/locales/zh-Hans.ts index 07def167c..200a57a5b 100644 --- a/web/src/i18n/locales/zh-Hans.ts +++ b/web/src/i18n/locales/zh-Hans.ts @@ -1266,6 +1266,20 @@ const zhHans = { level: '级别', runner: '执行器', viewConversation: '显示对话详情', + turns: '{{count}} 轮对话', + userMessage: '用户', + noUserMessage: '未记录用户输入', + assistantMessage: '助手', + assistantMessageCount: '助手 +{{count}}', + noAssistantMessage: '未记录助手回复', + messageCount: '消息数', + conversationTrace: '消息链路', + noLlmCalls: '未记录模型调用', + roles: { + user: '用户', + assistant: '助手', + message: '消息', + }, }, llmCalls: { title: 'LLM调用', diff --git a/web/src/i18n/locales/zh-Hant.ts b/web/src/i18n/locales/zh-Hant.ts index 8c67078df..90e1d8d8f 100644 --- a/web/src/i18n/locales/zh-Hant.ts +++ b/web/src/i18n/locales/zh-Hant.ts @@ -1264,6 +1264,20 @@ const zhHant = { level: '級別', runner: '執行器', viewConversation: '顯示對話詳情', + turns: '{{count}} 輪對話', + userMessage: '使用者', + noUserMessage: '未記錄使用者輸入', + assistantMessage: '助手', + assistantMessageCount: '助手 +{{count}}', + noAssistantMessage: '未記錄助手回覆', + messageCount: '訊息數', + conversationTrace: '訊息鏈路', + noLlmCalls: '未記錄模型呼叫', + roles: { + user: '使用者', + assistant: '助手', + message: '訊息', + }, }, llmCalls: { title: 'LLM呼叫', diff --git a/web/tests/e2e/fixtures/langbot-api.ts b/web/tests/e2e/fixtures/langbot-api.ts index e0be0ebc4..a3c70887f 100644 --- a/web/tests/e2e/fixtures/langbot-api.ts +++ b/web/tests/e2e/fixtures/langbot-api.ts @@ -72,6 +72,7 @@ interface LangBotApiMockState { counters: Record; knowledgeBases: KnowledgeBaseMock[]; mcpServers: MCPServerMock[]; + monitoringData: unknown; pipelines: PipelineMock[]; skills: SkillMock[]; } @@ -689,11 +690,12 @@ async function handleBackendApi(route: Route, state: LangBotApiMockState) { } if (path === '/api/v1/monitoring/data') { - return fulfillJson(route, emptyMonitoringData()); + return fulfillJson(route, state.monitoringData); } 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') { @@ -798,14 +800,19 @@ async function handleCloudApi(route: Route) { export async function installLangBotApiMocks( page: Page, - options: { authenticated?: boolean; storage?: JsonRecord } = {}, + options: { + authenticated?: boolean; + monitoringData?: unknown; + storage?: JsonRecord; + } = {}, ) { - const { authenticated = false, storage = {} } = options; + const { authenticated = false, monitoringData, storage = {} } = options; const state: LangBotApiMockState = { bots: [], counters: {}, knowledgeBases: [], mcpServers: [], + monitoringData: monitoringData || emptyMonitoringData(), pipelines: [], skills: [], }; diff --git a/web/tests/e2e/monitoring-turns.spec.ts b/web/tests/e2e/monitoring-turns.spec.ts new file mode 100644 index 000000000..53dbb2870 --- /dev/null +++ b/web/tests/e2e/monitoring-turns.spec.ts @@ -0,0 +1,359 @@ +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, +} 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 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 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')]; + + return { + messages, + llmCalls, + 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), + embeddingCalls: [], + sessions: [], + errors: scenario.errors.map(rawError), + totalCount: { + messages: scenario.messages.length, + llmCalls: scenario.llmCalls.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, + ); + + 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?.errors).toHaveLength(1); + expect(agentTurn?.totalTokens).toBe(790); + expect(agentTurn?.totalDuration).toBe(600); + }); + + 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('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('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 retry failed')).toBeVisible(); + }); +});