@mooncompany/uplink-chat 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of @mooncompany/uplink-chat might be problematic. Click here for more details.

Files changed (158) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +185 -0
  3. package/bin/uplink.js +279 -0
  4. package/middleware/error-handler.js +69 -0
  5. package/package.json +93 -0
  6. package/public/css/agents.36b98c0f.css +1469 -0
  7. package/public/css/agents.css +1469 -0
  8. package/public/css/app.a6a7f8f5.css +2731 -0
  9. package/public/css/app.css +2731 -0
  10. package/public/css/artifacts.css +444 -0
  11. package/public/css/commands.css +55 -0
  12. package/public/css/connection.css +131 -0
  13. package/public/css/dashboard.css +233 -0
  14. package/public/css/developer.css +328 -0
  15. package/public/css/files.css +123 -0
  16. package/public/css/markdown.css +156 -0
  17. package/public/css/message-actions.css +278 -0
  18. package/public/css/mobile.css +614 -0
  19. package/public/css/panels-unified.css +483 -0
  20. package/public/css/premium.css +415 -0
  21. package/public/css/realtime.css +189 -0
  22. package/public/css/satellites.css +401 -0
  23. package/public/css/shortcuts.css +185 -0
  24. package/public/css/split-view.4def0262.css +673 -0
  25. package/public/css/split-view.css +673 -0
  26. package/public/css/theme-generator.css +391 -0
  27. package/public/css/themes.css +387 -0
  28. package/public/css/timestamps.css +54 -0
  29. package/public/css/variables.css +78 -0
  30. package/public/dist/bundle.b55050c4.js +15757 -0
  31. package/public/favicon.svg +24 -0
  32. package/public/img/agents/ada.png +0 -0
  33. package/public/img/agents/clarice.png +0 -0
  34. package/public/img/agents/dennis-nedry.png +0 -0
  35. package/public/img/agents/elliot-alderson.png +0 -0
  36. package/public/img/agents/main.png +0 -0
  37. package/public/img/agents/scotty.png +0 -0
  38. package/public/img/agents/top-flight-security.png +0 -0
  39. package/public/index.html +1083 -0
  40. package/public/js/agents-data.js +234 -0
  41. package/public/js/agents-ui.js +72 -0
  42. package/public/js/agents.js +1525 -0
  43. package/public/js/app.js +79 -0
  44. package/public/js/appearance-settings.js +111 -0
  45. package/public/js/artifacts.js +432 -0
  46. package/public/js/audio-queue.js +168 -0
  47. package/public/js/bootstrap.js +54 -0
  48. package/public/js/chat.js +1211 -0
  49. package/public/js/commands.js +581 -0
  50. package/public/js/connection-api.js +121 -0
  51. package/public/js/connection.js +1231 -0
  52. package/public/js/context-tracker.js +271 -0
  53. package/public/js/core.js +172 -0
  54. package/public/js/dashboard.js +452 -0
  55. package/public/js/developer.js +432 -0
  56. package/public/js/encryption.js +124 -0
  57. package/public/js/errors.js +122 -0
  58. package/public/js/event-bus.js +77 -0
  59. package/public/js/fetch-utils.js +171 -0
  60. package/public/js/file-handler.js +229 -0
  61. package/public/js/files.js +352 -0
  62. package/public/js/gateway-chat.js +538 -0
  63. package/public/js/logger.js +112 -0
  64. package/public/js/markdown.js +190 -0
  65. package/public/js/message-actions.js +431 -0
  66. package/public/js/message-renderer.js +288 -0
  67. package/public/js/missed-messages.js +235 -0
  68. package/public/js/mobile-debug.js +95 -0
  69. package/public/js/notifications.js +367 -0
  70. package/public/js/offline-queue.js +178 -0
  71. package/public/js/onboarding.js +543 -0
  72. package/public/js/panels.js +156 -0
  73. package/public/js/premium.js +412 -0
  74. package/public/js/realtime-voice.js +844 -0
  75. package/public/js/satellite-sync.js +256 -0
  76. package/public/js/satellite-ui.js +175 -0
  77. package/public/js/satellites.js +1516 -0
  78. package/public/js/settings.js +1087 -0
  79. package/public/js/shortcuts.js +381 -0
  80. package/public/js/split-chat.js +1234 -0
  81. package/public/js/split-resize.js +211 -0
  82. package/public/js/splitview.js +340 -0
  83. package/public/js/storage.js +408 -0
  84. package/public/js/streaming-handler.js +324 -0
  85. package/public/js/stt-settings.js +316 -0
  86. package/public/js/theme-generator.js +661 -0
  87. package/public/js/themes.js +164 -0
  88. package/public/js/timestamps.js +198 -0
  89. package/public/js/tts-settings.js +575 -0
  90. package/public/js/ui.js +267 -0
  91. package/public/js/update-notifier.js +143 -0
  92. package/public/js/utils/constants.js +165 -0
  93. package/public/js/utils/sanitize.js +93 -0
  94. package/public/js/utils/sse-parser.js +195 -0
  95. package/public/js/voice.js +883 -0
  96. package/public/manifest.json +58 -0
  97. package/public/moon_texture.jpg +0 -0
  98. package/public/sw.js +221 -0
  99. package/public/three.min.js +6 -0
  100. package/server/channel.js +529 -0
  101. package/server/chat.js +270 -0
  102. package/server/config-store.js +362 -0
  103. package/server/config.js +159 -0
  104. package/server/context.js +131 -0
  105. package/server/gateway-commands.js +211 -0
  106. package/server/gateway-proxy.js +318 -0
  107. package/server/index.js +22 -0
  108. package/server/logger.js +89 -0
  109. package/server/middleware/auth.js +188 -0
  110. package/server/middleware.js +218 -0
  111. package/server/openclaw-discover.js +308 -0
  112. package/server/premium/index.js +156 -0
  113. package/server/premium/license.js +140 -0
  114. package/server/realtime/bridge.js +837 -0
  115. package/server/realtime/index.js +349 -0
  116. package/server/realtime/tts-stream.js +446 -0
  117. package/server/routes/agents.js +564 -0
  118. package/server/routes/artifacts.js +174 -0
  119. package/server/routes/chat.js +311 -0
  120. package/server/routes/config-settings.js +345 -0
  121. package/server/routes/config.js +603 -0
  122. package/server/routes/files.js +307 -0
  123. package/server/routes/index.js +18 -0
  124. package/server/routes/media.js +451 -0
  125. package/server/routes/missed-messages.js +107 -0
  126. package/server/routes/premium.js +75 -0
  127. package/server/routes/push.js +156 -0
  128. package/server/routes/satellite.js +406 -0
  129. package/server/routes/status.js +251 -0
  130. package/server/routes/stt.js +35 -0
  131. package/server/routes/voice.js +260 -0
  132. package/server/routes/webhooks.js +203 -0
  133. package/server/routes.js +206 -0
  134. package/server/runtime-config.js +336 -0
  135. package/server/share.js +305 -0
  136. package/server/stt/faster-whisper.js +72 -0
  137. package/server/stt/groq.js +51 -0
  138. package/server/stt/index.js +196 -0
  139. package/server/stt/openai.js +49 -0
  140. package/server/sync.js +244 -0
  141. package/server/tailscale-https.js +175 -0
  142. package/server/tts.js +646 -0
  143. package/server/update-checker.js +172 -0
  144. package/server/utils/filename.js +129 -0
  145. package/server/utils.js +147 -0
  146. package/server/watchdog.js +318 -0
  147. package/server/websocket/broadcast.js +359 -0
  148. package/server/websocket/connections.js +339 -0
  149. package/server/websocket/index.js +215 -0
  150. package/server/websocket/routing.js +277 -0
  151. package/server/websocket/sync.js +102 -0
  152. package/server.js +404 -0
  153. package/utils/detect-tool-usage.js +93 -0
  154. package/utils/errors.js +158 -0
  155. package/utils/html-escape.js +84 -0
  156. package/utils/id-sanitize.js +94 -0
  157. package/utils/response.js +130 -0
  158. package/utils/with-retry.js +105 -0
@@ -0,0 +1,575 @@
1
+ // ============================================
2
+ // TTS SETTINGS MODULE
3
+ // Text-to-Speech configuration and voice selection
4
+ // ============================================
5
+
6
+ // DOM elements
7
+ let ttsProviderSelect, ttsProviderDesc;
8
+ let audioToggle;
9
+ let elevenLabsKeyInput, elevenLabsSaveBtn, elevenLabsKeyStatus;
10
+ let elevenLabsVoiceRow, elevenLabsVoiceSelect;
11
+ let openaiKeyInput, openaiKeySaveBtn, openaiKeyStatus;
12
+ let openaiTtsVoiceSelect, openaiTtsModelSelect;
13
+ let edgeTtsVoiceSelect;
14
+ let localTtsUrlInput, localTtsSaveBtn;
15
+
16
+ function init() {
17
+ ttsProviderSelect = document.getElementById('ttsProviderSelect');
18
+ ttsProviderDesc = document.getElementById('ttsProviderDesc');
19
+ audioToggle = document.getElementById('audioToggle');
20
+ elevenLabsKeyInput = document.getElementById('elevenLabsKeyInput');
21
+ elevenLabsSaveBtn = document.getElementById('elevenLabsSaveBtn');
22
+ elevenLabsKeyStatus = document.getElementById('elevenLabsKeyStatus');
23
+ elevenLabsVoiceRow = document.getElementById('elevenLabsVoiceRow');
24
+ elevenLabsVoiceSelect = document.getElementById('elevenLabsVoiceSelect');
25
+ openaiKeyInput = document.getElementById('openaiKeyInput');
26
+ openaiKeySaveBtn = document.getElementById('openaiKeySaveBtn');
27
+ openaiKeyStatus = document.getElementById('openaiKeyStatus');
28
+ openaiTtsVoiceSelect = document.getElementById('openaiTtsVoiceSelect');
29
+ openaiTtsModelSelect = document.getElementById('openaiTtsModelSelect');
30
+ edgeTtsVoiceSelect = document.getElementById('edgeTtsVoiceSelect');
31
+ localTtsUrlInput = document.getElementById('localTtsUrlInput');
32
+ localTtsSaveBtn = document.getElementById('localTtsSaveBtn');
33
+
34
+ setupEvents();
35
+ }
36
+
37
+ function setupEvents() {
38
+ // Audio toggle
39
+ audioToggle?.addEventListener('click', () => {
40
+ const core = window.UplinkCore;
41
+ if (core) {
42
+ core.audioResponses = !core.audioResponses;
43
+ audioToggle.classList.toggle('on', core.audioResponses);
44
+ audioToggle.setAttribute('aria-checked', core.audioResponses ? 'true' : 'false');
45
+ }
46
+ });
47
+
48
+ // Audio toggle keyboard handler
49
+ audioToggle?.addEventListener('keydown', (e) => {
50
+ if (e.key === 'Enter' || e.key === ' ') {
51
+ e.preventDefault();
52
+ audioToggle.click();
53
+ }
54
+ });
55
+
56
+ // TTS Provider change
57
+ ttsProviderSelect?.addEventListener('change', async () => {
58
+ const newProvider = ttsProviderSelect.value;
59
+
60
+ try {
61
+ const response = await fetch('/api/config', {
62
+ method: 'POST',
63
+ headers: { 'Content-Type': 'application/json' },
64
+ body: JSON.stringify({ ttsProvider: newProvider })
65
+ });
66
+
67
+ if (!response.ok) {
68
+ throw new Error(`HTTP ${response.status}`);
69
+ }
70
+
71
+ // Update description based on provider
72
+ if (ttsProviderDesc) {
73
+ const descriptions = {
74
+ 'none': 'Voice synthesis disabled',
75
+ 'elevenlabs': 'High-quality cloud TTS (requires API key)',
76
+ 'openai': 'Cloud TTS (uses your OpenAI API key)',
77
+ 'local': 'GPU-accelerated local TTS (custom voice)',
78
+ 'edge': 'Free Microsoft TTS (no API key needed)',
79
+ 'piper': 'Fast local TTS (requires piper install)',
80
+ };
81
+ ttsProviderDesc.textContent = descriptions[newProvider] || 'Voice synthesis service';
82
+ }
83
+
84
+ // Show config panel for the selected provider
85
+ updateProviderConfigVisibility(newProvider);
86
+
87
+ showToast('TTS provider updated', 'success');
88
+ } catch (error) {
89
+ console.error('Failed to update TTS provider:', error);
90
+ showToast('Failed to update TTS provider', 'error');
91
+ // Revert selection
92
+ fetchServerConfig();
93
+ }
94
+ });
95
+
96
+ // ElevenLabs API key save
97
+ elevenLabsSaveBtn?.addEventListener('click', saveElevenLabsKey);
98
+ elevenLabsKeyInput?.addEventListener('keypress', (e) => {
99
+ if (e.key === 'Enter') saveElevenLabsKey();
100
+ });
101
+
102
+ // ElevenLabs voice selection
103
+ elevenLabsVoiceSelect?.addEventListener('change', async () => {
104
+ const voiceId = elevenLabsVoiceSelect.value;
105
+ const voiceName = elevenLabsVoiceSelect.options[elevenLabsVoiceSelect.selectedIndex]?.text;
106
+
107
+ if (!voiceId) return;
108
+
109
+ try {
110
+ const response = await fetch('/api/config/elevenlabs-voice', {
111
+ method: 'POST',
112
+ headers: { 'Content-Type': 'application/json' },
113
+ body: JSON.stringify({ voiceId, voiceName })
114
+ });
115
+
116
+ if (response.ok) {
117
+ showToast(`Voice set to ${voiceName}`, 'success');
118
+ } else {
119
+ throw new Error('Failed to save voice');
120
+ }
121
+ } catch (error) {
122
+ console.error('Failed to set voice:', error);
123
+ showToast('Failed to set voice', 'error');
124
+ }
125
+ });
126
+
127
+ // OpenAI API key save
128
+ openaiKeySaveBtn?.addEventListener('click', saveOpenAIKey);
129
+ openaiKeyInput?.addEventListener('keypress', (e) => {
130
+ if (e.key === 'Enter') saveOpenAIKey();
131
+ });
132
+
133
+ // OpenAI TTS voice/model change
134
+ openaiTtsVoiceSelect?.addEventListener('change', async () => {
135
+ try {
136
+ const response = await fetch('/api/config/openai-tts', {
137
+ method: 'POST',
138
+ headers: { 'Content-Type': 'application/json' },
139
+ body: JSON.stringify({ voice: openaiTtsVoiceSelect.value })
140
+ });
141
+ if (response.ok) {
142
+ showToast(`OpenAI voice set to ${openaiTtsVoiceSelect.value}`, 'success');
143
+ } else {
144
+ const err = await response.json();
145
+ showToast(err.error || 'Failed to save', 'error');
146
+ }
147
+ } catch (e) {
148
+ showToast('Failed to save OpenAI voice', 'error');
149
+ }
150
+ });
151
+
152
+ openaiTtsModelSelect?.addEventListener('change', async () => {
153
+ try {
154
+ const response = await fetch('/api/config/openai-tts', {
155
+ method: 'POST',
156
+ headers: { 'Content-Type': 'application/json' },
157
+ body: JSON.stringify({ model: openaiTtsModelSelect.value })
158
+ });
159
+ if (response.ok) {
160
+ const labels = { 'tts-1': 'Standard', 'tts-1-hd': 'HD', 'gpt-4o-mini-tts': 'GPT-4o Mini' };
161
+ showToast(`OpenAI model set to ${labels[openaiTtsModelSelect.value] || openaiTtsModelSelect.value}`, 'success');
162
+ } else {
163
+ const err = await response.json();
164
+ showToast(err.error || 'Failed to save', 'error');
165
+ }
166
+ } catch (e) {
167
+ showToast('Failed to save OpenAI model', 'error');
168
+ }
169
+ });
170
+
171
+ // Edge TTS voice change
172
+ edgeTtsVoiceSelect?.addEventListener('change', async () => {
173
+ const voice = edgeTtsVoiceSelect.value;
174
+ if (!voice) return;
175
+
176
+ try {
177
+ const response = await fetch('/api/config/edge-voice', {
178
+ method: 'POST',
179
+ headers: { 'Content-Type': 'application/json' },
180
+ body: JSON.stringify({ voice })
181
+ });
182
+ if (response.ok) {
183
+ showToast(`Edge voice set to ${edgeTtsVoiceSelect.options[edgeTtsVoiceSelect.selectedIndex]?.text || voice}`, 'success');
184
+ } else {
185
+ showToast('Failed to save Edge voice', 'error');
186
+ }
187
+ } catch (e) {
188
+ showToast('Failed to save Edge voice', 'error');
189
+ }
190
+ });
191
+
192
+ // XTTS URL save
193
+ localTtsSaveBtn?.addEventListener('click', async () => {
194
+ const url = localTtsUrlInput?.value.trim();
195
+
196
+ try {
197
+ const response = await fetch('/api/config/local-tts', {
198
+ method: 'POST',
199
+ headers: { 'Content-Type': 'application/json' },
200
+ body: JSON.stringify({ url: url || '' })
201
+ });
202
+ if (response.ok) {
203
+ showToast(url ? 'XTTS server URL saved' : 'XTTS URL cleared', 'success');
204
+ } else {
205
+ const err = await response.json();
206
+ showToast(err.error || 'Failed to save', 'error');
207
+ }
208
+ } catch (e) {
209
+ showToast('Failed to save XTTS URL', 'error');
210
+ }
211
+ });
212
+ }
213
+
214
+ /**
215
+ * Show config panel for the active provider, hide all others
216
+ */
217
+ function updateProviderConfigVisibility(provider) {
218
+ const settingsPanel = document.getElementById('settingsPanel');
219
+
220
+ // Hide all provider config panels
221
+ const allConfigs = settingsPanel?.querySelectorAll('.tts-provider-config') || [];
222
+ allConfigs.forEach(el => { el.style.display = 'none'; });
223
+
224
+ // Show the active provider's config
225
+ const activeConfig = document.getElementById(`ttsConfig-${provider}`);
226
+ if (activeConfig) {
227
+ activeConfig.style.display = 'block';
228
+ }
229
+
230
+ // Provider-specific init
231
+ if (provider === 'elevenlabs') {
232
+ loadElevenLabsVoices();
233
+ } else if (provider === 'edge') {
234
+ loadEdgeTTSVoices();
235
+ } else if (provider === 'openai') {
236
+ loadOpenAITTSStatus();
237
+ } else if (provider === 'piper') {
238
+ loadPiperStatus();
239
+ } else if (provider === 'local') {
240
+ loadXTTSStatus();
241
+ }
242
+ }
243
+
244
+ async function saveElevenLabsKey() {
245
+ const apiKey = elevenLabsKeyInput?.value.trim();
246
+
247
+ if (!apiKey) {
248
+ if (elevenLabsKeyStatus) {
249
+ elevenLabsKeyStatus.textContent = 'Please enter an API key';
250
+ elevenLabsKeyStatus.style.color = 'var(--error-color, #ef4444)';
251
+ }
252
+ return;
253
+ }
254
+
255
+ if (elevenLabsKeyStatus) {
256
+ elevenLabsKeyStatus.textContent = 'Validating...';
257
+ elevenLabsKeyStatus.style.color = 'var(--text-muted)';
258
+ }
259
+
260
+ try {
261
+ const response = await fetch('/api/config/elevenlabs-key', {
262
+ method: 'POST',
263
+ headers: { 'Content-Type': 'application/json' },
264
+ body: JSON.stringify({ apiKey })
265
+ });
266
+
267
+ const data = await response.json();
268
+
269
+ if (data.valid) {
270
+ if (elevenLabsKeyStatus) {
271
+ elevenLabsKeyStatus.textContent = `✓ Key saved (${data.subscription} tier)`;
272
+ elevenLabsKeyStatus.style.color = 'var(--success-color, #4ade80)';
273
+ }
274
+ if (elevenLabsKeyInput) {
275
+ elevenLabsKeyInput.value = ''; // Clear input after save
276
+ elevenLabsKeyInput.placeholder = '••••••••••••••••';
277
+ }
278
+ showToast('ElevenLabs API key saved', 'success');
279
+
280
+ // Load available voices
281
+ loadElevenLabsVoices();
282
+ } else {
283
+ if (elevenLabsKeyStatus) {
284
+ elevenLabsKeyStatus.textContent = data.error || 'Invalid API key';
285
+ elevenLabsKeyStatus.style.color = 'var(--error-color, #ef4444)';
286
+ }
287
+ showToast('Invalid API key', 'error');
288
+ }
289
+ } catch (error) {
290
+ console.error('Failed to save ElevenLabs key:', error);
291
+ if (elevenLabsKeyStatus) {
292
+ elevenLabsKeyStatus.textContent = 'Failed to validate key';
293
+ elevenLabsKeyStatus.style.color = 'var(--error-color, #ef4444)';
294
+ }
295
+ showToast('Failed to save API key', 'error');
296
+ }
297
+ }
298
+
299
+ async function loadElevenLabsVoices() {
300
+ if (!elevenLabsVoiceSelect) return;
301
+
302
+ elevenLabsVoiceSelect.innerHTML = '<option value="">Loading voices...</option>';
303
+
304
+ try {
305
+ const response = await fetch('/api/config/elevenlabs-voices');
306
+ const data = await response.json();
307
+
308
+ if (data.error || !data.voices?.length) {
309
+ elevenLabsVoiceSelect.innerHTML = '<option value="">No voices available</option>';
310
+ if (elevenLabsVoiceRow) elevenLabsVoiceRow.style.display = 'none';
311
+ return;
312
+ }
313
+
314
+ // Get current voice from config
315
+ const configRes = await fetch('/api/config');
316
+ const config = await configRes.json();
317
+ const currentVoiceId = config.elevenLabsVoiceId;
318
+
319
+ // Build options
320
+ elevenLabsVoiceSelect.innerHTML = data.voices.map(v =>
321
+ `<option value="${v.id}" ${v.id === currentVoiceId ? 'selected' : ''}>${v.name}</option>`
322
+ ).join('');
323
+
324
+ if (elevenLabsVoiceRow) elevenLabsVoiceRow.style.display = 'flex';
325
+ } catch (error) {
326
+ console.error('Failed to load voices:', error);
327
+ elevenLabsVoiceSelect.innerHTML = '<option value="">Failed to load voices</option>';
328
+ }
329
+ }
330
+
331
+ async function loadOpenAITTSStatus() {
332
+ try {
333
+ const res = await fetch('/api/config/tts-status');
334
+ const data = await res.json();
335
+ const info = data.openai;
336
+
337
+ if (info?.hasKey) {
338
+ if (openaiKeyStatus) {
339
+ openaiKeyStatus.textContent = '✓ API key configured';
340
+ openaiKeyStatus.style.color = 'var(--success-color, #4ade80)';
341
+ }
342
+ if (openaiKeyInput) openaiKeyInput.placeholder = '••••••••••••••••';
343
+ } else {
344
+ if (openaiKeyStatus) {
345
+ openaiKeyStatus.textContent = 'Enter your OpenAI API key';
346
+ openaiKeyStatus.style.color = '';
347
+ }
348
+ }
349
+
350
+ // Sync dropdowns with current config
351
+ if (openaiTtsVoiceSelect && info?.voice) {
352
+ openaiTtsVoiceSelect.value = info.voice;
353
+ }
354
+ if (openaiTtsModelSelect && info?.model) {
355
+ openaiTtsModelSelect.value = info.model;
356
+ }
357
+ } catch (e) {
358
+ console.error('Failed to load OpenAI TTS status:', e);
359
+ if (openaiKeyStatus) {
360
+ openaiKeyStatus.textContent = 'Failed to check status';
361
+ openaiKeyStatus.style.color = 'var(--error-color, #ef4444)';
362
+ }
363
+ }
364
+ }
365
+
366
+ async function saveOpenAIKey() {
367
+ const apiKey = openaiKeyInput?.value.trim();
368
+
369
+ if (!apiKey) {
370
+ if (openaiKeyStatus) {
371
+ openaiKeyStatus.textContent = 'Please enter an API key';
372
+ openaiKeyStatus.style.color = 'var(--error-color, #ef4444)';
373
+ }
374
+ return;
375
+ }
376
+
377
+ if (openaiKeyStatus) {
378
+ openaiKeyStatus.textContent = 'Validating...';
379
+ openaiKeyStatus.style.color = 'var(--text-muted)';
380
+ }
381
+
382
+ try {
383
+ const response = await fetch('/api/config/openai-key', {
384
+ method: 'POST',
385
+ headers: { 'Content-Type': 'application/json' },
386
+ body: JSON.stringify({ apiKey })
387
+ });
388
+
389
+ const data = await response.json();
390
+
391
+ if (data.valid) {
392
+ if (openaiKeyStatus) {
393
+ openaiKeyStatus.textContent = '✓ API key saved';
394
+ openaiKeyStatus.style.color = 'var(--success-color, #4ade80)';
395
+ }
396
+ if (openaiKeyInput) {
397
+ openaiKeyInput.value = '';
398
+ openaiKeyInput.placeholder = '••••••••••••••••';
399
+ }
400
+ showToast('OpenAI API key saved', 'success');
401
+ } else {
402
+ if (openaiKeyStatus) {
403
+ openaiKeyStatus.textContent = data.error || 'Invalid API key';
404
+ openaiKeyStatus.style.color = 'var(--error-color, #ef4444)';
405
+ }
406
+ showToast('Invalid API key', 'error');
407
+ }
408
+ } catch (error) {
409
+ console.error('Failed to save OpenAI key:', error);
410
+ if (openaiKeyStatus) {
411
+ openaiKeyStatus.textContent = 'Failed to validate key';
412
+ openaiKeyStatus.style.color = 'var(--error-color, #ef4444)';
413
+ }
414
+ showToast('Failed to save API key', 'error');
415
+ }
416
+ }
417
+
418
+ async function loadEdgeTTSVoices() {
419
+ const statusDesc = document.getElementById('edgeTtsStatusDesc');
420
+ const statusDot = document.getElementById('edgeTtsStatusDot');
421
+ const voiceRow = document.getElementById('edgeTtsVoiceRow');
422
+
423
+ try {
424
+ const res = await fetch('/api/config/tts-status');
425
+ const data = await res.json();
426
+ const info = data.edge;
427
+
428
+ if (!info?.installed) {
429
+ if (statusDesc) statusDesc.textContent = 'Not installed — run: npm install node-edge-tts';
430
+ if (statusDot) statusDot.className = 'status-indicator disconnected';
431
+ if (voiceRow) voiceRow.style.display = 'none';
432
+ return;
433
+ }
434
+
435
+ if (statusDesc) statusDesc.textContent = 'Installed — free, no API key needed';
436
+ if (statusDot) statusDot.className = 'status-indicator connected';
437
+
438
+ // Load voice list
439
+ if (edgeTtsVoiceSelect) {
440
+ edgeTtsVoiceSelect.innerHTML = '<option value="">Loading voices...</option>';
441
+
442
+ const voicesRes = await fetch('/api/config/edge-voices');
443
+ const voicesData = await voicesRes.json();
444
+
445
+ if (voicesData.voices?.length) {
446
+ edgeTtsVoiceSelect.innerHTML = voicesData.voices.map(v =>
447
+ `<option value="${v.shortName}" ${v.shortName === info.voice ? 'selected' : ''}>${v.name} (${v.gender})</option>`
448
+ ).join('');
449
+ if (voiceRow) voiceRow.style.display = 'flex';
450
+ } else {
451
+ edgeTtsVoiceSelect.innerHTML = '<option value="">No voices available</option>';
452
+ }
453
+ }
454
+ } catch (e) {
455
+ console.error('Failed to load Edge TTS voices:', e);
456
+ if (statusDesc) statusDesc.textContent = 'Failed to check status';
457
+ if (statusDot) statusDot.className = 'status-indicator disconnected';
458
+ }
459
+ }
460
+
461
+ async function loadPiperStatus() {
462
+ const statusDesc = document.getElementById('piperStatusDesc');
463
+ const statusDot = document.getElementById('piperStatusDot');
464
+
465
+ try {
466
+ const res = await fetch('/api/config/tts-status');
467
+ const data = await res.json();
468
+ const info = data.piper;
469
+
470
+ if (info?.configured) {
471
+ if (statusDesc) statusDesc.textContent = 'Configured via environment (PIPER_MODEL)';
472
+ if (statusDot) statusDot.className = 'status-indicator connected';
473
+ } else {
474
+ if (statusDesc) statusDesc.textContent = 'Not configured — set PIPER_MODEL in .env';
475
+ if (statusDot) statusDot.className = 'status-indicator disconnected';
476
+ }
477
+ } catch (e) {
478
+ console.error('Failed to load Piper status:', e);
479
+ }
480
+ }
481
+
482
+ async function loadXTTSStatus() {
483
+ try {
484
+ const res = await fetch('/api/config/tts-status');
485
+ const data = await res.json();
486
+ const info = data.local;
487
+
488
+ if (localTtsUrlInput && info?.url) {
489
+ localTtsUrlInput.value = info.url;
490
+ }
491
+ } catch (e) {
492
+ console.error('Failed to load XTTS status:', e);
493
+ }
494
+ }
495
+
496
+ async function fetchServerConfig() {
497
+ try {
498
+ const response = await fetch('/api/config');
499
+ if (!response.ok) return;
500
+
501
+ const config = await response.json();
502
+
503
+ if (ttsProviderSelect && config.ttsProvider) {
504
+ ttsProviderSelect.value = config.ttsProvider;
505
+
506
+ // Enable/disable Edge TTS based on availability
507
+ const edgeOption = document.getElementById('edgeTtsOption');
508
+ if (edgeOption) {
509
+ if (config.edgeTtsAvailable) {
510
+ edgeOption.disabled = false;
511
+ edgeOption.textContent = 'Edge TTS (Free)';
512
+ } else {
513
+ edgeOption.disabled = true;
514
+ edgeOption.textContent = 'Edge TTS (not installed)';
515
+ }
516
+ }
517
+
518
+ // Update description
519
+ if (ttsProviderDesc) {
520
+ const descriptions = {
521
+ 'none': 'Voice synthesis disabled',
522
+ 'elevenlabs': 'High-quality cloud TTS (requires API key)',
523
+ 'openai': 'OpenAI TTS (requires API key)',
524
+ 'local': 'GPU-accelerated local TTS (custom voice)',
525
+ 'edge': 'Free Microsoft TTS (user-installed)',
526
+ 'piper': 'Fast local TTS (user-installed)'
527
+ };
528
+ ttsProviderDesc.textContent = descriptions[config.ttsProvider] || 'Voice synthesis service';
529
+ }
530
+
531
+ // Show config panel for active provider
532
+ updateProviderConfigVisibility(config.ttsProvider);
533
+
534
+ // Update ElevenLabs key status
535
+ if (config.hasElevenLabsKey && elevenLabsKeyStatus) {
536
+ elevenLabsKeyStatus.textContent = '✓ API key configured';
537
+ elevenLabsKeyStatus.style.color = 'var(--success-color, #4ade80)';
538
+ if (elevenLabsKeyInput) elevenLabsKeyInput.placeholder = '••••••••••••••••';
539
+ }
540
+ }
541
+ } catch (error) {
542
+ console.warn('TTS Settings: Failed to fetch server config', error);
543
+ }
544
+ }
545
+
546
+ function showToast(message, type = 'success') {
547
+ if (window.UplinkSettings?.showToast) {
548
+ window.UplinkSettings.showToast(message, type);
549
+ }
550
+ }
551
+
552
+ function applyState() {
553
+ const core = window.UplinkCore;
554
+ if (!core) return;
555
+
556
+ if (audioToggle) {
557
+ audioToggle.classList.toggle('on', core.audioResponses);
558
+ audioToggle.setAttribute('aria-checked', core.audioResponses ? 'true' : 'false');
559
+ }
560
+
561
+ fetchServerConfig();
562
+ }
563
+
564
+ // Expose API
565
+ export const UplinkTTSSettings = {
566
+ init,
567
+ applyState,
568
+ fetchServerConfig,
569
+ updateProviderConfigVisibility
570
+ };
571
+
572
+ import { UplinkCore } from './core.js';
573
+
574
+ // Backward compat: assign to window
575
+ window.UplinkTTSSettings = UplinkTTSSettings;