@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,1234 @@
|
|
|
1
|
+
// ============================================
|
|
2
|
+
// SPLIT CHAT MODULE
|
|
3
|
+
// Secondary chat component for dual-pane mode
|
|
4
|
+
// Independent chat state sharing the same WebSocket
|
|
5
|
+
// ============================================
|
|
6
|
+
|
|
7
|
+
import { UplinkCore } from './core.js';
|
|
8
|
+
import { UplinkLogger } from './logger.js';
|
|
9
|
+
|
|
10
|
+
const logger = UplinkLogger;
|
|
11
|
+
|
|
12
|
+
// ============================================
|
|
13
|
+
// STATE
|
|
14
|
+
// ============================================
|
|
15
|
+
|
|
16
|
+
// Active session state
|
|
17
|
+
let activeSession = null; // { sessionKey, label, agentName }
|
|
18
|
+
|
|
19
|
+
// DOM references (set in init)
|
|
20
|
+
let paneEl = null;
|
|
21
|
+
let iconEl = null;
|
|
22
|
+
let nameEl = null;
|
|
23
|
+
let messagesEl = null;
|
|
24
|
+
let inputEl = null;
|
|
25
|
+
let sendBtnEl = null;
|
|
26
|
+
let switchBtnEl = null;
|
|
27
|
+
let closeBtnEl = null;
|
|
28
|
+
let contextBadgeEl = null;
|
|
29
|
+
let contextBarFillEl = null;
|
|
30
|
+
let contextTextEl = null;
|
|
31
|
+
|
|
32
|
+
// Streaming handler instance (created lazily via shared UplinkStreamingHandler)
|
|
33
|
+
let streamHandler = null;
|
|
34
|
+
|
|
35
|
+
// History loading timestamp (used to prevent sync event duplication after load)
|
|
36
|
+
let historyLoadedAt = 0;
|
|
37
|
+
|
|
38
|
+
// Track recently sent messages to prevent duplication from sync events
|
|
39
|
+
let lastSentMessageText = null;
|
|
40
|
+
let lastSentMessageTime = 0;
|
|
41
|
+
|
|
42
|
+
// Auto-scroll state
|
|
43
|
+
const SCROLL_THRESHOLD_PX = 100;
|
|
44
|
+
let isNearBottom = true;
|
|
45
|
+
|
|
46
|
+
// Picker state
|
|
47
|
+
let pickerVisible = false;
|
|
48
|
+
let pickerEl = null;
|
|
49
|
+
|
|
50
|
+
// WebSocket listener unsubscribe function
|
|
51
|
+
let wsUnsubscribe = null;
|
|
52
|
+
|
|
53
|
+
// Typing indicator element
|
|
54
|
+
let typingEl = null;
|
|
55
|
+
|
|
56
|
+
// Abort controller for stopping generation
|
|
57
|
+
let currentAbortController = null;
|
|
58
|
+
|
|
59
|
+
// Module init retry state
|
|
60
|
+
let initRetryCount = 0;
|
|
61
|
+
const MAX_INIT_RETRIES = 10;
|
|
62
|
+
|
|
63
|
+
// ============================================
|
|
64
|
+
// SHARED MODULE HELPERS
|
|
65
|
+
// ============================================
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Get or create the streaming handler instance for split chat.
|
|
69
|
+
* Lazily initialized because messagesEl may not be available at module load time.
|
|
70
|
+
*/
|
|
71
|
+
function getStreamHandler() {
|
|
72
|
+
if (!streamHandler && messagesEl && window.UplinkStreamingHandler) {
|
|
73
|
+
streamHandler = window.UplinkStreamingHandler.create({
|
|
74
|
+
container: messagesEl,
|
|
75
|
+
formatMessage: renderMarkdown,
|
|
76
|
+
agentId: activeSession?.agentId || null,
|
|
77
|
+
onStreamStart: () => {
|
|
78
|
+
setInputEnabled(false);
|
|
79
|
+
},
|
|
80
|
+
onStreamEnd: () => setInputEnabled(true),
|
|
81
|
+
getIsNearBottom: () => isNearBottom,
|
|
82
|
+
showAvatar: true
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
return streamHandler;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Render markdown text to HTML (delegates to shared module).
|
|
90
|
+
*/
|
|
91
|
+
function renderMarkdown(text) {
|
|
92
|
+
return window.UplinkMessageRenderer?.renderMarkdown(text) || text || '';
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Get agent emoji (delegates to shared module).
|
|
97
|
+
*/
|
|
98
|
+
function getAgentEmoji(agentId) {
|
|
99
|
+
return window.UplinkMessageRenderer?.getAgentEmoji(agentId) || '🤖';
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Parse a session key into satelliteId and agentId.
|
|
104
|
+
* Format: agent:<agentId>:main or agent:<agentId>:uplink:satellite:<satelliteId>
|
|
105
|
+
* @param {string} sessionKey
|
|
106
|
+
* @returns {{ satelliteId: string, agentId: string }}
|
|
107
|
+
*/
|
|
108
|
+
function parseSessionKey(sessionKey) {
|
|
109
|
+
let satelliteId = 'main';
|
|
110
|
+
let agentId = 'main';
|
|
111
|
+
const parts = (sessionKey || '').split(':');
|
|
112
|
+
if (parts[0] === 'agent' && parts.length >= 3) {
|
|
113
|
+
agentId = parts[1];
|
|
114
|
+
if (parts.length >= 5 && parts[2] === 'uplink' && parts[3] === 'satellite') {
|
|
115
|
+
satelliteId = parts[4];
|
|
116
|
+
} else {
|
|
117
|
+
satelliteId = parts[2]; // 'main' or other
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
return { satelliteId, agentId };
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Escape HTML for safe insertion (delegates to shared module).
|
|
125
|
+
*/
|
|
126
|
+
function escapeHtml(text) {
|
|
127
|
+
if (window.UplinkMessageRenderer?.escapeHtml) {
|
|
128
|
+
return window.UplinkMessageRenderer.escapeHtml(text);
|
|
129
|
+
}
|
|
130
|
+
const div = document.createElement('div');
|
|
131
|
+
div.textContent = text || '';
|
|
132
|
+
return div.innerHTML;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Escape a string for use in HTML attributes (delegates to shared module).
|
|
137
|
+
*/
|
|
138
|
+
function escapeAttr(text) {
|
|
139
|
+
if (window.UplinkMessageRenderer?.escapeAttr) {
|
|
140
|
+
return window.UplinkMessageRenderer.escapeAttr(text);
|
|
141
|
+
}
|
|
142
|
+
return (text || '')
|
|
143
|
+
.replace(/&/g, '&')
|
|
144
|
+
.replace(/"/g, '"')
|
|
145
|
+
.replace(/'/g, ''')
|
|
146
|
+
.replace(/</g, '<')
|
|
147
|
+
.replace(/>/g, '>');
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// ============================================
|
|
151
|
+
// INITIALIZATION
|
|
152
|
+
// ============================================
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Initialize the split chat module.
|
|
156
|
+
* Called once on page load. Sets up DOM references and event listeners.
|
|
157
|
+
*/
|
|
158
|
+
function init() {
|
|
159
|
+
paneEl = document.getElementById('splitSecondary');
|
|
160
|
+
iconEl = document.getElementById('splitSecondaryIcon');
|
|
161
|
+
nameEl = document.getElementById('splitSecondaryName');
|
|
162
|
+
messagesEl = document.getElementById('splitSecondaryMessages');
|
|
163
|
+
inputEl = document.getElementById('splitSecondaryInput');
|
|
164
|
+
sendBtnEl = document.getElementById('splitSecondarySend');
|
|
165
|
+
switchBtnEl = document.getElementById('splitSecondarySwitch');
|
|
166
|
+
closeBtnEl = document.getElementById('splitSecondaryClose');
|
|
167
|
+
contextBadgeEl = document.getElementById('splitContextBadge');
|
|
168
|
+
contextBarFillEl = document.getElementById('splitContextBarFill');
|
|
169
|
+
contextTextEl = document.getElementById('splitContextText');
|
|
170
|
+
|
|
171
|
+
if (!paneEl || !messagesEl || !inputEl || !switchBtnEl || !sendBtnEl) {
|
|
172
|
+
if (initRetryCount >= MAX_INIT_RETRIES) {
|
|
173
|
+
logger.warn('SplitChat: Required elements not found after max retries');
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
initRetryCount++;
|
|
177
|
+
const delay = Math.min(100 * Math.pow(2, initRetryCount), 5000);
|
|
178
|
+
setTimeout(init, delay);
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
setupEventListeners();
|
|
183
|
+
subscribeToWebSocket();
|
|
184
|
+
logger.debug('SplitChat: Initialized');
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Set up DOM event listeners for input, buttons, and scrolling.
|
|
189
|
+
*/
|
|
190
|
+
function setupEventListeners() {
|
|
191
|
+
// Enter to send (Shift+Enter for newline)
|
|
192
|
+
if (inputEl) {
|
|
193
|
+
inputEl.addEventListener('keydown', (e) => {
|
|
194
|
+
if (e.key === 'Enter' && !e.shiftKey) {
|
|
195
|
+
e.preventDefault();
|
|
196
|
+
handleSend();
|
|
197
|
+
}
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
// Auto-resize textarea as user types
|
|
201
|
+
inputEl.addEventListener('input', () => {
|
|
202
|
+
const prevHeight = inputEl.offsetHeight;
|
|
203
|
+
inputEl.style.height = 'auto';
|
|
204
|
+
inputEl.style.height = Math.min(inputEl.scrollHeight, 150) + 'px';
|
|
205
|
+
const delta = inputEl.offsetHeight - prevHeight;
|
|
206
|
+
if (delta > 0 && messagesEl && isNearBottom) {
|
|
207
|
+
messagesEl.scrollTop += delta;
|
|
208
|
+
}
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// Send button
|
|
213
|
+
if (sendBtnEl) {
|
|
214
|
+
sendBtnEl.addEventListener('click', handleSend);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// Switch session button
|
|
218
|
+
if (switchBtnEl) {
|
|
219
|
+
switchBtnEl.addEventListener('click', (e) => {
|
|
220
|
+
e.preventDefault();
|
|
221
|
+
e.stopPropagation();
|
|
222
|
+
showPicker();
|
|
223
|
+
});
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// Close button
|
|
227
|
+
if (closeBtnEl) {
|
|
228
|
+
closeBtnEl.addEventListener('click', closeSession);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// Scroll tracking
|
|
232
|
+
if (messagesEl) {
|
|
233
|
+
messagesEl.addEventListener('scroll', () => {
|
|
234
|
+
const distFromBottom = messagesEl.scrollHeight - messagesEl.scrollTop - messagesEl.clientHeight;
|
|
235
|
+
isNearBottom = distFromBottom <= SCROLL_THRESHOLD_PX;
|
|
236
|
+
}, { passive: true });
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* Subscribe to the shared WebSocket connection for streaming events.
|
|
242
|
+
* Filters events to only handle the secondary session's sessionKey.
|
|
243
|
+
*/
|
|
244
|
+
function subscribeToWebSocket() {
|
|
245
|
+
if (!window.UplinkConnection) {
|
|
246
|
+
// Retry if connection module not ready yet
|
|
247
|
+
setTimeout(subscribeToWebSocket, 500);
|
|
248
|
+
return;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
wsUnsubscribe = window.UplinkConnection.onConnection((event, data) => {
|
|
252
|
+
if (event !== 'message') return;
|
|
253
|
+
if (!activeSession) return;
|
|
254
|
+
|
|
255
|
+
let parsed;
|
|
256
|
+
try {
|
|
257
|
+
parsed = typeof data === 'string' ? JSON.parse(data) : data;
|
|
258
|
+
} catch {
|
|
259
|
+
return;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// Unwrap OpenClaw event envelope
|
|
263
|
+
if (parsed.type === 'event' && parsed.event && parsed.payload) {
|
|
264
|
+
handleWsEvent(parsed.event, parsed.payload);
|
|
265
|
+
return;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// Handle sync events routed directly (connection.js sends raw data, so
|
|
269
|
+
// this path fires when another module re-emits with unwrapped type)
|
|
270
|
+
if (parsed.type && parsed.type.startsWith('sync_')) {
|
|
271
|
+
handleSyncEvent(parsed.type, parsed);
|
|
272
|
+
}
|
|
273
|
+
});
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// ============================================
|
|
277
|
+
// WEBSOCKET EVENT HANDLERS
|
|
278
|
+
// ============================================
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* Handle unwrapped OpenClaw event envelope messages.
|
|
282
|
+
*/
|
|
283
|
+
function handleWsEvent(eventName, payload) {
|
|
284
|
+
if (!activeSession) return;
|
|
285
|
+
|
|
286
|
+
// Check if this event is for our session
|
|
287
|
+
const eventSatelliteId = payload.satelliteId;
|
|
288
|
+
if (eventSatelliteId && !isOurSession(eventSatelliteId)) return;
|
|
289
|
+
|
|
290
|
+
switch (eventName) {
|
|
291
|
+
case 'sync.thinking':
|
|
292
|
+
handleStreamThinking(payload);
|
|
293
|
+
break;
|
|
294
|
+
case 'sync.delta':
|
|
295
|
+
handleStreamDelta(payload);
|
|
296
|
+
break;
|
|
297
|
+
case 'sync.tool':
|
|
298
|
+
handleStreamTool(payload);
|
|
299
|
+
break;
|
|
300
|
+
case 'sync.complete':
|
|
301
|
+
handleStreamComplete(payload);
|
|
302
|
+
break;
|
|
303
|
+
case 'sync':
|
|
304
|
+
handleSyncMessage(payload);
|
|
305
|
+
break;
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* Handle sync events with type prefix (e.g., sync_delta).
|
|
311
|
+
*/
|
|
312
|
+
function handleSyncEvent(type, data) {
|
|
313
|
+
if (!activeSession) return;
|
|
314
|
+
|
|
315
|
+
const eventSatelliteId = data.satelliteId;
|
|
316
|
+
if (eventSatelliteId && !isOurSession(eventSatelliteId)) return;
|
|
317
|
+
|
|
318
|
+
switch (type) {
|
|
319
|
+
case 'sync_thinking':
|
|
320
|
+
handleStreamThinking(data);
|
|
321
|
+
break;
|
|
322
|
+
case 'sync_delta':
|
|
323
|
+
handleStreamDelta(data);
|
|
324
|
+
break;
|
|
325
|
+
case 'sync_tool':
|
|
326
|
+
handleStreamTool(data);
|
|
327
|
+
break;
|
|
328
|
+
case 'sync_complete':
|
|
329
|
+
handleStreamComplete(data);
|
|
330
|
+
break;
|
|
331
|
+
case 'sync_message':
|
|
332
|
+
handleSyncMessage(data);
|
|
333
|
+
break;
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/**
|
|
338
|
+
* Check if a satelliteId belongs to our active session.
|
|
339
|
+
*/
|
|
340
|
+
function isOurSession(satelliteId) {
|
|
341
|
+
if (!activeSession) return false;
|
|
342
|
+
|
|
343
|
+
// Match by sessionKey components
|
|
344
|
+
// Session keys look like: agent:main:main or agent:main:uplink:satellite:sat-xxxxx
|
|
345
|
+
const sk = activeSession.sessionKey;
|
|
346
|
+
if (!sk) return false;
|
|
347
|
+
|
|
348
|
+
// Extract satellite ID from session key
|
|
349
|
+
// Format: agent:<agentId>:uplink:satellite:<satelliteId>
|
|
350
|
+
// Or: agent:<agentId>:main (for primary)
|
|
351
|
+
if (sk.includes(':uplink:satellite:')) {
|
|
352
|
+
const parts = sk.split(':uplink:satellite:');
|
|
353
|
+
return parts[1] === satelliteId;
|
|
354
|
+
}
|
|
355
|
+
// Primary session
|
|
356
|
+
if (sk.endsWith(':main')) {
|
|
357
|
+
return satelliteId === 'main';
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
return false;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
/**
|
|
364
|
+
* Handle thinking status - show streaming bubble with thinking indicator.
|
|
365
|
+
*/
|
|
366
|
+
function handleStreamThinking(data) {
|
|
367
|
+
logger.debug('SplitChat: Thinking event received');
|
|
368
|
+
hideTyping();
|
|
369
|
+
const handler = getStreamHandler();
|
|
370
|
+
if (!handler) return;
|
|
371
|
+
if (!handler.getStreamingDiv()) {
|
|
372
|
+
handler.createStreamingMessage();
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
// Ensure .streaming class is on the div
|
|
376
|
+
const streamingDiv = handler.getStreamingDiv();
|
|
377
|
+
if (streamingDiv && !streamingDiv.classList.contains('streaming')) {
|
|
378
|
+
streamingDiv.classList.add('streaming');
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
handler.updateStreamingContent('🧠 Thinking...');
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
/**
|
|
385
|
+
* Handle streaming delta - accumulate content and render progressively.
|
|
386
|
+
*/
|
|
387
|
+
function handleStreamDelta(data) {
|
|
388
|
+
const { content } = data;
|
|
389
|
+
if (!content) return;
|
|
390
|
+
|
|
391
|
+
logger.debug('SplitChat: Delta event received');
|
|
392
|
+
hideTyping();
|
|
393
|
+
const handler = getStreamHandler();
|
|
394
|
+
if (!handler) return;
|
|
395
|
+
if (!handler.getStreamingDiv()) {
|
|
396
|
+
handler.createStreamingMessage();
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
// Ensure .streaming class is on the div
|
|
400
|
+
const streamingDiv = handler.getStreamingDiv();
|
|
401
|
+
if (streamingDiv && !streamingDiv.classList.contains('streaming')) {
|
|
402
|
+
streamingDiv.classList.add('streaming');
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
handler.setStreamContent(handler.getStreamContent() + content);
|
|
406
|
+
handler.updateStreamingContent(handler.getStreamContent());
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
/**
|
|
410
|
+
* Handle tool usage during streaming.
|
|
411
|
+
*/
|
|
412
|
+
function handleStreamTool(data) {
|
|
413
|
+
const { tool } = data;
|
|
414
|
+
if (!tool) return;
|
|
415
|
+
|
|
416
|
+
hideTyping();
|
|
417
|
+
const handler = getStreamHandler();
|
|
418
|
+
if (!handler) return;
|
|
419
|
+
if (!handler.getStreamingDiv()) {
|
|
420
|
+
handler.createStreamingMessage();
|
|
421
|
+
}
|
|
422
|
+
handler.updateStreamingContent(`🔧 Using ${tool}...`);
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
/**
|
|
426
|
+
* Handle stream completion - finalize message.
|
|
427
|
+
*/
|
|
428
|
+
function handleStreamComplete(_data) {
|
|
429
|
+
const handler = getStreamHandler();
|
|
430
|
+
if (handler) handler.finalizeStreamingMessage();
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
/**
|
|
434
|
+
* Handle a final sync message (complete message, not streaming).
|
|
435
|
+
*/
|
|
436
|
+
function handleSyncMessage(data) {
|
|
437
|
+
const { messageId, role, content } = data;
|
|
438
|
+
if (!content) return;
|
|
439
|
+
|
|
440
|
+
// Dedup by messageId via shared streaming handler
|
|
441
|
+
const handler = getStreamHandler();
|
|
442
|
+
if (handler && messageId && handler.isDuplicate(messageId)) {
|
|
443
|
+
logger.debug('SplitChat: Duplicate messageId, skipping:', messageId);
|
|
444
|
+
return;
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
// Skip sync messages that arrived shortly after history load to prevent duplication
|
|
448
|
+
if (historyLoadedAt && Date.now() - historyLoadedAt < 5000) {
|
|
449
|
+
return;
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
// Skip user messages that we just sent (prevent duplication from sync echo)
|
|
453
|
+
if (role === 'user' && content === lastSentMessageText && Date.now() - lastSentMessageTime < 5000) {
|
|
454
|
+
logger.debug('SplitChat: Skipping sync echo of own message');
|
|
455
|
+
return;
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
logger.debug('SplitChat: Processing sync message', role);
|
|
459
|
+
|
|
460
|
+
// If streaming is active, the streaming handlers own the assistant message lifecycle.
|
|
461
|
+
// Sync messages during active streaming are the "final" version - use them to finalize
|
|
462
|
+
// instead of creating a duplicate div.
|
|
463
|
+
if (role === 'assistant' && handler) {
|
|
464
|
+
const streamingDiv = handler.getStreamingDiv();
|
|
465
|
+
const isStreaming = handler.getIsStreaming?.() || !!streamingDiv;
|
|
466
|
+
|
|
467
|
+
if (isStreaming && streamingDiv) {
|
|
468
|
+
// Stream is active - finalize with the sync content (full message)
|
|
469
|
+
handler.setStreamContent(content);
|
|
470
|
+
handler.finalizeStreamingMessage();
|
|
471
|
+
return;
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
// If stream just completed (within 2s), this sync message is a duplicate of what
|
|
475
|
+
// was already finalized by handleStreamComplete. Skip it.
|
|
476
|
+
if (handler._lastFinalizedAt && Date.now() - handler._lastFinalizedAt < 2000) {
|
|
477
|
+
logger.debug('SplitChat: Skipping sync message — stream just finalized');
|
|
478
|
+
return;
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
// Append as a complete message (cross-device sync, another user sent to this satellite)
|
|
483
|
+
if (role === 'assistant' || role === 'user') {
|
|
484
|
+
appendMessage(content, role);
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
// ============================================
|
|
489
|
+
// SESSION MANAGEMENT
|
|
490
|
+
// ============================================
|
|
491
|
+
|
|
492
|
+
/**
|
|
493
|
+
* Connect to a specific gateway session.
|
|
494
|
+
* Loads history and subscribes to streaming events for that session.
|
|
495
|
+
* @param {string} sessionKey - The gateway session key
|
|
496
|
+
* @param {string} label - Human-readable session label
|
|
497
|
+
* @param {string} agentName - The agent name for display
|
|
498
|
+
* @param {string} agentId - The agent ID for avatar lookup
|
|
499
|
+
*/
|
|
500
|
+
async function openSession(sessionKey, label, agentName, agentId = 'main') {
|
|
501
|
+
// Close any existing session first
|
|
502
|
+
if (activeSession) {
|
|
503
|
+
resetState();
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
activeSession = { sessionKey, label, agentName, agentId };
|
|
507
|
+
|
|
508
|
+
// Reset streaming handler so it picks up the new agentId
|
|
509
|
+
streamHandler = null;
|
|
510
|
+
|
|
511
|
+
// Update UI - name
|
|
512
|
+
if (nameEl) {
|
|
513
|
+
nameEl.textContent = agentName || label || 'Secondary';
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
// Update UI - avatar
|
|
517
|
+
if (iconEl) {
|
|
518
|
+
const avatarPath = `/img/agents/${agentId}.png`;
|
|
519
|
+
iconEl.src = avatarPath;
|
|
520
|
+
iconEl.alt = `${agentName || 'Agent'} avatar`;
|
|
521
|
+
// Fallback to default if agent avatar doesn't exist
|
|
522
|
+
iconEl.onerror = () => {
|
|
523
|
+
iconEl.onerror = null; // Prevent infinite loop
|
|
524
|
+
iconEl.src = '/img/agents/default.png';
|
|
525
|
+
};
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
// Clear messages container
|
|
529
|
+
if (messagesEl) {
|
|
530
|
+
messagesEl.innerHTML = '';
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
// Enable input
|
|
534
|
+
setInputEnabled(true);
|
|
535
|
+
|
|
536
|
+
// Hide picker if visible
|
|
537
|
+
hidePicker();
|
|
538
|
+
|
|
539
|
+
// Load history
|
|
540
|
+
await loadHistory(sessionKey);
|
|
541
|
+
|
|
542
|
+
// Fetch context data for this session
|
|
543
|
+
fetchContextData();
|
|
544
|
+
|
|
545
|
+
logger.debug('SplitChat: Opened session', sessionKey);
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
/**
|
|
549
|
+
* Disconnect and reset the secondary chat.
|
|
550
|
+
*/
|
|
551
|
+
function closeSession() {
|
|
552
|
+
const handler = getStreamHandler();
|
|
553
|
+
if (handler?.getStreamingDiv()) {
|
|
554
|
+
handler.finalizeStreamingMessage();
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
resetState();
|
|
558
|
+
|
|
559
|
+
// Reset context
|
|
560
|
+
contextUsed = 0;
|
|
561
|
+
displayedTokens = 0;
|
|
562
|
+
updateContextDisplay();
|
|
563
|
+
|
|
564
|
+
// Update UI to show picker / empty state
|
|
565
|
+
if (nameEl) {
|
|
566
|
+
nameEl.textContent = '';
|
|
567
|
+
}
|
|
568
|
+
if (messagesEl) {
|
|
569
|
+
messagesEl.innerHTML = '';
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
setInputEnabled(false);
|
|
573
|
+
|
|
574
|
+
logger.debug('SplitChat: Session closed');
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
/**
|
|
578
|
+
* Reset internal state without touching DOM beyond what's needed.
|
|
579
|
+
*/
|
|
580
|
+
function resetState() {
|
|
581
|
+
activeSession = null;
|
|
582
|
+
historyLoadedAt = 0;
|
|
583
|
+
hideTyping();
|
|
584
|
+
|
|
585
|
+
const handler = getStreamHandler();
|
|
586
|
+
if (handler) handler.reset();
|
|
587
|
+
|
|
588
|
+
if (currentAbortController) {
|
|
589
|
+
currentAbortController.abort();
|
|
590
|
+
currentAbortController = null;
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
/**
|
|
595
|
+
* Get the active session info.
|
|
596
|
+
* @returns {{ sessionKey: string, label: string, agentName: string } | null}
|
|
597
|
+
*/
|
|
598
|
+
function getActiveSession() {
|
|
599
|
+
return activeSession ? { ...activeSession } : null;
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
/**
|
|
603
|
+
* Check if the secondary chat has an active session.
|
|
604
|
+
* @returns {boolean}
|
|
605
|
+
*/
|
|
606
|
+
function isActive() {
|
|
607
|
+
return activeSession !== null;
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
// ============================================
|
|
611
|
+
// HISTORY
|
|
612
|
+
// ============================================
|
|
613
|
+
|
|
614
|
+
/**
|
|
615
|
+
* Load chat history from the gateway for the given session key.
|
|
616
|
+
* @param {string} sessionKey - The session key to fetch history for
|
|
617
|
+
*/
|
|
618
|
+
async function loadHistory(sessionKey) {
|
|
619
|
+
try {
|
|
620
|
+
const { satelliteId, agentId } = parseSessionKey(sessionKey);
|
|
621
|
+
|
|
622
|
+
const url = `/api/gateway/history?satelliteId=${encodeURIComponent(satelliteId)}&agentId=${encodeURIComponent(agentId)}&limit=50`;
|
|
623
|
+
const response = await fetch(url);
|
|
624
|
+
|
|
625
|
+
if (!response.ok) {
|
|
626
|
+
logger.warn('SplitChat: History fetch failed:', response.status);
|
|
627
|
+
return;
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
const data = await response.json();
|
|
631
|
+
if (!data.ok || !data.messages || data.messages.length === 0) {
|
|
632
|
+
logger.debug('SplitChat: No history messages');
|
|
633
|
+
return;
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
// Render messages
|
|
637
|
+
data.messages.forEach(msg => {
|
|
638
|
+
const role = msg.type === 'user' ? 'user' : 'assistant';
|
|
639
|
+
const timestamp = msg.createdAt ? new Date(msg.createdAt).getTime() : Date.now();
|
|
640
|
+
appendMessage(msg.text || msg.content || '', role, timestamp);
|
|
641
|
+
});
|
|
642
|
+
|
|
643
|
+
// Mark history as loaded to prevent sync event duplication
|
|
644
|
+
historyLoadedAt = Date.now();
|
|
645
|
+
|
|
646
|
+
// Scroll to bottom after loading history
|
|
647
|
+
if (messagesEl) {
|
|
648
|
+
messagesEl.scrollTop = messagesEl.scrollHeight;
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
logger.debug('SplitChat: Loaded', data.messages.length, 'history messages');
|
|
652
|
+
} catch (err) {
|
|
653
|
+
logger.error('SplitChat: Failed to load history', err);
|
|
654
|
+
}
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
// ============================================
|
|
658
|
+
// MESSAGE RENDERING
|
|
659
|
+
// ============================================
|
|
660
|
+
|
|
661
|
+
/**
|
|
662
|
+
* Append a complete message to the secondary messages container.
|
|
663
|
+
* Delegates to shared UplinkMessageRenderer.
|
|
664
|
+
* @param {string} content - Message text (markdown)
|
|
665
|
+
* @param {string} role - 'user' or 'assistant'
|
|
666
|
+
* @param {number} timestamp - Optional timestamp for the message
|
|
667
|
+
*/
|
|
668
|
+
function appendMessage(content, role, timestamp = null) {
|
|
669
|
+
if (!messagesEl || !content) return;
|
|
670
|
+
|
|
671
|
+
const renderer = window.UplinkMessageRenderer;
|
|
672
|
+
if (renderer?.addMessageToContainer) {
|
|
673
|
+
return renderer.addMessageToContainer({
|
|
674
|
+
container: messagesEl,
|
|
675
|
+
text: content,
|
|
676
|
+
type: role,
|
|
677
|
+
showAvatar: role === 'assistant',
|
|
678
|
+
agentId: activeSession?.agentId || null,
|
|
679
|
+
timestamp: timestamp || Date.now(),
|
|
680
|
+
scroll: { isNearBottom }
|
|
681
|
+
});
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
// Fallback if renderer not loaded yet
|
|
685
|
+
const div = document.createElement('div');
|
|
686
|
+
div.className = `message ${role}`;
|
|
687
|
+
div.dataset.time = timestamp || Date.now();
|
|
688
|
+
|
|
689
|
+
const textSpan = document.createElement('span');
|
|
690
|
+
textSpan.className = 'message-text';
|
|
691
|
+
textSpan.innerHTML = renderMarkdown(content);
|
|
692
|
+
div.appendChild(textSpan);
|
|
693
|
+
|
|
694
|
+
messagesEl.appendChild(div);
|
|
695
|
+
if (isNearBottom) {
|
|
696
|
+
messagesEl.scrollTop = messagesEl.scrollHeight;
|
|
697
|
+
}
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
// ============================================
|
|
701
|
+
// STREAMING MESSAGE MANAGEMENT
|
|
702
|
+
// Delegated to shared UplinkStreamingHandler instance.
|
|
703
|
+
// See getStreamHandler() in SHARED MODULE HELPERS section.
|
|
704
|
+
// ============================================
|
|
705
|
+
|
|
706
|
+
// ============================================
|
|
707
|
+
// SENDING MESSAGES
|
|
708
|
+
// ============================================
|
|
709
|
+
|
|
710
|
+
/**
|
|
711
|
+
* Handle send button click or Enter key.
|
|
712
|
+
*/
|
|
713
|
+
async function handleSend() {
|
|
714
|
+
if (!activeSession) return;
|
|
715
|
+
const handler = getStreamHandler();
|
|
716
|
+
if (handler?.getIsStreaming()) return;
|
|
717
|
+
|
|
718
|
+
const text = inputEl?.value?.trim();
|
|
719
|
+
if (!text) return;
|
|
720
|
+
|
|
721
|
+
// Clear input
|
|
722
|
+
inputEl.value = '';
|
|
723
|
+
inputEl.style.height = 'auto';
|
|
724
|
+
|
|
725
|
+
// Show user message
|
|
726
|
+
appendMessage(text, 'user');
|
|
727
|
+
|
|
728
|
+
// Track this message to prevent duplication from sync events
|
|
729
|
+
lastSentMessageText = text;
|
|
730
|
+
lastSentMessageTime = Date.now();
|
|
731
|
+
|
|
732
|
+
// Show typing indicator
|
|
733
|
+
showTyping();
|
|
734
|
+
setInputEnabled(false);
|
|
735
|
+
|
|
736
|
+
// Create abort controller
|
|
737
|
+
currentAbortController = new AbortController();
|
|
738
|
+
|
|
739
|
+
try {
|
|
740
|
+
const { satelliteId, agentId } = parseSessionKey(activeSession.sessionKey);
|
|
741
|
+
|
|
742
|
+
const response = await fetch('/api/chat', {
|
|
743
|
+
method: 'POST',
|
|
744
|
+
headers: { 'Content-Type': 'application/json' },
|
|
745
|
+
body: JSON.stringify({
|
|
746
|
+
message: text,
|
|
747
|
+
stream: true,
|
|
748
|
+
satelliteId,
|
|
749
|
+
satelliteName: activeSession.label || satelliteId,
|
|
750
|
+
agentId
|
|
751
|
+
}),
|
|
752
|
+
signal: currentAbortController.signal
|
|
753
|
+
});
|
|
754
|
+
|
|
755
|
+
if (!response.ok) {
|
|
756
|
+
throw new Error(`HTTP ${response.status}`);
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
// Process SSE stream
|
|
760
|
+
// The WebSocket sync events will handle rendering via our WS listener.
|
|
761
|
+
// We still read the SSE stream to handle cases where WS sync isn't available
|
|
762
|
+
// and to detect completion.
|
|
763
|
+
await processSSEResponse(response);
|
|
764
|
+
|
|
765
|
+
} catch (err) {
|
|
766
|
+
if (err.name === 'AbortError') {
|
|
767
|
+
appendSystemMessage('Generation stopped');
|
|
768
|
+
} else {
|
|
769
|
+
logger.error('SplitChat: Send failed', err);
|
|
770
|
+
appendSystemMessage('Failed to send message');
|
|
771
|
+
}
|
|
772
|
+
} finally {
|
|
773
|
+
hideTyping();
|
|
774
|
+
currentAbortController = null;
|
|
775
|
+
|
|
776
|
+
// If streaming didn't start, ensure input is re-enabled
|
|
777
|
+
if (!handler?.getIsStreaming()) {
|
|
778
|
+
setInputEnabled(true);
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
// Refresh context after message completes
|
|
782
|
+
refreshContext();
|
|
783
|
+
}
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
/**
|
|
787
|
+
* Process SSE response from /api/chat.
|
|
788
|
+
* WebSocket sync events handle the real-time streaming display.
|
|
789
|
+
* The SSE stream is read as a fallback and for completion detection.
|
|
790
|
+
* Delegates to shared UplinkStreamingHandler for chunk processing.
|
|
791
|
+
* @param {Response} response - Fetch response
|
|
792
|
+
*/
|
|
793
|
+
async function processSSEResponse(response) {
|
|
794
|
+
const reader = response.body.getReader();
|
|
795
|
+
const decoder = new TextDecoder();
|
|
796
|
+
let buffer = '';
|
|
797
|
+
let fullResponse = '';
|
|
798
|
+
|
|
799
|
+
// Split-chat's WebSocket subscription (subscribeToWebSocket) handles all
|
|
800
|
+
// streaming display via handleStreamThinking/handleStreamDelta/handleStreamComplete.
|
|
801
|
+
// The SSE stream is consumed ONLY to drain the response and track errors.
|
|
802
|
+
// We never create streaming divs here — that would race with the WS handlers.
|
|
803
|
+
|
|
804
|
+
while (true) {
|
|
805
|
+
const { done, value } = await reader.read();
|
|
806
|
+
if (done) break;
|
|
807
|
+
|
|
808
|
+
buffer += decoder.decode(value, { stream: true });
|
|
809
|
+
const lines = buffer.split('\n');
|
|
810
|
+
buffer = lines.pop() || '';
|
|
811
|
+
|
|
812
|
+
for (const line of lines) {
|
|
813
|
+
if (!line.startsWith('data: ')) continue;
|
|
814
|
+
|
|
815
|
+
const data = line.slice(6);
|
|
816
|
+
if (data === '[DONE]' || data.startsWith(':')) continue;
|
|
817
|
+
|
|
818
|
+
try {
|
|
819
|
+
const parsed = JSON.parse(data);
|
|
820
|
+
|
|
821
|
+
// Track content for completion detection
|
|
822
|
+
if (parsed.content) {
|
|
823
|
+
fullResponse += parsed.content;
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
// Surface errors
|
|
827
|
+
if (parsed.error) {
|
|
828
|
+
appendSystemMessage(parsed.error || parsed.message || 'An error occurred');
|
|
829
|
+
setInputEnabled(true);
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
await new Promise(r => setTimeout(r, 0));
|
|
833
|
+
} catch (e) {
|
|
834
|
+
// Skip unparseable chunks
|
|
835
|
+
}
|
|
836
|
+
}
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
return { fullResponse };
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
// ============================================
|
|
843
|
+
// SESSION PICKER
|
|
844
|
+
// ============================================
|
|
845
|
+
|
|
846
|
+
/**
|
|
847
|
+
* Display a session/agent picker overlay inside the secondary pane.
|
|
848
|
+
* Pulls sessions from satellites or falls back to fetching from gateway.
|
|
849
|
+
*/
|
|
850
|
+
async function showPicker() {
|
|
851
|
+
logger.debug('SplitChat: showPicker() called');
|
|
852
|
+
|
|
853
|
+
// Check actual DOM presence instead of just the flag
|
|
854
|
+
// (pickerEl might have been removed without calling hidePicker)
|
|
855
|
+
if (pickerEl && pickerEl.parentNode) {
|
|
856
|
+
logger.debug('SplitChat: Picker already visible, hiding');
|
|
857
|
+
hidePicker();
|
|
858
|
+
return;
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
// Reset flag if out of sync
|
|
862
|
+
if (pickerVisible && !pickerEl) {
|
|
863
|
+
pickerVisible = false;
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
// Reset flag if out of sync
|
|
867
|
+
if (pickerVisible && !pickerEl) {
|
|
868
|
+
console.warn('[SplitChat] pickerVisible out of sync, resetting');
|
|
869
|
+
pickerVisible = false;
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
const container = paneEl || document.getElementById('splitSecondary');
|
|
873
|
+
if (!container) {
|
|
874
|
+
logger.warn('SplitChat: No container found for picker');
|
|
875
|
+
return;
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
// Build picker data
|
|
879
|
+
let sessions = [];
|
|
880
|
+
|
|
881
|
+
// Try to get from satellites module
|
|
882
|
+
const sats = window.UplinkSatellites?.getSatellites?.();
|
|
883
|
+
if (sats && Object.keys(sats).length > 0) {
|
|
884
|
+
for (const [id, sat] of Object.entries(sats)) {
|
|
885
|
+
const agentId = sat.agentId || 'main';
|
|
886
|
+
const sessionKey = id === 'main'
|
|
887
|
+
? `agent:${agentId}:main`
|
|
888
|
+
: `agent:${agentId}:uplink:satellite:${id}`;
|
|
889
|
+
|
|
890
|
+
sessions.push({
|
|
891
|
+
sessionKey,
|
|
892
|
+
label: sat.name || id,
|
|
893
|
+
agentName: sat.name || id,
|
|
894
|
+
agentId,
|
|
895
|
+
emoji: getAgentEmoji(agentId),
|
|
896
|
+
satelliteId: id
|
|
897
|
+
});
|
|
898
|
+
}
|
|
899
|
+
}
|
|
900
|
+
|
|
901
|
+
// Fallback: fetch from API
|
|
902
|
+
if (sessions.length === 0) {
|
|
903
|
+
try {
|
|
904
|
+
const res = await fetch('/api/gateway/sessions');
|
|
905
|
+
if (res.ok) {
|
|
906
|
+
const data = await res.json();
|
|
907
|
+
if (data.sessions) {
|
|
908
|
+
sessions = data.sessions.map(s => ({
|
|
909
|
+
sessionKey: s.sessionKey || s.key,
|
|
910
|
+
label: s.label || s.name || s.sessionKey,
|
|
911
|
+
agentName: s.agentName || s.label || 'Agent',
|
|
912
|
+
emoji: '🤖',
|
|
913
|
+
satelliteId: s.satelliteId || 'main'
|
|
914
|
+
}));
|
|
915
|
+
}
|
|
916
|
+
}
|
|
917
|
+
} catch (err) {
|
|
918
|
+
logger.warn('SplitChat: Failed to fetch sessions', err);
|
|
919
|
+
}
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
// Filter out the primary chat's current session to avoid conflicts
|
|
923
|
+
const primarySatId = window.UplinkSatellites?.getCurrentSatellite?.() || 'main';
|
|
924
|
+
|
|
925
|
+
// Build picker UI
|
|
926
|
+
pickerEl = document.createElement('div');
|
|
927
|
+
pickerEl.className = 'split-chat-picker';
|
|
928
|
+
pickerEl.innerHTML = `
|
|
929
|
+
<div class="split-chat-picker-header">
|
|
930
|
+
<span class="split-chat-picker-title">Select Session</span>
|
|
931
|
+
<button class="split-chat-picker-close" aria-label="Close picker">×</button>
|
|
932
|
+
</div>
|
|
933
|
+
<div class="split-chat-picker-list">
|
|
934
|
+
${sessions.length === 0 ? '<div class="split-chat-picker-empty">No sessions available</div>' : ''}
|
|
935
|
+
${sessions.map(s => {
|
|
936
|
+
const isCurrent = s.satelliteId === primarySatId;
|
|
937
|
+
const isActive = activeSession?.sessionKey === s.sessionKey;
|
|
938
|
+
const avatarSrc = `/img/agents/${s.agentId || 'main'}.png`;
|
|
939
|
+
return `
|
|
940
|
+
<button class="split-chat-picker-item${isCurrent ? ' primary' : ''}${isActive ? ' active' : ''}"
|
|
941
|
+
data-session-key="${escapeAttr(s.sessionKey)}"
|
|
942
|
+
data-label="${escapeAttr(s.label)}"
|
|
943
|
+
data-agent-name="${escapeAttr(s.agentName)}"
|
|
944
|
+
data-agent-id="${escapeAttr(s.agentId || 'main')}">
|
|
945
|
+
<img class="split-chat-picker-avatar" src="${avatarSrc}" alt="${escapeAttr(s.agentName || s.label)}" onerror="this.src='/img/agents/default.png'">
|
|
946
|
+
<span class="split-chat-picker-name">${escapeHtml(s.agentName || s.label)}</span>
|
|
947
|
+
${isCurrent ? '<span class="split-chat-picker-badge">Primary</span>' : ''}
|
|
948
|
+
${isActive ? '<span class="split-chat-picker-badge active">Active</span>' : ''}
|
|
949
|
+
</button>
|
|
950
|
+
`;
|
|
951
|
+
}).join('')}
|
|
952
|
+
</div>
|
|
953
|
+
`;
|
|
954
|
+
|
|
955
|
+
// Ensure container has relative positioning for overlay
|
|
956
|
+
container.style.position = 'relative';
|
|
957
|
+
container.appendChild(pickerEl);
|
|
958
|
+
pickerVisible = true;
|
|
959
|
+
|
|
960
|
+
// Event listeners
|
|
961
|
+
pickerEl.querySelector('.split-chat-picker-close').addEventListener('click', hidePicker);
|
|
962
|
+
|
|
963
|
+
pickerEl.querySelectorAll('.split-chat-picker-item').forEach(item => {
|
|
964
|
+
item.addEventListener('click', () => {
|
|
965
|
+
const sessionKey = item.dataset.sessionKey;
|
|
966
|
+
const label = item.dataset.label;
|
|
967
|
+
const agentName = item.dataset.agentName;
|
|
968
|
+
const agentId = item.dataset.agentId;
|
|
969
|
+
openSession(sessionKey, label, agentName, agentId);
|
|
970
|
+
});
|
|
971
|
+
});
|
|
972
|
+
}
|
|
973
|
+
|
|
974
|
+
/**
|
|
975
|
+
* Hide the session picker overlay.
|
|
976
|
+
*/
|
|
977
|
+
function hidePicker() {
|
|
978
|
+
if (pickerEl && pickerEl.parentNode) {
|
|
979
|
+
pickerEl.remove();
|
|
980
|
+
}
|
|
981
|
+
pickerEl = null;
|
|
982
|
+
pickerVisible = false;
|
|
983
|
+
}
|
|
984
|
+
|
|
985
|
+
// ============================================
|
|
986
|
+
// TYPING INDICATOR
|
|
987
|
+
// ============================================
|
|
988
|
+
|
|
989
|
+
/**
|
|
990
|
+
* Show the typing indicator in the messages container.
|
|
991
|
+
*/
|
|
992
|
+
function showTyping() {
|
|
993
|
+
if (typingEl) return;
|
|
994
|
+
|
|
995
|
+
typingEl = document.createElement('div');
|
|
996
|
+
typingEl.className = 'typing';
|
|
997
|
+
typingEl.setAttribute('role', 'status');
|
|
998
|
+
typingEl.setAttribute('aria-label', 'Assistant is typing');
|
|
999
|
+
typingEl.innerHTML = '<span></span><span></span><span></span>';
|
|
1000
|
+
|
|
1001
|
+
if (messagesEl) {
|
|
1002
|
+
messagesEl.appendChild(typingEl);
|
|
1003
|
+
if (isNearBottom) {
|
|
1004
|
+
messagesEl.scrollTop = messagesEl.scrollHeight;
|
|
1005
|
+
}
|
|
1006
|
+
}
|
|
1007
|
+
}
|
|
1008
|
+
|
|
1009
|
+
/**
|
|
1010
|
+
* Hide the typing indicator.
|
|
1011
|
+
*/
|
|
1012
|
+
function hideTyping() {
|
|
1013
|
+
if (typingEl) {
|
|
1014
|
+
if (typingEl.parentNode) typingEl.remove();
|
|
1015
|
+
typingEl = null;
|
|
1016
|
+
}
|
|
1017
|
+
// Also remove any orphaned typing elements in our container
|
|
1018
|
+
if (messagesEl) {
|
|
1019
|
+
messagesEl.querySelectorAll('.typing').forEach(el => el.remove());
|
|
1020
|
+
}
|
|
1021
|
+
}
|
|
1022
|
+
|
|
1023
|
+
// ============================================
|
|
1024
|
+
// SYSTEM MESSAGES
|
|
1025
|
+
// ============================================
|
|
1026
|
+
|
|
1027
|
+
/**
|
|
1028
|
+
* Append a system message (error, info) that auto-dismisses.
|
|
1029
|
+
* @param {string} text - Message text
|
|
1030
|
+
*/
|
|
1031
|
+
function appendSystemMessage(text) {
|
|
1032
|
+
if (!messagesEl) return;
|
|
1033
|
+
|
|
1034
|
+
const div = document.createElement('div');
|
|
1035
|
+
div.className = 'message system';
|
|
1036
|
+
div.setAttribute('role', 'alert');
|
|
1037
|
+
div.setAttribute('aria-live', 'polite');
|
|
1038
|
+
|
|
1039
|
+
const textSpan = document.createElement('span');
|
|
1040
|
+
textSpan.className = 'message-text';
|
|
1041
|
+
textSpan.textContent = text;
|
|
1042
|
+
div.appendChild(textSpan);
|
|
1043
|
+
|
|
1044
|
+
messagesEl.appendChild(div);
|
|
1045
|
+
|
|
1046
|
+
if (isNearBottom) {
|
|
1047
|
+
messagesEl.scrollTop = messagesEl.scrollHeight;
|
|
1048
|
+
}
|
|
1049
|
+
|
|
1050
|
+
// Auto-dismiss after 10 seconds
|
|
1051
|
+
setTimeout(() => {
|
|
1052
|
+
if (div.parentNode) {
|
|
1053
|
+
div.style.transition = 'opacity 0.3s';
|
|
1054
|
+
div.style.opacity = '0';
|
|
1055
|
+
setTimeout(() => div.remove(), 300);
|
|
1056
|
+
}
|
|
1057
|
+
}, 10000);
|
|
1058
|
+
}
|
|
1059
|
+
|
|
1060
|
+
// ============================================
|
|
1061
|
+
// CONTEXT TRACKING
|
|
1062
|
+
// ============================================
|
|
1063
|
+
|
|
1064
|
+
const DEFAULT_CONTEXT_WINDOW = 200000;
|
|
1065
|
+
let contextUsed = 0;
|
|
1066
|
+
let contextMax = DEFAULT_CONTEXT_WINDOW;
|
|
1067
|
+
let displayedTokens = 0;
|
|
1068
|
+
let contextAnimationFrame = null;
|
|
1069
|
+
|
|
1070
|
+
/**
|
|
1071
|
+
* Format token count to human-readable string
|
|
1072
|
+
*/
|
|
1073
|
+
function formatTokens(tokens) {
|
|
1074
|
+
if (tokens >= 1000000) {
|
|
1075
|
+
return (tokens / 1000000).toFixed(1).replace(/\.0$/, '') + 'M';
|
|
1076
|
+
}
|
|
1077
|
+
if (tokens >= 1000) {
|
|
1078
|
+
return (tokens / 1000).toFixed(tokens >= 10000 ? 0 : 1).replace(/\.0$/, '') + 'k';
|
|
1079
|
+
}
|
|
1080
|
+
return String(tokens);
|
|
1081
|
+
}
|
|
1082
|
+
|
|
1083
|
+
/**
|
|
1084
|
+
* Get color based on usage percentage
|
|
1085
|
+
*/
|
|
1086
|
+
function getUsageColor(pct) {
|
|
1087
|
+
if (pct >= 90) return 'var(--error)';
|
|
1088
|
+
if (pct >= 75) return 'var(--warning)';
|
|
1089
|
+
return 'var(--success)';
|
|
1090
|
+
}
|
|
1091
|
+
|
|
1092
|
+
/**
|
|
1093
|
+
* Animate token count smoothly
|
|
1094
|
+
*/
|
|
1095
|
+
function animateContextTokenCount(target, max) {
|
|
1096
|
+
if (contextAnimationFrame) cancelAnimationFrame(contextAnimationFrame);
|
|
1097
|
+
|
|
1098
|
+
const start = displayedTokens;
|
|
1099
|
+
const diff = target - start;
|
|
1100
|
+
if (diff === 0) return;
|
|
1101
|
+
|
|
1102
|
+
const duration = 600;
|
|
1103
|
+
const startTime = performance.now();
|
|
1104
|
+
|
|
1105
|
+
function tick(now) {
|
|
1106
|
+
const elapsed = now - startTime;
|
|
1107
|
+
const progress = Math.min(elapsed / duration, 1);
|
|
1108
|
+
const eased = 1 - Math.pow(1 - progress, 3);
|
|
1109
|
+
|
|
1110
|
+
displayedTokens = Math.round(start + diff * eased);
|
|
1111
|
+
const isMobile = window.innerWidth <= 768;
|
|
1112
|
+
const pct = Math.min(100, Math.round((displayedTokens / max) * 100));
|
|
1113
|
+
|
|
1114
|
+
if (contextTextEl) {
|
|
1115
|
+
contextTextEl.textContent = isMobile ? `${pct}%` : `${formatTokens(displayedTokens)}/${formatTokens(max)}`;
|
|
1116
|
+
}
|
|
1117
|
+
|
|
1118
|
+
if (progress < 1) {
|
|
1119
|
+
contextAnimationFrame = requestAnimationFrame(tick);
|
|
1120
|
+
} else {
|
|
1121
|
+
displayedTokens = target;
|
|
1122
|
+
contextAnimationFrame = null;
|
|
1123
|
+
}
|
|
1124
|
+
}
|
|
1125
|
+
|
|
1126
|
+
contextAnimationFrame = requestAnimationFrame(tick);
|
|
1127
|
+
}
|
|
1128
|
+
|
|
1129
|
+
/**
|
|
1130
|
+
* Update context badge display
|
|
1131
|
+
*/
|
|
1132
|
+
function updateContextDisplay() {
|
|
1133
|
+
if (!contextBadgeEl) return;
|
|
1134
|
+
|
|
1135
|
+
if (contextUsed <= 0) {
|
|
1136
|
+
contextBadgeEl.style.display = 'none';
|
|
1137
|
+
return;
|
|
1138
|
+
}
|
|
1139
|
+
|
|
1140
|
+
contextBadgeEl.style.display = 'flex';
|
|
1141
|
+
|
|
1142
|
+
const pct = Math.min(100, Math.round((contextUsed / contextMax) * 100));
|
|
1143
|
+
const color = getUsageColor(pct);
|
|
1144
|
+
|
|
1145
|
+
if (contextBarFillEl) {
|
|
1146
|
+
contextBarFillEl.style.width = pct + '%';
|
|
1147
|
+
contextBarFillEl.style.background = color;
|
|
1148
|
+
}
|
|
1149
|
+
|
|
1150
|
+
animateContextTokenCount(contextUsed, contextMax);
|
|
1151
|
+
|
|
1152
|
+
contextBadgeEl.title = `Context: ${contextUsed.toLocaleString()} / ${contextMax.toLocaleString()} tokens (${pct}%)`;
|
|
1153
|
+
contextBadgeEl.setAttribute('aria-label', `Context window usage: ${pct}% - ${formatTokens(contextUsed)} of ${formatTokens(contextMax)} tokens used`);
|
|
1154
|
+
}
|
|
1155
|
+
|
|
1156
|
+
/**
|
|
1157
|
+
* Fetch context data for the active split session
|
|
1158
|
+
*/
|
|
1159
|
+
async function fetchContextData() {
|
|
1160
|
+
if (!activeSession) {
|
|
1161
|
+
contextUsed = 0;
|
|
1162
|
+
updateContextDisplay();
|
|
1163
|
+
return;
|
|
1164
|
+
}
|
|
1165
|
+
|
|
1166
|
+
try {
|
|
1167
|
+
const { satelliteId, agentId } = parseSessionKey(activeSession.sessionKey);
|
|
1168
|
+
const response = await fetch(`/api/session/context?satelliteId=${encodeURIComponent(satelliteId)}&agentId=${encodeURIComponent(agentId)}`);
|
|
1169
|
+
|
|
1170
|
+
if (!response.ok) return;
|
|
1171
|
+
|
|
1172
|
+
const data = await response.json();
|
|
1173
|
+
if (!data.ok && data.error) {
|
|
1174
|
+
logger.warn('SplitChat: Context fetch error', data.error);
|
|
1175
|
+
return;
|
|
1176
|
+
}
|
|
1177
|
+
|
|
1178
|
+
contextUsed = data.totalTokens || 0;
|
|
1179
|
+
contextMax = data.contextTokens || DEFAULT_CONTEXT_WINDOW;
|
|
1180
|
+
updateContextDisplay();
|
|
1181
|
+
} catch (e) {
|
|
1182
|
+
logger.warn('SplitChat: Context fetch failed', e.message);
|
|
1183
|
+
}
|
|
1184
|
+
}
|
|
1185
|
+
|
|
1186
|
+
/**
|
|
1187
|
+
* Refresh context after message completes
|
|
1188
|
+
*/
|
|
1189
|
+
function refreshContext() {
|
|
1190
|
+
setTimeout(fetchContextData, 2000);
|
|
1191
|
+
setTimeout(fetchContextData, 8000);
|
|
1192
|
+
}
|
|
1193
|
+
|
|
1194
|
+
// ============================================
|
|
1195
|
+
// UI HELPERS
|
|
1196
|
+
// ============================================
|
|
1197
|
+
|
|
1198
|
+
/**
|
|
1199
|
+
* Enable or disable the input area.
|
|
1200
|
+
* @param {boolean} enabled
|
|
1201
|
+
*/
|
|
1202
|
+
function setInputEnabled(enabled) {
|
|
1203
|
+
if (inputEl) {
|
|
1204
|
+
inputEl.disabled = !enabled;
|
|
1205
|
+
}
|
|
1206
|
+
if (sendBtnEl) {
|
|
1207
|
+
sendBtnEl.disabled = !enabled;
|
|
1208
|
+
}
|
|
1209
|
+
}
|
|
1210
|
+
|
|
1211
|
+
// getAgentEmoji, escapeHtml, escapeAttr are defined in SHARED MODULE HELPERS
|
|
1212
|
+
// section above, delegating to UplinkMessageRenderer.
|
|
1213
|
+
|
|
1214
|
+
// ============================================
|
|
1215
|
+
// PUBLIC API
|
|
1216
|
+
// ============================================
|
|
1217
|
+
|
|
1218
|
+
export const UplinkSplitChat = {
|
|
1219
|
+
init,
|
|
1220
|
+
openSession,
|
|
1221
|
+
closeSession,
|
|
1222
|
+
getActiveSession,
|
|
1223
|
+
isActive,
|
|
1224
|
+
showPicker,
|
|
1225
|
+
hidePicker
|
|
1226
|
+
};
|
|
1227
|
+
|
|
1228
|
+
// Backward compat: assign to window
|
|
1229
|
+
window.UplinkSplitChat = UplinkSplitChat;
|
|
1230
|
+
|
|
1231
|
+
// Register with UplinkCore if available
|
|
1232
|
+
UplinkCore.registerModule('splitChat', init);
|
|
1233
|
+
|
|
1234
|
+
logger.debug('SplitChat: Module loaded');
|