Spaces:
Sleeping
Sleeping
| import { useState, useEffect, useRef, useCallback, Component } from 'react'; | |
| import ReactMarkdown from 'react-markdown'; | |
| import remarkGfm from 'remark-gfm'; | |
| import './index.css'; | |
| // ── Error Boundary ──────────────────────────────────────────── | |
| class ErrorBoundary extends Component { | |
| constructor(props) { | |
| super(props); | |
| this.state = { hasError: false, error: null }; | |
| } | |
| static getDerivedStateFromError(error) { | |
| return { hasError: true, error }; | |
| } | |
| componentDidCatch(error, info) { | |
| console.error('[BaoThang-AI ErrorBoundary]', error, info); | |
| } | |
| render() { | |
| if (this.state.hasError) { | |
| return ( | |
| <div style={{ display:'flex', flexDirection:'column', alignItems:'center', justifyContent:'center', height:'100vh', gap:'16px', background:'var(--color-bg-primary)', color:'var(--color-text-primary)' }}> | |
| <i className="fas fa-heart-broken" style={{ fontSize:'48px', color:'var(--color-danger)' }} /> | |
| <h2 style={{ fontSize:'1.5rem', fontWeight:700 }}>Đã xảy ra lỗi không mong muốn</h2> | |
| <p style={{ opacity:0.7 }}>Vui lòng tải lại trang để tiếp tục.</p> | |
| <button onClick={() => window.location.reload()} style={{ padding:'10px 24px', borderRadius:'8px', border:'none', background:'linear-gradient(135deg, var(--color-primary), var(--color-primary-light))', color:'white', cursor:'pointer', fontSize:'14px', fontWeight:600 }}> | |
| <i className="fas fa-redo" style={{ marginRight:'8px' }} />Tải lại trang | |
| </button> | |
| </div> | |
| ); | |
| } | |
| return this.props.children; | |
| } | |
| } | |
| // ── Utility Helpers ─────────────────────────────────────────── | |
| function formatTimeAgo(timestamp) { | |
| const now = new Date(); | |
| const past = new Date(timestamp); | |
| const diffMs = now - past; | |
| const diffMins = Math.floor(diffMs / 60000); | |
| const diffHours = Math.floor(diffMs / 3600000); | |
| const diffDays = Math.floor(diffMs / 86400000); | |
| if (diffMins < 1) return 'Vừa xong'; | |
| if (diffMins < 60) return `${diffMins} phút trước`; | |
| if (diffHours < 24) return `${diffHours} giờ trước`; | |
| if (diffDays < 7) return `${diffDays} ngày trước`; | |
| return past.toLocaleDateString(); | |
| } | |
| function buildDownloadText(chatHistory) { | |
| let content = 'BaoThang-AI — TTYT Khu vực Bảo Thắng\n' + '='.repeat(50) + '\n\n'; | |
| chatHistory.forEach((msg) => { | |
| content += `[${msg.timestamp}] ${msg.type === 'user' ? 'Bác sĩ' : 'BaoThang-AI'}:\n${msg.content}\n`; | |
| if (msg.source) content += `Nguồn: ${msg.source}\n`; | |
| content += '\n'; | |
| }); | |
| return content; | |
| } | |
| // ── Sidebar Component ───────────────────────────────────────── | |
| function Sidebar({ sidebarOpen, sessions, currentSessionId, onNewChat, onLoadSession, onDeleteSession, onToggleTheme, theme }) { | |
| return ( | |
| <aside style={{ | |
| width: sidebarOpen ? '280px' : '0', | |
| transform: sidebarOpen ? 'translateX(0)' : 'translateX(-100%)', | |
| overflow: sidebarOpen ? 'visible' : 'hidden', | |
| flexShrink: 0, | |
| height: '100%', | |
| display: 'flex', | |
| flexDirection: 'column', | |
| transition: 'width 0.3s, transform 0.3s', | |
| borderRight: sidebarOpen ? '1px solid var(--color-border-color)' : 'none', | |
| background: 'var(--color-glass-bg)', | |
| backdropFilter: 'blur(20px)', | |
| WebkitBackdropFilter: 'blur(20px)', | |
| position: 'relative', | |
| zIndex: 20, | |
| }}> | |
| {/* Header */} | |
| <div style={{ padding: '16px', borderBottom: '1px solid var(--color-border-color)' }}> | |
| <div style={{ display:'flex', alignItems:'center', gap:'12px', marginBottom:'16px', paddingInline:'4px' }}> | |
| <div style={{ width:'40px', height:'40px', borderRadius:'12px', background:'linear-gradient(135deg, var(--color-primary), var(--color-primary-light))', display:'flex', alignItems:'center', justifyContent:'center', color:'white', flexShrink:0 }}> | |
| <i className="fas fa-heartbeat" style={{ fontSize:'18px' }} /> | |
| </div> | |
| <div> | |
| <div style={{ fontWeight:700, fontSize:'17px', color:'var(--color-text-primary)', letterSpacing:'-0.01em' }}>BaoThang-AI</div> | |
| <span style={{ fontSize:'11px', fontWeight:600, padding:'2px 8px', borderRadius:'999px', background:'color-mix(in srgb, var(--color-primary) 12%, transparent)', color:'var(--color-primary)' }}>Pro v3.0</span> | |
| </div> | |
| </div> | |
| <button onClick={onNewChat} style={{ width:'100%', display:'flex', alignItems:'center', justifyContent:'center', gap:'8px', padding:'8px 12px', borderRadius:'8px', border:'1px solid var(--color-border-color)', background:'var(--color-bg-secondary)', color:'var(--color-text-primary)', cursor:'pointer', fontSize:'14px', fontWeight:500, transition:'background 0.2s' }}> | |
| <i className="fas fa-plus" style={{ color:'var(--color-primary)' }} /> | |
| <span>Đoạn chat mới</span> | |
| </button> | |
| </div> | |
| {/* Chat List */} | |
| <div style={{ flex:1, overflowY:'auto', padding:'12px', display:'flex', flexDirection:'column', gap:'2px' }}> | |
| <div style={{ fontSize:'11px', fontWeight:600, color:'var(--color-text-tertiary)', textTransform:'uppercase', letterSpacing:'0.08em', marginBottom:'8px', padding:'0 8px', marginTop:'8px' }}>Đoạn chat gần đây</div> | |
| {sessions === null ? ( | |
| <div style={{ textAlign:'center', padding:'16px', color:'var(--color-text-tertiary)', fontSize:'13px' }}> | |
| <i className="fas fa-circle-notch fa-spin" style={{ marginRight:'8px' }} />Đang tải... | |
| </div> | |
| ) : sessions.length === 0 ? ( | |
| <div style={{ textAlign:'center', padding:'16px', color:'var(--color-text-tertiary)', fontSize:'13px' }}>Chưa có lịch sử</div> | |
| ) : ( | |
| sessions.map((session) => ( | |
| <div key={session.session_id} onClick={() => onLoadSession(session.session_id)} | |
| style={{ display:'flex', alignItems:'center', gap:'10px', padding:'10px', borderRadius:'8px', cursor:'pointer', transition:'background 0.15s', background: currentSessionId === session.session_id ? 'color-mix(in srgb, var(--color-primary) 10%, transparent)' : 'transparent', color: currentSessionId === session.session_id ? 'var(--color-primary)' : 'var(--color-text-secondary)' }} | |
| onMouseEnter={e => { if (currentSessionId !== session.session_id) e.currentTarget.style.background = 'var(--color-bg-secondary)'; }} | |
| onMouseLeave={e => { if (currentSessionId !== session.session_id) e.currentTarget.style.background = 'transparent'; }} | |
| > | |
| <i className="fas fa-message" style={{ fontSize:'13px', opacity:0.7, flexShrink:0 }} /> | |
| <div style={{ flex:1, minWidth:0 }}> | |
| <div style={{ fontSize:'13.5px', fontWeight:500, overflow:'hidden', textOverflow:'ellipsis', whiteSpace:'nowrap' }}>{session.preview || 'Trò chuyện mới'}</div> | |
| <div style={{ fontSize:'11px', opacity:0.6, marginTop:'2px' }}>{formatTimeAgo(session.last_active)}</div> | |
| </div> | |
| <button onClick={(e) => { e.stopPropagation(); onDeleteSession(session.session_id); }} | |
| style={{ opacity:0, padding:'4px', border:'none', background:'transparent', cursor:'pointer', color:'var(--color-danger)', transition:'opacity 0.15s', flexShrink:0 }} | |
| onMouseEnter={e => e.currentTarget.style.opacity = '1'} | |
| onMouseLeave={e => e.currentTarget.style.opacity = '0'} | |
| title="Xóa"> | |
| <i className="fas fa-trash" style={{ fontSize:'12px' }} /> | |
| </button> | |
| </div> | |
| )) | |
| )} | |
| </div> | |
| {/* Footer */} | |
| <div style={{ padding:'16px', borderTop:'1px solid var(--color-border-color)', background:'color-mix(in srgb, var(--color-bg-primary) 30%, transparent)' }}> | |
| <div style={{ background:'var(--color-bg-secondary)', borderRadius:'8px', padding:'12px', border:'1px solid var(--color-border-color)', marginBottom:'12px' }}> | |
| <div style={{ display:'flex', alignItems:'center', gap:'8px', color:'var(--color-primary)', fontWeight:600, fontSize:'13px', marginBottom:'4px' }}> | |
| <i className="fas fa-hospital" />BVĐK Bảo Thắng | |
| </div> | |
| <div style={{ fontSize:'11px', color:'var(--color-text-secondary)' }}>Hệ thống tra cứu chuyên môn y tế</div> | |
| </div> | |
| <button onClick={onToggleTheme} style={{ width:'100%', display:'flex', alignItems:'center', justifyContent:'space-between', padding:'8px 12px', borderRadius:'6px', border:'none', background:'transparent', cursor:'pointer', fontSize:'13.5px', color:'var(--color-text-secondary)', transition:'background 0.2s' }} | |
| onMouseEnter={e => e.currentTarget.style.background = 'var(--color-bg-secondary)'} | |
| onMouseLeave={e => e.currentTarget.style.background = 'transparent'}> | |
| <span>Giao diện</span> | |
| <i className={`fas ${theme === 'dark' ? 'fa-sun' : 'fa-moon'}`} style={{ color: theme === 'dark' ? 'var(--color-warning)' : 'inherit' }} /> | |
| </button> | |
| </div> | |
| </aside> | |
| ); | |
| } | |
| // ── Chat Area Component ─────────────────────────────────────── | |
| function ChatArea({ messages, isTyping, showWelcome, chatAreaRef }) { | |
| return ( | |
| <div ref={chatAreaRef} style={{ flex:1, overflowY:'auto', padding:'24px 16px', scrollBehavior:'smooth' }}> | |
| <div style={{ maxWidth:'1100px', margin:'0 auto', width:'100%', display:'flex', flexDirection:'column', gap:'24px' }}> | |
| {/* Welcome Screen */} | |
| {showWelcome && ( | |
| <div className="animate-fade-in" style={{ display:'flex', flexDirection:'column', alignItems:'center', justifyContent:'center', minHeight:'60vh', textAlign:'center' }}> | |
| <div style={{ width:'64px', height:'64px', borderRadius:'16px', background:'linear-gradient(135deg, var(--color-primary), var(--color-accent))', display:'flex', alignItems:'center', justifyContent:'center', color:'white', fontSize:'28px', marginBottom:'24px', boxShadow:'var(--shadow-premium)' }}> | |
| <i className="fas fa-robot" /> | |
| </div> | |
| <h2 style={{ fontSize:'1.875rem', fontWeight:700, background:'linear-gradient(135deg, var(--color-primary), var(--color-accent))', WebkitBackgroundClip:'text', WebkitTextFillColor:'transparent', marginBottom:'8px' }}>BaoThang-AI Pro</h2> | |
| <p style={{ color:'var(--color-text-secondary)', maxWidth:'420px', marginBottom:'32px', lineHeight:1.6 }}>Trợ lý y khoa thông minh hỗ trợ tra cứu phác đồ, thuốc và các hướng dẫn lâm sàng.</p> | |
| </div> | |
| )} | |
| {/* Messages */} | |
| {messages.map((msg, idx) => ( | |
| <div key={idx} className="animate-slide-up" style={{ display:'flex', width:'100%', justifyContent: msg.type === 'user' ? 'flex-end' : 'flex-start' }}> | |
| <div style={{ display:'flex', gap:'12px', maxWidth:'85%', flexDirection: msg.type === 'user' ? 'row-reverse' : 'row' }}> | |
| {/* Avatar */} | |
| <div style={{ width:'32px', height:'32px', borderRadius:'50%', flexShrink:0, display:'flex', alignItems:'center', justifyContent:'center', color:'white', fontSize:'12px', marginTop:'4px', background: msg.type === 'user' ? 'var(--color-accent)' : 'var(--color-primary)', boxShadow:'0 2px 8px rgba(0,0,0,0.15)' }}> | |
| <i className={`fas ${msg.type === 'user' ? 'fa-user-md' : 'fa-robot'}`} /> | |
| </div> | |
| <div style={{ display:'flex', flexDirection:'column', gap:'4px', minWidth:0 }}> | |
| <div style={{ display:'flex', alignItems:'center', gap:'8px', padding:'0 4px', justifyContent: msg.type === 'user' ? 'flex-end' : 'flex-start' }}> | |
| <span style={{ fontSize:'12px', fontWeight:600, color:'var(--color-text-secondary)' }}>{msg.type === 'user' ? 'Bác sĩ' : 'BaoThang-AI'}</span> | |
| <span style={{ fontSize:'10px', color:'var(--color-text-tertiary)' }}>{msg.timestamp}</span> | |
| </div> | |
| <div style={{ | |
| padding:'14px 16px', | |
| borderRadius: msg.type === 'user' ? '16px 4px 16px 16px' : '4px 16px 16px 16px', | |
| background: msg.type === 'user' ? 'linear-gradient(135deg, var(--color-primary), #0d9488)' : 'var(--color-bubble-bot)', | |
| border: msg.type === 'user' ? 'none' : '1px solid var(--color-border-color)', | |
| color: msg.type === 'user' ? 'white' : 'var(--color-text-primary)', | |
| boxShadow: msg.type === 'user' ? '0 4px 12px rgba(5, 150, 105, 0.3)' : 'var(--shadow-glass-sm)', | |
| }}> | |
| {msg.type === 'user' ? ( | |
| <div style={{ fontSize:'15px', lineHeight:1.6, whiteSpace:'pre-wrap' }}>{msg.content}</div> | |
| ) : ( | |
| <div className="markdown-body"><ReactMarkdown remarkPlugins={[remarkGfm]}>{msg.content}</ReactMarkdown></div> | |
| )} | |
| </div> | |
| {msg.type === 'assistant' && ( | |
| <div style={{ display:'flex', alignItems:'center', justifyContent:'space-between', padding:'0 4px', marginTop:'4px', gap:'16px' }}> | |
| {msg.source ? ( | |
| <span style={{ fontSize:'11px', color:'var(--color-primary)', display:'flex', alignItems:'center', gap:'6px', padding:'4px 8px', border:'1px solid color-mix(in srgb, var(--color-primary) 20%, transparent)', background:'color-mix(in srgb, var(--color-primary) 5%, transparent)', borderRadius:'6px', maxWidth:'80%', overflow:'hidden', textOverflow:'ellipsis', whiteSpace:'nowrap' }} title={msg.source}> | |
| <i className="fas fa-file-medical-alt" /><span style={{ overflow:'hidden', textOverflow:'ellipsis' }}>{msg.source}</span> | |
| </span> | |
| ) : <span />} | |
| <button onClick={() => navigator.clipboard.writeText(msg.content)} | |
| style={{ color:'var(--color-text-tertiary)', border:'none', background:'transparent', cursor:'pointer', padding:'6px', borderRadius:'6px', transition:'all 0.2s' }} | |
| onMouseEnter={e => { e.currentTarget.style.color = 'var(--color-primary)'; e.currentTarget.style.background = 'color-mix(in srgb, var(--color-primary) 10%, transparent)'; }} | |
| onMouseLeave={e => { e.currentTarget.style.color = 'var(--color-text-tertiary)'; e.currentTarget.style.background = 'transparent'; }} | |
| title="Sao chép"> | |
| <i className="fas fa-copy" style={{ fontSize:'13px' }} /> | |
| </button> | |
| </div> | |
| )} | |
| </div> | |
| </div> | |
| </div> | |
| ))} | |
| {/* Typing Indicator */} | |
| {isTyping && ( | |
| <div className="animate-fade-in" style={{ display:'flex', width:'100%', justifyContent:'flex-start' }}> | |
| <div style={{ display:'flex', gap:'12px', maxWidth:'85%' }}> | |
| <div style={{ width:'32px', height:'32px', borderRadius:'50%', flexShrink:0, display:'flex', alignItems:'center', justifyContent:'center', color:'white', fontSize:'12px', marginTop:'4px', background:'var(--color-primary)' }}> | |
| <i className="fas fa-robot" /> | |
| </div> | |
| <div style={{ display:'flex', flexDirection:'column', gap:'4px' }}> | |
| <span style={{ fontSize:'12px', fontWeight:600, color:'var(--color-text-secondary)', padding:'0 4px' }}>BaoThang-AI</span> | |
| <div style={{ padding:'14px 16px', borderRadius:'4px 16px 16px 16px', background:'var(--color-bubble-bot)', border:'1px solid var(--color-border-color)', boxShadow:'var(--shadow-glass-sm)', display:'flex', alignItems:'center', height:'52px' }}> | |
| <span style={{ fontSize:'14px', fontWeight:500, color:'var(--color-text-secondary)', marginRight:'12px', opacity:0.8 }}>Đang phân tích</span> | |
| <div className="typing-dots"> | |
| <span className="dot" /><span className="dot" /><span className="dot" /> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| )} | |
| </div> | |
| </div> | |
| ); | |
| } | |
| // ── Input Area Component ────────────────────────────────────── | |
| function InputArea({ inputValue, setInputValue, onSend, isTyping, inputRef }) { | |
| const handleKeyDown = (e) => { | |
| if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); onSend(); } | |
| }; | |
| const handleInput = (e) => { | |
| setInputValue(e.target.value); | |
| e.target.style.height = 'auto'; | |
| e.target.style.height = Math.min(e.target.scrollHeight, 120) + 'px'; | |
| }; | |
| const canSend = inputValue.trim() && !isTyping; | |
| return ( | |
| <div style={{ padding:'16px', paddingTop:'32px', background:'linear-gradient(to top, var(--color-bg-primary), color-mix(in srgb, var(--color-bg-primary) 95%, transparent))', position:'relative', zIndex:10, marginBottom:'8px' }}> | |
| <div style={{ maxWidth:'1100px', margin:'0 auto' }}> | |
| <div style={{ position:'relative', background:'var(--color-bg-secondary)', padding:'8px', borderRadius:'16px', display:'flex', alignItems:'flex-end', gap:'8px', border:'1px solid var(--color-border-color)', boxShadow:'var(--shadow-glass-sm)', transition:'box-shadow 0.2s, border-color 0.2s' }} | |
| onFocusCapture={e => { e.currentTarget.style.boxShadow = `0 0 0 2px color-mix(in srgb, var(--color-primary) 40%, transparent)`; e.currentTarget.style.borderColor = 'color-mix(in srgb, var(--color-primary) 50%, transparent)'; }} | |
| onBlurCapture={e => { e.currentTarget.style.boxShadow = 'var(--shadow-glass-sm)'; e.currentTarget.style.borderColor = 'var(--color-border-color)'; }}> | |
| <textarea ref={inputRef} rows={1} value={inputValue} onChange={handleInput} onKeyDown={handleKeyDown} | |
| placeholder="Nhập triệu chứng, thuốc, phác đồ điều trị..." | |
| style={{ flex:1, background:'transparent', border:'none', outline:'none', resize:'none', padding:'12px', fontSize:'15px', maxHeight:'120px', color:'var(--color-text-primary)', fontFamily:'inherit', lineHeight:1.6, boxShadow:'none' }} /> | |
| <button onClick={onSend} disabled={!canSend} | |
| style={{ width:'46px', height:'46px', flexShrink:0, display:'flex', alignItems:'center', justifyContent:'center', borderRadius:'10px', border:'none', cursor: canSend ? 'pointer' : 'not-allowed', marginBottom:'2px', marginRight:'2px', transition:'all 0.2s', background: canSend ? 'var(--color-primary)' : 'var(--color-bg-primary)', color: canSend ? 'white' : 'var(--color-text-tertiary)' }} | |
| onMouseEnter={e => { if (canSend) { e.currentTarget.style.background = 'var(--color-primary-light)'; e.currentTarget.style.transform = 'translateY(-1px)'; }}} | |
| onMouseLeave={e => { if (canSend) { e.currentTarget.style.background = 'var(--color-primary)'; e.currentTarget.style.transform = 'translateY(0)'; }}}> | |
| <i className={`fas fa-paper-plane ${isTyping ? 'fa-spin' : ''}`} /> | |
| </button> | |
| </div> | |
| <div style={{ textAlign:'center', marginTop:'12px', fontSize:'11.5px', color:'var(--color-text-tertiary)', display:'flex', alignItems:'center', justifyContent:'center', gap:'6px', opacity:0.8 }}> | |
| <i className="fas fa-shield-alt" style={{ color:'color-mix(in srgb, var(--color-primary) 70%, transparent)' }} /> | |
| <span>AI có thể mắc lỗi. Luôn tham khảo ý kiến chuyên gia và phác đồ y tế chính thức.</span> | |
| </div> | |
| </div> | |
| </div> | |
| ); | |
| } | |
| // ── App Root ────────────────────────────────────────────────── | |
| const API_BASE = '/api/v1'; | |
| function useIsMobile(breakpoint = 768) { | |
| const [isMobile, setIsMobile] = useState(() => window.innerWidth <= breakpoint); | |
| useEffect(() => { | |
| const handler = () => setIsMobile(window.innerWidth <= breakpoint); | |
| window.addEventListener('resize', handler); | |
| return () => window.removeEventListener('resize', handler); | |
| }, [breakpoint]); | |
| return isMobile; | |
| } | |
| export default function App() { | |
| const [theme, setTheme] = useState(() => localStorage.getItem('theme') || 'light'); | |
| const isMobile = useIsMobile(); | |
| const [sidebarOpen, setSidebarOpen] = useState(() => { | |
| if (window.innerWidth <= 768) return false; | |
| return localStorage.getItem('sidebarOpen') !== 'false'; | |
| }); | |
| const [sessions, setSessions] = useState(null); | |
| const [currentSessionId, setCurrentSessionId] = useState(null); | |
| const [messages, setMessages] = useState([]); | |
| const [chatHistory, setChatHistory] = useState([]); | |
| const [showWelcome, setShowWelcome] = useState(true); | |
| const [isTyping, setIsTyping] = useState(false); | |
| const [inputValue, setInputValue] = useState(''); | |
| const [toast, setToast] = useState({ show: false, message: '', type: 'success' }); | |
| const chatAreaRef = useRef(null); | |
| const inputRef = useRef(null); | |
| const toastTimerRef = useRef(null); | |
| useEffect(() => { | |
| document.documentElement.setAttribute('data-theme', theme); | |
| localStorage.setItem('theme', theme); | |
| }, [theme]); | |
| const toggleTheme = () => setTheme(t => t === 'light' ? 'dark' : 'light'); | |
| const toggleSidebar = () => setSidebarOpen(prev => { | |
| if (!isMobile) localStorage.setItem('sidebarOpen', String(!prev)); | |
| return !prev; | |
| }); | |
| const closeSidebar = () => setSidebarOpen(false); | |
| const showToast = useCallback((message, type = 'success') => { | |
| if (toastTimerRef.current) clearTimeout(toastTimerRef.current); | |
| setToast({ show: true, message, type }); | |
| toastTimerRef.current = setTimeout(() => setToast(t => ({ ...t, show: false })), 3000); | |
| }, []); | |
| const scrollToBottom = useCallback(() => { | |
| if (chatAreaRef.current) chatAreaRef.current.scrollTo({ top: chatAreaRef.current.scrollHeight, behavior: 'smooth' }); | |
| }, []); | |
| useEffect(() => { scrollToBottom(); }, [messages, isTyping, scrollToBottom]); | |
| const loadSessions = useCallback(async () => { | |
| try { | |
| const res = await fetch(`${API_BASE}/sessions`); | |
| const data = await res.json(); | |
| if (data.success && data.sessions) setSessions(data.sessions); | |
| } catch { setSessions([]); } | |
| }, []); | |
| useEffect(() => { | |
| loadSessions(); | |
| (async () => { | |
| try { | |
| const res = await fetch(`${API_BASE}/history`); | |
| const data = await res.json(); | |
| if (data.success && data.messages && data.messages.length > 0) { | |
| const msgs = data.messages.map(m => ({ type: m.role === 'user' ? 'user' : 'assistant', content: m.content, timestamp: m.timestamp || '', source: m.source || null })); | |
| setMessages(msgs); | |
| setChatHistory(msgs.map(m => ({ ...m }))); | |
| setShowWelcome(false); | |
| } | |
| } catch { /* silent */ } | |
| })(); | |
| }, [loadSessions]); | |
| const createNewChat = useCallback(async () => { | |
| try { | |
| const res = await fetch(`${API_BASE}/new-chat`, { method: 'POST' }); | |
| if (res.ok) { | |
| setMessages([]); setChatHistory([]); setCurrentSessionId(null); setShowWelcome(true); | |
| await loadSessions(); | |
| if (isMobile) closeSidebar(); | |
| } | |
| } catch { showToast('Lỗi tạo chat mới', 'error'); } | |
| }, [loadSessions, showToast, isMobile]); | |
| const loadSession = useCallback(async (sessionId) => { | |
| try { | |
| const res = await fetch(`${API_BASE}/session/${sessionId}`); | |
| const data = await res.json(); | |
| if (data.success) { | |
| setCurrentSessionId(sessionId); | |
| const msgs = data.messages.map(m => ({ type: m.role === 'user' ? 'user' : 'assistant', content: m.content, timestamp: m.timestamp || '', source: m.source || null })); | |
| setMessages(msgs); setChatHistory(msgs.map(m => ({ ...m }))); setShowWelcome(false); | |
| if (isMobile) closeSidebar(); | |
| } | |
| } catch { showToast('Lỗi tải dữ liệu', 'error'); } | |
| }, [showToast, isMobile]); | |
| const deleteSession = useCallback(async (sessionId) => { | |
| if (!window.confirm('Xóa đoạn chat này?')) return; | |
| try { | |
| const res = await fetch(`${API_BASE}/session/${sessionId}`, { method: 'DELETE' }); | |
| if (res.ok) { | |
| await loadSessions(); | |
| if (currentSessionId === sessionId) await createNewChat(); | |
| showToast('Đã xóa trò chuyện', 'success'); | |
| } | |
| } catch { showToast('Không thể xóa', 'error'); } | |
| }, [currentSessionId, loadSessions, createNewChat, showToast]); | |
| const clearChat = useCallback(async () => { | |
| if (!window.confirm('Xóa hội thoại hiện tại?')) return; | |
| try { | |
| const res = await fetch(`${API_BASE}/clear`, { method: 'POST' }); | |
| if (res.ok) { setMessages([]); setChatHistory([]); setShowWelcome(true); showToast('Đã dọn dẹp hội thoại', 'success'); } | |
| } catch { showToast('Lỗi truy xuất', 'error'); } | |
| }, [showToast]); | |
| const downloadChat = useCallback(() => { | |
| if (chatHistory.length === 0) { showToast('Không có dữ liệu', 'error'); return; } | |
| const blob = new Blob([buildDownloadText(chatHistory)], { type: 'text/plain' }); | |
| const url = URL.createObjectURL(blob); | |
| const a = document.createElement('a'); | |
| a.href = url; a.download = `BaoThang-${Date.now()}.txt`; a.click(); | |
| URL.revokeObjectURL(url); | |
| showToast('Tải xuống thành công', 'success'); | |
| }, [chatHistory, showToast]); | |
| const sendMessage = useCallback(async (overrideText) => { | |
| const message = (overrideText ?? inputValue).trim(); | |
| if (!message || isTyping) return; | |
| setShowWelcome(false); | |
| const time = new Date().toLocaleTimeString('vi-VN', { hour: '2-digit', minute: '2-digit' }); | |
| const userMsg = { type: 'user', content: message, timestamp: time, source: null }; | |
| setMessages(prev => [...prev, userMsg]); | |
| setChatHistory(prev => [...prev, userMsg]); | |
| setInputValue(''); | |
| if (inputRef.current) inputRef.current.style.height = 'auto'; | |
| setIsTyping(true); | |
| try { | |
| const res = await fetch(`${API_BASE}/chat/stream`, { | |
| method: 'POST', | |
| headers: { 'Content-Type': 'application/json' }, | |
| body: JSON.stringify({ message }), | |
| }); | |
| if (!res.ok) { | |
| throw new Error(`HTTP ${res.status}`); | |
| } | |
| // Create placeholder bot message for streaming | |
| const botMsg = { type: 'assistant', content: '', timestamp: time, source: null }; | |
| setMessages(prev => [...prev, botMsg]); | |
| const botIdx = { current: -1 }; // will be set after first render | |
| const reader = res.body.getReader(); | |
| const decoder = new TextDecoder(); | |
| let buffer = ''; | |
| let streamedContent = ''; | |
| let finalSource = null; | |
| while (true) { | |
| const { done, value } = await reader.read(); | |
| if (done) break; | |
| buffer += decoder.decode(value, { stream: true }); | |
| // Parse SSE events from buffer | |
| const lines = buffer.split('\n'); | |
| buffer = lines.pop(); // keep incomplete line | |
| for (const line of lines) { | |
| if (!line.startsWith('data: ')) continue; | |
| try { | |
| const event = JSON.parse(line.slice(6)); | |
| if (event.type === 'token') { | |
| streamedContent += event.content; | |
| setMessages(prev => { | |
| const updated = [...prev]; | |
| updated[updated.length - 1] = { ...updated[updated.length - 1], content: streamedContent }; | |
| return updated; | |
| }); | |
| } else if (event.type === 'replace') { | |
| // Replace full content with cleaned version (no [USED_SOURCES:...] tag, with refs) | |
| streamedContent = event.content; | |
| setMessages(prev => { | |
| const updated = [...prev]; | |
| updated[updated.length - 1] = { ...updated[updated.length - 1], content: streamedContent }; | |
| return updated; | |
| }); | |
| } else if (event.type === 'done') { | |
| finalSource = event.source || null; | |
| setMessages(prev => { | |
| const updated = [...prev]; | |
| updated[updated.length - 1] = { ...updated[updated.length - 1], source: finalSource }; | |
| return updated; | |
| }); | |
| } | |
| } catch { /* skip malformed SSE line */ } | |
| } | |
| } | |
| // Finalize | |
| const finalMsg = { type: 'assistant', content: streamedContent, timestamp: time, source: finalSource }; | |
| setChatHistory(prev => [...prev, finalMsg]); | |
| await loadSessions(); | |
| } catch { | |
| setMessages(prev => [...prev, { type: 'assistant', content: 'Lỗi kết nối. Vui lòng kiểm tra lại mạng.', timestamp: time, source: null }]); | |
| showToast('Lỗi kết nối', 'error'); | |
| } finally { setIsTyping(false); } | |
| }, [inputValue, isTyping, loadSessions, showToast]); | |
| const toastBg = { success: 'var(--color-success)', error: 'var(--color-danger)', info: 'var(--color-primary)' }; | |
| const toastIcon = { success: 'fa-check-circle', error: 'fa-exclamation-circle', info: 'fa-info-circle' }; | |
| return ( | |
| <> | |
| <div className="animated-bg" /> | |
| <div style={{ display:'flex', height:'100vh', width:'100%', position:'relative', zIndex:1, overflow:'hidden' }}> | |
| {/* Mobile backdrop */} | |
| {isMobile && sidebarOpen && ( | |
| <div onClick={closeSidebar} style={{ position:'fixed', inset:0, background:'rgba(0,0,0,0.4)', backdropFilter:'blur(4px)', zIndex:15 }} /> | |
| )} | |
| <Sidebar sidebarOpen={sidebarOpen} sessions={sessions} currentSessionId={currentSessionId} | |
| onNewChat={createNewChat} onLoadSession={loadSession} onDeleteSession={deleteSession} | |
| onToggleTheme={toggleTheme} theme={theme} /> | |
| <main style={{ flex:1, display:'flex', flexDirection:'column', height:'100%', overflow:'hidden' }}> | |
| {/* Header */} | |
| <header className="glass-header" style={{ height:'64px', display:'flex', alignItems:'center', justifyContent:'space-between', padding:'0 16px', position:'relative', zIndex:10, flexShrink:0 }}> | |
| <div style={{ display:'flex', alignItems:'center', gap:'12px' }}> | |
| <button onClick={toggleSidebar} style={{ width:'38px', height:'38px', borderRadius:'8px', border:'1px solid var(--color-border-color)', background:'var(--color-bg-secondary)', color:'var(--color-text-secondary)', cursor:'pointer', display:'flex', alignItems:'center', justifyContent:'center', flexShrink:0, transition:'all 0.2s' }}> | |
| <i className={`fas ${sidebarOpen ? 'fa-times' : 'fa-bars'}`} /> | |
| </button> | |
| <div style={{ fontWeight:700, fontSize:'16px', color:'var(--color-text-primary)', letterSpacing:'-0.01em', display:'flex', alignItems:'center', gap:'10px' }}> | |
| BaoThang-AI | |
| <div style={{ display:'flex', alignItems:'center', gap:'6px', padding:'4px 10px', borderRadius:'999px', background:'color-mix(in srgb, var(--color-success) 10%, transparent)', border:'1px solid color-mix(in srgb, var(--color-success) 20%, transparent)' }}> | |
| <span style={{ width:'7px', height:'7px', borderRadius:'50%', background:'var(--color-success)', display:'inline-block', animation:'ping 2s infinite' }} /> | |
| <span style={{ fontSize:'10.5px', fontWeight:700, color:'var(--color-success)', textTransform:'uppercase', letterSpacing:'0.05em' }}>Ready</span> | |
| </div> | |
| </div> | |
| </div> | |
| <div style={{ display:'flex', alignItems:'center', gap:'8px' }}> | |
| {[{ icon:'fa-eraser', onClick:clearChat, title:'Xóa hội thoại', danger:true }, { icon:'fa-download', onClick:downloadChat, title:'Tải xuống' }].map(({ icon, onClick, title, danger }) => ( | |
| <button key={icon} onClick={onClick} title={title} | |
| style={{ width:'36px', height:'36px', borderRadius:'50%', background:'var(--color-bg-secondary)', border:'none', cursor:'pointer', display:'flex', alignItems:'center', justifyContent:'center', color:'var(--color-text-secondary)', transition:'all 0.2s' }} | |
| onMouseEnter={e => { e.currentTarget.style.color = danger ? 'var(--color-danger)' : 'var(--color-primary)'; e.currentTarget.style.background = danger ? 'color-mix(in srgb, var(--color-danger) 10%, transparent)' : 'color-mix(in srgb, var(--color-primary) 10%, transparent)'; }} | |
| onMouseLeave={e => { e.currentTarget.style.color = 'var(--color-text-secondary)'; e.currentTarget.style.background = 'var(--color-bg-secondary)'; }}> | |
| <i className={`fas ${icon}`} style={{ fontSize:'15px' }} /> | |
| </button> | |
| ))} | |
| </div> | |
| </header> | |
| <ChatArea messages={messages} isTyping={isTyping} showWelcome={showWelcome} chatAreaRef={chatAreaRef} /> | |
| <InputArea inputValue={inputValue} setInputValue={setInputValue} onSend={() => sendMessage()} isTyping={isTyping} inputRef={inputRef} /> | |
| </main> | |
| </div> | |
| {/* Toast */} | |
| <div style={{ position:'fixed', top:'24px', left:'50%', transform:`translateX(-50%) translateY(${toast.show ? '0' : '-32px'})`, zIndex:9999, padding:'12px 20px', borderRadius:'12px', boxShadow:'var(--shadow-premium)', border:'1px solid rgba(255,255,255,0.2)', display:'flex', alignItems:'center', gap:'10px', transition:'all 0.3s cubic-bezier(0.2, 0.8, 0.2, 1)', opacity: toast.show ? 1 : 0, pointerEvents: toast.show ? 'auto' : 'none', background: toastBg[toast.type], color:'white' }}> | |
| <i className={`fas ${toastIcon[toast.type]}`} style={{ fontSize:'16px' }} /> | |
| <span style={{ fontWeight:500, fontSize:'14px' }}>{toast.message}</span> | |
| </div> | |
| <style>{` | |
| @keyframes ping { | |
| 0%, 100% { transform: scale(1); opacity: 1; } | |
| 50% { transform: scale(1.4); opacity: 0.7; } | |
| } | |
| `}</style> | |
| </> | |
| ); | |
| } | |
| export function AppWithErrorBoundary() { | |
| return <ErrorBoundary><App /></ErrorBoundary>; | |
| } | |