@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,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, '&')
|
|
671
|
+
.replace(/</g, '<')
|
|
672
|
+
.replace(/>/g, '>')
|
|
673
|
+
.replace(/"/g, '"')
|
|
674
|
+
.replace(/'/g, ''');
|
|
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);
|