diff --git "a/app.js" "b/app.js" --- "a/app.js" +++ "b/app.js" @@ -1,2081 +1,2818 @@ -// ============================================================ -// 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 } | 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(); -} - -/** - * 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 }; - - 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 - */ -function addChatHistory(role, content) { - 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) - if (typeof saveChatMessage === 'function') { - saveChatMessage(role, clean.trim(), currentLang).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 = []; - } -} - -/** - * @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); -} - -/** - * @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) - */ -function appendMessage(text, sender, confidence, adapterPath, responseTime, imageDataURL) { - const display = document.getElementById('message-display'); - if (!display) return; - - // Lưu bot response vào history (trừ khi đang skip) - if (sender === 'bot' && text && !_skipHistoryOnce) { - 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(); -} - -/** - * 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'); -} - -/** - * 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; - 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); - } -} - -// ============================================================ -// 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); - } -} - -/** - * 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 (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); - - // Xóa input - input.value = ''; - - // 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(); - - // 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