VirtualKimi commited on
Commit
79fe2c6
Β·
verified Β·
1 Parent(s): 82d9664

Upload 30 files

Browse files
CHANGELOG.md CHANGED
@@ -1,13 +1,24 @@
1
  # Virtual Kimi Changelog
2
 
3
- # [1.1.0] - 2025-08-28
4
 
5
- ### Added
6
 
7
- - **Recommended LLMs**: Updated the list of recommended LLM models to reflect current recommendations and improvements.
 
 
 
 
 
 
 
 
 
8
 
9
  ### Changed
10
 
 
 
11
  - **Settings modal UI/UX**: Updated tab layout and visual behavior in the settings modal for clearer navigation and improved usability.
12
 
13
  ### Fixed
 
1
  # Virtual Kimi Changelog
2
 
3
+ # [1.1.1] - 2025-08-29
4
 
5
+ ### Improvements
6
 
7
+ - Improved language and voice selection logic: normalization, fallback, and robust preference management across all modules.
8
+ - Enhanced voice compatibility and ensured consistent language handling.
9
+
10
+ ### Bug Fixes
11
+
12
+ - Fixed issue where videos could freeze after opening or closing the memory modal or changing memory sections.
13
+ - Added automatic reset to neutral video state after UI interactions to prevent stuck/frozen videos.
14
+ - Fixed import/export functions for preferences and data to ensure exported files can be re-imported correctly.
15
+
16
+ # [1.1.0] - 2025-08-28
17
 
18
  ### Changed
19
 
20
+ - **Recommended LLMs**: Updated the list of recommended LLM models to reflect current recommendations and improvements.
21
+
22
  - **Settings modal UI/UX**: Updated tab layout and visual behavior in the settings modal for clearer navigation and improved usability.
23
 
24
  ### Fixed
index.html CHANGED
@@ -56,8 +56,8 @@
56
  "name": "Jean & Kimi"
57
  },
58
  "dateCreated": "2025-07-16",
59
- "dateModified": "2025-08-28",
60
- "version": "v1.1.0"
61
  }
62
  </script>
63
 
@@ -1072,8 +1072,8 @@
1072
  <h3><i class="fas fa-code"></i> Technical Information</h3>
1073
  <div class="tech-info">
1074
  <p><strong>Created date :</strong> July 16, 2025</p>
1075
- <p><strong>Version :</strong> v1.1.0</p>
1076
- <p><strong>Last update :</strong> August 28, 2025</p>
1077
  <p><strong>Technologies :</strong> HTML5, CSS3, JavaScript ES6+, IndexedDB, Web Speech
1078
  API</p>
1079
  <p><strong>Status :</strong> βœ… Stable and functional</p>
@@ -1126,7 +1126,7 @@
1126
  "name": "Jean & Kimi"
1127
  },
1128
  "dateCreated": "2025-07-16",
1129
- "version": "v1.1.0"
1130
  }
1131
  }
1132
  </script>
 
56
  "name": "Jean & Kimi"
57
  },
58
  "dateCreated": "2025-07-16",
59
+ "dateModified": "2025-08-29",
60
+ "version": "v1.1.1"
61
  }
62
  </script>
63
 
 
1072
  <h3><i class="fas fa-code"></i> Technical Information</h3>
1073
  <div class="tech-info">
1074
  <p><strong>Created date :</strong> July 16, 2025</p>
1075
+ <p><strong>Version :</strong> v1.1.1</p>
1076
+ <p><strong>Last update :</strong> August 29, 2025</p>
1077
  <p><strong>Technologies :</strong> HTML5, CSS3, JavaScript ES6+, IndexedDB, Web Speech
1078
  API</p>
1079
  <p><strong>Status :</strong> βœ… Stable and functional</p>
 
1126
  "name": "Jean & Kimi"
1127
  },
1128
  "dateCreated": "2025-07-16",
1129
+ "version": "v1.1.1"
1130
  }
1131
  }
1132
  </script>
kimi-js/kimi-database.js CHANGED
@@ -89,6 +89,51 @@ class KimiDatabase {
89
  });
90
  }
91
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
92
  async init() {
93
  await this.db.open();
94
  await this.initializeDefaultsIfNeeded();
@@ -357,6 +402,58 @@ class KimiDatabase {
357
  } catch (mErr) {
358
  // Non-blocking: ignore migration error
359
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
360
  } catch {}
361
  }
362
 
@@ -491,6 +588,28 @@ class KimiDatabase {
491
  }
492
  }
493
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
494
  // Cache the result
495
  const cache =
496
  window.KimiCacheManager && typeof window.KimiCacheManager.set === "function" ? window.KimiCacheManager : null;
@@ -768,6 +887,18 @@ class KimiDatabase {
768
  }
769
 
770
  async setPreferencesBatch(prefsArray) {
 
 
 
 
 
 
 
 
 
 
 
 
771
  const numericMap = {
772
  voiceRate: "VOICE_RATE",
773
  voicePitch: "VOICE_PITCH",
@@ -779,7 +910,7 @@ class KimiDatabase {
779
  llmFrequencyPenalty: "LLM_FREQUENCY_PENALTY",
780
  llmPresencePenalty: "LLM_PRESENCE_PENALTY"
781
  };
782
- const batch = prefsArray.map(({ key, value }) => {
783
  if (numericMap[key] && window.KIMI_CONFIG && typeof window.KIMI_CONFIG.validate === "function") {
784
  const validation = window.KIMI_CONFIG.validate(value, numericMap[key]);
785
  if (validation.valid) value = validation.value;
 
89
  });
90
  }
91
 
92
+ async setConversationsBatch(conversationsArray) {
93
+ if (!Array.isArray(conversationsArray)) return;
94
+ try {
95
+ await this.db.conversations.clear();
96
+ if (conversationsArray.length) {
97
+ await this.db.conversations.bulkPut(conversationsArray);
98
+ }
99
+ } catch (error) {
100
+ console.error("Error restoring conversations:", error);
101
+ }
102
+ }
103
+
104
+ async setLLMModelsBatch(modelsArray) {
105
+ if (!Array.isArray(modelsArray)) return;
106
+ try {
107
+ await this.db.llmModels.clear();
108
+ if (modelsArray.length) {
109
+ await this.db.llmModels.bulkPut(modelsArray);
110
+ }
111
+ } catch (error) {
112
+ console.error("Error restoring LLM models:", error);
113
+ }
114
+ }
115
+
116
+ async getAllMemories() {
117
+ try {
118
+ return await this.db.memories.toArray();
119
+ } catch (error) {
120
+ console.warn("Error getting all memories:", error);
121
+ return [];
122
+ }
123
+ }
124
+
125
+ async setAllMemories(memoriesArray) {
126
+ if (!Array.isArray(memoriesArray)) return;
127
+ try {
128
+ await this.db.memories.clear();
129
+ if (memoriesArray.length) {
130
+ await this.db.memories.bulkPut(memoriesArray);
131
+ }
132
+ } catch (error) {
133
+ console.error("Error restoring memories:", error);
134
+ }
135
+ }
136
+
137
  async init() {
138
  await this.db.open();
139
  await this.initializeDefaultsIfNeeded();
 
402
  } catch (mErr) {
403
  // Non-blocking: ignore migration error
404
  }
405
+
406
+ // MIGRATION: Normalize legacy selectedLanguage values to primary subtag (e.g., 'en-US'|'en_US'|'us:en' -> 'en')
407
+ try {
408
+ const langRecord = await this.db.preferences.get("selectedLanguage");
409
+ if (langRecord && typeof langRecord.value === "string") {
410
+ let raw = String(langRecord.value).toLowerCase();
411
+ // handle 'us:en' -> take part after ':'
412
+ if (raw.includes(":")) {
413
+ const parts = raw.split(":");
414
+ raw = parts[parts.length - 1];
415
+ }
416
+ raw = raw.replace("_", "-");
417
+ const primary = raw.includes("-") ? raw.split("-")[0] : raw;
418
+ if (primary && primary !== langRecord.value) {
419
+ await this.db.preferences.put({
420
+ key: "selectedLanguage",
421
+ value: primary,
422
+ updated: new Date().toISOString()
423
+ });
424
+ console.log(`πŸ”§ Migration: Normalized selectedLanguage '${langRecord.value}' -> '${primary}'`);
425
+ }
426
+ }
427
+ } catch (normErr) {
428
+ // Non-blocking
429
+ }
430
+
431
+ // FORCED MIGRATION: Normalize any preference keys containing the word 'language' to primary subtag
432
+ // WARNING: This is destructive by design and will overwrite values without backup as requested.
433
+ try {
434
+ const allPrefs = await this.db.preferences.toArray();
435
+ const langKeyRegex = /\blanguage\b/i;
436
+ let modified = 0;
437
+ for (const p of allPrefs) {
438
+ if (!p || typeof p.key !== "string" || typeof p.value !== "string") continue;
439
+ if (!langKeyRegex.test(p.key)) continue;
440
+ let raw = String(p.value).toLowerCase();
441
+ if (raw.includes(":")) raw = raw.split(":").pop();
442
+ raw = raw.replace("_", "-");
443
+ const primary = raw.includes("-") ? raw.split("-")[0] : raw;
444
+ if (primary && primary !== p.value) {
445
+ await this.db.preferences.put({ key: p.key, value: primary, updated: new Date().toISOString() });
446
+ modified++;
447
+ }
448
+ }
449
+ if (modified) {
450
+ console.log(
451
+ `πŸ”§ Forced Migration: Normalized ${modified} language-related preference(s) to primary subtag (no backup)`
452
+ );
453
+ }
454
+ } catch (fmErr) {
455
+ console.warn("Forced migration failed:", fmErr);
456
+ }
457
  } catch {}
458
  }
459
 
 
588
  }
589
  }
590
 
591
+ // Normalize specific preferences for backward-compatibility
592
+ if (key === "selectedLanguage" && typeof value === "string") {
593
+ try {
594
+ let raw = String(value).toLowerCase();
595
+ if (raw.includes(":")) raw = raw.split(":").pop();
596
+ raw = raw.replace("_", "-");
597
+ const primary = raw.includes("-") ? raw.split("-")[0] : raw;
598
+ if (primary && primary !== value) {
599
+ // Persist normalized primary subtag to DB for future reads
600
+ try {
601
+ await this.db.preferences.put({ key: key, value: primary, updated: new Date().toISOString() });
602
+ value = primary;
603
+ } catch (mErr) {
604
+ // ignore persistence error, but return normalized value
605
+ value = primary;
606
+ }
607
+ }
608
+ } catch (e) {
609
+ // ignore normalization errors
610
+ }
611
+ }
612
+
613
  // Cache the result
614
  const cache =
615
  window.KimiCacheManager && typeof window.KimiCacheManager.set === "function" ? window.KimiCacheManager : null;
 
887
  }
888
 
889
  async setPreferencesBatch(prefsArray) {
890
+ // Backwards-compatible: accept either an array [{key,value},...] or an object map { key: value }
891
+ let prefsInput = prefsArray;
892
+ if (!Array.isArray(prefsInput) && prefsInput && typeof prefsInput === "object") {
893
+ // convert map to array
894
+ prefsInput = Object.keys(prefsInput).map(k => ({ key: k, value: prefsInput[k] }));
895
+ console.warn("setPreferencesBatch: converted prefs map to array for backward compatibility");
896
+ }
897
+ if (!Array.isArray(prefsInput)) {
898
+ console.warn("setPreferencesBatch: expected array or object, got", typeof prefsArray);
899
+ return;
900
+ }
901
+
902
  const numericMap = {
903
  voiceRate: "VOICE_RATE",
904
  voicePitch: "VOICE_PITCH",
 
910
  llmFrequencyPenalty: "LLM_FREQUENCY_PENALTY",
911
  llmPresencePenalty: "LLM_PRESENCE_PENALTY"
912
  };
913
+ const batch = prefsInput.map(({ key, value }) => {
914
  if (numericMap[key] && window.KIMI_CONFIG && typeof window.KIMI_CONFIG.validate === "function") {
915
  const validation = window.KIMI_CONFIG.validate(value, numericMap[key]);
916
  if (validation.valid) value = validation.value;
kimi-js/kimi-memory-ui.js CHANGED
@@ -33,13 +33,19 @@ class KimiMemoryUI {
33
  // Add memory button
34
  const addMemoryBtn = document.getElementById("add-memory");
35
  if (addMemoryBtn) {
36
- addMemoryBtn.addEventListener("click", () => this.addManualMemory());
 
 
 
37
  }
38
 
39
  // Memory modal close
40
  const memoryClose = document.getElementById("memory-close");
41
  if (memoryClose) {
42
- memoryClose.addEventListener("click", () => this.closeMemoryModal());
 
 
 
43
  }
44
 
45
  // Memory export
@@ -51,7 +57,10 @@ class KimiMemoryUI {
51
  // Memory filter
52
  const memoryFilter = document.getElementById("memory-filter-category");
53
  if (memoryFilter) {
54
- memoryFilter.addEventListener("change", () => this.filterMemories());
 
 
 
55
  }
56
 
57
  // Memory search
@@ -881,3 +890,10 @@ document.addEventListener("DOMContentLoaded", async () => {
881
  });
882
 
883
  window.KimiMemoryUI = KimiMemoryUI;
 
 
 
 
 
 
 
 
33
  // Add memory button
34
  const addMemoryBtn = document.getElementById("add-memory");
35
  if (addMemoryBtn) {
36
+ addMemoryBtn.addEventListener("click", () => {
37
+ this.addManualMemory();
38
+ ensureVideoNeutralOnUIChange();
39
+ });
40
  }
41
 
42
  // Memory modal close
43
  const memoryClose = document.getElementById("memory-close");
44
  if (memoryClose) {
45
+ memoryClose.addEventListener("click", () => {
46
+ this.closeMemoryModal();
47
+ ensureVideoNeutralOnUIChange();
48
+ });
49
  }
50
 
51
  // Memory export
 
57
  // Memory filter
58
  const memoryFilter = document.getElementById("memory-filter-category");
59
  if (memoryFilter) {
60
+ memoryFilter.addEventListener("change", () => {
61
+ this.filterMemories();
62
+ ensureVideoNeutralOnUIChange();
63
+ });
64
  }
65
 
66
  // Memory search
 
890
  });
891
 
892
  window.KimiMemoryUI = KimiMemoryUI;
893
+
894
+ function ensureVideoNeutralOnUIChange() {
895
+ if (window.kimiVideo && window.kimiVideo.getCurrentVideoInfo) {
896
+ const info = window.kimiVideo.getCurrentVideoInfo();
897
+ if (info && info.ended) window.kimiVideo.returnToNeutral();
898
+ }
899
+ }
kimi-js/kimi-module.js CHANGED
@@ -77,9 +77,14 @@ class KimiDataManager extends KimiBaseManager {
77
 
78
  try {
79
  const conversations = await this.db.getAllConversations();
80
- const preferences = await this.db.getAllPreferences();
 
 
 
 
81
  const personalityTraits = await this.db.getAllPersonalityTraits();
82
  const models = await this.db.getAllLLMModels();
 
83
 
84
  const exportData = {
85
  version: "1.0",
@@ -88,11 +93,13 @@ class KimiDataManager extends KimiBaseManager {
88
  preferences: preferences,
89
  personalityTraits: personalityTraits,
90
  models: models,
 
91
  metadata: {
92
  totalConversations: conversations.length,
93
  totalPreferences: Object.keys(preferences).length,
94
  totalTraits: Object.keys(personalityTraits).length,
95
- totalModels: models.length
 
96
  }
97
  };
98
 
@@ -112,6 +119,88 @@ class KimiDataManager extends KimiBaseManager {
112
  }
113
  }
114
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
115
  async cleanOldData() {
116
  if (!this.db) {
117
  console.error("Database not available");
@@ -787,6 +876,14 @@ async function loadSettingsData() {
787
  const voicePitch = preferences.voicePitch !== undefined ? preferences.voicePitch : 1.1;
788
  const voiceVolume = preferences.voiceVolume !== undefined ? preferences.voiceVolume : 0.8;
789
  const selectedLanguage = preferences.selectedLanguage || "en";
 
 
 
 
 
 
 
 
790
  const apiKey = preferences.providerApiKey || "";
791
  const provider = preferences.llmProvider || "openrouter";
792
  const baseUrl = preferences.llmBaseUrl || "https://openrouter.ai/api/v1/chat/completions";
@@ -801,7 +898,7 @@ async function loadSettingsData() {
801
 
802
  // Update UI with voice settings
803
  const languageSelect = document.getElementById("language-selection");
804
- if (languageSelect) languageSelect.value = selectedLanguage;
805
  updateSlider("voice-rate", voiceRate);
806
  updateSlider("voice-pitch", voicePitch);
807
  updateSlider("voice-volume", voiceVolume);
 
77
 
78
  try {
79
  const conversations = await this.db.getAllConversations();
80
+ const preferencesObj = await this.db.getAllPreferences();
81
+ // Export preferences as an array of {key,value} so export is directly re-importable
82
+ const preferences = Array.isArray(preferencesObj)
83
+ ? preferencesObj
84
+ : Object.keys(preferencesObj).map(k => ({ key: k, value: preferencesObj[k] }));
85
  const personalityTraits = await this.db.getAllPersonalityTraits();
86
  const models = await this.db.getAllLLMModels();
87
+ const memories = await this.db.getAllMemories();
88
 
89
  const exportData = {
90
  version: "1.0",
 
93
  preferences: preferences,
94
  personalityTraits: personalityTraits,
95
  models: models,
96
+ memories: memories,
97
  metadata: {
98
  totalConversations: conversations.length,
99
  totalPreferences: Object.keys(preferences).length,
100
  totalTraits: Object.keys(personalityTraits).length,
101
+ totalModels: models.length,
102
+ totalMemories: memories.length
103
  }
104
  };
105
 
 
119
  }
120
  }
121
 
122
+ async importData(event) {
123
+ const file = event.target.files[0];
124
+ if (!file) {
125
+ alert("No file selected.");
126
+ return;
127
+ }
128
+ const reader = new FileReader();
129
+ reader.onload = async e => {
130
+ try {
131
+ const data = JSON.parse(e.target.result);
132
+ try {
133
+ console.log("Import file keys:", Object.keys(data));
134
+ } catch (ex) {}
135
+
136
+ if (data.preferences) {
137
+ try {
138
+ const isArray = Array.isArray(data.preferences);
139
+ const len = isArray ? data.preferences.length : Object.keys(data.preferences).length;
140
+ console.log("Import: preferences type=", isArray ? "array" : "object", "length=", len);
141
+ } catch (ex) {}
142
+ await this.db.setPreferencesBatch(data.preferences);
143
+ } else {
144
+ console.log("Import: no preferences found");
145
+ }
146
+
147
+ if (data.conversations) {
148
+ try {
149
+ console.log(
150
+ "Import: conversations length=",
151
+ Array.isArray(data.conversations) ? data.conversations.length : "not-array"
152
+ );
153
+ } catch (ex) {}
154
+ await this.db.setConversationsBatch(data.conversations);
155
+ } else {
156
+ console.log("Import: no conversations found");
157
+ }
158
+
159
+ if (data.personalityTraits) {
160
+ try {
161
+ console.log("Import: personalityTraits type=", typeof data.personalityTraits);
162
+ } catch (ex) {}
163
+ await this.db.setPersonalityBatch(data.personalityTraits);
164
+ } else {
165
+ console.log("Import: no personalityTraits found");
166
+ }
167
+
168
+ if (data.models) {
169
+ try {
170
+ console.log("Import: models length=", Array.isArray(data.models) ? data.models.length : "not-array");
171
+ } catch (ex) {}
172
+ await this.db.setLLMModelsBatch(data.models);
173
+ } else {
174
+ console.log("Import: no models found");
175
+ }
176
+
177
+ if (data.memories) {
178
+ try {
179
+ console.log(
180
+ "Import: memories length=",
181
+ Array.isArray(data.memories) ? data.memories.length : "not-array"
182
+ );
183
+ } catch (ex) {}
184
+ await this.db.setAllMemories(data.memories);
185
+ } else {
186
+ console.log("Import: no memories found");
187
+ }
188
+
189
+ alert("Import successful!");
190
+ await this.updateStorageInfo();
191
+
192
+ // Reload the page to ensure all UI state is rebuilt from the newly imported DB
193
+ setTimeout(() => {
194
+ location.reload();
195
+ }, 200);
196
+ } catch (err) {
197
+ console.error("Import failed:", err);
198
+ alert("Import failed. Invalid file or format.");
199
+ }
200
+ };
201
+ reader.readAsText(file);
202
+ }
203
+
204
  async cleanOldData() {
205
  if (!this.db) {
206
  console.error("Database not available");
 
876
  const voicePitch = preferences.voicePitch !== undefined ? preferences.voicePitch : 1.1;
877
  const voiceVolume = preferences.voiceVolume !== undefined ? preferences.voiceVolume : 0.8;
878
  const selectedLanguage = preferences.selectedLanguage || "en";
879
+ // Normalize legacy formats to primary subtag (e.g., 'en-US' -> 'en')
880
+ const normSelectedLanguage = (function (raw) {
881
+ if (!raw) return "en";
882
+ let r = String(raw).toLowerCase();
883
+ if (r.includes(":")) r = r.split(":").pop();
884
+ r = r.replace("_", "-");
885
+ return r.includes("-") ? r.split("-")[0] : r;
886
+ })(selectedLanguage);
887
  const apiKey = preferences.providerApiKey || "";
888
  const provider = preferences.llmProvider || "openrouter";
889
  const baseUrl = preferences.llmBaseUrl || "https://openrouter.ai/api/v1/chat/completions";
 
898
 
899
  // Update UI with voice settings
900
  const languageSelect = document.getElementById("language-selection");
901
+ if (languageSelect) languageSelect.value = normSelectedLanguage;
902
  updateSlider("voice-rate", voiceRate);
903
  updateSlider("voice-pitch", voicePitch);
904
  updateSlider("voice-volume", voiceVolume);
kimi-js/kimi-utils.js CHANGED
@@ -164,6 +164,22 @@ window.KimiLanguageUtils = {
164
  if (/[\u3040-\u309f\u30a0-\u30ff\u4e00-\u9faf]/i.test(text)) return "ja";
165
  if (/[\u4e00-\u9fff]/i.test(text)) return "zh";
166
  return "en";
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
167
  }
168
  };
169
 
 
164
  if (/[\u3040-\u309f\u30a0-\u30ff\u4e00-\u9faf]/i.test(text)) return "ja";
165
  if (/[\u4e00-\u9fff]/i.test(text)) return "zh";
166
  return "en";
167
+ },
168
+ // Normalize language codes to a primary subtag (e.g. 'en-US' -> 'en', 'us:en' -> 'en')
169
+ normalizeLanguageCode(raw) {
170
+ if (!raw) return "";
171
+ try {
172
+ let norm = String(raw).toLowerCase();
173
+ if (norm.includes(":")) {
174
+ const parts = norm.split(":");
175
+ norm = parts[parts.length - 1];
176
+ }
177
+ norm = norm.replace("_", "-");
178
+ if (norm.includes("-")) norm = norm.split("-")[0];
179
+ return norm;
180
+ } catch (e) {
181
+ return "";
182
+ }
183
  }
184
  };
185
 
kimi-js/kimi-voices.js CHANGED
@@ -207,7 +207,8 @@ class KimiVoiceManager {
207
  // Only get language from DB if not already set
208
  if (!this.selectedLanguage) {
209
  const selectedLanguage = await this.db?.getPreference("selectedLanguage", "en");
210
- this.selectedLanguage = selectedLanguage || "en";
 
211
  }
212
 
213
  const savedVoice = await this.db?.getPreference("selectedVoice", "auto");
@@ -409,7 +410,14 @@ class KimiVoiceManager {
409
  if (e.target.value === "auto") {
410
  console.log(`🎀 Voice set to automatic selection for language "${this.selectedLanguage}"`);
411
  await this.db?.setPreference("selectedVoice", "auto");
412
- this.currentVoice = null; // Trigger auto-selection next time
 
 
 
 
 
 
 
413
  } else {
414
  this.currentVoice = this.availableVoices.find(voice => voice.name === e.target.value);
415
  console.log(`🎀 Voice manually selected: "${this.currentVoice?.name}" (${this.currentVoice?.lang})`);
@@ -451,15 +459,31 @@ class KimiVoiceManager {
451
  return languageMap[langShort] || langShort;
452
  }
453
 
 
 
454
  getVoicesForLanguage(language) {
455
- let filteredVoices = this.availableVoices.filter(voice => voice.lang.toLowerCase().startsWith(language));
456
- if (filteredVoices.length === 0) {
457
- filteredVoices = this.availableVoices.filter(voice => voice.lang.toLowerCase().includes(language));
458
- }
459
- if (filteredVoices.length === 0) {
460
- // As last resort, return all voices
461
- filteredVoices = this.availableVoices;
 
 
 
 
 
 
 
 
 
 
 
462
  }
 
 
 
463
  return filteredVoices;
464
  }
465
 
@@ -551,8 +575,32 @@ class KimiVoiceManager {
551
  }
552
 
553
  async speak(text, options = {}) {
554
- if (!text || !this.currentVoice) {
555
- console.warn("Unable to speak: empty text or voice not initialized");
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
556
  return;
557
  }
558
  this.clearTranscriptTimeout();
@@ -1258,7 +1306,7 @@ class KimiVoiceManager {
1258
  async handleLanguageChange(e) {
1259
  const newLang = e.target.value;
1260
  const oldLang = this.selectedLanguage;
1261
- console.log(`οΏ½ Language changing: "${oldLang}" β†’ "${newLang}"`);
1262
 
1263
  this.selectedLanguage = newLang;
1264
  await this.db?.setPreference("selectedLanguage", newLang);
@@ -1268,16 +1316,31 @@ class KimiVoiceManager {
1268
  await window.kimiI18nManager.setLanguage(newLang);
1269
  }
1270
 
1271
- // ALWAYS reset voice when changing language to ensure compatibility
1272
- const currentVoicePref = await this.db?.getPreference("selectedVoice", "auto");
1273
-
1274
- // Reset voice selection to force re-selection for new language
1275
- this.currentVoice = null;
1276
-
1277
- // CRITICAL: Reset voice preference to "auto" to prevent old voice from being reused
1278
- await this.db?.setPreference("selectedVoice", "auto");
 
 
 
 
 
 
 
1279
 
1280
- await this.initVoices();
 
 
 
 
 
 
 
 
1281
 
1282
  if (this.currentVoice) {
1283
  console.log(`🎀 Voice selected for "${newLang}": "${this.currentVoice.name}" (${this.currentVoice.lang})`);
 
207
  // Only get language from DB if not already set
208
  if (!this.selectedLanguage) {
209
  const selectedLanguage = await this.db?.getPreference("selectedLanguage", "en");
210
+ // Normalize legacy formats (en-US, en_US, us:en -> en) using shared util
211
+ this.selectedLanguage = window.KimiLanguageUtils.normalizeLanguageCode(selectedLanguage || "en") || "en";
212
  }
213
 
214
  const savedVoice = await this.db?.getPreference("selectedVoice", "auto");
 
410
  if (e.target.value === "auto") {
411
  console.log(`🎀 Voice set to automatic selection for language "${this.selectedLanguage}"`);
412
  await this.db?.setPreference("selectedVoice", "auto");
413
+ this.currentVoice = null; // clear immediate in-memory voice
414
+ // Re-initialize voices synchronously so currentVoice is set before other code reacts
415
+ try {
416
+ await this.initVoices();
417
+ } catch (err) {
418
+ // If init fails, leave currentVoice null but don't throw
419
+ console.warn("🎀 initVoices failed after setting auto:", err);
420
+ }
421
  } else {
422
  this.currentVoice = this.availableVoices.find(voice => voice.name === e.target.value);
423
  console.log(`🎀 Voice manually selected: "${this.currentVoice?.name}" (${this.currentVoice?.lang})`);
 
459
  return languageMap[langShort] || langShort;
460
  }
461
 
462
+ // language normalization handled by window.KimiLanguageUtils.normalizeLanguageCode
463
+
464
  getVoicesForLanguage(language) {
465
+ const norm = window.KimiLanguageUtils.normalizeLanguageCode(language || "");
466
+ // First pass: voices whose lang primary subtag starts with normalized code
467
+ let filteredVoices = this.availableVoices.filter(voice => {
468
+ try {
469
+ const vlang = String(voice.lang || "").toLowerCase();
470
+ return vlang.startsWith(norm);
471
+ } catch (e) {
472
+ return false;
473
+ }
474
+ });
475
+
476
+ // Second pass: voices that contain the code anywhere
477
+ if (filteredVoices.length === 0 && norm) {
478
+ filteredVoices = this.availableVoices.filter(voice =>
479
+ String(voice.lang || "")
480
+ .toLowerCase()
481
+ .includes(norm)
482
+ );
483
  }
484
+
485
+ // Last resort: return all voices
486
+ if (filteredVoices.length === 0) filteredVoices = this.availableVoices;
487
  return filteredVoices;
488
  }
489
 
 
575
  }
576
 
577
  async speak(text, options = {}) {
578
+ // If no text or voice not ready, attempt short retries for voice initialization
579
+ if (!text) {
580
+ console.warn("Unable to speak: empty text");
581
+ return;
582
+ }
583
+
584
+ const maxRetries = 3;
585
+ let attempt = 0;
586
+ while (!this.currentVoice && attempt < maxRetries) {
587
+ // Small jittered backoff
588
+ const wait = 100 + Math.floor(Math.random() * 200); // 100-300ms
589
+ await new Promise(r => setTimeout(r, wait));
590
+ // If voices available, try to init
591
+ if (this.availableVoices.length > 0) {
592
+ // attempt to pick a voice for the current language
593
+ try {
594
+ await this.initVoices();
595
+ } catch (e) {
596
+ // ignore and retry
597
+ }
598
+ }
599
+ attempt++;
600
+ }
601
+
602
+ if (!this.currentVoice) {
603
+ console.warn("Unable to speak: voice not initialized after retries");
604
  return;
605
  }
606
  this.clearTranscriptTimeout();
 
1306
  async handleLanguageChange(e) {
1307
  const newLang = e.target.value;
1308
  const oldLang = this.selectedLanguage;
1309
+ console.log(`🎀 Language changing: "${oldLang}" β†’ "${newLang}"`);
1310
 
1311
  this.selectedLanguage = newLang;
1312
  await this.db?.setPreference("selectedLanguage", newLang);
 
1316
  await window.kimiI18nManager.setLanguage(newLang);
1317
  }
1318
 
1319
+ // Check saved voice compatibility: only reset to 'auto' if incompatible
1320
+ try {
1321
+ const currentVoicePref = await this.db?.getPreference("selectedVoice", "auto");
1322
+ // Clear in-memory currentVoice to allow re-selection
1323
+ this.currentVoice = null;
1324
+
1325
+ if (currentVoicePref && currentVoicePref !== "auto") {
1326
+ // If saved voice name exists, check if it's present among filtered voices for the new language
1327
+ const filtered = this.getVoicesForLanguage(newLang);
1328
+ const compatible = filtered.some(v => v.name === currentVoicePref);
1329
+ if (!compatible) {
1330
+ // Only write 'auto' when incompatible
1331
+ await this.db?.setPreference("selectedVoice", "auto");
1332
+ }
1333
+ }
1334
 
1335
+ // Re-init voices to pick a correct voice for the new language
1336
+ await this.initVoices();
1337
+ } catch (err) {
1338
+ // On error, fall back to safe behavior: init voices and set 'auto'
1339
+ try {
1340
+ await this.db?.setPreference("selectedVoice", "auto");
1341
+ } catch {}
1342
+ await this.initVoices();
1343
+ }
1344
 
1345
  if (this.currentVoice) {
1346
  console.log(`🎀 Voice selected for "${newLang}": "${this.currentVoice.name}" (${this.currentVoice.lang})`);