272 lines
8.1 KiB
JavaScript
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);
|