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