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.
- package/bin/kernel.js +389 -23
- package/config.example.yaml +17 -0
- package/package.json +2 -1
- package/src/agent.js +355 -82
- package/src/bot.js +724 -12
- package/src/character.js +406 -0
- package/src/characters/builder.js +174 -0
- package/src/characters/builtins.js +421 -0
- package/src/conversation.js +17 -2
- package/src/dashboard/agents.css +469 -0
- package/src/dashboard/agents.html +184 -0
- package/src/dashboard/agents.js +873 -0
- package/src/dashboard/dashboard.css +281 -0
- package/src/dashboard/dashboard.js +579 -0
- package/src/dashboard/index.html +366 -0
- package/src/dashboard/server.js +521 -0
- package/src/dashboard/shared.css +700 -0
- package/src/dashboard/shared.js +209 -0
- package/src/life/engine.js +28 -20
- package/src/life/evolution.js +7 -5
- package/src/life/journal.js +5 -4
- package/src/life/memory.js +12 -9
- package/src/life/share-queue.js +7 -5
- package/src/prompts/orchestrator.js +76 -14
- package/src/prompts/workers.js +22 -0
- package/src/self.js +17 -5
- package/src/services/linkedin-api.js +190 -0
- package/src/services/stt.js +8 -2
- package/src/services/tts.js +32 -2
- package/src/services/x-api.js +141 -0
- package/src/swarm/worker-registry.js +7 -0
- package/src/tools/categories.js +4 -0
- package/src/tools/index.js +6 -0
- package/src/tools/linkedin.js +264 -0
- package/src/tools/orchestrator-tools.js +337 -2
- package/src/tools/x.js +256 -0
- package/src/utils/config.js +104 -57
- package/src/utils/display.js +73 -12
- package/src/utils/temporal-awareness.js +24 -10
|
@@ -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
|
+
})();
|