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/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 + Details -->
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
- <canvas id="mesh-canvas"></canvas>
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
- min-height: 300px;
375
+ border-radius: 0;
376
+ border: none;
369
377
  }
370
378
 
371
- #mesh-canvas {
379
+ #mesh-container {
372
380
  flex: 1;
373
381
  width: 100%;
374
- cursor: default;
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
- /* Node detail */
378
- .detail-card { flex-shrink: 0; }
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 18px;
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
- height: 100%;
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 meshNodes = [];
586
- let meshEdges = [];
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 DPR = window.devicePixelRatio || 1;
703
- let W = 0, H = 0;
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
- function initMesh() {
706
- resizeCanvas();
707
- window.addEventListener('resize', resizeCanvas);
708
- canvas.addEventListener('mousedown', onCanvasMouseDown);
709
- canvas.addEventListener('mousemove', onCanvasMouseMove);
710
- canvas.addEventListener('mouseup', onCanvasMouseUp);
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
- function resizeCanvas() {
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 updateMeshData() {
728
- const allNodes = [
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
- // Preserve positions for existing nodes
738
- const oldPositions = {};
739
- for (const n of meshNodes) oldPositions[n.id] = { x: n.x, y: n.y, vx: n.vx, vy: n.vy };
740
-
741
- meshNodes = allNodes.map((n, i) => {
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
- // Build edges
757
- meshEdges = [];
758
- for (const p of state.peers) {
759
- if (p.connection === 'direct') {
760
- meshEdges.push({ from: state.nodeId, to: p.nodeId, type: 'direct' });
761
- } else if (p.reachableVia) {
762
- meshEdges.push({ from: p.reachableVia, to: p.nodeId, type: 'relay' });
763
- // Also ensure edge from self to relay node
764
- if (!meshEdges.find(e => (e.from === state.nodeId && e.to === p.reachableVia) || (e.to === state.nodeId && e.from === p.reachableVia))) {
765
- meshEdges.push({ from: state.nodeId, to: p.reachableVia, type: 'direct' });
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
- layoutNodes();
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
- function layoutNodes() {
774
- // Pin self to center only if not manually dragged
775
- const self = meshNodes.find(n => n.type === 'self');
776
- if (self && !self._pinned) { self.x = W / 2; self.y = H / 2; }
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
- // Force simulation step
780
- function simulateForces() {
781
- const k = 0.005; // spring constant
782
- const repulsion = 8000;
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
- // Spring to connected nodes
805
- for (const edge of meshEdges) {
806
- let other = null;
807
- if (edge.from === a.id) other = meshNodes.find(n => n.id === edge.to);
808
- else if (edge.to === a.id) other = meshNodes.find(n => n.id === edge.from);
809
- if (!other) continue;
810
-
811
- const dx = other.x - a.x;
812
- const dy = other.y - a.y;
813
- const d = Math.sqrt(dx * dx + dy * dy);
814
- const desired = 150;
815
- const f = k * (d - desired);
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
- // Gravity toward center
821
- fx += (center.x - a.x) * 0.001;
822
- fy += (center.y - a.y) * 0.001;
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
- a.vx = (a.vx + fx) * damping;
825
- a.vy = (a.vy + fy) * damping;
826
- a.x += a.vx;
827
- a.y += a.vy;
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
- // Bounds
830
- const pad = 60;
831
- a.x = Math.max(pad, Math.min(W - pad, a.x));
832
- a.y = Math.max(pad, Math.min(H - pad, a.y));
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
- let particleTime = 0;
837
-
838
- function renderMesh() {
839
- ctx.clearRect(0, 0, W, H);
840
-
841
- // Grid background
842
- ctx.strokeStyle = 'rgba(30, 32, 48, 0.5)';
843
- ctx.lineWidth = 0.5;
844
- const gridSize = 40;
845
- for (let x = 0; x < W; x += gridSize) {
846
- ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, H); ctx.stroke();
847
- }
848
- for (let y = 0; y < H; y += gridSize) {
849
- ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(W, y); ctx.stroke();
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
- particleTime += 0.015;
853
-
854
- // Draw edges
855
- for (const edge of meshEdges) {
856
- const a = meshNodes.find(n => n.id === edge.from);
857
- const b = meshNodes.find(n => n.id === edge.to);
858
- if (!a || !b) continue;
859
-
860
- const isDirect = edge.type === 'direct';
861
- ctx.beginPath();
862
- ctx.moveTo(a.x, a.y);
863
- ctx.lineTo(b.x, b.y);
864
-
865
- if (isDirect) {
866
- ctx.strokeStyle = 'rgba(99, 102, 241, 0.3)';
867
- ctx.lineWidth = 2;
868
- ctx.setLineDash([]);
869
- } else {
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
- // Draw nodes
896
- for (const node of meshNodes) {
897
- const isHovered = hoveredNode === node.id;
898
- const isSelected = selectedNode === node.id;
899
- const r = node.type === 'self' ? 28 : 22;
900
-
901
- // Glow
902
- if (node.type !== 'offline') {
903
- const glowColor = node.type === 'self' ? 'rgba(129,140,248,0.15)' :
904
- node.type === 'direct' ? 'rgba(52,211,153,0.12)' :
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
- // Node circle
914
- ctx.beginPath();
915
- ctx.arc(node.x, node.y, r, 0, Math.PI * 2);
916
-
917
- const color = node.type === 'self' ? '#818cf8' :
918
- node.type === 'direct' ? '#34d399' :
919
- node.type === 'relay' ? '#fbbf24' : '#555d75';
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
- // Label
939
- ctx.textAlign = 'center';
940
- ctx.textBaseline = 'middle';
941
- ctx.font = (node.type === 'self' ? '600 12px' : '500 11px') + ' -apple-system, system-ui, sans-serif';
942
- ctx.fillStyle = '#fff';
943
- ctx.fillText(node.id, node.x, node.y + r + 16);
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
- function adjustAlpha(hex, alpha) {
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 renderLoop() {
970
- simulateForces();
971
- renderMesh();
972
- animFrame = requestAnimationFrame(renderLoop);
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 hitTest(mx, my) {
976
- for (let i = meshNodes.length - 1; i >= 0; i--) {
977
- const n = meshNodes[i];
978
- const r = n.type === 'self' ? 28 : 22;
979
- const dx = mx - n.x;
980
- const dy = my - n.y;
981
- if (dx * dx + dy * dy <= (r + 8) * (r + 8)) return n;
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
- function getCanvasPos(e) {
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
- function onCanvasMouseDown(e) {
992
- const { x, y } = getCanvasPos(e);
993
- const hit = hitTest(x, y);
994
- if (hit) {
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
- function onCanvasMouseMove(e) {
1007
- const { x, y } = getCanvasPos(e);
1008
- if (dragNode) {
1009
- dragNode.x = x - dragOffset.x;
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(&#39;expanded&#39;);this.nextElementSibling.classList.toggle(&#39;expanded&#39;)">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(&#39;expanded&#39;);this.nextElementSibling.classList.toggle(&#39;expanded&#39;)">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(&#39;expanded&#39;);this.nextElementSibling.classList.toggle(&#39;expanded&#39;)">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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;').replace(/'/g,'&#39;'); }
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
- chatMsgs.innerHTML = '<div class="chat-empty">Select a model and start chatting with your cluster.</div>';
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