rag-vietnamese / static /index.html
thaidinhz1's picture
feat: add compare pipeline tab + has_context badge in UI
862a5f6
Raw History Blame
25.7 kB
<!DOCTYPE html>
<html lang="vi" data-theme="light">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Vietnamese RAG System</title>
<style>
:root {
--bg: #f0f4f8; --surface: #ffffff; --surface2: #f7fafc;
--border: #e2e8f0; --text: #2d3748; --text2: #718096; --text3: #a0aec0;
--primary: #1a73e8; --primary-dark: #1558b0; --primary-light: #e8f0fe;
--green: #68d391; --green-bg: #c6f6d5; --green-text: #276749;
--red: #e53e3e; --shadow: rgba(0,0,0,0.08);
}
[data-theme="dark"] {
--bg: #0f1117; --surface: #1a1d27; --surface2: #242736;
--border: #2d3148; --text: #e2e8f0; --text2: #a0aec0; --text3: #718096;
--primary: #4d8ef0; --primary-dark: #3a7be8; --primary-light: #1a2a4a;
--green: #48bb78; --green-bg: #1a3a2a; --green-text: #68d391;
--red: #fc8181; --shadow: rgba(0,0,0,0.3);
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: var(--bg); color: var(--text); min-height: 100vh; display: flex; flex-direction: column; transition: background 0.2s, color 0.2s; }
/* Header */
header { background: linear-gradient(135deg, #1a73e8, #0d47a1); color: white; padding: 14px 24px; box-shadow: 0 2px 8px rgba(0,0,0,0.2); display: flex; align-items: center; justify-content: space-between; }
.header-left h1 { font-size: 1.2rem; font-weight: 700; }
.header-left p { font-size: 0.78rem; opacity: 0.85; margin-top: 3px; }
.theme-btn { background: rgba(255,255,255,0.15); border: none; color: white; padding: 7px 12px; border-radius: 8px; cursor: pointer; font-size: 0.85rem; transition: background 0.2s; }
.theme-btn:hover { background: rgba(255,255,255,0.25); }
/* Layout */
.layout { flex: 1; display: flex; max-width: 1100px; width: 100%; margin: 0 auto; padding: 20px 16px 0; gap: 20px; }
/* Sidebar */
.sidebar { width: 260px; flex-shrink: 0; display: flex; flex-direction: column; gap: 12px; }
.card { background: var(--surface); border-radius: 12px; padding: 16px; box-shadow: 0 2px 8px var(--shadow); border: 1px solid var(--border); }
.card h3 { font-size: 0.83rem; font-weight: 700; color: var(--text2); margin-bottom: 10px; }
.drop-zone { border: 2px dashed var(--border); border-radius: 8px; padding: 18px 12px; text-align: center; cursor: pointer; transition: all 0.2s; }
.drop-zone:hover, .drop-zone.drag-over { border-color: var(--primary); background: var(--primary-light); }
.drop-zone .drop-icon { font-size: 1.6rem; margin-bottom: 6px; }
.drop-zone p { font-size: 0.76rem; color: var(--text2); }
.drop-zone span { font-size: 0.7rem; color: var(--text3); }
#file-input { display: none; }
.upload-btn { width: 100%; margin-top: 8px; padding: 8px; background: var(--primary); color: white; border: none; border-radius: 8px; cursor: pointer; font-size: 0.82rem; font-weight: 600; transition: background 0.2s; }
.upload-btn:hover { background: var(--primary-dark); }
.upload-btn:disabled { background: var(--border); color: var(--text3); cursor: not-allowed; }
.upload-status { margin-top: 8px; font-size: 0.76rem; min-height: 16px; }
.upload-status.ok { color: var(--green); }
.upload-status.err { color: var(--red); }
.upload-status.loading { color: var(--text3); }
.doc-list-empty { font-size: 0.76rem; color: var(--text3); text-align: center; padding: 10px 0; }
.doc-item { display: flex; align-items: flex-start; gap: 8px; padding: 6px 0; border-bottom: 1px solid var(--border); }
.doc-item:last-child { border-bottom: none; }
.doc-name { font-size: 0.76rem; color: var(--text); font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.doc-meta { font-size: 0.68rem; color: var(--text3); margin-top: 2px; }
/* Sample questions */
.sample-q { display: block; width: 100%; text-align: left; background: var(--surface2); border: 1px solid var(--border); border-radius: 8px; padding: 8px 10px; margin-bottom: 6px; cursor: pointer; font-size: 0.76rem; color: var(--text2); transition: all 0.15s; }
.sample-q:last-child { margin-bottom: 0; }
.sample-q:hover { background: var(--primary-light); border-color: var(--primary); color: var(--primary); }
/* Chat */
.chat-area { flex: 1; min-width: 0; display: flex; flex-direction: column; }
#history { display: flex; flex-direction: column; gap: 20px; padding-bottom: 110px; }
.chat-item { display: flex; flex-direction: column; gap: 8px; }
.bubble-q { align-self: flex-end; background: var(--primary); color: white; padding: 10px 16px; border-radius: 18px 18px 4px 18px; max-width: 70%; font-size: 0.93rem; word-break: break-word; }
.bubble-a { align-self: flex-start; background: var(--surface); border-radius: 4px 18px 18px 18px; padding: 16px 18px; max-width: 100%; box-shadow: 0 2px 8px var(--shadow); width: 100%; border: 1px solid var(--border); position: relative; }
.copy-btn { position: absolute; top: 10px; right: 12px; background: var(--surface2); border: 1px solid var(--border); border-radius: 6px; padding: 4px 8px; font-size: 0.7rem; color: var(--text3); cursor: pointer; transition: all 0.15s; }
.copy-btn:hover { background: var(--primary-light); color: var(--primary); border-color: var(--primary); }
.copy-btn.copied { color: var(--green); border-color: var(--green); }
.answer-text { font-size: 0.93rem; line-height: 1.8; white-space: pre-wrap; word-break: break-word; padding-right: 60px; }
.cite { display: inline-block; background: var(--primary-light); color: var(--primary); border-radius: 4px; padding: 1px 6px; font-size: 0.76rem; font-weight: 700; }
.sources-section { margin-top: 14px; border-top: 1px solid var(--border); padding-top: 10px; }
.sources-title { font-size: 0.7rem; font-weight: 700; color: var(--text3); text-transform: uppercase; letter-spacing: 0.08em; margin-bottom: 8px; }
.source-item { display: grid; grid-template-columns: 1fr 130px; align-items: center; gap: 10px; padding: 6px 0; border-bottom: 1px solid var(--surface2); }
.source-item:last-child { border-bottom: none; }
.source-name { font-size: 0.78rem; color: var(--text2); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.source-page { font-size: 0.7rem; color: var(--text3); }
.score-wrap { display: flex; flex-direction: column; gap: 3px; }
.score-row { display: flex; align-items: center; gap: 6px; }
.score-bar { flex: 1; height: 4px; background: var(--border); border-radius: 3px; overflow: hidden; }
.score-fill { height: 100%; border-radius: 3px; transition: width 0.5s ease; }
.fill-rrf { background: var(--green); }
.fill-rerank { background: var(--primary); }
.score-num { font-size: 0.66rem; color: var(--text3); width: 32px; text-align: right; flex-shrink: 0; }
/* Skeleton loader */
.skeleton { background: var(--surface); border-radius: 4px 18px 18px 18px; padding: 16px 18px; width: 100%; border: 1px solid var(--border); }
.skel-line { height: 12px; background: linear-gradient(90deg, var(--border) 25%, var(--surface2) 50%, var(--border) 75%); background-size: 200% 100%; animation: shimmer 1.2s infinite; border-radius: 6px; margin-bottom: 10px; }
.skel-line:last-child { margin-bottom: 0; width: 60%; }
@keyframes shimmer { 0% { background-position: 200% 0; } 100% { background-position: -200% 0; } }
/* Input bar */
.input-bar { position: fixed; bottom: 0; left: 0; right: 0; background: linear-gradient(to top, var(--bg) 80%, transparent); padding: 10px 16px 18px; }
.input-inner { max-width: 1100px; margin: 0 auto; padding-left: 276px; }
.input-row { display: flex; gap: 8px; background: var(--surface); border-radius: 14px; padding: 8px 8px 8px 16px; box-shadow: 0 4px 20px var(--shadow); border: 1px solid var(--border); }
input[type=text] { flex: 1; border: none; outline: none; font-size: 0.95rem; background: transparent; color: var(--text); }
input[type=text]::placeholder { color: var(--text3); }
.send-btn { padding: 9px 20px; background: var(--primary); color: white; border: none; border-radius: 10px; cursor: pointer; font-size: 0.9rem; font-weight: 600; transition: background 0.2s, transform 0.1s; white-space: nowrap; }
.send-btn:hover { background: var(--primary-dark); }
.send-btn:active { transform: scale(0.97); }
.send-btn:disabled { background: var(--border); color: var(--text3); cursor: not-allowed; transform: none; }
/* Empty state */
.empty-state { text-align: center; padding: 48px 0 24px; }
.empty-state .icon { font-size: 2.8rem; margin-bottom: 12px; }
.empty-state h2 { font-size: 1rem; color: var(--text2); font-weight: 500; }
.empty-state p { font-size: 0.8rem; margin-top: 6px; color: var(--text3); }
.error-text { color: var(--red); font-size: 0.88rem; }
.cursor { animation: blink 0.8s step-end infinite; color: var(--primary); }
@keyframes blink { 50% { opacity: 0; } }
.uploaded-badge { display: inline-block; background: var(--green-bg); color: var(--green-text); border-radius: 4px; padding: 1px 5px; font-size: 0.68rem; font-weight: 600; margin-left: 4px; }
.ctx-badge { display: inline-block; background: #fef3c7; color: #92400e; border-radius: 4px; padding: 1px 5px; font-size: 0.65rem; font-weight: 600; margin-left: 4px; }
[data-theme="dark"] .ctx-badge { background: #3a2a0a; color: #fbbf24; }
/* Tabs */
.tabs { display: flex; gap: 4px; margin-bottom: 16px; border-bottom: 2px solid var(--border); }
.tab-btn { padding: 8px 16px; font-size: 0.85rem; font-weight: 600; border: none; background: none; color: var(--text2); cursor: pointer; border-bottom: 2px solid transparent; margin-bottom: -2px; transition: all 0.15s; }
.tab-btn.active { color: var(--primary); border-bottom-color: var(--primary); }
.tab-btn:hover:not(.active) { color: var(--text); }
.tab-panel { display: none; }
.tab-panel.active { display: block; }
/* Compare panel */
.compare-cols { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; margin-top: 16px; }
.compare-col h4 { font-size: 0.8rem; font-weight: 700; padding: 8px 12px; border-radius: 8px 8px 0 0; margin-bottom: 0; }
.col-text h4 { background: var(--primary-light); color: var(--primary); }
.col-colpali h4 { background: #fef3c7; color: #92400e; }
[data-theme="dark"] .col-colpali h4 { background: #3a2a0a; color: #fbbf24; }
.compare-result { background: var(--surface); border: 1px solid var(--border); border-radius: 0 0 8px 8px; padding: 10px; min-height: 80px; }
.compare-hit { padding: 6px 0; border-bottom: 1px solid var(--border); font-size: 0.78rem; }
.compare-hit:last-child { border-bottom: none; }
.compare-hit .hit-source { color: var(--text); font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.compare-hit .hit-meta { color: var(--text3); font-size: 0.7rem; margin-top: 2px; }
.compare-empty { color: var(--text3); font-size: 0.76rem; text-align: center; padding: 16px 0; }
.compare-loading { color: var(--text3); font-size: 0.76rem; text-align: center; padding: 16px 0; }
@media (max-width: 680px) {
.compare-cols { grid-template-columns: 1fr; }
}
/* Mobile */
@media (max-width: 680px) {
.sidebar { display: none; }
.layout { padding: 12px 12px 0; }
.input-inner { padding-left: 0; }
.bubble-q { max-width: 85%; }
header { padding: 12px 16px; }
.header-left h1 { font-size: 1.05rem; }
.header-left p { display: none; }
}
</style>
</head>
<body>
<header>
<div class="header-left">
<h1>🇻🇳 Vietnamese RAG System</h1>
<p>Hybrid Search · Reranking · Citation · Groq + Gemini</p>
</div>
<button class="theme-btn" onclick="toggleTheme()" id="theme-btn">🌙 Dark</button>
</header>
<div class="layout">
<div class="sidebar">
<div class="card">
<h3>📤 Upload tài liệu</h3>
<div class="drop-zone" id="drop-zone" onclick="document.getElementById('file-input').click()">
<div class="drop-icon">📄</div>
<p>Kéo thả PDF vào đây</p>
<span>hoặc bấm để chọn file</span>
</div>
<input type="file" id="file-input" accept=".pdf" onchange="handleFileSelect(this)">
<button class="upload-btn" id="upload-btn" onclick="uploadFile()" disabled>Upload</button>
<div class="upload-status" id="upload-status"></div>
</div>
<div class="card">
<h3>📚 Tài liệu đã upload</h3>
<div id="doc-list-content">
<div class="doc-list-empty">Chưa có tài liệu nào</div>
</div>
</div>
<div class="card">
<h3>💡 Câu hỏi mẫu</h3>
<button class="sample-q" onclick="askSample(this)">FPT hoạt động trong những lĩnh vực kinh doanh nào?</button>
<button class="sample-q" onclick="askSample(this)">Doanh thu của FPT trong quý 2 năm 2016 là bao nhiêu?</button>
<button class="sample-q" onclick="askSample(this)">Lợi nhuận sau thuế quý 1 năm 2026 của công ty là bao nhiêu?</button>
<button class="sample-q" onclick="askSample(this)">Tổng tài sản của TIG tại cuối quý 1 năm 2026?</button>
</div>
</div>
<div class="chat-area">
<div class="tabs">
<button class="tab-btn active" onclick="switchTab('chat')">💬 Chat</button>
<button class="tab-btn" onclick="switchTab('compare')">⚖️ So sánh Pipeline</button>
</div>
<div id="tab-chat" class="tab-panel active">
<div id="history">
<div class="empty-state">
<div class="icon">💬</div>
<h2>Hãy đặt câu hỏi về tài liệu</h2>
<p>Hóa đơn bán lẻ · Báo cáo tài chính · hoặc upload PDF của bạn</p>
</div>
</div>
</div>
<div id="tab-compare" class="tab-panel">
<div class="card">
<h3>⚖️ So sánh Text RAG vs ColPali Visual Retrieval</h3>
<p style="font-size:0.78rem;color:var(--text2);margin-bottom:12px">Nhập câu hỏi và xem 2 pipeline truy xuất tài liệu như thế nào khác nhau.</p>
<div style="display:flex;gap:8px">
<input type="text" id="cmp-q" placeholder="Nhập câu hỏi để so sánh..." style="flex:1;border:1px solid var(--border);border-radius:8px;padding:8px 12px;font-size:0.88rem;background:var(--surface2);color:var(--text);outline:none" onkeydown="if(event.key==='Enter')runCompare()">
<button onclick="runCompare()" style="padding:8px 16px;background:var(--primary);color:white;border:none;border-radius:8px;cursor:pointer;font-weight:600;font-size:0.85rem">So sánh</button>
</div>
<div id="compare-result">
<div class="compare-cols">
<div class="compare-col col-text">
<h4>📝 Text RAG (BM25 + Vector + Rerank)</h4>
<div class="compare-result"><div class="compare-empty">Nhập câu hỏi để xem kết quả</div></div>
</div>
<div class="compare-col col-colpali">
<h4>🖼️ ColPali Visual Retrieval (MaxSim)</h4>
<div class="compare-result"><div class="compare-empty">Nhập câu hỏi để xem kết quả</div></div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="input-bar">
<div class="input-inner">
<div class="input-row">
<input type="text" id="q" placeholder="Nhập câu hỏi..." onkeydown="if(event.key==='Enter')ask()">
<button class="send-btn" id="btn" onclick="ask()">Hỏi →</button>
</div>
</div>
</div>
<script>
// Tabs
function switchTab(name) {
document.querySelectorAll('.tab-btn').forEach((b, i) => b.classList.toggle('active', ['chat','compare'][i] === name));
document.getElementById('tab-chat').classList.toggle('active', name === 'chat');
document.getElementById('tab-compare').classList.toggle('active', name === 'compare');
}
// Compare
async function runCompare() {
const q = document.getElementById('cmp-q').value.trim();
if (!q) return;
const cols = document.querySelectorAll('#compare-result .compare-result');
cols.forEach(c => c.innerHTML = '<div class="compare-loading">Đang tìm kiếm...</div>');
try {
const res = await fetch('/compare', { method: 'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify({question: q, top_k: 5}) });
const data = await res.json();
// Text RAG
cols[0].innerHTML = data.text_rag.length ? data.text_rag.map((s,i) =>
`<div class="compare-hit">
<div class="hit-source">[${i+1}] 📄 ${s.source}${s.has_context ? '<span class="ctx-badge">✦ context</span>' : ''}</div>
<div class="hit-meta">Trang ${s.page} · RRF ${s.rrf_score} · Rerank ${s.rerank_score}</div>
</div>`).join('') : '<div class="compare-empty">Không có kết quả</div>';
// ColPali
cols[1].innerHTML = data.colpali.length ? data.colpali.map((s,i) =>
`<div class="compare-hit">
<div class="hit-source">[${i+1}] 🖼️ ${s.source || 'N/A'}</div>
<div class="hit-meta">Trang ${s.page || '?'} · Score ${s.score || 'N/A'}</div>
</div>`).join('') : '<div class="compare-empty">Không có kết quả (ColPali chạy local)</div>';
} catch(e) {
cols.forEach(c => c.innerHTML = `<div class="compare-empty">Lỗi: ${e.message}</div>`);
}
}
// Theme
function toggleTheme() {
const html = document.documentElement;
const btn = document.getElementById('theme-btn');
if (html.dataset.theme === 'dark') {
html.dataset.theme = 'light';
btn.textContent = '🌙 Dark';
localStorage.setItem('theme', 'light');
} else {
html.dataset.theme = 'dark';
btn.textContent = '☀️ Light';
localStorage.setItem('theme', 'dark');
}
}
if (localStorage.getItem('theme') === 'dark') {
document.documentElement.dataset.theme = 'dark';
document.getElementById('theme-btn').textContent = '☀️ Light';
}
// Sample questions
function askSample(btn) {
document.getElementById('q').value = btn.textContent;
ask();
}
// Upload
let selectedFile = null;
const dropZone = document.getElementById('drop-zone');
dropZone.addEventListener('dragover', e => { e.preventDefault(); dropZone.classList.add('drag-over'); });
dropZone.addEventListener('dragleave', () => dropZone.classList.remove('drag-over'));
dropZone.addEventListener('drop', e => {
e.preventDefault(); dropZone.classList.remove('drag-over');
const file = e.dataTransfer.files[0];
if (file && file.name.endsWith('.pdf')) setFile(file);
});
function handleFileSelect(input) { if (input.files[0]) setFile(input.files[0]); }
function setFile(file) {
selectedFile = file;
document.getElementById('upload-btn').disabled = false;
setStatus(`Đã chọn: ${file.name}`, '');
}
function setStatus(msg, type) {
const el = document.getElementById('upload-status');
el.textContent = msg; el.className = 'upload-status ' + type;
}
async function uploadFile() {
if (!selectedFile) return;
document.getElementById('upload-btn').disabled = true;
setStatus('Đang xử lý...', 'loading');
const formData = new FormData();
formData.append('file', selectedFile);
try {
const res = await fetch('/upload', { method: 'POST', body: formData });
if (!res.ok) { const err = await res.json(); setStatus('Lỗi: ' + err.detail, 'err'); document.getElementById('upload-btn').disabled = false; return; }
const data = await res.json();
setStatus(`✓ ${data.name} (${data.pages} trang, ${data.chunks} chunks)`, 'ok');
selectedFile = null; document.getElementById('file-input').value = '';
refreshDocList();
} catch (e) { setStatus('Lỗi kết nối', 'err'); document.getElementById('upload-btn').disabled = false; }
}
async function refreshDocList() {
const res = await fetch('/documents');
const docs = await res.json();
const el = document.getElementById('doc-list-content');
if (!docs.length) { el.innerHTML = '<div class="doc-list-empty">Chưa có tài liệu nào</div>'; return; }
el.innerHTML = docs.map(d => `
<div class="doc-item">
<div>
<div class="doc-name" title="${d.name}">📄 ${d.name}</div>
<div class="doc-meta">${d.pages} trang · ${d.chunks} chunks</div>
</div>
</div>`).join('');
}
function highlightCitations(text) {
return text.replace(/\[(\d+)\]/g, '<span class="cite">[$1]</span>');
}
function renderSources(sources) {
if (!sources || !sources.length) return '';
const items = sources.map(s => {
const rrfPct = Math.min(s.rrf_score * 3000, 100).toFixed(0);
const rerankPct = Math.min(s.rerank_score * 150, 100).toFixed(0);
const badge = s.uploaded ? '<span class="uploaded-badge">uploaded</span>' : '';
const ctxBadge = s.has_context ? '<span class="ctx-badge">✦ context</span>' : '';
return `<div class="source-item">
<div>
<div class="source-name">📄 ${s.source}${badge}${ctxBadge}</div>
<div class="source-page">Trang ${s.page}</div>
</div>
<div class="score-wrap">
<div class="score-row"><div class="score-bar"><div class="score-fill fill-rrf" style="width:${rrfPct}%"></div></div><div class="score-num">${s.rrf_score}</div></div>
<div class="score-row"><div class="score-bar"><div class="score-fill fill-rerank" style="width:${rerankPct}%"></div></div><div class="score-num">${s.rerank_score}</div></div>
</div>
</div>`;
}).join('');
return `<div class="sources-section"><div class="sources-title">Nguồn tham khảo</div>${items}</div>`;
}
function addCopyBtn(bubble, getText) {
const btn = document.createElement('button');
btn.className = 'copy-btn'; btn.textContent = 'Copy';
btn.onclick = () => {
navigator.clipboard.writeText(getText());
btn.textContent = '✓ Copied'; btn.classList.add('copied');
setTimeout(() => { btn.textContent = 'Copy'; btn.classList.remove('copied'); }, 2000);
};
bubble.appendChild(btn);
return btn;
}
async function ask() {
const input = document.getElementById('q');
const q = input.value.trim();
if (!q) return;
const btn = document.getElementById('btn');
const history = document.getElementById('history');
const empty = history.querySelector('.empty-state');
if (empty) empty.remove();
const item = document.createElement('div');
item.className = 'chat-item';
item.innerHTML = `
<div class="bubble-q">${q}</div>
<div class="bubble-a">
<div class="skeleton">
<div class="skel-line"></div>
<div class="skel-line"></div>
<div class="skel-line"></div>
</div>
</div>`;
history.appendChild(item);
item.scrollIntoView({ behavior: 'smooth', block: 'end' });
input.value = ''; btn.disabled = true;
try {
const res = await fetch('/stream', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ question: q, top_k: 5 })
});
const bubble = item.querySelector('.bubble-a');
let fullText = '', sources = [];
let copyBtn = null;
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop();
for (const line of lines) {
if (!line.startsWith('data: ')) continue;
let msg;
try { msg = JSON.parse(line.slice(6)); } catch { continue; }
if (msg.type === 'sources') {
sources = msg.sources;
bubble.innerHTML = '<div class="answer-text"></div>';
copyBtn = addCopyBtn(bubble, () => fullText);
} else if (msg.type === 'token') {
fullText += msg.text;
bubble.querySelector('.answer-text').innerHTML = highlightCitations(fullText) + '<span class="cursor">▋</span>';
item.scrollIntoView({ behavior: 'smooth', block: 'end' });
} else if (msg.type === 'done') {
bubble.querySelector('.answer-text').innerHTML = highlightCitations(fullText);
bubble.querySelector('.cursor')?.remove();
bubble.insertAdjacentHTML('beforeend', renderSources(sources));
}
}
}
} catch (e) {
item.querySelector('.bubble-a').innerHTML = `<span class="error-text">Lỗi: ${e.message}</span>`;
}
btn.disabled = false; input.focus();
item.scrollIntoView({ behavior: 'smooth', block: 'end' });
}
refreshDocList();
</script>
</body>
</html>