| <!DOCTYPE html> |
| <html lang="vi"> |
| <head> |
| <meta charset="UTF-8"> |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> |
| <title>HUST RAG — Trợ lý Học vụ</title> |
| <meta name="description" content="Hệ thống hỏi đáp quy chế sinh viên Đại học Bách khoa Hà Nội"> |
| <link rel="icon" href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 100 100%22><text y=%22.9em%22 font-size=%2290%22>🎓</text></svg>"> |
| <link rel="preconnect" href="https://fonts.googleapis.com"> |
| <link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet"> |
| <link rel="stylesheet" href="/static/style.css"> |
| <script> |
| window.MathJax = { |
| tex: { |
| inlineMath: [['$', '$'], ['\\(', '\\)']], |
| displayMath: [['$$', '$$'], ['\\[', '\\]']] |
| }, |
| startup: { typeset: false } |
| }; |
| </script> |
| <script src="https://cdn.jsdelivr.net/npm/mathjax@3/es5/tex-mml-chtml.js" async></script> |
| </head> |
| <body> |
| <div id="app"> |
| |
| <header> |
| <div class="logo">BK</div> |
| <div class="header-text"> |
| <h1>HUST RAG Assistant</h1> |
| <p>Trợ lý học vụ Đại học Bách khoa Hà Nội</p> |
| </div> |
| <div class="status-dot" title="Online"></div> |
| </header> |
|
|
| |
| <div id="welcome"> |
| <div class="icon">🎓</div> |
| <h2>Xin chào!</h2> |
| <p>Tôi là trợ lý học vụ HUST. Hãy hỏi tôi bất kỳ câu hỏi nào về quy chế, quy định sinh viên.</p> |
| <div class="suggestions"> |
| <button onclick="askSuggestion(this)">Điều kiện tốt nghiệp đại học?</button> |
| <button onclick="askSuggestion(this)">Cách tính điểm học kỳ?</button> |
| <button onclick="askSuggestion(this)">Điều kiện đổi ngành?</button> |
| <button onclick="askSuggestion(this)">Đăng ký hoãn thi thế nào?</button> |
| </div> |
| </div> |
|
|
| |
| <div id="messages" style="display: none;"></div> |
|
|
| |
| <div id="input-area"> |
| <div class="input-row"> |
| <textarea id="input" rows="1" placeholder="Nhập câu hỏi của bạn..." |
| onkeydown="handleKey(event)" oninput="autoResize(this)"></textarea> |
| <button id="send-btn" onclick="sendMessage()" title="Gửi"> |
| <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"> |
| <line x1="22" y1="2" x2="11" y2="13"></line> |
| <polygon points="22 2 15 22 11 13 2 9 22 2"></polygon> |
| </svg> |
| </button> |
| </div> |
| <div class="hint">Enter để gửi · Shift+Enter để xuống dòng</div> |
| </div> |
| </div> |
|
|
| <script> |
| |
| |
| |
| const API_BASE = ""; |
| |
| const messagesEl = document.getElementById('messages'); |
| const welcomeEl = document.getElementById('welcome'); |
| const inputEl = document.getElementById('input'); |
| const sendBtn = document.getElementById('send-btn'); |
| let isStreaming = false; |
| let _apiKey = ""; |
| |
| |
| (async function loadConfig() { |
| try { |
| const res = await fetch(`${API_BASE}/api/config`); |
| const data = await res.json(); |
| _apiKey = data.api_key || ""; |
| } catch (e) { |
| console.warn("Could not load API config:", e); |
| } |
| })(); |
| |
| function autoResize(el) { |
| el.style.height = 'auto'; |
| el.style.height = Math.min(el.scrollHeight, 120) + 'px'; |
| } |
| |
| function handleKey(e) { |
| if (e.key === 'Enter' && !e.shiftKey) { |
| e.preventDefault(); |
| sendMessage(); |
| } |
| } |
| |
| function askSuggestion(btn) { |
| inputEl.value = btn.textContent; |
| sendMessage(); |
| } |
| |
| function renderMarkdown(text) { |
| |
| text = text.replace(/<think>[\s\S]*?<\/think>\n?/g, ''); |
| if (text.includes('<think>')) { |
| |
| text = text.replace(/<think>[\s\S]*/g, '<span style="color: #666; font-style: italic;">[Đang suy nghĩ...]</span>'); |
| } |
| |
| |
| let mathBlocks = []; |
| |
| |
| text = text.replace(/\$\$([\s\S]*?)\$\$/g, function(match) { |
| mathBlocks.push(match); |
| return `__MATH_BLOCK_${mathBlocks.length - 1}__`; |
| }); |
| text = text.replace(/\\\[([\s\S]*?)\\\]/g, function(match) { |
| mathBlocks.push(match); |
| return `__MATH_BLOCK_${mathBlocks.length - 1}__`; |
| }); |
| |
| |
| text = text.replace(/\\\(([\s\S]*?)\\\)/g, function(match) { |
| mathBlocks.push(match); |
| return `__MATH_BLOCK_${mathBlocks.length - 1}__`; |
| }); |
| text = text.replace(/\$([^\$\n]+?)\$/g, function(match) { |
| mathBlocks.push(match); |
| return `__MATH_BLOCK_${mathBlocks.length - 1}__`; |
| }); |
| |
| |
| let html = text |
| .replace(/```(\w*)\n([\s\S]*?)```/g, '<pre><code>$2</code></pre>') |
| .replace(/`([^`]+)`/g, '<code>$1</code>') |
| .replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>') |
| .replace(/\*(.+?)\*/g, '<em>$1</em>') |
| .replace(/^### (.+)$/gm, '<h3>$1</h3>') |
| .replace(/^## (.+)$/gm, '<h2>$1</h2>') |
| .replace(/^# (.+)$/gm, '<h1>$1</h1>') |
| .replace(/^[-*] (.+)$/gm, '<li>$1</li>') |
| .replace(/^\d+\. (.+)$/gm, '<li>$1</li>') |
| .replace(/\n{2,}/g, '</p><p>') |
| .replace(/\n/g, '<br>'); |
| |
| html = html.replace(/((?:<li>.*?<\/li>\s*)+)/gs, '<ul>$1</ul>'); |
| |
| |
| for (let i = 0; i < mathBlocks.length; i++) { |
| html = html.replace(`__MATH_BLOCK_${i}__`, mathBlocks[i]); |
| } |
| |
| return `<p>${html}</p>`.replace(/<p><\/p>/g, ''); |
| } |
| |
| async function sendMessage() { |
| const text = inputEl.value.trim(); |
| if (!text || isStreaming) return; |
| |
| welcomeEl.style.display = 'none'; |
| messagesEl.style.display = 'flex'; |
| |
| const userMsg = document.createElement('div'); |
| userMsg.className = 'msg user'; |
| userMsg.textContent = text; |
| messagesEl.appendChild(userMsg); |
| |
| inputEl.value = ''; |
| inputEl.style.height = 'auto'; |
| scrollToBottom(); |
| |
| const botMsg = document.createElement('div'); |
| botMsg.className = 'msg bot'; |
| botMsg.innerHTML = ` |
| <div class="label">Trợ lý HUST</div> |
| <div class="content"> |
| <div class="typing-indicator"> |
| <span></span><span></span><span></span> |
| </div> |
| </div>`; |
| messagesEl.appendChild(botMsg); |
| scrollToBottom(); |
| |
| const contentEl = botMsg.querySelector('.content'); |
| isStreaming = true; |
| sendBtn.disabled = true; |
| |
| try { |
| const headers = { 'Content-Type': 'application/json' }; |
| if (_apiKey) headers['X-API-Key'] = _apiKey; |
| |
| const res = await fetch(`${API_BASE}/api/chat`, { |
| method: 'POST', |
| headers: headers, |
| body: JSON.stringify({ message: text }), |
| }); |
| |
| if (res.headers.get('content-type')?.includes('application/json')) { |
| const data = await res.json(); |
| contentEl.innerHTML = renderMarkdown(data.answer || data.error || 'Không có phản hồi.'); |
| scrollToBottom(); |
| return; |
| } |
| |
| const reader = res.body.getReader(); |
| const decoder = new TextDecoder(); |
| let fullText = ''; |
| |
| while (true) { |
| const { done, value } = await reader.read(); |
| if (done) break; |
| |
| const chunk = decoder.decode(value, { stream: true }); |
| const lines = chunk.split('\n'); |
| |
| for (const line of lines) { |
| if (line.startsWith('data: ')) { |
| const payload = line.slice(6).trim(); |
| if (payload === '[DONE]') continue; |
| try { |
| const parsed = JSON.parse(payload); |
| if (parsed.token) { |
| fullText += parsed.token; |
| contentEl.innerHTML = renderMarkdown(fullText); |
| scrollToBottom(); |
| } |
| } catch {} |
| } |
| } |
| } |
| |
| if (fullText) { |
| contentEl.innerHTML = renderMarkdown(fullText); |
| |
| if (window.MathJax && window.MathJax.typesetPromise) { |
| try { |
| await window.MathJax.typesetPromise([contentEl]); |
| } catch(err) {} |
| } |
| } |
| } catch (err) { |
| contentEl.innerHTML = '<span style="color: var(--red-accent)">Lỗi kết nối. Vui lòng thử lại.</span>'; |
| } finally { |
| isStreaming = false; |
| sendBtn.disabled = false; |
| scrollToBottom(); |
| inputEl.focus(); |
| } |
| } |
| |
| function scrollToBottom() { |
| requestAnimationFrame(() => { |
| messagesEl.scrollTop = messagesEl.scrollHeight; |
| }); |
| } |
| |
| inputEl.focus(); |
| </script> |
| </body> |
| </html> |
|
|