| import { useState, useEffect, useRef, useCallback, Component } from 'react'; |
| import ReactMarkdown from 'react-markdown'; |
| import remarkGfm from 'remark-gfm'; |
| import './index.css'; |
|
|
| |
| 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('[DeepMed-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; |
| } |
| } |
|
|
| |
| 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 = 'DeepMed-AI — TTYT Khu vực Thanh Ba\n' + '='.repeat(50) + '\n\n'; |
| chatHistory.forEach((msg) => { |
| content += `[${msg.timestamp}] ${msg.type === 'user' ? 'Bác sĩ' : 'DeepMed-AI'}:\n${msg.content}\n`; |
| if (msg.source) content += `Nguồn: ${msg.source}\n`; |
| content += '\n'; |
| }); |
| return content; |
| } |
|
|
| |
| 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' }}>DeepMed-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" />TTYT Thanh Ba |
| </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> |
| ); |
| } |
|
|
| |
| 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' }}>DeepMed-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ĩ' : 'DeepMed-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' }}>DeepMed-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> |
| ); |
| } |
|
|
| |
| 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> |
| ); |
| } |
|
|
| |
| 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 { } |
| })(); |
| }, [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 = `DeepMed-${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}`); |
| } |
|
|
| |
| const botMsg = { type: 'assistant', content: '', timestamp: time, source: null }; |
| setMessages(prev => [...prev, botMsg]); |
| const botIdx = { current: -1 }; |
|
|
| 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 }); |
|
|
| |
| const lines = buffer.split('\n'); |
| buffer = lines.pop(); |
|
|
| 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') { |
| |
| 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 { } |
| } |
| } |
|
|
| |
| 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' }}> |
| DeepMed-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>; |
| } |
|
|