Spaces:
Sleeping
Sleeping
Download static/index.html from thaidinhz1/rag-vietnamese: direct link, hf CLI and curl.
- Browser
- Download file 25.7 kB
-
https://huggingface.co/spaces/thaidinhz1/rag-vietnamese/resolve/9e7ded3dd31be325a258cd2b19fbc3441d5fe4f5/static/index.html
- Command line
-
hf download hf://spaces/thaidinhz1/rag-vietnamese@9e7ded3dd31be325a258cd2b19fbc3441d5fe4f5/static/index.html
-
curl -L -o index.html https://huggingface.co/spaces/thaidinhz1/rag-vietnamese/resolve/9e7ded3dd31be325a258cd2b19fbc3441d5fe4f5/static/index.html
25.7 kB
| <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> | |