import React, { useState, useEffect, useRef, useCallback, useMemo, 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, ChevronDown, ChevronRight, Workflow, ThumbsUp, ThumbsDown, ShieldCheck, ShieldOff, Wrench, } from 'lucide-react'; import { toast } from 'sonner'; import BotAdminsDialog, { useBotAdmins, } from '@/app/home/bots/components/bot-admins/BotAdminsDialog'; import type { BotAdmin } from '@/app/home/bots/components/bot-admins/BotAdminsDialog'; import { copyToClipboard } from '@/app/utils/clipboard'; 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; } interface SessionToolCall { id: string; timestamp: string; tool_name: string; tool_source: string; duration: number; status: string; message_id?: string | null; arguments?: string | null; result?: string | null; error_message?: string | null; } type SessionTimelineItem = | { id: string; type: 'message'; timestamp: number; order: number; message: SessionMessage; } | { id: string; type: 'tool'; timestamp: number; order: number; toolCall: SessionToolCall; }; export interface BotSessionMonitorHandle { 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 [toolCalls, setToolCalls] = useState([]); const [expandedToolCallIds, setExpandedToolCallIds] = useState< Record >({}); const messagesContainerRef = useRef(null); const { admins, reload: reloadAdmins } = useBotAdmins(botId); const [adminsDialogOpen, setAdminsDialogOpen] = useState(false); const [togglingAdmin, setTogglingAdmin] = useState(null); const parseSessionType = (sessionId: string): string | null => { const lower = sessionId.toLowerCase(); if (lower.includes('person')) return 'person'; if (lower.includes('group')) return 'group'; return null; }; const isSessionAdmin = (session: SessionInfo): boolean => { const type = parseSessionType(session.session_id); const lid = session.user_id ?? session.session_id.replace( /^.*?[._](?:PERSON|GROUP|person|group)[._]/i, '', ); return admins.some( (a: BotAdmin) => a.launcher_type === type && a.launcher_id === lid, ); }; const toggleAdmin = async (session: SessionInfo) => { const type = parseSessionType(session.session_id); if (!type) return; const lid = session.user_id ?? session.session_id.replace( /^.*?[._](?:PERSON|GROUP|person|group)[._]/i, '', ); const key = session.session_id; setTogglingAdmin(key); try { const existing = admins.find( (a: BotAdmin) => a.launcher_type === type && a.launcher_id === lid, ); if (existing) { await httpClient.deleteBotAdmin(botId, existing.id); toast.success(t('bots.admins.deleteSuccess')); } else { await httpClient.addBotAdmin(botId, type, lid); toast.success(t('bots.admins.addSuccess')); } await reloadAdmins(); } catch { toast.error(t('bots.admins.addError')); } finally { setTogglingAdmin(null); } }; const abbreviateId = (id: string): string => { if (id.length <= 10) return id; return `${id.slice(0, 4)}..${id.slice(-4)}`; }; const copyUserId = (userId: string) => { copyToClipboard(userId).catch(() => {}); 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); setExpandedToolCallIds({}); 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); try { const analysisRes = await httpClient.get<{ tool_calls?: SessionToolCall[]; }>( `/api/v1/monitoring/sessions/${encodeURIComponent(sessionId)}/analysis`, ); setToolCalls(analysisRes?.tool_calls ?? []); } catch (analysisError) { console.error('Failed to load session tool calls:', analysisError); setToolCalls([]); } // Collect user message IDs for feedback matching 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([]); setToolCalls([]); setExpandedToolCallIds({}); setFeedbackMap({}); } }, [selectedSessionId, loadMessages]); useEffect(() => { if (messages.length === 0 && toolCalls.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, toolCalls]); 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 formatDuration = (durationMs: number): string => { if (!durationMs) return '0ms'; if (durationMs < 1000) return `${durationMs}ms`; return `${(durationMs / 1000).toFixed(2)}s`; }; const truncateToolDetail = (value?: string | null): string => { if (!value) return ''; return value.length > 600 ? `${value.slice(0, 600)}...` : value; }; const toggleToolCallDetails = (toolCallId: string) => { setExpandedToolCallIds((previous) => ({ ...previous, [toolCallId]: !previous[toolCallId], })); }; const feedbackByMessageId = useMemo(() => { const map: Record = {}; for (let index = 0; index < messages.length; index++) { const msg = messages[index]; if (isUserMessage(msg)) continue; for (let previousIndex = index - 1; previousIndex >= 0; previousIndex--) { const previousMessage = messages[previousIndex]; if (isUserMessage(previousMessage)) { const feedback = feedbackMap[previousMessage.id]; if (feedback) { map[msg.id] = feedback; } break; } } } return map; }, [feedbackMap, messages]); const timelineItems = useMemo(() => { const messageItems: SessionTimelineItem[] = messages.map( (message, index) => ({ id: `message-${message.id}`, type: 'message', timestamp: parseTimestamp(message.timestamp).getTime(), order: index * 2, message, }), ); const toolItems: SessionTimelineItem[] = toolCalls.map( (toolCall, index) => ({ id: `tool-${toolCall.id}`, type: 'tool', timestamp: parseTimestamp(toolCall.timestamp).getTime(), order: index * 2 + 1, toolCall, }), ); return [...messageItems, ...toolItems].sort( (a, b) => a.timestamp - b.timestamp || a.order - b.order, ); }, [messages, toolCalls]); const selectedSession = sessions.find( (s) => s.session_id === selectedSessionId, ); return ( <>
{/* Left Panel: Session List */}
{/* Admin header */}
{/* 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; const sessionType = parseSessionType(session.session_id); const sessionIsAdmin = isSessionAdmin(session); return (
setSelectedSessionId(session.session_id)} >
{session.user_name || session.user_id || session.session_id.slice(0, 12)} {formatRelativeTime(session.last_activity)}
{sessionType && ( {sessionType} )} {session.user_id && ( {abbreviateId(session.user_id)} )} {session.is_active && ( )}
); })}
)}
{/* 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?.user_id && ( <> · {selectedSession.user_id} )} {selectedSession?.is_active && ( <> · Active )} {selectedSession && parseSessionType(selectedSessionId) && ( <> · )}
{/* Messages Area */}
{loadingMessages ? (
{t('bots.sessionMonitor.loading')}
) : timelineItems.length === 0 ? (
{t('bots.sessionMonitor.noMessages')}
) : ( timelineItems.map((item) => { if (item.type === 'tool') { const call = item.toolCall; const hasToolDetails = Boolean( call.arguments || call.result || call.error_message, ); const expandedToolCall = Boolean( expandedToolCallIds[call.id], ); const detailsId = `tool-call-details-${call.id}`; return (
{hasToolDetails && expandedToolCall && (
{(call.arguments || call.result) && (
{call.arguments && (
{t( 'monitoring.toolCalls.arguments', { defaultValue: '参数', }, )}
                                            {truncateToolDetail(call.arguments)}
                                          
)} {call.result && (
{t('monitoring.toolCalls.result', { defaultValue: '结果', })}
                                            {truncateToolDetail(call.result)}
                                          
)}
)} {call.error_message && (
{call.error_message}
)}
)}
{t('monitoring.toolCalls.title', { defaultValue: '工具调用', })} {formatTime(call.timestamp)} {hasToolDetails && ( <> · {expandedToolCall ? t( 'monitoring.toolCalls.hideDetails', { defaultValue: '隐藏详情', }, ) : t( 'monitoring.toolCalls.showDetails', { defaultValue: '查看详情', }, )} )}
); } const msg = item.message; const isUser = isUserMessage(msg); const isDiscarded = msg.status === 'discarded' || msg.pipeline_id === PIPELINE_DISCARD; const msgFeedback = feedbackByMessageId[msg.id]; 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;