kernelbot 1.0.37 → 1.0.38

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.
@@ -0,0 +1,873 @@
1
+ /**
2
+ * KERNEL Agents — Interactive topology visualization.
3
+ * Zoom/pan canvas, draggable nodes, animated message particles, minimap.
4
+ * Depends on window.KERNEL from shared.js.
5
+ */
6
+ (function() {
7
+ const { esc, formatDuration, timeAgo, formatBytes, $,
8
+ startClock, setMiniGauge, connectSSE, initParticleCanvas, initWaveform } = window.KERNEL;
9
+
10
+ // ── Init shared ──
11
+ startClock();
12
+ initParticleCanvas();
13
+ initWaveform();
14
+
15
+ // ── State ──
16
+ let capabilities = null;
17
+ let configData = null;
18
+ let lastSnap = null;
19
+ let selectedNode = null;
20
+ let prevRunningIds = new Set();
21
+
22
+ // ── Zoom / Pan state ──
23
+ let zoom = 1;
24
+ let panX = 0, panY = 0;
25
+ const ZOOM_MIN = 0.3, ZOOM_MAX = 3, ZOOM_STEP = 0.12;
26
+ let isPanning = false;
27
+ let panStartX = 0, panStartY = 0;
28
+ let panStartPanX = 0, panStartPanY = 0;
29
+
30
+ // ── Node drag state ──
31
+ let isDragging = false;
32
+ let dragNode = null;
33
+ let dragStartX = 0, dragStartY = 0;
34
+ let dragNodeStartX = 0, dragNodeStartY = 0;
35
+
36
+ // ── Node positions (absolute px in canvas space) ──
37
+ const nodePositions = {};
38
+
39
+ // ── Particle animation state ──
40
+ const particles = []; // { pathEl, progress, speed, id, burst }
41
+ let particleAnimFrame = null;
42
+
43
+ // ── Fetch config ──
44
+ fetch('/api/config').then(r => r.json()).then(d => { configData = d; updateHeroPulse(); });
45
+
46
+ // ══════════════════════════════════════════
47
+ // ZOOM & PAN
48
+ // ══════════════════════════════════════════
49
+
50
+ function applyTransform() {
51
+ const canvas = $('workflow-canvas');
52
+ if (canvas) canvas.style.transform = `translate(${panX}px, ${panY}px) scale(${zoom})`;
53
+ $('zoom-level').textContent = Math.round(zoom * 100) + '%';
54
+ updateMinimap();
55
+ }
56
+
57
+ function zoomTo(newZoom, cx, cy) {
58
+ const container = $('workflow-container');
59
+ if (!container) return;
60
+ const rect = container.getBoundingClientRect();
61
+ // Default center of container
62
+ if (cx == null) cx = rect.width / 2;
63
+ if (cy == null) cy = rect.height / 2;
64
+ const old = zoom;
65
+ zoom = Math.max(ZOOM_MIN, Math.min(ZOOM_MAX, newZoom));
66
+ const scale = zoom / old;
67
+ panX = cx - scale * (cx - panX);
68
+ panY = cy - scale * (cy - panY);
69
+ applyTransform();
70
+ }
71
+
72
+ // Mouse wheel zoom
73
+ $('workflow-container').addEventListener('wheel', (e) => {
74
+ e.preventDefault();
75
+ const rect = $('workflow-container').getBoundingClientRect();
76
+ const cx = e.clientX - rect.left;
77
+ const cy = e.clientY - rect.top;
78
+ const delta = e.deltaY > 0 ? -ZOOM_STEP : ZOOM_STEP;
79
+ zoomTo(zoom + delta, cx, cy);
80
+ }, { passive: false });
81
+
82
+ // Pan via mouse drag on empty space
83
+ $('workflow-container').addEventListener('mousedown', (e) => {
84
+ if (e.target.closest('.workflow-node') || e.target.closest('.zoom-controls') || e.target.closest('.minimap')) return;
85
+ isPanning = true;
86
+ panStartX = e.clientX;
87
+ panStartY = e.clientY;
88
+ panStartPanX = panX;
89
+ panStartPanY = panY;
90
+ $('workflow-container').classList.add('grabbing');
91
+ });
92
+
93
+ window.addEventListener('mousemove', (e) => {
94
+ if (isPanning) {
95
+ panX = panStartPanX + (e.clientX - panStartX);
96
+ panY = panStartPanY + (e.clientY - panStartY);
97
+ applyTransform();
98
+ }
99
+ if (isDragging && dragNode) {
100
+ const dx = (e.clientX - dragStartX) / zoom;
101
+ const dy = (e.clientY - dragStartY) / zoom;
102
+ const id = dragNode.dataset.nodeId;
103
+ const pos = nodePositions[id];
104
+ if (pos) {
105
+ pos.x = dragNodeStartX + dx;
106
+ pos.y = dragNodeStartY + dy;
107
+ dragNode.style.left = pos.x + 'px';
108
+ dragNode.style.top = pos.y + 'px';
109
+ updateConnectionPaths();
110
+ updateMinimap();
111
+ }
112
+ }
113
+ });
114
+
115
+ window.addEventListener('mouseup', () => {
116
+ if (isPanning) {
117
+ isPanning = false;
118
+ $('workflow-container').classList.remove('grabbing');
119
+ }
120
+ if (isDragging) {
121
+ isDragging = false;
122
+ if (dragNode) dragNode.classList.remove('dragging');
123
+ $('workflow-container').classList.remove('dragging-node');
124
+ dragNode = null;
125
+ }
126
+ });
127
+
128
+ // Zoom buttons
129
+ $('zoom-in').addEventListener('click', () => zoomTo(zoom + ZOOM_STEP));
130
+ $('zoom-out').addEventListener('click', () => zoomTo(zoom - ZOOM_STEP));
131
+ $('zoom-fit').addEventListener('click', fitToView);
132
+
133
+ function fitToView() {
134
+ const container = $('workflow-container');
135
+ if (!container) return;
136
+ const ids = Object.keys(nodePositions);
137
+ if (!ids.length) return;
138
+ let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
139
+ for (const id of ids) {
140
+ const p = nodePositions[id];
141
+ const node = $(`node-${id}`);
142
+ if (!node) continue;
143
+ minX = Math.min(minX, p.x);
144
+ minY = Math.min(minY, p.y);
145
+ maxX = Math.max(maxX, p.x + node.offsetWidth);
146
+ maxY = Math.max(maxY, p.y + node.offsetHeight);
147
+ }
148
+ const pad = 60;
149
+ const bw = maxX - minX + pad * 2;
150
+ const bh = maxY - minY + pad * 2;
151
+ const cw = container.offsetWidth;
152
+ const ch = container.offsetHeight;
153
+ zoom = Math.max(ZOOM_MIN, Math.min(ZOOM_MAX, Math.min(cw / bw, ch / bh)));
154
+ panX = (cw - bw * zoom) / 2 - minX * zoom + pad * zoom;
155
+ panY = (ch - bh * zoom) / 2 - minY * zoom + pad * zoom;
156
+ applyTransform();
157
+ }
158
+
159
+ // ══════════════════════════════════════════
160
+ // NODE BUILDING & LAYOUT
161
+ // ══════════════════════════════════════════
162
+
163
+ function defaultLayout(caps) {
164
+ // Default positions in canvas pixel space
165
+ const positions = {
166
+ user: { x: 60, y: 280 },
167
+ orchestrator: { x: 380, y: 250 },
168
+ };
169
+ if (caps?.workers) {
170
+ const workers = typeof caps.workers === 'object' && !Array.isArray(caps.workers)
171
+ ? Object.keys(caps.workers) : [];
172
+ const startY = 40;
173
+ const gap = 110;
174
+ for (let i = 0; i < workers.length; i++) {
175
+ positions[workers[i]] = { x: 720, y: startY + i * gap };
176
+ }
177
+ }
178
+ return positions;
179
+ }
180
+
181
+ function buildNodes(caps) {
182
+ const container = $('workflow-nodes');
183
+ container.innerHTML = '';
184
+
185
+ // Compute default layout
186
+ const defaults = defaultLayout(caps);
187
+ for (const [id, pos] of Object.entries(defaults)) {
188
+ if (!nodePositions[id]) nodePositions[id] = { ...pos };
189
+ }
190
+
191
+ // User node
192
+ createNode(container, 'user', {
193
+ emoji: '\u{1F4AC}',
194
+ title: 'TELEGRAM',
195
+ cls: 'user-node',
196
+ body: '<div class="node-meta-row"><span class="k">Source</span><span class="v">USER INPUT</span></div>',
197
+ ports: ['right'],
198
+ });
199
+
200
+ // Orchestrator
201
+ const orchProvider = configData?.orchestrator?.provider || '--';
202
+ const orchModel = configData?.orchestrator?.model || '--';
203
+ createNode(container, 'orchestrator', {
204
+ emoji: '\u{1F9E0}',
205
+ title: 'ORCHESTRATOR',
206
+ cls: 'orchestrator',
207
+ body: `<div class="node-meta-row"><span class="k">Provider</span><span class="v">${esc(orchProvider)}</span></div>`
208
+ + `<div class="node-meta-row"><span class="k">Model</span><span class="v">${esc(orchModel.length > 18 ? orchModel.slice(0,16)+'..' : orchModel)}</span></div>`
209
+ + '<div class="node-meta-row"><span class="k">Tools</span><span class="v">dispatch / list / cancel</span></div>',
210
+ ports: ['left', 'right'],
211
+ });
212
+
213
+ // Workers
214
+ if (caps?.workers) {
215
+ const workers = typeof caps.workers === 'object' && !Array.isArray(caps.workers)
216
+ ? Object.entries(caps.workers) : [];
217
+ for (const [type, w] of workers) {
218
+ createNode(container, type, {
219
+ emoji: w.emoji || '\u2699\uFE0F',
220
+ title: w.label?.replace(' Worker', '').toUpperCase() || type.toUpperCase(),
221
+ cls: '',
222
+ body: `<div class="node-meta-row"><span class="k">Tools</span><span class="v">${w.tools?.length || 0}</span></div>`
223
+ + `<div class="node-meta-row"><span class="k">Timeout</span><span class="v">${formatDuration(w.timeout)}</span></div>`
224
+ + `<div class="node-meta-row"><span class="k">Categories</span><span class="v">${(w.categories||[]).length}</span></div>`,
225
+ ports: ['left'],
226
+ });
227
+ }
228
+ }
229
+
230
+ positionNodes();
231
+ }
232
+
233
+ function createNode(container, id, opts) {
234
+ const node = document.createElement('div');
235
+ node.className = `workflow-node ${opts.cls || ''}`.trim();
236
+ node.id = `node-${id}`;
237
+ node.dataset.nodeId = id;
238
+
239
+ let portsHtml = '';
240
+ for (const side of (opts.ports || [])) {
241
+ portsHtml += `<div class="node-port ${side}" data-port="${side}"></div>`;
242
+ }
243
+
244
+ node.innerHTML = `
245
+ ${portsHtml}
246
+ <div class="node-header">
247
+ <span class="node-emoji">${opts.emoji}</span>
248
+ <span class="node-title">${opts.title}</span>
249
+ <span class="node-status-dot idle" id="status-${id}"></span>
250
+ </div>
251
+ <div class="node-body">${opts.body}</div>
252
+ <div class="node-jobs" id="jobs-${id}"></div>
253
+ `;
254
+
255
+ // Node drag
256
+ node.addEventListener('mousedown', (e) => {
257
+ if (e.target.closest('.detail-panel')) return;
258
+ e.stopPropagation();
259
+ isDragging = true;
260
+ dragNode = node;
261
+ dragStartX = e.clientX;
262
+ dragStartY = e.clientY;
263
+ const pos = nodePositions[id];
264
+ dragNodeStartX = pos ? pos.x : 0;
265
+ dragNodeStartY = pos ? pos.y : 0;
266
+ node.classList.add('dragging');
267
+ $('workflow-container').classList.add('dragging-node');
268
+ });
269
+
270
+ // Click (only if not dragged)
271
+ let clickStart = null;
272
+ node.addEventListener('mousedown', (e) => { clickStart = { x: e.clientX, y: e.clientY }; });
273
+ node.addEventListener('mouseup', (e) => {
274
+ if (clickStart && Math.abs(e.clientX - clickStart.x) < 5 && Math.abs(e.clientY - clickStart.y) < 5) {
275
+ openDetailPanel(id);
276
+ }
277
+ clickStart = null;
278
+ });
279
+
280
+ container.appendChild(node);
281
+ }
282
+
283
+ function positionNodes() {
284
+ for (const [id, pos] of Object.entries(nodePositions)) {
285
+ const node = $(`node-${id}`);
286
+ if (!node) continue;
287
+ node.style.left = pos.x + 'px';
288
+ node.style.top = pos.y + 'px';
289
+ }
290
+ updateConnectionPaths();
291
+ updateMinimap();
292
+ }
293
+
294
+ // ══════════════════════════════════════════
295
+ // SVG CONNECTIONS
296
+ // ══════════════════════════════════════════
297
+
298
+ function buildConnections(caps) {
299
+ const svg = $('workflow-svg');
300
+ svg.querySelectorAll('.connection-path, .msg-particle, .msg-trail').forEach(p => p.remove());
301
+
302
+ createConnection(svg, 'user', 'orchestrator', 'conn-user-orch');
303
+
304
+ if (caps?.workers) {
305
+ const workers = typeof caps.workers === 'object' && !Array.isArray(caps.workers)
306
+ ? Object.keys(caps.workers) : [];
307
+ for (const type of workers) {
308
+ createConnection(svg, 'orchestrator', type, `conn-orch-${type}`);
309
+ }
310
+ }
311
+ }
312
+
313
+ function createConnection(svg, fromId, toId, connId) {
314
+ const path = document.createElementNS('http://www.w3.org/2000/svg', 'path');
315
+ path.id = connId;
316
+ path.classList.add('connection-path');
317
+ path.dataset.from = fromId;
318
+ path.dataset.to = toId;
319
+ svg.appendChild(path);
320
+ }
321
+
322
+ function getNodeCenter(id, side) {
323
+ const node = $(`node-${id}`);
324
+ const pos = nodePositions[id];
325
+ if (!node || !pos) return { x: 0, y: 0 };
326
+ const w = node.offsetWidth;
327
+ const h = node.offsetHeight;
328
+ if (side === 'right') return { x: pos.x + w, y: pos.y + h / 2 };
329
+ if (side === 'left') return { x: pos.x, y: pos.y + h / 2 };
330
+ return { x: pos.x + w / 2, y: pos.y + h / 2 };
331
+ }
332
+
333
+ function updateConnectionPaths() {
334
+ const svg = $('workflow-svg');
335
+ if (!svg) return;
336
+
337
+ svg.querySelectorAll('.connection-path').forEach(path => {
338
+ const fromId = path.dataset.from;
339
+ const toId = path.dataset.to;
340
+ const from = getNodeCenter(fromId, 'right');
341
+ const to = getNodeCenter(toId, 'left');
342
+ const dx = Math.abs(to.x - from.x) * 0.5;
343
+ const d = `M ${from.x} ${from.y} C ${from.x + dx} ${from.y}, ${to.x - dx} ${to.y}, ${to.x} ${to.y}`;
344
+ path.setAttribute('d', d);
345
+ });
346
+ }
347
+
348
+ // ══════════════════════════════════════════
349
+ // ANIMATED MESSAGE PARTICLES
350
+ // ══════════════════════════════════════════
351
+
352
+ function spawnParticle(connId, opts = {}) {
353
+ const pathEl = document.getElementById(connId);
354
+ if (!pathEl || pathEl.getTotalLength() === 0) return;
355
+
356
+ const svg = $('workflow-svg');
357
+ const circle = document.createElementNS('http://www.w3.org/2000/svg', 'circle');
358
+ circle.classList.add('msg-particle');
359
+ if (opts.burst) circle.classList.add('burst');
360
+ circle.setAttribute('r', opts.burst ? 4 : 3);
361
+ svg.appendChild(circle);
362
+
363
+ // Glow trail
364
+ const trail = document.createElementNS('http://www.w3.org/2000/svg', 'path');
365
+ trail.classList.add('msg-trail');
366
+ svg.appendChild(trail);
367
+
368
+ particles.push({
369
+ pathEl,
370
+ circle,
371
+ trail,
372
+ progress: 0,
373
+ speed: opts.speed || (0.004 + Math.random() * 0.003),
374
+ burst: !!opts.burst,
375
+ });
376
+
377
+ if (!particleAnimFrame) startParticleLoop();
378
+ }
379
+
380
+ function startParticleLoop() {
381
+ function tick() {
382
+ const svg = $('workflow-svg');
383
+ for (let i = particles.length - 1; i >= 0; i--) {
384
+ const p = particles[i];
385
+ p.progress += p.speed;
386
+ if (p.progress >= 1) {
387
+ p.circle.remove();
388
+ p.trail.remove();
389
+ particles.splice(i, 1);
390
+ continue;
391
+ }
392
+ const len = p.pathEl.getTotalLength();
393
+ const pt = p.pathEl.getPointAtLength(p.progress * len);
394
+ p.circle.setAttribute('cx', pt.x);
395
+ p.circle.setAttribute('cy', pt.y);
396
+
397
+ // Trail: draw a short segment behind the particle
398
+ const trailStart = Math.max(0, p.progress - 0.08);
399
+ const steps = 8;
400
+ let d = '';
401
+ for (let s = 0; s <= steps; s++) {
402
+ const t = trailStart + (p.progress - trailStart) * (s / steps);
403
+ const tp = p.pathEl.getPointAtLength(t * len);
404
+ d += (s === 0 ? 'M' : 'L') + ` ${tp.x} ${tp.y}`;
405
+ }
406
+ p.trail.setAttribute('d', d);
407
+ }
408
+ if (particles.length > 0) {
409
+ particleAnimFrame = requestAnimationFrame(tick);
410
+ } else {
411
+ particleAnimFrame = null;
412
+ }
413
+ }
414
+ particleAnimFrame = requestAnimationFrame(tick);
415
+ }
416
+
417
+ // Spawn particles periodically for active connections
418
+ let particleInterval = null;
419
+ function startParticleSpawner(runningTypes) {
420
+ if (particleInterval) clearInterval(particleInterval);
421
+ if (runningTypes.size === 0) return;
422
+
423
+ function spawn() {
424
+ // User → orch
425
+ if (runningTypes.size > 0) {
426
+ spawnParticle('conn-user-orch');
427
+ }
428
+ // Orch → active workers
429
+ for (const type of runningTypes) {
430
+ spawnParticle(`conn-orch-${type}`, { burst: Math.random() < 0.2 });
431
+ }
432
+ }
433
+ spawn();
434
+ particleInterval = setInterval(spawn, 1800 + Math.random() * 600);
435
+ }
436
+
437
+ // ══════════════════════════════════════════
438
+ // MINIMAP
439
+ // ══════════════════════════════════════════
440
+
441
+ function updateMinimap() {
442
+ const minimap = $('minimap');
443
+ const viewport = $('minimap-viewport');
444
+ const container = $('workflow-container');
445
+ if (!minimap || !viewport || !container) return;
446
+
447
+ const mmW = minimap.offsetWidth;
448
+ const mmH = minimap.offsetHeight;
449
+
450
+ // Compute world bounds from nodes
451
+ const ids = Object.keys(nodePositions);
452
+ if (!ids.length) return;
453
+ let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
454
+ for (const id of ids) {
455
+ const p = nodePositions[id];
456
+ const node = $(`node-${id}`);
457
+ if (!node) continue;
458
+ minX = Math.min(minX, p.x);
459
+ minY = Math.min(minY, p.y);
460
+ maxX = Math.max(maxX, p.x + node.offsetWidth);
461
+ maxY = Math.max(maxY, p.y + node.offsetHeight);
462
+ }
463
+ const pad = 40;
464
+ minX -= pad; minY -= pad; maxX += pad; maxY += pad;
465
+ const worldW = maxX - minX;
466
+ const worldH = maxY - minY;
467
+ const scale = Math.min(mmW / worldW, mmH / worldH);
468
+
469
+ // Clear existing minimap nodes
470
+ minimap.querySelectorAll('.minimap-node').forEach(n => n.remove());
471
+
472
+ // Draw mini nodes
473
+ for (const id of ids) {
474
+ const p = nodePositions[id];
475
+ const node = $(`node-${id}`);
476
+ if (!node) continue;
477
+ const dot = document.createElement('div');
478
+ dot.className = 'minimap-node';
479
+ if (id === 'user') dot.classList.add('user');
480
+ else if (id === 'orchestrator') dot.classList.add('orch');
481
+ if (node.classList.contains('has-active-job')) dot.classList.add('active');
482
+ dot.style.left = ((p.x - minX) * scale) + 'px';
483
+ dot.style.top = ((p.y - minY) * scale) + 'px';
484
+ dot.style.width = (node.offsetWidth * scale) + 'px';
485
+ dot.style.height = (node.offsetHeight * scale) + 'px';
486
+ minimap.appendChild(dot);
487
+ }
488
+
489
+ // Viewport rect (what the user can see)
490
+ const cw = container.offsetWidth;
491
+ const ch = container.offsetHeight;
492
+ // Visible area in world coordinates
493
+ const vx = (-panX / zoom);
494
+ const vy = (-panY / zoom);
495
+ const vw = cw / zoom;
496
+ const vh = ch / zoom;
497
+ viewport.style.left = ((vx - minX) * scale) + 'px';
498
+ viewport.style.top = ((vy - minY) * scale) + 'px';
499
+ viewport.style.width = (vw * scale) + 'px';
500
+ viewport.style.height = (vh * scale) + 'px';
501
+ }
502
+
503
+ // ══════════════════════════════════════════
504
+ // SSE UPDATES
505
+ // ══════════════════════════════════════════
506
+
507
+ connectSSE(onSnapshot);
508
+
509
+ function onSnapshot(snap) {
510
+ lastSnap = snap;
511
+
512
+ if (snap.capabilities && !capabilities) {
513
+ capabilities = snap.capabilities;
514
+ buildNodes(capabilities);
515
+ buildConnections(capabilities);
516
+ requestAnimationFrame(() => {
517
+ positionNodes();
518
+ fitToView();
519
+ });
520
+ }
521
+
522
+ updateNodes(snap);
523
+ updateConnections(snap);
524
+ updateStats(snap);
525
+ updateRightBar(snap);
526
+ updateSystem(snap.system);
527
+ renderTicker(snap.logs);
528
+ detectNewJobs(snap);
529
+
530
+ if (selectedNode) updateDetailPanel(selectedNode);
531
+ }
532
+
533
+ // ── Ripple effect on new job arrival ──
534
+ function detectNewJobs(snap) {
535
+ const jobs = snap.jobs || [];
536
+ const currentRunning = new Set(jobs.filter(j => j.status === 'running').map(j => j.id));
537
+ for (const id of currentRunning) {
538
+ if (!prevRunningIds.has(id)) {
539
+ // New job — find its worker type and ripple
540
+ const job = jobs.find(j => j.id === id);
541
+ if (job?.type) {
542
+ const node = $(`node-${job.type}`);
543
+ if (node) {
544
+ node.classList.remove('ripple');
545
+ void node.offsetWidth; // force reflow
546
+ node.classList.add('ripple');
547
+ setTimeout(() => node.classList.remove('ripple'), 900);
548
+ }
549
+ // Also ripple orchestrator
550
+ const orch = $('node-orchestrator');
551
+ if (orch) {
552
+ orch.classList.remove('ripple');
553
+ void orch.offsetWidth;
554
+ orch.classList.add('ripple');
555
+ setTimeout(() => orch.classList.remove('ripple'), 900);
556
+ }
557
+ // Spawn burst particle
558
+ spawnParticle('conn-user-orch', { burst: true, speed: 0.008 });
559
+ spawnParticle(`conn-orch-${job.type}`, { burst: true, speed: 0.006 });
560
+ }
561
+ }
562
+ }
563
+ prevRunningIds = currentRunning;
564
+ }
565
+
566
+ function updateNodes(snap) {
567
+ const jobs = snap.jobs || [];
568
+ const runningByType = {};
569
+ for (const j of jobs) {
570
+ if (j.status === 'running') {
571
+ if (!runningByType[j.type]) runningByType[j.type] = [];
572
+ runningByType[j.type].push(j);
573
+ }
574
+ }
575
+
576
+ // Orchestrator
577
+ const orchDot = $('status-orchestrator');
578
+ const orchRunning = jobs.filter(j => j.status === 'running').length;
579
+ if (orchDot) orchDot.className = 'node-status-dot ' + (orchRunning > 0 ? 'active' : 'idle');
580
+ const orchNode = $('node-orchestrator');
581
+ if (orchNode) orchNode.classList.toggle('has-active-job', orchRunning > 0);
582
+ const orchJobs = $('jobs-orchestrator');
583
+ if (orchJobs) {
584
+ orchJobs.innerHTML = orchRunning > 0
585
+ ? `<div class="node-job-indicator"><span class="job-pulse"></span><span class="job-task">${orchRunning} active job${orchRunning > 1 ? 's' : ''}</span></div>`
586
+ : '';
587
+ }
588
+
589
+ // User
590
+ const userDot = $('status-user');
591
+ if (userDot) userDot.className = 'node-status-dot active';
592
+
593
+ // Workers
594
+ if (capabilities?.workers) {
595
+ const workerTypes = typeof capabilities.workers === 'object' && !Array.isArray(capabilities.workers)
596
+ ? Object.keys(capabilities.workers) : [];
597
+ for (const type of workerTypes) {
598
+ const dot = $(`status-${type}`);
599
+ const node = $(`node-${type}`);
600
+ const jobsEl = $(`jobs-${type}`);
601
+ const running = runningByType[type] || [];
602
+ if (dot) dot.className = 'node-status-dot ' + (running.length > 0 ? 'active' : 'idle');
603
+ if (node) node.classList.toggle('has-active-job', running.length > 0);
604
+ if (jobsEl) {
605
+ if (running.length > 0) {
606
+ let jh = '';
607
+ for (const j of running.slice(0, 2)) {
608
+ jh += `<div class="node-job-indicator"><span class="job-pulse"></span><span class="job-id">${esc(j.id)}</span><span class="job-task">${esc((j.task||'').slice(0,40))}</span></div>`;
609
+ }
610
+ if (running.length > 2) jh += `<div style="font-size:8px;color:var(--dim);padding:2px 6px">+${running.length - 2} more</div>`;
611
+ jobsEl.innerHTML = jh;
612
+ } else {
613
+ jobsEl.innerHTML = '';
614
+ }
615
+ }
616
+ }
617
+ }
618
+ }
619
+
620
+ function updateConnections(snap) {
621
+ const jobs = snap.jobs || [];
622
+ const runningTypes = new Set();
623
+ for (const j of jobs) {
624
+ if (j.status === 'running') runningTypes.add(j.type);
625
+ }
626
+
627
+ const svg = $('workflow-svg');
628
+ if (!svg) return;
629
+
630
+ const userOrch = svg.querySelector('#conn-user-orch');
631
+ if (userOrch) userOrch.classList.toggle('active', runningTypes.size > 0);
632
+
633
+ svg.querySelectorAll('.connection-path').forEach(path => {
634
+ if (path.id === 'conn-user-orch') return;
635
+ const toType = path.dataset.to;
636
+ path.classList.toggle('active', runningTypes.has(toType));
637
+ });
638
+
639
+ // Port dots
640
+ document.querySelectorAll('.node-port').forEach(port => {
641
+ const node = port.closest('.workflow-node');
642
+ if (!node) return;
643
+ const nodeId = node.dataset.nodeId;
644
+ const isActive = nodeId === 'user' ? runningTypes.size > 0
645
+ : nodeId === 'orchestrator' ? runningTypes.size > 0
646
+ : runningTypes.has(nodeId);
647
+ port.classList.toggle('active', isActive);
648
+ });
649
+
650
+ // Particle spawner
651
+ startParticleSpawner(runningTypes);
652
+ }
653
+
654
+ function updateStats(snap) {
655
+ const jobs = snap.jobs || [];
656
+ const running = jobs.filter(j => j.status === 'running').length;
657
+ const queued = jobs.filter(j => j.status === 'queued').length;
658
+ const completed = jobs.filter(j => j.status === 'completed').length;
659
+ const failed = jobs.filter(j => j.status === 'failed').length;
660
+
661
+ const setVal = (id, val) => { const el = $(id); if (el) el.textContent = val; };
662
+ setVal('stat-running', running);
663
+ setVal('stat-queued', queued);
664
+ setVal('stat-completed', completed);
665
+ setVal('stat-failed', failed);
666
+ setVal('stat-total-tools', snap.capabilities?.totalTools || 0);
667
+
668
+ const pJobs = $('pulse-jobs');
669
+ if (pJobs) {
670
+ pJobs.textContent = running > 0 ? running : '0';
671
+ pJobs.className = 'pulse-val' + (running > 0 ? ' active' : ' idle');
672
+ }
673
+ const pWorkers = $('pulse-workers');
674
+ if (pWorkers && snap.capabilities?.workers) {
675
+ const count = typeof snap.capabilities.workers === 'object'
676
+ ? Object.keys(snap.capabilities.workers).length : 0;
677
+ pWorkers.textContent = count;
678
+ }
679
+ }
680
+
681
+ function updateHeroPulse() {
682
+ if (!configData) return;
683
+ const pOrch = $('pulse-orch');
684
+ if (pOrch) {
685
+ const m = configData.orchestrator?.model || '--';
686
+ pOrch.textContent = m.length > 18 ? m.slice(0,16)+'..' : m;
687
+ pOrch.className = 'pulse-val active';
688
+ }
689
+ const pBrain = $('pulse-brain');
690
+ if (pBrain) {
691
+ const m = configData.brain?.model || '--';
692
+ pBrain.textContent = m.length > 18 ? m.slice(0,16)+'..' : m;
693
+ pBrain.className = 'pulse-val active';
694
+ }
695
+ }
696
+
697
+ function updateRightBar(snap) {
698
+ const jobs = snap.jobs || [];
699
+ const running = jobs.filter(j => j.status === 'running').length;
700
+ const completed = jobs.filter(j => j.status === 'completed').length;
701
+ const failed = jobs.filter(j => j.status === 'failed').length;
702
+ const set = (id, val, cls) => { const el = $(id); if (el) { el.textContent = val; el.className = cls; } };
703
+ set('rb-jobs', running, 'r-val' + (running > 0 ? '' : ' zero'));
704
+ set('rb-total-jobs', jobs.length, 'r-val' + (jobs.length > 0 ? '' : ' zero'));
705
+ set('rb-completed', completed, 'r-val' + (completed > 0 ? '' : ' zero'));
706
+ set('rb-failed', failed, 'r-val' + (failed > 0 ? ' warn' : ' zero'));
707
+ }
708
+
709
+ function updateSystem(sys) {
710
+ if (!sys) return;
711
+ const hdrUp = $('hdr-uptime');
712
+ if (hdrUp) hdrUp.textContent = formatDuration(sys.uptime);
713
+ const cores = sys.cpu.cores;
714
+ const cpu1 = sys.cpu.load1 / cores * 100;
715
+ const ramPct = parseFloat(sys.ram.percent);
716
+ setMiniGauge('sb-cpu', cpu1);
717
+ setMiniGauge('sb-ram', ramPct);
718
+ }
719
+
720
+ function renderTicker(logs) {
721
+ if (!logs || !logs.length) return;
722
+ const last = logs.slice(-20);
723
+ let items = '';
724
+ for (const l of last) {
725
+ const lvl = (l.level||'info').toLowerCase();
726
+ const ts = l.timestamp ? l.timestamp.replace(/^.*T/,'').replace(/\..*$/,'') : '';
727
+ items += `<span class="ticker-item"><span class="ts">[${esc(ts)}]</span> <span class="lvl-${lvl}">${esc(lvl.toUpperCase())}</span> <span class="msg">${esc((l.message||'').slice(0,80))}</span></span>`;
728
+ }
729
+ $('ticker-track').innerHTML = items + items;
730
+ document.documentElement.style.setProperty('--ticker-duration', Math.max(last.length * 4, 30) + 's');
731
+ }
732
+
733
+ // ══════════════════════════════════════════
734
+ // DETAIL PANEL
735
+ // ══════════════════════════════════════════
736
+
737
+ function openDetailPanel(nodeId) {
738
+ const panel = $('detail-panel');
739
+ if (selectedNode === nodeId && panel.classList.contains('open')) {
740
+ closeDetailPanel();
741
+ return;
742
+ }
743
+ document.querySelectorAll('.workflow-node.selected').forEach(n => n.classList.remove('selected'));
744
+ selectedNode = nodeId;
745
+ const node = $(`node-${nodeId}`);
746
+ if (node) node.classList.add('selected');
747
+ updateDetailPanel(nodeId);
748
+ panel.classList.add('open');
749
+ }
750
+
751
+ function closeDetailPanel() {
752
+ $('detail-panel').classList.remove('open');
753
+ document.querySelectorAll('.workflow-node.selected').forEach(n => n.classList.remove('selected'));
754
+ selectedNode = null;
755
+ }
756
+
757
+ function updateDetailPanel(nodeId) {
758
+ const title = $('detail-title');
759
+ const body = $('detail-body');
760
+ if (!title || !body) return;
761
+ const jobs = lastSnap?.jobs || [];
762
+
763
+ if (nodeId === 'user') {
764
+ title.textContent = '\u{1F4AC} TELEGRAM USER';
765
+ let h = '<div class="detail-section"><div class="detail-section-label">OVERVIEW</div>';
766
+ h += '<div class="detail-row"><span class="k">Type</span><span class="v">User Input Source</span></div>';
767
+ h += '<div class="detail-row"><span class="k">Protocol</span><span class="v">Telegram Bot API</span></div>';
768
+ const convs = lastSnap?.conversations || [];
769
+ h += `<div class="detail-row"><span class="k">Active Chats</span><span class="v">${convs.length}</span></div></div>`;
770
+ if (convs.length) {
771
+ h += '<div class="detail-section"><div class="detail-section-label">RECENT CHATS</div>';
772
+ for (const c of convs.slice(0, 8)) {
773
+ h += `<div class="detail-row"><span class="k">${esc(c.chatId)}</span><span class="v">${c.messageCount} msgs \u00b7 ${timeAgo(c.lastTimestamp)}</span></div>`;
774
+ }
775
+ h += '</div>';
776
+ }
777
+ body.innerHTML = h;
778
+
779
+ } else if (nodeId === 'orchestrator') {
780
+ title.textContent = '\u{1F9E0} ORCHESTRATOR';
781
+ let h = '<div class="detail-section"><div class="detail-section-label">CONFIGURATION</div>';
782
+ h += `<div class="detail-row"><span class="k">Provider</span><span class="v">${esc(configData?.orchestrator?.provider || '--')}</span></div>`;
783
+ h += `<div class="detail-row"><span class="k">Model</span><span class="v">${esc(configData?.orchestrator?.model || '--')}</span></div>`;
784
+ h += `<div class="detail-row"><span class="k">Max Tokens</span><span class="v">${configData?.orchestrator?.max_tokens || '--'}</span></div>`;
785
+ h += `<div class="detail-row"><span class="k">Temperature</span><span class="v">${configData?.orchestrator?.temperature ?? '--'}</span></div></div>`;
786
+ h += '<div class="detail-section"><div class="detail-section-label">WORKER BRAIN CONFIG</div>';
787
+ h += `<div class="detail-row"><span class="k">Provider</span><span class="v">${esc(configData?.brain?.provider || '--')}</span></div>`;
788
+ h += `<div class="detail-row"><span class="k">Model</span><span class="v">${esc(configData?.brain?.model || '--')}</span></div>`;
789
+ h += `<div class="detail-row"><span class="k">Max Tool Depth</span><span class="v">${configData?.brain?.max_tool_depth || '--'}</span></div></div>`;
790
+ const running = jobs.filter(j => j.status === 'running').length;
791
+ const queued = jobs.filter(j => j.status === 'queued').length;
792
+ h += '<div class="detail-section"><div class="detail-section-label">JOB OVERVIEW</div>';
793
+ h += `<div class="detail-row"><span class="k">Running</span><span class="v">${running}</span></div>`;
794
+ h += `<div class="detail-row"><span class="k">Queued</span><span class="v">${queued}</span></div>`;
795
+ h += `<div class="detail-row"><span class="k">Total</span><span class="v">${jobs.length}</span></div>`;
796
+ h += `<div class="detail-row"><span class="k">Concurrent Limit</span><span class="v">${configData?.swarm?.max_concurrent_jobs || '--'}</span></div></div>`;
797
+ h += '<div class="detail-section"><div class="detail-section-label">TOOLS</div>';
798
+ h += '<div><span class="detail-tool-tag">dispatch_task</span><span class="detail-tool-tag">list_jobs</span><span class="detail-tool-tag">cancel_job</span></div></div>';
799
+ body.innerHTML = h;
800
+
801
+ } else {
802
+ const w = capabilities?.workers?.[nodeId];
803
+ if (!w) return;
804
+ title.textContent = `${w.emoji || '\u2699\uFE0F'} ${(w.label || nodeId).toUpperCase()}`;
805
+ let h = '<div class="detail-section"><div class="detail-section-label">DESCRIPTION</div>';
806
+ h += `<div style="font-size:10px;color:var(--text)">${esc(w.description || '--')}</div></div>`;
807
+ h += '<div class="detail-section"><div class="detail-section-label">CONFIG</div>';
808
+ h += `<div class="detail-row"><span class="k">Timeout</span><span class="v">${formatDuration(w.timeout)}</span></div>`;
809
+ h += `<div class="detail-row"><span class="k">Tools</span><span class="v">${w.tools?.length || 0}</span></div>`;
810
+ h += `<div class="detail-row"><span class="k">Categories</span><span class="v">${(w.categories||[]).length}</span></div></div>`;
811
+ if (w.categories?.length) {
812
+ h += '<div class="detail-section"><div class="detail-section-label">TOOL CATEGORIES</div><div>';
813
+ for (const cat of w.categories) h += `<span class="detail-cat-tag">${esc(cat)}</span>`;
814
+ h += '</div></div>';
815
+ }
816
+ if (w.tools?.length) {
817
+ h += '<div class="detail-section"><div class="detail-section-label">TOOLS</div><div>';
818
+ for (const t of w.tools) h += `<span class="detail-tool-tag">${esc(t)}</span>`;
819
+ h += '</div></div>';
820
+ }
821
+ const workerJobs = jobs.filter(j => j.type === nodeId);
822
+ const runningJobs = workerJobs.filter(j => j.status === 'running');
823
+ const recentJobs = workerJobs.filter(j => j.status !== 'running').slice(0, 5);
824
+ if (runningJobs.length) {
825
+ h += '<div class="detail-section"><div class="detail-section-label">RUNNING JOBS</div>';
826
+ for (const j of runningJobs) {
827
+ h += '<div class="detail-job-item">';
828
+ h += `<div class="detail-job-meta"><span class="badge running">RUNNING</span> <span style="color:var(--amber);font-family:var(--font-hud);font-size:8px">${esc(j.id)}</span> <span style="color:var(--dim);font-size:9px">${formatDuration(j.duration)}</span></div>`;
829
+ h += `<div class="detail-job-task">${esc((j.task||'').slice(0,100))}</div>`;
830
+ if (j.lastThinking) h += `<div class="detail-job-sub">${esc(j.lastThinking.slice(0,80))}</div>`;
831
+ h += `<div class="detail-job-sub">LLM: ${j.llmCalls||0} \u00b7 Tools: ${j.toolCalls||0}</div></div>`;
832
+ }
833
+ h += '</div>';
834
+ }
835
+ if (recentJobs.length) {
836
+ h += '<div class="detail-section"><div class="detail-section-label">RECENT JOBS</div>';
837
+ for (const j of recentJobs) {
838
+ const badgeCls = j.status === 'completed' ? 'completed' : j.status === 'failed' ? 'failed' : 'cancelled';
839
+ h += '<div class="detail-job-item">';
840
+ h += `<div class="detail-job-meta"><span class="badge ${badgeCls}">${j.status.toUpperCase()}</span> <span style="color:var(--amber);font-family:var(--font-hud);font-size:8px">${esc(j.id)}</span> <span style="color:var(--dim);font-size:9px">${timeAgo(j.completedAt)}</span></div>`;
841
+ h += `<div class="detail-job-task">${esc((j.task||'').slice(0,80))}</div></div>`;
842
+ }
843
+ h += '</div>';
844
+ }
845
+ if (!runningJobs.length && !recentJobs.length) {
846
+ h += '<div class="detail-section"><div style="color:var(--dim);font-style:italic;text-align:center;padding:12px 0;font-size:11px">NO JOBS</div></div>';
847
+ }
848
+ body.innerHTML = h;
849
+ }
850
+ }
851
+
852
+ // ── Close panel handlers ──
853
+ $('detail-close').addEventListener('click', closeDetailPanel);
854
+
855
+ document.addEventListener('click', (e) => {
856
+ if (!selectedNode) return;
857
+ const panel = $('detail-panel');
858
+ const isInsidePanel = panel.contains(e.target);
859
+ const isInsideNode = e.target.closest('.workflow-node');
860
+ if (!isInsidePanel && !isInsideNode) closeDetailPanel();
861
+ });
862
+
863
+ // ── Resize ──
864
+ let resizeTimer;
865
+ window.addEventListener('resize', () => {
866
+ clearTimeout(resizeTimer);
867
+ resizeTimer = setTimeout(() => {
868
+ updateConnectionPaths();
869
+ updateMinimap();
870
+ }, 150);
871
+ });
872
+
873
+ })();