mr4 commited on
Commit
4b05d12
·
verified ·
1 Parent(s): 16702cd

Upload 27 files

Browse files
Files changed (5) hide show
  1. adapters/language-selector.js +153 -0
  2. adapters/voice-adapter.js +203 -47
  3. app.js +46 -6
  4. index.html +5 -0
  5. style.css +39 -0
adapters/language-selector.js ADDED
@@ -0,0 +1,153 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // ============================================================
2
+ // Language Selector — Ánh xạ ngôn ngữ tập trung
3
+ // Tái sử dụng cho Voice, LLM, và các adapter khác
4
+ // ============================================================
5
+
6
+ /**
7
+ * Ánh xạ ngôn ngữ app → locale (BCP 47 format).
8
+ * Dùng cho Web Speech API, TTS, STT.
9
+ */
10
+ var SPEECH_LOCALE_MAP = {
11
+ vi: 'vi-VN',
12
+ en: 'en-US',
13
+ ja: 'ja-JP'
14
+ };
15
+
16
+ /**
17
+ * Ánh xạ ngôn ngữ app → Whisper language code (ISO 639-1).
18
+ * Dùng cho STT (Whisper ONNX).
19
+ */
20
+ var WHISPER_LANG_MAP = {
21
+ vi: 'vietnamese',
22
+ en: 'english',
23
+ ja: 'japanese'
24
+ };
25
+
26
+ /**
27
+ * Ánh xạ ngôn ngữ app → LLM language code.
28
+ * Dùng cho LLM adapter (Ollama, Hugging Face, v.v.).
29
+ */
30
+ var LLM_LANG_MAP = {
31
+ vi: 'vi',
32
+ en: 'en',
33
+ ja: 'ja'
34
+ };
35
+
36
+ /**
37
+ * Danh sách ngôn ngữ được hỗ trợ.
38
+ */
39
+ var SUPPORTED_LANGUAGES = ['vi', 'en', 'ja'];
40
+
41
+ /**
42
+ * Lấy locale từ language code.
43
+ * @param {string} lang - Language code (vi, en, ja)
44
+ * @returns {string} Locale (vi-VN, en-US, ja-JP)
45
+ */
46
+ function getLocale(lang) {
47
+ return SPEECH_LOCALE_MAP[lang] || lang;
48
+ }
49
+
50
+ /**
51
+ * Lấy Whisper language code từ language code.
52
+ * @param {string} lang - Language code (vi, en, ja)
53
+ * @returns {string} Whisper language code (vietnamese, english, japanese)
54
+ */
55
+ function getWhisperLang(lang) {
56
+ return WHISPER_LANG_MAP[lang] || 'vietnamese';
57
+ }
58
+
59
+ /**
60
+ * Lấy LLM language code từ language code.
61
+ * @param {string} lang - Language code (vi, en, ja)
62
+ * @returns {string} LLM language code (vi, en, ja)
63
+ */
64
+ function getLLMLang(lang) {
65
+ return LLM_LANG_MAP[lang] || 'vi';
66
+ }
67
+
68
+ /**
69
+ * Kiểm tra ngôn ngữ có được hỗ trợ không.
70
+ * @param {string} lang - Language code
71
+ * @returns {boolean}
72
+ */
73
+ function isLanguageSupported(lang) {
74
+ return SUPPORTED_LANGUAGES.includes(lang);
75
+ }
76
+
77
+ /**
78
+ * Lấy ngôn ngữ mặc định.
79
+ * @returns {string} Language code (vi)
80
+ */
81
+ function getDefaultLanguage() {
82
+ return 'vi';
83
+ }
84
+
85
+ /**
86
+ * Lấy danh sách ngôn ngữ được hỗ trợ.
87
+ * @returns {array} Danh sách language codes
88
+ */
89
+ function getSupportedLanguages() {
90
+ return SUPPORTED_LANGUAGES.slice();
91
+ }
92
+
93
+ /**
94
+ * Lưu ngôn ngữ vào localStorage.
95
+ * @param {string} lang - Language code
96
+ */
97
+ function saveLanguageToStorage(lang) {
98
+ if (typeof window !== 'undefined' && window.localStorage) {
99
+ try {
100
+ window.localStorage.setItem('selectedLanguage', lang);
101
+ console.log('[LanguageSelector] Saved language to localStorage:', lang);
102
+ } catch (err) {
103
+ console.error('[LanguageSelector] Error saving to localStorage:', err);
104
+ }
105
+ }
106
+ }
107
+
108
+ /**
109
+ * Lấy ngôn ngữ từ localStorage.
110
+ * @returns {string|null} Language code hoặc null nếu không có
111
+ */
112
+ function getLanguageFromStorage() {
113
+ if (typeof window !== 'undefined' && window.localStorage) {
114
+ try {
115
+ var lang = window.localStorage.getItem('selectedLanguage');
116
+ return lang || null;
117
+ } catch (err) {
118
+ console.error('[LanguageSelector] Error reading from localStorage:', err);
119
+ return null;
120
+ }
121
+ }
122
+ return null;
123
+ }
124
+
125
+ /**
126
+ * Callback khi ngôn ngữ thay đổi.
127
+ * Lưu vào localStorage và có thể được override bởi các adapter để reload state.
128
+ * @param {string} lang - Language code mới
129
+ */
130
+ function onLanguageChange(lang) {
131
+ console.log('[LanguageSelector] Language changed to:', lang);
132
+ saveLanguageToStorage(lang);
133
+ // Các adapter có thể override hàm này để reload state
134
+ }
135
+
136
+ // ============================================================
137
+ // Export cho Node/test
138
+ // ============================================================
139
+ if (typeof module !== 'undefined' && module.exports) {
140
+ globalThis.SPEECH_LOCALE_MAP = SPEECH_LOCALE_MAP;
141
+ globalThis.WHISPER_LANG_MAP = WHISPER_LANG_MAP;
142
+ globalThis.LLM_LANG_MAP = LLM_LANG_MAP;
143
+ globalThis.SUPPORTED_LANGUAGES = SUPPORTED_LANGUAGES;
144
+ globalThis.getLocale = getLocale;
145
+ globalThis.getWhisperLang = getWhisperLang;
146
+ globalThis.getLLMLang = getLLMLang;
147
+ globalThis.isLanguageSupported = isLanguageSupported;
148
+ globalThis.getDefaultLanguage = getDefaultLanguage;
149
+ globalThis.getSupportedLanguages = getSupportedLanguages;
150
+ globalThis.saveLanguageToStorage = saveLanguageToStorage;
151
+ globalThis.getLanguageFromStorage = getLanguageFromStorage;
152
+ globalThis.onLanguageChange = onLanguageChange;
153
+ }
adapters/voice-adapter.js CHANGED
@@ -1,23 +1,14 @@
1
  // ============================================================
2
- // Voice Adapter — STT (Whisper ONNX via Transformers.js) + TTS (Web Speech API)
3
  // STT: chạy offline trong browser, không cần server
4
- // TTS: Web Speech API native
5
  // ============================================================
6
 
7
- /**
8
- * Ánh xạ ngôn ngữ app → locale / Whisper language code.
9
- */
10
- var SPEECH_LOCALE_MAP = {
11
- vi: 'vi-VN',
12
- en: 'en-US',
13
- ja: 'ja-JP'
14
- };
15
-
16
- // Whisper language codes (ISO 639-1)
17
- var WHISPER_LANG_MAP = {
18
- vi: 'vietnamese',
19
- en: 'english',
20
- ja: 'japanese'
21
  };
22
 
23
  // === Trạng thái nội bộ ===
@@ -28,6 +19,10 @@ var _mediaRecorder = null; // MediaRecorder instance
28
  var _audioChunks = []; // Chunks audio thu được
29
  var _whisperPipeline = null; // Transformers.js pipeline (lazy load)
30
  var _whisperLoading = false; // Đang load model
 
 
 
 
31
 
32
  // Whisper model — nhỏ, đa ngôn ngữ, chạy được trên browser
33
  var WHISPER_MODEL_ID = 'onnx-community/whisper-base';
@@ -46,30 +41,128 @@ function initVoiceAdapter() {
46
  );
47
  _ttsSupported = !!(
48
  typeof window !== 'undefined' &&
49
- window.speechSynthesis
50
  );
51
  return { sttSupported: _sttSupported, ttsSupported: _ttsSupported };
52
  }
53
 
54
  // ============================================================
55
- // TTS — Text to Speech (Web Speech API, giữ nguyên)
56
  // ============================================================
57
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
58
  function getVoicesForLang(lang) {
59
- if (!_ttsSupported) return [];
60
- var locale = SPEECH_LOCALE_MAP[lang] || lang;
61
- var prefix = locale.split('-')[0].toLowerCase();
62
- var all = window.speechSynthesis.getVoices();
63
- var filtered = all.filter(function (v) {
64
- var vLang = (v.lang || '').toLowerCase();
65
- return vLang === locale.toLowerCase() || vLang.startsWith(prefix + '-') || vLang.startsWith(prefix + '_');
66
- });
67
- filtered.sort(function (a, b) {
68
- if (a.localService && !b.localService) return -1;
69
- if (!a.localService && b.localService) return 1;
70
- return 0;
71
- });
72
- return filtered;
73
  }
74
 
75
  function getDefaultVoice(lang) {
@@ -77,27 +170,91 @@ function getDefaultVoice(lang) {
77
  return voices.length > 0 ? voices[0] : null;
78
  }
79
 
80
- function speakText(text, lang, voiceName) {
81
  if (!_ttsSupported || !text || !text.trim()) return;
82
- window.speechSynthesis.cancel();
83
- var utterance = new SpeechSynthesisUtterance(text);
84
- utterance.lang = SPEECH_LOCALE_MAP[lang] || lang;
85
- var voices = getVoicesForLang(lang);
86
- var selectedVoice = null;
87
- if (voiceName) {
88
- selectedVoice = voices.find(function (v) { return v.name === voiceName; }) || null;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
89
  }
90
- if (!selectedVoice) selectedVoice = getDefaultVoice(lang);
91
- if (selectedVoice) utterance.voice = selectedVoice;
92
- window.speechSynthesis.speak(utterance);
93
  }
94
 
95
  function stopSpeaking() {
96
- if (_ttsSupported) window.speechSynthesis.cancel();
 
 
 
 
 
 
 
 
 
 
 
 
 
97
  }
98
 
99
  function isSpeaking() {
100
- return _ttsSupported && window.speechSynthesis.speaking;
101
  }
102
 
103
  // ============================================================
@@ -265,7 +422,7 @@ async function startVoiceInput(lang, onInterim, onFinal, onError) {
265
  });
266
 
267
  var audioData = await _decodeAudioBlob(audioBlob);
268
- var whisperLang = WHISPER_LANG_MAP[lang] || 'vietnamese';
269
 
270
  var result = await pipeline(audioData, {
271
  language: whisperLang,
@@ -337,7 +494,6 @@ if (typeof window !== 'undefined') {
337
  // Export cho Node/test (stub functions)
338
  // ============================================================
339
  if (typeof module !== 'undefined' && module.exports) {
340
- globalThis.SPEECH_LOCALE_MAP = SPEECH_LOCALE_MAP;
341
  globalThis.initVoiceAdapter = function () { return { sttSupported: false, ttsSupported: false }; };
342
  globalThis.getVoicesForLang = function () { return []; };
343
  globalThis.getDefaultVoice = function () { return null; };
 
1
  // ============================================================
2
+ // Voice Adapter — STT (Whisper ONNX via Transformers.js) + TTS (MMS-TTS via Transformers.js)
3
  // STT: chạy offline trong browser, không cần server
4
+ // TTS: MMS-TTS (Xenova) - hỗ trợ en, vi; fallback Web Speech API cho ja
5
  // ============================================================
6
 
7
+ // TTS Model IDs (Xenova ONNX versions)
8
+ var TTS_MODELS = {
9
+ en: 'Xenova/mms-tts-eng',
10
+ vi: 'Xenova/mms-tts-vie',
11
+ ja: null // Use Web Speech API for Japanese
 
 
 
 
 
 
 
 
 
12
  };
13
 
14
  // === Trạng thái nội bộ ===
 
19
  var _audioChunks = []; // Chunks audio thu được
20
  var _whisperPipeline = null; // Transformers.js pipeline (lazy load)
21
  var _whisperLoading = false; // Đang load model
22
+ var _ttsPipelines = {}; // Cache pipelines per language
23
+ var _ttsModelLoading = {}; // Track loading state per language
24
+ var _isSpeaking = false;
25
+ var _audioContext = null;
26
 
27
  // Whisper model — nhỏ, đa ngôn ngữ, chạy được trên browser
28
  var WHISPER_MODEL_ID = 'onnx-community/whisper-base';
 
41
  );
42
  _ttsSupported = !!(
43
  typeof window !== 'undefined' &&
44
+ (typeof AudioContext !== 'undefined' || typeof window.webkitAudioContext !== 'undefined')
45
  );
46
  return { sttSupported: _sttSupported, ttsSupported: _ttsSupported };
47
  }
48
 
49
  // ============================================================
50
+ // TTS — Text to Speech (MMS-TTS via Transformers.js + Web Speech API fallback)
51
  // ============================================================
52
 
53
+ /**
54
+ * Lazy-load TTS pipeline cho ngôn ngữ.
55
+ * @param {string} lang - Language code (en, vi, ja)
56
+ * @param {function} onStatus - Callback(message) để hiển thị trạng thái loading
57
+ * @returns {Promise<object>} Transformers.js pipeline hoặc null nếu fallback
58
+ */
59
+ async function _getTTSPipeline(lang, onStatus) {
60
+ var langCode = getLLMLang(lang);
61
+ var modelId = TTS_MODELS[langCode];
62
+
63
+ // Fallback to Web Speech API cho tiếng Nhật
64
+ if (!modelId) {
65
+ console.log('[TTS] No model for', langCode, '- using Web Speech API fallback');
66
+ return null;
67
+ }
68
+
69
+ if (_ttsPipelines[langCode]) return _ttsPipelines[langCode];
70
+ if (_ttsModelLoading[langCode]) {
71
+ // Chờ cho đến khi load xong
72
+ return new Promise(function (resolve, reject) {
73
+ var interval = setInterval(function () {
74
+ if (_ttsPipelines[langCode]) {
75
+ clearInterval(interval);
76
+ resolve(_ttsPipelines[langCode]);
77
+ } else if (!_ttsModelLoading[langCode]) {
78
+ clearInterval(interval);
79
+ reject(new Error('TTS model load failed'));
80
+ }
81
+ }, 200);
82
+ });
83
+ }
84
+
85
+ _ttsModelLoading[langCode] = true;
86
+ if (onStatus) onStatus('⏳ Đang tải TTS model lần đầu (~100MB)...');
87
+ console.log('[TTS] Loading model:', modelId);
88
+
89
+ try {
90
+ var transformers = await import(
91
+ 'https://cdn.jsdelivr.net/npm/@huggingface/transformers@3/dist/transformers.min.js'
92
+ );
93
+
94
+ var pipeline = await transformers.pipeline(
95
+ 'text-to-speech',
96
+ modelId,
97
+ {
98
+ dtype: 'fp32',
99
+ device: 'wasm',
100
+ }
101
+ );
102
+
103
+ _ttsPipelines[langCode] = pipeline;
104
+ _ttsModelLoading[langCode] = false;
105
+ if (onStatus) onStatus('✅ TTS model đã sẵn sàng');
106
+ console.log('[TTS] Model loaded successfully');
107
+ return pipeline;
108
+ } catch (err) {
109
+ _ttsModelLoading[langCode] = false;
110
+ console.error('[TTS] Load error:', err);
111
+ throw err;
112
+ }
113
+ }
114
+
115
+ /**
116
+ * Phát âm thanh từ audio data.
117
+ * @param {Float32Array} audioData - Audio samples
118
+ * @param {number} sampleRate - Sample rate (thường 22050 hoặc 24000)
119
+ */
120
+ function _playAudio(audioData, sampleRate) {
121
+ if (!_audioContext) {
122
+ _audioContext = new (window.AudioContext || window.webkitAudioContext)();
123
+ }
124
+
125
+ var audioBuffer = _audioContext.createBuffer(1, audioData.length, sampleRate);
126
+ var channelData = audioBuffer.getChannelData(0);
127
+ for (var i = 0; i < audioData.length; i++) {
128
+ channelData[i] = audioData[i];
129
+ }
130
+
131
+ var source = _audioContext.createBufferSource();
132
+ source.buffer = audioBuffer;
133
+ source.connect(_audioContext.destination);
134
+ source.onended = function () {
135
+ _isSpeaking = false;
136
+ };
137
+ source.start(0);
138
+ _isSpeaking = true;
139
+ }
140
+
141
+ /**
142
+ * Phát âm thanh bằng Web Speech API (fallback cho tiếng Nhật).
143
+ * @param {string} text - Text to speak
144
+ * @param {string} lang - Language code
145
+ */
146
+ function _speakWithWebSpeechAPI(text, lang) {
147
+ if (!text || !text.trim()) return;
148
+
149
+ try {
150
+ window.speechSynthesis.cancel();
151
+ var utterance = new SpeechSynthesisUtterance(text);
152
+ utterance.lang = getLocale(lang);
153
+ window.speechSynthesis.speak(utterance);
154
+ _isSpeaking = true;
155
+ } catch (err) {
156
+ console.error('[TTS] Web Speech API error:', err);
157
+ }
158
+ }
159
+
160
  function getVoicesForLang(lang) {
161
+ // MMS-TTS hỗ trợ: en, vi
162
+ // Trả về danh sách "voices" (thực tế chỉ là language codes)
163
+ var langCode = getLLMLang(lang);
164
+ var supported = ['en', 'vi'];
165
+ return supported.includes(langCode) ? [{ name: langCode, lang: lang }] : [];
 
 
 
 
 
 
 
 
 
166
  }
167
 
168
  function getDefaultVoice(lang) {
 
170
  return voices.length > 0 ? voices[0] : null;
171
  }
172
 
173
+ async function speakText(text, lang, voiceName) {
174
  if (!_ttsSupported || !text || !text.trim()) return;
175
+
176
+ var langCode = getLLMLang(lang);
177
+
178
+ try {
179
+ // Show TTS status
180
+ if (typeof showTTSStatus === 'function') {
181
+ showTTSStatus('⏳ Đang tải model TTS...');
182
+ }
183
+
184
+ // Thử dùng TTS model
185
+ var pipeline = await _getTTSPipeline(lang, function (msg) {
186
+ console.log('[TTS]', msg);
187
+ if (typeof showTTSStatus === 'function') {
188
+ showTTSStatus(msg);
189
+ }
190
+ });
191
+
192
+ if (pipeline) {
193
+ // TTS - MMS-TTS không cần speaker embeddings
194
+ if (typeof showTTSStatus === 'function') {
195
+ showTTSStatus('⏳ Đang tạo âm thanh...');
196
+ }
197
+ console.log('[TTS] Generating speech for:', text.substring(0, 50));
198
+
199
+ var result = await pipeline(text);
200
+
201
+ // result.audio là Float32Array, result.sampling_rate là sample rate
202
+ if (result && result.audio) {
203
+ if (typeof showTTSStatus === 'function') {
204
+ showTTSStatus('⏳ Đang phát âm thanh...');
205
+ }
206
+ _playAudio(result.audio, result.sampling_rate || 22050);
207
+
208
+ // Hide status sau 1 giây
209
+ setTimeout(function () {
210
+ if (typeof hideTTSStatus === 'function') {
211
+ hideTTSStatus();
212
+ }
213
+ }, 1000);
214
+ }
215
+ } else {
216
+ // Fallback to Web Speech API
217
+ if (typeof showTTSStatus === 'function') {
218
+ showTTSStatus('⏳ Đang phát âm thanh...');
219
+ }
220
+ _speakWithWebSpeechAPI(text, lang);
221
+
222
+ // Hide status sau 1 giây
223
+ setTimeout(function () {
224
+ if (typeof hideTTSStatus === 'function') {
225
+ hideTTSStatus();
226
+ }
227
+ }, 1000);
228
+ }
229
+ } catch (err) {
230
+ console.error('[TTS] Error:', err);
231
+ if (typeof hideTTSStatus === 'function') {
232
+ hideTTSStatus();
233
+ }
234
+ // Fallback to Web Speech API on error
235
+ _speakWithWebSpeechAPI(text, lang);
236
  }
 
 
 
237
  }
238
 
239
  function stopSpeaking() {
240
+ _isSpeaking = false;
241
+
242
+ // Stop Web Speech API
243
+ if (typeof window !== 'undefined' && window.speechSynthesis) {
244
+ window.speechSynthesis.cancel();
245
+ }
246
+
247
+ // Stop Web Audio API
248
+ if (_audioContext) {
249
+ try {
250
+ _audioContext.close();
251
+ } catch (e) {}
252
+ _audioContext = null;
253
+ }
254
  }
255
 
256
  function isSpeaking() {
257
+ return _isSpeaking || (typeof window !== 'undefined' && window.speechSynthesis && window.speechSynthesis.speaking);
258
  }
259
 
260
  // ============================================================
 
422
  });
423
 
424
  var audioData = await _decodeAudioBlob(audioBlob);
425
+ var whisperLang = getWhisperLang(lang);
426
 
427
  var result = await pipeline(audioData, {
428
  language: whisperLang,
 
494
  // Export cho Node/test (stub functions)
495
  // ============================================================
496
  if (typeof module !== 'undefined' && module.exports) {
 
497
  globalThis.initVoiceAdapter = function () { return { sttSupported: false, ttsSupported: false }; };
498
  globalThis.getVoicesForLang = function () { return []; };
499
  globalThis.getDefaultVoice = function () { return null; };
app.js CHANGED
@@ -732,6 +732,29 @@ function showError(message) {
732
  appendMessage(message, 'bot');
733
  }
734
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
735
  /**
736
  * Tính tỉ lệ khớp (confidence) dựa trên trigger đã match.
737
  * - Trigger chính xác (không chứa wildcard) → 100
@@ -871,6 +894,12 @@ async function initBot(lang) {
871
  */
872
  async function changeLanguage(lang) {
873
  currentLang = lang;
 
 
 
 
 
 
874
  await initBot(lang);
875
 
876
  // Xóa lịch sử hội thoại
@@ -1095,7 +1124,7 @@ async function sendMessage() {
1095
  return;
1096
  }
1097
 
1098
- // Hiển thị tin nhắn người dùng (kèm ảnh nếu có)
1099
  appendMessage(text || '', 'user', undefined, undefined, undefined, attachment ? attachment.dataURL : undefined);
1100
 
1101
  // Lưu user message vào history (nếu có ảnh mà không có text, ghi chú [image])
@@ -1997,16 +2026,27 @@ async function initializeApp() {
1997
  await loadPreprocessedData();
1998
  }
1999
 
2000
- // Khởi tạo bot với tiếng Việt (ngôn ngữ mặc định)
2001
- await initBot('vi');
 
 
 
 
 
 
 
 
 
 
 
2002
 
2003
  // Hiển thị lời chào khởi tạo
2004
  if (bot) {
2005
- var greeting = await bot.reply(USERNAME, GREETING_TRIGGERS['vi']);
2006
  _skipHistoryOnce = true;
2007
  appendMessage(greeting, 'bot');
2008
- updateRulesList('vi');
2009
- updateMacrosList('vi');
2010
  }
2011
 
2012
  // Load 10 messages gần nhất từ IndexedDB
 
732
  appendMessage(message, 'bot');
733
  }
734
 
735
+ /**
736
+ * Hiển thị TTS processing status.
737
+ * @param {string} message - Status message
738
+ */
739
+ function showTTSStatus(message) {
740
+ var ttsStatus = document.getElementById('tts-status');
741
+ var ttsStatusText = document.getElementById('tts-status-text');
742
+ if (ttsStatus && ttsStatusText) {
743
+ ttsStatusText.textContent = message || 'Đang xử lý âm thanh...';
744
+ ttsStatus.classList.remove('hidden');
745
+ }
746
+ }
747
+
748
+ /**
749
+ * Ẩn TTS processing status.
750
+ */
751
+ function hideTTSStatus() {
752
+ var ttsStatus = document.getElementById('tts-status');
753
+ if (ttsStatus) {
754
+ ttsStatus.classList.add('hidden');
755
+ }
756
+ }
757
+
758
  /**
759
  * Tính tỉ lệ khớp (confidence) dựa trên trigger đã match.
760
  * - Trigger chính xác (không chứa wildcard) → 100
 
894
  */
895
  async function changeLanguage(lang) {
896
  currentLang = lang;
897
+
898
+ // Notify language selector
899
+ if (typeof onLanguageChange === 'function') {
900
+ onLanguageChange(lang);
901
+ }
902
+
903
  await initBot(lang);
904
 
905
  // Xóa lịch sử hội thoại
 
1124
  return;
1125
  }
1126
 
1127
+ // Hiển thị tin nhắn người dùng NGAY LẬP TỨC (kèm ảnh nếu có)
1128
  appendMessage(text || '', 'user', undefined, undefined, undefined, attachment ? attachment.dataURL : undefined);
1129
 
1130
  // Lưu user message vào history (nếu có ảnh mà không có text, ghi chú [image])
 
2026
  await loadPreprocessedData();
2027
  }
2028
 
2029
+ // Lấy ngôn ngữ từ localStorage, nếu không thì dùng mặc định
2030
+ var savedLang = (typeof getLanguageFromStorage === 'function') ? getLanguageFromStorage() : null;
2031
+ var initialLang = (savedLang && (typeof isLanguageSupported === 'function' ? isLanguageSupported(savedLang) : true)) ? savedLang : 'vi';
2032
+ currentLang = initialLang;
2033
+
2034
+ // Cập nhật language selector UI
2035
+ var langSelector = document.getElementById('language-selector');
2036
+ if (langSelector) {
2037
+ langSelector.value = initialLang;
2038
+ }
2039
+
2040
+ // Khởi tạo bot với ngôn ngữ đã lưu
2041
+ await initBot(initialLang);
2042
 
2043
  // Hiển thị lời chào khởi tạo
2044
  if (bot) {
2045
+ var greeting = await bot.reply(USERNAME, GREETING_TRIGGERS[initialLang]);
2046
  _skipHistoryOnce = true;
2047
  appendMessage(greeting, 'bot');
2048
+ updateRulesList(initialLang);
2049
+ updateMacrosList(initialLang);
2050
  }
2051
 
2052
  // Load 10 messages gần nhất từ IndexedDB
index.html CHANGED
@@ -121,6 +121,10 @@
121
  </div>
122
  </div>
123
  <div id="message-display" class="message-display"></div>
 
 
 
 
124
  <div class="input-area">
125
  <button id="attach-button" title="Đính kèm ảnh" aria-label="Đính kèm ảnh">📎</button>
126
  <input type="file" id="file-input" accept="image/*" class="hidden">
@@ -170,6 +174,7 @@
170
  <script src="brain.js"></script>
171
  <script src="data-loader.js"></script>
172
  <script src="data/chat-history-db.js"></script>
 
173
  <script src="adapters/text-similarity.js"></script>
174
  <script src="adapters/specific-response.js"></script>
175
  <script src="adapters/time-adapter.js"></script>
 
121
  </div>
122
  </div>
123
  <div id="message-display" class="message-display"></div>
124
+ <div id="tts-status" class="tts-status hidden" aria-live="polite">
125
+ <span class="tts-spinner">⏳</span>
126
+ <span id="tts-status-text">Đang xử lý âm thanh...</span>
127
+ </div>
128
  <div class="input-area">
129
  <button id="attach-button" title="Đính kèm ảnh" aria-label="Đính kèm ảnh">📎</button>
130
  <input type="file" id="file-input" accept="image/*" class="hidden">
 
174
  <script src="brain.js"></script>
175
  <script src="data-loader.js"></script>
176
  <script src="data/chat-history-db.js"></script>
177
+ <script src="adapters/language-selector.js"></script>
178
  <script src="adapters/text-similarity.js"></script>
179
  <script src="adapters/specific-response.js"></script>
180
  <script src="adapters/time-adapter.js"></script>
style.css CHANGED
@@ -1081,3 +1081,42 @@ body {
1081
  object-fit: cover;
1082
  border: 1px solid #dee2e6;
1083
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1081
  object-fit: cover;
1082
  border: 1px solid #dee2e6;
1083
  }
1084
+
1085
+
1086
+ /* === TTS Status Indicator === */
1087
+ .tts-status {
1088
+ display: flex;
1089
+ align-items: center;
1090
+ gap: 8px;
1091
+ padding: 8px 12px;
1092
+ background-color: #fff3cd;
1093
+ border-bottom: 1px solid #ffc107;
1094
+ color: #856404;
1095
+ font-size: 0.875rem;
1096
+ animation: slideDown 0.3s ease-out;
1097
+ }
1098
+
1099
+ .tts-status.hidden {
1100
+ display: none;
1101
+ }
1102
+
1103
+ .tts-spinner {
1104
+ display: inline-block;
1105
+ animation: spin 1s linear infinite;
1106
+ }
1107
+
1108
+ @keyframes spin {
1109
+ 0% { transform: rotate(0deg); }
1110
+ 100% { transform: rotate(360deg); }
1111
+ }
1112
+
1113
+ @keyframes slideDown {
1114
+ from {
1115
+ opacity: 0;
1116
+ transform: translateY(-10px);
1117
+ }
1118
+ to {
1119
+ opacity: 1;
1120
+ transform: translateY(0);
1121
+ }
1122
+ }