222 lines
6.7 KiB
JavaScript
222 lines
6.7 KiB
JavaScript
const API = '';
|
|
let currentSession = null;
|
|
|
|
// 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 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;
|
|
|
|
try {
|
|
const data = await api(`/api/sessions/${currentSession.id}/messages`, {
|
|
method: 'POST',
|
|
body: JSON.stringify({ content: text }),
|
|
});
|
|
|
|
typingEl.remove();
|
|
appendMessage('assistant', data.content, data.sources);
|
|
scrollToBottom();
|
|
loadSessions();
|
|
} catch (e) {
|
|
typingEl.remove();
|
|
appendMessage('assistant', 'Lỗi kết nối: ' + e.message);
|
|
scrollToBottom();
|
|
} finally {
|
|
btnSend.disabled = false;
|
|
inputMessage.focus();
|
|
}
|
|
}
|
|
|
|
function appendMessage(role, content, sources = 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 = `<div class="bubble">${esc(content)}</div>`;
|
|
|
|
if (sources && sources.length > 0) {
|
|
html += `<div class="sources">`;
|
|
sources.forEach(s => {
|
|
html += `<a class="source-chip" href="${esc(s.url)}" target="_blank" title="${esc(s.title)}">${esc(s.title.substring(0, 40))}</a>`;
|
|
});
|
|
html += `</div>`;
|
|
}
|
|
|
|
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;
|
|
|
|
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);
|