@intranefr/superbackend 1.6.6 → 1.7.7

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.
Files changed (51) hide show
  1. package/.env.example +4 -0
  2. package/README.md +18 -0
  3. package/package.json +8 -2
  4. package/public/js/admin-superdemos.js +396 -0
  5. package/public/sdk/superdemos.iife.js +614 -0
  6. package/public/superdemos-qa.html +324 -0
  7. package/sdk/superdemos/browser/src/index.js +719 -0
  8. package/src/cli/agent-chat.js +369 -0
  9. package/src/cli/agent-list.js +42 -0
  10. package/src/controllers/adminAgentsChat.controller.js +172 -0
  11. package/src/controllers/adminSuperDemos.controller.js +382 -0
  12. package/src/controllers/superDemosPublic.controller.js +126 -0
  13. package/src/middleware.js +102 -19
  14. package/src/models/BlogAutomationLock.js +4 -4
  15. package/src/models/BlogPost.js +16 -16
  16. package/src/models/CacheEntry.js +17 -6
  17. package/src/models/JsonConfig.js +2 -4
  18. package/src/models/RateLimitMetricBucket.js +10 -5
  19. package/src/models/SuperDemo.js +38 -0
  20. package/src/models/SuperDemoProject.js +32 -0
  21. package/src/models/SuperDemoStep.js +27 -0
  22. package/src/routes/adminAgents.routes.js +10 -0
  23. package/src/routes/adminMarkdowns.routes.js +3 -0
  24. package/src/routes/adminSuperDemos.routes.js +31 -0
  25. package/src/routes/superDemos.routes.js +9 -0
  26. package/src/services/auditLogger.js +75 -37
  27. package/src/services/email.service.js +18 -3
  28. package/src/services/superDemosAuthoringSessions.service.js +132 -0
  29. package/src/services/superDemosWs.service.js +164 -0
  30. package/src/services/terminalsWs.service.js +35 -3
  31. package/src/utils/rbac/rightsRegistry.js +2 -0
  32. package/views/admin-agents.ejs +261 -11
  33. package/views/admin-dashboard.ejs +78 -8
  34. package/views/admin-superdemos.ejs +335 -0
  35. package/views/admin-terminals.ejs +462 -34
  36. package/views/partials/admin/agents-chat.ejs +80 -0
  37. package/views/partials/dashboard/nav-items.ejs +1 -0
  38. package/views/partials/dashboard/tab-bar.ejs +6 -0
  39. package/cookies.txt +0 -6
  40. package/cookies1.txt +0 -6
  41. package/cookies2.txt +0 -6
  42. package/cookies3.txt +0 -6
  43. package/cookies4.txt +0 -5
  44. package/cookies_old.txt +0 -5
  45. package/cookies_old_test.txt +0 -6
  46. package/cookies_super.txt +0 -5
  47. package/cookies_super_test.txt +0 -6
  48. package/cookies_test.txt +0 -6
  49. package/test-access.js +0 -63
  50. package/test-iframe-fix.html +0 -63
  51. package/test-iframe.html +0 -14
@@ -42,6 +42,7 @@
42
42
  <div class="flex items-center gap-2">
43
43
  <button id="btn-new" class="px-3 py-2 rounded bg-blue-600 text-white text-sm hover:bg-blue-700">New</button>
44
44
  <button id="btn-close" class="px-3 py-2 rounded bg-red-600 text-white text-sm hover:bg-red-700">Close</button>
45
+ <button id="btn-reconnect" class="px-3 py-2 rounded bg-yellow-600 text-white text-sm hover:bg-yellow-700">Reconnect</button>
45
46
  <button id="btn-fullscreen" class="px-3 py-2 rounded bg-gray-600 text-white text-sm hover:bg-gray-700">Fullscreen</button>
46
47
  </div>
47
48
  </div>
@@ -49,8 +50,13 @@
49
50
  <div class="bg-white border border-gray-200 rounded-lg">
50
51
  <div class="border-b border-gray-200 flex items-center justify-between px-3 py-2">
51
52
  <div id="tabs" class="flex items-center gap-2 overflow-auto"></div>
52
- <div class="text-xs text-gray-500 whitespace-nowrap">
53
- Ctrl+Shift+T new · Ctrl+Shift+W close · Ctrl+Tab next · Ctrl+Shift+Tab prev · Alt+1..9 switch
53
+ <div class="flex items-center gap-4">
54
+ <div id="connection-status" class="text-xs text-gray-500 whitespace-nowrap">
55
+ <span id="status-indicator">○</span> <span id="status-text">No connection</span>
56
+ </div>
57
+ <div class="text-xs text-gray-500 whitespace-nowrap">
58
+ Ctrl+Shift+T new · Ctrl+Shift+W close · Ctrl+Tab next · Ctrl+Shift+Tab prev · Alt+1..9 switch
59
+ </div>
54
60
  </div>
55
61
  </div>
56
62
  <div id="terminal-container" class="h-[70vh]"></div>
@@ -62,17 +68,299 @@
62
68
  <script>
63
69
  window.BASE_URL = '<%= baseUrl %>';
64
70
  window.ADMIN_PATH = '<%= adminPath %>';
71
+ window.SERVER_PORT = '<%= serverPort %>';
65
72
 
66
73
  const state = {
67
74
  sessions: [],
68
75
  activeId: null,
69
76
  };
70
77
 
78
+ // WebSocket connection states
79
+ const ConnectionStates = {
80
+ DISCONNECTED: 'disconnected',
81
+ CONNECTING: 'connecting',
82
+ CONNECTED: 'connected',
83
+ RECONNECTING: 'reconnecting',
84
+ FAILED: 'failed'
85
+ };
86
+
87
+ // WebSocket Manager for robust connection handling
88
+ class WebSocketManager {
89
+ constructor(sessionId, term, onMessage, onClose, onError) {
90
+ this.sessionId = sessionId;
91
+ this.term = term;
92
+ this.onMessage = onMessage;
93
+ this.onClose = onClose;
94
+ this.onError = onError;
95
+ this.ws = null;
96
+ this.state = ConnectionStates.DISCONNECTED;
97
+ this.reconnectAttempts = 0;
98
+ this.maxReconnectAttempts = 10;
99
+ this.reconnectDelay = 1000; // Start with 1 second
100
+ this.maxReconnectDelay = 30000; // Max 30 seconds
101
+ this.heartbeatInterval = null;
102
+ this.heartbeatTimeout = null;
103
+ this.isManualClose = false;
104
+ this.connectionDebounce = null;
105
+
106
+ // Connection quality metrics
107
+ this.latency = 0;
108
+ this.lastPingTime = 0;
109
+ this.connectionQuality = 'good'; // good, poor, bad
110
+ this.messageCount = 0;
111
+ this.errorCount = 0;
112
+ }
113
+
114
+ connect() {
115
+ if (this.connectionDebounce) {
116
+ clearTimeout(this.connectionDebounce);
117
+ }
118
+
119
+ this.connectionDebounce = setTimeout(() => {
120
+ this._doConnect();
121
+ }, 100);
122
+ }
123
+
124
+ _doConnect() {
125
+ if (this.state === ConnectionStates.CONNECTING || this.state === ConnectionStates.CONNECTED) {
126
+ return;
127
+ }
128
+
129
+ this.state = ConnectionStates.CONNECTING;
130
+ this.updateConnectionUI();
131
+
132
+ try {
133
+ const wsUrl = baseWsUrl() + '/api/admin/terminals/ws?sessionId=' + encodeURIComponent(this.sessionId);
134
+ console.log(`[WS] Connecting to ${wsUrl}`);
135
+
136
+ this.ws = new WebSocket(wsUrl);
137
+ this.setupEventHandlers();
138
+ } catch (error) {
139
+ console.error('[WS] Connection error:', error);
140
+ this.handleError(error);
141
+ }
142
+ }
143
+
144
+ setupEventHandlers() {
145
+ if (!this.ws) return;
146
+
147
+ this.ws.onopen = () => {
148
+ console.log(`[WS] Connected for session ${this.sessionId}`);
149
+ this.state = ConnectionStates.CONNECTED;
150
+ this.reconnectAttempts = 0;
151
+ this.reconnectDelay = 1000;
152
+ this.startHeartbeat();
153
+ this.updateConnectionUI();
154
+ this.term.write('\r\n[connected]\r\n');
155
+ };
156
+
157
+ this.ws.onmessage = (event) => {
158
+ this.resetHeartbeat();
159
+ this.messageCount++;
160
+
161
+ try {
162
+ const msg = JSON.parse(String(event.data || ''));
163
+ if (msg && typeof msg === 'object') {
164
+ // Track latency for pong responses
165
+ if (msg.type === 'pong' && this.lastPingTime > 0) {
166
+ this.latency = Date.now() - this.lastPingTime;
167
+
168
+ // Update connection quality based on latency
169
+ if (this.latency < 100) {
170
+ this.connectionQuality = 'good';
171
+ } else if (this.latency < 500) {
172
+ this.connectionQuality = 'poor';
173
+ } else {
174
+ this.connectionQuality = 'bad';
175
+ }
176
+
177
+ console.log(`[WS] Latency: ${this.latency}ms, Quality: ${this.connectionQuality}`);
178
+ }
179
+
180
+ this.onMessage(msg);
181
+ }
182
+ } catch (error) {
183
+ console.error('[WS] Message parse error:', error);
184
+ this.errorCount++;
185
+ }
186
+ };
187
+
188
+ this.ws.onclose = (event) => {
189
+ console.log(`[WS] Connection closed for session ${this.sessionId}, code: ${event.code}, reason: ${event.reason}`);
190
+ this.stopHeartbeat();
191
+ this.ws = null;
192
+
193
+ if (!this.isManualClose) {
194
+ this.handleDisconnect();
195
+ } else {
196
+ this.state = ConnectionStates.DISCONNECTED;
197
+ this.updateConnectionUI();
198
+ }
199
+
200
+ if (this.onClose) {
201
+ this.onClose(event);
202
+ }
203
+ };
204
+
205
+ this.ws.onerror = (error) => {
206
+ console.error(`[WS] Error for session ${this.sessionId}:`, error);
207
+ this.handleError(error);
208
+ if (this.onError) {
209
+ this.onError(error);
210
+ }
211
+ };
212
+ }
213
+
214
+ startHeartbeat() {
215
+ this.stopHeartbeat();
216
+
217
+ // Send ping every 30 seconds
218
+ this.heartbeatInterval = setInterval(() => {
219
+ if (this.ws && this.ws.readyState === WebSocket.OPEN) {
220
+ try {
221
+ this.lastPingTime = Date.now();
222
+ this.ws.send(JSON.stringify({ type: 'ping' }));
223
+
224
+ // Set timeout to detect if pong is received
225
+ this.heartbeatTimeout = setTimeout(() => {
226
+ console.warn('[WS] Heartbeat timeout, reconnecting...');
227
+ this.connectionQuality = 'bad';
228
+ this.reconnect();
229
+ }, 5000);
230
+ } catch (error) {
231
+ console.error('[WS] Heartbeat send error:', error);
232
+ this.connectionQuality = 'bad';
233
+ this.reconnect();
234
+ }
235
+ }
236
+ }, 30000);
237
+ }
238
+
239
+ stopHeartbeat() {
240
+ if (this.heartbeatInterval) {
241
+ clearInterval(this.heartbeatInterval);
242
+ this.heartbeatInterval = null;
243
+ }
244
+ if (this.heartbeatTimeout) {
245
+ clearTimeout(this.heartbeatTimeout);
246
+ this.heartbeatTimeout = null;
247
+ }
248
+ }
249
+
250
+ resetHeartbeat() {
251
+ if (this.heartbeatTimeout) {
252
+ clearTimeout(this.heartbeatTimeout);
253
+ this.heartbeatTimeout = null;
254
+ }
255
+ }
256
+
257
+ handleDisconnect() {
258
+ this.state = ConnectionStates.RECONNECTING;
259
+ this.updateConnectionUI();
260
+ this.term.write('\r\n[disconnected, attempting to reconnect...]\r\n');
261
+ this.reconnect();
262
+ }
263
+
264
+ handleError(error) {
265
+ this.state = ConnectionStates.FAILED;
266
+ this.updateConnectionUI();
267
+ this.term.write(`\r\n[connection error: ${error.message}]\r\n`);
268
+ }
269
+
270
+ reconnect() {
271
+ if (this.isManualClose) return;
272
+
273
+ if (this.reconnectAttempts >= this.maxReconnectAttempts) {
274
+ console.error('[WS] Max reconnect attempts reached');
275
+ this.state = ConnectionStates.FAILED;
276
+ this.updateConnectionUI();
277
+ this.term.write('\r\n[connection failed - max retries reached]\r\n');
278
+ return;
279
+ }
280
+
281
+ this.reconnectAttempts++;
282
+ const delay = Math.min(this.reconnectDelay * Math.pow(1.5, this.reconnectAttempts - 1), this.maxReconnectDelay);
283
+
284
+ console.log(`[WS] Reconnection attempt ${this.reconnectAttempts}/${this.maxReconnectAttempts} in ${delay}ms`);
285
+
286
+ setTimeout(() => {
287
+ if (!this.isManualClose) {
288
+ this._doConnect();
289
+ }
290
+ }, delay);
291
+ }
292
+
293
+ send(data) {
294
+ if (this.ws && this.ws.readyState === WebSocket.OPEN) {
295
+ try {
296
+ this.ws.send(JSON.stringify(data));
297
+ return true;
298
+ } catch (error) {
299
+ console.error('[WS] Send error:', error);
300
+ return false;
301
+ }
302
+ }
303
+ return false;
304
+ }
305
+
306
+ close() {
307
+ this.isManualClose = true;
308
+ this.stopHeartbeat();
309
+ if (this.connectionDebounce) {
310
+ clearTimeout(this.connectionDebounce);
311
+ this.connectionDebounce = null;
312
+ }
313
+ if (this.ws) {
314
+ this.ws.close();
315
+ this.ws = null;
316
+ }
317
+ this.state = ConnectionStates.DISCONNECTED;
318
+ this.updateConnectionUI();
319
+ }
320
+
321
+ updateConnectionUI() {
322
+ // Update tab appearance based on connection state
323
+ renderTabs();
324
+ }
325
+
326
+ getConnectionStatus() {
327
+ return {
328
+ state: this.state,
329
+ attempts: this.reconnectAttempts,
330
+ maxAttempts: this.maxReconnectAttempts
331
+ };
332
+ }
333
+ }
334
+
71
335
  function baseWsUrl() {
72
- const base = window.BASE_URL || '';
73
- const u = new URL(base || window.location.origin);
336
+ // Use window.location.origin as primary source since it's always correct
337
+ const origin = window.location.origin;
338
+ console.log('[WS] Using origin:', origin);
339
+ console.log('[WS] BASE_URL fallback:', window.BASE_URL);
340
+ console.log('[WS] SERVER_PORT:', window.SERVER_PORT);
341
+
342
+ // If BASE_URL contains a different port than SERVER_PORT, override it
343
+ let finalOrigin = origin;
344
+ if (window.BASE_URL && window.SERVER_PORT) {
345
+ try {
346
+ const baseUrlObj = new URL(window.BASE_URL);
347
+ const serverPort = window.SERVER_PORT.toString();
348
+
349
+ if (baseUrlObj.port !== serverPort) {
350
+ console.log(`[WS] Overriding BASE_URL port ${baseUrlObj.port} with SERVER_PORT ${serverPort}`);
351
+ finalOrigin = `${baseUrlObj.protocol}//${baseUrlObj.hostname}:${serverPort}`;
352
+ }
353
+ } catch (error) {
354
+ console.warn('[WS] Failed to parse BASE_URL, using origin:', error);
355
+ }
356
+ }
357
+
358
+ const u = new URL(finalOrigin);
74
359
  const proto = u.protocol === 'https:' ? 'wss:' : 'ws:';
75
- return proto + '//' + u.host + u.pathname.replace(/\/$/, '');
360
+ const wsUrl = proto + '//' + u.host + u.pathname.replace(/\/$/, '');
361
+
362
+ console.log('[WS] Constructed WebSocket URL:', wsUrl);
363
+ return wsUrl;
76
364
  }
77
365
 
78
366
  function apiUrl(path) {
@@ -98,23 +386,60 @@
98
386
  state.sessions.forEach((s, idx) => {
99
387
  const btn = document.createElement('button');
100
388
  const active = s.sessionId === state.activeId;
101
- btn.className = `px-3 py-1.5 rounded text-sm border ${active ? 'bg-blue-50 border-blue-200 text-blue-800' : 'bg-white border-gray-200 text-gray-700 hover:bg-gray-50'}`;
102
- btn.textContent = `#${idx + 1} ${s.sessionId.slice(0, 6)}`;
389
+ const wsManager = s.wsManager;
390
+ let statusIndicator = '';
391
+ let statusClass = '';
392
+
393
+ if (wsManager) {
394
+ const status = wsManager.getConnectionStatus();
395
+ switch (status.state) {
396
+ case ConnectionStates.CONNECTED:
397
+ statusIndicator = '●';
398
+ statusClass = 'text-green-500';
399
+ break;
400
+ case ConnectionStates.CONNECTING:
401
+ case ConnectionStates.RECONNECTING:
402
+ statusIndicator = '○';
403
+ statusClass = 'text-yellow-500';
404
+ break;
405
+ case ConnectionStates.FAILED:
406
+ statusIndicator = '✗';
407
+ statusClass = 'text-red-500';
408
+ break;
409
+ default:
410
+ statusIndicator = '○';
411
+ statusClass = 'text-gray-400';
412
+ }
413
+ }
414
+
415
+ btn.className = `px-3 py-1.5 rounded text-sm border flex items-center gap-1 ${active ? 'bg-blue-50 border-blue-200 text-blue-800' : 'bg-white border-gray-200 text-gray-700 hover:bg-gray-50'}`;
416
+ btn.innerHTML = `
417
+ <span class="${statusClass}">${statusIndicator}</span>
418
+ <span>#${idx + 1} ${s.sessionId.slice(0, 6)}</span>
419
+ `;
103
420
  btn.addEventListener('click', () => activateSession(s.sessionId));
104
421
  tabs.appendChild(btn);
105
422
  });
106
423
  }
107
424
 
108
425
  function destroySessionClient(s) {
109
- try { if (s.ws) s.ws.close(); } catch {}
426
+ try {
427
+ if (s.wsManager) {
428
+ s.wsManager.close();
429
+ } else if (s.ws) {
430
+ s.ws.close();
431
+ }
432
+ } catch {}
110
433
  try { if (s.term) s.term.dispose(); } catch {}
111
434
  s.ws = null;
435
+ s.wsManager = null;
112
436
  s.term = null;
113
437
  }
114
438
 
115
439
  function activateSession(sessionId) {
116
440
  state.activeId = sessionId;
117
441
  renderTabs();
442
+ updateConnectionStatus();
118
443
 
119
444
  const container = qs('terminal-container');
120
445
  container.innerHTML = '';
@@ -133,7 +458,7 @@
133
458
 
134
459
  function resizeActive() {
135
460
  const s = state.sessions.find((x) => x.sessionId === state.activeId);
136
- if (!s || !s.term || !s.ws) return;
461
+ if (!s || !s.term) return;
137
462
 
138
463
  const el = s.termElement;
139
464
  const container = el.parentElement;
@@ -146,9 +471,14 @@
146
471
  s.term.resize(cols, rows);
147
472
  } catch {}
148
473
 
149
- try {
150
- s.ws.send(JSON.stringify({ type: 'resize', cols, rows }));
151
- } catch {}
474
+ // Use WebSocketManager if available, fallback to direct WebSocket
475
+ if (s.wsManager) {
476
+ s.wsManager.send({ type: 'resize', cols, rows });
477
+ } else if (s.ws) {
478
+ try {
479
+ s.ws.send(JSON.stringify({ type: 'resize', cols, rows }));
480
+ } catch {}
481
+ }
152
482
  }
153
483
 
154
484
  async function newTerminal() {
@@ -169,41 +499,54 @@
169
499
  const termElement = document.createElement('div');
170
500
  termElement.className = 'w-full h-full';
171
501
 
172
- const ws = new WebSocket(baseWsUrl() + '/api/admin/terminals/ws?sessionId=' + encodeURIComponent(sessionId));
502
+ // Create WebSocketManager instead of direct WebSocket
503
+ const wsManager = new WebSocketManager(
504
+ sessionId,
505
+ term,
506
+ (msg) => {
507
+ if (msg.type === 'output') {
508
+ term.write(msg.data || '');
509
+ } else if (msg.type === 'pong') {
510
+ // Heartbeat response received
511
+ console.log('[WS] Heartbeat pong received');
512
+ } else if (msg.type === 'error') {
513
+ term.write(`\r\n[server error: ${msg.error || 'unknown'}]\r\n`);
514
+ }
515
+ },
516
+ (event) => {
517
+ term.write('\r\n[connection closed]\r\n');
518
+ },
519
+ (error) => {
520
+ term.write(`\r\n[connection error]\r\n`);
521
+ }
522
+ );
173
523
 
174
- const s = { sessionId, term, ws, termElement };
524
+ const s = { sessionId, term, wsManager, termElement };
175
525
  state.sessions.push(s);
176
526
  state.activeId = sessionId;
177
527
  renderTabs();
178
528
 
179
- ws.onopen = () => {
180
- activateSession(sessionId);
181
- };
182
-
183
- ws.onmessage = (ev) => {
184
- let msg;
185
- try { msg = JSON.parse(String(ev.data || '')); } catch { return; }
186
- if (!msg || typeof msg !== 'object') return;
187
-
188
- if (msg.type === 'output') {
189
- term.write(msg.data || '');
190
- }
191
- };
192
-
193
- ws.onclose = () => {
194
- term.write('\r\n[disconnected]\r\n');
195
- };
529
+ // Start the connection
530
+ wsManager.connect();
196
531
 
197
532
  term.onData((data) => {
198
- try {
199
- ws.send(JSON.stringify({ type: 'input', data }));
200
- } catch {}
533
+ wsManager.send({ type: 'input', data });
201
534
  });
202
535
 
203
536
  window.addEventListener('resize', () => {
204
537
  if (state.activeId === sessionId) resizeActive();
205
538
  });
206
539
 
540
+ // Activate session when connection is established
541
+ const checkConnection = setInterval(() => {
542
+ if (wsManager.state === ConnectionStates.CONNECTED) {
543
+ clearInterval(checkConnection);
544
+ activateSession(sessionId);
545
+ } else if (wsManager.state === ConnectionStates.FAILED) {
546
+ clearInterval(checkConnection);
547
+ }
548
+ }, 100);
549
+
207
550
  resolve();
208
551
  } catch (e) {
209
552
  reject(e);
@@ -280,6 +623,91 @@
280
623
 
281
624
  qs('btn-new').addEventListener('click', () => newTerminal());
282
625
  qs('btn-close').addEventListener('click', () => closeActive());
626
+ qs('btn-reconnect').addEventListener('click', () => reconnectActive());
627
+
628
+ function updateConnectionStatus() {
629
+ const s = state.sessions.find((x) => x.sessionId === state.activeId);
630
+ const indicator = qs('status-indicator');
631
+ const text = qs('status-text');
632
+
633
+ if (!s || !s.wsManager) {
634
+ indicator.textContent = '○';
635
+ indicator.className = 'text-gray-400';
636
+ text.textContent = 'No connection';
637
+ return;
638
+ }
639
+
640
+ const status = s.wsManager.getConnectionStatus();
641
+ const latency = s.wsManager.latency;
642
+ const quality = s.wsManager.connectionQuality;
643
+
644
+ let statusText = '';
645
+ let qualityColor = '';
646
+
647
+ switch (status.state) {
648
+ case ConnectionStates.CONNECTED:
649
+ indicator.textContent = '●';
650
+ indicator.className = 'text-green-500';
651
+ statusText = `Connected (${latency}ms)`;
652
+
653
+ // Add quality indicator
654
+ if (quality === 'good') {
655
+ qualityColor = 'text-green-600';
656
+ statusText += ' ●';
657
+ } else if (quality === 'poor') {
658
+ qualityColor = 'text-yellow-600';
659
+ statusText += ' ○';
660
+ } else {
661
+ qualityColor = 'text-red-600';
662
+ statusText += ' ○';
663
+ }
664
+ break;
665
+ case ConnectionStates.CONNECTING:
666
+ indicator.textContent = '○';
667
+ indicator.className = 'text-yellow-500';
668
+ statusText = 'Connecting...';
669
+ break;
670
+ case ConnectionStates.RECONNECTING:
671
+ indicator.textContent = '○';
672
+ indicator.className = 'text-yellow-500';
673
+ statusText = `Reconnecting... (${status.attempts}/${status.maxAttempts})`;
674
+ break;
675
+ case ConnectionStates.FAILED:
676
+ indicator.textContent = '✗';
677
+ indicator.className = 'text-red-500';
678
+ statusText = 'Connection failed';
679
+ break;
680
+ default:
681
+ indicator.textContent = '○';
682
+ indicator.className = 'text-gray-400';
683
+ statusText = 'Disconnected';
684
+ }
685
+
686
+ text.textContent = statusText;
687
+ if (qualityColor) {
688
+ text.className = qualityColor;
689
+ } else {
690
+ text.className = 'text-gray-500';
691
+ }
692
+ }
693
+
694
+ function reconnectActive() {
695
+ const s = state.sessions.find((x) => x.sessionId === state.activeId);
696
+ if (s && s.wsManager) {
697
+ console.log('[UI] Manual reconnect triggered');
698
+ s.wsManager.close();
699
+ setTimeout(() => {
700
+ s.wsManager.connect();
701
+ }, 100);
702
+ }
703
+ }
704
+
705
+ // Override the WebSocketManager's updateConnectionUI to also update status
706
+ const originalUpdateConnectionUI = WebSocketManager.prototype.updateConnectionUI;
707
+ WebSocketManager.prototype.updateConnectionUI = function() {
708
+ originalUpdateConnectionUI.call(this);
709
+ updateConnectionStatus();
710
+ };
283
711
 
284
712
  (async function init() {
285
713
  const listed = await api('/api/admin/terminals/sessions');
@@ -0,0 +1,80 @@
1
+ <div class="bg-white rounded-xl border border-gray-200 shadow-sm overflow-hidden" :class="{ 'fixed inset-0 z-[70] rounded-none': chat.isFullscreen }">
2
+ <div class="px-4 py-3 border-b border-gray-200 flex flex-wrap items-center justify-between gap-2 bg-gray-50">
3
+ <div class="flex items-center gap-2">
4
+ <h2 class="text-sm font-semibold text-gray-700">Agent Chat</h2>
5
+ <span class="text-xs px-2 py-0.5 rounded bg-indigo-100 text-indigo-700" v-if="chat.chatId">{{ chat.chatId }}</span>
6
+ <span class="text-xs px-2 py-0.5 rounded bg-gray-100 text-gray-600" v-if="chat.dbName">DB: {{ chat.dbName }}</span>
7
+ </div>
8
+ <div class="flex items-center gap-2">
9
+ <button @click="toggleChatFullscreen" class="px-2 py-1 text-xs rounded border border-gray-300 hover:bg-gray-100">
10
+ {{ chat.isFullscreen ? 'Exit Fullscreen' : 'Fullscreen' }}
11
+ </button>
12
+ </div>
13
+ </div>
14
+
15
+ <div class="p-4 border-b border-gray-100 flex flex-wrap items-center gap-2">
16
+ <select v-model="chat.selectedAgentId" class="px-3 py-2 text-sm border border-gray-300 rounded-lg" @change="newSession">
17
+ <option value="">Select agent</option>
18
+ <option v-for="agent in agents" :key="agent._id" :value="agent._id">{{ agent.name }} ({{ agent.model }})</option>
19
+ </select>
20
+
21
+ <button @click="newSession" :disabled="!chat.selectedAgentId" class="px-3 py-2 text-xs rounded bg-indigo-600 text-white disabled:opacity-50">New Session</button>
22
+ <button @click="loadSessions" :disabled="!chat.selectedAgentId" class="px-3 py-2 text-xs rounded bg-gray-200 text-gray-800 disabled:opacity-50">Sessions</button>
23
+ <button @click="compactSession" :disabled="!chat.selectedAgentId || !chat.chatId || chat.sending" class="px-3 py-2 text-xs rounded bg-amber-500 text-white disabled:opacity-50">Compact</button>
24
+ <button @click="promptRenameSession" :disabled="!chat.chatId" class="px-3 py-2 text-xs rounded bg-sky-600 text-white disabled:opacity-50">Rename</button>
25
+ <button @click="stopGeneration" :disabled="!chat.sending" class="px-3 py-2 text-xs rounded bg-red-600 text-white disabled:opacity-50">Stop</button>
26
+ </div>
27
+
28
+ <div class="h-[55vh] overflow-y-auto p-4 bg-white" :class="{ 'h-[calc(100vh-230px)]': chat.isFullscreen }" ref="chatScrollEl">
29
+ <div v-if="chat.messages.length === 0" class="text-sm text-gray-500">Start a conversation with your selected agent.</div>
30
+ <div v-for="(msg, idx) in chat.messages" :key="idx" class="mb-4">
31
+ <div class="text-xs font-semibold mb-1" :class="msg.role === 'user' ? 'text-indigo-600' : 'text-emerald-600'">
32
+ {{ msg.role === 'user' ? 'You' : (msg.agentName || 'Agent') }}
33
+ </div>
34
+ <div class="whitespace-pre-wrap text-sm rounded-lg px-3 py-2" :class="msg.role === 'user' ? 'bg-indigo-50 border border-indigo-100' : 'bg-gray-50 border border-gray-100'">
35
+ {{ msg.text }}
36
+ </div>
37
+ </div>
38
+ </div>
39
+
40
+ <div class="px-4 py-2 border-t border-gray-200 bg-gray-50 text-xs text-gray-600 flex flex-wrap gap-2 items-center">
41
+ <span>Status: {{ chat.status || 'idle' }}</span>
42
+ <span v-if="chat.usage && chat.usage.total">Tokens: {{ chat.usage.total }} / {{ chat.usage.max || '?' }}</span>
43
+ </div>
44
+
45
+ <div class="p-4 border-t border-gray-100 bg-white">
46
+ <textarea
47
+ v-model="chat.input"
48
+ rows="3"
49
+ class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-indigo-500 outline-none"
50
+ placeholder="Type a message... (Shift+Enter for newline)"
51
+ @keydown="onChatInputKeydown"
52
+ ></textarea>
53
+ <div class="mt-2 flex justify-end">
54
+ <button @click="sendChatMessage" :disabled="!chat.selectedAgentId || !chat.input.trim() || chat.sending" class="px-4 py-2 rounded-lg bg-indigo-600 text-white text-sm hover:bg-indigo-700 disabled:opacity-50">
55
+ {{ chat.sending ? 'Sending...' : 'Send' }}
56
+ </button>
57
+ </div>
58
+ </div>
59
+
60
+ <div v-if="chat.showSessions" class="fixed inset-0 bg-black/40 z-[80] flex items-center justify-center p-4">
61
+ <div class="bg-white rounded-xl w-full max-w-xl max-h-[80vh] overflow-hidden border border-gray-200">
62
+ <div class="px-4 py-3 border-b flex justify-between items-center">
63
+ <h3 class="font-semibold text-gray-800">Recent Sessions</h3>
64
+ <button @click="chat.showSessions = false" class="text-gray-500 hover:text-gray-700">Close</button>
65
+ </div>
66
+ <div class="max-h-[60vh] overflow-y-auto">
67
+ <button
68
+ v-for="session in chat.sessions"
69
+ :key="session.id"
70
+ class="w-full text-left px-4 py-3 border-b hover:bg-gray-50"
71
+ @click="switchSession(session.id)"
72
+ >
73
+ <div class="text-sm font-medium text-gray-800">{{ session.label || session.id }}</div>
74
+ <div class="text-xs text-gray-500">Tokens: {{ session.totalTokens || 0 }} · {{ new Date(session.updatedAt).toLocaleString() }}</div>
75
+ </button>
76
+ <div v-if="chat.sessions.length === 0" class="px-4 py-8 text-sm text-gray-500">No sessions found.</div>
77
+ </div>
78
+ </div>
79
+ </div>
80
+ </div>
@@ -29,6 +29,7 @@
29
29
  { id: 'assets', label: 'Assets', path: adminPath + '/assets', icon: 'ti-photo' },
30
30
  { id: 'file-manager', label: 'File Manager', path: adminPath + '/file-manager', icon: 'ti-folder' },
31
31
  { id: 'ui-components', label: 'UI Components', path: adminPath + '/ui-components', icon: 'ti-components' },
32
+ { id: 'superdemos', label: 'SuperDemos', path: adminPath + '/superdemos', icon: 'ti-presentation' },
32
33
  { id: 'headless', label: 'Headless CMS', path: adminPath + '/headless', icon: 'ti-table' },
33
34
  { id: 'pages', label: 'Pages', path: adminPath + '/pages', icon: 'ti-file-text' },
34
35
  { id: 'blog-system', label: 'Blog system', path: adminPath + '/blog', icon: 'ti-news' },
@@ -19,6 +19,12 @@
19
19
  <div v-if="activeTabId === tab.id" class="absolute bottom-[-1px] left-0 right-0 h-[2px] bg-blue-600"></div>
20
20
  </div>
21
21
 
22
+ <!-- Tab Count Display -->
23
+ <div v-if="tabs.length > 0" class="flex items-center px-3 text-[11px]" :class="isTabLimitReached ? 'text-blue-600 font-medium' : 'text-gray-500'">
24
+ {{ tabCountDisplay }}
25
+ <i v-if="isTabLimitReached" class="ti ti-arrows-autorenew-right ml-1 text-[10px]" title="Oldest tab will be auto-closed when limit is reached"></i>
26
+ </div>
27
+
22
28
  <!-- Empty State Tab Placeholder if needed -->
23
29
  <div v-if="tabs.length === 0" class="flex items-center px-4 text-[11px] text-gray-400 italic">
24
30
  No open tabs