mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-07-22 12:26:08 +00:00
Improve monitoring conversation turns
This commit is contained in:
@@ -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 (
|
||||||
|
<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();
|
||||||
|
|
||||||
|
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`}
|
||||||
|
/>
|
||||||
|
<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>
|
||||||
|
|
||||||
|
{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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -145,8 +145,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 +162,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 +183,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,6 +202,7 @@ 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,
|
||||||
}),
|
}),
|
||||||
@@ -317,6 +323,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,36 @@ 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?.messages, data?.llmCalls, data?.errors],
|
||||||
);
|
);
|
||||||
|
|
||||||
// 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 +215,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,6 +33,7 @@ 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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<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: [],
|
||||||
|
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<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[],
|
||||||
|
): 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<string, ConversationTurn[]>();
|
||||||
|
const lastTurnBySession = new Map<string, ConversationTurn>();
|
||||||
|
const messageIdToTurn = new Map<string, ConversationTurn>();
|
||||||
|
|
||||||
|
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(),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1329,6 +1329,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',
|
||||||
|
|||||||
@@ -1363,6 +1363,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',
|
||||||
|
|||||||
@@ -1336,6 +1336,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呼び出し',
|
||||||
|
|||||||
@@ -1339,6 +1339,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',
|
||||||
|
|||||||
@@ -1307,6 +1307,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',
|
||||||
|
|||||||
@@ -1332,6 +1332,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',
|
||||||
|
|||||||
@@ -1266,6 +1266,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调用',
|
||||||
|
|||||||
@@ -1264,6 +1264,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呼叫',
|
||||||
|
|||||||
@@ -72,6 +72,7 @@ interface LangBotApiMockState {
|
|||||||
counters: Record<string, number>;
|
counters: Record<string, number>;
|
||||||
knowledgeBases: KnowledgeBaseMock[];
|
knowledgeBases: KnowledgeBaseMock[];
|
||||||
mcpServers: MCPServerMock[];
|
mcpServers: MCPServerMock[];
|
||||||
|
monitoringData: unknown;
|
||||||
pipelines: PipelineMock[];
|
pipelines: PipelineMock[];
|
||||||
skills: SkillMock[];
|
skills: SkillMock[];
|
||||||
}
|
}
|
||||||
@@ -689,11 +690,12 @@ 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/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,14 +800,19 @@ 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;
|
||||||
|
storage?: JsonRecord;
|
||||||
|
} = {},
|
||||||
) {
|
) {
|
||||||
const { authenticated = false, storage = {} } = options;
|
const { authenticated = false, monitoringData, storage = {} } = options;
|
||||||
const state: LangBotApiMockState = {
|
const state: LangBotApiMockState = {
|
||||||
bots: [],
|
bots: [],
|
||||||
counters: {},
|
counters: {},
|
||||||
knowledgeBases: [],
|
knowledgeBases: [],
|
||||||
mcpServers: [],
|
mcpServers: [],
|
||||||
|
monitoringData: monitoringData || emptyMonitoringData(),
|
||||||
pipelines: [],
|
pipelines: [],
|
||||||
skills: [],
|
skills: [],
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user