/* * @Author: ZhaoYing * @Date: 2025-12-10 16:46:17 * @Last Modified by: ZhaoYing * @Last Modified time: 2026-03-31 15:01:53 */ import { type FC, useRef, useEffect, useState } from 'react' import clsx from 'clsx' import Markdown from '@/components/Markdown' import type { ChatContentProps } from './types' import { Spin, Image, Flex, Button } from 'antd' import { SoundOutlined } from '@ant-design/icons' import { useTranslation } from 'react-i18next' import AudioPlayer from './AudioPlayer' import VideoPlayer from './VideoPlayer' const getFileUrl = (file: any) => { return file.thumbUrl || file.url || (file.originFileObj ? URL.createObjectURL(file.originFileObj) : undefined) } /** * Chat Content Display Component * Responsible for rendering chat message list, supports different role message styles and auto-scrolling */ const ChatContent: FC = ({ classNames, contentClassNames, data = [], streamLoading = false, empty, labelPosition = 'bottom', labelFormat, errorDesc, renderRuntime, onSend }) => { const { t } = useTranslation() // Scroll container reference for controlling auto-scroll to bottom const scrollContainerRef = useRef<(HTMLDivElement | null)>(null) const prevDataLengthRef = useRef(data.length); const isScrolledToBottomRef = useRef(true); const audioRef = useRef(null) const [expandedReasoning, setExpandedReasoning] = useState>(new Set()) const [manualToggledReasoning, setManualToggledReasoning] = useState>(new Set()) const toggleReasoning = (index: number) => { setManualToggledReasoning(prev => new Set(prev).add(index)) setExpandedReasoning(prev => { const next = new Set(prev) next.has(index) ? next.delete(index) : next.add(index) return next }) } const isReasoningExpanded = (index: number) => { if (manualToggledReasoning.has(index)) return expandedReasoning.has(index) return !data[index]?.content } const [playingIndex, setPlayingIndex] = useState(null) const handlePlay = (audio_url: string, audio_status?: string) => { if (audio_status !== 'completed' && typeof audio_status === 'string') return if (playingIndex === audio_url) { audioRef.current?.pause() setPlayingIndex(null) return } if (audioRef.current) { audioRef.current.pause() } const audio = new Audio(audio_url) audioRef.current = audio audio.play() setPlayingIndex(audio_url) audio.onended = () => setPlayingIndex(null) } // Track scroll position to determine if user is at bottom useEffect(() => { const handleScroll = () => { if (scrollContainerRef.current) { const { scrollTop, scrollHeight, clientHeight } = scrollContainerRef.current; // Consider user is at bottom if within 100px of the bottom isScrolledToBottomRef.current = scrollHeight - scrollTop - clientHeight < 100; } }; const container = scrollContainerRef.current; if (container) { container.addEventListener('scroll', handleScroll); // Initial check handleScroll(); } return () => { if (container) { container.removeEventListener('scroll', handleScroll); } }; }, []); // Auto-scroll to bottom when data changes to show latest messages // When data array length remains unchanged, if data is updated and user manually scrolled up, don't auto-scroll to bottom // When data array length changes, auto-scroll to bottom // If already scrolled to bottom, will auto-scroll to bottom useEffect(() => { if (playingIndex && !data.some(item => item.meta_data?.audio_url === playingIndex)) { audioRef.current?.pause() setPlayingIndex(null) } setTimeout(() => { if (scrollContainerRef.current) { // Auto-scroll if data length changed OR user is currently at bottom if (data.length !== prevDataLengthRef.current || isScrolledToBottomRef.current) { scrollContainerRef.current.scrollTop = scrollContainerRef.current.scrollHeight; isScrolledToBottomRef.current = true; } prevDataLengthRef.current = data.length; } }, 0); }, [data]) const handleDownload = (file: any) => { window.open(getFileUrl(file), '_blank') } return (
{data.length === 0 ? empty // Display empty state : data.map((item, index) => (
{/* Don't display if streaming and content is empty */} {streamLoading && item.content === '' && !renderRuntime ? : <> {/* Top label (such as timestamp, username, etc.) */} {labelPosition === 'top' &&
{labelFormat(item)}
} {item.meta_data?.files && item.meta_data?.files.length > 0 && {item.meta_data?.files?.map((file) => { if (file.type.includes('image')) { return (
{file.name}
) } if (file.type.includes('video')) { return (
{/*
) } if (file.type.includes('audio')) { return (
) } return ( handleDownload(file)} >
{file.name}
{file.type?.split('/')[file.type?.split('/').length - 1]} ยท {file.size}
) })}
} {/* Message bubble */}
{item.meta_data?.reasoning_content &&
{t('memoryConversation.reasoning_content')} toggleReasoning(index)} >
{isReasoningExpanded(index) && }
} {item.status &&
} {item.subContent && renderRuntime && renderRuntime(item, index)} {/* Render message content using Markdown component */} {item.meta_data?.suggested_questions && item.meta_data?.suggested_questions?.length > 0 && {item.meta_data?.suggested_questions?.map((question, idx) => ( ))} } {item.meta_data?.citations && item.meta_data?.citations.length > 0 &&
{t('memoryConversation.citations')}
{item.meta_data?.citations?.map((citation, idx) => (
{ const params = new URLSearchParams({ documentId: citation.document_id, parentId: citation.knowledge_id }); window.open(`/#/knowledge-base/${citation.knowledge_id}/DocumentDetails?${params}`, '_blank'); }} >{citation.file_name}
))}
}
{/* Bottom label (such as timestamp, username, etc.) */} {labelPosition === 'bottom' && {item.meta_data?.audio_url && <> {playingIndex !== item.meta_data?.audio_url && item.meta_data?.audio_status === 'pending' ? : playingIndex !== item.meta_data?.audio_url ? handlePlay(item.meta_data?.audio_url!, item.meta_data?.audio_status)} /> :
handlePlay(item.meta_data?.audio_url!, item.meta_data?.audio_status)} /> } }
{labelFormat(item)}
} }
)) }
) } export default ChatContent