import React, { useState, useEffect, useRef, useCallback } from 'react'; import { useTranslation } from 'react-i18next'; import { httpClient } from '@/app/infra/http/HttpClient'; import { DialogContent } from '@/components/ui/dialog'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { ScrollArea } from '@/components/ui/scroll-area'; import { Switch } from '@/components/ui/switch'; import { cn } from '@/lib/utils'; import { Message, MessageChainComponent, Image, Plain, At, Quote, Voice, File as FileComponent, Source, } from '@/app/infra/entities/message'; import { toast } from 'sonner'; import AtBadge from './AtBadge'; import { WebSocketClient } from '@/app/infra/websocket/WebSocketClient'; import ImagePreviewDialog from './ImagePreviewDialog'; import ReactMarkdown from 'react-markdown'; import remarkGfm from 'remark-gfm'; import rehypeHighlight from 'rehype-highlight'; import rehypeRaw from 'rehype-raw'; import rehypeSanitize from 'rehype-sanitize'; import rehypeSlug from 'rehype-slug'; import rehypeAutolinkHeadings from 'rehype-autolink-headings'; import '@/styles/github-markdown.css'; import { User, Users, ImageIcon, Paperclip, Send, Reply, Music, Code, AlignLeft, } from 'lucide-react'; interface DebugDialogProps { open: boolean; pipelineId: string; isEmbedded?: boolean; onConnectionStatusChange?: (isConnected: boolean) => void; } export default function DebugDialog({ open, pipelineId, isEmbedded = false, onConnectionStatusChange, }: DebugDialogProps) { const { t } = useTranslation(); const [selectedPipelineId, setSelectedPipelineId] = useState(pipelineId); const [sessionType, setSessionType] = useState<'person' | 'group'>('person'); const [messages, setMessages] = useState([]); const [inputValue, setInputValue] = useState(''); const [showAtPopover, setShowAtPopover] = useState(false); const [hasAt, setHasAt] = useState(false); const [isHovering, setIsHovering] = useState(false); const [isConnected, setIsConnected] = useState(false); const [selectedImages, setSelectedImages] = useState< Array<{ file: File; preview: string; fileKey?: string; kind: 'image' | 'voice' | 'file'; }> >([]); const [isUploading, setIsUploading] = useState(false); const [previewImageUrl, setPreviewImageUrl] = useState(''); const [showImagePreview, setShowImagePreview] = useState(false); const [quotedMessage, setQuotedMessage] = useState(null); const [rawModeMessages, setRawModeMessages] = useState>( new Set(), ); const [streamOutput, setStreamOutput] = useState(true); const messagesEndRef = useRef(null); const inputRef = useRef(null); const popoverRef = useRef(null); const fileInputRef = useRef(null); const wsClientRef = useRef(null); const isInitializingRef = useRef(false); const scrollToBottom = useCallback(() => { // Use setTimeout to ensure scroll happens after DOM update setTimeout(() => { const scrollArea = document.querySelector('.scroll-area') as HTMLElement; if (scrollArea) { scrollArea.scrollTo({ top: scrollArea.scrollHeight, behavior: 'smooth', }); } // Also ensure messagesEndRef scrolls into view messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }); }, 0); }, []); const loadMessages = useCallback( async (pipelineId: string) => { try { const response = await httpClient.getWebSocketHistoryMessages( pipelineId, sessionType, ); setMessages(response.messages); } catch (error) { console.error('Failed to load messages:', error); } }, [sessionType], ); // Initialize WebSocket connection const initWebSocket = useCallback( async (pipelineId: string) => { // Prevent duplicate initialization if (isInitializingRef.current) { return; } try { isInitializingRef.current = true; // Disconnect old connection if (wsClientRef.current) { wsClientRef.current.disconnect(); wsClientRef.current = null; } // Create new connection const wsClient = new WebSocketClient(pipelineId, sessionType); wsClient .onConnected(() => { setIsConnected(true); isInitializingRef.current = false; }) .onMessage((wsMessage) => { // Convert WebSocketMessage to Message type const message: Message = { ...wsMessage, message_chain: wsMessage.message_chain as MessageChainComponent[], }; setMessages((prevMessages) => { // Check if message with same ID already exists const existingIndex = prevMessages.findIndex( (m) => m.id === message.id, ); if (existingIndex >= 0) { // Update existing message (streaming output) const newMessages = [...prevMessages]; newMessages[existingIndex] = message; return newMessages; } else { // Add new message return [...prevMessages, message]; } }); }) .onError((error) => { console.error('WebSocket error:', error); setIsConnected(false); isInitializingRef.current = false; toast.error(t('pipelines.debugDialog.connectionError')); }) .onClose(() => { setIsConnected(false); isInitializingRef.current = false; }) .onBroadcast((message) => { toast.info(message); }); await wsClient.connect(); wsClientRef.current = wsClient; } catch (error) { console.error('WebSocket connection failed:', error); setIsConnected(false); isInitializingRef.current = false; toast.error(t('pipelines.debugDialog.connectionFailed')); } }, [sessionType, t], ); // Scroll when messages change useEffect(() => { scrollToBottom(); }, [messages, scrollToBottom]); // Watch open and pipelineId changes: connect on open, disconnect on close useEffect(() => { if (open) { setSelectedPipelineId(pipelineId); } else { // Disconnect WebSocket immediately when dialog closes if (wsClientRef.current) { wsClientRef.current.disconnect(); wsClientRef.current = null; setIsConnected(false); isInitializingRef.current = false; } } return () => { // Disconnect WebSocket on component unmount if (wsClientRef.current) { wsClientRef.current.disconnect(); wsClientRef.current = null; isInitializingRef.current = false; } }; }, [open, pipelineId]); // Reload messages and reconnect when sessionType or selectedPipelineId changes useEffect(() => { if (open) { // Clear current messages to avoid showing stale messages setMessages([]); loadMessages(selectedPipelineId); initWebSocket(selectedPipelineId); } }, [sessionType, selectedPipelineId, open, loadMessages, initWebSocket]); // Notify parent of connection status changes useEffect(() => { onConnectionStatusChange?.(isConnected); }, [isConnected, onConnectionStatusChange]); useEffect(() => { const handleClickOutside = (event: MouseEvent) => { if ( popoverRef.current && !popoverRef.current.contains(event.target as Node) && !inputRef.current?.contains(event.target as Node) ) { setShowAtPopover(false); } }; document.addEventListener('mousedown', handleClickOutside); return () => { document.removeEventListener('mousedown', handleClickOutside); }; }, []); useEffect(() => { if (showAtPopover) { setIsHovering(true); } }, [showAtPopover]); const handleInputChange = (e: React.ChangeEvent) => { const value = e.target.value; if (sessionType === 'group') { if (value.endsWith('@')) { setShowAtPopover(true); } else if (showAtPopover && (!value.includes('@') || value.length > 1)) { setShowAtPopover(false); } } setInputValue(value); }; const handleAtSelect = () => { setHasAt(true); setShowAtPopover(false); setInputValue(inputValue.slice(0, -1)); }; const handleAtRemove = () => { setHasAt(false); }; const handleKeyPress = (e: React.KeyboardEvent) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); if (showAtPopover) { handleAtSelect(); } else { sendMessage(); } } else if (e.key === 'Backspace' && hasAt && inputValue === '') { handleAtRemove(); } }; const handleImageSelect = async (e: React.ChangeEvent) => { const files = e.target.files; if (!files || files.length === 0) return; const newImages: Array<{ file: File; preview: string; kind: 'image' | 'voice' | 'file'; }> = []; for (let i = 0; i < files.length; i++) { const file = files[i]; if (file.type.startsWith('image/')) { newImages.push({ file, preview: URL.createObjectURL(file), kind: 'image', }); } else if (file.type.startsWith('audio/')) { newImages.push({ file, preview: '', kind: 'voice' }); } else { newImages.push({ file, preview: '', kind: 'file' }); } } setSelectedImages((prev) => [...prev, ...newImages]); // reset the input so selecting the same file again re-triggers onChange e.target.value = ''; }; const handleRemoveImage = (index: number) => { setSelectedImages((prev) => { const newImages = [...prev]; if (newImages[index].preview) { URL.revokeObjectURL(newImages[index].preview); } newImages.splice(index, 1); return newImages; }); }; const sendMessage = async () => { if ( !inputValue.trim() && !hasAt && selectedImages.length === 0 && !quotedMessage ) return; if (!isConnected || !wsClientRef.current) { toast.error(t('pipelines.debugDialog.notConnected')); return; } try { setIsUploading(true); const messageChain = []; // Add quoted message if present if (quotedMessage) { // Get message_id from the quoted message Source component const sourceComponent = quotedMessage.message_chain.find( (c) => c.type === 'Source', ) as Source | undefined; const messageId = sourceComponent ? sourceComponent.id : quotedMessage.id; messageChain.push({ type: 'Quote', id: messageId, origin: quotedMessage.message_chain.filter( (c) => c.type !== 'Source', ), }); } let text_content = inputValue.trim(); if (hasAt) { text_content = ' ' + text_content; } if (hasAt) { messageChain.push({ type: 'At', target: 'websocketbot', display: 'websocketbot', }); } // Add text content if (text_content) { messageChain.push({ type: 'Plain', text: text_content, }); } // Upload attachments and add to message chain for (const attachment of selectedImages) { try { if (attachment.kind === 'image') { const result = await httpClient.uploadWebSocketImage( selectedPipelineId, attachment.file, ); messageChain.push({ type: 'Image', path: result.file_key, }); } else { // Voice / File go through the generic document upload endpoint, // which returns a storage key the backend resolves into the // sandbox inbox just like images. const result = await httpClient.uploadDocumentFile(attachment.file); messageChain.push({ type: attachment.kind === 'voice' ? 'Voice' : 'File', path: result.file_id, ...(attachment.kind === 'file' ? { name: attachment.file.name } : {}), }); } } catch (error) { console.error('Attachment upload failed:', error); toast.error(t('pipelines.debugDialog.imageUploadFailed')); } } // Clear input, images, and quoted message setInputValue(''); setHasAt(false); setQuotedMessage(null); selectedImages.forEach((img) => { if (img.preview) URL.revokeObjectURL(img.preview); }); setSelectedImages([]); // Send message via WebSocket // Do not add locally; wait for backend broadcast with correct ID wsClientRef.current.sendMessage(messageChain, streamOutput); } catch (error) { console.error('Failed to send message:', error); toast.error(t('pipelines.debugDialog.sendFailed')); } finally { setIsUploading(false); inputRef.current?.focus(); } }; const renderMessageComponent = ( component: MessageChainComponent, index: number, ) => { switch (component.type) { case 'Plain': return {(component as Plain).text}; case 'At': { const atComponent = component as At; // Prefer display name, fall back to target const displayName = atComponent.display || atComponent.target?.toString() || ''; return ( ); } case 'AtAll': return ( ); case 'Image': { const img = component as Image; const imageUrl = img.url || (img.base64 ? img.base64 : ''); if (!imageUrl) return null; return (
Image { setPreviewImageUrl(imageUrl); setShowImagePreview(true); }} />
); } case 'File': { const file = component as FileComponent; const downloadHref = file.base64 ? file.base64.startsWith('data:') ? file.base64 : `data:application/octet-stream;base64,${file.base64}` : file.url || ''; const fileName = file.name || 'Unknown'; return (
{downloadHref ? ( [{t('pipelines.debugDialog.file')}] {fileName} ) : ( [{t('pipelines.debugDialog.file')}] {fileName} )}
); } case 'Voice': { const voice = component as Voice; const voiceUrl = voice.url || (voice.base64 ? voice.base64 : ''); if (!voiceUrl) { return [{t('pipelines.debugDialog.voice')}]; } return (
{voice.length && voice.length > 0 && ( {voice.length}s )}
); } case 'Quote': { const quote = component as Quote; return (
{quote.origin?.map((comp, idx) => renderMessageComponent(comp as MessageChainComponent, idx), )}
); } case 'Source': // Source is not rendered return null; default: return [{component.type}]; } }; const getMessageTimestamp = (message: Message): number => { // Try to get timestamp from Source component in message_chain const sourceComponent = message.message_chain.find( (c) => c.type === 'Source', ) as Source | undefined; if (sourceComponent && sourceComponent.timestamp) { return sourceComponent.timestamp; } // Fall back to message.timestamp if no Source component // Assume ISO string, convert to Unix timestamp (seconds) if (message.timestamp) { return Math.floor(new Date(message.timestamp).getTime() / 1000); } return 0; }; const formatTimestamp = (timestamp: number): string => { if (!timestamp) return ''; const date = new Date(timestamp * 1000); const now = new Date(); const hours = date.getHours().toString().padStart(2, '0'); const minutes = date.getMinutes().toString().padStart(2, '0'); // Check if today const isToday = now.toDateString() === date.toDateString(); if (isToday) { return `${hours}:${minutes}`; } // Check if yesterday const yesterday = new Date(now); yesterday.setDate(yesterday.getDate() - 1); const isYesterday = yesterday.toDateString() === date.toDateString(); if (isYesterday) { return `${t('bots.yesterday')} ${hours}:${minutes}`; } // Check if this year const isThisYear = now.getFullYear() === date.getFullYear(); if (isThisYear) { const month = date.getMonth() + 1; const day = date.getDate(); return t('bots.dateFormat', { month, day }); } // Earlier dates return t('bots.earlier'); }; // Generate a unique key for a message const getMessageKey = (message: Message): string => { return `${message.id}-${message.timestamp}`; }; // Toggle raw mode for a message (by default, messages are in markdown mode) const toggleRawMode = (message: Message) => { const key = getMessageKey(message); setRawModeMessages((prev) => { const newSet = new Set(prev); if (newSet.has(key)) { newSet.delete(key); } else { newSet.add(key); } return newSet; }); }; // Check if message has any Plain text content const hasPlainText = (message: Message): boolean => { return message.message_chain.some((c) => c.type === 'Plain'); }; // Extract plain text from message chain const getPlainText = (message: Message): string => { return message.message_chain .filter((c) => c.type === 'Plain') .map((c) => (c as Plain).text) .join(''); }; const renderMessageContent = (message: Message) => { const key = getMessageKey(message); const isRawMode = rawModeMessages.has(key); // By default, render with markdown if there's plain text (unless raw mode is enabled) if (!isRawMode && hasPlainText(message)) { const plainText = getPlainText(message); const nonPlainComponents = message.message_chain.filter( (c) => c.type !== 'Plain' && c.type !== 'Source', ); return (
{/* Render non-Plain components first */} {nonPlainComponents.map((component, index) => renderMessageComponent(component, index), )} {/* Render Plain text as markdown */}
    {children}
, ol: ({ children }) => (
    {children}
), li: ({ children }) =>
  • {children}
  • , img: ({ src, alt, ...props }) => { const imageSrc = src || ''; if (typeof imageSrc !== 'string') { return ( {alt ); } return ( {alt ); }, }} > {plainText}
    ); } return (
    {message.message_chain.map((component, index) => renderMessageComponent(component, index), )}
    ); }; const renderContent = () => (
    {messages.length === 0 ? (
    {t('pipelines.debugDialog.noMessages')}
    ) : ( messages.map((message) => (
    {renderMessageContent(message)}
    {message.role === 'user' ? t('pipelines.debugDialog.userMessage') : t('pipelines.debugDialog.botMessage')} {hasPlainText(message) && ( )}
    {formatTimestamp(getMessageTimestamp(message))}
    )) )}
    {/* Quoted message preview */} {quotedMessage && (
    {t('pipelines.debugDialog.replyTo')}{' '} {quotedMessage.role === 'user' ? t('pipelines.debugDialog.userMessage') : t('pipelines.debugDialog.botMessage')}
    {quotedMessage.message_chain .filter((c) => c.type === 'Plain') .map((c) => (c as Plain).text) .join('')}
    )} {/* Attachment preview area */} {selectedImages.length > 0 && (
    {selectedImages.map((image, index) => (
    {image.kind === 'image' ? ( {`preview-${index}`} ) : (
    {image.kind === 'voice' ? ( ) : ( )} {image.file.name}
    )}
    ))}
    )}
    {t('pipelines.debugDialog.streamOutput')}
    {hasAt && ( )}
    {showAtPopover && (
    setIsHovering(true)} onMouseLeave={() => setIsHovering(false)} > @websocketbot - {t('pipelines.debugDialog.atTips')}
    )}
    ); // Embedded mode: return content directly if (isEmbedded) { return ( <>
    {renderContent()}
    setShowImagePreview(false)} /> ); } // Dialog wrapper mode return ( <> {renderContent()} setShowImagePreview(false)} /> ); }