clawmatrix 0.1.14 → 0.1.16
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/BOOTSTRAP.md +55 -8
- package/package.json +4 -2
- package/src/auth.ts +42 -12
- package/src/cluster-service.ts +35 -7
- package/src/compat.ts +3 -0
- package/src/config.ts +57 -6
- package/src/connection.ts +34 -8
- package/src/device-info.ts +48 -0
- package/src/handoff.ts +330 -21
- package/src/http-utils.ts +35 -0
- package/src/index.ts +47 -19
- package/src/model-proxy.ts +546 -242
- package/src/peer-manager.ts +65 -6
- package/src/router.ts +89 -47
- package/src/tool-proxy.ts +22 -7
- package/src/tools/cluster-events.ts +119 -0
- package/src/tools/cluster-exec.ts +4 -0
- package/src/tools/cluster-handoff-reply.ts +77 -0
- package/src/tools/cluster-handoff.ts +12 -0
- package/src/tools/cluster-peers.ts +17 -1
- package/src/tools/cluster-send.ts +1 -3
- package/src/tools/cluster-tool.ts +2 -5
- package/src/types.ts +117 -0
- package/src/web-ui.ts +694 -342
- package/src/web.ts +726 -50
package/src/web-ui.ts
CHANGED
|
@@ -9,6 +9,7 @@ export function renderDashboard(nodeId: string): string {
|
|
|
9
9
|
<style>
|
|
10
10
|
${CSS}
|
|
11
11
|
</style>
|
|
12
|
+
<script src="https://cdn.jsdelivr.net/npm/force-graph@1/dist/force-graph.min.js"></script>
|
|
12
13
|
</head>
|
|
13
14
|
<body>
|
|
14
15
|
|
|
@@ -72,33 +73,42 @@ ${CSS}
|
|
|
72
73
|
|
|
73
74
|
<!-- Main content -->
|
|
74
75
|
<div class="main">
|
|
75
|
-
<!-- Left: Mesh
|
|
76
|
+
<!-- Left: Mesh -->
|
|
76
77
|
<div class="panel-left">
|
|
77
78
|
<div class="card mesh-card">
|
|
78
79
|
<div class="card-header">
|
|
79
80
|
<h2>Mesh Topology</h2>
|
|
80
81
|
<span id="peer-count" class="badge">0 nodes</span>
|
|
81
82
|
</div>
|
|
82
|
-
<
|
|
83
|
-
</div>
|
|
84
|
-
<div id="node-detail" class="card detail-card hidden">
|
|
85
|
-
<div class="card-header">
|
|
86
|
-
<h2 id="detail-title">Node Details</h2>
|
|
87
|
-
<span id="detail-status" class="badge"></span>
|
|
88
|
-
</div>
|
|
89
|
-
<div id="detail-body" class="detail-body"></div>
|
|
83
|
+
<div id="mesh-container"></div>
|
|
90
84
|
</div>
|
|
91
85
|
</div>
|
|
92
86
|
|
|
93
|
-
<!-- Right: Chat -->
|
|
87
|
+
<!-- Right: Detail + Chat -->
|
|
94
88
|
<div class="panel-right">
|
|
89
|
+
<div id="node-detail" class="detail-panel hidden">
|
|
90
|
+
<div class="detail-header">
|
|
91
|
+
<div class="detail-header-left">
|
|
92
|
+
<span id="detail-status-dot" class="detail-dot"></span>
|
|
93
|
+
<span id="detail-title" class="detail-title-text"></span>
|
|
94
|
+
<span id="detail-status" class="badge"></span>
|
|
95
|
+
</div>
|
|
96
|
+
<button id="detail-close" class="btn-icon" title="Close">
|
|
97
|
+
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
|
|
98
|
+
</button>
|
|
99
|
+
</div>
|
|
100
|
+
<div id="detail-body" class="detail-body"></div>
|
|
101
|
+
</div>
|
|
95
102
|
<div class="card chat-card">
|
|
96
103
|
<div class="card-header">
|
|
97
|
-
<h2>Chat</h2>
|
|
104
|
+
<h2 id="chat-title">Chat</h2>
|
|
98
105
|
<div class="chat-selects">
|
|
99
106
|
<select id="chat-model" title="Model">
|
|
100
107
|
<option value="">Select model...</option>
|
|
101
108
|
</select>
|
|
109
|
+
<select id="chat-agent" title="Agent" class="hidden">
|
|
110
|
+
<option value="">Select agent...</option>
|
|
111
|
+
</select>
|
|
102
112
|
</div>
|
|
103
113
|
</div>
|
|
104
114
|
<div id="chat-messages" class="chat-messages">
|
|
@@ -310,10 +320,7 @@ body {
|
|
|
310
320
|
flex: 1;
|
|
311
321
|
display: flex;
|
|
312
322
|
flex-direction: column;
|
|
313
|
-
padding: 16px;
|
|
314
|
-
gap: 16px;
|
|
315
323
|
min-width: 0;
|
|
316
|
-
overflow-y: auto;
|
|
317
324
|
}
|
|
318
325
|
|
|
319
326
|
.panel-right {
|
|
@@ -365,22 +372,84 @@ body {
|
|
|
365
372
|
flex: 1;
|
|
366
373
|
display: flex;
|
|
367
374
|
flex-direction: column;
|
|
368
|
-
|
|
375
|
+
border-radius: 0;
|
|
376
|
+
border: none;
|
|
369
377
|
}
|
|
370
378
|
|
|
371
|
-
#mesh-
|
|
379
|
+
#mesh-container {
|
|
372
380
|
flex: 1;
|
|
373
381
|
width: 100%;
|
|
374
|
-
|
|
382
|
+
overflow: hidden;
|
|
383
|
+
}
|
|
384
|
+
#mesh-container canvas {
|
|
385
|
+
width: 100% !important;
|
|
386
|
+
height: 100% !important;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
/* Node detail (right panel) */
|
|
390
|
+
.detail-panel {
|
|
391
|
+
flex: 1 1 50%;
|
|
392
|
+
min-height: 0;
|
|
393
|
+
overflow-y: auto;
|
|
394
|
+
border-bottom: 1px solid var(--border);
|
|
395
|
+
background: var(--bg-card);
|
|
396
|
+
animation: slideDown 0.2s ease-out;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
@keyframes slideDown {
|
|
400
|
+
from { opacity: 0; }
|
|
401
|
+
to { opacity: 1; }
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
.detail-header {
|
|
405
|
+
display: flex;
|
|
406
|
+
align-items: center;
|
|
407
|
+
justify-content: space-between;
|
|
408
|
+
padding: 10px 16px;
|
|
409
|
+
border-bottom: 1px solid var(--border-subtle);
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
.detail-header-left {
|
|
413
|
+
display: flex;
|
|
414
|
+
align-items: center;
|
|
415
|
+
gap: 8px;
|
|
416
|
+
min-width: 0;
|
|
375
417
|
}
|
|
376
418
|
|
|
377
|
-
|
|
378
|
-
|
|
419
|
+
.detail-dot {
|
|
420
|
+
width: 8px;
|
|
421
|
+
height: 8px;
|
|
422
|
+
border-radius: 50%;
|
|
423
|
+
flex-shrink: 0;
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
.detail-title-text {
|
|
427
|
+
font-size: 13px;
|
|
428
|
+
font-weight: 600;
|
|
429
|
+
color: var(--text);
|
|
430
|
+
white-space: nowrap;
|
|
431
|
+
overflow: hidden;
|
|
432
|
+
text-overflow: ellipsis;
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
.btn-icon {
|
|
436
|
+
background: transparent;
|
|
437
|
+
border: none;
|
|
438
|
+
color: var(--text-dim);
|
|
439
|
+
cursor: pointer;
|
|
440
|
+
padding: 4px;
|
|
441
|
+
border-radius: 4px;
|
|
442
|
+
display: flex;
|
|
443
|
+
align-items: center;
|
|
444
|
+
transition: color 0.15s, background 0.15s;
|
|
445
|
+
}
|
|
446
|
+
.btn-icon:hover { color: var(--text); background: rgba(255,255,255,0.05); }
|
|
379
447
|
|
|
380
448
|
.detail-body {
|
|
381
|
-
padding: 16px
|
|
449
|
+
padding: 12px 16px;
|
|
382
450
|
font-size: 13px;
|
|
383
451
|
line-height: 1.7;
|
|
452
|
+
overflow-y: auto;
|
|
384
453
|
}
|
|
385
454
|
|
|
386
455
|
.detail-body .detail-section {
|
|
@@ -395,6 +464,34 @@ body {
|
|
|
395
464
|
margin-bottom: 4px;
|
|
396
465
|
}
|
|
397
466
|
|
|
467
|
+
.detail-body .detail-label.collapsible {
|
|
468
|
+
cursor: pointer;
|
|
469
|
+
user-select: none;
|
|
470
|
+
display: flex;
|
|
471
|
+
align-items: center;
|
|
472
|
+
gap: 4px;
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
.detail-body .detail-label.collapsible::before {
|
|
476
|
+
content: '▶';
|
|
477
|
+
font-size: 8px;
|
|
478
|
+
transition: transform 0.15s;
|
|
479
|
+
display: inline-block;
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
.detail-body .detail-label.collapsible.expanded::before {
|
|
483
|
+
transform: rotate(90deg);
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
.detail-body .detail-items {
|
|
487
|
+
display: none;
|
|
488
|
+
padding-top: 2px;
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
.detail-body .detail-items.expanded {
|
|
492
|
+
display: block;
|
|
493
|
+
}
|
|
494
|
+
|
|
398
495
|
.detail-body .detail-tags {
|
|
399
496
|
display: flex;
|
|
400
497
|
flex-wrap: wrap;
|
|
@@ -410,6 +507,18 @@ body {
|
|
|
410
507
|
color: var(--text-secondary);
|
|
411
508
|
}
|
|
412
509
|
|
|
510
|
+
.detail-body .detail-grid {
|
|
511
|
+
display: grid;
|
|
512
|
+
grid-template-columns: auto 1fr;
|
|
513
|
+
gap: 4px 12px;
|
|
514
|
+
font-size: 12px;
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
.detail-body .detail-key {
|
|
518
|
+
color: var(--text-dim);
|
|
519
|
+
font-weight: 500;
|
|
520
|
+
}
|
|
521
|
+
|
|
413
522
|
.detail-body .item-row {
|
|
414
523
|
display: flex;
|
|
415
524
|
align-items: center;
|
|
@@ -428,7 +537,8 @@ body {
|
|
|
428
537
|
.chat-card {
|
|
429
538
|
display: flex;
|
|
430
539
|
flex-direction: column;
|
|
431
|
-
|
|
540
|
+
flex: 1 1 50%;
|
|
541
|
+
min-height: 0;
|
|
432
542
|
border-radius: 0;
|
|
433
543
|
border: none;
|
|
434
544
|
border-top: none;
|
|
@@ -582,13 +692,11 @@ const JS = `
|
|
|
582
692
|
let selectedNode = null;
|
|
583
693
|
let chatMessages = [];
|
|
584
694
|
let chatStreaming = false;
|
|
585
|
-
let
|
|
586
|
-
let
|
|
695
|
+
let chatMode = 'model'; // 'model' | 'handoff'
|
|
696
|
+
let handoffNodeId = null;
|
|
587
697
|
let hoveredNode = null;
|
|
588
|
-
let dragNode = null;
|
|
589
|
-
let dragOffset = { x: 0, y: 0 };
|
|
590
|
-
let animFrame = null;
|
|
591
698
|
let pollTimer = null;
|
|
699
|
+
let graph = null;
|
|
592
700
|
|
|
593
701
|
// ── DOM refs ──
|
|
594
702
|
const $ = (id) => document.getElementById(id);
|
|
@@ -597,8 +705,6 @@ const JS = `
|
|
|
597
705
|
const loginForm = $('login-form');
|
|
598
706
|
const loginToken = $('login-token');
|
|
599
707
|
const loginError = $('login-error');
|
|
600
|
-
const canvas = $('mesh-canvas');
|
|
601
|
-
const ctx = canvas.getContext('2d');
|
|
602
708
|
|
|
603
709
|
// ── Auth ──
|
|
604
710
|
loginForm.addEventListener('submit', async (e) => {
|
|
@@ -651,13 +757,10 @@ const JS = `
|
|
|
651
757
|
initMesh();
|
|
652
758
|
pollStatus();
|
|
653
759
|
pollTimer = setInterval(pollStatus, 3000);
|
|
654
|
-
requestAnimationFrame(renderLoop);
|
|
655
760
|
}
|
|
656
761
|
|
|
657
762
|
function stopDashboard() {
|
|
658
763
|
if (pollTimer) { clearInterval(pollTimer); pollTimer = null; }
|
|
659
|
-
if (animFrame) { cancelAnimationFrame(animFrame); animFrame = null; }
|
|
660
|
-
window.removeEventListener('resize', resizeCanvas);
|
|
661
764
|
}
|
|
662
765
|
|
|
663
766
|
async function pollStatus() {
|
|
@@ -698,341 +801,372 @@ const JS = `
|
|
|
698
801
|
return h + 'h ' + m + 'm';
|
|
699
802
|
}
|
|
700
803
|
|
|
701
|
-
// ── Mesh visualization ──
|
|
702
|
-
const
|
|
703
|
-
|
|
804
|
+
// ── Mesh visualization (force-graph) ──
|
|
805
|
+
const NODE_COLORS = {
|
|
806
|
+
self: '#818cf8',
|
|
807
|
+
direct: '#34d399',
|
|
808
|
+
relay: '#fbbf24',
|
|
809
|
+
satellite: '#f472b6',
|
|
810
|
+
offline: '#555d75',
|
|
811
|
+
};
|
|
704
812
|
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
canvas.addEventListener('mouseleave', () => { hoveredNode = null; dragNode = null; canvas.style.cursor = 'default'; });
|
|
712
|
-
}
|
|
813
|
+
const GLOW_COLORS = {
|
|
814
|
+
self: [129, 140, 248],
|
|
815
|
+
direct: [52, 211, 153],
|
|
816
|
+
relay: [251, 191, 36],
|
|
817
|
+
satellite: [244, 114, 182],
|
|
818
|
+
};
|
|
713
819
|
|
|
714
|
-
|
|
715
|
-
const rect = canvas.parentElement.getBoundingClientRect();
|
|
716
|
-
const headerH = canvas.parentElement.querySelector('.card-header')?.offsetHeight || 0;
|
|
717
|
-
W = rect.width;
|
|
718
|
-
H = rect.height - headerH;
|
|
719
|
-
canvas.width = W * DPR;
|
|
720
|
-
canvas.height = H * DPR;
|
|
721
|
-
canvas.style.width = W + 'px';
|
|
722
|
-
canvas.style.height = H + 'px';
|
|
723
|
-
ctx.setTransform(DPR, 0, 0, DPR, 0, 0);
|
|
724
|
-
layoutNodes();
|
|
725
|
-
}
|
|
820
|
+
let frameCount = 0;
|
|
726
821
|
|
|
727
|
-
function
|
|
728
|
-
const
|
|
729
|
-
{ id: state.nodeId, type: 'self', data: state.local },
|
|
730
|
-
...state.peers.map(p => ({
|
|
731
|
-
id: p.nodeId,
|
|
732
|
-
type: p.online ? (p.connection === 'direct' ? 'direct' : 'relay') : 'offline',
|
|
733
|
-
data: p,
|
|
734
|
-
})),
|
|
735
|
-
];
|
|
822
|
+
function initMesh() {
|
|
823
|
+
const container = $('mesh-container');
|
|
736
824
|
|
|
737
|
-
//
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
const old = oldPositions[n.id];
|
|
743
|
-
if (old) return { ...n, x: old.x, y: old.y, vx: old.vx, vy: old.vy };
|
|
744
|
-
// New node: random position
|
|
745
|
-
const angle = (i / allNodes.length) * Math.PI * 2 - Math.PI / 2;
|
|
746
|
-
const r = n.type === 'self' ? 0 : 120 + Math.random() * 40;
|
|
747
|
-
return {
|
|
748
|
-
...n,
|
|
749
|
-
x: W / 2 + Math.cos(angle) * r,
|
|
750
|
-
y: H / 2 + Math.sin(angle) * r,
|
|
751
|
-
vx: 0,
|
|
752
|
-
vy: 0,
|
|
753
|
-
};
|
|
825
|
+
// Close button for detail panel
|
|
826
|
+
$('detail-close').addEventListener('click', () => {
|
|
827
|
+
selectedNode = null;
|
|
828
|
+
$('node-detail').classList.add('hidden');
|
|
829
|
+
setChatMode('model', null);
|
|
754
830
|
});
|
|
755
831
|
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
832
|
+
graph = new ForceGraph()(container)
|
|
833
|
+
.backgroundColor('transparent')
|
|
834
|
+
.nodeId('id')
|
|
835
|
+
.linkSource('source')
|
|
836
|
+
.linkTarget('target')
|
|
837
|
+
.nodeVal(node => node.type === 'self' ? 8 : node.type === 'satellite' ? 3 : 5)
|
|
838
|
+
.nodeCanvasObjectMode(() => 'replace')
|
|
839
|
+
.nodeCanvasObject((node, ctx, globalScale) => {
|
|
840
|
+
const isSelected = selectedNode === node.id;
|
|
841
|
+
const isHovered = hoveredNode === node.id;
|
|
842
|
+
const r = node.type === 'self' ? 16 : node.type === 'satellite' ? 9 : 12;
|
|
843
|
+
const color = NODE_COLORS[node.type] || NODE_COLORS.offline;
|
|
844
|
+
const glowRgb = GLOW_COLORS[node.type];
|
|
845
|
+
|
|
846
|
+
// Animated pulse glow
|
|
847
|
+
if (glowRgb) {
|
|
848
|
+
const pulse = 0.5 + 0.5 * Math.sin(frameCount * 0.03 + (node.__idx || 0) * 1.5);
|
|
849
|
+
const glowR = r * (2.5 + (isHovered || isSelected ? 1.0 : 0) + pulse * 0.5);
|
|
850
|
+
const glowAlpha = isSelected ? 0.25 : isHovered ? 0.2 : 0.08 + pulse * 0.04;
|
|
851
|
+
const grad = ctx.createRadialGradient(node.x, node.y, r * 0.5, node.x, node.y, glowR);
|
|
852
|
+
grad.addColorStop(0, 'rgba(' + glowRgb.join(',') + ',' + glowAlpha + ')');
|
|
853
|
+
grad.addColorStop(0.6, 'rgba(' + glowRgb.join(',') + ',' + (glowAlpha * 0.3) + ')');
|
|
854
|
+
grad.addColorStop(1, 'transparent');
|
|
855
|
+
ctx.fillStyle = grad;
|
|
856
|
+
ctx.fillRect(node.x - glowR, node.y - glowR, glowR * 2, glowR * 2);
|
|
766
857
|
}
|
|
767
|
-
}
|
|
768
|
-
}
|
|
769
858
|
|
|
770
|
-
|
|
771
|
-
|
|
859
|
+
// Outer ring for selected/hovered
|
|
860
|
+
if (isSelected || isHovered) {
|
|
861
|
+
ctx.beginPath();
|
|
862
|
+
ctx.arc(node.x, node.y, r + 3, 0, Math.PI * 2);
|
|
863
|
+
ctx.strokeStyle = color + (isSelected ? '60' : '30');
|
|
864
|
+
ctx.lineWidth = 2;
|
|
865
|
+
ctx.stroke();
|
|
866
|
+
}
|
|
772
867
|
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
868
|
+
// Main circle with gradient fill
|
|
869
|
+
const fillGrad = ctx.createRadialGradient(node.x - r * 0.3, node.y - r * 0.3, 0, node.x, node.y, r);
|
|
870
|
+
fillGrad.addColorStop(0, color);
|
|
871
|
+
fillGrad.addColorStop(1, color + 'aa');
|
|
872
|
+
ctx.beginPath();
|
|
873
|
+
ctx.arc(node.x, node.y, r, 0, Math.PI * 2);
|
|
874
|
+
ctx.fillStyle = fillGrad;
|
|
875
|
+
ctx.fill();
|
|
778
876
|
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
const damping = 0.85;
|
|
784
|
-
const center = { x: W / 2, y: H / 2 };
|
|
785
|
-
|
|
786
|
-
for (let i = 0; i < meshNodes.length; i++) {
|
|
787
|
-
const a = meshNodes[i];
|
|
788
|
-
if (a._pinned || (a.type === 'self' && !dragNode)) continue; // pinned
|
|
789
|
-
|
|
790
|
-
let fx = 0, fy = 0;
|
|
791
|
-
|
|
792
|
-
// Repulsion from all other nodes
|
|
793
|
-
for (let j = 0; j < meshNodes.length; j++) {
|
|
794
|
-
if (i === j) continue;
|
|
795
|
-
const b = meshNodes[j];
|
|
796
|
-
let dx = a.x - b.x;
|
|
797
|
-
let dy = a.y - b.y;
|
|
798
|
-
const d2 = dx * dx + dy * dy + 1;
|
|
799
|
-
const f = repulsion / d2;
|
|
800
|
-
fx += dx * f;
|
|
801
|
-
fy += dy * f;
|
|
802
|
-
}
|
|
877
|
+
// Border
|
|
878
|
+
ctx.strokeStyle = isSelected ? '#fff' : isHovered ? color : color + '50';
|
|
879
|
+
ctx.lineWidth = isSelected ? 2 : isHovered ? 1.5 : 1;
|
|
880
|
+
ctx.stroke();
|
|
803
881
|
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
fx += dx / d * f;
|
|
817
|
-
fy += dy / d * f;
|
|
818
|
-
}
|
|
882
|
+
// Inner ring for self with animated rotation
|
|
883
|
+
if (node.type === 'self') {
|
|
884
|
+
ctx.save();
|
|
885
|
+
ctx.translate(node.x, node.y);
|
|
886
|
+
ctx.rotate(frameCount * 0.008);
|
|
887
|
+
ctx.beginPath();
|
|
888
|
+
ctx.arc(0, 0, r - 5, 0, Math.PI * 1.5);
|
|
889
|
+
ctx.strokeStyle = 'rgba(255,255,255,0.35)';
|
|
890
|
+
ctx.lineWidth = 1.5;
|
|
891
|
+
ctx.stroke();
|
|
892
|
+
ctx.restore();
|
|
893
|
+
}
|
|
819
894
|
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
895
|
+
// Label with shadow
|
|
896
|
+
ctx.textAlign = 'center';
|
|
897
|
+
ctx.textBaseline = 'middle';
|
|
898
|
+
const fontSize = Math.max((node.type === 'self' ? 12 : 11) / globalScale, 3);
|
|
899
|
+
ctx.font = (node.type === 'self' ? '600 ' : '500 ') + fontSize + 'px -apple-system, system-ui, sans-serif';
|
|
900
|
+
const labelY = node.y + r + 12 / globalScale;
|
|
901
|
+
ctx.fillStyle = 'rgba(0,0,0,0.5)';
|
|
902
|
+
ctx.fillText(node.id, node.x + 0.5 / globalScale, labelY + 0.5 / globalScale);
|
|
903
|
+
ctx.fillStyle = isSelected || isHovered ? '#fff' : 'rgba(255,255,255,0.8)';
|
|
904
|
+
ctx.fillText(node.id, node.x, labelY);
|
|
905
|
+
|
|
906
|
+
// Capability counts
|
|
907
|
+
const data = node.data;
|
|
908
|
+
if (data) {
|
|
909
|
+
const parts = [];
|
|
910
|
+
if (node.type === 'satellite') {
|
|
911
|
+
parts.push(data.ssid ? data.ssid : 'cellular');
|
|
912
|
+
} else {
|
|
913
|
+
const agents = data.agents?.length || 0;
|
|
914
|
+
const models = data.models?.length || 0;
|
|
915
|
+
const tools = data.toolProxy?.enabled ? (data.toolProxy.allow?.length || 0) : 0;
|
|
916
|
+
if (models) parts.push(models + 'M');
|
|
917
|
+
if (agents) parts.push(agents + 'A');
|
|
918
|
+
if (tools) parts.push(tools + 'T');
|
|
919
|
+
}
|
|
920
|
+
if (parts.length) {
|
|
921
|
+
ctx.font = Math.max(10 / globalScale, 2.5) + 'px -apple-system, system-ui, sans-serif';
|
|
922
|
+
ctx.fillStyle = 'rgba(255,255,255,0.4)';
|
|
923
|
+
ctx.fillText(parts.join(' \\u00b7 '), node.x, labelY + 13 / globalScale);
|
|
924
|
+
}
|
|
925
|
+
}
|
|
926
|
+
})
|
|
927
|
+
.linkCanvasObjectMode(() => 'replace')
|
|
928
|
+
.linkCanvasObject((link, ctx, globalScale) => {
|
|
929
|
+
const src = link.source;
|
|
930
|
+
const tgt = link.target;
|
|
931
|
+
if (!src || !tgt || src.x == null || tgt.x == null) return;
|
|
932
|
+
|
|
933
|
+
const isDirect = link.type === 'direct';
|
|
934
|
+
const isSatellite = link.type === 'satellite';
|
|
935
|
+
const rgb = getLinkColorRgb(src, tgt);
|
|
936
|
+
|
|
937
|
+
// Link line with glow
|
|
938
|
+
ctx.beginPath();
|
|
939
|
+
ctx.moveTo(src.x, src.y);
|
|
940
|
+
ctx.lineTo(tgt.x, tgt.y);
|
|
823
941
|
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
942
|
+
// Glow layer
|
|
943
|
+
ctx.strokeStyle = 'rgba(' + rgb + ',0.12)';
|
|
944
|
+
ctx.lineWidth = isDirect ? 6 : 4;
|
|
945
|
+
ctx.setLineDash([]);
|
|
946
|
+
ctx.stroke();
|
|
828
947
|
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
948
|
+
// Main line
|
|
949
|
+
ctx.beginPath();
|
|
950
|
+
ctx.moveTo(src.x, src.y);
|
|
951
|
+
ctx.lineTo(tgt.x, tgt.y);
|
|
952
|
+
ctx.strokeStyle = 'rgba(' + rgb + ',' + (isDirect ? '0.55' : '0.4') + ')';
|
|
953
|
+
ctx.lineWidth = isDirect ? 2 : 1.5;
|
|
954
|
+
if (isSatellite) ctx.setLineDash([3, 5]);
|
|
955
|
+
else if (!isDirect) ctx.setLineDash([6, 4]);
|
|
956
|
+
else ctx.setLineDash([]);
|
|
957
|
+
ctx.stroke();
|
|
958
|
+
ctx.setLineDash([]);
|
|
959
|
+
})
|
|
960
|
+
.linkDirectionalParticles(link => link.type === 'direct' ? 4 : 2)
|
|
961
|
+
.linkDirectionalParticleSpeed(link => link.type === 'direct' ? 0.004 : 0.003)
|
|
962
|
+
.linkDirectionalParticleWidth(link => link.type === 'direct' ? 3 : 2)
|
|
963
|
+
.linkDirectionalParticleColor(link => {
|
|
964
|
+
return 'rgba(' + getLinkColorRgb(link.source, link.target) + ',0.8)';
|
|
965
|
+
})
|
|
966
|
+
.onNodeHover(node => {
|
|
967
|
+
hoveredNode = node ? node.id : null;
|
|
968
|
+
container.style.cursor = node ? 'pointer' : 'default';
|
|
969
|
+
})
|
|
970
|
+
.onNodeClick((node) => {
|
|
971
|
+
selectedNode = node.id;
|
|
972
|
+
updateDetail(node.id);
|
|
973
|
+
setChatMode(node.id !== state.nodeId ? 'handoff' : 'model', node.id !== state.nodeId ? node.id : null);
|
|
974
|
+
})
|
|
975
|
+
.onBackgroundClick(() => {
|
|
976
|
+
selectedNode = null;
|
|
977
|
+
$('node-detail').classList.add('hidden');
|
|
978
|
+
setChatMode('model', null);
|
|
979
|
+
})
|
|
980
|
+
.onNodeDragEnd(node => {
|
|
981
|
+
node.fx = node.x;
|
|
982
|
+
node.fy = node.y;
|
|
983
|
+
})
|
|
984
|
+
.onRenderFramePre((ctx, globalScale) => {
|
|
985
|
+
frameCount++;
|
|
986
|
+
// Subtle radial gradient background
|
|
987
|
+
const w = graph.width();
|
|
988
|
+
const h = graph.height();
|
|
989
|
+
const bgGrad = ctx.createRadialGradient(0, 0, 0, 0, 0, Math.max(w, h) * 0.6);
|
|
990
|
+
bgGrad.addColorStop(0, 'rgba(99, 102, 241, 0.03)');
|
|
991
|
+
bgGrad.addColorStop(1, 'transparent');
|
|
992
|
+
ctx.fillStyle = bgGrad;
|
|
993
|
+
ctx.fillRect(-w / 2, -h / 2, w, h);
|
|
994
|
+
|
|
995
|
+
// Grid dots instead of lines
|
|
996
|
+
ctx.fillStyle = 'rgba(255, 255, 255, 0.04)';
|
|
997
|
+
const gridSize = 30 / globalScale;
|
|
998
|
+
const dotR = 1 / globalScale;
|
|
999
|
+
const xMin = -w / 2;
|
|
1000
|
+
const yMin = -h / 2;
|
|
1001
|
+
const xMax = w / 2;
|
|
1002
|
+
const yMax = h / 2;
|
|
1003
|
+
for (let x = Math.floor(xMin / gridSize) * gridSize; x < xMax; x += gridSize) {
|
|
1004
|
+
for (let y = Math.floor(yMin / gridSize) * gridSize; y < yMax; y += gridSize) {
|
|
1005
|
+
ctx.beginPath();
|
|
1006
|
+
ctx.arc(x, y, dotR, 0, Math.PI * 2);
|
|
1007
|
+
ctx.fill();
|
|
1008
|
+
}
|
|
1009
|
+
}
|
|
1010
|
+
})
|
|
1011
|
+
.cooldownTime(Infinity)
|
|
1012
|
+
.d3AlphaMin(0);
|
|
1013
|
+
|
|
1014
|
+
// Configure forces — strong repulsion to spread nodes apart
|
|
1015
|
+
graph.d3Force('link').distance(200);
|
|
1016
|
+
graph.d3Force('charge').strength(-800).distanceMin(50);
|
|
1017
|
+
graph.d3Force('center', null);
|
|
1018
|
+
|
|
1019
|
+
// Observe container resize
|
|
1020
|
+
new ResizeObserver(() => {
|
|
1021
|
+
const rect = container.getBoundingClientRect();
|
|
1022
|
+
const headerH = container.parentElement.querySelector('.card-header')?.offsetHeight || 0;
|
|
1023
|
+
graph.width(rect.width).height(rect.height - headerH);
|
|
1024
|
+
}).observe(container.parentElement);
|
|
1025
|
+
|
|
1026
|
+
// Trigger initial size
|
|
1027
|
+
const rect = container.getBoundingClientRect();
|
|
1028
|
+
const headerH = container.parentElement.querySelector('.card-header')?.offsetHeight || 0;
|
|
1029
|
+
graph.width(rect.width).height(rect.height - headerH);
|
|
834
1030
|
}
|
|
835
1031
|
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
//
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
1032
|
+
// Color palette for distinguishing different WSS connections
|
|
1033
|
+
const LINK_COLORS = [
|
|
1034
|
+
[129, 140, 248], // indigo
|
|
1035
|
+
[52, 211, 153], // emerald
|
|
1036
|
+
[251, 146, 60], // orange
|
|
1037
|
+
[167, 139, 250], // violet
|
|
1038
|
+
[56, 189, 248], // sky
|
|
1039
|
+
[251, 191, 36], // amber
|
|
1040
|
+
[244, 114, 182], // pink
|
|
1041
|
+
[45, 212, 191], // teal
|
|
1042
|
+
[248, 113, 113], // red
|
|
1043
|
+
[163, 230, 53], // lime
|
|
1044
|
+
];
|
|
1045
|
+
let linkColorIndex = 0;
|
|
1046
|
+
const linkColorMap = {}; // edge key → color index
|
|
1047
|
+
|
|
1048
|
+
function getLinkColorRgb(source, target) {
|
|
1049
|
+
const s = typeof source === 'object' ? source.id : source;
|
|
1050
|
+
const t = typeof target === 'object' ? target.id : target;
|
|
1051
|
+
const key = [s, t].sort().join('::');
|
|
1052
|
+
if (!(key in linkColorMap)) {
|
|
1053
|
+
linkColorMap[key] = linkColorIndex % LINK_COLORS.length;
|
|
1054
|
+
linkColorIndex++;
|
|
850
1055
|
}
|
|
1056
|
+
const c = LINK_COLORS[linkColorMap[key]];
|
|
1057
|
+
return c[0] + ',' + c[1] + ',' + c[2];
|
|
1058
|
+
}
|
|
851
1059
|
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
ctx.strokeStyle = 'rgba(251, 191, 36, 0.25)';
|
|
871
|
-
ctx.lineWidth = 1.5;
|
|
872
|
-
ctx.setLineDash([6, 4]);
|
|
873
|
-
}
|
|
874
|
-
ctx.stroke();
|
|
875
|
-
ctx.setLineDash([]);
|
|
876
|
-
|
|
877
|
-
// Animated particles along edge
|
|
878
|
-
const color = isDirect ? '99, 102, 241' : '251, 191, 36';
|
|
879
|
-
const dx = b.x - a.x;
|
|
880
|
-
const dy = b.y - a.y;
|
|
881
|
-
const len = Math.sqrt(dx * dx + dy * dy);
|
|
882
|
-
if (len < 10) continue;
|
|
883
|
-
|
|
884
|
-
for (let i = 0; i < 3; i++) {
|
|
885
|
-
const t = ((particleTime * 0.5 + i * 0.33) % 1);
|
|
886
|
-
const px = a.x + dx * t;
|
|
887
|
-
const py = a.y + dy * t;
|
|
888
|
-
ctx.beginPath();
|
|
889
|
-
ctx.arc(px, py, 2, 0, Math.PI * 2);
|
|
890
|
-
ctx.fillStyle = 'rgba(' + color + ', ' + (0.6 - t * 0.4) + ')';
|
|
891
|
-
ctx.fill();
|
|
1060
|
+
function buildGraphInputs() {
|
|
1061
|
+
const allPeers = state.peers.map((p, i) => ({
|
|
1062
|
+
id: p.nodeId,
|
|
1063
|
+
type: p.connection === 'satellite' ? 'satellite' : (p.online ? (p.connection === 'direct' ? 'direct' : 'relay') : 'offline'),
|
|
1064
|
+
data: p,
|
|
1065
|
+
}));
|
|
1066
|
+
const nodes = [
|
|
1067
|
+
{ id: state.nodeId, type: 'self', data: state.local },
|
|
1068
|
+
...allPeers,
|
|
1069
|
+
];
|
|
1070
|
+
// Spread initial positions in a circle so nodes don't start at (0,0)
|
|
1071
|
+
const radius = 120;
|
|
1072
|
+
for (let i = 0; i < nodes.length; i++) {
|
|
1073
|
+
const n = nodes[i];
|
|
1074
|
+
if (n.x == null) {
|
|
1075
|
+
const angle = (2 * Math.PI * i) / nodes.length - Math.PI / 2;
|
|
1076
|
+
n.x = radius * Math.cos(angle);
|
|
1077
|
+
n.y = radius * Math.sin(angle);
|
|
892
1078
|
}
|
|
893
1079
|
}
|
|
894
1080
|
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
const
|
|
899
|
-
const
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
'rgba(251,191,36,0.1)';
|
|
906
|
-
const grad = ctx.createRadialGradient(node.x, node.y, r, node.x, node.y, r * 2.5);
|
|
907
|
-
grad.addColorStop(0, glowColor);
|
|
908
|
-
grad.addColorStop(1, 'transparent');
|
|
909
|
-
ctx.fillStyle = grad;
|
|
910
|
-
ctx.fillRect(node.x - r * 3, node.y - r * 3, r * 6, r * 6);
|
|
1081
|
+
const links = [];
|
|
1082
|
+
const edgeMap = {}; // pairKey → link object (dedup, direct wins over relay)
|
|
1083
|
+
function addEdge(a, b, type) {
|
|
1084
|
+
const pairKey = [a, b].sort().join('::');
|
|
1085
|
+
const existing = edgeMap[pairKey];
|
|
1086
|
+
// direct > relay > satellite: keep the stronger type
|
|
1087
|
+
if (existing) {
|
|
1088
|
+
if (existing.type === 'direct') return; // already best
|
|
1089
|
+
if (type === 'direct') { existing.type = 'direct'; return; }
|
|
1090
|
+
return; // keep first
|
|
911
1091
|
}
|
|
1092
|
+
getLinkColorRgb(a, b); // pre-register color for this pair
|
|
1093
|
+
const link = { source: a, target: b, type: type };
|
|
1094
|
+
edgeMap[pairKey] = link;
|
|
1095
|
+
links.push(link);
|
|
1096
|
+
}
|
|
912
1097
|
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
ctx.fillStyle = isHovered || isSelected ? color : adjustAlpha(color, 0.8);
|
|
922
|
-
ctx.fill();
|
|
923
|
-
|
|
924
|
-
// Border
|
|
925
|
-
ctx.strokeStyle = isSelected ? '#fff' : isHovered ? adjustAlpha(color, 1) : adjustAlpha(color, 0.3);
|
|
926
|
-
ctx.lineWidth = isSelected ? 2.5 : isHovered ? 2 : 1.5;
|
|
927
|
-
ctx.stroke();
|
|
928
|
-
|
|
929
|
-
// Inner ring for self
|
|
930
|
-
if (node.type === 'self') {
|
|
931
|
-
ctx.beginPath();
|
|
932
|
-
ctx.arc(node.x, node.y, r - 6, 0, Math.PI * 2);
|
|
933
|
-
ctx.strokeStyle = 'rgba(255,255,255,0.3)';
|
|
934
|
-
ctx.lineWidth = 1;
|
|
935
|
-
ctx.stroke();
|
|
1098
|
+
for (const p of state.peers) {
|
|
1099
|
+
if (p.connection === 'satellite') {
|
|
1100
|
+
addEdge(state.nodeId, p.nodeId, 'satellite');
|
|
1101
|
+
} else if (p.connection === 'direct') {
|
|
1102
|
+
addEdge(state.nodeId, p.nodeId, 'direct');
|
|
1103
|
+
} else if (p.reachableVia) {
|
|
1104
|
+
addEdge(p.reachableVia, p.nodeId, 'relay');
|
|
1105
|
+
addEdge(state.nodeId, p.reachableVia, 'direct');
|
|
936
1106
|
}
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
// Capability counts below label
|
|
946
|
-
const data = node.data;
|
|
947
|
-
if (data) {
|
|
948
|
-
const parts = [];
|
|
949
|
-
const agents = data.agents?.length || 0;
|
|
950
|
-
const models = data.models?.length || 0;
|
|
951
|
-
if (models) parts.push(models + 'M');
|
|
952
|
-
if (agents) parts.push(agents + 'A');
|
|
953
|
-
if (parts.length) {
|
|
954
|
-
ctx.font = '10px -apple-system, system-ui, sans-serif';
|
|
955
|
-
ctx.fillStyle = 'rgba(255,255,255,0.4)';
|
|
956
|
-
ctx.fillText(parts.join(' · '), node.x, node.y + r + 30);
|
|
1107
|
+
}
|
|
1108
|
+
const nodeIds = new Set(nodes.map(n => n.id));
|
|
1109
|
+
for (const p of state.peers) {
|
|
1110
|
+
if (p.directPeers) {
|
|
1111
|
+
for (const dp of p.directPeers) {
|
|
1112
|
+
if (dp !== state.nodeId && nodeIds.has(dp)) {
|
|
1113
|
+
addEdge(p.nodeId, dp, 'direct');
|
|
1114
|
+
}
|
|
957
1115
|
}
|
|
958
1116
|
}
|
|
959
1117
|
}
|
|
960
|
-
}
|
|
961
1118
|
|
|
962
|
-
|
|
963
|
-
const r = parseInt(hex.slice(1, 3), 16);
|
|
964
|
-
const g = parseInt(hex.slice(3, 5), 16);
|
|
965
|
-
const b = parseInt(hex.slice(5, 7), 16);
|
|
966
|
-
return 'rgba(' + r + ',' + g + ',' + b + ',' + alpha + ')';
|
|
1119
|
+
return { nodes, links };
|
|
967
1120
|
}
|
|
968
1121
|
|
|
969
|
-
function
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
1122
|
+
function linkKey(l) {
|
|
1123
|
+
const s = typeof l.source === 'object' ? l.source.id : l.source;
|
|
1124
|
+
const t = typeof l.target === 'object' ? l.target.id : l.target;
|
|
1125
|
+
return [s, t].sort().join('::');
|
|
973
1126
|
}
|
|
974
1127
|
|
|
975
|
-
function
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
1128
|
+
function updateMeshData() {
|
|
1129
|
+
if (!graph) return;
|
|
1130
|
+
const { nodes: newNodes, links: newLinks } = buildGraphInputs();
|
|
1131
|
+
const { nodes: curNodes, links: curLinks } = graph.graphData();
|
|
1132
|
+
|
|
1133
|
+
// Check if topology actually changed
|
|
1134
|
+
const curNodeIds = new Set(curNodes.map(n => n.id));
|
|
1135
|
+
const newNodeIds = new Set(newNodes.map(n => n.id));
|
|
1136
|
+
const curLinkKeys = new Set(curLinks.map(linkKey));
|
|
1137
|
+
const newLinkKeys = new Set(newLinks.map(linkKey));
|
|
1138
|
+
|
|
1139
|
+
const nodesChanged = newNodeIds.size !== curNodeIds.size || [...newNodeIds].some(id => !curNodeIds.has(id));
|
|
1140
|
+
const linksChanged = newLinkKeys.size !== curLinkKeys.size || [...newLinkKeys].some(k => !curLinkKeys.has(k));
|
|
1141
|
+
|
|
1142
|
+
// Always update node data (type, capabilities) in place
|
|
1143
|
+
const curMap = {};
|
|
1144
|
+
for (const n of curNodes) curMap[n.id] = n;
|
|
1145
|
+
for (const nn of newNodes) {
|
|
1146
|
+
const cur = curMap[nn.id];
|
|
1147
|
+
if (cur) {
|
|
1148
|
+
cur.type = nn.type;
|
|
1149
|
+
cur.data = nn.data;
|
|
1150
|
+
}
|
|
982
1151
|
}
|
|
983
|
-
return null;
|
|
984
|
-
}
|
|
985
1152
|
|
|
986
|
-
|
|
987
|
-
const rect = canvas.getBoundingClientRect();
|
|
988
|
-
return { x: e.clientX - rect.left, y: e.clientY - rect.top };
|
|
989
|
-
}
|
|
1153
|
+
if (!nodesChanged && !linksChanged) return; // topology unchanged, skip
|
|
990
1154
|
|
|
991
|
-
|
|
992
|
-
const
|
|
993
|
-
const
|
|
994
|
-
|
|
995
|
-
dragNode = hit;
|
|
996
|
-
dragOffset.x = x - hit.x;
|
|
997
|
-
dragOffset.y = y - hit.y;
|
|
998
|
-
hit._pinned = true;
|
|
999
|
-
canvas.style.cursor = 'grabbing';
|
|
1000
|
-
// Also select on click
|
|
1001
|
-
selectedNode = hit.id;
|
|
1002
|
-
updateDetail(hit.id);
|
|
1155
|
+
// Topology changed — preserve positions of existing nodes
|
|
1156
|
+
const posMap = {};
|
|
1157
|
+
for (const n of curNodes) {
|
|
1158
|
+
posMap[n.id] = { x: n.x, y: n.y, vx: n.vx, vy: n.vy, fx: n.fx, fy: n.fy };
|
|
1003
1159
|
}
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
dragNode.y = y - dragOffset.y;
|
|
1011
|
-
canvas.style.cursor = 'grabbing';
|
|
1012
|
-
return;
|
|
1013
|
-
}
|
|
1014
|
-
const hit = hitTest(x, y);
|
|
1015
|
-
hoveredNode = hit ? hit.id : null;
|
|
1016
|
-
canvas.style.cursor = hit ? 'grab' : 'default';
|
|
1017
|
-
}
|
|
1018
|
-
|
|
1019
|
-
function onCanvasMouseUp(e) {
|
|
1020
|
-
if (dragNode) {
|
|
1021
|
-
// Keep self pinned to where it was dropped; release others after a delay
|
|
1022
|
-
if (dragNode.type !== 'self') {
|
|
1023
|
-
const node = dragNode;
|
|
1024
|
-
setTimeout(() => { node._pinned = false; }, 2000);
|
|
1025
|
-
}
|
|
1026
|
-
dragNode = null;
|
|
1027
|
-
canvas.style.cursor = 'default';
|
|
1028
|
-
} else {
|
|
1029
|
-
const { x, y } = getCanvasPos(e);
|
|
1030
|
-
const hit = hitTest(x, y);
|
|
1031
|
-
if (!hit) {
|
|
1032
|
-
selectedNode = null;
|
|
1033
|
-
$('node-detail').classList.add('hidden');
|
|
1160
|
+
for (const n of newNodes) {
|
|
1161
|
+
const old = posMap[n.id];
|
|
1162
|
+
if (old) {
|
|
1163
|
+
n.x = old.x; n.y = old.y;
|
|
1164
|
+
n.vx = old.vx; n.vy = old.vy;
|
|
1165
|
+
n.fx = old.fx; n.fy = old.fy;
|
|
1034
1166
|
}
|
|
1035
1167
|
}
|
|
1168
|
+
|
|
1169
|
+
graph.graphData({ nodes: newNodes, links: newLinks });
|
|
1036
1170
|
}
|
|
1037
1171
|
|
|
1038
1172
|
// ── Node detail panel ──
|
|
@@ -1043,14 +1177,29 @@ const JS = `
|
|
|
1043
1177
|
$('node-detail').classList.add('hidden');
|
|
1044
1178
|
return;
|
|
1045
1179
|
}
|
|
1180
|
+
const isSat = nodeData.connection === 'satellite';
|
|
1046
1181
|
|
|
1047
1182
|
$('node-detail').classList.remove('hidden');
|
|
1048
1183
|
$('detail-title').textContent = nodeId;
|
|
1049
1184
|
|
|
1185
|
+
// Status dot color
|
|
1186
|
+
const dot = $('detail-status-dot');
|
|
1187
|
+
const dotColor = isLocal ? 'var(--accent)' : isSat ? '#f472b6' : nodeData.online ? 'var(--green)' : 'var(--red)';
|
|
1188
|
+
dot.style.background = dotColor;
|
|
1189
|
+
if (isLocal || isSat || nodeData.online) dot.style.boxShadow = '0 0 6px ' + dotColor;
|
|
1190
|
+
else dot.style.boxShadow = 'none';
|
|
1191
|
+
|
|
1050
1192
|
const statusBadge = $('detail-status');
|
|
1193
|
+
statusBadge.style.background = '';
|
|
1194
|
+
statusBadge.style.color = '';
|
|
1051
1195
|
if (isLocal) {
|
|
1052
1196
|
statusBadge.textContent = 'Self';
|
|
1053
1197
|
statusBadge.className = 'badge badge-self';
|
|
1198
|
+
} else if (isSat) {
|
|
1199
|
+
statusBadge.textContent = 'Satellite';
|
|
1200
|
+
statusBadge.style.background = 'rgba(244,114,182,0.15)';
|
|
1201
|
+
statusBadge.style.color = '#f472b6';
|
|
1202
|
+
statusBadge.className = 'badge';
|
|
1054
1203
|
} else if (nodeData.online) {
|
|
1055
1204
|
statusBadge.textContent = nodeData.connection === 'direct' ? 'Direct' : 'Relay';
|
|
1056
1205
|
statusBadge.className = 'badge ' + (nodeData.connection === 'direct' ? 'badge-online' : 'badge-relay');
|
|
@@ -1061,6 +1210,57 @@ const JS = `
|
|
|
1061
1210
|
|
|
1062
1211
|
let html = '';
|
|
1063
1212
|
|
|
1213
|
+
// Satellite node detail
|
|
1214
|
+
if (isSat) {
|
|
1215
|
+
html += '<div class="detail-section">';
|
|
1216
|
+
html += '<div class="detail-label">Network</div>';
|
|
1217
|
+
html += '<div class="detail-grid">';
|
|
1218
|
+
if (nodeData.cellular) {
|
|
1219
|
+
html += '<span class="detail-key">Type</span><span>Cellular</span>';
|
|
1220
|
+
} else if (nodeData.ssid) {
|
|
1221
|
+
html += '<span class="detail-key">WiFi</span><span>' + esc(nodeData.ssid) + '</span>';
|
|
1222
|
+
}
|
|
1223
|
+
if (nodeData.ip) html += '<span class="detail-key">IP</span><span>' + esc(nodeData.ip) + '</span>';
|
|
1224
|
+
if (nodeData.router) html += '<span class="detail-key">Router</span><span>' + esc(nodeData.router) + '</span>';
|
|
1225
|
+
if (nodeData.country) html += '<span class="detail-key">Country</span><span>' + esc(nodeData.country) + '</span>';
|
|
1226
|
+
if (nodeData.location) html += '<span class="detail-key">Location</span><span>' + esc(nodeData.location) + '</span>';
|
|
1227
|
+
if (nodeData.platform) html += '<span class="detail-key">Platform</span><span>' + esc(nodeData.platform) + '</span>';
|
|
1228
|
+
if (typeof nodeData.battery === 'number') {
|
|
1229
|
+
const bat = nodeData.battery + '%' + (nodeData.charging ? ' (charging)' : '');
|
|
1230
|
+
html += '<span class="detail-key">Battery</span><span>' + esc(bat) + '</span>';
|
|
1231
|
+
}
|
|
1232
|
+
if (nodeData.lastSeen) html += '<span class="detail-key">Last seen</span><span>' + new Date(nodeData.lastSeen).toLocaleTimeString() + '</span>';
|
|
1233
|
+
html += '</div></div>';
|
|
1234
|
+
const satTools = nodeData.toolProxy?.allow || [];
|
|
1235
|
+
if (satTools.length > 0) {
|
|
1236
|
+
html += '<div class="detail-section">';
|
|
1237
|
+
html += '<div class="detail-label">Tools (' + satTools.length + ')</div>';
|
|
1238
|
+
html += '<div style="display:flex;flex-wrap:wrap;gap:4px">';
|
|
1239
|
+
for (const t of satTools) {
|
|
1240
|
+
html += '<span class="badge" style="background:rgba(244,114,182,0.1);color:#f472b6;font-size:11px">' + esc(t) + '</span>';
|
|
1241
|
+
}
|
|
1242
|
+
html += '</div></div>';
|
|
1243
|
+
}
|
|
1244
|
+
$('detail-body').innerHTML = html;
|
|
1245
|
+
return;
|
|
1246
|
+
}
|
|
1247
|
+
|
|
1248
|
+
// Device info
|
|
1249
|
+
if (nodeData.deviceInfo) {
|
|
1250
|
+
const d = nodeData.deviceInfo;
|
|
1251
|
+
html += '<div class="detail-section">';
|
|
1252
|
+
html += '<div class="detail-label">System</div>';
|
|
1253
|
+
html += '<div class="detail-grid">';
|
|
1254
|
+
html += '<span class="detail-key">OS</span><span>' + esc(d.os) + '</span>';
|
|
1255
|
+
html += '<span class="detail-key">Arch</span><span>' + esc(d.arch) + '</span>';
|
|
1256
|
+
html += '<span class="detail-key">Host</span><span>' + esc(d.hostname) + '</span>';
|
|
1257
|
+
html += '<span class="detail-key">CPU</span><span>' + esc(d.cpuModel) + ' (' + d.cpuCores + ' cores)</span>';
|
|
1258
|
+
html += '<span class="detail-key">Memory</span><span>' + formatMemory(d.totalMemoryMB) + '</span>';
|
|
1259
|
+
if (d.openclawVersion && d.openclawVersion !== 'unknown') html += '<span class="detail-key">OpenClaw</span><span>' + esc(d.openclawVersion) + '</span>';
|
|
1260
|
+
html += '</div>';
|
|
1261
|
+
html += '</div>';
|
|
1262
|
+
}
|
|
1263
|
+
|
|
1064
1264
|
// Connection info
|
|
1065
1265
|
if (!isLocal && nodeData.connection === 'relay' && nodeData.reachableVia) {
|
|
1066
1266
|
html += '<div class="detail-section">';
|
|
@@ -1072,27 +1272,56 @@ const JS = `
|
|
|
1072
1272
|
// Models
|
|
1073
1273
|
if (nodeData.models?.length) {
|
|
1074
1274
|
html += '<div class="detail-section">';
|
|
1075
|
-
html += '<div class="detail-label">Models</div>';
|
|
1275
|
+
html += '<div class="detail-label collapsible" onclick="this.classList.toggle('expanded');this.nextElementSibling.classList.toggle('expanded')">Models (' + nodeData.models.length + ')</div>';
|
|
1276
|
+
html += '<div class="detail-items">';
|
|
1076
1277
|
for (const m of nodeData.models) {
|
|
1077
1278
|
html += '<div class="item-row"><span class="item-icon" style="background:var(--accent)"></span>';
|
|
1078
1279
|
html += '<span>' + esc(m.id) + '</span>';
|
|
1079
1280
|
if (m.description) html += '<span style="color:var(--text-dim);font-size:11px"> — ' + esc(m.description) + '</span>';
|
|
1080
1281
|
html += '</div>';
|
|
1081
1282
|
}
|
|
1082
|
-
html += '</div>';
|
|
1283
|
+
html += '</div></div>';
|
|
1083
1284
|
}
|
|
1084
1285
|
|
|
1085
1286
|
// Agents
|
|
1086
1287
|
if (nodeData.agents?.length) {
|
|
1087
1288
|
html += '<div class="detail-section">';
|
|
1088
|
-
html += '<div class="detail-label">Agents</div>';
|
|
1289
|
+
html += '<div class="detail-label collapsible" onclick="this.classList.toggle('expanded');this.nextElementSibling.classList.toggle('expanded')">Agents (' + nodeData.agents.length + ')</div>';
|
|
1290
|
+
html += '<div class="detail-items">';
|
|
1089
1291
|
for (const a of nodeData.agents) {
|
|
1090
1292
|
html += '<div class="item-row"><span class="item-icon" style="background:var(--green)"></span>';
|
|
1091
1293
|
html += '<span>' + esc(a.id) + '</span>';
|
|
1092
1294
|
if (a.description) html += '<span style="color:var(--text-dim);font-size:11px"> — ' + esc(a.description) + '</span>';
|
|
1093
1295
|
html += '</div>';
|
|
1094
1296
|
}
|
|
1095
|
-
html += '</div>';
|
|
1297
|
+
html += '</div></div>';
|
|
1298
|
+
}
|
|
1299
|
+
|
|
1300
|
+
// Cluster Tools
|
|
1301
|
+
if (nodeData.clusterTools?.length) {
|
|
1302
|
+
html += '<div class="detail-section">';
|
|
1303
|
+
html += '<div class="detail-label collapsible" onclick="this.classList.toggle('expanded');this.nextElementSibling.classList.toggle('expanded')">Cluster Tools (' + nodeData.clusterTools.length + ')</div>';
|
|
1304
|
+
html += '<div class="detail-items">';
|
|
1305
|
+
for (const t of nodeData.clusterTools) {
|
|
1306
|
+
html += '<div class="item-row"><span class="item-icon" style="background:var(--orange, #f59e0b)"></span>';
|
|
1307
|
+
html += '<span>' + esc(t) + '</span></div>';
|
|
1308
|
+
}
|
|
1309
|
+
html += '</div></div>';
|
|
1310
|
+
}
|
|
1311
|
+
|
|
1312
|
+
// Tool Proxy
|
|
1313
|
+
if (nodeData.toolProxy) {
|
|
1314
|
+
html += '<div class="detail-section">';
|
|
1315
|
+
html += '<div class="detail-label">Tool Proxy</div>';
|
|
1316
|
+
html += '<div class="detail-grid">';
|
|
1317
|
+
html += '<span class="detail-key">Status</span><span>' + (nodeData.toolProxy.enabled ? '<span style="color:var(--green)">Enabled</span>' : '<span style="color:var(--text-dim)">Disabled</span>') + '</span>';
|
|
1318
|
+
if (nodeData.toolProxy.enabled && nodeData.toolProxy.allow?.length) {
|
|
1319
|
+
html += '<span class="detail-key">Allow</span><span>' + nodeData.toolProxy.allow.map(function(t) { return esc(t); }).join(', ') + '</span>';
|
|
1320
|
+
}
|
|
1321
|
+
if (nodeData.toolProxy.enabled && nodeData.toolProxy.deny?.length) {
|
|
1322
|
+
html += '<span class="detail-key">Deny</span><span>' + nodeData.toolProxy.deny.map(function(t) { return esc(t); }).join(', ') + '</span>';
|
|
1323
|
+
}
|
|
1324
|
+
html += '</div></div>';
|
|
1096
1325
|
}
|
|
1097
1326
|
|
|
1098
1327
|
// Tags
|
|
@@ -1116,6 +1345,51 @@ const JS = `
|
|
|
1116
1345
|
|
|
1117
1346
|
function esc(s) { return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"').replace(/'/g,'''); }
|
|
1118
1347
|
|
|
1348
|
+
function formatMemory(mb) {
|
|
1349
|
+
if (mb >= 1024) return (mb / 1024).toFixed(1) + ' GB';
|
|
1350
|
+
return mb + ' MB';
|
|
1351
|
+
}
|
|
1352
|
+
|
|
1353
|
+
// ── Chat mode ──
|
|
1354
|
+
function setChatMode(mode, nodeId) {
|
|
1355
|
+
if (chatMode === mode && handoffNodeId === nodeId) return;
|
|
1356
|
+
chatMode = mode;
|
|
1357
|
+
handoffNodeId = nodeId;
|
|
1358
|
+
|
|
1359
|
+
const title = $('chat-title');
|
|
1360
|
+
const modelSel = $('chat-model');
|
|
1361
|
+
const agentSel = $('chat-agent');
|
|
1362
|
+
|
|
1363
|
+
if (mode === 'handoff' && nodeId) {
|
|
1364
|
+
title.textContent = 'Handoff \\u2192 ' + nodeId;
|
|
1365
|
+
modelSel.classList.add('hidden');
|
|
1366
|
+
agentSel.classList.remove('hidden');
|
|
1367
|
+
updateAgentSelect();
|
|
1368
|
+
} else {
|
|
1369
|
+
title.textContent = 'Chat';
|
|
1370
|
+
modelSel.classList.remove('hidden');
|
|
1371
|
+
agentSel.classList.add('hidden');
|
|
1372
|
+
}
|
|
1373
|
+
|
|
1374
|
+
// Clear conversation when switching modes
|
|
1375
|
+
chatMessages = [];
|
|
1376
|
+
renderChatMessages();
|
|
1377
|
+
}
|
|
1378
|
+
|
|
1379
|
+
function updateAgentSelect() {
|
|
1380
|
+
const sel = $('chat-agent');
|
|
1381
|
+
const node = state.peers.find(p => p.nodeId === handoffNodeId);
|
|
1382
|
+
sel.innerHTML = '<option value="">Select agent...</option>';
|
|
1383
|
+
if (node?.agents) {
|
|
1384
|
+
for (const a of node.agents) {
|
|
1385
|
+
const opt = document.createElement('option');
|
|
1386
|
+
opt.value = a.id;
|
|
1387
|
+
opt.textContent = a.id + (a.description ? ' \\u2014 ' + a.description : '');
|
|
1388
|
+
sel.appendChild(opt);
|
|
1389
|
+
}
|
|
1390
|
+
}
|
|
1391
|
+
}
|
|
1392
|
+
|
|
1119
1393
|
// ── Model select ──
|
|
1120
1394
|
function updateModelSelect() {
|
|
1121
1395
|
const sel = $('chat-model');
|
|
@@ -1169,23 +1443,27 @@ const JS = `
|
|
|
1169
1443
|
e.preventDefault();
|
|
1170
1444
|
if (chatStreaming) return;
|
|
1171
1445
|
|
|
1172
|
-
const modelVal = $('chat-model').value;
|
|
1173
|
-
if (!modelVal) { alert('Please select a model'); return; }
|
|
1174
|
-
|
|
1175
1446
|
const text = chatInput.value.trim();
|
|
1176
1447
|
if (!text) return;
|
|
1177
1448
|
|
|
1449
|
+
if (chatMode === 'handoff') {
|
|
1450
|
+
await submitHandoff(text);
|
|
1451
|
+
} else {
|
|
1452
|
+
await submitChat(text);
|
|
1453
|
+
}
|
|
1454
|
+
});
|
|
1455
|
+
|
|
1456
|
+
async function submitChat(text) {
|
|
1457
|
+
const modelVal = $('chat-model').value;
|
|
1458
|
+
if (!modelVal) { alert('Please select a model'); return; }
|
|
1459
|
+
|
|
1178
1460
|
chatInput.value = '';
|
|
1179
1461
|
chatInput.style.height = 'auto';
|
|
1180
1462
|
|
|
1181
1463
|
const [nodeId, ...modelParts] = modelVal.split('/');
|
|
1182
1464
|
const model = modelParts.join('/');
|
|
1183
1465
|
|
|
1184
|
-
// Add user message
|
|
1185
1466
|
chatMessages.push({ role: 'user', content: text });
|
|
1186
|
-
renderChatMessages();
|
|
1187
|
-
|
|
1188
|
-
// Add placeholder for assistant
|
|
1189
1467
|
chatMessages.push({ role: 'assistant', content: '' });
|
|
1190
1468
|
renderChatMessages();
|
|
1191
1469
|
|
|
@@ -1238,7 +1516,6 @@ const JS = `
|
|
|
1238
1516
|
}
|
|
1239
1517
|
}
|
|
1240
1518
|
|
|
1241
|
-
// Clean up empty assistant message
|
|
1242
1519
|
if (!chatMessages[chatMessages.length - 1].content) {
|
|
1243
1520
|
chatMessages[chatMessages.length - 1] = { role: 'error', content: 'No response received' };
|
|
1244
1521
|
}
|
|
@@ -1251,11 +1528,86 @@ const JS = `
|
|
|
1251
1528
|
$('chat-send').disabled = false;
|
|
1252
1529
|
chatInput.focus();
|
|
1253
1530
|
}
|
|
1254
|
-
}
|
|
1531
|
+
}
|
|
1532
|
+
|
|
1533
|
+
async function submitHandoff(text) {
|
|
1534
|
+
const agent = $('chat-agent').value;
|
|
1535
|
+
if (!agent) { alert('Please select an agent'); return; }
|
|
1536
|
+
|
|
1537
|
+
chatInput.value = '';
|
|
1538
|
+
chatInput.style.height = 'auto';
|
|
1539
|
+
|
|
1540
|
+
chatMessages.push({ role: 'user', content: text });
|
|
1541
|
+
chatMessages.push({ role: 'assistant', content: '' });
|
|
1542
|
+
renderChatMessages();
|
|
1543
|
+
|
|
1544
|
+
chatStreaming = true;
|
|
1545
|
+
$('chat-send').disabled = true;
|
|
1546
|
+
|
|
1547
|
+
try {
|
|
1548
|
+
const res = await fetch('/api/handoff', {
|
|
1549
|
+
method: 'POST',
|
|
1550
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1551
|
+
body: JSON.stringify({ nodeId: handoffNodeId, agent, task: text }),
|
|
1552
|
+
});
|
|
1553
|
+
|
|
1554
|
+
if (!res.ok) {
|
|
1555
|
+
const err = await res.text();
|
|
1556
|
+
chatMessages[chatMessages.length - 1] = { role: 'error', content: 'Error: ' + err };
|
|
1557
|
+
renderChatMessages();
|
|
1558
|
+
return;
|
|
1559
|
+
}
|
|
1560
|
+
|
|
1561
|
+
const reader = res.body.getReader();
|
|
1562
|
+
const decoder = new TextDecoder();
|
|
1563
|
+
let buffer = '';
|
|
1564
|
+
|
|
1565
|
+
while (true) {
|
|
1566
|
+
const { done, value } = await reader.read();
|
|
1567
|
+
if (done) break;
|
|
1568
|
+
|
|
1569
|
+
buffer += decoder.decode(value, { stream: true });
|
|
1570
|
+
const lines = buffer.split('\\n');
|
|
1571
|
+
buffer = lines.pop();
|
|
1572
|
+
|
|
1573
|
+
for (const line of lines) {
|
|
1574
|
+
if (!line.startsWith('data: ')) continue;
|
|
1575
|
+
const data = line.slice(6).trim();
|
|
1576
|
+
|
|
1577
|
+
try {
|
|
1578
|
+
const parsed = JSON.parse(data);
|
|
1579
|
+
if (parsed.type === 'delta') {
|
|
1580
|
+
chatMessages[chatMessages.length - 1].content += parsed.content;
|
|
1581
|
+
renderChatMessages();
|
|
1582
|
+
} else if (parsed.type === 'error') {
|
|
1583
|
+
chatMessages[chatMessages.length - 1] = { role: 'error', content: 'Error: ' + parsed.error };
|
|
1584
|
+
renderChatMessages();
|
|
1585
|
+
}
|
|
1586
|
+
// type === 'done' — stream already accumulated the content
|
|
1587
|
+
} catch {}
|
|
1588
|
+
}
|
|
1589
|
+
}
|
|
1590
|
+
|
|
1591
|
+
if (!chatMessages[chatMessages.length - 1].content) {
|
|
1592
|
+
chatMessages[chatMessages.length - 1] = { role: 'error', content: 'No response received' };
|
|
1593
|
+
}
|
|
1594
|
+
renderChatMessages();
|
|
1595
|
+
} catch (err) {
|
|
1596
|
+
chatMessages[chatMessages.length - 1] = { role: 'error', content: 'Error: ' + err.message };
|
|
1597
|
+
renderChatMessages();
|
|
1598
|
+
} finally {
|
|
1599
|
+
chatStreaming = false;
|
|
1600
|
+
$('chat-send').disabled = false;
|
|
1601
|
+
chatInput.focus();
|
|
1602
|
+
}
|
|
1603
|
+
}
|
|
1255
1604
|
|
|
1256
1605
|
function renderChatMessages() {
|
|
1257
1606
|
if (chatMessages.length === 0) {
|
|
1258
|
-
|
|
1607
|
+
const hint = chatMode === 'handoff'
|
|
1608
|
+
? 'Select an agent and describe your task.'
|
|
1609
|
+
: 'Select a model and start chatting with your cluster.';
|
|
1610
|
+
chatMsgs.innerHTML = '<div class="chat-empty">' + hint + '</div>';
|
|
1259
1611
|
return;
|
|
1260
1612
|
}
|
|
1261
1613
|
|