CHUỖI THỰC HÀNH: CHATBOT AI HỎI ĐÁP ĐẤT ĐAI — PHP 8.x + MySQL 9.x Lưu ý trách nhiệm: chatbot chỉ tổng hợp thông tin tham khảo từ nguồn do quản trị viên phê duyệt. Không dùng để kết luận quyền sử dụng đất, tranh chấp, quy hoạch hoặc nghĩa vụ tài chính cụ thể. Không nhập CCCD, số sổ, ảnh giấy tờ, địa chỉ/tọa độ chính xác hay dữ liệu cá nhân vào chat. # 09/10 — Giao diện chatbot: hiển thị an toàn, rõ nguồn Giao diện không được tin nội dung AI là HTML. Nếu dùng `innerHTML` với câu trả lời mô hình sinh ra, một lỗi ở server hoặc nguồn dữ liệu có thể thành XSS. Vì vậy JavaScript dưới đây luôn dùng `textContent`, còn link nguồn được tạo bằng DOM với URL đã do backend trả về từ database. ## 1. Tạo `views/chat.php` ```php <?php declare(strict_types=1); $base = rtrim((string)Config::get('APP_BASE_PATH', ''), '/'); $endpoint = ($base === '' ? '' : $base) . '/api/chat'; ?> <!doctype html> <html lang="vi"> <head> <meta charset="utf-8"> <meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="csrf-token" content="<?= htmlspecialchars($csrfToken, ENT_QUOTES, 'UTF-8') ?>"> <title>Trợ lý thông tin đất đai</title> <link rel="stylesheet" href="<?= htmlspecialchars(($base === '' ? '' : $base) . '/assets/chat.css', ENT_QUOTES, 'UTF-8') ?>"> </head> <body> <main class="shell"> <header> <p class="eyebrow">PHP 8.x + MySQL 9.x · RAG có nguồn</p> <h1>Trợ lý thông tin đất đai</h1> <p class="notice">Chỉ mang tính tham khảo từ nguồn đã hiển thị. Không gửi CCCD, số sổ, ảnh hồ sơ, địa chỉ hoặc tọa độ cụ thể. Với việc có ảnh hưởng quyền/lợi ích, hãy kiểm tra nguồn gốc và liên hệ cơ quan có thẩm quyền.</p> </header> <section id="messages" class="messages" aria-live="polite" aria-relevant="additions"> <article class="message assistant">Xin chào. Bạn có thể hỏi ở mức tổng quát, ví dụ: “Tôi cần kiểm tra nguồn nào trước khi tìm hiểu một thủ tục đất đai?”</article> </section> <form id="chat-form" class="chat-form" data-endpoint="<?= htmlspecialchars($endpoint, ENT_QUOTES, 'UTF-8') ?>"> <label for="message">Câu hỏi</label> <textarea id="message" name="message" rows="4" maxlength="1600" required placeholder="Không nhập dữ liệu cá nhân hoặc thông tin hồ sơ cụ thể."></textarea> <div class="form-row"> <span id="counter">0 / 1600</span> <button id="send" type="submit">Gửi câu hỏi</button> </div> </form> </main> <script src="<?= htmlspecialchars(($base === '' ? '' : $base) . '/assets/chat.js', ENT_QUOTES, 'UTF-8') ?>" defer></script> </body> </html> ``` ## 2. Tạo `public/assets/chat.css` ```css :root { color-scheme: light; font-family: system-ui, -apple-system, "Segoe UI", sans-serif; color: #16202a; background: #f3f7f4; } * { box-sizing: border-box; } body { margin: 0; line-height: 1.55; } .shell { width: min(820px, calc(100% - 32px)); margin: 32px auto; } h1 { margin: 0 0 8px; font-size: clamp(1.7rem, 4vw, 2.4rem); } .eyebrow { margin: 0; color: #176b45; font-weight: 700; font-size: .86rem; text-transform: uppercase; letter-spacing: .06em; } .notice { border-left: 4px solid #b45309; padding: 12px 16px; background: #fff7ed; border-radius: 6px; } .messages { min-height: 260px; margin: 24px 0; padding: 16px; border: 1px solid #d8e0dc; border-radius: 12px; background: #fff; } .message { max-width: 92%; margin: 0 0 14px; padding: 12px 14px; border-radius: 10px; white-space: pre-wrap; overflow-wrap: anywhere; } .message.user { margin-left: auto; background: #dbeafe; } .message.assistant { background: #edf7ef; } .sources { margin: -6px 0 16px; padding: 10px 14px; border-left: 3px solid #176b45; background: #f8fafc; } .sources h2 { margin: 0 0 6px; font-size: 1rem; } .sources ul { margin: 0; padding-left: 20px; } .chat-form { padding: 16px; border: 1px solid #d8e0dc; border-radius: 12px; background: #fff; } textarea { width: 100%; resize: vertical; padding: 10px; font: inherit; border: 1px solid #8da099; border-radius: 8px; } .form-row { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-top: 10px; } button { border: 0; border-radius: 8px; padding: 10px 16px; color: #fff; background: #176b45; font: inherit; font-weight: 700; cursor: pointer; } button:disabled { opacity: .6; cursor: wait; } .error { color: #9b1c1c; background: #fef2f2; } ``` ## 3. Tạo `public/assets/chat.js` ```js (() => { 'use strict'; const form = document.querySelector('#chat-form'); const input = document.querySelector('#message'); const messages = document.querySelector('#messages'); const button = document.querySelector('#send'); const counter = document.querySelector('#counter'); const csrf = document.querySelector('meta[name="csrf-token"]')?.content || ''; const key = 'land_ai_chat_session_id'; let sessionId = sessionStorage.getItem(key) || (crypto.randomUUID ? crypto.randomUUID() : ''); if (sessionId) sessionStorage.setItem(key, sessionId); function addMessage(role, text, isError = false) { const item = document.createElement('article'); item.className = `message ${role}${isError ? ' error' : ''}`; item.textContent = text; // Không dùng innerHTML với dữ liệu AI hay dữ liệu người dùng. messages.appendChild(item); item.scrollIntoView({block: 'end', behavior: 'smooth'}); } function addSources(sources) { if (!Array.isArray(sources) || sources.length === 0) return; const box = document.createElement('section'); box.className = 'sources'; const heading = document.createElement('h2'); heading.textContent = 'Nguồn tham khảo đã dùng'; const list = document.createElement('ul'); for (const source of sources) { if (!source || typeof source.url !== 'string' || typeof source.title !== 'string') continue; const link = document.createElement('a'); link.href = source.url; link.target = '_blank'; link.rel = 'noopener noreferrer'; link.textContent = `${source.title} (kiểm tra: ${source.checked_at || 'không rõ'})`; const li = document.createElement('li'); li.appendChild(link); list.appendChild(li); } if (list.childElementCount > 0) { box.append(heading, list); messages.appendChild(box); } } input.addEventListener('input', () => { counter.textContent = `${input.value.length} / 1600`; }); form.addEventListener('submit', async (event) => { event.preventDefault(); const message = input.value.trim(); if (message.length < 10) { addMessage('assistant', 'Hãy nhập câu hỏi từ 10 ký tự trở lên.', true); return; } addMessage('user', message); input.value = ''; counter.textContent = '0 / 1600'; button.disabled = true; try { const response = await fetch(form.dataset.endpoint, { method: 'POST', credentials: 'same-origin', headers: {'Content-Type': 'application/json', 'X-CSRF-Token': csrf}, body: JSON.stringify({message, session_id: sessionId || null}) }); const data = await response.json(); if (!response.ok || !data.ok) throw new Error(data.error || 'Yêu cầu không thành công.'); if (data.session_id) { sessionId = data.session_id; sessionStorage.setItem(key, sessionId); } addMessage('assistant', data.answer || 'Không có nội dung trả lời.'); addSources(data.sources); } catch (error) { addMessage('assistant', error instanceof Error ? error.message : 'Không thể kết nối. Vui lòng thử lại.', true); } finally { button.disabled = false; input.focus(); } }); })(); ``` Mở `http://localhost:8080/`, thử với fake mode. Khi người dùng gửi HTML như `<img ...>`, nó phải hiện như chữ chứ không chạy. Nếu không thấy nguồn, hãy kiểm tra dữ liệu đã `published` ở bài 05 — không bỏ qua bước này để ép chatbot trả lời.