import React, { useState, useEffect, useRef, useCallback, useLayoutEffect } from 'react' import ReactMarkdown from 'react-markdown' import remarkMath from 'remark-math' import remarkGfm from 'remark-gfm' import rehypeKatex from 'rehype-katex' import { Copy, Check, ChevronDown, ChevronUp, Bot, User, FileText, FileDown, FileCode, Download } from 'lucide-react' import { jsPDF } from 'jspdf' import html2canvas from 'html2canvas' import { preprocessLaTeX, parseMessageContent } from '../utils/chatUtils' import ErrorBoundary from './ErrorBoundary' import calculusIcon from '../assets/calculus-icon.png' const MessageFooter = ({ role, onCopy, onToggleExpand, isExpanded, isOverflow, copiedId, idx, onExportMD, onExportPDF, onExportLaTeX }) => { const [showMenu, setShowMenu] = useState(false) return (
{isOverflow && ( )}
{role === 'assistant' && (
{showMenu && (
setShowMenu(false)}>
)}
)}
) } const CollapsibleContent = ({ content, messageId, maxLines = 12, isStreaming = false, children }) => { const [expanded, setExpanded] = useState(() => { if (!messageId) return false return localStorage.getItem(messageId) === 'true' }) // Maintain expanded state after streaming finishes for the current session const [wasStreaming, setWasStreaming] = useState(false) useEffect(() => { if (isStreaming && !wasStreaming) { setWasStreaming(true) setExpanded(true) } }, [isStreaming, wasStreaming]) const [isOverflow, setIsOverflow] = useState(false) const contentRef = useRef(null) useEffect(() => { if (contentRef.current) { const lineHeight = parseFloat(getComputedStyle(contentRef.current).lineHeight) const maxHeight = lineHeight * maxLines setIsOverflow(contentRef.current.scrollHeight > maxHeight + 10) } }, [content, maxLines]) const toggleExpand = () => { const nextState = !expanded setExpanded(nextState) if (messageId) { localStorage.setItem(messageId, String(nextState)) } } return (
}} > {preprocessLaTeX(parseMessageContent(content))}
{children({ isOverflow, isExpanded: expanded, onToggleExpand: toggleExpand })}
) } const MessageList = ({ messages, isLoading, conversationId, onExampleClick, onImageClick, userAvatar, userName = 'User' }) => { const messagesEndRef = useRef(null) const containerRef = useRef(null) const [showScrollBtn, setShowScrollBtn] = useState(false) const [isRestored, setIsRestored] = useState(false) const [isTransitioning, setIsTransitioning] = useState(false) const scrollPositionsRef = useRef({}) // Persistent in-memory scroll storage const prevConversationId = useRef(conversationId) const prevMessagesLengthRef = useRef(0) const prevStreamingRef = useRef(false) const [copiedId, setCopiedId] = useState(null) const pdfExportRef = useRef(null) const [exportingIndex, setExportingIndex] = useState(null) const scrollToBottom = (behavior = 'smooth') => { if (containerRef.current) { const container = containerRef.current if (behavior === 'smooth') { // Use requestAnimationFrame to ensure React has rendered the new content requestAnimationFrame(() => { container.scrollTo({ top: container.scrollHeight, behavior: 'smooth' }) }) } else { container.scrollTop = container.scrollHeight } } } const handleScroll = useCallback(() => { if (containerRef.current && conversationId && isRestored && !isTransitioning) { const { scrollTop, scrollHeight, clientHeight } = containerRef.current // Save to both Ref (instant access) and SessionStorage (persistence) scrollPositionsRef.current[conversationId] = scrollTop sessionStorage.setItem(`scroll_${conversationId}`, String(scrollTop)) setShowScrollBtn(scrollHeight - scrollTop - clientHeight > 100) } }, [conversationId, isRestored, isTransitioning]) // Detect Session Change useLayoutEffect(() => { if (prevConversationId.current !== conversationId) { // Store current position before switching if (containerRef.current && prevConversationId.current) { scrollPositionsRef.current[prevConversationId.current] = containerRef.current.scrollTop } setIsRestored(false) // Only set transitioning if we're moving from one real session to another // This prevents "white screen" on new sessions while streaming starts setIsTransitioning(!!prevConversationId.current && !!conversationId) prevConversationId.current = conversationId prevMessagesLengthRef.current = 0 } }, [conversationId]) // Restore Scroll Position useLayoutEffect(() => { if (!containerRef.current || messages.length === 0 || !conversationId) { if (messages.length === 0 && conversationId) { setIsTransitioning(false) // No messages, nothing to restore setIsRestored(true) } return } if (isRestored) return const container = containerRef.current // Priority: Ref -> SessionStorage -> Bottom const savedScroll = scrollPositionsRef.current[conversationId] ?? sessionStorage.getItem(`scroll_${conversationId}`) // Use 'auto' behavior for instant restoration (no jump) if (savedScroll !== null) { container.scrollTo({ top: parseInt(savedScroll, 10), behavior: 'auto' }) } else { container.scrollTo({ top: container.scrollHeight, behavior: 'auto' }) } // Force a layout reflow to ensure scroll is applied before showing void container.offsetHeight // If it's a session switch (isTransitioning), we use a small delay to hide the "jump" if (isTransitioning) { const timer = setTimeout(() => { setIsRestored(true) setIsTransitioning(false) prevMessagesLengthRef.current = messages.length }, 50) return () => clearTimeout(timer) } else { // --- REFRESH CASE (INITIAL LOAD) --- // Set isRestored to true to start animations // Set prevMessagesLength to 0 to make ALL messages animate from the start setIsRestored(true) prevMessagesLengthRef.current = 0 } }, [messages.length, conversationId, isRestored, isTransitioning]) const isInitialLoadRef = useRef(true) // Handle New Messages useEffect(() => { if (!isRestored || isTransitioning || messages.length === 0) return const isNewMessage = messages.length > prevMessagesLengthRef.current // If it's the initial load (refresh), we want animations but NOT auto-scroll if (isInitialLoadRef.current) { isInitialLoadRef.current = false prevMessagesLengthRef.current = messages.length return } const lastMessage = messages[messages.length - 1] const isUserMessage = lastMessage?.role === 'user' if (isNewMessage) { // Always scroll to bottom for user messages // For bot messages, only scroll if already at bottom (sticky) if (isUserMessage || !showScrollBtn) { scrollToBottom('smooth') } } prevMessagesLengthRef.current = messages.length }, [messages.length, showScrollBtn, isRestored, isTransitioning]) // Auto-scroll during streaming to keep user's view locked on new content const lastMessageContent = messages[messages.length - 1]?.content const isLastMessageStreaming = messages[messages.length - 1]?.isStreaming useEffect(() => { if (!containerRef.current) return if (isLastMessageStreaming) { // "Sticky Scroll": Only auto-scroll if user is already near the bottom // !showScrollBtn means distance to bottom < 100px if (!showScrollBtn) { containerRef.current.scrollTop = containerRef.current.scrollHeight } } else if (prevStreamingRef.current) { // Just finished streaming - do one final sync to be sure // but only if the user didn't scroll too far up if (!showScrollBtn) { scrollToBottom('smooth') } } prevStreamingRef.current = isLastMessageStreaming }, [lastMessageContent, isLastMessageStreaming, showScrollBtn]) const copyToClipboard = (text, idx) => { navigator.clipboard.writeText(text) setCopiedId(idx) setTimeout(() => setCopiedId(null), 2000) } const exportToMarkdown = (content, idx) => { const blob = new Blob([content], { type: 'text/markdown' }) const url = URL.createObjectURL(blob) const a = document.createElement('a') a.href = url a.download = `chat-answer-${idx + 1}.md` document.body.appendChild(a) a.click() document.body.removeChild(a) URL.revokeObjectURL(url) } const exportToLaTeX = (content, idx) => { const texContent = `\\documentclass{article}\n\\usepackage[utf8]{inputenc}\n\\usepackage{amsmath}\n\n\\begin{document}\n${content}\n\\end{document}` const blob = new Blob([texContent], { type: 'text/x-tex' }) const url = URL.createObjectURL(blob) const a = document.createElement('a') a.href = url a.download = `chat-answer-${idx + 1}.tex` document.body.appendChild(a) a.click() document.body.removeChild(a) URL.revokeObjectURL(url) } const exportToPDF = async (idx) => { setExportingIndex(idx) // Wait for DOM to update the hidden element setTimeout(async () => { if (!pdfExportRef.current) return try { const canvas = await html2canvas(pdfExportRef.current, { scale: 3, useCORS: true, backgroundColor: '#ffffff', logging: false, }) const imgData = canvas.toDataURL('image/png') const pdfWidth = 595.28 const pdfHeight = 841.89 const pdf = new jsPDF('p', 'pt', 'a4') const margin = 0 // Container already has padding const innerWidth = pdfWidth const imgProps = pdf.getImageProperties(imgData) const imgHeight = (imgProps.height * innerWidth) / imgProps.width let heightLeft = imgHeight let position = 0 pdf.addImage(imgData, 'PNG', 0, position, innerWidth, imgHeight) heightLeft -= pdfHeight while (heightLeft >= 0) { pdf.addPage() position = heightLeft - imgHeight pdf.addImage(imgData, 'PNG', 0, position, innerWidth, imgHeight) heightLeft -= pdfHeight } pdf.save(`chat-answer-${idx + 1}.pdf`) setExportingIndex(null) } catch (error) { console.error('PDF Export failed:', error) setExportingIndex(null) } }, 100) } if (messages.length === 0) { return (
Icon e.target.style.display = 'none'} />

Xin chào, tôi có thể giúp gì?

Tôi là Pochi, bạn đồng hành của bạn trong việc chinh phục môn toán giải tích.
Hãy bắt đầu bằng việc đặt câu hỏi cho tôi nhé!

) } return (
{messages.map((msg, idx) => (
= prevMessagesLengthRef.current - 1 ? 'animate-msg-slide-up' : ''}`} style={{ animationDelay: isRestored ? `${Math.min((idx - (prevMessagesLengthRef.current - 1)) * 0.05, 0.5)}s` : '0s' }} > {/* Avatar removed */}
{msg.status &&
{msg.status}
} {msg.images && msg.images.length > 0 && (
{msg.images.map((src, i) => (
onImageClick(msg.images, i)}> {`Attachment
))}
)} {msg.content ? ( {({ isOverflow, isExpanded, onToggleExpand }) => ( copyToClipboard(msg.content, idx)} onExportMD={() => exportToMarkdown(msg.content, idx)} onExportPDF={() => exportToPDF(idx)} onExportLaTeX={() => exportToLaTeX(msg.content, idx)} copiedId={copiedId} idx={idx} /> )} ) : msg.role === 'assistant' ? (
Đang suy nghĩ...
) : ( ... )}
))}
{/* Hidden Premium PDF Layout Component */} {exportingIndex !== null && (
{/* Background Watermark */}
POCHI
POCHI
Assistant Export
{new Date().toLocaleDateString('vi-VN')}
{preprocessLaTeX(parseMessageContent(messages[exportingIndex].content))}
Tài liệu được tạo bởi Pochi
)} {showScrollBtn && ( )}
) } export default MessageList