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 { cn } from '@/lib/utils'; import { Message, MessageChainComponent, Image, Plain, At, Quote, Voice, 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 rehypeSlug from 'rehype-slug'; import rehypeAutolinkHeadings from 'rehype-autolink-headings'; import '@/styles/github-markdown.css'; 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 }> >([]); 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 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(() => { // 使用setTimeout确保在DOM更新后执行滚动 setTimeout(() => { const scrollArea = document.querySelector('.scroll-area') as HTMLElement; if (scrollArea) { scrollArea.scrollTo({ top: scrollArea.scrollHeight, behavior: 'smooth', }); } // 同时确保messagesEndRef也滚动到视图 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], ); // 初始化WebSocket连接 const initWebSocket = useCallback( async (pipelineId: string) => { // 防止重复初始化 if (isInitializingRef.current) { return; } try { isInitializingRef.current = true; // 断开旧连接 if (wsClientRef.current) { wsClientRef.current.disconnect(); wsClientRef.current = null; } // 创建新连接 const wsClient = new WebSocketClient(pipelineId, sessionType); wsClient .onConnected(() => { setIsConnected(true); isInitializingRef.current = false; }) .onMessage((wsMessage) => { // 将 WebSocketMessage 转换为 Message 类型 const message: Message = { ...wsMessage, message_chain: wsMessage.message_chain as MessageChainComponent[], }; setMessages((prevMessages) => { // 查找是否已存在相同ID的消息 const existingIndex = prevMessages.findIndex( (m) => m.id === message.id, ); if (existingIndex >= 0) { // 更新已存在的消息(流式输出) const newMessages = [...prevMessages]; newMessages[existingIndex] = message; return newMessages; } else { // 添加新消息 return [...prevMessages, message]; } }); }) .onError((error) => { console.error('WebSocket错误:', 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连接失败:', error); setIsConnected(false); isInitializingRef.current = false; toast.error(t('pipelines.debugDialog.connectionFailed')); } }, [sessionType, t], ); // 在useEffect中监听messages变化时滚动 useEffect(() => { scrollToBottom(); }, [messages, scrollToBottom]); // 监听 open 和 pipelineId 变化,进入时连接,离开时断开 useEffect(() => { if (open) { setSelectedPipelineId(pipelineId); } else { // 关闭对话框时立即断开WebSocket if (wsClientRef.current) { wsClientRef.current.disconnect(); wsClientRef.current = null; setIsConnected(false); isInitializingRef.current = false; } } return () => { // 组件卸载时断开WebSocket if (wsClientRef.current) { wsClientRef.current.disconnect(); wsClientRef.current = null; isInitializingRef.current = false; } }; }, [open, pipelineId]); // 监听 sessionType 和 selectedPipelineId 变化,重新加载消息和连接 useEffect(() => { if (open) { // 清空当前消息,避免显示旧的消息 setMessages([]); loadMessages(selectedPipelineId); initWebSocket(selectedPipelineId); } }, [sessionType, selectedPipelineId, open, loadMessages, initWebSocket]); // 通知父组件连接状态变化 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 }> = []; for (let i = 0; i < files.length; i++) { const file = files[i]; if (file.type.startsWith('image/')) { const preview = URL.createObjectURL(file); newImages.push({ file, preview }); } } setSelectedImages((prev) => [...prev, ...newImages]); }; const handleRemoveImage = (index: number) => { setSelectedImages((prev) => { const newImages = [...prev]; 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 = []; // 添加引用消息(如果有) if (quotedMessage) { // 获取被引用消息的Source组件以获取message_id 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', }); } // 添加文本 if (text_content) { messageChain.push({ type: 'Plain', text: text_content, }); } // 上传图片并添加到消息链 for (const image of selectedImages) { try { const result = await httpClient.uploadWebSocketImage( selectedPipelineId, image.file, ); messageChain.push({ type: 'Image', path: result.file_key, }); } catch (error) { console.error('图片上传失败:', error); toast.error(t('pipelines.debugDialog.imageUploadFailed')); } } // 清空输入框、图片和引用消息 setInputValue(''); setHasAt(false); setQuotedMessage(null); selectedImages.forEach((img) => URL.revokeObjectURL(img.preview)); setSelectedImages([]); // 通过WebSocket发送消息 // 不在本地添加消息,等待后端广播回来(带有正确的ID) wsClientRef.current.sendMessage(messageChain); } 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; // 优先使用 display,如果没有则使用 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 MessageChainComponent & { name?: string }; return (
[文件] {file.name || 'Unknown'}
); } case 'Voice': { const voice = component as Voice; const voiceUrl = voice.url || (voice.base64 ? voice.base64 : ''); if (!voiceUrl) { return [语音]; } 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 不显示 return null; default: return [{component.type}]; } }; const getMessageTimestamp = (message: Message): number => { // 首先尝试从message_chain中的Source组件获取时间戳 const sourceComponent = message.message_chain.find( (c) => c.type === 'Source', ) as Source | undefined; if (sourceComponent && sourceComponent.timestamp) { return sourceComponent.timestamp; } // 如果没有Source组件,使用message.timestamp // 假设timestamp是ISO字符串,转换为Unix时间戳(秒) 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'); // 判断是否是今天 const isToday = now.toDateString() === date.toDateString(); if (isToday) { return `${hours}:${minutes}`; } // 判断是否是昨天 const yesterday = new Date(now); yesterday.setDate(yesterday.getDate() - 1); const isYesterday = yesterday.toDateString() === date.toDateString(); if (isYesterday) { return `${t('bots.yesterday')} ${hours}:${minutes}`; } // 判断是否是今年 const isThisYear = now.getFullYear() === date.getFullYear(); if (isThisYear) { const month = date.getMonth() + 1; const day = date.getDate(); return t('bots.dateFormat', { month, day }); } // 更早的日期 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))}
    )) )}
    {/* 引用消息预览区域 */} {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('')}
    )} {/* 图片预览区域 */} {selectedImages.length > 0 && (
    {selectedImages.map((image, index) => (
    {`preview-${index}`}
    ))}
    )}
    {hasAt && ( )}
    {showAtPopover && (
    setIsHovering(true)} onMouseLeave={() => setIsHovering(false)} > @websocketbot - {t('pipelines.debugDialog.atTips')}
    )}
    ); // 如果是嵌入模式,直接返回内容 if (isEmbedded) { return ( <>
    {renderContent()}
    setShowImagePreview(false)} /> ); } // 原有的Dialog包装 return ( <> {renderContent()} setShowImagePreview(false)} /> ); }