chatbot-js / app.js
mr4's picture
Upload 27 files
4b05d12 verified
// ============================================================
// 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ẻ <a> 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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
// Replace newlines with <br>
escaped = escaped.replace(/\n/g, '<br>');
// Replace URLs with <a> tags
return escaped.replace(/(https?:\/\/[^\s<]+)/g, '<a href="$1" target="_blank" rel="noopener">$1</a>');
}
/**
* 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('</think>');
if (endIdx !== -1) {
return {
thinking: text.substring(0, endIdx).trim(),
response: text.substring(endIdx + '</think>'.length).replace(/^\n+/, '').trim(),
thinkingDone: true
};
}
// Chưa có </think> → toàn bộ là thinking
return { thinking: text.trim(), response: '', thinkingDone: false };
}
/**
* Xóa tag <think>...</think> khỏi text (dùng khi thinking bị disable).
* @param {string} text
* @returns {string}
*/
function _stripThinkTags(text) {
if (!text) return '';
return text.replace(/<think>[\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 <think>...</think>)
* @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('</think>');
if (thinkEnd !== -1) {
clean = clean.substring(thinkEnd + '</think>'.length);
}
clean = clean.replace(/<think>[\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<void>}
*/
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<void>}
*/
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<string|null>} 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<void>}
*/
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 <script> tags trong index.html
// Trong Node/test: được load bởi IIFE bên dưới
// ============================================================
// Node/test: expose shared variables to globalThis for adapter files
(function () {
if (typeof module === 'undefined' || !module.exports) return;
// Adapter files access these as free variables — in Node they need to be on globalThis
// Use Object.defineProperty for _adapterPath and currentLang to keep them in sync with app.js locals
Object.defineProperty(globalThis, '_adapterPath', {
get: function () { return _adapterPath; },
set: function (val) { _adapterPath = val; },
configurable: true
});
Object.defineProperty(globalThis, 'currentLang', {
get: function () { return currentLang; },
set: function (val) { currentLang = val; },
configurable: true
});
globalThis.removeVietnameseDiacritics = removeVietnameseDiacritics;
})();
// Node/test: load adapter files
(function () {
if (typeof module === 'undefined' || !module.exports) return;
var _path = require('path');
var _adDir = _path.join(__dirname, 'adapters');
var files = [
'text-similarity.js',
'specific-response.js',
'time-adapter.js',
'math-adapter.js',
'unit-conversion.js',
'best-match.js',
'logic-dispatcher.js',
'web-search.js',
'llm-adapter.js',
'voice-adapter.js',
'adapter-registry.js'
];
for (var i = 0; i < files.length; i++) {
require(_path.join(_adDir, files[i]));
}
// Load preprocessed data synchronously for Node/test
if (typeof loadPreprocessedData === 'function') {
loadPreprocessedData();
}
})();
// ============================================================
// Object Macro List Panel — Toggle và cập nhật danh sách macros
// ============================================================
/**
* Lấy trạng thái enable/disable của các adapter từ localStorage.
* @returns {{[key: string]: boolean}}
*/
function getAdapterStates() {
try {
var raw = localStorage.getItem('hikari_adapter_states');
if (raw) return JSON.parse(raw) || {};
} catch (e) { /* ignore */ }
return {};
}
/**
* Lưu trạng thái active của tất cả adapter vào localStorage.
*/
function saveAdapterStates() {
var states = {};
var keys = Object.keys(ADAPTER_REGISTRY);
for (var i = 0; i < keys.length; i++) {
states[keys[i]] = ADAPTER_REGISTRY[keys[i]].active !== false;
}
try {
localStorage.setItem('hikari_adapter_states', JSON.stringify(states));
} catch (e) { /* ignore */ }
}
/**
* Cập nhật trạng thái active của một adapter và lưu vào localStorage.
* Thay đổi sẽ có hiệu lực ngay lập tức vì wrapper trong registerAdapters() kiểm tra flag mỗi lần gọi.
* @param {string} adapterKey
* @param {boolean} isActive
*/
function setAdapterActive(adapterKey, isActive) {
if (!ADAPTER_REGISTRY[adapterKey]) return;
// voice-adapter luôn enabled
if (adapterKey === 'voice-adapter' || adapterKey === 'voice_adapter') return;
ADAPTER_REGISTRY[adapterKey].active = isActive;
saveAdapterStates();
}
/**
* Toggle hiển thị panel danh sách Object Macros.
* Thêm/xóa class 'hidden' trên phần tử #macros-panel.
*/
function toggleMacrosPanel() {
var panel = document.getElementById('macros-panel');
if (panel) {
panel.classList.toggle('hidden');
}
}
/**
* Toggle hiển thị panel Settings.
*/
function toggleSettingsPanel() {
var panel = document.getElementById('settings-panel');
if (panel) {
panel.classList.toggle('hidden');
}
}
// ============================================================
// History Dialog — Xem lịch sử chat (IndexedDB + Session)
// ============================================================
var _historyCurrentTab = 'db'; // 'db' hoặc 'session'
var _historyCurrentPage = 1;
var _historyPageSize = 20;
function openHistoryDialog() {
var overlay = document.getElementById('history-overlay');
if (overlay) overlay.classList.remove('hidden');
_historyCurrentTab = 'db';
_historyCurrentPage = 1;
_updateHistoryTabUI();
_loadHistoryPage();
}
function closeHistoryDialog() {
var overlay = document.getElementById('history-overlay');
if (overlay) overlay.classList.add('hidden');
}
function _updateHistoryTabUI() {
var tabDb = document.getElementById('history-tab-db');
var tabSession = document.getElementById('history-tab-session');
if (tabDb) tabDb.classList.toggle('active', _historyCurrentTab === 'db');
if (tabSession) tabSession.classList.toggle('active', _historyCurrentTab === 'session');
}
function _formatTimestamp(ts) {
if (!ts) return '';
var d = new Date(ts);
var pad = function (n) { return n < 10 ? '0' + n : '' + n; };
return d.getFullYear() + '-' + pad(d.getMonth() + 1) + '-' + pad(d.getDate())
+ ' ' + pad(d.getHours()) + ':' + pad(d.getMinutes());
}
async function _loadHistoryPage() {
var listEl = document.getElementById('history-list');
var pagingEl = document.getElementById('history-paging');
if (!listEl) return;
listEl.innerHTML = '';
if (_historyCurrentTab === 'db') {
// IndexedDB history
if (typeof getMessagesPage !== 'function') {
listEl.innerHTML = '<p class="history-empty">IndexedDB không khả dụng.</p>';
if (pagingEl) pagingEl.innerHTML = '';
return;
}
try {
var result = await getMessagesPage(_historyCurrentPage, _historyPageSize);
if (result.messages.length === 0) {
listEl.innerHTML = '<p class="history-empty">Chưa có lịch sử.</p>';
} else {
for (var i = 0; i < result.messages.length; i++) {
var msg = result.messages[i];
var div = document.createElement('div');
div.className = 'history-item history-item-' + msg.role;
var meta = document.createElement('span');
meta.className = 'history-meta';
meta.textContent = (msg.role === 'user' ? '👤' : '🤖') + ' ' + _formatTimestamp(msg.timestamp);
div.appendChild(meta);
var content = document.createElement('span');
content.className = 'history-content';
content.textContent = msg.content;
div.appendChild(content);
// Hiển thị thumbnail attachment nếu có
if (msg.role === 'user' && typeof getAttachmentByMessageId === 'function') {
(function (divEl, msgId) {
getAttachmentByMessageId(msgId).then(function (att) {
if (att && typeof attachmentToDataURL === 'function') {
var thumb = document.createElement('img');
thumb.className = 'history-attachment-thumb';
thumb.src = attachmentToDataURL(att);
thumb.alt = att.fileName || 'attachment';
divEl.appendChild(thumb);
}
}).catch(function () {});
})(div, msg.id);
}
listEl.appendChild(div);
}
}
// Paging
if (pagingEl) {
pagingEl.innerHTML = '';
if (result.totalPages > 1) {
var info = document.createElement('span');
info.className = 'history-page-info';
info.textContent = 'Trang ' + result.page + '/' + result.totalPages + ' (' + result.total + ' messages)';
pagingEl.appendChild(info);
if (result.page > 1) {
var prevBtn = document.createElement('button');
prevBtn.className = 'history-page-btn';
prevBtn.textContent = '← Trước';
prevBtn.addEventListener('click', function () {
_historyCurrentPage--;
_loadHistoryPage();
});
pagingEl.appendChild(prevBtn);
}
if (result.page < result.totalPages) {
var nextBtn = document.createElement('button');
nextBtn.className = 'history-page-btn';
nextBtn.textContent = 'Sau →';
nextBtn.addEventListener('click', function () {
_historyCurrentPage++;
_loadHistoryPage();
});
pagingEl.appendChild(nextBtn);
}
}
}
} catch (err) {
listEl.innerHTML = '<p class="history-empty">Lỗi: ' + err.message + '</p>';
if (pagingEl) pagingEl.innerHTML = '';
}
} else {
// Session history (_chatHistory)
if (pagingEl) pagingEl.innerHTML = '';
if (_chatHistory.length === 0) {
listEl.innerHTML = '<p class="history-empty">Session hiện tại chưa có lịch sử.</p>';
return;
}
// Hiển thị mới nhất trước
for (var j = _chatHistory.length - 1; j >= 0; j--) {
var m = _chatHistory[j];
var d = document.createElement('div');
d.className = 'history-item history-item-' + m.role;
var icon = document.createElement('span');
icon.className = 'history-meta';
icon.textContent = (m.role === 'user' ? '👤 User' : '🤖 Bot');
d.appendChild(icon);
var c = document.createElement('span');
c.className = 'history-content';
c.textContent = m.content;
d.appendChild(c);
listEl.appendChild(d);
}
}
}
async function clearAllHistory() {
if (typeof clearAllChatMessages === 'function') {
await clearAllChatMessages();
}
clearChatHistory();
// Nếu dialog đang mở, refresh
var overlay = document.getElementById('history-overlay');
if (overlay && !overlay.classList.contains('hidden')) {
_loadHistoryPage();
}
}
/**
* Load 10 messages gần nhất từ IndexedDB và hiển thị trong chat.
*/
async function loadRecentHistoryToChat() {
if (typeof getRecentMessages !== 'function') return;
try {
var messages = await getRecentMessages(10);
if (messages.length === 0) return;
for (var i = 0; i < messages.length; i++) {
var msg = messages[i];
var sender = msg.role === 'user' ? 'user' : 'bot';
// Load attachment nếu có (chỉ cho user messages)
var imageDataURL = null;
if (msg.role === 'user' && typeof getAttachmentByMessageId === 'function') {
try {
var att = await getAttachmentByMessageId(msg.id);
if (att && typeof attachmentToDataURL === 'function') {
imageDataURL = attachmentToDataURL(att);
}
} catch (attErr) {
console.warn('Không thể load attachment cho message', msg.id, attErr);
}
}
// skipHistory = true để tránh lưu lại vào IndexedDB
appendMessage(msg.content, sender, null, null, null, imageDataURL, true);
}
} catch (err) {
console.error('Lỗi load history:', err);
}
}
/**
* Cập nhật danh sách Object Macros cho ngôn ngữ chỉ định.
* Xóa nội dung cũ của #macros-list, duyệt qua ADAPTER_REGISTRY,
* tạo <li> cho mỗi adapter với tên, mô tả, và cú pháp <call>.
*
* @param {string} lang - Mã ngôn ngữ ("vi", "en", "ja")
*/
function updateMacrosList(lang) {
var list = document.getElementById('macros-list');
if (!list) return;
list.innerHTML = '';
var keys = Object.keys(ADAPTER_REGISTRY);
for (var i = 0; i < keys.length; i++) {
var key = keys[i];
var adapter = ADAPTER_REGISTRY[key];
var isActive = adapter.active !== false;
var isVoice = (key === 'voice-adapter' || key === 'voice_adapter');
var li = document.createElement('li');
li.className = 'macro-item' + (isActive ? '' : ' disabled');
// Header row: name + toggle
var header = document.createElement('div');
header.className = 'macro-item-header';
var strong = document.createElement('strong');
strong.textContent = adapter.name[lang] || adapter.name['vi'];
header.appendChild(strong);
// Toggle checkbox (ẩn cho voice-adapter)
if (!isVoice) {
var toggleLabel = document.createElement('label');
toggleLabel.className = 'macro-toggle-label';
toggleLabel.title = isActive
? (lang === 'en' ? 'Disable adapter' : lang === 'ja' ? '無効にする' : 'Tắt adapter')
: (lang === 'en' ? 'Enable adapter' : lang === 'ja' ? '有効にする' : 'Bật adapter');
var toggleInput = document.createElement('input');
toggleInput.type = 'checkbox';
toggleInput.className = 'macro-toggle-input';
toggleInput.checked = isActive;
toggleInput.dataset.adapterKey = key;
(function (adapterKey, liEl) {
toggleInput.addEventListener('change', function (e) {
setAdapterActive(adapterKey, e.target.checked);
liEl.className = 'macro-item' + (e.target.checked ? '' : ' disabled');
});
})(key, li);
toggleLabel.appendChild(toggleInput);
header.appendChild(toggleLabel);
}
li.appendChild(header);
var p = document.createElement('p');
p.className = 'macro-item-desc';
p.textContent = adapter.description[lang] || adapter.description['vi'];
li.appendChild(p);
var span = document.createElement('span');
span.className = 'macro-call-syntax';
span.textContent = adapter.callSyntax;
li.appendChild(span);
list.appendChild(li);
}
}
// ============================================================
// Rules Panel — Hiển thị danh sách triggers dạng dễ đọc
// ============================================================
/**
* Toggle hiển thị panel danh sách rules khi click nút #rules-button.
* Thêm/xóa class 'hidden' trên #rules-panel.
*/
function toggleRulesPanel() {
var panel = document.getElementById('rules-panel');
if (panel) {
panel.classList.toggle('hidden');
}
}
/**
* Cập nhật danh sách triggers cho ngôn ngữ chỉ định.
* Lấy triggers từ bot.getUserTopicTriggers(), lọc bỏ wildcard mặc định '*',
* format dạng dễ đọc và hiển thị trong #rules-list.
* @param {string} lang - Mã ngôn ngữ ("vi", "en", "ja")
*/
function updateRulesList(lang) {
var list = document.getElementById('rules-list');
if (!list) return;
// Xóa nội dung cũ
list.innerHTML = '';
// Nếu bot chưa khởi tạo, hiển thị thông báo
if (!bot) {
var emptyLi = document.createElement('li');
emptyLi.textContent = lang === 'en'
? 'Bot is not initialized.'
: lang === 'ja'
? 'ボットが初期化されていません。'
: 'Bot chưa được khởi tạo.';
list.appendChild(emptyLi);
return;
}
try {
// Lấy danh sách triggers từ internal data của RiveScript
var triggers = [];
var topicData = bot._topics && bot._topics.random;
if (topicData) {
var keys = Object.keys(topicData);
for (var k = 0; k < keys.length; k++) {
var entry = topicData[keys[k]];
if (entry && entry.trigger) {
triggers.push(entry.trigger);
}
}
}
if (triggers.length === 0) {
return;
}
// Dùng Set để loại bỏ trigger trùng lặp (wildcard variants cùng response)
var seen = {};
for (var i = 0; i < triggers.length; i++) {
var trigger = triggers[i];
// Lọc bỏ trigger wildcard mặc định '*' và trigger chỉ chứa wildcard
if (trigger.trim() === '*') continue;
// Lọc bỏ trigger dạng "* la ai" (chỉ wildcard + từ) — giữ trigger có nội dung rõ ràng
if (/^\*\s/.test(trigger.trim()) && trigger.trim().split(/\s+/).length <= 3) continue;
var formatted = formatTrigger(trigger);
if (formatted.length === 0 || seen[formatted]) continue;
seen[formatted] = true;
var li = document.createElement('li');
li.textContent = formatted;
list.appendChild(li);
}
} catch (err) {
console.error('Lỗi lấy danh sách triggers:', err);
}
}
/**
* Chuyển trigger RiveScript thành dạng dễ đọc cho người dùng.
* - Thay '*' bằng '...'
* - Xóa cú pháp RiveScript như <call>...</call>, <star>, <bot ...>
* - Xóa ký tự thừa và trim khoảng trắng
* @param {string} trigger - Chuỗi trigger RiveScript
* @returns {string} Chuỗi đã format dễ đọc
*/
function formatTrigger(trigger) {
if (typeof trigger !== 'string') return '';
var result = trigger;
// Xóa thẻ <call>...</call> và nội dung bên trong
result = result.replace(/<call>[^<]*<\/call>/g, '');
// Xóa các thẻ RiveScript khác: <star>, <bot ...>, <input>, <reply>, v.v.
result = result.replace(/<[^>]+>/g, '');
// Thay wildcard '*' bằng '...'
result = result.replace(/\*/g, '...');
// Thay '#' (number wildcard) bằng '[số]'
result = result.replace(/#/g, '[số]');
// Thay '_' (word wildcard) bằng '[từ]'
result = result.replace(/_/g, '[từ]');
// Xóa khoảng trắng thừa
result = result.replace(/\s+/g, ' ').trim();
return result;
}
// ============================================================
// Help Dialog — Popup hướng dẫn sử dụng đa ngôn ngữ
// ============================================================
/**
* Nội dung hướng dẫn sử dụng — load từ data/help-content.json
* Browser: HELP_CONTENT được khai báo bởi data-loader.js
* Node/test: HELP_CONTENT được khai báo ở block đầu file
*/
/**
* Render nội dung help dialog theo ngôn ngữ hiện tại.
*/
function renderHelpContent() {
var body = document.getElementById('help-dialog-body');
var titleEl = document.getElementById('help-dialog-title');
if (!body) return;
var lang = currentLang || 'vi';
var content = HELP_CONTENT[lang] || HELP_CONTENT['vi'];
if (titleEl) {
titleEl.textContent = content.title;
}
var html = '';
for (var i = 0; i < content.sections.length; i++) {
var section = content.sections[i];
html += '<div class="help-section">';
html += '<h3>' + section.icon + ' ' + section.heading + '</h3>';
html += '<ul>';
for (var j = 0; j < section.items.length; j++) {
html += '<li>' + section.items[j] + '</li>';
}
html += '</ul>';
html += '</div>';
}
body.innerHTML = html;
}
/**
* Mở help dialog.
*/
function openHelpDialog() {
var overlay = document.getElementById('help-overlay');
if (!overlay) return;
renderHelpContent();
overlay.classList.remove('hidden');
}
/**
* Đóng help dialog.
*/
function closeHelpDialog() {
var overlay = document.getElementById('help-overlay');
if (!overlay) return;
overlay.classList.add('hidden');
}
// ============================================================
// Khởi tạo ứng dụng — Main Initialization
// ============================================================
/**
* Hàm khởi tạo chính của ứng dụng Hikari Chatbot.
* - Kiểm tra CDN RiveScript đã tải chưa
* - Khởi tạo bot với ngôn ngữ mặc định (tiếng Việt)
* - Hiển thị lời chào khởi tạo chứa tên "Hikari" và hướng dẫn sử dụng
* - Gắn event listener cho Language Selector, nút gửi, input Enter, nút macros, nút rules
* @returns {Promise<void>}
*/
async function initializeApp() {
// Kiểm tra CDN RiveScript
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;
}
// Load brain files từ .rive (chỉ trong trình duyệt)
if (typeof loadAllBrains === 'function') {
await loadAllBrains();
}
// Load dữ liệu JSON (chỉ trong trình duyệt)
if (typeof loadAllData === 'function') {
await loadAllData();
}
// Áp dụng adapter states từ localStorage vào ADAPTER_REGISTRY
if (typeof getAdapterStates === 'function' && typeof ADAPTER_REGISTRY !== 'undefined') {
var savedStates = getAdapterStates();
var stateKeys = Object.keys(savedStates);
for (var si = 0; si < stateKeys.length; si++) {
var sk = stateKeys[si];
if (ADAPTER_REGISTRY[sk] && sk !== 'voice-adapter' && sk !== 'voice_adapter') {
ADAPTER_REGISTRY[sk].active = savedStates[sk];
}
}
}
// Load preprocessed similarity data (chỉ trong trình duyệt)
if (typeof loadPreprocessedData === 'function') {
await loadPreprocessedData();
}
// Lấy ngôn ngữ từ localStorage, nếu không có thì dùng mặc định
var savedLang = (typeof getLanguageFromStorage === 'function') ? getLanguageFromStorage() : null;
var initialLang = (savedLang && (typeof isLanguageSupported === 'function' ? isLanguageSupported(savedLang) : true)) ? savedLang : 'vi';
currentLang = initialLang;
// Cập nhật language selector UI
var langSelector = document.getElementById('language-selector');
if (langSelector) {
langSelector.value = initialLang;
}
// Khởi tạo bot với ngôn ngữ đã lưu
await initBot(initialLang);
// Hiển thị lời chào khởi tạo
if (bot) {
var greeting = await bot.reply(USERNAME, GREETING_TRIGGERS[initialLang]);
_skipHistoryOnce = true;
appendMessage(greeting, 'bot');
updateRulesList(initialLang);
updateMacrosList(initialLang);
}
// Load 10 messages gần nhất từ IndexedDB
await loadRecentHistoryToChat();
// === Gắn Event Listeners ===
// Language Selector: change → changeLanguage
var langSelector = document.getElementById('language-selector');
if (langSelector) {
langSelector.addEventListener('change', function(e) {
changeLanguage(e.target.value);
});
}
// Nút gửi: click → sendMessage
var sendBtn = document.getElementById('send-button');
if (sendBtn) {
sendBtn.addEventListener('click', sendMessage);
}
// Input: keypress Enter → sendMessage
var msgInput = document.getElementById('message-input');
if (msgInput) {
msgInput.addEventListener('keypress', function(e) {
if (e.key === 'Enter') {
sendMessage();
}
});
// Input: input event → parseAdapterPrefixCommand → show/hide badge
msgInput.addEventListener('input', function(e) {
var inputText = e.target.value;
var parsed = parseAdapterPrefixCommand(inputText);
if (parsed) {
showAdapterPrefixBadge(parsed.adapterKey);
} else {
hideAdapterPrefixBadge();
}
});
}
// Nút macros: click → toggleMacrosPanel
var macrosBtn = document.getElementById('macros-button');
if (macrosBtn) {
macrosBtn.addEventListener('click', toggleMacrosPanel);
}
// Nút rules: click → toggleRulesPanel
var rulesBtn = document.getElementById('rules-button');
if (rulesBtn) {
rulesBtn.addEventListener('click', toggleRulesPanel);
}
// Interaction Mode Badge: click → cycle modes
var modeBadge = document.getElementById('interaction-mode-badge');
if (modeBadge) {
modeBadge.addEventListener('click', function () {
console.log('[Event] Interaction mode badge clicked');
cycleInteractionMode();
});
console.log('[Init] Interaction mode badge click listener attached');
}
// Nút help: click → openHelpDialog
var helpBtn = document.getElementById('help-button');
if (helpBtn) {
helpBtn.addEventListener('click', openHelpDialog);
}
// Thinking toggle: change → bật/tắt thinking mode
var thinkingToggle = document.getElementById('thinking-toggle');
if (thinkingToggle) {
thinkingToggle.addEventListener('change', function (e) {
if (typeof setLLMThinkingEnabled === 'function') {
setLLMThinkingEnabled(e.target.checked);
}
});
}
// Nút settings
var settingsBtn = document.getElementById('settings-button');
if (settingsBtn) {
settingsBtn.addEventListener('click', toggleSettingsPanel);
}
// History toggle
var historyToggle = document.getElementById('history-toggle');
if (historyToggle) {
historyToggle.addEventListener('change', function (e) {
setChatHistoryEnabled(e.target.checked);
});
}
// History max turns
var historyMaxTurns = document.getElementById('history-max-turns');
if (historyMaxTurns) {
historyMaxTurns.addEventListener('change', function (e) {
setChatHistoryMaxTurns(parseInt(e.target.value, 10) || 0);
});
}
// View History button
var viewHistoryBtn = document.getElementById('view-history-button');
if (viewHistoryBtn) {
viewHistoryBtn.addEventListener('click', openHistoryDialog);
}
// Clear History button
var clearHistoryBtn = document.getElementById('clear-history-button');
if (clearHistoryBtn) {
clearHistoryBtn.addEventListener('click', function () {
if (confirm('Xóa toàn bộ lịch sử chat?')) {
clearAllHistory();
}
});
}
// History dialog: close button
var historyCloseBtn = document.getElementById('history-close-button');
if (historyCloseBtn) {
historyCloseBtn.addEventListener('click', closeHistoryDialog);
}
// History dialog: overlay click to close
var historyOverlay = document.getElementById('history-overlay');
if (historyOverlay) {
historyOverlay.addEventListener('click', function (e) {
if (e.target === historyOverlay) closeHistoryDialog();
});
}
// History dialog: tab buttons
var tabDb = document.getElementById('history-tab-db');
if (tabDb) {
tabDb.addEventListener('click', function () {
_historyCurrentTab = 'db';
_historyCurrentPage = 1;
_updateHistoryTabUI();
_loadHistoryPage();
});
}
var tabSession = document.getElementById('history-tab-session');
if (tabSession) {
tabSession.addEventListener('click', function () {
_historyCurrentTab = 'session';
_updateHistoryTabUI();
_loadHistoryPage();
});
}
// Nút attach: click → mở file picker
var attachBtn = document.getElementById('attach-button');
if (attachBtn) {
attachBtn.addEventListener('click', function () {
var fileInput = document.getElementById('file-input');
if (fileInput) fileInput.click();
});
}
// File input: change → xử lý file đính kèm
var fileInput = document.getElementById('file-input');
if (fileInput) {
fileInput.addEventListener('change', function (e) {
var file = e.target.files && e.target.files[0];
if (file) handleFileAttachment(file);
});
}
// Nút xóa attachment
var removeAttachBtn = document.getElementById('attachment-remove');
if (removeAttachBtn) {
removeAttachBtn.addEventListener('click', clearAttachment);
}
// Help dialog: đóng khi click nút X
var helpCloseBtn = document.getElementById('help-close-button');
if (helpCloseBtn) {
helpCloseBtn.addEventListener('click', closeHelpDialog);
}
// Help dialog: đóng khi click overlay (bên ngoài dialog)
var helpOverlay = document.getElementById('help-overlay');
if (helpOverlay) {
helpOverlay.addEventListener('click', function(e) {
if (e.target === helpOverlay) {
closeHelpDialog();
}
});
}
// Help dialog: đóng khi nhấn Escape
document.addEventListener('keydown', function(e) {
if (e.key === 'Escape') {
closeHelpDialog();
}
});
// === Voice Adapter: khởi tạo ===
var voiceSupport = (typeof initVoiceAdapter === 'function') ? initVoiceAdapter() : { sttSupported: false, ttsSupported: false };
// Ẩn nút microphone mặc định (chỉ hiện khi mode voice-*)
var micBtn = document.getElementById('voice-input-button');
if (micBtn) {
if (!voiceSupport.sttSupported) {
micBtn.classList.add('hidden');
micBtn.title = currentLang === 'en' ? 'Speech recognition not supported' : currentLang === 'ja' ? '音声認識非対応' : 'Trình duyệt không hỗ trợ nhận diện giọng nói';
} else {
micBtn.classList.add('hidden'); // Ẩn cho đến khi mode voice được chọn
micBtn.addEventListener('click', function () {
if (typeof isVoiceInputActive === 'function' && isVoiceInputActive()) {
stopVoiceInput();
} else {
startVoiceInput();
}
});
}
}
// Interaction Mode selector
var modeSelect = document.getElementById('interaction-mode-select');
if (modeSelect) {
console.log('[Init] Interaction mode select found, setting up listener');
// Vô hiệu hóa các option cần API không hỗ trợ
var opts = modeSelect.querySelectorAll('option');
opts.forEach(function (opt) {
var val = opt.value;
var needsSTT = val.startsWith('voice-');
var needsTTS = val.endsWith('-voice');
if ((needsSTT && !voiceSupport.sttSupported) || (needsTTS && !voiceSupport.ttsSupported)) {
opt.disabled = true;
opt.title = 'Trình duyệt không hỗ trợ';
console.log('[Init] Disabled option:', val, '(STT:', voiceSupport.sttSupported, 'TTS:', voiceSupport.ttsSupported, ')');
}
});
modeSelect.addEventListener('change', function (e) {
console.log('[Event] Interaction mode changed to:', e.target.value);
setInteractionMode(e.target.value);
});
console.log('[Init] Interaction mode listener attached. Current value:', modeSelect.value);
} else {
console.warn('[Init] interaction-mode-select not found!');
}
// TTS voice selector
var voiceSelect = document.getElementById('tts-voice-select');
if (voiceSelect) {
if (!voiceSupport.ttsSupported) {
// Ẩn TTS controls
var ttsSection = document.getElementById('tts-settings-section');
if (ttsSection) ttsSection.classList.add('hidden');
} else {
// Populate voices (có thể cần chờ onvoiceschanged)
updateVoiceSelector(currentLang);
if (window.speechSynthesis) {
window.speechSynthesis.onvoiceschanged = function () {
updateVoiceSelector(currentLang);
};
}
voiceSelect.addEventListener('change', function (e) {
_selectedVoiceName = e.target.value || null;
try { localStorage.setItem('hikari_voice_name', _selectedVoiceName || ''); } catch (ex) {}
});
}
}
// Voice output toggle
var voiceOutToggle = document.getElementById('voice-output-toggle');
if (voiceOutToggle) {
voiceOutToggle.addEventListener('change', function (e) {
var cur = getInteractionMode();
if (e.target.checked) {
setInteractionMode(cur.startsWith('voice-') ? 'voice-voice' : 'text-voice');
} else {
setInteractionMode(cur.startsWith('voice-') ? 'voice-text' : 'text-text');
}
});
}
// Voice input toggle
var voiceInToggle = document.getElementById('voice-input-toggle');
if (voiceInToggle) {
voiceInToggle.addEventListener('change', function (e) {
var cur = getInteractionMode();
if (e.target.checked) {
setInteractionMode(cur.endsWith('-voice') ? 'voice-voice' : 'voice-text');
} else {
setInteractionMode(cur.endsWith('-voice') ? 'text-voice' : 'text-text');
}
});
}
// === Load settings từ localStorage ===
(function () {
try {
// Interaction mode
var savedMode = localStorage.getItem('hikari_interaction_mode');
if (savedMode && ['text-text', 'text-voice', 'voice-text', 'voice-voice'].indexOf(savedMode) !== -1) {
// Kiểm tra mode có được hỗ trợ không trước khi áp dụng
var needsSTT = savedMode.startsWith('voice-');
var needsTTS = savedMode.endsWith('-voice');
if ((!needsSTT || voiceSupport.sttSupported) && (!needsTTS || voiceSupport.ttsSupported)) {
setInteractionMode(savedMode);
} else {
setInteractionMode('text-text');
}
} else {
setInteractionMode('text-text');
}
// Voice name
var savedVoice = localStorage.getItem('hikari_voice_name');
if (savedVoice) {
_selectedVoiceName = savedVoice;
if (voiceSelect) voiceSelect.value = savedVoice;
}
// Chat history enabled
var savedHistEnabled = localStorage.getItem('hikari_chat_history_enabled');
if (savedHistEnabled !== null) {
var histEnabled = savedHistEnabled === '1';
_chatHistoryEnabled = histEnabled;
if (historyToggle) historyToggle.checked = histEnabled;
}
// Chat history max turns
var savedMaxTurns = localStorage.getItem('hikari_chat_history_max_turns');
if (savedMaxTurns !== null) {
var maxTurns = Math.max(0, parseInt(savedMaxTurns, 10) || 0);
_chatHistoryMaxTurns = maxTurns;
if (historyMaxTurns) historyMaxTurns.value = maxTurns;
}
// LLM thinking
var savedThinking = localStorage.getItem('hikari_llm_thinking');
if (savedThinking !== null) {
var thinkingOn = savedThinking === '1';
if (typeof setLLMThinkingEnabled === 'function') setLLMThinkingEnabled(thinkingOn);
if (thinkingToggle) thinkingToggle.checked = thinkingOn;
}
// Voice output toggle UI sync
if (voiceOutToggle) voiceOutToggle.checked = isVoiceOutputEnabled();
// Voice input toggle UI sync
if (voiceInToggle) voiceInToggle.checked = isVoiceInputEnabled();
} catch (e) {
console.warn('[initializeApp] Lỗi load settings từ localStorage:', e);
setInteractionMode('text-text');
}
})();
// === Retention Policy Settings ===
var retentionModeSelect = document.getElementById('retention-mode-select');
var retentionCountSection = document.getElementById('retention-count-section');
var retentionDaysSection = document.getElementById('retention-days-section');
var retentionMaxCount = document.getElementById('retention-max-count');
var retentionMaxDays = document.getElementById('retention-max-days');
// Populate UI từ localStorage
if (typeof getRetentionConfig === 'function') {
var retCfg = getRetentionConfig();
if (retentionModeSelect) retentionModeSelect.value = retCfg.mode;
if (retCfg.mode === 'days') {
if (retentionCountSection) retentionCountSection.classList.add('hidden');
if (retentionDaysSection) retentionDaysSection.classList.remove('hidden');
if (retentionMaxDays) retentionMaxDays.value = retCfg.value;
} else {
if (retentionCountSection) retentionCountSection.classList.remove('hidden');
if (retentionDaysSection) retentionDaysSection.classList.add('hidden');
if (retentionMaxCount) retentionMaxCount.value = retCfg.value;
}
}
if (retentionModeSelect) {
retentionModeSelect.addEventListener('change', function (e) {
var mode = e.target.value;
if (mode === 'days') {
if (retentionCountSection) retentionCountSection.classList.add('hidden');
if (retentionDaysSection) retentionDaysSection.classList.remove('hidden');
var days = retentionMaxDays ? (parseInt(retentionMaxDays.value, 10) || 30) : 30;
if (typeof setRetentionConfig === 'function') setRetentionConfig('days', days);
} else {
if (retentionCountSection) retentionCountSection.classList.remove('hidden');
if (retentionDaysSection) retentionDaysSection.classList.add('hidden');
var count = retentionMaxCount ? (parseInt(retentionMaxCount.value, 10) || 50) : 50;
if (typeof setRetentionConfig === 'function') setRetentionConfig('count', count);
}
});
}
if (retentionMaxCount) {
retentionMaxCount.addEventListener('change', function (e) {
var val = parseInt(e.target.value, 10) || 50;
if (typeof setRetentionConfig === 'function') setRetentionConfig('count', val);
});
}
if (retentionMaxDays) {
retentionMaxDays.addEventListener('change', function (e) {
var val = parseInt(e.target.value, 10) || 30;
if (typeof setRetentionConfig === 'function') setRetentionConfig('days', val);
});
}
}
// Tự động khởi tạo khi DOM sẵn sàng (chỉ trong trình duyệt, không chạy trong môi trường test)
if (typeof document !== 'undefined' && typeof module === 'undefined') {
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initializeApp);
} else {
initializeApp();
}
}
// ============================================================
// Voice Input / Output + Interaction Mode
// ============================================================
/**
* Trạng thái Interaction Mode.
* 'text-text' | 'text-voice' | 'voice-text' | 'voice-voice'
*/
var _interactionMode = 'text-text';
/** Tên voice TTS đã chọn (null = dùng default). */
var _selectedVoiceName = null;
/**
* Lấy chế độ tương tác hiện tại.
* @returns {string}
*/
function getInteractionMode() {
return _interactionMode;
}
/**
* Cycle qua các interaction modes (text-text → text-voice → voice-text → voice-voice → text-text).
* Bỏ qua các mode không được hỗ trợ (thiếu STT hoặc TTS).
*/
function cycleInteractionMode() {
var modes = ['text-text', 'text-voice', 'voice-text', 'voice-voice'];
var currentIndex = modes.indexOf(_interactionMode);
// Kiểm tra voice support
var voiceSupport = { sttSupported: false, ttsSupported: false };
if (typeof initVoiceAdapter === 'function') {
voiceSupport = initVoiceAdapter();
}
// Tìm mode tiếp theo được hỗ trợ
var nextIndex = (currentIndex + 1) % modes.length;
var attempts = 0;
while (attempts < modes.length) {
var nextMode = modes[nextIndex];
var needsSTT = nextMode.startsWith('voice-');
var needsTTS = nextMode.endsWith('-voice');
// Kiểm tra xem mode này có được hỗ trợ không
if ((!needsSTT || voiceSupport.sttSupported) && (!needsTTS || voiceSupport.ttsSupported)) {
console.log('[cycleInteractionMode] Switching from', _interactionMode, 'to', nextMode);
setInteractionMode(nextMode);
return;
}
// Thử mode tiếp theo
nextIndex = (nextIndex + 1) % modes.length;
attempts++;
}
// Nếu không tìm được mode nào khác, giữ nguyên
console.warn('[cycleInteractionMode] No other supported modes found, staying at', _interactionMode);
}
/**
* Kiểm tra voice input có được bật không.
* @returns {boolean}
*/
function isVoiceInputEnabled() {
return _interactionMode.startsWith('voice-');
}
/**
* Kiểm tra voice output có được bật không.
* @returns {boolean}
*/
function isVoiceOutputEnabled() {
return _interactionMode.endsWith('-voice');
}
/**
* Đặt chế độ tương tác, cập nhật UI.
* @param {string} mode - 'text-text' | 'text-voice' | 'voice-text' | 'voice-voice'
*/
function setInteractionMode(mode) {
console.log('[setInteractionMode] Setting mode to:', mode);
_interactionMode = mode;
try { localStorage.setItem('hikari_interaction_mode', mode); } catch (e) {}
// Hiện/ẩn nút microphone
var micBtn = document.getElementById('voice-input-button');
if (micBtn) {
var shouldShow = isVoiceInputEnabled();
console.log('[setInteractionMode] Voice input enabled:', shouldShow);
micBtn.classList.toggle('hidden', !shouldShow);
} else {
console.warn('[setInteractionMode] voice-input-button not found');
}
// Cập nhật badge
var badge = document.getElementById('interaction-mode-badge');
if (badge) {
var badges = {
'text-text': '📝→📝',
'text-voice': '📝→🔊',
'voice-text': '🎤→📝',
'voice-voice': '🎤→🔊'
};
badge.textContent = badges[mode] || '📝→📝';
badge.title = mode;
console.log('[setInteractionMode] Badge updated to:', badge.textContent);
} else {
console.warn('[setInteractionMode] interaction-mode-badge not found');
}
// Sync select nếu có
var modeSelect = document.getElementById('interaction-mode-select');
if (modeSelect && modeSelect.value !== mode) {
modeSelect.value = mode;
console.log('[setInteractionMode] Select synced to:', mode);
}
console.log('[setInteractionMode] Mode set successfully. Voice input enabled:', isVoiceInputEnabled(), 'Voice output enabled:', isVoiceOutputEnabled());
}
/**
* Đọc text bằng TTS nếu voice output đang bật.
* Gọi thẳng speakText từ voice-adapter.js (đã load qua script tag).
* @param {string} text
*/
function onBotReplyReady(text) {
if (!isVoiceOutputEnabled()) return;
// speakText được định nghĩa trong voice-adapter.js — không wrap lại để tránh đệ quy
if (typeof speakText === 'function') {
speakText(text, currentLang, _selectedVoiceName);
}
}
/**
* Dừng TTS — delegate sang voice-adapter.js.
*/
function stopSpeaking() {
if (typeof window !== 'undefined' && window.speechSynthesis) {
window.speechSynthesis.cancel();
}
}
/**
* Cập nhật dropdown chọn voice theo ngôn ngữ.
* @param {string} lang
*/
function updateVoiceSelector(lang) {
var select = document.getElementById('tts-voice-select');
if (!select) return;
var voices = (typeof getVoicesForLang === 'function') ? getVoicesForLang(lang) : [];
select.innerHTML = '';
if (voices.length === 0) {
var opt = document.createElement('option');
opt.value = '';
opt.textContent = lang === 'en' ? '(No voices available)' : lang === 'ja' ? '(音声なし)' : '(Không có giọng)';
select.appendChild(opt);
return;
}
voices.forEach(function (v) {
var opt = document.createElement('option');
opt.value = v.name;
opt.textContent = v.name + (v.localService ? ' ★' : '');
select.appendChild(opt);
});
// Chọn default voice
var defaultVoice = (typeof getDefaultVoice === 'function') ? getDefaultVoice(lang) : null;
if (defaultVoice) {
select.value = defaultVoice.name;
_selectedVoiceName = defaultVoice.name;
}
}
/**
* Bắt đầu nhận diện giọng nói.
* Dừng TTS trước để tránh feedback loop (voice→voice mode).
*/
function startVoiceInput() {
// Dừng TTS trước (tránh feedback loop trong voice→voice)
stopSpeaking();
var micBtn = document.getElementById('voice-input-button');
var msgInput = document.getElementById('message-input');
var prevPlaceholder = msgInput ? msgInput.placeholder : '';
if (micBtn) micBtn.classList.add('voice-listening');
if (typeof window !== 'undefined' && typeof window._voiceAdapterStartVoiceInput === 'function') {
window._voiceAdapterStartVoiceInput(
currentLang,
// onInterim: hiển thị trạng thái vào placeholder (không ghi đè text đang nhập)
function (statusText) {
if (msgInput) {
msgInput.placeholder = statusText;
msgInput.disabled = true;
}
},
// onFinal: điền kết quả và gửi
function (text) {
if (micBtn) micBtn.classList.remove('voice-listening');
if (msgInput) {
msgInput.disabled = false;
msgInput.placeholder = prevPlaceholder;
msgInput.value = text;
}
sendMessage();
},
// onError
function (err) {
if (micBtn) micBtn.classList.remove('voice-listening');
if (msgInput) {
msgInput.disabled = false;
msgInput.placeholder = prevPlaceholder;
}
console.warn('STT error:', err);
var lang = currentLang || 'vi';
if (err === 'no-speech') return; // không cần thông báo
var msg;
if (err === 'not-allowed') {
msg = lang === 'en' ? '🎤 Microphone access denied. Please allow microphone in browser settings.'
: lang === 'ja' ? '🎤 マイクへのアクセスが拒否されました。'
: '🎤 Trình duyệt chặn microphone. Vui lòng cấp quyền microphone.';
} else if (err && err.startsWith('transcribe-failed')) {
msg = lang === 'en' ? '🎤 Transcription failed. Try again.'
: lang === 'ja' ? '🎤 音声認識に失敗しました。'
: '🎤 Nhận dạng giọng nói thất bại. Thử lại nhé!';
} else {
msg = lang === 'en' ? '🎤 Voice input error: ' + err
: lang === 'ja' ? '🎤 音声入力エラー: ' + err
: '🎤 Lỗi voice input: ' + err;
}
showError(msg);
}
);
} else {
if (micBtn) micBtn.classList.remove('voice-listening');
if (msgInput) { msgInput.disabled = false; msgInput.placeholder = prevPlaceholder; }
console.warn('[startVoiceInput] voice-adapter chưa được load');
}
}
/**
* Dừng nhận diện giọng nói.
*/
function stopVoiceInput() {
var micBtn = document.getElementById('voice-input-button');
if (micBtn) micBtn.classList.remove('voice-listening');
if (typeof window !== 'undefined' && typeof window._voiceAdapterStopVoiceInput === 'function') {
window._voiceAdapterStopVoiceInput();
}
}
// ============================================================
// Export cho testing (module.exports nếu chạy trong Node/Vitest)
// ============================================================
if (typeof module !== 'undefined' && module.exports) {
module.exports = {
validateMessage: validateMessage,
linkifyText: linkifyText,
removeVietnameseDiacritics: removeVietnameseDiacritics,
normalizeInput: normalizeInput,
scrollToBottom: scrollToBottom,
appendMessage: appendMessage,
showError: showError,
calculateConfidence: calculateConfidence,
getConfidenceClass: getConfidenceClass,
BRAIN_DATA: BRAIN_DATA,
initBot: initBot,
changeLanguage: changeLanguage,
GREETING_TRIGGERS: GREETING_TRIGGERS,
sendMessage: sendMessage,
getBotReply: getBotReply,
getFinalFallbackMessage: getFinalFallbackMessage,
extractWebSearchQuery: extractWebSearchQuery,
showLoadingIndicator: showLoadingIndicator,
hideLoadingIndicator: hideLoadingIndicator,
callFallbackAPI: callFallbackAPI,
levenshteinDistance: levenshteinDistance,
jaccardSimilarity: jaccardSimilarity,
cosineSimilarity: cosineSimilarity,
cosineSimilarityTFIDF: cosineSimilarityTFIDF,
synsetSimilarity: synsetSimilarity,
synsetSimilarityPreprocessed: synsetSimilarityPreprocessed,
areSynonyms: areSynonyms,
SYNONYM_GROUPS: SYNONYM_GROUPS,
textSimilarity: textSimilarity,
loadPreprocessedData: loadPreprocessedData,
getPreprocessedLang: getPreprocessedLang,
findBestMatchPreprocessed: findBestMatchPreprocessed,
tokenizeForSimilarity: tokenizeForSimilarity,
SPECIFIC_RESPONSES: SPECIFIC_RESPONSES,
specificResponseAdapter: specificResponseAdapter,
TIME_KEYWORDS: TIME_KEYWORDS,
DAY_NAMES: DAY_NAMES,
MONTH_NAMES_EN: MONTH_NAMES_EN,
timeAdapter: timeAdapter,
formatTime: formatTime,
formatDate: formatDate,
formatDay: formatDay,
parseMathExpression: parseMathExpression,
mathematicalEvaluationAdapter: mathematicalEvaluationAdapter,
CONVERSION_FACTORS: CONVERSION_FACTORS,
TEMPERATURE_UNITS: TEMPERATURE_UNITS,
convertTemperature: convertTemperature,
convertUnit: convertUnit,
parseConversionRequest: parseConversionRequest,
getSupportedUnits: getSupportedUnits,
unitConversionAdapter: unitConversionAdapter,
QA_DATASET: QA_DATASET,
bestMatchAdapter: bestMatchAdapter,
INVALID_RESPONSE_PHRASES: INVALID_RESPONSE_PHRASES,
isValidAdapterResult: isValidAdapterResult,
logicAdapterDispatcher: logicAdapterDispatcher,
ADAPTER_REGISTRY: ADAPTER_REGISTRY,
ADAPTER_DISPLAY_NAMES: ADAPTER_DISPLAY_NAMES,
getAdapterDisplayName: getAdapterDisplayName,
get _adapterPath() { return _adapterPath; },
set _adapterPath(val) { _adapterPath = val; },
registerAdapters: registerAdapters,
webSearchAdapter: webSearchAdapter,
googleSearch: googleSearch,
duckDuckGoSearch: duckDuckGoSearch,
llmAdapter: llmAdapter,
loadLLMModel: loadLLMModel,
llmGenerate: llmGenerate,
llmGenerateWithImage: llmGenerateWithImage,
isWebGPUSupported: isWebGPUSupported,
isLLMReady: isLLMReady,
getLLMStatus: getLLMStatus,
getLLMLastError: getLLMLastError,
cancelLLMGeneration: cancelLLMGeneration,
isLLMGenerating: isLLMGenerating,
showLLMCancelButton: showLLMCancelButton,
hideLLMCancelButton: hideLLMCancelButton,
createStreamingBotMessage: createStreamingBotMessage,
createStreamingCallback: createStreamingCallback,
finalizeStreamingMessage: finalizeStreamingMessage,
removeStreamingMessage: removeStreamingMessage,
setLLMModelId: setLLMModelId,
setLLMThinkingEnabled: setLLMThinkingEnabled,
isLLMThinkingEnabled: isLLMThinkingEnabled,
setSendingDisabled: setSendingDisabled,
setSendingEnabled: setSendingEnabled,
showLLMLoadingStatus: showLLMLoadingStatus,
hideLLMLoadingStatus: hideLLMLoadingStatus,
onLLMStatusChange: onLLMStatusChange,
setLLMStatusCallback: setLLMStatusCallback,
handleFileAttachment: handleFileAttachment,
clearAttachment: clearAttachment,
consumeAttachment: consumeAttachment,
get _attachedImage() { return _attachedImage; },
set _attachedImage(val) { _attachedImage = val; },
toggleMacrosPanel: toggleMacrosPanel,
toggleSettingsPanel: toggleSettingsPanel,
openHistoryDialog: openHistoryDialog,
closeHistoryDialog: closeHistoryDialog,
clearAllHistory: clearAllHistory,
loadRecentHistoryToChat: loadRecentHistoryToChat,
addChatHistory: addChatHistory,
getChatHistoryForLLM: getChatHistoryForLLM,
clearChatHistory: clearChatHistory,
setChatHistoryEnabled: setChatHistoryEnabled,
isChatHistoryEnabled: isChatHistoryEnabled,
setChatHistoryMaxTurns: setChatHistoryMaxTurns,
getChatHistoryMaxTurns: getChatHistoryMaxTurns,
get _chatHistory() { return _chatHistory; },
updateMacrosList: updateMacrosList,
toggleRulesPanel: toggleRulesPanel,
updateRulesList: updateRulesList,
formatTrigger: formatTrigger,
initializeApp: initializeApp,
// Expose state for testing
get bot() { return bot; },
set bot(val) { bot = val; },
get currentLang() { return currentLang; },
set currentLang(val) { currentLang = val; },
USERNAME: USERNAME,
FALLBACK_API_URL: FALLBACK_API_URL,
FALLBACK_API_TIMEOUT: FALLBACK_API_TIMEOUT,
LLM_MODEL_ID_CONFIG: LLM_MODEL_ID_CONFIG,
// Voice + Interaction Mode
getInteractionMode: getInteractionMode,
setInteractionMode: setInteractionMode,
cycleInteractionMode: cycleInteractionMode,
isVoiceInputEnabled: isVoiceInputEnabled,
isVoiceOutputEnabled: isVoiceOutputEnabled,
onBotReplyReady: onBotReplyReady,
stopSpeaking: stopSpeaking,
startVoiceInput: startVoiceInput,
stopVoiceInput: stopVoiceInput,
updateVoiceSelector: updateVoiceSelector,
get _interactionMode() { return _interactionMode; },
get _selectedVoiceName() { return _selectedVoiceName; }
};
}