@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,1231 @@
1
+ // ============================================
2
+ // CONNECTION MODULE
3
+ // WebSocket connection orchestrator
4
+ // Imports WS and API modules
5
+ // ============================================
6
+
7
+ import { UplinkLogger } from './logger.js';
8
+ import { UplinkCore } from './core.js';
9
+ import { emit as emitEvent } from './event-bus.js';
10
+ import * as ConnectionAPI from './connection-api.js';
11
+
12
+ const STORAGE_KEY = 'uplink-connection';
13
+
14
+ // WebSocket configuration
15
+ const config = {
16
+ maxReconnectAttempts: 10,
17
+ baseReconnectDelay: 1000,
18
+ maxReconnectDelay: 30000,
19
+ heartbeatInterval: 15000,
20
+ persistentRetryInterval: 60000
21
+ };
22
+
23
+ // Deduplication for sync messages
24
+ const seenMessageIds = new Set();
25
+ const MAX_SEEN_IDS = 1000;
26
+ const CONTENT_DEDUP_WINDOW_MS = 60000; // 60 second window for content-based dedup
27
+ const recentContentHashes = new Map(); // hash -> timestamp
28
+
29
+ // Active sync streams for real-time cross-device streaming
30
+ const activeSyncStreams = new Map(); // requestId -> {div, fullResponse, orphanTimer}
31
+ const SYNC_STREAM_ORPHAN_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes
32
+
33
+ // Track that a sync stream was used during this processing cycle
34
+ // Prevents SSE from creating a duplicate bubble after WS already handled display
35
+ let syncStreamWasUsed = false;
36
+
37
+ // WebSocket state now managed by ConnectionWS module
38
+ let ws = null;
39
+ let isConnected = false;
40
+ let connectionListeners = [];
41
+ let isOffline = false;
42
+ let isReconnecting = false;
43
+
44
+ // Reconnection state
45
+ let reconnectAttempts = 0;
46
+ let reconnectTimer = null;
47
+ let maxRetriesReached = false;
48
+ let persistentRetryTimer = null;
49
+
50
+ // AbortController for event listeners cleanup
51
+ let eventsAbortController = null;
52
+
53
+ // Connection mode: 'direct' or 'proxied' (set for debugging)
54
+ window.UPLINK_CONNECTION_MODE = null;
55
+
56
+ /**
57
+ * Determine connection mode based on hostname
58
+ * @returns {string} 'proxied' always
59
+ *
60
+ * NOTE: Uplink's /ws endpoint handles message routing through the channel
61
+ * layer. The /gateway proxy speaks raw OpenClaw protocol (connect handshake,
62
+ * req/res/event framing, scopes, device identity) which requires a full
63
+ * protocol client implementation.
64
+ *
65
+ * TODO: Implement native OpenClaw WebSocket protocol client for direct
66
+ * gateway-proxy mode. This would eliminate the channel layer overhead
67
+ * and give true real-time streaming.
68
+ */
69
+ function getConnectionMode() {
70
+ return 'proxied';
71
+ }
72
+
73
+ /**
74
+ * Build the Gateway WebSocket URL based on connection mode
75
+ * @param {string} directUrl - The direct Gateway URL (from settings)
76
+ * @param {string|null} token - The Gateway auth token
77
+ * @returns {string} The WebSocket URL to use
78
+ */
79
+ function buildGatewayWsUrl(directUrl, token) {
80
+ const mode = getConnectionMode();
81
+ window.UPLINK_CONNECTION_MODE = mode;
82
+
83
+ if (mode === 'direct') {
84
+ // Use direct connection to Gateway (only works same-origin / localhost)
85
+ const wsUrl = directUrl.replace(/^http/, 'ws') + '/ws';
86
+ logger.debug('Connection: Using direct Gateway connection:', wsUrl);
87
+ return wsUrl;
88
+ } else {
89
+ // Use Uplink's WebSocket endpoint
90
+ const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:';
91
+ const wsUrl = `${protocol}//${location.host}/ws`;
92
+ logger.debug('Connection: Using Uplink WebSocket for sync:', wsUrl);
93
+ return wsUrl;
94
+ }
95
+ }
96
+
97
+ /**
98
+ * Get Gateway settings from settings or localStorage
99
+ * @returns {{url: string, token: string|null}}
100
+ */
101
+ function getGatewaySettings() {
102
+ const settings = JSON.parse(localStorage.getItem('uplink-settings') || '{}');
103
+ const gatewayUrl = settings.gatewayUrl || 'http://localhost:18789';
104
+ const token = settings.gatewayToken || window.UPLINK_GATEWAY_TOKEN || null;
105
+ const wsUrl = buildGatewayWsUrl(gatewayUrl, token);
106
+
107
+ return { url: wsUrl, token };
108
+ }
109
+
110
+ function init() {
111
+ // Abort previous event listeners to prevent stacking if init called multiple times
112
+ if (eventsAbortController) {
113
+ eventsAbortController.abort();
114
+ }
115
+ eventsAbortController = new AbortController();
116
+ const signal = eventsAbortController.signal;
117
+
118
+ loadSettings();
119
+ addConnectionUI();
120
+
121
+ // Listen for visibility changes to reconnect when tab becomes visible
122
+ document.addEventListener('visibilitychange', () => {
123
+ if (document.visibilityState === 'visible' && !isOffline) {
124
+ // Browser throttles WebSocket events in background tabs.
125
+ // Server may have killed connection for missed pongs.
126
+ // Force immediate reconnect if connection is not open.
127
+ if (!ws || ws.readyState !== WebSocket.OPEN) {
128
+ logger.debug('Connection: Tab resumed, forcing reconnect');
129
+ // Clear all state to avoid guards blocking reconnection
130
+ isConnected = false;
131
+ isReconnecting = false;
132
+ reconnectAttempts = 0;
133
+ maxRetriesReached = false;
134
+ clearTimeout(reconnectTimer);
135
+ clearTimeout(persistentRetryTimer);
136
+ reconnect();
137
+ }
138
+ }
139
+ }, { signal });
140
+
141
+ // Listen for online/offline events
142
+ window.addEventListener('online', handleOnline, { signal });
143
+ window.addEventListener('offline', handleOffline, { signal });
144
+
145
+ // Check initial online state
146
+ if (!navigator.onLine) {
147
+ handleOffline();
148
+ } else {
149
+ // Run initial health check to verify server connectivity
150
+ checkServerHealth();
151
+ }
152
+
153
+ logger.debug('Connection: Initialized');
154
+ }
155
+
156
+ // Check server health and establish WebSocket connection
157
+ async function checkServerHealth() {
158
+ const isHealthy = await ConnectionAPI.checkServerHealth();
159
+ if (isHealthy) {
160
+ // Server is reachable - now establish WebSocket for real-time sync
161
+ logger.debug('Connection: Server reachable, establishing WebSocket');
162
+
163
+ // Get URL based on connection mode (direct or proxied)
164
+ const { url: wsUrl } = getGatewaySettings();
165
+ logger.debug('Connection: Mode =', window.UPLINK_CONNECTION_MODE);
166
+
167
+ // Connect WebSocket for real-time updates
168
+ connect(wsUrl).catch(err => {
169
+ logger.error('Connection: Initial WebSocket connection failed', err);
170
+ updateStatus('disconnected');
171
+ });
172
+ } else {
173
+ updateStatus('disconnected');
174
+ }
175
+ }
176
+
177
+ function handleOnline() {
178
+ logger.debug('Connection: Network came online');
179
+ isOffline = false;
180
+ removeOfflineBanner();
181
+ enableSendButton();
182
+
183
+ // Clear persistent retry timer if running
184
+ if (persistentRetryTimer) {
185
+ clearTimeout(persistentRetryTimer);
186
+ persistentRetryTimer = null;
187
+ }
188
+ maxRetriesReached = false;
189
+ isReconnecting = false;
190
+
191
+ // Reset reconnect attempts and trigger reconnect
192
+ reconnectAttempts = 0;
193
+ if (!isConnected) {
194
+ reconnect();
195
+ }
196
+ }
197
+
198
+ function handleOffline() {
199
+ logger.debug('Connection: Network went offline');
200
+ isOffline = true;
201
+ showOfflineBanner();
202
+ disableSendButton();
203
+ updateStatus('offline');
204
+ }
205
+
206
+ function showOfflineBanner() {
207
+ if (document.getElementById('offline-banner')) return;
208
+
209
+ const banner = document.createElement('div');
210
+ banner.id = 'offline-banner';
211
+ banner.innerHTML = `
212
+ <span class="offline-icon">⚠️</span>
213
+ <span class="offline-text">You are offline. Some features may be unavailable.</span>
214
+ `;
215
+ banner.style.cssText = `
216
+ position: fixed;
217
+ top: 0;
218
+ left: 0;
219
+ right: 0;
220
+ background: #f59e0b;
221
+ color: #1f2937;
222
+ padding: 12px 16px;
223
+ text-align: center;
224
+ font-weight: 500;
225
+ z-index: 100000;
226
+ display: flex;
227
+ align-items: center;
228
+ justify-content: center;
229
+ gap: 8px;
230
+ box-shadow: 0 2px 8px rgba(0,0,0,0.2);
231
+ `;
232
+
233
+ document.body.appendChild(banner);
234
+ }
235
+
236
+ function removeOfflineBanner() {
237
+ const banner = document.getElementById('offline-banner');
238
+ if (banner) {
239
+ banner.remove();
240
+ }
241
+ }
242
+
243
+ function disableSendButton() {
244
+ const sendBtn = document.getElementById('send-btn');
245
+ if (sendBtn) {
246
+ sendBtn.disabled = true;
247
+ }
248
+ }
249
+
250
+ function enableSendButton() {
251
+ const sendBtn = document.getElementById('send-btn');
252
+ if (sendBtn) {
253
+ sendBtn.disabled = false;
254
+ }
255
+ }
256
+
257
+ // Allowed config keys to prevent localStorage poisoning (H-13)
258
+ const ALLOWED_CONFIG_KEYS = new Set(Object.keys(config));
259
+
260
+ function loadSettings() {
261
+ try {
262
+ const saved = localStorage.getItem(STORAGE_KEY);
263
+ if (saved) {
264
+ const parsed = JSON.parse(saved);
265
+ // Only merge recognized config keys to prevent prototype/config poisoning
266
+ if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
267
+ for (const key of Object.keys(parsed)) {
268
+ if (ALLOWED_CONFIG_KEYS.has(key) && typeof parsed[key] === typeof config[key]) {
269
+ config[key] = parsed[key];
270
+ }
271
+ }
272
+ }
273
+ }
274
+ } catch (e) {
275
+ logger.error('Connection: Failed to load settings', e);
276
+ }
277
+ }
278
+
279
+ function addConnectionUI() {
280
+ // Find status badge and logo
281
+ const badge = document.getElementById('connectionBadge');
282
+ const logo = document.getElementById('logoRefresh');
283
+
284
+ // Logo click refreshes chat (fetch from Gateway for main satellite)
285
+ if (logo) {
286
+ logo.addEventListener('click', () => {
287
+ if (window.UplinkSatellites?.refreshHistory) {
288
+ window.UplinkSatellites.refreshHistory();
289
+ logger.debug('Connection: Refreshing chat via logo click (Gateway fetch)');
290
+ } else if (window.UplinkChat?.loadHistory) {
291
+ window.UplinkChat.loadHistory();
292
+ logger.debug('Connection: Refreshing chat via logo click (local storage)');
293
+ }
294
+ });
295
+ }
296
+
297
+ // Hard refresh button - full page reload bypassing cache
298
+ const refreshBtn = document.getElementById('refreshBtn');
299
+ if (refreshBtn) {
300
+ refreshBtn.addEventListener('click', () => {
301
+ logger.debug('Connection: Hard refresh triggered');
302
+ // Clear service worker cache then reload
303
+ if ('caches' in window) {
304
+ caches.keys().then(names => {
305
+ return Promise.all(names.map(name => caches.delete(name)));
306
+ }).then(() => {
307
+ window.location.reload();
308
+ });
309
+ } else {
310
+ window.location.reload();
311
+ }
312
+ });
313
+ }
314
+
315
+ // Badge click reconnects if disconnected
316
+ if (badge) {
317
+ badge.style.cursor = 'pointer';
318
+ badge.title = 'Connection status - Click to reconnect';
319
+
320
+ badge.addEventListener('click', () => {
321
+ if (!isConnected) {
322
+ reconnect();
323
+ }
324
+ });
325
+ }
326
+ }
327
+
328
+ function connect(url) {
329
+ if (ws && ws.readyState === WebSocket.OPEN) {
330
+ return Promise.resolve(ws);
331
+ }
332
+
333
+ return new Promise((resolve, reject) => {
334
+ let settled = false; // Track whether the promise has been resolved/rejected
335
+
336
+ // Connection timeout — if WS doesn't open in 10s, reject
337
+ const connectTimeout = setTimeout(() => {
338
+ logger.warn('Connection: WebSocket connection timed out');
339
+ if (ws) {
340
+ try { ws.close(); } catch (e) { /* ignore */ }
341
+ }
342
+ if (!settled) {
343
+ settled = true;
344
+ reject(new Error('Connection timed out'));
345
+ }
346
+ }, 10000);
347
+
348
+ try {
349
+ ws = new WebSocket(url);
350
+
351
+ ws.onopen = () => {
352
+ clearTimeout(connectTimeout);
353
+ logger.debug('Connection: WebSocket connected');
354
+ isConnected = true;
355
+ reconnectAttempts = 0;
356
+ isReconnecting = false; // Clear reconnection state on successful connect
357
+ updateStatus('connected');
358
+ enableSendButton();
359
+ startHeartbeat();
360
+ notifyListeners('connected');
361
+ emitEvent('connection:status', { status: 'connected' });
362
+ if (!settled) {
363
+ settled = true;
364
+ resolve(ws);
365
+ }
366
+ };
367
+
368
+ ws.onclose = (event) => {
369
+ clearTimeout(connectTimeout);
370
+ logger.debug('Connection: WebSocket closed', event.code, event.reason);
371
+ isConnected = false;
372
+ stopHeartbeat();
373
+
374
+ // Only show disconnected if we're not offline
375
+ if (!isOffline && navigator.onLine) {
376
+ updateStatus('disconnected');
377
+ }
378
+ notifyListeners('disconnected');
379
+ emitEvent('connection:status', { status: 'disconnected', code: event.code, reason: event.reason });
380
+
381
+ // Reject the promise if we never connected (so reconnect() can handle it)
382
+ if (!settled) {
383
+ settled = true;
384
+ reject(new Error(`WebSocket closed: ${event.code} ${event.reason || ''}`));
385
+ } else if (event.code !== 1000) {
386
+ // Already connected and then lost connection — schedule auto-reconnect
387
+ scheduleReconnect();
388
+ }
389
+ };
390
+
391
+ ws.onerror = (error) => {
392
+ clearTimeout(connectTimeout);
393
+ logger.error('Connection: WebSocket error', error);
394
+ updateStatus('error');
395
+ notifyListeners('error', error);
396
+ // Note: onerror is always followed by onclose, which handles reject
397
+ };
398
+
399
+ ws.onmessage = (event) => {
400
+ let data;
401
+ try {
402
+ data = JSON.parse(event.data);
403
+ } catch (e) {
404
+ // Not JSON, pass through
405
+ notifyListeners('message', event.data);
406
+ return;
407
+ }
408
+
409
+ try {
410
+ let type = data.type;
411
+ let eventData = data;
412
+
413
+ // Unwrap OpenClaw event envelope
414
+ if (data.type === 'event' && data.event && data.payload) {
415
+ if (data.event === 'sync') {
416
+ type = 'sync_message';
417
+ eventData = data.payload;
418
+ } else if (data.event === 'sync.thinking') {
419
+ type = 'sync_thinking';
420
+ eventData = data.payload;
421
+ } else if (data.event === 'sync.delta') {
422
+ type = 'sync_delta';
423
+ eventData = data.payload;
424
+ } else if (data.event === 'sync.tool') {
425
+ type = 'sync_tool';
426
+ eventData = data.payload;
427
+ } else if (data.event === 'sync.complete') {
428
+ type = 'sync_complete';
429
+ eventData = data.payload;
430
+ } else if (data.event.startsWith('openclaw.')) {
431
+ type = `openclaw_${data.event.slice(9)}`;
432
+ eventData = data.payload;
433
+ } else {
434
+ type = data.event;
435
+ eventData = data.payload;
436
+ }
437
+ }
438
+
439
+ // Handle webhook notifications
440
+ if (type === 'notification' && eventData.notification) {
441
+ showWebhookNotification(eventData.notification);
442
+ }
443
+
444
+ // Handle webhook triggers
445
+ if (type === 'trigger') {
446
+ handleWebhookTrigger(eventData.action, eventData.params);
447
+ }
448
+
449
+ // Handle webhook messages (show in chat)
450
+ if (type === 'webhook_message') {
451
+ if (window.addMessage) {
452
+ window.addMessage(`[${eventData.source}] ${eventData.message}`, 'user', null, false);
453
+ window.addMessage(eventData.response, 'assistant', null, false);
454
+ }
455
+ }
456
+
457
+ // Handle sync messages (cross-device sync)
458
+ if (type === 'sync_message') {
459
+ handleSyncMessage(eventData);
460
+ }
461
+
462
+ // Handle real-time sync streaming events
463
+ if (type === 'sync_thinking') handleSyncThinking(eventData);
464
+ if (type === 'sync_delta') handleSyncDelta(eventData);
465
+ if (type === 'sync_tool') handleSyncTool(eventData);
466
+ if (type === 'sync_complete') handleSyncComplete(eventData);
467
+
468
+ // Handle voice processing status updates
469
+ if (type === 'voiceStatus') {
470
+ const voiceStatusEl = document.getElementById('voiceStatus');
471
+ if (voiceStatusEl && eventData.label) {
472
+ voiceStatusEl.textContent = eventData.label;
473
+ }
474
+ }
475
+
476
+ // Handle OpenClaw push messages (from transcript watcher)
477
+ if (type === 'openclaw_message') {
478
+ handleOpenClawMessage(eventData);
479
+ }
480
+
481
+ // Handle update notifications
482
+ if (type === 'update_available') {
483
+ window.dispatchEvent(new CustomEvent('uplink:ws-message', { detail: eventData }));
484
+ }
485
+ } catch (e) {
486
+ if (window.UplinkLogger) UplinkLogger.error('Connection: Message handler error', e);
487
+ }
488
+
489
+ notifyListeners('message', event.data);
490
+ };
491
+
492
+ } catch (err) {
493
+ reject(err);
494
+ }
495
+ });
496
+ }
497
+
498
+ function disconnect() {
499
+ if (ws) {
500
+ ws.close(1000, 'User disconnect');
501
+ ws = null;
502
+ }
503
+ clearTimeout(reconnectTimer);
504
+ clearTimeout(persistentRetryTimer);
505
+ stopHeartbeat();
506
+ isConnected = false;
507
+ reconnectAttempts = 0;
508
+ maxRetriesReached = false;
509
+ isReconnecting = false;
510
+ }
511
+
512
+ function reconnect() {
513
+ clearTimeout(reconnectTimer); // Prevent duplicate reconnects
514
+ if (isConnected || isReconnecting) return;
515
+
516
+ // Don't try to reconnect if we're offline
517
+ if (isOffline || !navigator.onLine) {
518
+ logger.debug('Connection: Skipping reconnect - network is offline');
519
+ updateStatus('offline');
520
+ return;
521
+ }
522
+
523
+ // Set lock to prevent multiple simultaneous reconnection attempts
524
+ isReconnecting = true;
525
+
526
+ // Get URL based on connection mode (direct or proxied)
527
+ const { url: wsUrl } = getGatewaySettings();
528
+
529
+ logger.debug('Connection: Reconnecting to', wsUrl.replace(/token=[^&]+/, 'token=***'));
530
+ logger.debug('Connection: Mode =', window.UPLINK_CONNECTION_MODE);
531
+ updateStatus('reconnecting');
532
+
533
+ connect(wsUrl).then(() => {
534
+ // Clear reconnection state on successful connect
535
+ isReconnecting = false;
536
+ }).catch(err => {
537
+ logger.error('Connection: Reconnect failed', err);
538
+ isReconnecting = false;
539
+ scheduleReconnect();
540
+ });
541
+ }
542
+
543
+ function scheduleReconnect() {
544
+ // If we've reached max attempts, switch to persistent retry mode
545
+ if (reconnectAttempts >= config.maxReconnectAttempts) {
546
+ if (!maxRetriesReached) {
547
+ logger.debug('Connection: Max reconnect attempts reached, switching to persistent retry mode');
548
+ maxRetriesReached = true;
549
+ updateStatus('failed');
550
+ }
551
+
552
+ // Keep trying at reduced frequency (once per minute)
553
+ const persistentDelay = config.persistentRetryInterval;
554
+ logger.debug(`Connection: Persistent retry in ${persistentDelay/1000}s`);
555
+
556
+ persistentRetryTimer = setTimeout(() => {
557
+ reconnectAttempts = 0; // Reset to try normal reconnection again
558
+ reconnect();
559
+ }, persistentDelay);
560
+ return;
561
+ }
562
+
563
+ // Exponential backoff with jitter (±30%)
564
+ const baseDelay = Math.min(
565
+ config.baseReconnectDelay * Math.pow(2, reconnectAttempts),
566
+ config.maxReconnectDelay
567
+ );
568
+ const delay = Math.round(baseDelay * (1 + Math.random() * 0.3));
569
+
570
+ reconnectAttempts++;
571
+ logger.debug(`Connection: Reconnect attempt ${reconnectAttempts} in ${Math.round(delay/1000)}s`);
572
+
573
+ // Update status with attempt info
574
+ updateStatus('reconnecting', { attempt: reconnectAttempts });
575
+
576
+ reconnectTimer = setTimeout(() => {
577
+ reconnect();
578
+ }, delay);
579
+ }
580
+
581
+ let heartbeatTimer = null;
582
+
583
+ function startHeartbeat() {
584
+ stopHeartbeat();
585
+ heartbeatTimer = setInterval(() => {
586
+ if (ws && ws.readyState === WebSocket.OPEN) {
587
+ ws.send(JSON.stringify({ type: 'ping' }));
588
+ }
589
+ }, config.heartbeatInterval);
590
+ }
591
+
592
+ function stopHeartbeat() {
593
+ if (heartbeatTimer) {
594
+ clearInterval(heartbeatTimer);
595
+ heartbeatTimer = null;
596
+ }
597
+ }
598
+
599
+ function updateStatus(status, extraInfo = null) {
600
+ const statusEl = document.getElementById('status');
601
+ const dotEl = document.querySelector('.status-dot');
602
+
603
+ if (!statusEl) return;
604
+
605
+ let statusText = {
606
+ connected: 'Connected',
607
+ disconnected: 'Disconnected',
608
+ connecting: 'Connecting...',
609
+ reconnecting: 'Reconnecting...',
610
+ offline: 'Offline',
611
+ error: 'Error',
612
+ failed: 'Connection failed'
613
+ }[status] || status;
614
+
615
+ // Add attempt info for reconnecting status
616
+ if (status === 'reconnecting' && extraInfo?.attempt) {
617
+ statusText = `Reconnecting (${extraInfo.attempt}/${config.maxReconnectAttempts})...`;
618
+ }
619
+
620
+ statusEl.textContent = statusText;
621
+
622
+ if (dotEl) {
623
+ dotEl.className = 'status-dot ' + status;
624
+ }
625
+
626
+ // Announce status change to screen readers
627
+ const srRegion = document.getElementById('connection-status-region');
628
+ if (srRegion) srRegion.textContent = statusText;
629
+
630
+ // Banner removed — status dot in header is sufficient
631
+
632
+ // Connection mode badge - hidden since we always use proxied mode now
633
+ // (keeping code for potential future use if we add direct mode option)
634
+ const modeBadge = document.getElementById('connectionModeBadge');
635
+ if (modeBadge) {
636
+ modeBadge.style.display = 'none';
637
+ }
638
+
639
+ // Update connection badge tooltip
640
+ const badge = document.getElementById('connectionBadge');
641
+ if (badge) {
642
+ badge.title = status === 'connected' ? 'Connected' : 'Click to reconnect';
643
+ }
644
+ }
645
+
646
+ // Auto-reconnect runs silently in the background.
647
+ // Status dot in header is the only connection indicator.
648
+
649
+ function onConnection(callback) {
650
+ connectionListeners.push(callback);
651
+ return () => {
652
+ connectionListeners = connectionListeners.filter(cb => cb !== callback);
653
+ };
654
+ }
655
+
656
+ function notifyListeners(event, data) {
657
+ connectionListeners.forEach(cb => {
658
+ try {
659
+ cb(event, data);
660
+ } catch (e) {
661
+ logger.error('Connection: Listener error', e);
662
+ }
663
+ });
664
+ }
665
+
666
+ // Escape HTML to prevent XSS
667
+ function escapeHtml(str) {
668
+ if (!str) return '';
669
+ return String(str)
670
+ .replace(/&/g, '&amp;')
671
+ .replace(/</g, '&lt;')
672
+ .replace(/>/g, '&gt;')
673
+ .replace(/"/g, '&quot;')
674
+ .replace(/'/g, '&#39;');
675
+ }
676
+
677
+ // Webhook notification helper
678
+ function showWebhookNotification(notification) {
679
+ const { title, body, type = 'info' } = notification;
680
+
681
+ // Try to use UplinkNotifications if available
682
+ if (window.UplinkNotifications?.show) {
683
+ window.UplinkNotifications.show(body || title, type);
684
+ return;
685
+ }
686
+
687
+ // Fallback: create toast notification using safe DOM APIs
688
+ const toast = document.createElement('div');
689
+ toast.className = `webhook-toast webhook-toast-${escapeHtml(type)}`;
690
+ // M-35: Announce webhook notifications to screen readers
691
+ toast.setAttribute('role', 'alert');
692
+ toast.setAttribute('aria-live', 'polite');
693
+
694
+ if (title) {
695
+ const titleEl = document.createElement('strong');
696
+ titleEl.textContent = title;
697
+ toast.appendChild(titleEl);
698
+ }
699
+ if (body) {
700
+ const bodyEl = document.createElement('p');
701
+ bodyEl.textContent = body;
702
+ toast.appendChild(bodyEl);
703
+ }
704
+ toast.style.cssText = `
705
+ position: fixed;
706
+ top: 80px;
707
+ right: 20px;
708
+ background: var(--bg-secondary, #333);
709
+ color: var(--text-primary, #fff);
710
+ padding: 12px 16px;
711
+ border-radius: 8px;
712
+ box-shadow: 0 4px 12px rgba(0,0,0,0.3);
713
+ z-index: 10000;
714
+ max-width: 300px;
715
+ animation: slideIn 0.3s ease;
716
+ `;
717
+ document.body.appendChild(toast);
718
+
719
+ setTimeout(() => {
720
+ toast.style.animation = 'slideOut 0.3s ease';
721
+ setTimeout(() => toast.remove(), 300);
722
+ }, 5000);
723
+ }
724
+
725
+ // ===========================================
726
+ // SYNC MESSAGE HANDLING (Cross-Device Sync)
727
+ // ===========================================
728
+
729
+ /**
730
+ * Simple hash for content-based deduplication
731
+ */
732
+ function hashContent(role, content) {
733
+ let hash = 0;
734
+ const str = `${role}:${content}`;
735
+ for (let i = 0; i < str.length; i++) {
736
+ const char = str.charCodeAt(i);
737
+ hash = ((hash << 5) - hash) + char;
738
+ hash = hash & hash; // Convert to 32bit integer
739
+ }
740
+ return hash.toString(36);
741
+ }
742
+
743
+ /**
744
+ * Clean up old entries from deduplication data structures
745
+ */
746
+ function cleanupDedup() {
747
+ // Clean up seenMessageIds if over limit (LRU eviction)
748
+ if (seenMessageIds.size > MAX_SEEN_IDS) {
749
+ const toRemove = seenMessageIds.size - MAX_SEEN_IDS;
750
+ const iterator = seenMessageIds.values();
751
+ for (let i = 0; i < toRemove; i++) {
752
+ seenMessageIds.delete(iterator.next().value);
753
+ }
754
+ }
755
+
756
+ // Clean up old content hashes
757
+ const now = Date.now();
758
+ for (const [hash, timestamp] of recentContentHashes) {
759
+ if (now - timestamp > CONTENT_DEDUP_WINDOW_MS) {
760
+ recentContentHashes.delete(hash);
761
+ }
762
+ }
763
+
764
+ // Hard cap to prevent unbounded growth
765
+ if (recentContentHashes.size > 1000) {
766
+ const entries = Array.from(recentContentHashes.entries())
767
+ .sort((a, b) => a[1] - b[1]);
768
+ const toRemoveCount = recentContentHashes.size - 500;
769
+ for (let i = 0; i < toRemoveCount; i++) {
770
+ recentContentHashes.delete(entries[i][0]);
771
+ }
772
+ }
773
+ }
774
+
775
+ /**
776
+ * Check if a message is a duplicate
777
+ */
778
+ function isDuplicateMessage(messageId, role, content, timestamp) {
779
+ // Check by messageId first
780
+ if (messageId && seenMessageIds.has(messageId)) {
781
+ logger.debug('Connection: Duplicate sync message (by ID):', messageId);
782
+ return true;
783
+ }
784
+
785
+ // Content-based dedup fallback
786
+ const contentHash = hashContent(role, content);
787
+ const existingTimestamp = recentContentHashes.get(contentHash);
788
+ if (existingTimestamp && Math.abs(timestamp - existingTimestamp) < CONTENT_DEDUP_WINDOW_MS) {
789
+ logger.debug('Connection: Duplicate sync message (by content):', contentHash);
790
+ return true;
791
+ }
792
+
793
+ return false;
794
+ }
795
+
796
+ /**
797
+ * Mark a message as seen for deduplication
798
+ */
799
+ function markMessageSeen(messageId, role, content, timestamp) {
800
+ if (messageId) {
801
+ seenMessageIds.add(messageId);
802
+ }
803
+ const contentHash = hashContent(role, content);
804
+ recentContentHashes.set(contentHash, timestamp);
805
+ cleanupDedup();
806
+ }
807
+
808
+ // ===========================================
809
+ // SYNC STREAMING HANDLERS (Real-Time Cross-Device Streaming)
810
+ // ===========================================
811
+
812
+ /**
813
+ * Check if we should handle a sync streaming event
814
+ * Returns false if the message is for a different satellite
815
+ *
816
+ * NOTE: We no longer skip sync streams when chatState === 'processing'.
817
+ * When behind a buffering proxy (e.g. Cloudflare tunnel), the SSE response
818
+ * from /api/chat gets buffered and arrives all at once. WebSocket sync deltas
819
+ * arrive in real-time and provide the streaming experience. The SSE handler
820
+ * in chat.js detects when a sync stream is active and defers to it.
821
+ */
822
+ function shouldHandleSyncStream(data) {
823
+ const currentSatellite = window.UplinkSatellites?.getCurrentId?.() || 'main';
824
+ if (data.satelliteId && data.satelliteId !== currentSatellite) {
825
+ return false;
826
+ }
827
+ return true;
828
+ }
829
+
830
+ /**
831
+ * Get or lazily create a sync stream entry
832
+ */
833
+ function getOrCreateSyncStream(requestId) {
834
+ let stream = activeSyncStreams.get(requestId);
835
+ if (!stream) {
836
+ const div = window.UplinkChat?.createStreamingMessage?.();
837
+ if (!div) return null;
838
+
839
+ syncStreamWasUsed = true;
840
+ stream = { div, fullResponse: '', orphanTimer: null };
841
+ // Set orphan timeout to clean up if server crashes mid-stream
842
+ stream.orphanTimer = setTimeout(() => {
843
+ logger.warn('Connection: Orphan sync stream cleaned up:', requestId);
844
+ const orphan = activeSyncStreams.get(requestId);
845
+ if (orphan) {
846
+ // Finalize with whatever content we have
847
+ if (orphan.fullResponse && window.UplinkChat?.finalizeSyncStream) {
848
+ window.UplinkChat.finalizeSyncStream(orphan.div, orphan.fullResponse);
849
+ } else if (orphan.div) {
850
+ orphan.div.classList.remove('streaming');
851
+ }
852
+ activeSyncStreams.delete(requestId);
853
+ }
854
+ }, SYNC_STREAM_ORPHAN_TIMEOUT_MS);
855
+ activeSyncStreams.set(requestId, stream);
856
+ }
857
+ return stream;
858
+ }
859
+
860
+ /**
861
+ * Handle sync.thinking event - create streaming bubble with thinking indicator
862
+ */
863
+ function handleSyncThinking(data) {
864
+ if (!shouldHandleSyncStream(data)) return;
865
+ const { requestId } = data;
866
+ if (!requestId) return;
867
+
868
+ // Hide the typing indicator since we're taking over display via WebSocket
869
+ if (window.UplinkChat?.hideTyping) {
870
+ window.UplinkChat.hideTyping();
871
+ }
872
+
873
+ const stream = getOrCreateSyncStream(requestId);
874
+ if (stream && window.UplinkChat?.updateStreamingMessage) {
875
+ window.UplinkChat.updateStreamingMessage(stream.div, '\u{1F9E0} Thinking...');
876
+ }
877
+ }
878
+
879
+ /**
880
+ * Handle sync.delta event - accumulate content and update streaming bubble
881
+ */
882
+ function handleSyncDelta(data) {
883
+ if (!shouldHandleSyncStream(data)) return;
884
+ const { requestId, content } = data;
885
+ if (!requestId || !content) return;
886
+
887
+ const stream = getOrCreateSyncStream(requestId);
888
+ if (!stream) return;
889
+
890
+ stream.fullResponse += content;
891
+ if (window.UplinkChat?.updateStreamingMessage) {
892
+ window.UplinkChat.updateStreamingMessage(stream.div, stream.fullResponse);
893
+ }
894
+ }
895
+
896
+ /**
897
+ * Handle sync.tool event - show tool usage in streaming bubble
898
+ */
899
+ function handleSyncTool(data) {
900
+ if (!shouldHandleSyncStream(data)) return;
901
+ const { requestId, tool } = data;
902
+ if (!requestId) return;
903
+
904
+ const stream = getOrCreateSyncStream(requestId);
905
+ if (stream && window.UplinkChat?.updateStreamingMessage) {
906
+ window.UplinkChat.updateStreamingMessage(stream.div, `\u{1F527} Using ${tool}...`);
907
+ }
908
+
909
+ // Log to activity panel
910
+ if (tool && window.UplinkDeveloper?.logTool) {
911
+ window.UplinkDeveloper.logTool(tool);
912
+ }
913
+ }
914
+
915
+ /**
916
+ * Handle sync.complete event - update token usage in activity panel
917
+ */
918
+ function handleSyncComplete(data) {
919
+ const { usage } = data;
920
+ if (usage && window.UplinkDeveloper?.updateTokens) {
921
+ window.UplinkDeveloper.updateTokens(usage);
922
+ }
923
+ // Refresh context tracker after sync complete
924
+ if (window.UplinkContextTracker?.refresh) {
925
+ window.UplinkContextTracker.refresh();
926
+ }
927
+ }
928
+
929
+ /**
930
+ * Clear all active sync streams (e.g., when switching satellites)
931
+ */
932
+ function clearActiveSyncStreams() {
933
+ for (const [requestId, stream] of activeSyncStreams) {
934
+ if (stream.orphanTimer) clearTimeout(stream.orphanTimer);
935
+ if (stream.div) stream.div.classList.remove('streaming');
936
+ }
937
+ activeSyncStreams.clear();
938
+ logger.debug('Connection: Cleared active sync streams');
939
+ }
940
+
941
+ /**
942
+ * Handle sync_message WebSocket events (cross-device sync)
943
+ */
944
+ function handleSyncMessage(data) {
945
+ const { messageId, role, content, satelliteId, timestamp, requestId } = data;
946
+
947
+ // Check if this is for the current satellite
948
+ const currentSatellite = window.UplinkSatellites?.getCurrentId?.() || 'main';
949
+ if (satelliteId && satelliteId !== currentSatellite) {
950
+ logger.debug('Connection: Sync message for different satellite:', satelliteId, 'current:', currentSatellite);
951
+ return;
952
+ }
953
+
954
+ // Skip assistant sync messages if we're currently processing a request
955
+ // (the HTTP response will handle adding it - prevents race condition duplicates)
956
+ // If we're processing AND there's an active sync stream for this requestId,
957
+ // let it through so it can finalize the stream bubble.
958
+ // Otherwise skip to avoid duplicate with SSE response.
959
+ if (role === 'assistant' && window.UplinkCore?.chatState === 'processing') {
960
+ if (requestId && activeSyncStreams.has(requestId)) {
961
+ // Let it through - will finalize the sync stream div below
962
+ logger.debug('Connection: Letting sync message through to finalize stream:', requestId);
963
+ } else {
964
+ logger.debug('Connection: Skipping sync message - already processing response');
965
+ return;
966
+ }
967
+ }
968
+
969
+ // Check for duplicates
970
+ if (isDuplicateMessage(messageId, role, content, timestamp)) {
971
+ return;
972
+ }
973
+
974
+ // Mark as seen
975
+ markMessageSeen(messageId, role, content, timestamp);
976
+
977
+ // If this message has a requestId and we have an active stream for it,
978
+ // finalize the stream instead of creating a new message bubble
979
+ if (requestId && activeSyncStreams.has(requestId)) {
980
+ const stream = activeSyncStreams.get(requestId);
981
+ if (stream.orphanTimer) clearTimeout(stream.orphanTimer);
982
+ if (window.UplinkChat?.finalizeSyncStream) {
983
+ window.UplinkChat.finalizeSyncStream(stream.div, content);
984
+ }
985
+ activeSyncStreams.delete(requestId);
986
+ logger.debug('Connection: Finalized sync stream:', requestId, content.substring(0, 50) + '...');
987
+ return;
988
+ }
989
+
990
+ // Fall through to existing behavior: display as a full message
991
+ // (backward compat for clients that missed deltas or old servers without requestId)
992
+ const type = role === 'user' ? 'user' : 'assistant';
993
+
994
+ if (window.UplinkChat?.addMessage) {
995
+ window.UplinkChat.addMessage(content, type, null, false); // false = don't save again
996
+ logger.debug('Connection: Displayed sync message:', type, content.substring(0, 50) + '...');
997
+ } else if (window.addMessage) {
998
+ window.addMessage(content, type, null, false);
999
+ logger.debug('Connection: Displayed sync message (fallback):', type);
1000
+ } else {
1001
+ logger.warn('Connection: No addMessage function available for sync message');
1002
+ }
1003
+ }
1004
+
1005
+ /**
1006
+ * Clear sync deduplication state (e.g., when switching satellites)
1007
+ */
1008
+ function clearSyncDedup() {
1009
+ seenMessageIds.clear();
1010
+ recentContentHashes.clear();
1011
+ logger.debug('Connection: Cleared sync deduplication state');
1012
+ }
1013
+
1014
+ /**
1015
+ * Handle openclaw_message WebSocket events (from transcript watcher)
1016
+ * These have slightly different format than sync_message
1017
+ * DISABLED: sync_message already handles cross-device sync, this causes duplicates
1018
+ */
1019
+ function handleOpenClawMessage(data) {
1020
+ // Skip - sync_message handler already covers this
1021
+ logger.debug('Connection: Skipping openclaw_message (handled by sync_message)');
1022
+ return;
1023
+
1024
+ const { content, role, satelliteId, timestamp } = data;
1025
+
1026
+ if (!content) return;
1027
+
1028
+ // Check if this is for the current satellite
1029
+ const currentSatellite = window.UplinkSatellites?.getCurrentId?.() || 'main';
1030
+ if (satelliteId && satelliteId !== currentSatellite) {
1031
+ logger.debug('Connection: OpenClaw message for different satellite:', satelliteId);
1032
+ return;
1033
+ }
1034
+
1035
+ // Use content-based deduplication (no messageId from transcript watcher)
1036
+ const msgTimestamp = timestamp || Date.now();
1037
+ const msgRole = role || 'assistant';
1038
+
1039
+ if (isDuplicateMessage(null, msgRole, content, msgTimestamp)) {
1040
+ return;
1041
+ }
1042
+
1043
+ // Mark as seen
1044
+ markMessageSeen(null, msgRole, content, msgTimestamp);
1045
+
1046
+ // Display the message
1047
+ const type = msgRole === 'user' ? 'user' : 'assistant';
1048
+
1049
+ if (window.UplinkChat?.addMessage) {
1050
+ window.UplinkChat.addMessage(content, type, null, false);
1051
+ logger.debug('Connection: Displayed OpenClaw message:', type, content.substring(0, 50) + '...');
1052
+ } else if (window.addMessage) {
1053
+ window.addMessage(content, type, null, false);
1054
+ }
1055
+ }
1056
+
1057
+ /**
1058
+ * Validate a URL is safe for navigation/audio (blocks javascript:, data:, vbscript:)
1059
+ * @param {string} url - URL to validate
1060
+ * @returns {boolean} - True if URL is safe
1061
+ */
1062
+ function isSafeUrl(url) {
1063
+ if (!url || typeof url !== 'string') return false;
1064
+ const trimmed = url.trim().toLowerCase();
1065
+ // Only allow http:, https:, and relative paths
1066
+ if (trimmed.startsWith('http://') || trimmed.startsWith('https://') || trimmed.startsWith('/')) {
1067
+ return true;
1068
+ }
1069
+ return false;
1070
+ }
1071
+
1072
+ // Webhook trigger handler
1073
+ function handleWebhookTrigger(action, params) {
1074
+ switch (action) {
1075
+ case 'sound': {
1076
+ // Play a sound — validate URL to prevent SSRF/exfiltration (C-09)
1077
+ const soundUrl = params.url || '/audio/notification.mp3';
1078
+ if (!isSafeUrl(soundUrl)) {
1079
+ if (window.UplinkLogger?.warn) {
1080
+ window.UplinkLogger.warn('Connection: Blocked unsafe sound URL:', soundUrl);
1081
+ }
1082
+ break;
1083
+ }
1084
+ const audio = new Audio(soundUrl);
1085
+ audio.play().catch(err => {
1086
+ if (window.UplinkLogger?.error) {
1087
+ window.UplinkLogger.error('Connection: Webhook audio playback failed:', err);
1088
+ }
1089
+ });
1090
+ break;
1091
+ }
1092
+ case 'focus':
1093
+ // Focus the window
1094
+ window.focus();
1095
+ break;
1096
+ case 'refresh':
1097
+ // Reload the page
1098
+ location.reload();
1099
+ break;
1100
+ case 'navigate': {
1101
+ // Navigate to URL — validate to prevent open redirect / XSS (C-08)
1102
+ if (params.url && isSafeUrl(params.url)) {
1103
+ window.location.href = params.url;
1104
+ } else if (params.url) {
1105
+ if (window.UplinkLogger?.warn) {
1106
+ window.UplinkLogger.warn('Connection: Blocked unsafe navigate URL:', params.url);
1107
+ }
1108
+ }
1109
+ break;
1110
+ }
1111
+ default:
1112
+ logger.debug('Unknown webhook trigger:', action, params);
1113
+ }
1114
+ }
1115
+
1116
+ // Cleanup on page unload to prevent dangling connections
1117
+ window.addEventListener('beforeunload', disconnect);
1118
+
1119
+ // Module init retry state
1120
+ let initRetryCount = 0;
1121
+ const MAX_INIT_RETRIES = 10;
1122
+ let initRetryTimer = null;
1123
+
1124
+ function scheduleInitRetry() {
1125
+ if (initRetryCount >= MAX_INIT_RETRIES) {
1126
+ logger.warn('Connection: Max init retries reached, giving up');
1127
+ return;
1128
+ }
1129
+ initRetryCount++;
1130
+ const delay = Math.min(50 * Math.pow(2, initRetryCount), 5000);
1131
+ initRetryTimer = setTimeout(init, delay);
1132
+ }
1133
+
1134
+ /**
1135
+ * Manual retry - resets all counters and attempts immediate reconnection
1136
+ */
1137
+ function manualRetry() {
1138
+ logger.debug('Connection: Manual retry initiated');
1139
+ clearTimeout(reconnectTimer);
1140
+ clearTimeout(persistentRetryTimer);
1141
+ reconnectAttempts = 0;
1142
+ maxRetriesReached = false;
1143
+ isReconnecting = false;
1144
+ reconnect();
1145
+ }
1146
+
1147
+ /**
1148
+ * Check if there's an active sync stream for a given requestId
1149
+ * Used by chat.js SSE handler to detect WebSocket streaming is active
1150
+ * @param {string} requestId
1151
+ * @returns {boolean}
1152
+ */
1153
+ function hasActiveSyncStream(requestId) {
1154
+ return requestId && activeSyncStreams.has(requestId);
1155
+ }
1156
+
1157
+ /**
1158
+ * Check if a sync stream was used during the current processing cycle
1159
+ * This remains true even after the stream is finalized, so the SSE handler
1160
+ * knows not to create a duplicate bubble
1161
+ * @returns {boolean}
1162
+ */
1163
+ function wasSyncStreamUsed() {
1164
+ return syncStreamWasUsed;
1165
+ }
1166
+
1167
+ /**
1168
+ * Reset the sync stream usage flag (call when starting a new request)
1169
+ */
1170
+ function resetSyncStreamUsed() {
1171
+ syncStreamWasUsed = false;
1172
+ }
1173
+
1174
+ /**
1175
+ * Adopt a sync stream - returns the stream's div and removes it from tracking
1176
+ * The SSE handler takes ownership for finalization
1177
+ * @param {string} requestId
1178
+ * @returns {{ div: HTMLDivElement, fullResponse: string } | null}
1179
+ */
1180
+ function adoptSyncStream(requestId) {
1181
+ if (!requestId || !activeSyncStreams.has(requestId)) return null;
1182
+ const stream = activeSyncStreams.get(requestId);
1183
+ if (stream.orphanTimer) clearTimeout(stream.orphanTimer);
1184
+ activeSyncStreams.delete(requestId);
1185
+ return { div: stream.div, fullResponse: stream.fullResponse };
1186
+ }
1187
+
1188
+ /**
1189
+ * Find any active sync stream (when requestId isn't known yet)
1190
+ * Returns the most recent one
1191
+ * @returns {{ requestId: string, div: HTMLDivElement, fullResponse: string } | null}
1192
+ */
1193
+ function findActiveSyncStream() {
1194
+ if (activeSyncStreams.size === 0) return null;
1195
+ // Return the last (most recent) entry
1196
+ let last = null;
1197
+ for (const [requestId, stream] of activeSyncStreams) {
1198
+ last = { requestId, div: stream.div, fullResponse: stream.fullResponse };
1199
+ }
1200
+ return last;
1201
+ }
1202
+
1203
+ // Export API
1204
+ export const UplinkConnection = {
1205
+ connect,
1206
+ disconnect,
1207
+ reconnect,
1208
+ manualRetry,
1209
+ onConnection,
1210
+ isConnected: () => isConnected,
1211
+ isReconnecting: () => isReconnecting,
1212
+ getReconnectAttempts: () => reconnectAttempts,
1213
+ getWebSocket: () => ws,
1214
+ config,
1215
+ // Sync deduplication
1216
+ clearSyncDedup,
1217
+ markMessageSeen,
1218
+ // Sync streaming
1219
+ clearActiveSyncStreams,
1220
+ hasActiveSyncStream,
1221
+ adoptSyncStream,
1222
+ findActiveSyncStream,
1223
+ wasSyncStreamUsed,
1224
+ resetSyncStreamUsed
1225
+ };
1226
+
1227
+ // Backward compat: assign to window
1228
+ window.UplinkConnection = UplinkConnection;
1229
+
1230
+ // Register module
1231
+ UplinkCore.registerModule('connection', init);