@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,1087 @@
1
+ // ============================================
2
+ // SETTINGS MODULE
3
+ // Settings panel, preferences
4
+ // ============================================
5
+
6
+ // DOM elements
7
+ let settingsBtn, settingsPanel;
8
+ let agentNameInput, gatewayUrlInput;
9
+ let encryptToggle, changePasswordRow, changePasswordBtn;
10
+ let syncRow, syncPushBtn, syncPullBtn, syncStatus;
11
+ let clearBtn, logoutBtn;
12
+ let textInput;
13
+ let showShortcutsBtn;
14
+ let voiceModeSelect, realtimeVoiceRow, realtimeVoiceSelect, realtimeKeyRow, realtimeKeyInput, realtimeKeySaveBtn, realtimeKeyStatus;
15
+
16
+ // Focus trap elements
17
+ let focusableElements = [];
18
+ let firstFocusableElement = null;
19
+ let lastFocusableElement = null;
20
+
21
+ // Cleanup: MutationObserver and AbortController for event listeners
22
+ let panelObserver = null;
23
+ let eventsAbortController = null;
24
+
25
+ function init() {
26
+ settingsBtn = document.getElementById('settingsBtn');
27
+ settingsPanel = document.getElementById('settingsPanel');
28
+ agentNameInput = document.getElementById('agentNameInput');
29
+ gatewayUrlInput = document.getElementById('gatewayUrlInput');
30
+ encryptToggle = document.getElementById('encryptToggle');
31
+ changePasswordRow = document.getElementById('changePasswordRow');
32
+ changePasswordBtn = document.getElementById('changePasswordBtn');
33
+ syncRow = document.getElementById('syncRow');
34
+ syncPushBtn = document.getElementById('syncPushBtn');
35
+ syncPullBtn = document.getElementById('syncPullBtn');
36
+ syncStatus = document.getElementById('syncStatus');
37
+ clearBtn = document.getElementById('settingsClearBtn');
38
+ logoutBtn = document.getElementById('logoutBtn');
39
+ textInput = document.getElementById('textInput');
40
+ showShortcutsBtn = document.getElementById('showShortcutsBtn');
41
+ voiceModeSelect = document.getElementById('voiceModeSelect');
42
+ realtimeVoiceRow = document.getElementById('realtimeVoiceRow');
43
+ realtimeVoiceSelect = document.getElementById('realtimeVoiceSelect');
44
+ realtimeKeyRow = document.getElementById('realtimeKeyRow');
45
+ realtimeKeyInput = document.getElementById('realtimeKeyInput');
46
+ realtimeKeySaveBtn = document.getElementById('realtimeKeySaveBtn');
47
+ realtimeKeyStatus = document.getElementById('realtimeKeyStatus');
48
+
49
+ if (!settingsBtn || !settingsPanel) {
50
+ console.warn('Settings: Elements not found, retrying...');
51
+ setTimeout(init, 100);
52
+ return;
53
+ }
54
+
55
+ // Initialize sub-modules
56
+ if (window.UplinkAppearanceSettings) window.UplinkAppearanceSettings.init();
57
+ if (window.UplinkTTSSettings) window.UplinkTTSSettings.init();
58
+ if (window.UplinkSTTSettings) window.UplinkSTTSettings.init();
59
+
60
+ // Setup collapsible sections
61
+ setupSections();
62
+
63
+ // Setup event listeners
64
+ setupEvents();
65
+
66
+ // Apply initial state
67
+ applyState();
68
+
69
+ // Register with panel manager
70
+ if (window.UplinkPanels && settingsPanel) {
71
+ window.UplinkPanels.register('settings', {
72
+ element: settingsPanel,
73
+ isOpen: () => settingsPanel.classList.contains('visible'),
74
+ open: () => {
75
+ settingsPanel.classList.add('visible');
76
+ onPanelOpen();
77
+ },
78
+ close: () => {
79
+ settingsPanel.classList.remove('visible');
80
+ onPanelClose();
81
+ }
82
+ });
83
+ }
84
+
85
+ // Cleanup previous observer if init is called again
86
+ if (panelObserver) {
87
+ panelObserver.disconnect();
88
+ panelObserver = null;
89
+ }
90
+
91
+ // Listen for panel visibility changes (for focus trap)
92
+ panelObserver = new MutationObserver((mutations) => {
93
+ mutations.forEach((mutation) => {
94
+ if (mutation.attributeName === 'class') {
95
+ const isVisible = settingsPanel.classList.contains('visible');
96
+ if (isVisible) {
97
+ onPanelOpen();
98
+ } else {
99
+ onPanelClose();
100
+ }
101
+ }
102
+ });
103
+ });
104
+
105
+ if (settingsPanel) {
106
+ panelObserver.observe(settingsPanel, { attributes: true });
107
+ }
108
+
109
+ // Bind logout button (now in HTML)
110
+ const existingLogoutBtn = document.getElementById('logoutBtn');
111
+ if (existingLogoutBtn) {
112
+ existingLogoutBtn.addEventListener('click', handleLogout);
113
+ }
114
+ console.log('Settings: Initialized');
115
+ }
116
+
117
+ // Collapsible section management
118
+ function setupSections() {
119
+ if (!settingsPanel) return;
120
+
121
+ const headers = settingsPanel.querySelectorAll('.settings-section-header');
122
+ headers.forEach(header => {
123
+ header.addEventListener('click', () => {
124
+ const expanded = header.getAttribute('aria-expanded') === 'true';
125
+ const bodyId = header.getAttribute('aria-controls');
126
+ const body = document.getElementById(bodyId);
127
+ if (!body) return;
128
+
129
+ if (expanded) {
130
+ // Collapse
131
+ header.setAttribute('aria-expanded', 'false');
132
+ body.classList.add('collapsed');
133
+ } else {
134
+ // Expand
135
+ header.setAttribute('aria-expanded', 'true');
136
+ body.classList.remove('collapsed');
137
+ }
138
+
139
+ // Update focusable elements for focus trap
140
+ updateFocusableElements();
141
+ });
142
+ });
143
+
144
+ // Restore saved section states
145
+ const savedSections = localStorage.getItem('uplink-settings-sections');
146
+ if (savedSections) {
147
+ try {
148
+ const states = JSON.parse(savedSections);
149
+ headers.forEach(header => {
150
+ const section = header.closest('.settings-section');
151
+ const key = section?.dataset.section;
152
+ if (key && states[key] !== undefined) {
153
+ const bodyId = header.getAttribute('aria-controls');
154
+ const body = document.getElementById(bodyId);
155
+ if (!body) return;
156
+
157
+ if (states[key]) {
158
+ header.setAttribute('aria-expanded', 'true');
159
+ body.classList.remove('collapsed');
160
+ } else {
161
+ header.setAttribute('aria-expanded', 'false');
162
+ body.classList.add('collapsed');
163
+ }
164
+ }
165
+ });
166
+ } catch (e) { /* ignore corrupt data */ }
167
+ }
168
+
169
+ // Save section states on toggle
170
+ settingsPanel.addEventListener('click', (e) => {
171
+ const header = e.target.closest('.settings-section-header');
172
+ if (!header) return;
173
+
174
+ // Debounce save
175
+ setTimeout(saveSectionStates, 50);
176
+ });
177
+ }
178
+
179
+ function saveSectionStates() {
180
+ if (!settingsPanel) return;
181
+ const states = {};
182
+ settingsPanel.querySelectorAll('.settings-section').forEach(section => {
183
+ const key = section.dataset.section;
184
+ const header = section.querySelector('.settings-section-header');
185
+ if (key && header) {
186
+ states[key] = header.getAttribute('aria-expanded') === 'true';
187
+ }
188
+ });
189
+ localStorage.setItem('uplink-settings-sections', JSON.stringify(states));
190
+ }
191
+
192
+ // Show toast notification
193
+ function showToast(message, type = 'success') {
194
+ const existingToast = document.querySelector('.settings-toast');
195
+ if (existingToast) {
196
+ existingToast.remove();
197
+ }
198
+
199
+ const toast = document.createElement('div');
200
+ toast.className = `settings-toast ${type}`;
201
+ toast.textContent = message;
202
+ // M-35: Announce toast to screen readers
203
+ toast.setAttribute('role', 'alert');
204
+ toast.setAttribute('aria-live', 'polite');
205
+ toast.style.cssText = `
206
+ position: fixed;
207
+ bottom: 20px;
208
+ left: 50%;
209
+ transform: translateX(-50%);
210
+ background: ${type === 'success' ? '#10b981' : '#ef4444'};
211
+ color: white;
212
+ padding: 12px 24px;
213
+ border-radius: 8px;
214
+ font-size: 14px;
215
+ font-weight: 500;
216
+ z-index: 10000;
217
+ box-shadow: 0 4px 12px rgba(0,0,0,0.15);
218
+ opacity: 0;
219
+ transition: opacity 0.3s ease;
220
+ `;
221
+
222
+ document.body.appendChild(toast);
223
+
224
+ // Trigger animation
225
+ requestAnimationFrame(() => {
226
+ toast.style.opacity = '1';
227
+ });
228
+
229
+ // Remove after 3 seconds
230
+ setTimeout(() => {
231
+ toast.style.opacity = '0';
232
+ setTimeout(() => toast.remove(), 300);
233
+ }, 3000);
234
+ }
235
+
236
+ // Focus trap functions
237
+ function updateFocusableElements() {
238
+ if (!settingsPanel) return;
239
+
240
+ focusableElements = Array.from(
241
+ settingsPanel.querySelectorAll(
242
+ 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
243
+ )
244
+ ).filter(el => !el.disabled && el.offsetParent !== null);
245
+
246
+ firstFocusableElement = focusableElements[0];
247
+ lastFocusableElement = focusableElements[focusableElements.length - 1];
248
+ }
249
+
250
+ function handleFocusTrap(e) {
251
+ if (e.key !== 'Tab' || !settingsPanel?.classList.contains('visible')) return;
252
+
253
+ updateFocusableElements();
254
+
255
+ if (focusableElements.length === 0) return;
256
+
257
+ if (e.shiftKey) {
258
+ // Shift + Tab
259
+ if (document.activeElement === firstFocusableElement) {
260
+ e.preventDefault();
261
+ lastFocusableElement.focus();
262
+ }
263
+ } else {
264
+ // Tab
265
+ if (document.activeElement === lastFocusableElement) {
266
+ e.preventDefault();
267
+ firstFocusableElement.focus();
268
+ }
269
+ }
270
+ }
271
+
272
+ function onPanelOpen() {
273
+ updateFocusableElements();
274
+ // Focus first focusable element
275
+ if (firstFocusableElement) {
276
+ firstFocusableElement.focus();
277
+ }
278
+ // Add focus trap listener
279
+ document.addEventListener('keydown', handleFocusTrap);
280
+ }
281
+
282
+ function onPanelClose() {
283
+ // Remove focus trap listener
284
+ document.removeEventListener('keydown', handleFocusTrap);
285
+ // Return focus to settings button
286
+ if (settingsBtn) {
287
+ settingsBtn.focus();
288
+ }
289
+ }
290
+
291
+ function applyState() {
292
+ const core = window.UplinkCore;
293
+ if (!core) return;
294
+
295
+ // Update inputs with current values
296
+ if (agentNameInput) agentNameInput.value = core.agentName;
297
+ if (gatewayUrlInput) gatewayUrlInput.value = core.gatewayUrl;
298
+ if (encryptToggle) {
299
+ encryptToggle.classList.toggle('on', core.encryptionEnabled);
300
+ encryptToggle.setAttribute('aria-checked', core.encryptionEnabled ? 'true' : 'false');
301
+ }
302
+ if (changePasswordRow) changePasswordRow.style.display = core.encryptionEnabled ? 'flex' : 'none';
303
+ if (syncRow) syncRow.style.display = core.encryptionEnabled ? 'flex' : 'none';
304
+ if (textInput) textInput.placeholder = `Message ${core.agentName}...`;
305
+
306
+ // Apply sub-module states
307
+ if (window.UplinkAppearanceSettings) window.UplinkAppearanceSettings.applyState();
308
+ if (window.UplinkTTSSettings) window.UplinkTTSSettings.applyState();
309
+ if (window.UplinkSTTSettings) window.UplinkSTTSettings.applyState();
310
+
311
+ // Load voice mode settings
312
+ loadVoiceModeSettings();
313
+
314
+ // Update about section
315
+ updateAboutSection();
316
+ }
317
+
318
+ async function loadVoiceModeSettings() {
319
+ try {
320
+ const response = await fetch('/api/config');
321
+ if (!response.ok) return;
322
+ const config = await response.json();
323
+
324
+ // Select the correct voice mode card
325
+ const mode = config.voiceMode || 'push-to-talk';
326
+ const voiceModeCards = document.querySelectorAll('.voice-mode-card');
327
+ voiceModeCards.forEach(card => {
328
+ if (card.dataset.mode === mode) {
329
+ card.classList.add('selected');
330
+ card.setAttribute('aria-checked', 'true');
331
+ } else {
332
+ card.classList.remove('selected');
333
+ card.setAttribute('aria-checked', 'false');
334
+ }
335
+ });
336
+
337
+ // Update agent name in agent-voice card description
338
+ const agentVoiceDesc = document.querySelector('.voice-mode-card[data-mode="agent-voice"] .voice-mode-desc');
339
+ if (agentVoiceDesc && config.assistantName) {
340
+ agentVoiceDesc.textContent = `Talk to ${config.assistantName} — full tools & memory`;
341
+ }
342
+
343
+ // Show/hide conditional settings
344
+ updateVoiceModeSettings(mode);
345
+
346
+ // Load voice-specific settings
347
+ if (realtimeVoiceSelect && config.realtimeVoice) {
348
+ realtimeVoiceSelect.value = config.realtimeVoice;
349
+ }
350
+ if (realtimeKeyStatus) {
351
+ realtimeKeyStatus.textContent = config.hasOpenaiKey
352
+ ? '✓ Key saved'
353
+ : 'Required for real-time voice';
354
+ }
355
+
356
+ // Load live voice settings
357
+ const liveVoiceSelect = document.getElementById('liveVoiceSelect');
358
+ if (liveVoiceSelect && config.realtimeVoice) {
359
+ liveVoiceSelect.value = config.realtimeVoice;
360
+ }
361
+ const liveVoiceKeyStatus = document.getElementById('liveVoiceKeyStatus');
362
+ if (liveVoiceKeyStatus) {
363
+ liveVoiceKeyStatus.textContent = config.hasOpenaiKey
364
+ ? '✓ Key saved'
365
+ : 'Required for real-time voice';
366
+ }
367
+
368
+ // Load agent voice settings
369
+ const agentVoiceTtsEngineSelect = document.getElementById('agentVoiceTtsEngineSelect');
370
+ if (agentVoiceTtsEngineSelect && config.agentVoiceTtsEngine) {
371
+ agentVoiceTtsEngineSelect.value = config.agentVoiceTtsEngine;
372
+ }
373
+ const agentVoiceTtsVoiceSelect = document.getElementById('agentVoiceTtsVoiceSelect');
374
+ if (agentVoiceTtsVoiceSelect && config.agentVoiceTtsVoice) {
375
+ agentVoiceTtsVoiceSelect.value = config.agentVoiceTtsVoice;
376
+ }
377
+
378
+ // Load VAD sensitivity
379
+ const vadSlider = document.getElementById('vadSensitivitySlider');
380
+ const vadValue = document.getElementById('vadSensitivityValue');
381
+ if (vadSlider) {
382
+ const savedVad = config.vadSilenceDurationMs || 400;
383
+ vadSlider.value = savedVad;
384
+ if (vadValue) vadValue.textContent = `${(savedVad / 1000).toFixed(1)}s`;
385
+ }
386
+ } catch (error) {
387
+ console.warn('Settings: Failed to load voice mode settings', error);
388
+ }
389
+ }
390
+
391
+ function updateVoiceModeSettings(mode) {
392
+ // Get containers for conditional settings
393
+ const pushToTalkSettings = document.getElementById('pushToTalkSettings');
394
+ const liveVoiceSettings = document.getElementById('liveVoiceSettings');
395
+ const agentVoiceSettings = document.getElementById('agentVoiceSettings');
396
+
397
+ // Hide all
398
+ if (pushToTalkSettings) pushToTalkSettings.classList.add('setting-hidden');
399
+ if (liveVoiceSettings) liveVoiceSettings.classList.add('setting-hidden');
400
+ if (agentVoiceSettings) agentVoiceSettings.classList.add('setting-hidden');
401
+
402
+ // Show relevant settings based on mode
403
+ if (mode === 'push-to-talk' && pushToTalkSettings) {
404
+ pushToTalkSettings.classList.remove('setting-hidden');
405
+ } else if (mode === 'live-voice' && liveVoiceSettings) {
406
+ liveVoiceSettings.classList.remove('setting-hidden');
407
+ } else if (mode === 'agent-voice' && agentVoiceSettings) {
408
+ agentVoiceSettings.classList.remove('setting-hidden');
409
+ }
410
+ }
411
+
412
+ async function updateAboutSection() {
413
+ const versionEl = document.getElementById('aboutVersion');
414
+ const statusEl = document.getElementById('aboutGatewayStatus');
415
+ const dotEl = document.getElementById('aboutGatewayDot');
416
+
417
+ if (!statusEl || !dotEl) return;
418
+
419
+ try {
420
+ // Use Uplink's own server-side gateway check instead of hitting the gateway directly
421
+ // This avoids CORS issues and works regardless of gateway URL format (ws://, http://, etc.)
422
+ const controller = new AbortController();
423
+ const timeout = setTimeout(() => controller.abort(), 5000);
424
+
425
+ const resp = await fetch('/api/session/status', { signal: controller.signal });
426
+ clearTimeout(timeout);
427
+
428
+ if (resp.ok) {
429
+ const data = await resp.json();
430
+ if (data.gatewayConnected) {
431
+ statusEl.textContent = 'Connected';
432
+ dotEl.className = 'status-indicator connected';
433
+ } else {
434
+ statusEl.textContent = 'Disconnected';
435
+ dotEl.className = 'status-indicator disconnected';
436
+ }
437
+ } else {
438
+ statusEl.textContent = `Error (${resp.status})`;
439
+ dotEl.className = 'status-indicator disconnected';
440
+ }
441
+ } catch (e) {
442
+ statusEl.textContent = 'Unreachable';
443
+ dotEl.className = 'status-indicator disconnected';
444
+ }
445
+ }
446
+
447
+ // Server settings state (for tracking changes)
448
+ let serverSettingsOriginal = { watchdogEnabled: true, networkAccess: false };
449
+
450
+ async function fetchServerSettings() {
451
+ try {
452
+ const response = await fetch('/api/config/server');
453
+ if (!response.ok) return;
454
+
455
+ const data = await response.json();
456
+ const watchdogToggle = document.getElementById('watchdogToggle');
457
+ const networkAccessToggle = document.getElementById('networkAccessToggle');
458
+ const restartRow = document.getElementById('serverRestartRow');
459
+
460
+ if (watchdogToggle) watchdogToggle.checked = data.watchdogEnabled;
461
+ if (networkAccessToggle) networkAccessToggle.checked = data.networkAccess;
462
+
463
+ serverSettingsOriginal = {
464
+ watchdogEnabled: data.watchdogEnabled,
465
+ networkAccess: data.networkAccess
466
+ };
467
+
468
+ // Hide restart row initially
469
+ if (restartRow) restartRow.style.display = 'none';
470
+ } catch (error) {
471
+ console.warn('Settings: Failed to fetch server settings', error);
472
+ }
473
+ }
474
+
475
+ function checkServerSettingsChanged() {
476
+ const watchdogToggle = document.getElementById('watchdogToggle');
477
+ const networkAccessToggle = document.getElementById('networkAccessToggle');
478
+ const restartRow = document.getElementById('serverRestartRow');
479
+
480
+ if (!watchdogToggle || !networkAccessToggle || !restartRow) return;
481
+
482
+ const changed = watchdogToggle.checked !== serverSettingsOriginal.watchdogEnabled ||
483
+ networkAccessToggle.checked !== serverSettingsOriginal.networkAccess;
484
+
485
+ restartRow.style.display = changed ? 'flex' : 'none';
486
+ }
487
+
488
+ async function saveServerSettings() {
489
+ const watchdogToggle = document.getElementById('watchdogToggle');
490
+ const networkAccessToggle = document.getElementById('networkAccessToggle');
491
+
492
+ if (!watchdogToggle || !networkAccessToggle) return;
493
+
494
+ try {
495
+ const response = await fetch('/api/config/server', {
496
+ method: 'POST',
497
+ headers: { 'Content-Type': 'application/json' },
498
+ body: JSON.stringify({
499
+ watchdogEnabled: watchdogToggle.checked,
500
+ networkAccess: networkAccessToggle.checked
501
+ })
502
+ });
503
+
504
+ if (!response.ok) throw new Error('Failed to save');
505
+ return true;
506
+ } catch (error) {
507
+ console.error('Settings: Failed to save server settings', error);
508
+ return false;
509
+ }
510
+ }
511
+
512
+ async function restartServer() {
513
+ const saved = await saveServerSettings();
514
+ if (!saved) {
515
+ alert('Failed to save settings. Please try again.');
516
+ return;
517
+ }
518
+
519
+ const restartBtn = document.getElementById('serverRestartBtn');
520
+ if (restartBtn) {
521
+ restartBtn.textContent = 'Restarting...';
522
+ restartBtn.disabled = true;
523
+ }
524
+
525
+ try {
526
+ await fetch('/api/config/server/restart', { method: 'POST' });
527
+ } catch {
528
+ // Expected — server is shutting down
529
+ }
530
+
531
+ // Wait for server to come back, then reload
532
+ let attempts = 0;
533
+ const checkReady = setInterval(async () => {
534
+ attempts++;
535
+ if (attempts > 30) { // 15 seconds max
536
+ clearInterval(checkReady);
537
+ if (restartBtn) {
538
+ restartBtn.textContent = 'Restart Server';
539
+ restartBtn.disabled = false;
540
+ }
541
+ alert('Server did not restart in time. Please restart manually.');
542
+ return;
543
+ }
544
+ try {
545
+ const res = await fetch('/api/status', { signal: AbortSignal.timeout(2000) });
546
+ if (res.ok) {
547
+ clearInterval(checkReady);
548
+ window.location.reload();
549
+ }
550
+ } catch {
551
+ // Server still down, keep trying
552
+ }
553
+ }, 500);
554
+ }
555
+
556
+ function setupEvents() {
557
+ // Abort previous event listeners to prevent stacking if init called multiple times
558
+ if (eventsAbortController) {
559
+ eventsAbortController.abort();
560
+ }
561
+ eventsAbortController = new AbortController();
562
+ const signal = eventsAbortController.signal;
563
+
564
+ // Toggle settings panel (via panel manager for mutual exclusivity)
565
+ settingsBtn?.addEventListener('click', () => {
566
+ if (window.UplinkPanels) {
567
+ window.UplinkPanels.toggle('settings', settingsBtn);
568
+ } else {
569
+ settingsPanel?.classList.toggle('visible');
570
+ }
571
+ });
572
+
573
+ // Close button
574
+ const closeBtn = document.getElementById('settingsCloseBtn');
575
+ closeBtn?.addEventListener('click', () => {
576
+ if (window.UplinkPanels) {
577
+ window.UplinkPanels.close('settings');
578
+ } else {
579
+ settingsPanel?.classList.remove('visible');
580
+ }
581
+ });
582
+
583
+ // Agent name change
584
+ agentNameInput?.addEventListener('change', () => {
585
+ const core = window.UplinkCore;
586
+ if (core) {
587
+ core.agentName = agentNameInput.value.trim() || 'Assistant';
588
+ if (textInput) textInput.placeholder = `Message ${core.agentName}...`;
589
+ }
590
+ });
591
+
592
+ // Gateway URL change with validation
593
+ gatewayUrlInput?.addEventListener('change', async () => {
594
+ const core = window.UplinkCore;
595
+ if (!core) return;
596
+
597
+ // Mobile keyboard fixes: trim, lowercase, remove spaces, fix autocorrect
598
+ let newUrl = gatewayUrlInput.value.trim().toLowerCase();
599
+ newUrl = newUrl.replace(/\s+/g, ''); // Remove spaces mobile keyboards add
600
+ newUrl = newUrl.replace(/local\s*host/gi, 'localhost'); // Fix "local host"
601
+
602
+ // Add http:// if no protocol
603
+ if (newUrl && !/^(https?|wss?):\/\//i.test(newUrl)) {
604
+ newUrl = 'http://' + newUrl;
605
+ }
606
+
607
+ // Update input with cleaned URL
608
+ gatewayUrlInput.value = newUrl;
609
+
610
+ if (!newUrl) return;
611
+
612
+ // Validate URL format
613
+ try {
614
+ new URL(newUrl);
615
+ } catch (e) {
616
+ showToast('Invalid URL format', 'error');
617
+ gatewayUrlInput.value = core.gatewayUrl;
618
+ return;
619
+ }
620
+
621
+ // Convert WebSocket URLs to HTTP for health check
622
+ const healthCheckUrl = newUrl
623
+ .replace(/^ws:/, 'http:')
624
+ .replace(/^wss:/, 'https:')
625
+ .replace(/\/$/, '');
626
+
627
+ // Test gateway connectivity
628
+ try {
629
+ const controller = new AbortController();
630
+ const timeoutId = setTimeout(() => controller.abort(), 5000);
631
+
632
+ const response = await fetch(`${healthCheckUrl}/health`, {
633
+ method: 'GET',
634
+ signal: controller.signal
635
+ });
636
+
637
+ clearTimeout(timeoutId);
638
+
639
+ if (!response.ok) {
640
+ throw new Error(`HTTP ${response.status}`);
641
+ }
642
+
643
+ // Gateway is reachable, save the URL
644
+ core.gatewayUrl = newUrl;
645
+ showToast('Settings saved!', 'success');
646
+ } catch (error) {
647
+ console.error('Gateway validation failed:', error);
648
+ // Still save the URL but warn user - they might know it works
649
+ core.gatewayUrl = newUrl;
650
+ showToast('Gateway unreachable, but URL saved. Check connection.', 'warning');
651
+ }
652
+ });
653
+
654
+ // Encryption toggle
655
+ encryptToggle?.addEventListener('click', toggleEncryption);
656
+
657
+ // Encryption toggle keyboard handler
658
+ encryptToggle?.addEventListener('keydown', (e) => {
659
+ if (e.key === 'Enter' || e.key === ' ') {
660
+ e.preventDefault();
661
+ encryptToggle.click();
662
+ }
663
+ });
664
+
665
+ // Change password
666
+ changePasswordBtn?.addEventListener('click', changePassword);
667
+
668
+ // Sync buttons
669
+ syncPushBtn?.addEventListener('click', handleSyncPush);
670
+ syncPullBtn?.addEventListener('click', handleSyncPull);
671
+
672
+ // Clear chat
673
+ clearBtn?.addEventListener('click', clearChat);
674
+
675
+ // Show keyboard shortcuts
676
+ showShortcutsBtn?.addEventListener('click', () => {
677
+ if (window.UplinkShortcuts?.show) {
678
+ window.UplinkShortcuts.show();
679
+ }
680
+ });
681
+
682
+ // Server settings: watchdog toggle
683
+ const watchdogToggle = document.getElementById('watchdogToggle');
684
+ watchdogToggle?.addEventListener('change', () => {
685
+ checkServerSettingsChanged();
686
+ }, { signal });
687
+
688
+ // Server settings: network access toggle
689
+ const networkAccessToggle = document.getElementById('networkAccessToggle');
690
+ networkAccessToggle?.addEventListener('change', () => {
691
+ checkServerSettingsChanged();
692
+ }, { signal });
693
+
694
+ // Server settings: restart button
695
+ const serverRestartBtn = document.getElementById('serverRestartBtn');
696
+ serverRestartBtn?.addEventListener('click', restartServer, { signal });
697
+
698
+ // Voice mode settings (radio cards)
699
+ const voiceModeCards = document.querySelectorAll('.voice-mode-card');
700
+
701
+ async function selectVoiceMode(card) {
702
+ const mode = card.dataset.mode;
703
+ if (!mode) return;
704
+
705
+ // Update UI selection
706
+ voiceModeCards.forEach(c => {
707
+ c.classList.remove('selected');
708
+ c.setAttribute('aria-checked', 'false');
709
+ });
710
+ card.classList.add('selected');
711
+ card.setAttribute('aria-checked', 'true');
712
+
713
+ // Save to config
714
+ try {
715
+ await fetch('/api/config', {
716
+ method: 'POST',
717
+ headers: { 'Content-Type': 'application/json' },
718
+ body: JSON.stringify({ voiceMode: mode })
719
+ });
720
+
721
+ // Show/hide conditional settings
722
+ updateVoiceModeSettings(mode);
723
+
724
+ showToast(`Voice mode: ${card.querySelector('.voice-mode-title').textContent}`, 'success');
725
+ } catch {
726
+ showToast('Failed to save voice mode', 'error');
727
+ }
728
+ }
729
+
730
+ voiceModeCards?.forEach(card => {
731
+ // Click handler
732
+ card.addEventListener('click', () => selectVoiceMode(card), { signal });
733
+
734
+ // Keyboard handler
735
+ card.addEventListener('keydown', (e) => {
736
+ if (e.key === 'Enter' || e.key === ' ') {
737
+ e.preventDefault();
738
+ selectVoiceMode(card);
739
+ }
740
+ }, { signal });
741
+ });
742
+
743
+ realtimeKeySaveBtn?.addEventListener('click', async () => {
744
+ const key = realtimeKeyInput?.value?.trim();
745
+ if (!key) {
746
+ showToast('Enter an API key', 'error');
747
+ return;
748
+ }
749
+ try {
750
+ await fetch('/api/config', {
751
+ method: 'POST',
752
+ headers: { 'Content-Type': 'application/json' },
753
+ body: JSON.stringify({ openaiApiKey: key })
754
+ });
755
+ realtimeKeyInput.value = '';
756
+ if (realtimeKeyStatus) realtimeKeyStatus.textContent = '✓ Key saved';
757
+ showToast('OpenAI API key saved', 'success');
758
+ } catch {
759
+ showToast('Failed to save API key', 'error');
760
+ }
761
+ }, { signal });
762
+
763
+ realtimeVoiceSelect?.addEventListener('change', async () => {
764
+ try {
765
+ await fetch('/api/config', {
766
+ method: 'POST',
767
+ headers: { 'Content-Type': 'application/json' },
768
+ body: JSON.stringify({ realtimeVoice: realtimeVoiceSelect.value })
769
+ });
770
+ showToast(`Real-time voice: ${realtimeVoiceSelect.value}`, 'success');
771
+ } catch {
772
+ showToast('Failed to save voice setting', 'error');
773
+ }
774
+ }, { signal });
775
+
776
+ // Live Voice settings
777
+ const liveVoiceKeySaveBtn = document.getElementById('liveVoiceKeySaveBtn');
778
+ const liveVoiceKeyInput = document.getElementById('liveVoiceKeyInput');
779
+ const liveVoiceKeyStatus = document.getElementById('liveVoiceKeyStatus');
780
+ const liveVoiceSelect = document.getElementById('liveVoiceSelect');
781
+
782
+ liveVoiceKeySaveBtn?.addEventListener('click', async () => {
783
+ const key = liveVoiceKeyInput?.value?.trim();
784
+ if (!key) {
785
+ showToast('Enter an API key', 'error');
786
+ return;
787
+ }
788
+ try {
789
+ await fetch('/api/config', {
790
+ method: 'POST',
791
+ headers: { 'Content-Type': 'application/json' },
792
+ body: JSON.stringify({ openaiApiKey: key })
793
+ });
794
+ liveVoiceKeyInput.value = '';
795
+ if (liveVoiceKeyStatus) liveVoiceKeyStatus.textContent = '✓ Key saved';
796
+ showToast('OpenAI API key saved', 'success');
797
+ } catch {
798
+ showToast('Failed to save API key', 'error');
799
+ }
800
+ }, { signal });
801
+
802
+ liveVoiceSelect?.addEventListener('change', async () => {
803
+ try {
804
+ await fetch('/api/config', {
805
+ method: 'POST',
806
+ headers: { 'Content-Type': 'application/json' },
807
+ body: JSON.stringify({ realtimeVoice: liveVoiceSelect.value })
808
+ });
809
+ showToast(`Live voice: ${liveVoiceSelect.value}`, 'success');
810
+ } catch {
811
+ showToast('Failed to save voice setting', 'error');
812
+ }
813
+ }, { signal });
814
+
815
+ // Agent Voice settings
816
+ const agentVoiceTtsEngineSelect = document.getElementById('agentVoiceTtsEngineSelect');
817
+ const agentVoiceTtsVoiceSelect = document.getElementById('agentVoiceTtsVoiceSelect');
818
+
819
+ agentVoiceTtsEngineSelect?.addEventListener('change', async () => {
820
+ try {
821
+ await fetch('/api/config', {
822
+ method: 'POST',
823
+ headers: { 'Content-Type': 'application/json' },
824
+ body: JSON.stringify({ agentVoiceTtsEngine: agentVoiceTtsEngineSelect.value })
825
+ });
826
+ showToast(`Agent TTS engine: ${agentVoiceTtsEngineSelect.value}`, 'success');
827
+ } catch {
828
+ showToast('Failed to save TTS engine', 'error');
829
+ }
830
+ }, { signal });
831
+
832
+ agentVoiceTtsVoiceSelect?.addEventListener('change', async () => {
833
+ try {
834
+ await fetch('/api/config', {
835
+ method: 'POST',
836
+ headers: { 'Content-Type': 'application/json' },
837
+ body: JSON.stringify({ agentVoiceTtsVoice: agentVoiceTtsVoiceSelect.value })
838
+ });
839
+ showToast(`Agent TTS voice: ${agentVoiceTtsVoiceSelect.value}`, 'success');
840
+ } catch {
841
+ showToast('Failed to save TTS voice', 'error');
842
+ }
843
+ }, { signal });
844
+
845
+ // VAD sensitivity slider
846
+ const vadSlider = document.getElementById('vadSensitivitySlider');
847
+ const vadValue = document.getElementById('vadSensitivityValue');
848
+ let vadSaveTimeout = null;
849
+ vadSlider?.addEventListener('input', () => {
850
+ const ms = parseInt(vadSlider.value);
851
+ if (vadValue) vadValue.textContent = `${(ms / 1000).toFixed(1)}s`;
852
+ // Debounce save
853
+ if (vadSaveTimeout) clearTimeout(vadSaveTimeout);
854
+ vadSaveTimeout = setTimeout(async () => {
855
+ try {
856
+ await fetch('/api/config', {
857
+ method: 'POST',
858
+ headers: { 'Content-Type': 'application/json' },
859
+ body: JSON.stringify({ vadSilenceDurationMs: ms })
860
+ });
861
+ showToast(`Speech detection: ${(ms / 1000).toFixed(1)}s`, 'success');
862
+ } catch {
863
+ showToast('Failed to save speech detection setting', 'error');
864
+ }
865
+ }, 500);
866
+ }, { signal });
867
+
868
+ // Fetch server settings on panel open
869
+ fetchServerSettings();
870
+
871
+ // Listen for unlocked event to apply state
872
+ window.addEventListener('uplink:unlocked', applyState, { signal });
873
+ }
874
+
875
+ async function toggleEncryption() {
876
+ const core = window.UplinkCore;
877
+ const storage = window.UplinkStorage;
878
+ if (!core || !storage) return;
879
+
880
+ if (core.encryptionEnabled) {
881
+ // Turning off
882
+ if (confirm('Disable encryption? Your chat history will be stored unencrypted.')) {
883
+ await storage.migrateHistory(false);
884
+ core.encryptionEnabled = false;
885
+ core.state.currentPassword = null;
886
+ encryptToggle?.classList.remove('on');
887
+ encryptToggle?.setAttribute('aria-checked', 'false');
888
+ if (changePasswordRow) changePasswordRow.style.display = 'none';
889
+ if (syncRow) syncRow.style.display = 'none';
890
+ }
891
+ } else {
892
+ // Turning on
893
+ const pass = prompt('Enter a password to encrypt your chat history (min 8 characters):');
894
+ if (pass && pass.length >= 8) {
895
+ const confirmPass = prompt('Confirm password:');
896
+ if (pass === confirmPass) {
897
+ core.state.currentPassword = pass;
898
+ await storage.migrateHistory(true, pass);
899
+ core.encryptionEnabled = true;
900
+ encryptToggle?.classList.add('on');
901
+ encryptToggle?.setAttribute('aria-checked', 'true');
902
+ if (changePasswordRow) changePasswordRow.style.display = 'flex';
903
+ if (syncRow) syncRow.style.display = 'flex';
904
+ } else {
905
+ alert('Passwords do not match');
906
+ }
907
+ } else if (pass) {
908
+ alert('Password must be at least 8 characters');
909
+ }
910
+ }
911
+ }
912
+
913
+ async function changePassword() {
914
+ const core = window.UplinkCore;
915
+ const crypto = window.UplinkEncryption;
916
+ const storage = window.UplinkStorage;
917
+ if (!core || !crypto || !storage) return;
918
+
919
+ const oldPass = prompt('Enter current password:');
920
+ if (!oldPass) return;
921
+
922
+ // Verify old password
923
+ const valid = await crypto.verifyPassword(oldPass);
924
+ if (!valid) {
925
+ alert('Incorrect password');
926
+ return;
927
+ }
928
+
929
+ const newPass = prompt('Enter new password (min 8 characters):');
930
+ if (!newPass || newPass.length < 8) {
931
+ if (newPass) alert('Password must be at least 8 characters');
932
+ return;
933
+ }
934
+
935
+ const confirmPass = prompt('Confirm new password:');
936
+ if (newPass !== confirmPass) {
937
+ alert('Passwords do not match');
938
+ return;
939
+ }
940
+
941
+ // Re-encrypt with new password
942
+ core.state.currentPassword = oldPass;
943
+ await storage.migrateHistory(true, newPass);
944
+ core.state.currentPassword = newPass;
945
+ alert('Password changed successfully');
946
+ }
947
+
948
+ function clearChat() {
949
+ const core = window.UplinkCore;
950
+ const agentName = core?.agentName || 'your assistant';
951
+
952
+ if (!confirm(`Clear chat history?\n\nThis only clears your local browser view - ${agentName} still remembers the conversation.`)) {
953
+ return;
954
+ }
955
+
956
+ const chat = window.UplinkChat;
957
+ const storage = window.UplinkStorage;
958
+
959
+ chat?.clearMessages();
960
+ storage?.clearHistory();
961
+ }
962
+
963
+ // Sync handlers
964
+ async function handleSyncPush() {
965
+ const storage = window.UplinkStorage;
966
+ if (!storage) return;
967
+
968
+ if (syncStatus) syncStatus.textContent = 'Pushing...';
969
+ if (syncPushBtn) syncPushBtn.disabled = true;
970
+
971
+ try {
972
+ await storage.pushSync();
973
+ if (syncStatus) syncStatus.textContent = 'Pushed successfully!';
974
+ setTimeout(() => {
975
+ if (syncStatus) syncStatus.textContent = 'Same password syncs to same account';
976
+ }, 3000);
977
+ } catch (e) {
978
+ console.error('Sync push failed:', e);
979
+ if (syncStatus) syncStatus.textContent = 'Push failed: ' + e.message;
980
+ } finally {
981
+ if (syncPushBtn) syncPushBtn.disabled = false;
982
+ }
983
+ }
984
+
985
+ async function handleSyncPull() {
986
+ const storage = window.UplinkStorage;
987
+ if (!storage) return;
988
+
989
+ if (syncStatus) syncStatus.textContent = 'Pulling...';
990
+ if (syncPullBtn) syncPullBtn.disabled = true;
991
+
992
+ try {
993
+ const syncData = await storage.pullSync();
994
+
995
+ if (!syncData) {
996
+ if (syncStatus) syncStatus.textContent = 'No sync data found for this password';
997
+ setTimeout(() => {
998
+ if (syncStatus) syncStatus.textContent = 'Same password syncs to same account';
999
+ }, 3000);
1000
+ return;
1001
+ }
1002
+
1003
+ // Ask user how to apply
1004
+ const mode = confirm('Replace local data with synced data?\n\nOK = Replace (recommended for new device)\nCancel = Merge (combine with local)')
1005
+ ? 'replace'
1006
+ : 'merge';
1007
+
1008
+ await storage.applySync(syncData, mode);
1009
+
1010
+ if (syncStatus) syncStatus.textContent = 'Synced successfully! Refreshing...';
1011
+
1012
+ // Reload page to apply changes
1013
+ setTimeout(() => location.reload(), 1000);
1014
+ } catch (e) {
1015
+ console.error('Sync pull failed:', e);
1016
+ if (syncStatus) syncStatus.textContent = 'Pull failed: ' + e.message;
1017
+ } finally {
1018
+ if (syncPullBtn) syncPullBtn.disabled = false;
1019
+ }
1020
+ }
1021
+
1022
+ async function handleLogout() {
1023
+ if (!confirm('Are you sure? This will clear all local Uplink data including chat history.')) return;
1024
+
1025
+ try {
1026
+ // Only remove Uplink-specific localStorage keys (not other apps on same origin)
1027
+ const uplinkKeys = [];
1028
+ for (let i = 0; i < localStorage.length; i++) {
1029
+ const key = localStorage.key(i);
1030
+ if (key && (
1031
+ key.startsWith('uplink') ||
1032
+ key.startsWith('chat') ||
1033
+ key.startsWith('settings') ||
1034
+ key.startsWith('gateway') ||
1035
+ key.startsWith('sync') ||
1036
+ key.startsWith('push') ||
1037
+ key.startsWith('voice') ||
1038
+ key.startsWith('theme') ||
1039
+ key.startsWith('tts') ||
1040
+ key.startsWith('messages') ||
1041
+ key.startsWith('satellite')
1042
+ )) {
1043
+ uplinkKeys.push(key);
1044
+ }
1045
+ }
1046
+ uplinkKeys.forEach(key => localStorage.removeItem(key));
1047
+
1048
+ sessionStorage.clear();
1049
+ const dbs = await window.indexedDB.databases?.();
1050
+ if (dbs) for (const db of dbs) if (db.name) window.indexedDB.deleteDatabase(db.name);
1051
+ location.reload();
1052
+ } catch (e) {
1053
+ console.error('Logout failed:', e);
1054
+ alert('Logout failed: ' + e.message);
1055
+ }
1056
+ }
1057
+
1058
+ // Cleanup function
1059
+ function destroy() {
1060
+ if (panelObserver) {
1061
+ panelObserver.disconnect();
1062
+ panelObserver = null;
1063
+ }
1064
+ if (eventsAbortController) {
1065
+ eventsAbortController.abort();
1066
+ eventsAbortController = null;
1067
+ }
1068
+ }
1069
+
1070
+ // Expose API
1071
+ export const UplinkSettings = {
1072
+ show: () => settingsPanel?.classList.add('visible'),
1073
+ hide: () => settingsPanel?.classList.remove('visible'),
1074
+ toggle: () => settingsPanel?.classList.toggle('visible'),
1075
+ applyState,
1076
+ logout: handleLogout,
1077
+ showToast,
1078
+ destroy
1079
+ };
1080
+
1081
+ import { UplinkCore } from './core.js';
1082
+
1083
+ // Backward compat: assign to window
1084
+ window.UplinkSettings = UplinkSettings;
1085
+
1086
+ // Register and init
1087
+ UplinkCore.registerModule('settings', init);