@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,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);
|