@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.
- package/LICENSE +21 -0
- package/README.md +185 -0
- package/bin/uplink.js +279 -0
- package/middleware/error-handler.js +69 -0
- package/package.json +93 -0
- package/public/css/agents.36b98c0f.css +1469 -0
- package/public/css/agents.css +1469 -0
- package/public/css/app.a6a7f8f5.css +2731 -0
- package/public/css/app.css +2731 -0
- package/public/css/artifacts.css +444 -0
- package/public/css/commands.css +55 -0
- package/public/css/connection.css +131 -0
- package/public/css/dashboard.css +233 -0
- package/public/css/developer.css +328 -0
- package/public/css/files.css +123 -0
- package/public/css/markdown.css +156 -0
- package/public/css/message-actions.css +278 -0
- package/public/css/mobile.css +614 -0
- package/public/css/panels-unified.css +483 -0
- package/public/css/premium.css +415 -0
- package/public/css/realtime.css +189 -0
- package/public/css/satellites.css +401 -0
- package/public/css/shortcuts.css +185 -0
- package/public/css/split-view.4def0262.css +673 -0
- package/public/css/split-view.css +673 -0
- package/public/css/theme-generator.css +391 -0
- package/public/css/themes.css +387 -0
- package/public/css/timestamps.css +54 -0
- package/public/css/variables.css +78 -0
- package/public/dist/bundle.b55050c4.js +15757 -0
- package/public/favicon.svg +24 -0
- package/public/img/agents/ada.png +0 -0
- package/public/img/agents/clarice.png +0 -0
- package/public/img/agents/dennis-nedry.png +0 -0
- package/public/img/agents/elliot-alderson.png +0 -0
- package/public/img/agents/main.png +0 -0
- package/public/img/agents/scotty.png +0 -0
- package/public/img/agents/top-flight-security.png +0 -0
- package/public/index.html +1083 -0
- package/public/js/agents-data.js +234 -0
- package/public/js/agents-ui.js +72 -0
- package/public/js/agents.js +1525 -0
- package/public/js/app.js +79 -0
- package/public/js/appearance-settings.js +111 -0
- package/public/js/artifacts.js +432 -0
- package/public/js/audio-queue.js +168 -0
- package/public/js/bootstrap.js +54 -0
- package/public/js/chat.js +1211 -0
- package/public/js/commands.js +581 -0
- package/public/js/connection-api.js +121 -0
- package/public/js/connection.js +1231 -0
- package/public/js/context-tracker.js +271 -0
- package/public/js/core.js +172 -0
- package/public/js/dashboard.js +452 -0
- package/public/js/developer.js +432 -0
- package/public/js/encryption.js +124 -0
- package/public/js/errors.js +122 -0
- package/public/js/event-bus.js +77 -0
- package/public/js/fetch-utils.js +171 -0
- package/public/js/file-handler.js +229 -0
- package/public/js/files.js +352 -0
- package/public/js/gateway-chat.js +538 -0
- package/public/js/logger.js +112 -0
- package/public/js/markdown.js +190 -0
- package/public/js/message-actions.js +431 -0
- package/public/js/message-renderer.js +288 -0
- package/public/js/missed-messages.js +235 -0
- package/public/js/mobile-debug.js +95 -0
- package/public/js/notifications.js +367 -0
- package/public/js/offline-queue.js +178 -0
- package/public/js/onboarding.js +543 -0
- package/public/js/panels.js +156 -0
- package/public/js/premium.js +412 -0
- package/public/js/realtime-voice.js +844 -0
- package/public/js/satellite-sync.js +256 -0
- package/public/js/satellite-ui.js +175 -0
- package/public/js/satellites.js +1516 -0
- package/public/js/settings.js +1087 -0
- package/public/js/shortcuts.js +381 -0
- package/public/js/split-chat.js +1234 -0
- package/public/js/split-resize.js +211 -0
- package/public/js/splitview.js +340 -0
- package/public/js/storage.js +408 -0
- package/public/js/streaming-handler.js +324 -0
- package/public/js/stt-settings.js +316 -0
- package/public/js/theme-generator.js +661 -0
- package/public/js/themes.js +164 -0
- package/public/js/timestamps.js +198 -0
- package/public/js/tts-settings.js +575 -0
- package/public/js/ui.js +267 -0
- package/public/js/update-notifier.js +143 -0
- package/public/js/utils/constants.js +165 -0
- package/public/js/utils/sanitize.js +93 -0
- package/public/js/utils/sse-parser.js +195 -0
- package/public/js/voice.js +883 -0
- package/public/manifest.json +58 -0
- package/public/moon_texture.jpg +0 -0
- package/public/sw.js +221 -0
- package/public/three.min.js +6 -0
- package/server/channel.js +529 -0
- package/server/chat.js +270 -0
- package/server/config-store.js +362 -0
- package/server/config.js +159 -0
- package/server/context.js +131 -0
- package/server/gateway-commands.js +211 -0
- package/server/gateway-proxy.js +318 -0
- package/server/index.js +22 -0
- package/server/logger.js +89 -0
- package/server/middleware/auth.js +188 -0
- package/server/middleware.js +218 -0
- package/server/openclaw-discover.js +308 -0
- package/server/premium/index.js +156 -0
- package/server/premium/license.js +140 -0
- package/server/realtime/bridge.js +837 -0
- package/server/realtime/index.js +349 -0
- package/server/realtime/tts-stream.js +446 -0
- package/server/routes/agents.js +564 -0
- package/server/routes/artifacts.js +174 -0
- package/server/routes/chat.js +311 -0
- package/server/routes/config-settings.js +345 -0
- package/server/routes/config.js +603 -0
- package/server/routes/files.js +307 -0
- package/server/routes/index.js +18 -0
- package/server/routes/media.js +451 -0
- package/server/routes/missed-messages.js +107 -0
- package/server/routes/premium.js +75 -0
- package/server/routes/push.js +156 -0
- package/server/routes/satellite.js +406 -0
- package/server/routes/status.js +251 -0
- package/server/routes/stt.js +35 -0
- package/server/routes/voice.js +260 -0
- package/server/routes/webhooks.js +203 -0
- package/server/routes.js +206 -0
- package/server/runtime-config.js +336 -0
- package/server/share.js +305 -0
- package/server/stt/faster-whisper.js +72 -0
- package/server/stt/groq.js +51 -0
- package/server/stt/index.js +196 -0
- package/server/stt/openai.js +49 -0
- package/server/sync.js +244 -0
- package/server/tailscale-https.js +175 -0
- package/server/tts.js +646 -0
- package/server/update-checker.js +172 -0
- package/server/utils/filename.js +129 -0
- package/server/utils.js +147 -0
- package/server/watchdog.js +318 -0
- package/server/websocket/broadcast.js +359 -0
- package/server/websocket/connections.js +339 -0
- package/server/websocket/index.js +215 -0
- package/server/websocket/routing.js +277 -0
- package/server/websocket/sync.js +102 -0
- package/server.js +404 -0
- package/utils/detect-tool-usage.js +93 -0
- package/utils/errors.js +158 -0
- package/utils/html-escape.js +84 -0
- package/utils/id-sanitize.js +94 -0
- package/utils/response.js +130 -0
- 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);
|