Files
2026-06-14 18:00:49 +07:00

272 lines
8.1 KiB
JavaScript

const API = '';
let currentSession = null;
let sendTime = 0;
// Configure marked
marked.setOptions({
breaks: true,
gfm: true,
headerIds: false,
mangle: false,
});
// ---- Theme ----
function initTheme() {
const saved = localStorage.getItem('rag-theme') || 'dark';
document.documentElement.setAttribute('data-theme', saved);
}
function toggleTheme() {
const current = document.documentElement.getAttribute('data-theme');
const next = current === 'dark' ? 'light' : 'dark';
document.documentElement.setAttribute('data-theme', next);
localStorage.setItem('rag-theme', next);
}
initTheme();
// DOM
const sessionList = document.getElementById('session-list');
const messages = document.getElementById('messages');
const emptyState = document.getElementById('empty-state');
const inputArea = document.getElementById('input-area');
const inputMessage = document.getElementById('input-message');
const btnSend = document.getElementById('btn-send');
const btnNewSession = document.getElementById('btn-new-session');
const btnClear = document.getElementById('btn-clear');
const btnDelete = document.getElementById('btn-delete');
const btnMenu = document.getElementById('btn-menu');
const btnTheme = document.getElementById('btn-theme');
const chatTitle = document.getElementById('chat-title');
const statsEl = document.getElementById('stats');
// ---- API helpers ----
async function api(path, opts = {}) {
const res = await fetch(API + path, {
headers: { 'Content-Type': 'application/json' },
...opts,
});
return res.json();
}
// ---- Sessions ----
async function loadSessions() {
const sessions = await api('/api/sessions');
sessionList.innerHTML = sessions.map(s => `
<div class="session-item ${currentSession?.id === s.id ? 'active' : ''}"
data-id="${s.id}" onclick="selectSession('${s.id}')">
<span class="title">${esc(s.title)}</span>
<span class="time">${formatTime(s.updated_at)}</span>
</div>
`).join('');
}
async function createSession() {
const s = await api('/api/sessions', { method: 'POST', body: '{}' });
currentSession = s;
await loadSessions();
showChat();
}
async function selectSession(id) {
const sessions = await api('/api/sessions');
currentSession = sessions.find(s => s.id === id) || null;
if (!currentSession) return;
await loadSessions();
await loadMessages(id);
showChat();
}
async function deleteSession() {
if (!currentSession) return;
if (!confirm('Xóa phiên này?')) return;
await api(`/api/sessions/${currentSession.id}`, { method: 'DELETE' });
currentSession = null;
await loadSessions();
showEmpty();
}
async function clearMessages() {
if (!currentSession) return;
if (!confirm('Xóa lịch sử chat trong phiên này?')) return;
await api(`/api/sessions/${currentSession.id}/clear`, { method: 'POST' });
await loadMessages(currentSession.id);
}
async function loadMessages(sessionId) {
const msgs = await api(`/api/sessions/${sessionId}/messages`);
messages.innerHTML = '';
if (msgs.length === 0) {
messages.innerHTML = `<div class="empty-state"><p>Gõ câu hỏi bên dưới để bắt đầu</p></div>`;
return;
}
msgs.forEach(m => appendMessage(m.role, m.content, m.sources));
scrollToBottom();
}
// ---- Chat ----
async function sendMessage() {
const text = inputMessage.value.trim();
if (!text || !currentSession) return;
inputMessage.value = '';
autoResize();
appendMessage('user', text);
scrollToBottom();
// Typing indicator
const typingEl = document.createElement('div');
typingEl.className = 'message assistant';
typingEl.innerHTML = `<div class="bubble"><div class="typing-indicator"><span></span><span></span><span></span></div></div>`;
messages.appendChild(typingEl);
scrollToBottom();
btnSend.disabled = true;
sendTime = performance.now();
try {
const data = await api(`/api/sessions/${currentSession.id}/messages`, {
method: 'POST',
body: JSON.stringify({ content: text }),
});
const elapsed = ((performance.now() - sendTime) / 1000).toFixed(1);
typingEl.remove();
appendMessage('assistant', data.content, data.sources, elapsed);
scrollToBottom();
loadSessions();
} catch (e) {
typingEl.remove();
appendMessage('assistant', 'Lỗi kết nối: ' + e.message);
scrollToBottom();
} finally {
btnSend.disabled = false;
inputMessage.focus();
}
}
function renderMarkdown(text) {
try {
return marked.parse(text || '');
} catch {
return esc(text);
}
}
function appendMessage(role, content, sources = null, elapsed = null) {
// Remove empty state if present
const empty = messages.querySelector('.empty-state');
if (empty) empty.remove();
const div = document.createElement('div');
div.className = `message ${role}`;
let html = '';
if (role === 'assistant') {
html += `<div class="bubble">${renderMarkdown(content)}</div>`;
} else {
html += `<div class="bubble">${esc(content)}</div>`;
}
// Sources + timing
const metaParts = [];
if (sources && sources.length > 0) {
const chips = sources.map(s =>
`<a class="source-chip" href="${esc(s.url)}" target="_blank" title="${esc(s.title)}">${esc(s.title.substring(0, 40))}</a>`
).join('');
metaParts.push(`<div class="sources">${chips}</div>`);
}
if (elapsed !== null && role === 'assistant') {
metaParts.push(`<div class="msg-meta"><span class="timing">⚡ ${elapsed}s</span></div>`);
}
if (metaParts.length) {
html += metaParts.join('');
}
div.innerHTML = html;
messages.appendChild(div);
}
// ---- UI state ----
function showChat() {
emptyState.style.display = 'none';
inputArea.style.display = 'block';
chatTitle.textContent = currentSession?.title || '';
btnClear.style.display = currentSession ? '' : 'none';
btnDelete.style.display = currentSession ? '' : 'none';
}
function showEmpty() {
emptyState.style.display = 'flex';
inputArea.style.display = 'none';
chatTitle.textContent = 'Chọn hoặc tạo phiên mới';
btnClear.style.display = 'none';
btnDelete.style.display = 'none';
messages.innerHTML = '';
messages.appendChild(emptyState);
}
function scrollToBottom() {
messages.scrollTop = messages.scrollHeight;
}
function autoResize() {
inputMessage.style.height = 'auto';
inputMessage.style.height = Math.min(inputMessage.scrollHeight, 120) + 'px';
}
// ---- Stats ----
async function loadStats() {
try {
const s = await api('/api/stats');
statsEl.innerHTML = `Chunks: ${s.chunks_indexed} | Phiên: ${s.sessions} | Tin nhắn: ${s.messages}`;
} catch {}
}
// ---- Util ----
function esc(str) {
if (!str) return '';
const d = document.createElement('div');
d.textContent = str;
return d.innerHTML;
}
function formatTime(iso) {
if (!iso) return '';
const d = new Date(iso);
const now = new Date();
const diff = now - d;
if (diff < 60000) return 'vừa xong';
if (diff < 3600000) return Math.floor(diff / 60000) + ' phút';
if (diff < 86400000) return Math.floor(diff / 3600000) + ' giờ';
return d.toLocaleDateString('vi-VN', { day: '2-digit', month: '2-digit' });
}
// ---- Events ----
btnNewSession.onclick = createSession;
btnClear.onclick = clearMessages;
btnDelete.onclick = deleteSession;
btnSend.onclick = sendMessage;
btnTheme.onclick = toggleTheme;
btnMenu.onclick = () => {
document.getElementById('sidebar').classList.toggle('open');
};
inputMessage.addEventListener('input', autoResize);
inputMessage.addEventListener('keydown', e => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
sendMessage();
}
});
// Close sidebar on mobile when clicking outside
document.querySelector('.main').addEventListener('click', () => {
document.getElementById('sidebar').classList.remove('open');
});
// Init
loadSessions().then(loadStats);