import React, { useState, useEffect, useRef, useCallback, forwardRef, useImperativeHandle, } from 'react'; import { useTranslation } from 'react-i18next'; import { httpClient } from '@/app/infra/http/HttpClient'; import { ScrollArea } from '@/components/ui/scroll-area'; import { cn } from '@/lib/utils'; import { Ban, Bot, Copy, Check, Workflow, ThumbsUp, ThumbsDown, } from 'lucide-react'; import { MessageChainComponent, Plain, At, Image, Quote, Voice, } from '@/app/infra/entities/message'; import { PIPELINE_DISCARD } from '@/app/home/bots/components/bot-form/RoutingRulesEditor'; interface SessionInfo { session_id: string; bot_id: string; bot_name: string; pipeline_id: string; pipeline_name: string; message_count: number; start_time: string; last_activity: string; is_active: boolean; platform?: string | null; user_id?: string | null; user_name?: string | null; } interface SessionMessage { 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 | null; user_id?: string | null; runner_name?: string | null; variables?: string | null; role?: string | null; } interface SessionFeedback { feedback_type: number; // 1=like, 2=dislike feedback_content?: string | null; stream_id?: string | null; } export interface BotSessionMonitorHandle { refreshSessions: () => Promise; } interface BotSessionMonitorProps { botId: string; } const BotSessionMonitor = forwardRef< BotSessionMonitorHandle, BotSessionMonitorProps >(function BotSessionMonitor({ botId }, ref) { const { t } = useTranslation(); const [sessions, setSessions] = useState([]); const [selectedSessionId, setSelectedSessionId] = useState( null, ); const [messages, setMessages] = useState([]); const [loadingSessions, setLoadingSessions] = useState(false); const [loadingMessages, setLoadingMessages] = useState(false); const [copiedUserId, setCopiedUserId] = useState(false); const [feedbackMap, setFeedbackMap] = useState< Record >({}); const messagesContainerRef = useRef(null); const parseSessionType = (sessionId: string): string | null => { const idx = sessionId.indexOf('_'); if (idx === -1) return null; const type = sessionId.slice(0, idx); if (type === 'person' || type === 'group') return type; return null; }; const abbreviateId = (id: string): string => { if (id.length <= 10) return id; return `${id.slice(0, 4)}..${id.slice(-4)}`; }; const copyUserId = (userId: string) => { navigator.clipboard.writeText(userId).then(() => { setCopiedUserId(true); setTimeout(() => setCopiedUserId(false), 2000); }); }; const loadSessions = useCallback(async () => { setLoadingSessions(true); try { const response = await httpClient.getBotSessions(botId); setSessions(response.sessions ?? []); } catch (error) { console.error('Failed to load sessions:', error); } finally { setLoadingSessions(false); } }, [botId]); useImperativeHandle( ref, () => ({ refreshSessions: loadSessions, }), [loadSessions], ); const loadMessages = useCallback( async (sessionId: string) => { setLoadingMessages(true); try { const messagesRes = await httpClient.getSessionMessages(sessionId); const sorted = (messagesRes.messages ?? []).sort( (a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime(), ); setMessages(sorted); // Collect user message IDs for feedback matching const userMsgIds = new Set( sorted.filter((m) => !m.role || m.role === 'user').map((m) => m.id), ); if (userMsgIds.size > 0) { // Fetch feedback for this bot, then match by stream_id locally const feedbackRes = await httpClient.get<{ feedback: SessionFeedback[]; }>( `/api/v1/monitoring/feedback?botId=${encodeURIComponent(botId)}&limit=200`, ); const map: Record = {}; if (feedbackRes?.feedback) { for (const fb of feedbackRes.feedback) { if (fb.stream_id && userMsgIds.has(fb.stream_id)) { map[fb.stream_id] = fb; } } } setFeedbackMap(map); } else { setFeedbackMap({}); } } catch (error) { console.error('Failed to load session messages:', error); } finally { setLoadingMessages(false); } }, [botId], ); useEffect(() => { loadSessions(); }, [loadSessions]); useEffect(() => { if (selectedSessionId) { loadMessages(selectedSessionId); } else { setMessages([]); } }, [selectedSessionId, loadMessages]); useEffect(() => { if (messages.length === 0) return; // Wait for DOM to render the new messages before scrolling requestAnimationFrame(() => { const container = messagesContainerRef.current; if (container) { const viewport = container.querySelector( '[data-radix-scroll-area-viewport]', ); const scrollTarget = viewport || container; scrollTarget.scrollTop = scrollTarget.scrollHeight; } }); }, [messages]); const parseMessageChain = (content: string): MessageChainComponent[] => { try { const parsed = JSON.parse(content); if (Array.isArray(parsed)) { return parsed as MessageChainComponent[]; } } catch { // Not JSON, return as plain text } return [{ type: 'Plain', text: content } as Plain]; }; const isUserMessage = (msg: SessionMessage): boolean => { if (msg.role === 'assistant') return false; if (msg.role === 'user') return true; return !msg.runner_name; }; const renderMessageComponent = ( component: MessageChainComponent, index: number, ) => { switch (component.type) { case 'Plain': return {(component as Plain).text}; case 'At': { const atComponent = component as At; const displayName = atComponent.display || atComponent.target?.toString() || ''; return ( @{displayName} ); } case 'AtAll': return ( @All ); case 'Image': { const img = component as Image; const imageUrl = img.url || (img.base64 ? img.base64 : ''); if (!imageUrl) { return ( [Image] ); } return (
Image
); } case 'Voice': { const voice = component as Voice; const voiceUrl = voice.url || (voice.base64 ? voice.base64 : ''); if (!voiceUrl) { return ( ๐ŸŽ™ [Voice] ); } return (
); } case 'Quote': { const quote = component as Quote; return (
{quote.origin?.map((comp, idx) => renderMessageComponent(comp as MessageChainComponent, idx), )}
); } case 'Source': return null; case 'File': { const file = component as MessageChainComponent & { name?: string }; return ( ๐Ÿ“Ž {file.name || 'File'} ); } default: return ( [{component.type}] ); } }; const renderMessageContent = (msg: SessionMessage) => { const chain = parseMessageChain(msg.message_content); return (
{chain.map((component, index) => renderMessageComponent(component, index), )}
); }; // Backend timestamps may lack timezone indicator; treat as UTC const parseTimestamp = (timestamp: string): Date => { if (!timestamp) return new Date(0); const hasTimezone = timestamp.endsWith('Z') || /[+-]\d{2}:?\d{2}$/.test(timestamp); return new Date(hasTimezone ? timestamp : timestamp + 'Z'); }; const formatTime = (timestamp: string): string => { if (!timestamp) return ''; const date = parseTimestamp(timestamp); const hours = date.getHours().toString().padStart(2, '0'); const minutes = date.getMinutes().toString().padStart(2, '0'); return `${hours}:${minutes}`; }; const formatRelativeTime = (timestamp: string): string => { if (!timestamp) return ''; const date = parseTimestamp(timestamp); const now = new Date(); const diffMs = now.getTime() - date.getTime(); const diffMins = Math.floor(diffMs / 60000); const diffHours = Math.floor(diffMs / 3600000); const diffDays = Math.floor(diffMs / 86400000); if (diffMins < 1) return '<1m'; if (diffMins < 60) return `${diffMins}m`; if (diffHours < 24) return `${diffHours}h`; return `${diffDays}d`; }; const selectedSession = sessions.find( (s) => s.session_id === selectedSessionId, ); return (
{/* Left Panel: Session List */}
{/* Session List */} {loadingSessions && sessions.length === 0 ? (
{t('bots.sessionMonitor.loading')}
) : sessions.length === 0 ? (
{t('bots.sessionMonitor.noSessions')}
) : (
{sessions.map((session) => { const isSelected = selectedSessionId === session.session_id; return ( ); })}
)}
{/* Right Panel: Messages */}
{!selectedSessionId ? (
{t('bots.sessionMonitor.selectSession')}
) : ( <> {/* Chat Header */}
{selectedSession?.user_name || selectedSession?.user_id || selectedSessionId.slice(0, 20)}
{parseSessionType(selectedSessionId) && ( {parseSessionType(selectedSessionId)} )} {selectedSession?.platform && ( <> {parseSessionType(selectedSessionId) && ยท} {selectedSession.platform} )} {selectedSession?.user_id && ( <> ยท {selectedSession.user_id} )} {selectedSession?.is_active && ( <> ยท Active )}
{/* Messages Area */}
{loadingMessages ? (
{t('bots.sessionMonitor.loading')}
) : messages.length === 0 ? (
{t('bots.sessionMonitor.noMessages')}
) : ( messages.map((msg, msgIndex) => { const isUser = isUserMessage(msg); const isDiscarded = msg.status === 'discarded' || msg.pipeline_id === PIPELINE_DISCARD; // For bot replies, find feedback linked to the preceding user message let msgFeedback: SessionFeedback | undefined; if (!isUser) { for (let i = msgIndex - 1; i >= 0; i--) { if (isUserMessage(messages[i])) { msgFeedback = feedbackMap[messages[i].id]; break; } } } return (
{renderMessageContent(msg)} {/* Role label + pipeline + timestamp */}
{isUser ? t('bots.sessionMonitor.userMessage', { defaultValue: 'User', }) : t('bots.sessionMonitor.botMessage', { defaultValue: 'Assistant', })} {formatTime(msg.timestamp)} {isDiscarded ? ( {t('bots.sessionMonitor.discarded', { defaultValue: 'Discarded', })} ) : msg.pipeline_name ? ( {msg.pipeline_name} ) : null} {msg.status === 'error' && ( error )} {msg.runner_name && ( {msg.runner_name} )} {/* Feedback indicator โ€” same line, pushed right */} {!isUser && msgFeedback && (msgFeedback.feedback_type === 1 ? ( {t('monitoring.feedback.like')} {msgFeedback.feedback_content && ( {msgFeedback.feedback_content} )} ) : ( {t('monitoring.feedback.dislike')} {msgFeedback.feedback_content && ( {msgFeedback.feedback_content} )} ))}
); }) )}
)}
); }); export default BotSessionMonitor;