@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,1211 @@
1
+ // ============================================
2
+ // CHAT MODULE
3
+ // Message sending, receiving, display
4
+ // ============================================
5
+ // Chat module v7 - streaming working
6
+
7
+ import { UplinkLogger } from './logger.js';
8
+ import { UplinkCore } from './core.js';
9
+ import { UplinkStorage } from './storage.js';
10
+ import { UplinkErrors } from './errors.js';
11
+ import { UplinkStreamingHandler } from './streaming-handler.js';
12
+ import { UplinkMessageRenderer } from './message-renderer.js';
13
+ import { UplinkAudioQueue } from './audio-queue.js';
14
+ import { UplinkOfflineQueue } from './offline-queue.js';
15
+ import { UplinkFileHandler } from './file-handler.js';
16
+ import { emit as emitEvent } from './event-bus.js';
17
+
18
+ // DOM elements (cached on init)
19
+ let messagesEl, emptyStateEl, textInput, sendBtn, typingEl, stopBtn;
20
+
21
+ // Auto-scroll behavior
22
+ const SCROLL_THRESHOLD_PX = 100;
23
+ let isNearBottom = true;
24
+ let scrollToBottomBtn = null;
25
+ let hasUnreadMessages = false;
26
+
27
+ // Typing indicator timeout (30 seconds for text, 5 minutes for images)
28
+ const TYPING_TIMEOUT_MS = 120000; // 2 minutes (thinking status will reset it sooner)
29
+ const IMAGE_TYPING_TIMEOUT_MS = 300000; // 5 minutes for image analysis
30
+ let typingTimeoutId = null;
31
+
32
+ // Message queue for when processing
33
+ const messageQueue = [];
34
+
35
+ // Offline message queue — delegated to UplinkOfflineQueue module
36
+
37
+ // Message hooks - allows other modules to observe messages without patching
38
+ // This replaces the window.addMessage patching pattern used by satellites/notifications
39
+ const messageHooks = [];
40
+
41
+ // Abort controller for stopping generation
42
+ let currentAbortController = null;
43
+
44
+ // Submission lock to prevent double-submit race conditions
45
+ let isSubmitting = false;
46
+
47
+ // AbortController for event listeners cleanup
48
+ let eventsAbortController = null;
49
+
50
+ // Module init retry state
51
+ let initRetryCount = 0;
52
+ const MAX_INIT_RETRIES = 10;
53
+
54
+ // Offline queue functions now live in offline-queue.js (UplinkOfflineQueue)
55
+
56
+ // Initialize
57
+ function init() {
58
+ messagesEl = document.getElementById('messages');
59
+ emptyStateEl = document.getElementById('emptyState');
60
+ textInput = document.getElementById('textInput');
61
+ sendBtn = document.getElementById('sendBtn');
62
+
63
+ if (!messagesEl || !textInput) {
64
+ if (initRetryCount >= MAX_INIT_RETRIES) {
65
+ logger.error('Chat: Required elements not found after max retries, giving up');
66
+ return;
67
+ }
68
+ initRetryCount++;
69
+ const delay = Math.min(100 * Math.pow(2, initRetryCount), 5000);
70
+ logger.warn(`Chat: Required elements not found, retrying (${initRetryCount}/${MAX_INIT_RETRIES})...`);
71
+ setTimeout(init, delay);
72
+ return;
73
+ }
74
+
75
+ // Abort previous event listeners to prevent stacking if init called multiple times
76
+ if (eventsAbortController) {
77
+ eventsAbortController.abort();
78
+ }
79
+ eventsAbortController = new AbortController();
80
+ const signal = eventsAbortController.signal;
81
+
82
+ updateScrollState();
83
+ messagesEl.addEventListener('scroll', updateScrollState, { passive: true, signal });
84
+
85
+ // Add aria-live region for screen reader announcements
86
+ messagesEl.setAttribute('aria-live', 'polite');
87
+ messagesEl.setAttribute('aria-atomic', 'false');
88
+ messagesEl.setAttribute('aria-relevant', 'additions');
89
+
90
+ // Event listeners
91
+ sendBtn?.addEventListener('click', () => {
92
+ // If in stop mode (send-stop class, no text in input), abort generation
93
+ if (isSendingState && sendBtn.classList.contains('send-stop')) {
94
+ stopGeneration();
95
+ return;
96
+ }
97
+ handleSubmit();
98
+ }, { signal });
99
+
100
+ // Detect mobile/touch device
101
+ const isMobile = /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent)
102
+ || ('ontouchstart' in window);
103
+
104
+ textInput.addEventListener('keydown', (e) => {
105
+ if (e.key === 'Enter') {
106
+ // On mobile: Enter = new line, use Send button to submit
107
+ // On desktop: Enter = submit, Shift+Enter = new line
108
+ if (isMobile) {
109
+ // Let Enter add new line naturally on mobile
110
+ return;
111
+ }
112
+
113
+ if (!e.shiftKey) {
114
+ // Don't handle if it's a slash command
115
+ if (textInput.value.trim().startsWith('/')) return;
116
+ e.preventDefault();
117
+ handleSubmit();
118
+ }
119
+ }
120
+ }, { signal });
121
+
122
+ // Auto-resize textarea — and keep messages scrolled to bottom as input grows
123
+ // Also toggle send/stop icon when user types during streaming
124
+ textInput.addEventListener('input', () => {
125
+ const prevHeight = textInput.offsetHeight;
126
+ textInput.style.height = 'auto';
127
+ textInput.style.height = Math.min(textInput.scrollHeight, 150) + 'px';
128
+ const delta = textInput.offsetHeight - prevHeight;
129
+ // If the input grew and user was near the bottom, scroll to compensate
130
+ if (delta > 0 && messagesEl && isNearBottom) {
131
+ messagesEl.scrollTop += delta;
132
+ }
133
+
134
+ // Change 2: Toggle send/stop button based on input content during streaming
135
+ if (isSendingState && sendBtn) {
136
+ if (textInput.value.trim().length > 0) {
137
+ showSendIcon();
138
+ } else {
139
+ showStopIcon();
140
+ }
141
+ }
142
+ }, { signal });
143
+
144
+ // Load offline queue from localStorage
145
+ if (window.UplinkOfflineQueue) {
146
+ window.UplinkOfflineQueue.load();
147
+ }
148
+
149
+ // Listen for online event to process queued messages
150
+ window.addEventListener('online', () => {
151
+ logger.debug('Chat: Network online, processing offline queue');
152
+ setTimeout(() => {
153
+ if (window.UplinkOfflineQueue) {
154
+ window.UplinkOfflineQueue.processQueue().catch(err =>
155
+ logger.error('Chat: Offline queue processing failed', err)
156
+ );
157
+ }
158
+ }, 1000); // Small delay to let connection stabilize
159
+ }, { signal });
160
+
161
+ // Listen for connection events to process queue
162
+ if (window.UplinkConnection) {
163
+ window.UplinkConnection.onConnection((event) => {
164
+ if (event === 'connected') {
165
+ setTimeout(() => {
166
+ if (window.UplinkOfflineQueue) {
167
+ window.UplinkOfflineQueue.processQueue().catch(err =>
168
+ logger.error('Chat: Offline queue processing failed', err)
169
+ );
170
+ }
171
+ }, 500);
172
+ }
173
+ });
174
+ }
175
+
176
+ // Load history (async but we don't block init on it — fire and forget with error handling)
177
+ loadHistory().catch(err => logger.error('Chat: Failed to load history', err));
178
+
179
+ // Initialize audio queue
180
+ if (window.UplinkAudioQueue) {
181
+ window.UplinkAudioQueue.init();
182
+ }
183
+
184
+ logger.debug('Chat: Initialized');
185
+ }
186
+
187
+ function updateScrollState() {
188
+ if (!messagesEl) return;
189
+ const distanceFromBottom = messagesEl.scrollHeight - messagesEl.scrollTop - messagesEl.clientHeight;
190
+ isNearBottom = distanceFromBottom <= SCROLL_THRESHOLD_PX;
191
+
192
+ // If user scrolled to bottom, hide the button and clear unread flag
193
+ if (isNearBottom) {
194
+ hasUnreadMessages = false;
195
+ hideScrollToBottomBtn();
196
+ }
197
+ }
198
+
199
+ /**
200
+ * Create and show scroll-to-bottom button
201
+ */
202
+ function showScrollToBottomBtn() {
203
+ if (scrollToBottomBtn || !messagesEl) return;
204
+
205
+ scrollToBottomBtn = document.createElement('button');
206
+ scrollToBottomBtn.className = 'scroll-to-bottom-btn';
207
+ scrollToBottomBtn.innerHTML = '↓ New messages';
208
+ scrollToBottomBtn.title = 'Scroll to bottom';
209
+ scrollToBottomBtn.setAttribute('aria-label', 'Scroll to new messages');
210
+ scrollToBottomBtn.style.cssText = `
211
+ position: absolute;
212
+ bottom: 80px;
213
+ left: 50%;
214
+ transform: translateX(-50%);
215
+ background: var(--accent, #007bff);
216
+ color: white;
217
+ border: none;
218
+ border-radius: 20px;
219
+ padding: 8px 16px;
220
+ font-size: 13px;
221
+ cursor: pointer;
222
+ z-index: 100;
223
+ box-shadow: 0 2px 8px rgba(0,0,0,0.3);
224
+ animation: fadeInUp 0.2s ease;
225
+ display: flex;
226
+ align-items: center;
227
+ gap: 6px;
228
+ `;
229
+
230
+ scrollToBottomBtn.onclick = () => {
231
+ if (messagesEl) {
232
+ messagesEl.scrollTo({ top: messagesEl.scrollHeight, behavior: 'smooth' });
233
+ }
234
+ hideScrollToBottomBtn();
235
+ hasUnreadMessages = false;
236
+ };
237
+
238
+ // Insert after messages container
239
+ const chatContainer = messagesEl.parentElement;
240
+ if (chatContainer) {
241
+ chatContainer.style.position = 'relative';
242
+ chatContainer.appendChild(scrollToBottomBtn);
243
+ }
244
+ }
245
+
246
+ /**
247
+ * Hide scroll-to-bottom button
248
+ */
249
+ function hideScrollToBottomBtn() {
250
+ if (scrollToBottomBtn) {
251
+ scrollToBottomBtn.remove();
252
+ scrollToBottomBtn = null;
253
+ }
254
+ }
255
+
256
+ // Load chat history from storage
257
+ async function loadHistory() {
258
+ if (!window.UplinkStorage) return;
259
+
260
+ const history = await window.UplinkStorage.loadHistory();
261
+ if (history.length > 0) {
262
+ if (emptyStateEl) emptyStateEl.style.display = 'none';
263
+ history.forEach(msg => {
264
+ addMessage(msg.text, msg.type, msg.imageUrl, false, msg.timestamp || null);
265
+ });
266
+ }
267
+ }
268
+
269
+ // Handle message submission
270
+ async function handleSubmit() {
271
+ // Prevent double submission race condition
272
+ if (isSubmitting) return;
273
+
274
+ let text = textInput.value.trim();
275
+ if (!text) return;
276
+
277
+ isSubmitting = true;
278
+
279
+ // Show send-in-progress state
280
+ setSendingState(true);
281
+
282
+ // Format with reply context if replying to a message
283
+ if (window.UplinkMessageActions?.getReplyContext()) {
284
+ text = window.UplinkMessageActions.formatMessageWithReply(text);
285
+ }
286
+
287
+ // Check for pending image from files module
288
+ const pendingImage = window.UplinkFiles?.getPendingImage();
289
+ const hasImage = !!pendingImage;
290
+
291
+ // Check for pending text file from files module
292
+ const pendingFile = window.UplinkFiles?.getPendingFile();
293
+ const hasTextFile = !hasImage && pendingFile?.isText;
294
+
295
+ // If there's a text file, prepend its content to the message
296
+ if (hasTextFile) {
297
+ const fileHeader = `[File: ${pendingFile.name}]\n\`\`\`\n${pendingFile.content}\n\`\`\`\n\n`;
298
+ text = fileHeader + text;
299
+ }
300
+
301
+ textInput.value = '';
302
+ textInput.style.height = 'auto';
303
+
304
+ // Clear image preview if exists
305
+ if (hasImage) {
306
+ window.UplinkFiles?.clearPending();
307
+ const preview = document.getElementById('imagePreview');
308
+ if (preview) preview.classList.remove('visible');
309
+ }
310
+
311
+ // Clear text file preview if exists
312
+ if (hasTextFile) {
313
+ window.UplinkFiles?.clearPending();
314
+ const preview = document.getElementById('imagePreview');
315
+ if (preview) preview.classList.remove('visible');
316
+ }
317
+
318
+ // Check if we're offline - queue the message for later
319
+ if (!navigator.onLine && window.UplinkOfflineQueue) {
320
+ const queuedMsg = window.UplinkOfflineQueue.queueMessage(text, hasImage ? pendingImage : null);
321
+ if (emptyStateEl) emptyStateEl.style.display = 'none';
322
+ window.UplinkOfflineQueue.addMessageWithQueuedIndicator(
323
+ messagesEl, text, 'user', hasImage ? pendingImage : null, queuedMsg.id, formatMessage, isNearBottom
324
+ );
325
+ isSubmitting = false;
326
+ setSendingState(false);
327
+ return;
328
+ }
329
+
330
+ // If already processing, queue the message silently
331
+ const core = window.UplinkCore;
332
+ if (core && core.chatState !== 'idle') {
333
+ messageQueue.push({ text, imageUrl: hasImage ? pendingImage : null });
334
+ // Show the user's message immediately with a subtle "queued" indicator
335
+ const msgDiv = addMessage(text, 'user', hasImage ? pendingImage : null);
336
+ if (msgDiv) {
337
+ msgDiv.classList.add('queued');
338
+ msgDiv.title = 'Queued - will send after current response';
339
+ }
340
+ isSubmitting = false;
341
+ // Don't call setSendingState(false) — we're still streaming.
342
+ // Switch back to stop icon since input is now empty.
343
+ showStopIcon();
344
+ return;
345
+ }
346
+
347
+ try {
348
+ if (hasImage) {
349
+ await sendImageMessage(pendingImage, text);
350
+ } else if (pendingFile && !pendingFile.isText && pendingFile.blob) {
351
+ // Non-text file (PDF, DOCX, XLSX, etc.) — upload via /api/file
352
+ const fileToSend = pendingFile;
353
+ window.UplinkFiles?.clearPending();
354
+ const preview = document.getElementById('imagePreview');
355
+ if (preview) preview.classList.remove('visible');
356
+ await sendFileMessage(fileToSend, text);
357
+ } else {
358
+ await sendTextMessage(text);
359
+ }
360
+ } finally {
361
+ isSubmitting = false;
362
+ setSendingState(false);
363
+ }
364
+ }
365
+
366
+ // SVG icons for send button states
367
+ const STOP_ICON_SVG = '<svg viewBox="0 0 16 16" fill="currentColor" xmlns="http://www.w3.org/2000/svg"><rect x="2" y="2" width="12" height="12" rx="2"/></svg>';
368
+
369
+ // Track whether we're currently in streaming/sending mode
370
+ let isSendingState = false;
371
+
372
+ /**
373
+ * Set visual state for send-in-progress
374
+ * When streaming: show stop icon. If user types, switch to send icon for queueing.
375
+ */
376
+ function setSendingState(sending) {
377
+ isSendingState = sending;
378
+ if (!sendBtn) return;
379
+
380
+ sendBtn.classList.toggle('sending', sending);
381
+
382
+ if (sending) {
383
+ sendBtn.dataset.originalHtml = sendBtn.innerHTML;
384
+ showStopIcon();
385
+ } else {
386
+ sendBtn.classList.remove('send-stop');
387
+ if (sendBtn.dataset.originalHtml) {
388
+ sendBtn.innerHTML = sendBtn.dataset.originalHtml;
389
+ }
390
+ sendBtn.setAttribute('aria-label', 'Send message');
391
+ }
392
+ // Keep textInput enabled - allow typing while waiting for response
393
+ }
394
+
395
+ /**
396
+ * Show the stop icon on the send button
397
+ */
398
+ function showStopIcon() {
399
+ if (!sendBtn) return;
400
+ sendBtn.classList.add('send-stop');
401
+ sendBtn.innerHTML = STOP_ICON_SVG;
402
+ sendBtn.setAttribute('aria-label', 'Stop generation');
403
+ }
404
+
405
+ /**
406
+ * Restore the send icon on the send button (for queueing during streaming)
407
+ */
408
+ function showSendIcon() {
409
+ if (!sendBtn) return;
410
+ sendBtn.classList.remove('send-stop');
411
+ if (sendBtn.dataset.originalHtml) {
412
+ sendBtn.innerHTML = sendBtn.dataset.originalHtml;
413
+ }
414
+ sendBtn.setAttribute('aria-label', 'Send message');
415
+ }
416
+
417
+ // addMessageWithQueuedIndicator now lives in offline-queue.js (UplinkOfflineQueue)
418
+
419
+ // ============================================
420
+ // STREAMING HANDLER INSTANCE
421
+ // Delegates streaming primitives to shared UplinkStreamingHandler
422
+ // ============================================
423
+
424
+ let chatStreamHandler = null;
425
+
426
+ /**
427
+ * Get or create the streaming handler instance for the main chat.
428
+ * Lazily initialized because messagesEl may not be available at module load time.
429
+ * @returns {Object} StreamingHandler instance
430
+ */
431
+ function getStreamHandler() {
432
+ if (!chatStreamHandler) {
433
+ // Ensure messagesEl is available (fallback query if init timing issue)
434
+ if (!messagesEl) {
435
+ messagesEl = document.getElementById('messages');
436
+ }
437
+ chatStreamHandler = window.UplinkStreamingHandler.create({
438
+ container: messagesEl,
439
+ formatMessage,
440
+ getIsNearBottom: () => isNearBottom,
441
+ showAvatar: true
442
+ });
443
+ }
444
+ return chatStreamHandler;
445
+ }
446
+
447
+ // ============================================
448
+ // sendTextMessage - Extracted Helper Functions
449
+ // ============================================
450
+
451
+ /**
452
+ * Create streaming message container for assistant response
453
+ * Delegates to shared StreamingHandler.
454
+ * @returns {HTMLDivElement} The message container element
455
+ */
456
+ function createStreamingMessageDiv() {
457
+ return getStreamHandler().createStreamingMessage();
458
+ }
459
+
460
+ /**
461
+ * Update streaming message content and handle auto-scroll.
462
+ * Delegates to shared StreamingHandler for throttled markdown rendering.
463
+ * @param {HTMLDivElement} _responseDiv - DEPRECATED: ignored, handler tracks its own div.
464
+ * Kept for backward compatibility with connection.js callers.
465
+ * @param {string} content - Complete accumulated response text
466
+ */
467
+ function updateStreamingContent(_responseDiv, content) {
468
+ getStreamHandler().updateStreamingContent(content);
469
+ }
470
+
471
+ /**
472
+ * Handle stream completion — finalize DOM if needed (WS-adopted divs),
473
+ * then apply chat-specific logic: usage stats, storage, seen-marking, hooks.
474
+ *
475
+ * When called from processStreamChunk's onDone callback, the shared handler
476
+ * has already finalized the div. When called from the WS-handled path in
477
+ * processSSEStream, the div is a WS sync stream adoption that needs DOM cleanup.
478
+ *
479
+ * @param {HTMLDivElement} responseDiv - The message container
480
+ * @param {string} fullResponse - Complete response text
481
+ * @param {Object} parsed - Parsed SSE data with usage info and media
482
+ */
483
+ function handleStreamCompletion(responseDiv, fullResponse, parsed) {
484
+ // Finalize DOM if the div is still in streaming state (WS-adopted path)
485
+ if (responseDiv && responseDiv.classList.contains('streaming')) {
486
+ responseDiv.classList.remove('streaming');
487
+ responseDiv.dataset.originalText = fullResponse;
488
+ const textSpan = responseDiv.querySelector('.message-text');
489
+ if (textSpan && fullResponse) {
490
+ textSpan.innerHTML = formatMessage(fullResponse);
491
+ }
492
+ }
493
+
494
+ // Add media images if present
495
+ if (responseDiv && parsed.media && parsed.media.length > 0) {
496
+ // Insert images before the text span
497
+ const textSpan = responseDiv.querySelector('.message-text');
498
+ parsed.media.forEach(mediaUrl => {
499
+ const img = document.createElement('img');
500
+ img.src = mediaUrl;
501
+ img.alt = 'Agent-generated media';
502
+ img.loading = 'lazy';
503
+ img.onerror = () => {
504
+ // Silently hide broken images
505
+ img.remove();
506
+ };
507
+ if (textSpan) {
508
+ responseDiv.insertBefore(img, textSpan);
509
+ } else {
510
+ responseDiv.appendChild(img);
511
+ }
512
+ });
513
+ }
514
+
515
+ if (parsed.usage) {
516
+ if (window.UplinkDeveloper) {
517
+ window.UplinkDeveloper.updateTokens(parsed.usage);
518
+ }
519
+ }
520
+ // Refresh context tracker after message completes
521
+ if (parsed.done && window.UplinkContextTracker) {
522
+ window.UplinkContextTracker.refresh();
523
+ }
524
+
525
+ if (fullResponse && window.UplinkStorage) {
526
+ window.UplinkStorage.saveMessage({ text: fullResponse, type: 'assistant' });
527
+ }
528
+
529
+ // Mark message as seen to prevent sync broadcast duplicate
530
+ if (fullResponse && window.UplinkConnection?.markMessageSeen) {
531
+ window.UplinkConnection.markMessageSeen(null, 'assistant', fullResponse, Date.now());
532
+ }
533
+
534
+ // Invoke message hooks for streamed assistant responses (for satellites, etc.)
535
+ if (fullResponse) {
536
+ for (const hook of messageHooks) {
537
+ try {
538
+ hook({ text: fullResponse, type: 'assistant', imageUrl: null, save: true });
539
+ } catch (e) {
540
+ logger.error('Chat: Message hook error (stream)', e);
541
+ }
542
+ }
543
+ }
544
+ }
545
+
546
+ /**
547
+ * Handle error received within SSE stream
548
+ * @param {string} errorMessage - Error message from stream
549
+ */
550
+ function handleInStreamError(errorMessage) {
551
+ hideTyping();
552
+ const friendlyMsg = window.UplinkErrors?.getFriendlyMessage(errorMessage) || errorMessage;
553
+ addMessage(friendlyMsg, 'system', null, false);
554
+ if (window.UplinkDeveloper) {
555
+ window.UplinkDeveloper.logError(new Error(errorMessage), '/api/chat');
556
+ }
557
+ }
558
+
559
+ /**
560
+ * Handle fetch/network level errors
561
+ * @param {Error} err - The caught error
562
+ */
563
+ function handleFetchError(err) {
564
+ hideTyping();
565
+ hideStopButton();
566
+
567
+ if (err.name === 'AbortError') {
568
+ addMessage('Generation stopped', 'system', null, false);
569
+ } else {
570
+ const friendlyMsg = window.UplinkErrors?.getFriendlyMessage(err) || err.message;
571
+ addMessage(friendlyMsg, 'system', null, false);
572
+ if (window.UplinkDeveloper) {
573
+ window.UplinkDeveloper.logError(err, '/api/chat');
574
+ }
575
+ }
576
+ }
577
+
578
+ /**
579
+ * Process a single parsed SSE chunk.
580
+ * Delegates to the shared StreamingHandler.processChunk() for thinking/tool/content/done/error
581
+ * handling, with chat-specific callbacks for typing indicator, developer logging,
582
+ * and stream completion finalization.
583
+ *
584
+ * @param {Object} parsed - Parsed JSON from SSE data line
585
+ * @param {Object} state - Mutable state { responseDiv, fullResponse }
586
+ * @returns {Object} Updated state
587
+ */
588
+ function processStreamChunk(parsed, state) {
589
+ const handler = getStreamHandler();
590
+
591
+ // Clear typing indicator on any real content event
592
+ if (parsed.status === 'thinking' || parsed.tool || parsed.content) {
593
+ clearTypingTimeout();
594
+ hideTyping();
595
+ }
596
+
597
+ handler.processChunk(parsed, {
598
+ onThinking: () => {
599
+ state.responseDiv = handler.getStreamingDiv();
600
+ },
601
+ onTool: (tool) => {
602
+ state.responseDiv = handler.getStreamingDiv();
603
+ if (window.UplinkDeveloper) {
604
+ window.UplinkDeveloper.logTool(tool);
605
+ }
606
+ },
607
+ onDone: ({ div, fullResponse, parsed: doneParsed }) => {
608
+ state.responseDiv = div;
609
+ state.fullResponse = fullResponse;
610
+ // Handle completion-specific logic (usage stats, storage save, hooks)
611
+ handleStreamCompletion(div, fullResponse, doneParsed);
612
+ },
613
+ onError: (errorMessage) => {
614
+ handleInStreamError(errorMessage);
615
+ }
616
+ });
617
+
618
+ // Keep state.responseDiv in sync with the handler's streaming div
619
+ if (!state.responseDiv && handler.getStreamingDiv()) {
620
+ state.responseDiv = handler.getStreamingDiv();
621
+ }
622
+ // Keep state.fullResponse in sync with accumulated content
623
+ if (handler.getStreamContent()) {
624
+ state.fullResponse = handler.getStreamContent();
625
+ }
626
+
627
+ return state;
628
+ }
629
+
630
+ /**
631
+ * Process SSE stream from response body
632
+ *
633
+ * Handles the case where a WebSocket sync stream is already displaying
634
+ * content in real-time (e.g., when behind Cloudflare tunnel which buffers SSE).
635
+ * If a sync stream div exists, the SSE handler defers to it for display
636
+ * and only handles finalization (usage stats, save to storage).
637
+ *
638
+ * @param {ReadableStreamDefaultReader} reader - Response body reader
639
+ * @returns {Promise<{responseDiv: HTMLDivElement|null, fullResponse: string, wsHandled: boolean}>}
640
+ */
641
+ async function processSSEStream(reader) {
642
+ const decoder = new TextDecoder();
643
+ let buffer = '';
644
+ let state = { responseDiv: null, fullResponse: '', wsHandled: false };
645
+
646
+ while (true) {
647
+ const { done, value } = await reader.read();
648
+ if (done) break;
649
+
650
+ buffer += decoder.decode(value, { stream: true });
651
+ const lines = buffer.split('\n');
652
+ buffer = lines.pop() || '';
653
+
654
+ for (const line of lines) {
655
+ if (!line.startsWith('data: ')) continue;
656
+
657
+ const data = line.slice(6);
658
+ if (data === '[DONE]' || data.startsWith(':')) continue;
659
+
660
+ try {
661
+ const parsed = JSON.parse(data);
662
+
663
+ // Check if WebSocket sync stream has taken over display
664
+ // This happens when behind a buffering proxy (e.g. Cloudflare tunnel)
665
+ // where SSE chunks arrive late but WS deltas arrive in real-time
666
+ // Also check wasSyncStreamUsed() — the stream may have already been
667
+ // finalized by the sync message handler before SSE chunks arrive
668
+ if (!state.wsHandled && !state.responseDiv) {
669
+ const activeSyncStream = window.UplinkConnection?.findActiveSyncStream?.();
670
+ const alreadyFinalized = window.UplinkConnection?.wasSyncStreamUsed?.();
671
+ if (activeSyncStream || alreadyFinalized) {
672
+ state.wsHandled = true;
673
+ logger.debug('Chat: WebSocket sync stream active/finalized, SSE deferring to WS for display');
674
+ }
675
+ }
676
+
677
+ if (state.wsHandled) {
678
+ // Still accumulate full response and handle completion,
679
+ // but don't create/update a display div — WS stream handles that
680
+ if (parsed.tool && window.UplinkDeveloper) {
681
+ window.UplinkDeveloper.logTool(parsed.tool);
682
+ }
683
+ if (parsed.content) {
684
+ state.fullResponse += parsed.content;
685
+ }
686
+ if (parsed.done) {
687
+ // Adopt the sync stream div for finalization
688
+ const active = window.UplinkConnection?.findActiveSyncStream?.();
689
+ if (active) {
690
+ const adopted = window.UplinkConnection?.adoptSyncStream?.(active.requestId);
691
+ if (adopted) {
692
+ state.responseDiv = adopted.div;
693
+ if (!state.fullResponse && adopted.fullResponse) {
694
+ state.fullResponse = adopted.fullResponse;
695
+ }
696
+ }
697
+ }
698
+ handleStreamCompletion(state.responseDiv, state.fullResponse, parsed);
699
+ }
700
+ } else {
701
+ state = processStreamChunk(parsed, state);
702
+ }
703
+
704
+ // Force a paint by yielding to the browser
705
+ await new Promise(r => setTimeout(r, 0));
706
+ } catch (jsonParseError) {
707
+ // Skip unparseable chunks
708
+ }
709
+ }
710
+ }
711
+
712
+ return state;
713
+ }
714
+
715
+ // ============================================
716
+ // sendTextMessage - Main Function (Refactored)
717
+ // ============================================
718
+
719
+ /**
720
+ * Send text message with streaming response
721
+ * @param {string} text - Message text to send
722
+ * @param {boolean} skipAddMessage - Skip adding user message (already shown when queued)
723
+ */
724
+ async function sendTextMessage(text, skipAddMessage = false) {
725
+ const core = window.UplinkCore;
726
+ if (core) core.chatState = 'processing';
727
+
728
+ // Reset sync stream tracking for this new request
729
+ window.UplinkConnection?.resetSyncStreamUsed?.();
730
+
731
+ if (!skipAddMessage) addMessage(text, 'user');
732
+
733
+ // Emit message:sent event for cross-module communication
734
+ emitEvent('message:sent', { text, type: 'user' });
735
+
736
+ showTyping();
737
+ showStopButton();
738
+ currentAbortController = new AbortController();
739
+
740
+ try {
741
+ const satelliteId = window.UplinkSatellites?.getCurrentSatellite() || 'main';
742
+ const satellites = window.UplinkSatellites?.getSatellites() || {};
743
+ const satelliteName = satellites[satelliteId]?.name || satelliteId;
744
+ const agentId = window.UplinkSatellites?.getCurrentAgentId?.() || 'main';
745
+
746
+ const chatResponse = await fetch('/api/chat', {
747
+ method: 'POST',
748
+ headers: { 'Content-Type': 'application/json' },
749
+ body: JSON.stringify({ message: text, stream: true, satelliteId, satelliteName, agentId }),
750
+ signal: currentAbortController.signal
751
+ });
752
+
753
+ if (!chatResponse.ok) {
754
+ throw new Error(`HTTP ${chatResponse.status}`);
755
+ }
756
+
757
+ const { responseDiv, fullResponse, wsHandled } = await processSSEStream(chatResponse.body.getReader());
758
+
759
+ hideTyping();
760
+ hideStopButton();
761
+
762
+ // If WebSocket handled the streaming display, check if we need to finalize
763
+ if (wsHandled && !responseDiv) {
764
+ // WS stream may still be active — adopt it now for cleanup
765
+ const active = window.UplinkConnection?.findActiveSyncStream?.();
766
+ const adopted = active ? window.UplinkConnection?.adoptSyncStream?.(active.requestId) : null;
767
+ if (adopted) {
768
+ const finalContent = adopted.fullResponse || fullResponse;
769
+ if (window.UplinkChat?.finalizeSyncStream) {
770
+ window.UplinkChat.finalizeSyncStream(adopted.div, finalContent);
771
+ }
772
+ if (finalContent && window.UplinkStorage) {
773
+ window.UplinkStorage.saveMessage({ text: finalContent, type: 'assistant' });
774
+ }
775
+ }
776
+ } else if (!fullResponse && !responseDiv) {
777
+ // Check if WS sync stream has the content (fallback)
778
+ const active = window.UplinkConnection?.findActiveSyncStream?.();
779
+ if (active) {
780
+ const adopted = window.UplinkConnection?.adoptSyncStream?.(active.requestId);
781
+ if (adopted?.fullResponse) {
782
+ if (window.UplinkChat?.finalizeSyncStream) {
783
+ window.UplinkChat.finalizeSyncStream(adopted.div, adopted.fullResponse);
784
+ }
785
+ if (window.UplinkStorage) {
786
+ window.UplinkStorage.saveMessage({ text: adopted.fullResponse, type: 'assistant' });
787
+ }
788
+ } else {
789
+ addMessage('No response received', 'system', null, false);
790
+ }
791
+ } else {
792
+ addMessage('No response received', 'system', null, false);
793
+ }
794
+ }
795
+
796
+ } catch (err) {
797
+ handleFetchError(err);
798
+ }
799
+
800
+ currentAbortController = null;
801
+ if (core) core.chatState = 'idle';
802
+ processQueue();
803
+ }
804
+
805
+ // ============================================
806
+ // FILE UPLOAD DELEGATES
807
+ // Delegate to shared UplinkFileHandler module
808
+ // ============================================
809
+
810
+ /** Chat interface object passed to UplinkFileHandler */
811
+ function getChatInterface() {
812
+ return {
813
+ addMessage,
814
+ showTyping,
815
+ hideTyping,
816
+ playAudio,
817
+ updateLastUserImageUrl: (serverUrl) => {
818
+ window.UplinkFileHandler?.updateLastUserImageUrl(messagesEl, serverUrl);
819
+ }
820
+ };
821
+ }
822
+
823
+ // Send image message — delegates to UplinkFileHandler
824
+ async function sendImageMessage(imageUrl, caption = '', skipAddMessage = false) {
825
+ const core = window.UplinkCore;
826
+ if (core) core.chatState = 'processing';
827
+
828
+ try {
829
+ await window.UplinkFileHandler.sendImageMessage(
830
+ imageUrl, caption, getChatInterface(), skipAddMessage, IMAGE_TYPING_TIMEOUT_MS
831
+ );
832
+ } catch (err) {
833
+ hideTyping();
834
+ const friendlyMsg = window.UplinkErrors?.getFriendlyMessage(err) || 'Upload failed';
835
+ addMessage(friendlyMsg, 'system', null, false);
836
+ }
837
+
838
+ if (core) core.chatState = 'idle';
839
+ processQueue();
840
+ }
841
+
842
+ // Send file message — delegates to UplinkFileHandler
843
+ async function sendFileMessage(fileInfo, caption = '') {
844
+ const core = window.UplinkCore;
845
+ if (core) core.chatState = 'processing';
846
+
847
+ try {
848
+ await window.UplinkFileHandler.sendFileMessage(
849
+ fileInfo, caption, getChatInterface(), IMAGE_TYPING_TIMEOUT_MS
850
+ );
851
+ } catch (err) {
852
+ hideTyping();
853
+ const friendlyMsg = window.UplinkErrors?.getFriendlyMessage(err) || 'File upload failed';
854
+ addMessage(friendlyMsg, 'system', null, false);
855
+ }
856
+
857
+ if (core) core.chatState = 'idle';
858
+ processQueue();
859
+ }
860
+
861
+ // Process queued messages (with mutex to prevent race conditions)
862
+ let processingQueue = false;
863
+ async function processQueue() {
864
+ if (processingQueue) return; // Prevent concurrent processing
865
+ if (messageQueue.length === 0) return;
866
+
867
+ const core = window.UplinkCore;
868
+ if (core && core.chatState !== 'idle') return;
869
+
870
+ processingQueue = true;
871
+ try {
872
+ const next = messageQueue.shift();
873
+ // Pass skipAddMessage=true since message was already shown when queued
874
+ if (next.imageUrl) {
875
+ await sendImageMessage(next.imageUrl, next.text, true);
876
+ } else {
877
+ await sendTextMessage(next.text, true);
878
+ }
879
+ } finally {
880
+ processingQueue = false;
881
+ // Process remaining items
882
+ if (messageQueue.length > 0) {
883
+ setTimeout(processQueue, 100);
884
+ }
885
+ }
886
+ }
887
+
888
+ // Add message to display
889
+ // Delegates DOM creation to shared UplinkMessageRenderer, then applies
890
+ // chat-specific behaviors (empty state, scroll-to-bottom button, storage, hooks).
891
+ function addMessage(text, type, imageUrl = null, save = true, timestamp = null) {
892
+ if (!messagesEl) return;
893
+
894
+ if (emptyStateEl) emptyStateEl.style.display = 'none';
895
+
896
+ // Delegate to shared renderer for div creation, avatar, image, text, scroll, auto-dismiss
897
+ const div = window.UplinkMessageRenderer?.addMessageToContainer({
898
+ container: messagesEl,
899
+ text,
900
+ type,
901
+ imageUrl,
902
+ showAvatar: type === 'assistant',
903
+ timestamp,
904
+ scroll: {
905
+ isNearBottom,
906
+ onNewMessage: () => {
907
+ // User has scrolled up and a real message arrived — show button
908
+ if (!scrollToBottomBtn) {
909
+ hasUnreadMessages = true;
910
+ showScrollToBottomBtn();
911
+ }
912
+ }
913
+ }
914
+ });
915
+
916
+ if (!div) return;
917
+
918
+ // Save to storage
919
+ if (save && type !== 'system' && window.UplinkStorage) {
920
+ window.UplinkStorage.saveMessage({ text, type, imageUrl });
921
+ }
922
+
923
+ // Mark message as seen for sync deduplication (prevents WebSocket echo duplicates)
924
+ if (text && window.UplinkConnection?.markMessageSeen) {
925
+ const role = type === 'user' ? 'user' : 'assistant';
926
+ window.UplinkConnection.markMessageSeen(null, role, text, Date.now());
927
+ }
928
+
929
+ // Invoke message hooks (for satellites, notifications, etc.)
930
+ // This replaces the need for other modules to patch addMessage
931
+ for (const hook of messageHooks) {
932
+ try {
933
+ hook({ text, type, imageUrl, save });
934
+ } catch (e) {
935
+ logger.error('Chat: Message hook error', e);
936
+ }
937
+ }
938
+
939
+ // Emit event bus event for cross-module communication
940
+ emitEvent('message:added', { text, type, imageUrl, save, timestamp });
941
+
942
+ return div;
943
+ }
944
+
945
+ // updateLastUserImageUrl now lives in file-handler.js (UplinkFileHandler)
946
+
947
+ /**
948
+ * Register a hook to be called when messages are added
949
+ * Replaces the old pattern of patching window.addMessage
950
+ * @param {Function} hook - Function receiving { text, type, imageUrl, save }
951
+ * @returns {Function} Unsubscribe function
952
+ */
953
+ function onMessage(hook) {
954
+ if (typeof hook !== 'function') return () => {};
955
+ messageHooks.push(hook);
956
+ return () => {
957
+ const idx = messageHooks.indexOf(hook);
958
+ if (idx >= 0) messageHooks.splice(idx, 1);
959
+ };
960
+ }
961
+
962
+ // Create screen reader announcement region
963
+ function createSRAnnouncementRegion() {
964
+ if (document.getElementById('sr-announcements')) return;
965
+
966
+ const region = document.createElement('div');
967
+ region.id = 'sr-announcements';
968
+ region.setAttribute('role', 'status');
969
+ region.setAttribute('aria-live', 'polite');
970
+ region.setAttribute('aria-atomic', 'true');
971
+ // Visually hidden but accessible to screen readers
972
+ region.style.cssText = 'position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0;';
973
+ document.body.appendChild(region);
974
+ }
975
+
976
+ // Announce message to screen readers
977
+ function announceToSR(message) {
978
+ const region = document.getElementById('sr-announcements');
979
+ if (region) {
980
+ // Clear and set to trigger announcement
981
+ region.textContent = '';
982
+ // Use setTimeout to ensure the change is detected
983
+ setTimeout(() => {
984
+ region.textContent = message;
985
+ }, 50);
986
+ }
987
+ }
988
+
989
+ // ============================================
990
+ // SHARED MODULE DELEGATES
991
+ // Delegate to UplinkMessageRenderer for avatars, formatting, etc.
992
+ // ============================================
993
+
994
+ function buildAgentAvatar(agentId) {
995
+ return window.UplinkMessageRenderer?.buildAgentAvatar(agentId) || null;
996
+ }
997
+
998
+ function formatMessage(text) {
999
+ return window.UplinkMessageRenderer?.formatMessage(text) || text || '';
1000
+ }
1001
+
1002
+ // Show typing indicator with timeout protection
1003
+ function showTyping(timeoutMs = TYPING_TIMEOUT_MS) {
1004
+ if (typingEl) return;
1005
+
1006
+ typingEl = document.createElement('div');
1007
+ typingEl.className = 'typing';
1008
+ typingEl.id = 'typing';
1009
+ typingEl.setAttribute('role', 'status');
1010
+ typingEl.setAttribute('aria-label', 'Assistant is typing');
1011
+ typingEl.innerHTML = '<span></span><span></span><span></span>';
1012
+ messagesEl?.appendChild(typingEl);
1013
+ if (messagesEl && isNearBottom) messagesEl.scrollTop = messagesEl.scrollHeight;
1014
+
1015
+ // Announce to screen readers
1016
+ const announcer = document.getElementById('sr-announcer');
1017
+ if (announcer) announcer.textContent = 'Assistant is typing';
1018
+
1019
+ // Set timeout to auto-hide typing indicator if server hangs
1020
+ clearTypingTimeout();
1021
+ typingTimeoutId = setTimeout(() => {
1022
+ const timeoutSec = Math.round(timeoutMs / 1000);
1023
+ logger.warn(`Chat: Typing indicator timed out after ${timeoutSec}s`);
1024
+ hideTyping();
1025
+ addMessage('Response timed out. The server may be busy.', 'system', null, false);
1026
+ }, timeoutMs);
1027
+ }
1028
+
1029
+ // Clear typing timeout
1030
+ function clearTypingTimeout() {
1031
+ if (typingTimeoutId) {
1032
+ clearTimeout(typingTimeoutId);
1033
+ typingTimeoutId = null;
1034
+ }
1035
+ }
1036
+
1037
+ // Hide typing indicator
1038
+ function hideTyping() {
1039
+ clearTypingTimeout();
1040
+
1041
+ // Always try to remove by ID first (catches orphaned elements)
1042
+ const typingById = document.getElementById('typing');
1043
+ if (typingById) typingById.remove();
1044
+
1045
+ // Then clear reference (might be different element)
1046
+ if (typingEl) {
1047
+ if (typingEl.parentNode) typingEl.remove();
1048
+ typingEl = null;
1049
+ }
1050
+
1051
+ // Nuclear option: remove ALL .typing elements
1052
+ document.querySelectorAll('.typing').forEach(el => el.remove());
1053
+
1054
+ // Clear screen reader announcement
1055
+ const announcer = document.getElementById('sr-announcer');
1056
+ if (announcer) announcer.textContent = '';
1057
+ }
1058
+
1059
+ // Show stop generation button
1060
+ function showStopButton() {
1061
+ if (stopBtn) return;
1062
+
1063
+ stopBtn = document.createElement('button');
1064
+ stopBtn.className = 'stop-generation-btn';
1065
+ stopBtn.innerHTML = '⬛ Stop';
1066
+ stopBtn.title = 'Stop generation';
1067
+ stopBtn.setAttribute('aria-label', 'Stop generation');
1068
+ stopBtn.onclick = stopGeneration;
1069
+
1070
+ // Insert before the input area
1071
+ const inputArea = document.querySelector('.chat-input');
1072
+ if (inputArea) {
1073
+ inputArea.insertAdjacentElement('beforebegin', stopBtn);
1074
+ }
1075
+ }
1076
+
1077
+ // Hide stop generation button
1078
+ function hideStopButton() {
1079
+ if (stopBtn) {
1080
+ stopBtn.remove();
1081
+ stopBtn = null;
1082
+ }
1083
+ }
1084
+
1085
+ // Stop the current generation
1086
+ function stopGeneration() {
1087
+ if (currentAbortController) {
1088
+ currentAbortController.abort();
1089
+ }
1090
+ clearAudioQueue();
1091
+ hideStopButton();
1092
+ // Reset send button to normal state
1093
+ setSendingState(false);
1094
+ }
1095
+
1096
+ // ============================================
1097
+ // AUDIO DELEGATES
1098
+ // Delegate to shared UplinkAudioQueue module
1099
+ // ============================================
1100
+
1101
+ function playAudio(url) {
1102
+ if (window.UplinkAudioQueue) {
1103
+ window.UplinkAudioQueue.playAudio(url);
1104
+ }
1105
+ }
1106
+
1107
+ function clearAudioQueue() {
1108
+ if (window.UplinkAudioQueue) {
1109
+ window.UplinkAudioQueue.clearQueue();
1110
+ }
1111
+ }
1112
+
1113
+ // Audio queue functions now live in audio-queue.js (UplinkAudioQueue)
1114
+ // playAudio() and clearAudioQueue() above are thin delegates
1115
+
1116
+ // Cleanup function
1117
+ function destroy() {
1118
+ if (eventsAbortController) {
1119
+ eventsAbortController.abort();
1120
+ eventsAbortController = null;
1121
+ }
1122
+ if (currentAbortController) {
1123
+ currentAbortController.abort();
1124
+ currentAbortController = null;
1125
+ }
1126
+ clearAudioQueue();
1127
+ messageQueue.length = 0;
1128
+ // Note: We don't clear offlineQueue on destroy - it persists for reconnection
1129
+ }
1130
+
1131
+ /**
1132
+ * Finalize a sync-streamed message bubble with the full content
1133
+ * Does NOT save to storage or fire hooks (sync messages use save=false)
1134
+ * @param {HTMLDivElement} div - The streaming message container
1135
+ * @param {string} fullContent - Complete response text
1136
+ */
1137
+ function finalizeSyncStream(div, fullContent) {
1138
+ if (!div) return;
1139
+
1140
+ // Guard against double-finalization: if already finalized, skip re-render
1141
+ if (!div.classList.contains('streaming')) return;
1142
+
1143
+ // Clean up streaming render timer via shared handler
1144
+ getStreamHandler().clearStreamRenderTimer();
1145
+
1146
+ div.classList.remove('streaming');
1147
+ div.dataset.originalText = fullContent;
1148
+ const textSpan = div.querySelector('.message-text');
1149
+ if (textSpan && fullContent) {
1150
+ textSpan.innerHTML = formatMessage(fullContent);
1151
+ }
1152
+ if (fullContent && window.UplinkConnection?.markMessageSeen) {
1153
+ window.UplinkConnection.markMessageSeen(null, 'assistant', fullContent, Date.now());
1154
+ }
1155
+ if (messagesEl && isNearBottom) {
1156
+ messagesEl.scrollTop = messagesEl.scrollHeight;
1157
+ }
1158
+ }
1159
+
1160
+ // Export API
1161
+ export const UplinkChat = {
1162
+ addMessage,
1163
+ formatMessage,
1164
+ showTyping,
1165
+ hideTyping,
1166
+ sendTextMessage,
1167
+ sendImageMessage,
1168
+ sendFileMessage,
1169
+ stopGeneration,
1170
+ playAudio,
1171
+ clearAudioQueue,
1172
+ createStreamingMessage: createStreamingMessageDiv,
1173
+ updateStreamingMessage: updateStreamingContent,
1174
+ finalizeSyncStream,
1175
+ clearMessages: () => {
1176
+ if (messagesEl) messagesEl.innerHTML = '';
1177
+ if (emptyStateEl) {
1178
+ emptyStateEl.style.display = 'flex';
1179
+ messagesEl?.appendChild(emptyStateEl);
1180
+ }
1181
+ },
1182
+ destroy,
1183
+ // Offline queue management (delegates to UplinkOfflineQueue)
1184
+ getOfflineQueueLength: () => UplinkOfflineQueue.getLength?.() || 0,
1185
+ processOfflineQueue: () => UplinkOfflineQueue.processQueue?.(),
1186
+ clearOfflineQueue: () => UplinkOfflineQueue.clear?.(),
1187
+ // Message hook system - replaces patching window.addMessage
1188
+ onMessage,
1189
+ loadHistory
1190
+ };
1191
+
1192
+ // Backward compat: assign to window
1193
+ window.UplinkChat = UplinkChat;
1194
+
1195
+ // DEPRECATED: Legacy globals for backward compatibility
1196
+ window.addMessage = addMessage;
1197
+ window.formatMessage = formatMessage;
1198
+
1199
+ // Helper for init retry with exponential backoff
1200
+ function scheduleInitRetry() {
1201
+ if (initRetryCount >= MAX_INIT_RETRIES) {
1202
+ UplinkLogger.warn('Chat: Max init retries reached, giving up');
1203
+ return;
1204
+ }
1205
+ initRetryCount++;
1206
+ const delay = Math.min(50 * Math.pow(2, initRetryCount), 5000);
1207
+ setTimeout(init, delay);
1208
+ }
1209
+
1210
+ // Register and init
1211
+ UplinkCore.registerModule('chat', init);