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();
+ });
+});