BaoThangAI / frontend /src /App.jsx
TrungLC's picture
Initial commit - BaoThangAI
cb75c6a
Raw
History Blame Contribute Delete
34.4 kB
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>;
}