// ============================================================ // Hikari Chatbot — app.js // Khung cơ bản và cấu hình (Task 1.3) // ============================================================ // === Trạng thái toàn cục === let bot = null; // Instance RiveScript hiện tại let currentLang = 'vi'; // Ngôn ngữ hiện tại const USERNAME = 'local-user'; // Username cố định cho RiveScript var _adapterPath = []; // Tracking adapter chain cho mỗi lượt phản hồi var _attachedImage = null; // Ảnh đính kèm hiện tại: { dataURL: string, name: string, file: File } | null // === Chat History === var _chatHistory = []; // Mảng {role: 'user'|'assistant', content: string} var _chatHistoryEnabled = false; // Mặc định không lưu history var _chatHistoryMaxTurns = 5; // Giới hạn số turn gửi cho LLM (0 = không giới hạn) var _skipHistoryOnce = false; // Flag skip lưu history cho message tiếp theo // === Hằng số cấu hình === const FALLBACK_API_URL = 'https://your-api-server.com/chat'; // URL có thể cấu hình const FALLBACK_API_TIMEOUT = 5000; // 5 giây const LLM_MODEL_ID_CONFIG = 'onnx-community/Qwen3.5-0.8B-ONNX-OPT'; // Model ID cho LLM Adapter (WebGPU) // ============================================================ // Hàm tiện ích // ============================================================ /** * Bảng ánh xạ ký tự tiếng Việt có dấu → không dấu. * Dùng để normalize input trước khi gửi cho RiveScript (triggers viết không dấu). */ var VIETNAMESE_DIACRITICS_MAP = { 'à': 'a', 'á': 'a', 'ả': 'a', 'ã': 'a', 'ạ': 'a', 'ă': 'a', 'ằ': 'a', 'ắ': 'a', 'ẳ': 'a', 'ẵ': 'a', 'ặ': 'a', 'â': 'a', 'ầ': 'a', 'ấ': 'a', 'ẩ': 'a', 'ẫ': 'a', 'ậ': 'a', 'đ': 'd', 'è': 'e', 'é': 'e', 'ẻ': 'e', 'ẽ': 'e', 'ẹ': 'e', 'ê': 'e', 'ề': 'e', 'ế': 'e', 'ể': 'e', 'ễ': 'e', 'ệ': 'e', 'ì': 'i', 'í': 'i', 'ỉ': 'i', 'ĩ': 'i', 'ị': 'i', 'ò': 'o', 'ó': 'o', 'ỏ': 'o', 'õ': 'o', 'ọ': 'o', 'ô': 'o', 'ồ': 'o', 'ố': 'o', 'ổ': 'o', 'ỗ': 'o', 'ộ': 'o', 'ơ': 'o', 'ờ': 'o', 'ớ': 'o', 'ở': 'o', 'ỡ': 'o', 'ợ': 'o', 'ù': 'u', 'ú': 'u', 'ủ': 'u', 'ũ': 'u', 'ụ': 'u', 'ư': 'u', 'ừ': 'u', 'ứ': 'u', 'ử': 'u', 'ữ': 'u', 'ự': 'u', 'ỳ': 'y', 'ý': 'y', 'ỷ': 'y', 'ỹ': 'y', 'ỵ': 'y' }; /** * Loại bỏ dấu tiếng Việt khỏi chuỗi. * "Xin chào bạn" → "Xin chao ban" * @param {string} str - Chuỗi đầu vào * @returns {string} Chuỗi đã bỏ dấu */ function removeVietnameseDiacritics(str) { if (typeof str !== 'string') return ''; var result = ''; for (var i = 0; i < str.length; i++) { var ch = str[i]; var lower = ch.toLowerCase(); if (VIETNAMESE_DIACRITICS_MAP[lower] !== undefined) { // Giữ nguyên case (hoa/thường) của ký tự gốc var mapped = VIETNAMESE_DIACRITICS_MAP[lower]; result += (ch === lower) ? mapped : mapped.toUpperCase(); } else { result += ch; } } return result; } /** * Danh sách modal particles / filler words theo ngôn ngữ. * Các từ này thường xuất hiện cuối câu và không mang nghĩa chính, * gây nhiễu khi match trigger RiveScript. */ var MODAL_PARTICLES = { vi: ['roi', 'vay', 'nhi', 'nhe', 'a', 'nha', 'di', 'the', 'ha', 'hen', 'ne', 'luon', 'chua', 'khong', 'duoc', 'day', 'do', 'ta', 'chu'], en: ['please', 'pls', 'right', 'huh', 'eh', 'ok', 'okay', 'well', 'so', 'then', 'anyway'], ja: ['ね', 'よ', 'な', 'か', 'さ', 'ぞ', 'わ', 'の', 'けど', 'でしょ'] }; /** * Normalize input cho RiveScript: * 1. Lowercase * 2. Bỏ dấu tiếng Việt (chỉ khi ngôn ngữ = vi) * 3. Bỏ dấu câu (punctuation) * 4. Bỏ modal particles / filler words cuối câu * 5. Trim khoảng trắng thừa * * @param {string} text - Input người dùng * @returns {string} Input đã normalize */ function normalizeInput(text) { if (typeof text !== 'string') return ''; var result = text.toLowerCase(); // Bỏ dấu tiếng Việt if (currentLang === 'vi') { result = removeVietnameseDiacritics(result); } // Bỏ dấu câu: ? ! . , ; : " ' ` ~ ( ) [ ] { } \ | @ # $ % ^ & // Giữ lại + - * / cho biểu thức toán học result = result.replace(/[?!.,;:"""''`~()[\]{}\\|@#$%^&]/g, ' '); // Chuẩn hóa khoảng trắng result = result.replace(/\s+/g, ' ').trim(); // Bỏ modal particles ở cuối câu (lặp để xử lý nhiều particle liên tiếp) var particles = MODAL_PARTICLES[currentLang] || []; if (particles.length > 0) { var changed = true; while (changed) { changed = false; for (var i = 0; i < particles.length; i++) { var p = particles[i]; // Chỉ bỏ nếu particle là từ cuối cùng và câu còn ít nhất 1 từ khác var suffix = ' ' + p; if (result.length > suffix.length && result.slice(-suffix.length) === suffix) { result = result.slice(0, -suffix.length).trim(); changed = true; break; } } } } return result; } /** * Kiểm tra tin nhắn có hợp lệ (không trống, không chỉ khoảng trắng). * @param {string} text - Nội dung tin nhắn * @returns {boolean} true nếu hợp lệ */ function validateMessage(text) { return typeof text === 'string' && text.trim().length > 0; } /** * Chuyển URLs trong text thành thẻ clickable. * Escape HTML trước, rồi replace URL patterns thành links. * @param {string} text * @returns {string} HTML string */ function linkifyText(text) { // Escape HTML entities trước var escaped = String(text) .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"'); // Replace newlines with
escaped = escaped.replace(/\n/g, '
'); // Replace URLs with
tags return escaped.replace(/(https?:\/\/[^\s<]+)/g, '$1'); } /** * Cuộn message display xuống cuối. */ function scrollToBottom() { const display = document.getElementById('message-display'); if (display) { display.scrollTop = display.scrollHeight; } } // ============================================================ // Send Button State Management // ============================================================ /** * Disable nút gửi và input. * @param {string} [placeholder] - Placeholder text cho input khi disabled */ function setSendingDisabled(placeholder) { var btn = document.getElementById('send-button'); var input = document.getElementById('message-input'); if (btn) { btn.disabled = true; btn.classList.add('disabled'); } if (input && placeholder) { input.dataset.prevPlaceholder = input.placeholder; input.placeholder = placeholder; } } /** * Enable nút gửi và input. */ function setSendingEnabled() { var btn = document.getElementById('send-button'); var input = document.getElementById('message-input'); if (btn) { btn.disabled = false; btn.classList.remove('disabled'); } if (input && input.dataset.prevPlaceholder) { input.placeholder = input.dataset.prevPlaceholder; delete input.dataset.prevPlaceholder; } } // ============================================================ // LLM Loading Status — Hiển thị trạng thái loading model trên UI // ============================================================ /** Phần tử hiển thị trạng thái loading LLM hiện tại (nếu có). */ var _llmLoadingStatusEl = null; /** * Hiển thị hoặc cập nhật trạng thái loading LLM trên message display. * @param {string} message - Nội dung trạng thái */ function showLLMLoadingStatus(message) { var display = document.getElementById('message-display'); if (!display) return; if (!_llmLoadingStatusEl) { _llmLoadingStatusEl = document.createElement('div'); _llmLoadingStatusEl.className = 'message bot llm-loading-status'; display.appendChild(_llmLoadingStatusEl); } _llmLoadingStatusEl.textContent = '🤖 ' + message; scrollToBottom(); } /** * Xóa trạng thái loading LLM khỏi message display. */ function hideLLMLoadingStatus() { if (_llmLoadingStatusEl && _llmLoadingStatusEl.parentNode) { _llmLoadingStatusEl.parentNode.removeChild(_llmLoadingStatusEl); } _llmLoadingStatusEl = null; } /** * Callback xử lý thông báo trạng thái từ LLM Adapter. * Được đăng ký qua setLLMStatusCallback(). * @param {string} action - 'loading_start' | 'loading_progress' | 'loading_done' | 'loading_error' * @param {string} message - Mô tả trạng thái */ function onLLMStatusChange(action, message) { if (action === 'loading_start' || action === 'loading_progress') { showLLMLoadingStatus(message); setSendingDisabled('Đang tải mô hình AI...'); } else if (action === 'loading_done') { hideLLMLoadingStatus(); showLLMLoadingStatus('✅ ' + message); // Tự động ẩn sau 2 giây setTimeout(function () { hideLLMLoadingStatus(); }, 2000); setSendingEnabled(); } else if (action === 'loading_error') { hideLLMLoadingStatus(); showLLMLoadingStatus('❌ ' + message); setSendingEnabled(); } } // ============================================================ // LLM Cancel Button — Nút hủy generate LLM // ============================================================ /** Phần tử nút cancel hiện tại (nếu có). */ var _llmCancelEl = null; /** * Hiển thị nút Cancel dưới message display khi LLM đang generate. * @returns {HTMLElement|null} Phần tử cancel button */ function showLLMCancelButton() { var display = document.getElementById('message-display'); if (!display) return null; hideLLMCancelButton(); _llmCancelEl = document.createElement('div'); _llmCancelEl.className = 'llm-cancel-container'; var btn = document.createElement('button'); btn.className = 'llm-cancel-button'; btn.textContent = '⏹ Cancel'; btn.addEventListener('click', function () { if (typeof cancelLLMGeneration === 'function') { cancelLLMGeneration(); } hideLLMCancelButton(); }); _llmCancelEl.appendChild(btn); display.appendChild(_llmCancelEl); scrollToBottom(); return _llmCancelEl; } /** * Ẩn/xóa nút Cancel. */ function hideLLMCancelButton() { if (_llmCancelEl && _llmCancelEl.parentNode) { _llmCancelEl.parentNode.removeChild(_llmCancelEl); } _llmCancelEl = null; } // ============================================================ // LLM Streaming Message — Tạo message bot và cập nhật realtime // ============================================================ /** * Tạo một message bot trống trong message display để stream text vào. * Bao gồm thinking block (ẩn mặc định) và response block. * @returns {{thinkEl: HTMLElement, textEl: HTMLElement, messageDiv: HTMLElement}} */ function createStreamingBotMessage() { var display = document.getElementById('message-display'); if (!display) return null; var messageDiv = document.createElement('div'); messageDiv.className = 'message bot streaming'; var thinkDiv = null; var thinkContent = null; var thinkingOn = (typeof isLLMThinkingEnabled === 'function') && isLLMThinkingEnabled(); // Thinking block — chỉ tạo khi thinking được bật if (thinkingOn) { thinkDiv = document.createElement('div'); thinkDiv.className = 'llm-thinking-block hidden'; var thinkLabel = document.createElement('span'); thinkLabel.className = 'llm-thinking-label'; thinkLabel.textContent = '🧠 Thinking...'; thinkDiv.appendChild(thinkLabel); thinkContent = document.createElement('span'); thinkContent.className = 'llm-thinking-content'; thinkDiv.appendChild(thinkContent); messageDiv.appendChild(thinkDiv); } // Response block var textSpan = document.createElement('span'); textSpan.className = 'message-text'; textSpan.textContent = '...'; messageDiv.appendChild(textSpan); display.appendChild(messageDiv); scrollToBottom(); return { thinkEl: thinkContent, textEl: textSpan, messageDiv: messageDiv, thinkDiv: thinkDiv }; } /** * Parse accumulated text thành thinking part và response part. * @param {string} text * @returns {{thinking: string, response: string, thinkingDone: boolean}} */ function _parseThinkingText(text) { if (!text) return { thinking: '', response: '', thinkingDone: false }; var endIdx = text.indexOf(''); if (endIdx !== -1) { return { thinking: text.substring(0, endIdx).trim(), response: text.substring(endIdx + ''.length).replace(/^\n+/, '').trim(), thinkingDone: true }; } // Chưa có → toàn bộ là thinking return { thinking: text.trim(), response: '', thinkingDone: false }; } /** * Xóa tag ... khỏi text (dùng khi thinking bị disable). * @param {string} text * @returns {string} */ function _stripThinkTags(text) { if (!text) return ''; return text.replace(/[\s\S]*?<\/think>/g, '').replace(/^\n+/, '').trim(); } /** * Tạo callback onToken để cập nhật streaming message element realtime. * Tách thinking và response, hiển thị thinking trong block riêng. * @param {object} els - { thinkEl, textEl, thinkDiv } * @returns {function} Callback fn(accumulatedText) */ function createStreamingCallback(els) { return function (accumulatedText) { if (!els) return; // Không có thinking block → strip think tags và stream thẳng vào textEl if (!els.thinkDiv) { if (els.textEl) { els.textEl.textContent = _stripThinkTags(accumulatedText) || '...'; } scrollToBottom(); return; } // Có thinking block → parse và tách var parsed = _parseThinkingText(accumulatedText); if (parsed.thinking && els.thinkEl) { els.thinkDiv.classList.remove('hidden'); els.thinkEl.textContent = parsed.thinking; } if (els.textEl) { if (parsed.thinkingDone) { els.textEl.textContent = parsed.response || '...'; } else { els.textEl.textContent = '...'; } } scrollToBottom(); }; } /** * Hoàn tất streaming message — render final text với thinking block, thêm metadata. * @param {object} els - { thinkEl, textEl, messageDiv, thinkDiv } * @param {string} finalText - Text cuối cùng (có thể chứa ...) * @param {number} [confidence] - Confidence score * @param {string[]} [adapterPath] - Adapter path * @param {number} [responseTime] - Response time (ms) */ function finalizeStreamingMessage(els, finalText, confidence, adapterPath, responseTime) { if (!els || !els.messageDiv) return; var messageDiv = els.messageDiv; // Lưu bot response vào history if (finalText) { addChatHistory('assistant', finalText); } if (els.thinkDiv) { // Thinking mode — parse và tách var parsed = _parseThinkingText(finalText); if (parsed.thinking && els.thinkEl) { els.thinkDiv.classList.remove('hidden'); els.thinkEl.textContent = parsed.thinking; var label = els.thinkDiv.querySelector('.llm-thinking-label'); if (label) label.textContent = '🧠 Thought'; } else { els.thinkDiv.classList.add('hidden'); } if (els.textEl) { els.textEl.textContent = parsed.thinkingDone ? parsed.response : finalText.replace(/^\n+/, '').trim(); } } else { // Non-thinking mode — strip think tags if (els.textEl) { els.textEl.textContent = _stripThinkTags(finalText); } } messageDiv.classList.remove('streaming'); if (typeof confidence === 'number') { var confSpan = document.createElement('span'); confSpan.className = 'confidence ' + getConfidenceClass(confidence); confSpan.textContent = 'Confidence: ' + confidence + '%'; messageDiv.appendChild(confSpan); } if (adapterPath && adapterPath.length > 0) { var pathSpan = document.createElement('span'); pathSpan.className = 'adapter-path'; var breadcrumb = adapterPath.map(function (key) { return getAdapterDisplayName(key); }).join(' › '); pathSpan.textContent = breadcrumb; messageDiv.appendChild(pathSpan); } if (typeof responseTime === 'number') { var timeSpan = document.createElement('span'); timeSpan.className = 'response-time'; timeSpan.textContent = '⏱ ' + responseTime + 'ms'; messageDiv.appendChild(timeSpan); } scrollToBottom(); // TTS: đọc phản hồi sau khi streaming hoàn tất var finalReply = els.thinkDiv ? (els.textEl ? els.textEl.textContent : '') : (els.textEl ? els.textEl.textContent : ''); if (finalReply) onBotReplyReady(finalReply); } /** * Xóa streaming message element (khi cần thay bằng fallback message). * @param {object} els - { messageDiv } hoặc HTMLElement (backward compat) */ function removeStreamingMessage(els) { var div = els && els.messageDiv ? els.messageDiv : (els && els.parentNode ? els.parentNode : null); if (div && div.parentNode) { div.parentNode.removeChild(div); } } // ============================================================ // File Attachment — Quản lý đính kèm ảnh // ============================================================ /** * Xử lý khi người dùng chọn file ảnh. * Đọc file, tạo preview, lưu dataURL. * @param {File} file - File ảnh từ input */ function handleFileAttachment(file) { if (!file || !file.type.startsWith('image/')) return; var reader = new FileReader(); reader.onload = function (e) { _attachedImage = { dataURL: e.target.result, name: file.name, file: file }; var thumb = document.getElementById('attachment-thumb'); var nameEl = document.getElementById('attachment-name'); var preview = document.getElementById('attachment-preview'); if (thumb) thumb.src = e.target.result; if (nameEl) nameEl.textContent = file.name; if (preview) preview.classList.remove('hidden'); }; reader.readAsDataURL(file); } /** * Xóa ảnh đính kèm hiện tại. */ function clearAttachment() { _attachedImage = null; var thumb = document.getElementById('attachment-thumb'); var nameEl = document.getElementById('attachment-name'); var preview = document.getElementById('attachment-preview'); var fileInput = document.getElementById('file-input'); if (thumb) thumb.src = ''; if (nameEl) nameEl.textContent = ''; if (preview) preview.classList.add('hidden'); if (fileInput) fileInput.value = ''; } /** * Lấy ảnh đính kèm hiện tại (nếu có) và xóa sau khi lấy. * @returns {{ dataURL: string, name: string }|null} */ function consumeAttachment() { var img = _attachedImage; if (img) { clearAttachment(); } return img; } // ============================================================ // Chat History — Lưu lịch sử hội thoại cho tất cả adapter // ============================================================ /** * Lưu một message vào chat history. * @param {string} role - 'user' hoặc 'assistant' * @param {string} content - Nội dung message * @param {File} [file] - File đính kèm (tùy chọn, chỉ dùng cho role='user') */ function addChatHistory(role, content, file) { if (!content || !content.trim()) return; // Strip thinking tags khi lưu assistant response var clean = content; if (role === 'assistant') { var thinkEnd = clean.indexOf(''); if (thinkEnd !== -1) { clean = clean.substring(thinkEnd + ''.length); } clean = clean.replace(/[\s\S]*?<\/think>/g, '').replace(/^\n+/, '').trim(); } if (!clean.trim()) return; _chatHistory.push({ role: role, content: clean.trim() }); // Lưu vào IndexedDB (async, không block), kèm file nếu có if (typeof saveChatMessage === 'function') { saveChatMessage(role, clean.trim(), currentLang, file || null).catch(function (err) { console.error('Lỗi lưu history vào IndexedDB:', err); }); } } /** * Lấy history đã trim theo maxTurns để gửi cho LLM. * Chỉ trả history khi _chatHistoryEnabled = true. * @returns {Array<{role: string, content: string}>} */ function getChatHistoryForLLM() { if (!_chatHistoryEnabled || _chatHistory.length === 0) return []; if (_chatHistoryMaxTurns > 0 && _chatHistory.length > _chatHistoryMaxTurns * 2) { return _chatHistory.slice(-_chatHistoryMaxTurns * 2); } return _chatHistory.slice(); } /** * Xóa toàn bộ chat history. */ function clearChatHistory() { _chatHistory = []; } /** * Bật/tắt lưu history. * @param {boolean} enabled */ function setChatHistoryEnabled(enabled) { _chatHistoryEnabled = !!enabled; if (!_chatHistoryEnabled) { _chatHistory = []; } try { localStorage.setItem('hikari_chat_history_enabled', _chatHistoryEnabled ? '1' : '0'); } catch (e) {} } /** * @returns {boolean} */ function isChatHistoryEnabled() { return _chatHistoryEnabled; } /** * Đặt giới hạn số turn gửi cho LLM. 0 = không giới hạn. * @param {number} maxTurns */ function setChatHistoryMaxTurns(maxTurns) { _chatHistoryMaxTurns = Math.max(0, parseInt(maxTurns, 10) || 0); try { localStorage.setItem('hikari_chat_history_max_turns', String(_chatHistoryMaxTurns)); } catch (e) {} } /** * @returns {number} */ function getChatHistoryMaxTurns() { return _chatHistoryMaxTurns; } /** * Thêm tin nhắn vào DOM với class phân biệt user/bot, kèm confidence, adapter path và thời gian xử lý nếu là bot. * @param {string} text - Nội dung tin nhắn * @param {"user"|"bot"} sender - Người gửi * @param {number} [confidence] - Tỉ lệ khớp (chỉ dùng cho bot) * @param {string[]} [adapterPath] - Danh sách adapter đã xử lý (chỉ dùng cho bot) * @param {number} [responseTime] - Thời gian xử lý (ms, chỉ dùng cho bot) * @param {string} [imageDataURL] - Data URL ảnh đính kèm (chỉ dùng cho user) * @param {boolean} [skipHistory] - Nếu true, không lưu vào chat history (dùng khi load từ history) */ function appendMessage(text, sender, confidence, adapterPath, responseTime, imageDataURL, skipHistory) { const display = document.getElementById('message-display'); if (!display) return; // Lưu bot response vào history (trừ khi đang skip hoặc skipHistory = true) if (sender === 'bot' && text && !_skipHistoryOnce && !skipHistory) { addChatHistory('assistant', text); } _skipHistoryOnce = false; const messageDiv = document.createElement('div'); messageDiv.className = 'message ' + sender; // Hiển thị ảnh đính kèm (nếu có) if (imageDataURL) { var imgEl = document.createElement('img'); imgEl.className = 'message-image'; imgEl.src = imageDataURL; imgEl.alt = 'Attached image'; messageDiv.appendChild(imgEl); } const textSpan = document.createElement('span'); textSpan.className = 'message-text'; // Render URLs as clickable links for bot messages if (sender === 'bot' && text.indexOf('http') !== -1) { textSpan.innerHTML = linkifyText(text); } else { textSpan.textContent = text; } messageDiv.appendChild(textSpan); if (sender === 'bot' && typeof confidence === 'number') { const confSpan = document.createElement('span'); confSpan.className = 'confidence ' + getConfidenceClass(confidence); confSpan.textContent = 'Confidence: ' + confidence + '%'; messageDiv.appendChild(confSpan); } if (sender === 'bot' && adapterPath && adapterPath.length > 0) { const pathSpan = document.createElement('span'); pathSpan.className = 'adapter-path'; var breadcrumb = adapterPath.map(function (key) { return getAdapterDisplayName(key); }).join(' › '); pathSpan.textContent = breadcrumb; messageDiv.appendChild(pathSpan); } if (sender === 'bot' && typeof responseTime === 'number') { const timeSpan = document.createElement('span'); timeSpan.className = 'response-time'; timeSpan.textContent = '⏱ ' + responseTime + 'ms'; messageDiv.appendChild(timeSpan); } display.appendChild(messageDiv); scrollToBottom(); // TTS: đọc phản hồi bot nếu voice output đang bật if (sender === 'bot' && text) { onBotReplyReady(text); } } /** * Hiển thị thông báo lỗi trong chat. * @param {string} message - Nội dung lỗi */ function showError(message) { appendMessage(message, 'bot'); } /** * Hiển thị TTS processing status. * @param {string} message - Status message */ function showTTSStatus(message) { var ttsStatus = document.getElementById('tts-status'); var ttsStatusText = document.getElementById('tts-status-text'); if (ttsStatus && ttsStatusText) { ttsStatusText.textContent = message || 'Đang xử lý âm thanh...'; ttsStatus.classList.remove('hidden'); } } /** * Ẩn TTS processing status. */ function hideTTSStatus() { var ttsStatus = document.getElementById('tts-status'); if (ttsStatus) { ttsStatus.classList.add('hidden'); } } /** * Tính tỉ lệ khớp (confidence) dựa trên trigger đã match. * - Trigger chính xác (không chứa wildcard) → 100 * - Trigger mặc định `*` → 0 * - Trigger chứa wildcard một phần → giá trị trung gian * @param {string} matchedTrigger - Trigger đã khớp từ lastMatch() * @returns {number} Confidence 0–100 */ function calculateConfidence(matchedTrigger) { if (typeof matchedTrigger !== 'string' || matchedTrigger.trim() === '') { return 0; } const trigger = matchedTrigger.trim(); // Trigger mặc định wildcard `*` → confidence = 0 if (trigger === '*') { return 0; } // Kiểm tra trigger có chứa wildcard không const wildcardPatterns = ['*', '#', '_']; const hasWildcard = wildcardPatterns.some(function (w) { return trigger.includes(w); }); if (!hasWildcard) { // Trigger chính xác (không wildcard) → confidence = 100 return 100; } // Trigger chứa wildcard một phần → tính giá trị trung gian // Dựa trên tỉ lệ phần không phải wildcard so với tổng chiều dài const parts = trigger.split(/[\s]+/); var nonWildcardParts = 0; for (var i = 0; i < parts.length; i++) { if (parts[i] !== '*' && parts[i] !== '#' && parts[i] !== '_') { nonWildcardParts++; } } var ratio = nonWildcardParts / parts.length; // Ánh xạ ratio vào khoảng [1, 99] để tránh trùng với 0 và 100 return Math.max(1, Math.min(99, Math.round(ratio * 100))); } /** * Trả về CSS class cho màu sắc confidence. * @param {number} confidence - Tỉ lệ khớp (0–100) * @returns {string} "confidence-high" nếu ≥50, "confidence-low" nếu <50 */ function getConfidenceClass(confidence) { return confidence >= 50 ? 'confidence-high' : 'confidence-low'; } // ============================================================ // Brain Data — Dữ liệu hội thoại RiveScript cho 3 ngôn ngữ // Được load từ file .rive riêng biệt trong thư mục brain/ // Xem: brain/vi.rive, brain/en.rive, brain/ja.rive // Trong trình duyệt: BRAIN_DATA được load bởi brain.js (qua fetch) // Trong Node/test: BRAIN_DATA được load bởi fs.readFileSync // ============================================================ // Node/test: khai báo các biến dữ liệu ngoài (browser đã có từ brain.js & data-loader.js) // Sử dụng IIFE để tránh var hoisting ghi đè biến global trong browser (function () { if (typeof module === 'undefined' || !module.exports) return; var _fs = require('fs'); var _path = require('path'); globalThis.BRAIN_DATA = { vi: _fs.readFileSync(_path.join(__dirname, 'brain', 'vi.rive'), 'utf8'), en: _fs.readFileSync(_path.join(__dirname, 'brain', 'en.rive'), 'utf8'), ja: _fs.readFileSync(_path.join(__dirname, 'brain', 'ja.rive'), 'utf8') }; globalThis.SPECIFIC_RESPONSES = JSON.parse(_fs.readFileSync(_path.join(__dirname, 'data', 'specific-responses.json'), 'utf8')); globalThis.QA_DATASET = JSON.parse(_fs.readFileSync(_path.join(__dirname, 'data', 'qa-dataset.json'), 'utf8')); globalThis.ADAPTER_REGISTRY = JSON.parse(_fs.readFileSync(_path.join(__dirname, 'data', 'adapter-registry.json'), 'utf8')); globalThis.HELP_CONTENT = JSON.parse(_fs.readFileSync(_path.join(__dirname, 'data', 'help-content.json'), 'utf8')); })(); // ============================================================ // Lời chào mặc định cho mỗi ngôn ngữ (trigger gửi đến bot) // ============================================================ const GREETING_TRIGGERS = { vi: 'xin chao', en: 'hello', ja: 'こんにちは' }; // ============================================================ // Khởi tạo RiveScript Engine // ============================================================ /** * Khởi tạo RiveScript engine với brain data của ngôn ngữ chỉ định. * Tạo instance mới, stream brain data, sort replies, đăng ký adapter. * @param {string} lang - Mã ngôn ngữ ("vi", "en", "ja") * @returns {Promise} */ async function initBot(lang) { // Kiểm tra CDN RiveScript đã tải chưa if (typeof RiveScript === 'undefined') { showError('Chatbot hiện không khả dụng. Vui lòng kiểm tra kết nối mạng và tải lại trang.'); return; } try { bot = new RiveScript({ utf8: true }); bot.stream(BRAIN_DATA[lang]); bot.sortReplies(); // Đăng ký adapter nếu hàm registerAdapters đã được định nghĩa (sẽ thêm ở task sau) if (typeof registerAdapters === 'function') { registerAdapters(bot, lang); } // Cấu hình LLM Model ID từ app.js if (typeof setLLMModelId === 'function') { setLLMModelId(LLM_MODEL_ID_CONFIG); } // Đăng ký callback nhận trạng thái loading LLM if (typeof setLLMStatusCallback === 'function') { setLLMStatusCallback(onLLMStatusChange); } } catch (err) { console.error('Lỗi khởi tạo RiveScript:', err); showError('Không thể khởi tạo chatbot. Vui lòng tải lại trang.'); bot = null; } } /** * Đổi ngôn ngữ: tạo lại bot, xóa chat, hiển thị lời chào mới, * cập nhật rules list và macros list. * @param {string} lang - Mã ngôn ngữ ("vi", "en", "ja") * @returns {Promise} */ async function changeLanguage(lang) { currentLang = lang; // Notify language selector if (typeof onLanguageChange === 'function') { onLanguageChange(lang); } await initBot(lang); // Xóa lịch sử hội thoại clearChatHistory(); var display = document.getElementById('message-display'); if (display) { display.innerHTML = ''; } // Hiển thị lời chào mới bằng ngôn ngữ được chọn if (bot) { try { var greeting = await bot.reply(USERNAME, GREETING_TRIGGERS[lang] || 'hello'); _skipHistoryOnce = true; appendMessage(greeting, 'bot'); } catch (err) { console.error('Lỗi lấy lời chào:', err); showError('Không thể hiển thị lời chào.'); } } // Cập nhật rules list nếu hàm đã được định nghĩa (sẽ thêm ở task sau) if (typeof updateRulesList === 'function') { updateRulesList(lang); } // Cập nhật macros list nếu hàm đã được định nghĩa (sẽ thêm ở task sau) if (typeof updateMacrosList === 'function') { updateMacrosList(lang); } // Cập nhật voice selector theo ngôn ngữ mới updateVoiceSelector(lang); } // ============================================================ // Gửi tin nhắn và xử lý phản hồi // ============================================================ /** * Hiển thị chỉ báo đang tải (loading indicator) trong message display. * @returns {HTMLElement} Phần tử loading indicator đã thêm vào DOM */ function showLoadingIndicator() { var display = document.getElementById('message-display'); if (!display) return null; var loadingDiv = document.createElement('div'); loadingDiv.className = 'message bot loading-indicator'; loadingDiv.textContent = '...'; display.appendChild(loadingDiv); scrollToBottom(); return loadingDiv; } /** * Ẩn/xóa chỉ báo đang tải. * @param {HTMLElement} element - Phần tử loading indicator cần xóa */ function hideLoadingIndicator(element) { if (element && element.parentNode) { element.parentNode.removeChild(element); } } /** * Gửi HTTP POST đến Fallback API với timeout 5s. * Sử dụng AbortController để hủy request khi vượt quá thời gian chờ. * @param {string} userMessage - Tin nhắn người dùng * @returns {Promise} Phản hồi từ API hoặc null nếu lỗi/timeout */ async function callFallbackAPI(userMessage) { var controller = new AbortController(); var timeoutId = setTimeout(function () { controller.abort(); }, FALLBACK_API_TIMEOUT); try { var response = await fetch(FALLBACK_API_URL, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ message: userMessage }), signal: controller.signal }); var data = await response.json(); return data.answer || data.reply || data.response || null; } catch (err) { // Timeout (AbortError) hoặc lỗi mạng → trả về null return null; } finally { clearTimeout(timeoutId); } } /** * Parse Adapter Prefix Command: /[key] [content] * Trả về {adapterKey, content} nếu hợp lệ, null nếu không. * Key hợp lệ: có trong ADAPTER_REGISTRY, active:true, không phải voice-adapter. * @param {string} input * @returns {{adapterKey: string, content: string}|null} */ function parseAdapterPrefixCommand(input) { if (!input || input.charAt(0) !== '/') return null; var match = input.match(/^\/([a-z_]+)\s+(.+)$/); if (!match) return null; var key = match[1]; var content = match[2].trim(); if (!content) return null; // Kiểm tra key trong ADAPTER_REGISTRY if (typeof ADAPTER_REGISTRY === 'undefined' || !ADAPTER_REGISTRY[key]) return null; // Bỏ qua voice-adapter if (key === 'voice-adapter' || key === 'voice_adapter') return null; // Bỏ qua adapter disabled if (ADAPTER_REGISTRY[key].active === false) return null; return { adapterKey: key, content: content }; } /** * Hiển thị badge adapter prefix phía trên input. * @param {string} adapterKey */ function showAdapterPrefixBadge(adapterKey) { var badge = document.getElementById('adapter-prefix-badge'); if (!badge) return; var name = (typeof getAdapterDisplayName === 'function') ? getAdapterDisplayName(adapterKey) : adapterKey; badge.textContent = '🔧 ' + name; badge.classList.remove('hidden'); } /** * Ẩn badge adapter prefix. */ function hideAdapterPrefixBadge() { var badge = document.getElementById('adapter-prefix-badge'); if (badge) badge.classList.add('hidden'); } /** * Detect nếu input là web search command. * Trả về query string nếu match, null nếu không. * @param {string} text - Input người dùng (raw) * @returns {string|null} Query string hoặc null */ function extractWebSearchQuery(text) { var lower = text.toLowerCase().trim(); // Normalize tiếng Việt để match "tra cứu" → "tra cuu" var norm = (typeof normalizeInput === 'function') ? normalizeInput(text).toLowerCase().trim() : lower; var prefixes = [ 'google ', 'tra cuu ', 'tra cứu ', 'search ', 'web search ', 'ウェブ検索 ', 'グーグル ', 'tìm trên web ', 'tìm trên mạng ', 'tim tren web ', 'tim tren mang ' ]; for (var i = 0; i < prefixes.length; i++) { if (lower.indexOf(prefixes[i]) === 0) { return text.substring(prefixes[i].length).trim(); } if (norm.indexOf(prefixes[i]) === 0) { return text.substring(prefixes[i].length).trim(); } } return null; } /** * Tạo message fallback cuối cùng khi tất cả adapter đều thất bại. * Kèm thông tin lỗi LLM (nếu có) để hỗ trợ debug. * @returns {string} */ function getFinalFallbackMessage() { var lang = currentLang || 'vi'; var base; if (lang === 'en') base = 'Sorry, I couldn\'t find an answer for your question. Please try rephrasing or ask something else.'; else if (lang === 'ja') base = '申し訳ありませんが、ご質問に対する回答が見つかりませんでした。別の言い方で試してみてください。'; else base = 'Xin lỗi, mình không tìm được câu trả lời cho câu hỏi của bạn. Bạn thử hỏi cách khác nhé!'; // Kèm thông tin lỗi LLM nếu có var llmError = (typeof getLLMLastError === 'function') ? getLLMLastError() : null; if (llmError) { base += '\n⚠️ LLM: ' + llmError; } return base; } /** * Lấy phản hồi từ bot cho một input, trả về reply + confidence + adapterPath. * @param {string} inputText - Input gửi cho bot * @returns {Promise<{reply: string, confidence: number, adapterPath: string[]}>} */ async function getBotReply(inputText) { _adapterPath = []; var reply = await bot.reply(USERNAME, inputText); var matchedTrigger = await bot.lastMatch(USERNAME); var confidence = calculateConfidence(matchedTrigger); var adapterPath = _adapterPath.length > 0 ? _adapterPath.slice() : ['rivescript']; return { reply: reply, confidence: confidence, adapterPath: adapterPath }; } /** * Lấy tin nhắn từ input, gửi đến bot, tính confidence, xử lý fallback, hiển thị kết quả. * * Chiến lược dual-reply (chỉ áp dụng cho tiếng Việt): * - Gửi cả input gốc (có dấu) VÀ input đã bỏ dấu cho bot * - So sánh confidence của 2 kết quả, chọn kết quả có confidence cao hơn * - Nếu bằng nhau, ưu tiên kết quả từ input gốc (có dấu) * * @returns {Promise} */ async function sendMessage() { var input = document.getElementById('message-input'); if (!input) return; var text = input.value; var attachment = consumeAttachment(); // Kiểm tra tin nhắn hợp lệ (cho phép gửi ảnh mà không cần text) if (!validateMessage(text) && !attachment) { return; } // Hiển thị tin nhắn người dùng NGAY LẬP TỨC (kèm ảnh nếu có) appendMessage(text || '', 'user', undefined, undefined, undefined, attachment ? attachment.dataURL : undefined); // Lưu user message vào history (nếu có ảnh mà không có text, ghi chú [image]) var historyText = (text && text.trim()) ? text : (attachment ? '[image: ' + attachment.name + ']' : ''); addChatHistory('user', historyText, attachment ? attachment.file : null); // Xóa input input.value = ''; // Ẩn badge adapter prefix sau khi gửi hideAdapterPrefixBadge(); // Kiểm tra bot đã khởi tạo chưa if (!bot) { showError('Chatbot chưa sẵn sàng. Vui lòng tải lại trang.'); return; } // Disable nút gửi trong khi đang xử lý setSendingDisabled(); var loadingEl = null; try { var startTime = Date.now(); // Adapter Prefix Command: /[key] [content] — gọi trực tiếp adapter được chỉ định var prefixCmd = parseAdapterPrefixCommand(text); if (prefixCmd && !attachment) { var adapterKey = prefixCmd.adapterKey; var content = prefixCmd.content; // Kiểm tra content không rỗng if (!content || !content.trim()) { var lang = currentLang || 'vi'; var emptyMsg = lang === 'en' ? 'Please provide content after the adapter prefix.' : lang === 'ja' ? 'アダプタープレフィックスの後にコンテンツを入力してください。' : 'Vui lòng nhập nội dung sau prefix adapter.'; showError(emptyMsg); return; } // Gọi adapter trực tiếp _adapterPath = []; var adapterResult = null; var adapterError = null; try { // Gọi adapter tương ứng với key if (adapterKey === 'best_match' && typeof bestMatchAdapter === 'function') { var bmRes = bestMatchAdapter(bot, content.split(/\s+/)); adapterResult = (bmRes && bmRes.answer) ? bmRes.answer : null; } else if (adapterKey === 'mathematical_evaluation' && typeof mathematicalEvaluationAdapter === 'function') { adapterResult = mathematicalEvaluationAdapter(bot, content.split(/\s+/)); } else if (adapterKey === 'specific_response' && typeof specificResponseAdapter === 'function') { adapterResult = specificResponseAdapter(bot, content.split(/\s+/)); } else if (adapterKey === 'time_adapter' && typeof timeAdapter === 'function') { adapterResult = timeAdapter(bot, content.split(/\s+/)); } else if (adapterKey === 'unit_conversion' && typeof unitConversionAdapter === 'function') { adapterResult = unitConversionAdapter(bot, content.split(/\s+/)); } else if (adapterKey === 'web_search' && typeof webSearchAdapter === 'function') { loadingEl = showLoadingIndicator(); adapterResult = await webSearchAdapter(bot, content.split(/\s+/)); hideLoadingIndicator(loadingEl); loadingEl = null; } else if (adapterKey === 'llm_adapter' && typeof llmAdapter === 'function') { var streamEl = createStreamingBotMessage(); var streamCb = createStreamingCallback(streamEl); showLLMCancelButton(); adapterResult = await llmAdapter(bot, content.split(/\s+/), null, streamCb, getChatHistoryForLLM()); hideLLMCancelButton(); var elapsed = Date.now() - startTime; var llmPath = _adapterPath.length > 0 ? _adapterPath.slice() : ['llm_adapter']; // Thêm "📌" prefix vào tên adapter đầu tiên trong breadcrumb if (llmPath.length > 0) llmPath[0] = '📌' + llmPath[0]; if (isValidAdapterResult(adapterResult)) { finalizeStreamingMessage(streamEl, adapterResult, null, llmPath, elapsed); } else { removeStreamingMessage(streamEl); appendMessage(getFinalFallbackMessage(), 'bot', null, llmPath, elapsed); } return; } else if (adapterKey === 'logic_adapter' && typeof logicAdapterDispatcher === 'function') { adapterResult = logicAdapterDispatcher(bot, content.split(/\s+/)); } else { adapterError = 'Adapter không được hỗ trợ: ' + adapterKey; } } catch (adapterErr) { console.error('Lỗi gọi adapter:', adapterErr); adapterError = 'Lỗi khi gọi adapter: ' + (adapterErr.message || adapterErr); } var elapsed = Date.now() - startTime; var adapterPath = _adapterPath.length > 0 ? _adapterPath.slice() : [adapterKey]; // Thêm "📌" prefix vào tên adapter đầu tiên trong breadcrumb if (adapterPath.length > 0) adapterPath[0] = '📌' + adapterPath[0]; if (adapterError) { showError(adapterError); } else if (isValidAdapterResult(adapterResult)) { appendMessage(adapterResult, 'bot', null, adapterPath, elapsed); } else { appendMessage(getFinalFallbackMessage(), 'bot', null, adapterPath, elapsed); } return; } // Nếu có ảnh đính kèm → gửi thẳng qua LLM Adapter (RiveScript không xử lý ảnh) if (attachment) { var streamEl = createStreamingBotMessage(); var streamCb = createStreamingCallback(streamEl); showLLMCancelButton(); try { _adapterPath = []; var imgResult = null; if (typeof llmAdapter === 'function') { imgResult = await llmAdapter(null, (text || '').split(/\s+/), attachment.dataURL, streamCb, getChatHistoryForLLM()); } hideLLMCancelButton(); var elapsedImg = Date.now() - startTime; var imgPath = _adapterPath.length > 0 ? _adapterPath.slice() : ['llm_adapter']; if (isValidAdapterResult(imgResult)) { finalizeStreamingMessage(streamEl, imgResult, null, imgPath, elapsedImg); } else { removeStreamingMessage(streamEl); appendMessage(getFinalFallbackMessage(), 'bot', null, imgPath, elapsedImg); } } catch (imgErr) { hideLLMCancelButton(); removeStreamingMessage(streamEl); console.error('Lỗi xử lý ảnh:', imgErr); var imgErrMsg = 'Không thể xử lý ảnh.'; var llmErr = (typeof getLLMLastError === 'function') ? getLLMLastError() : null; if (llmErr) imgErrMsg += '\n⚠️ LLM: ' + llmErr; else if (imgErr && imgErr.message) imgErrMsg += '\n⚠️ ' + imgErr.message; showError(imgErrMsg); } return; } // Web Search: detect "google ...", "tra cuu ...", "search ...", "web search ..." trước khi gửi cho RiveScript // Xử lý trực tiếp vì web search là async và RiveScript subroutine không hỗ trợ Promise var webSearchQuery = extractWebSearchQuery(text); if (webSearchQuery && typeof webSearchAdapter === 'function') { loadingEl = showLoadingIndicator(); try { _adapterPath = []; var searchResult = await webSearchAdapter(null, webSearchQuery.split(/\s+/)); hideLoadingIndicator(loadingEl); loadingEl = null; var elapsed = Date.now() - startTime; var searchPath = _adapterPath.length > 0 ? _adapterPath.slice() : ['web_search']; appendMessage(searchResult, 'bot', null, searchPath, elapsed); } catch (searchErr) { hideLoadingIndicator(loadingEl); loadingEl = null; console.error('Lỗi tìm kiếm web:', searchErr); showError('Tìm kiếm thất bại. Vui lòng thử lại.'); } return; } var best; var normalizedText = normalizeInput(text); var isDifferent = normalizedText !== text; if (currentLang === 'vi' && isDifferent) { var rawResult = await getBotReply(text); var normResult = await getBotReply(normalizedText); best = (normResult.confidence > rawResult.confidence) ? normResult : rawResult; } else { best = await getBotReply(text); } var reply = best.reply; var confidence = best.confidence; var adapterPath = best.adapterPath; if (confidence >= 50) { var elapsed = Date.now() - startTime; appendMessage(reply, 'bot', confidence, adapterPath, elapsed); } else { // Thử best match adapter var localFallback = null; var localPath = []; var localConfidence = confidence; try { _adapterPath = []; var bmResult = bestMatchAdapter(bot, text.toLowerCase().split(/\s+/)); localPath = _adapterPath.slice(); if (currentLang === 'vi') { _adapterPath = []; var normBmResult = bestMatchAdapter(bot, normalizeInput(text).toLowerCase().split(/\s+/)); var normPath = _adapterPath.slice(); if (normBmResult && isValidAdapterResult(normBmResult.answer) && (!bmResult || !isValidAdapterResult(bmResult.answer) || normBmResult.score > bmResult.score)) { bmResult = normBmResult; localPath = normPath; } } if (bmResult && isValidAdapterResult(bmResult.answer)) { localFallback = bmResult.answer; localConfidence = Math.round(bmResult.score * 100); } } catch (matchErr) { console.error('Lỗi best match adapter:', matchErr); localFallback = null; } if (localFallback) { var elapsed2 = Date.now() - startTime; appendMessage(localFallback, 'bot', localConfidence, localPath, elapsed2); } else { // Thử Fallback API → LLM Adapter loadingEl = showLoadingIndicator(); try { var apiResult = await callFallbackAPI(text); if (apiResult) { hideLoadingIndicator(loadingEl); loadingEl = null; var elapsed3 = Date.now() - startTime; var apiPath = adapterPath.concat(['fallback_api']); appendMessage(apiResult, 'bot', confidence, apiPath, elapsed3); } else { // Fallback API trả null → thử LLM Adapter (WebGPU) hideLoadingIndicator(loadingEl); loadingEl = null; var streamEl2 = createStreamingBotMessage(); var streamCb2 = createStreamingCallback(streamEl2); showLLMCancelButton(); var llmResult = null; if (typeof llmAdapter === 'function') { try { llmResult = await llmAdapter(null, text.split(/\s+/), null, streamCb2, getChatHistoryForLLM()); } catch (llmErr) { console.error('Lỗi LLM adapter:', llmErr); llmResult = null; } } hideLLMCancelButton(); var elapsed3b = Date.now() - startTime; if (isValidAdapterResult(llmResult)) { var llmPath = adapterPath.concat(['llm_adapter']); finalizeStreamingMessage(streamEl2, llmResult, confidence, llmPath, elapsed3b); } else { removeStreamingMessage(streamEl2); appendMessage(getFinalFallbackMessage(), 'bot', confidence, adapterPath, elapsed3b); } } } catch (apiErr) { // callFallbackAPI ném exception → thử LLM Adapter (WebGPU) console.error('Lỗi fallback API:', apiErr); hideLoadingIndicator(loadingEl); loadingEl = null; var streamEl3 = createStreamingBotMessage(); var streamCb3 = createStreamingCallback(streamEl3); showLLMCancelButton(); var llmResult2 = null; if (typeof llmAdapter === 'function') { try { llmResult2 = await llmAdapter(null, text.split(/\s+/), null, streamCb3, getChatHistoryForLLM()); } catch (llmErr2) { console.error('Lỗi LLM adapter:', llmErr2); llmResult2 = null; } } hideLLMCancelButton(); var elapsed4 = Date.now() - startTime; if (isValidAdapterResult(llmResult2)) { var llmPath2 = adapterPath.concat(['llm_adapter']); finalizeStreamingMessage(streamEl3, llmResult2, confidence, llmPath2, elapsed4); } else { removeStreamingMessage(streamEl3); appendMessage(getFinalFallbackMessage(), 'bot', confidence, adapterPath, elapsed4); } } } } } catch (err) { console.error('Lỗi xử lý tin nhắn:', err); showError('Xin lỗi, mình gặp sự cố. Bạn thử gửi lại nhé!'); } finally { // Luôn dọn dẹp loading indicator, cancel button và enable lại nút gửi hideLLMCancelButton(); if (loadingEl) { hideLoadingIndicator(loadingEl); } setSendingEnabled(); scrollToBottom(); } } // ============================================================ // Logic Adapters — Tách thành các file riêng trong thư mục adapters/ // Xem: adapters/text-similarity.js, adapters/specific-response.js, // adapters/time-adapter.js, adapters/math-adapter.js, // adapters/unit-conversion.js, adapters/best-match.js, // adapters/logic-dispatcher.js, adapters/adapter-registry.js // Trong trình duyệt: được load bởi