them web app va README

This commit is contained in:
2026-06-12 22:13:19 +07:00
parent abddb932aa
commit c512d60160
8 changed files with 1216 additions and 155 deletions
+221
View File
@@ -0,0 +1,221 @@
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);
+341
View File
@@ -0,0 +1,341 @@
:root {
--bg: #0f0f0f;
--bg-sidebar: #161616;
--bg-chat: #0f0f0f;
--bg-input: #1e1e1e;
--bg-user: #2563eb;
--bg-assistant: #1a1a1a;
--bg-hover: #1e1e1e;
--bg-active: #2563eb20;
--border: #2a2a2a;
--text: #e5e5e5;
--text-dim: #888;
--text-muted: #555;
--accent: #2563eb;
--accent-hover: #1d4ed8;
--danger: #dc2626;
--danger-hover: #b91c1c;
--radius: 12px;
--radius-sm: 8px;
--sidebar-w: 280px;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: var(--bg);
color: var(--text);
height: 100vh;
overflow: hidden;
}
.app {
display: flex;
height: 100vh;
}
/* Sidebar */
.sidebar {
width: var(--sidebar-w);
background: var(--bg-sidebar);
border-right: 1px solid var(--border);
display: flex;
flex-direction: column;
flex-shrink: 0;
transition: transform 0.2s;
}
.sidebar-header {
padding: 16px;
display: flex;
align-items: center;
justify-content: space-between;
border-bottom: 1px solid var(--border);
}
.sidebar-header h2 {
font-size: 16px;
font-weight: 600;
}
.session-list {
flex: 1;
overflow-y: auto;
padding: 8px;
}
.session-item {
padding: 10px 12px;
border-radius: var(--radius-sm);
cursor: pointer;
margin-bottom: 2px;
display: flex;
align-items: center;
justify-content: space-between;
transition: background 0.15s;
}
.session-item:hover {
background: var(--bg-hover);
}
.session-item.active {
background: var(--bg-active);
border: 1px solid var(--accent);
}
.session-item .title {
font-size: 13px;
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.session-item .time {
font-size: 11px;
color: var(--text-muted);
margin-left: 8px;
flex-shrink: 0;
}
.sidebar-footer {
padding: 12px 16px;
border-top: 1px solid var(--border);
}
.stats {
font-size: 11px;
color: var(--text-muted);
line-height: 1.6;
}
/* Main */
.main {
flex: 1;
display: flex;
flex-direction: column;
min-width: 0;
}
.chat-header {
padding: 12px 16px;
border-bottom: 1px solid var(--border);
display: flex;
align-items: center;
gap: 12px;
background: var(--bg-sidebar);
}
.chat-title {
flex: 1;
font-size: 14px;
font-weight: 500;
}
.chat-actions {
display: flex;
gap: 4px;
}
/* Messages */
.messages {
flex: 1;
overflow-y: auto;
padding: 16px;
display: flex;
flex-direction: column;
gap: 16px;
}
.empty-state {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
color: var(--text-dim);
gap: 8px;
}
.empty-icon { opacity: 0.3; margin-bottom: 8px; }
.empty-state h3 { font-size: 20px; color: var(--text); }
.empty-state p { font-size: 14px; }
.empty-state .hint { font-size: 12px; color: var(--text-muted); margin-top: 8px; }
.message {
max-width: 800px;
width: 100%;
margin: 0 auto;
display: flex;
flex-direction: column;
gap: 4px;
}
.message.user .bubble {
background: var(--bg-user);
color: white;
align-self: flex-end;
border-radius: var(--radius) var(--radius) 4px var(--radius);
}
.message.assistant .bubble {
background: var(--bg-assistant);
border: 1px solid var(--border);
border-radius: var(--radius) var(--radius) var(--radius) 4px;
}
.bubble {
padding: 12px 16px;
font-size: 14px;
line-height: 1.7;
white-space: pre-wrap;
word-break: break-word;
}
.sources {
display: flex;
flex-wrap: wrap;
gap: 6px;
padding: 0 4px;
}
.source-chip {
font-size: 11px;
padding: 3px 8px;
border-radius: 999px;
background: var(--bg-input);
border: 1px solid var(--border);
color: var(--text-dim);
text-decoration: none;
transition: all 0.15s;
}
.source-chip:hover {
background: var(--accent);
color: white;
border-color: var(--accent);
}
.typing-indicator {
display: flex;
gap: 4px;
padding: 12px 16px;
}
.typing-indicator span {
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--text-muted);
animation: bounce 1.4s infinite;
}
.typing-indicator span:nth-child(2) { animation-delay: 0.2s; }
.typing-indicator span:nth-child(3) { animation-delay: 0.4s; }
@keyframes bounce {
0%, 60%, 100% { transform: translateY(0); }
30% { transform: translateY(-4px); }
}
/* Input */
.input-area {
padding: 12px 16px 16px;
border-top: 1px solid var(--border);
background: var(--bg-sidebar);
}
.input-wrapper {
display: flex;
align-items: flex-end;
background: var(--bg-input);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 4px;
transition: border-color 0.15s;
}
.input-wrapper:focus-within {
border-color: var(--accent);
}
#input-message {
flex: 1;
background: none;
border: none;
color: var(--text);
font-size: 14px;
padding: 8px 12px;
resize: none;
outline: none;
font-family: inherit;
max-height: 120px;
line-height: 1.5;
}
#input-message::placeholder { color: var(--text-muted); }
.btn-send {
width: 36px;
height: 36px;
border: none;
background: var(--accent);
color: white;
border-radius: var(--radius-sm);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
transition: background 0.15s;
}
.btn-send:hover { background: var(--accent-hover); }
.btn-send:disabled { opacity: 0.5; cursor: not-allowed; }
.input-hint {
font-size: 11px;
color: var(--text-muted);
text-align: center;
margin-top: 6px;
}
/* Buttons */
.btn-icon {
width: 32px;
height: 32px;
border: none;
background: none;
color: var(--text-dim);
border-radius: var(--radius-sm);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.15s;
}
.btn-icon:hover { background: var(--bg-hover); color: var(--text); }
.btn-icon.btn-danger:hover { background: var(--danger); color: white; }
/* Scrollbar */
::-webkit-scrollbar { width: 6px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; }
::-webkit-scrollbar-thumb:hover { background: var(--text-muted); }
/* Responsive */
@media (max-width: 768px) {
.sidebar {
position: fixed;
left: 0;
top: 0;
bottom: 0;
z-index: 100;
transform: translateX(-100%);
}
.sidebar.open { transform: translateX(0); }
.btn-menu { display: flex !important; }
}