clawmatrix 0.2.11 → 0.3.1

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 DELETED
@@ -1,1622 +0,0 @@
1
- /** Returns the full HTML for the ClawMatrix dashboard SPA. */
2
- export function renderDashboard(nodeId: string): string {
3
- return `<!DOCTYPE html>
4
- <html lang="en">
5
- <head>
6
- <meta charset="utf-8">
7
- <meta name="viewport" content="width=device-width, initial-scale=1">
8
- <title>ClawMatrix — ${esc(nodeId)}</title>
9
- <style>
10
- ${CSS}
11
- </style>
12
- <script src="https://cdn.jsdelivr.net/npm/force-graph@1/dist/force-graph.min.js"></script>
13
- </head>
14
- <body>
15
-
16
- <!-- Login -->
17
- <div id="login-view" class="login-view">
18
- <div class="login-card">
19
- <div class="login-logo">
20
- <svg width="48" height="48" viewBox="0 0 48 48" fill="none">
21
- <circle cx="24" cy="24" r="6" fill="#818cf8"/>
22
- <circle cx="10" cy="12" r="4" fill="#6366f1" opacity=".7"/>
23
- <circle cx="38" cy="12" r="4" fill="#6366f1" opacity=".7"/>
24
- <circle cx="10" cy="36" r="4" fill="#6366f1" opacity=".7"/>
25
- <circle cx="38" cy="36" r="4" fill="#6366f1" opacity=".7"/>
26
- <line x1="24" y1="24" x2="10" y2="12" stroke="#6366f1" stroke-width="1.5" opacity=".5"/>
27
- <line x1="24" y1="24" x2="38" y2="12" stroke="#6366f1" stroke-width="1.5" opacity=".5"/>
28
- <line x1="24" y1="24" x2="10" y2="36" stroke="#6366f1" stroke-width="1.5" opacity=".5"/>
29
- <line x1="24" y1="24" x2="38" y2="36" stroke="#6366f1" stroke-width="1.5" opacity=".5"/>
30
- <line x1="10" y1="12" x2="38" y2="12" stroke="#6366f1" stroke-width="1" opacity=".3"/>
31
- <line x1="38" y1="12" x2="38" y2="36" stroke="#6366f1" stroke-width="1" opacity=".3"/>
32
- <line x1="38" y1="36" x2="10" y2="36" stroke="#6366f1" stroke-width="1" opacity=".3"/>
33
- <line x1="10" y1="36" x2="10" y2="12" stroke="#6366f1" stroke-width="1" opacity=".3"/>
34
- </svg>
35
- </div>
36
- <h1>ClawMatrix</h1>
37
- <p class="login-subtitle">Mesh Cluster Dashboard</p>
38
- <form id="login-form" autocomplete="off">
39
- <input type="password" id="login-token" placeholder="Access Token" autocomplete="current-password" required>
40
- <button type="submit">Connect</button>
41
- <p id="login-error" class="login-error"></p>
42
- </form>
43
- </div>
44
- </div>
45
-
46
- <!-- Dashboard -->
47
- <div id="dashboard" class="dashboard hidden">
48
- <!-- Header -->
49
- <header class="header">
50
- <div class="header-left">
51
- <svg width="28" height="28" viewBox="0 0 48 48" fill="none" class="header-logo">
52
- <circle cx="24" cy="24" r="6" fill="#818cf8"/>
53
- <circle cx="10" cy="12" r="4" fill="#6366f1" opacity=".7"/>
54
- <circle cx="38" cy="12" r="4" fill="#6366f1" opacity=".7"/>
55
- <circle cx="10" cy="36" r="4" fill="#6366f1" opacity=".7"/>
56
- <circle cx="38" cy="36" r="4" fill="#6366f1" opacity=".7"/>
57
- <line x1="24" y1="24" x2="10" y2="12" stroke="#6366f1" stroke-width="1.5" opacity=".5"/>
58
- <line x1="24" y1="24" x2="38" y2="12" stroke="#6366f1" stroke-width="1.5" opacity=".5"/>
59
- <line x1="24" y1="24" x2="10" y2="36" stroke="#6366f1" stroke-width="1.5" opacity=".5"/>
60
- <line x1="24" y1="24" x2="38" y2="36" stroke="#6366f1" stroke-width="1.5" opacity=".5"/>
61
- </svg>
62
- <span class="header-title">ClawMatrix</span>
63
- <span id="header-node" class="header-node"></span>
64
- </div>
65
- <div class="header-right">
66
- <span id="header-status" class="status-dot offline"></span>
67
- <span id="header-uptime" class="header-uptime"></span>
68
- <button id="btn-logout" class="btn-ghost" title="Logout">
69
- <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><polyline points="16 17 21 12 16 7"/><line x1="21" y1="12" x2="9" y2="12"/></svg>
70
- </button>
71
- </div>
72
- </header>
73
-
74
- <!-- Main content -->
75
- <div class="main">
76
- <!-- Left: Mesh -->
77
- <div class="panel-left">
78
- <div class="card mesh-card">
79
- <div class="card-header">
80
- <h2>Mesh Topology</h2>
81
- <span id="peer-count" class="badge">0 nodes</span>
82
- </div>
83
- <div id="mesh-container"></div>
84
- </div>
85
- </div>
86
-
87
- <!-- Right: Detail + Chat -->
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>
102
- <div class="card chat-card">
103
- <div class="card-header">
104
- <h2 id="chat-title">Chat</h2>
105
- <div class="chat-selects">
106
- <select id="chat-model" title="Model">
107
- <option value="">Select model...</option>
108
- </select>
109
- <select id="chat-agent" title="Agent" class="hidden">
110
- <option value="">Select agent...</option>
111
- </select>
112
- </div>
113
- </div>
114
- <div id="chat-messages" class="chat-messages">
115
- <div class="chat-empty">Select a model and start chatting with your cluster.</div>
116
- </div>
117
- <form id="chat-form" class="chat-input-row">
118
- <textarea id="chat-input" placeholder="Type a message..." rows="1"></textarea>
119
- <button type="submit" id="chat-send" title="Send">
120
- <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/></svg>
121
- </button>
122
- </form>
123
- </div>
124
- </div>
125
- </div>
126
- </div>
127
-
128
- <script>
129
- ${JS}
130
- </script>
131
- </body>
132
- </html>`;
133
- }
134
-
135
- function esc(s: string): string {
136
- return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#39;");
137
- }
138
-
139
- // ── CSS ─────────────────────────────────────────────────────────────
140
- const CSS = `
141
- :root {
142
- --bg: #08090d;
143
- --bg-card: #0f1117;
144
- --bg-card-hover: #161822;
145
- --border: #1e2030;
146
- --border-subtle: #151725;
147
- --text: #e2e8f0;
148
- --text-secondary: #8b92a8;
149
- --text-dim: #555d75;
150
- --accent: #818cf8;
151
- --accent-dim: #6366f1;
152
- --accent-glow: rgba(99, 102, 241, 0.15);
153
- --green: #34d399;
154
- --green-dim: rgba(52, 211, 153, 0.15);
155
- --orange: #fbbf24;
156
- --orange-dim: rgba(251, 191, 36, 0.15);
157
- --red: #f87171;
158
- --red-dim: rgba(248, 113, 113, 0.15);
159
- --radius: 10px;
160
- --radius-sm: 6px;
161
- }
162
-
163
- *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
164
-
165
- body {
166
- font-family: -apple-system, BlinkMacSystemFont, 'SF Pro', 'Segoe UI', system-ui, sans-serif;
167
- background: var(--bg);
168
- color: var(--text);
169
- overflow: hidden;
170
- height: 100vh;
171
- }
172
-
173
- .hidden { display: none !important; }
174
-
175
- /* Login */
176
- .login-view {
177
- height: 100vh;
178
- display: flex;
179
- align-items: center;
180
- justify-content: center;
181
- background: var(--bg);
182
- background-image:
183
- radial-gradient(ellipse at 30% 20%, rgba(99,102,241,0.08) 0%, transparent 50%),
184
- radial-gradient(ellipse at 70% 80%, rgba(99,102,241,0.05) 0%, transparent 50%);
185
- }
186
-
187
- .login-card {
188
- background: var(--bg-card);
189
- border: 1px solid var(--border);
190
- border-radius: 16px;
191
- padding: 48px 40px 40px;
192
- width: 380px;
193
- text-align: center;
194
- box-shadow: 0 0 80px rgba(99,102,241,0.06);
195
- }
196
-
197
- .login-logo { margin-bottom: 20px; }
198
-
199
- .login-card h1 {
200
- font-size: 24px;
201
- font-weight: 700;
202
- letter-spacing: -0.5px;
203
- margin-bottom: 4px;
204
- }
205
-
206
- .login-subtitle {
207
- color: var(--text-secondary);
208
- font-size: 14px;
209
- margin-bottom: 32px;
210
- }
211
-
212
- .login-card input {
213
- width: 100%;
214
- padding: 12px 16px;
215
- background: var(--bg);
216
- border: 1px solid var(--border);
217
- border-radius: var(--radius-sm);
218
- color: var(--text);
219
- font-size: 14px;
220
- outline: none;
221
- transition: border-color 0.2s;
222
- }
223
-
224
- .login-card input:focus {
225
- border-color: var(--accent-dim);
226
- box-shadow: 0 0 0 3px var(--accent-glow);
227
- }
228
-
229
- .login-card button {
230
- width: 100%;
231
- padding: 12px;
232
- margin-top: 16px;
233
- background: var(--accent-dim);
234
- color: #fff;
235
- border: none;
236
- border-radius: var(--radius-sm);
237
- font-size: 14px;
238
- font-weight: 600;
239
- cursor: pointer;
240
- transition: background 0.2s, transform 0.1s;
241
- }
242
-
243
- .login-card button:hover { background: var(--accent); }
244
- .login-card button:active { transform: scale(0.98); }
245
-
246
- .login-error {
247
- color: var(--red);
248
- font-size: 13px;
249
- margin-top: 12px;
250
- min-height: 20px;
251
- }
252
-
253
- /* Header */
254
- .header {
255
- display: flex;
256
- align-items: center;
257
- justify-content: space-between;
258
- padding: 0 24px;
259
- height: 56px;
260
- border-bottom: 1px solid var(--border);
261
- background: var(--bg-card);
262
- flex-shrink: 0;
263
- }
264
-
265
- .header-left { display: flex; align-items: center; gap: 10px; }
266
- .header-title { font-weight: 700; font-size: 16px; letter-spacing: -0.3px; }
267
- .header-node {
268
- font-size: 13px;
269
- color: var(--text-secondary);
270
- padding: 2px 10px;
271
- background: var(--accent-glow);
272
- border-radius: 12px;
273
- border: 1px solid rgba(99,102,241,0.2);
274
- }
275
-
276
- .header-right { display: flex; align-items: center; gap: 14px; }
277
-
278
- .header-uptime {
279
- font-size: 12px;
280
- color: var(--text-dim);
281
- font-variant-numeric: tabular-nums;
282
- }
283
-
284
- .status-dot {
285
- width: 8px; height: 8px;
286
- border-radius: 50%;
287
- display: inline-block;
288
- }
289
- .status-dot.online { background: var(--green); box-shadow: 0 0 8px var(--green); }
290
- .status-dot.offline { background: var(--red); }
291
-
292
- .btn-ghost {
293
- background: transparent;
294
- border: 1px solid var(--border);
295
- color: var(--text-secondary);
296
- border-radius: var(--radius-sm);
297
- padding: 6px 8px;
298
- cursor: pointer;
299
- display: flex;
300
- align-items: center;
301
- transition: color 0.2s, border-color 0.2s;
302
- }
303
- .btn-ghost:hover { color: var(--text); border-color: var(--text-dim); }
304
-
305
- /* Dashboard layout */
306
- .dashboard {
307
- display: flex;
308
- flex-direction: column;
309
- height: 100vh;
310
- }
311
-
312
- .main {
313
- display: flex;
314
- flex: 1;
315
- min-height: 0;
316
- overflow: hidden;
317
- }
318
-
319
- .panel-left {
320
- flex: 1;
321
- display: flex;
322
- flex-direction: column;
323
- min-width: 0;
324
- }
325
-
326
- .panel-right {
327
- width: 420px;
328
- min-width: 320px;
329
- border-left: 1px solid var(--border);
330
- display: flex;
331
- flex-direction: column;
332
- }
333
-
334
- /* Cards */
335
- .card {
336
- background: var(--bg-card);
337
- border: 1px solid var(--border);
338
- border-radius: var(--radius);
339
- overflow: hidden;
340
- }
341
-
342
- .card-header {
343
- display: flex;
344
- align-items: center;
345
- justify-content: space-between;
346
- padding: 14px 18px;
347
- border-bottom: 1px solid var(--border-subtle);
348
- }
349
-
350
- .card-header h2 {
351
- font-size: 13px;
352
- font-weight: 600;
353
- text-transform: uppercase;
354
- letter-spacing: 0.5px;
355
- color: var(--text-secondary);
356
- }
357
-
358
- .badge {
359
- font-size: 11px;
360
- padding: 2px 10px;
361
- border-radius: 10px;
362
- font-weight: 600;
363
- }
364
-
365
- .badge-online { background: var(--green-dim); color: var(--green); }
366
- .badge-relay { background: var(--orange-dim); color: var(--orange); }
367
- .badge-offline { background: var(--red-dim); color: var(--red); }
368
- .badge-self { background: var(--accent-glow); color: var(--accent); }
369
-
370
- /* Mesh canvas */
371
- .mesh-card {
372
- flex: 1;
373
- display: flex;
374
- flex-direction: column;
375
- border-radius: 0;
376
- border: none;
377
- }
378
-
379
- #mesh-container {
380
- flex: 1;
381
- width: 100%;
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;
417
- }
418
-
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); }
447
-
448
- .detail-body {
449
- padding: 12px 16px;
450
- font-size: 13px;
451
- line-height: 1.7;
452
- overflow-y: auto;
453
- }
454
-
455
- .detail-body .detail-section {
456
- margin-bottom: 12px;
457
- }
458
-
459
- .detail-body .detail-label {
460
- color: var(--text-dim);
461
- font-size: 11px;
462
- text-transform: uppercase;
463
- letter-spacing: 0.5px;
464
- margin-bottom: 4px;
465
- }
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
-
495
- .detail-body .detail-tags {
496
- display: flex;
497
- flex-wrap: wrap;
498
- gap: 6px;
499
- }
500
-
501
- .detail-body .tag {
502
- font-size: 11px;
503
- padding: 2px 8px;
504
- border-radius: 4px;
505
- background: var(--bg);
506
- border: 1px solid var(--border);
507
- color: var(--text-secondary);
508
- }
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
-
522
- .detail-body .item-row {
523
- display: flex;
524
- align-items: center;
525
- gap: 8px;
526
- padding: 4px 0;
527
- }
528
-
529
- .detail-body .item-icon {
530
- width: 6px;
531
- height: 6px;
532
- border-radius: 50%;
533
- flex-shrink: 0;
534
- }
535
-
536
- /* Chat */
537
- .chat-card {
538
- display: flex;
539
- flex-direction: column;
540
- flex: 1 1 50%;
541
- min-height: 0;
542
- border-radius: 0;
543
- border: none;
544
- border-top: none;
545
- }
546
-
547
- .chat-selects {
548
- display: flex;
549
- gap: 8px;
550
- }
551
-
552
- .chat-selects select {
553
- background: var(--bg);
554
- border: 1px solid var(--border);
555
- color: var(--text);
556
- font-size: 12px;
557
- padding: 4px 8px;
558
- border-radius: var(--radius-sm);
559
- outline: none;
560
- cursor: pointer;
561
- max-width: 200px;
562
- }
563
-
564
- .chat-selects select:focus {
565
- border-color: var(--accent-dim);
566
- }
567
-
568
- .chat-messages {
569
- flex: 1;
570
- overflow-y: auto;
571
- padding: 16px 18px;
572
- display: flex;
573
- flex-direction: column;
574
- gap: 12px;
575
- }
576
-
577
- .chat-empty {
578
- color: var(--text-dim);
579
- font-size: 13px;
580
- text-align: center;
581
- margin: auto;
582
- padding: 40px 20px;
583
- }
584
-
585
- .chat-msg {
586
- max-width: 90%;
587
- padding: 10px 14px;
588
- border-radius: 12px;
589
- font-size: 13px;
590
- line-height: 1.6;
591
- word-break: break-word;
592
- white-space: pre-wrap;
593
- }
594
-
595
- .chat-msg.user {
596
- align-self: flex-end;
597
- background: var(--accent-dim);
598
- color: #fff;
599
- border-bottom-right-radius: 4px;
600
- }
601
-
602
- .chat-msg.assistant {
603
- align-self: flex-start;
604
- background: var(--bg);
605
- border: 1px solid var(--border);
606
- color: var(--text);
607
- border-bottom-left-radius: 4px;
608
- }
609
-
610
- .chat-msg.error {
611
- align-self: center;
612
- background: var(--red-dim);
613
- color: var(--red);
614
- font-size: 12px;
615
- }
616
-
617
- .chat-input-row {
618
- display: flex;
619
- align-items: flex-end;
620
- gap: 8px;
621
- padding: 12px 16px;
622
- border-top: 1px solid var(--border-subtle);
623
- background: var(--bg-card);
624
- }
625
-
626
- .chat-input-row textarea {
627
- flex: 1;
628
- background: var(--bg);
629
- border: 1px solid var(--border);
630
- color: var(--text);
631
- border-radius: var(--radius-sm);
632
- padding: 10px 14px;
633
- font-size: 13px;
634
- font-family: inherit;
635
- resize: none;
636
- outline: none;
637
- max-height: 120px;
638
- line-height: 1.5;
639
- transition: border-color 0.2s;
640
- }
641
-
642
- .chat-input-row textarea:focus {
643
- border-color: var(--accent-dim);
644
- box-shadow: 0 0 0 3px var(--accent-glow);
645
- }
646
-
647
- .chat-input-row button {
648
- width: 38px;
649
- height: 38px;
650
- border-radius: var(--radius-sm);
651
- background: var(--accent-dim);
652
- color: #fff;
653
- border: none;
654
- cursor: pointer;
655
- display: flex;
656
- align-items: center;
657
- justify-content: center;
658
- flex-shrink: 0;
659
- transition: background 0.2s, transform 0.1s;
660
- }
661
-
662
- .chat-input-row button:hover { background: var(--accent); }
663
- .chat-input-row button:active { transform: scale(0.95); }
664
- .chat-input-row button:disabled { opacity: 0.4; cursor: not-allowed; }
665
-
666
- /* Scrollbar */
667
- ::-webkit-scrollbar { width: 6px; }
668
- ::-webkit-scrollbar-track { background: transparent; }
669
- ::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; }
670
- ::-webkit-scrollbar-thumb:hover { background: var(--text-dim); }
671
-
672
- /* Responsive */
673
- @media (max-width: 860px) {
674
- .main { flex-direction: column; }
675
- .panel-right {
676
- width: 100%;
677
- border-left: none;
678
- border-top: 1px solid var(--border);
679
- height: 50vh;
680
- }
681
- .panel-left { overflow-y: auto; }
682
- }
683
- `;
684
-
685
- // ── JavaScript ──────────────────────────────────────────────────────
686
- const JS = `
687
- (function() {
688
- 'use strict';
689
-
690
- // ── State ──
691
- let state = { local: null, peers: [], nodeId: '', uptime: 0 };
692
- let selectedNode = null;
693
- let chatMessages = [];
694
- let chatStreaming = false;
695
- let chatMode = 'model'; // 'model' | 'handoff'
696
- let handoffNodeId = null;
697
- let hoveredNode = null;
698
- let pollTimer = null;
699
- let graph = null;
700
-
701
- // ── DOM refs ──
702
- const $ = (id) => document.getElementById(id);
703
- const loginView = $('login-view');
704
- const dashboard = $('dashboard');
705
- const loginForm = $('login-form');
706
- const loginToken = $('login-token');
707
- const loginError = $('login-error');
708
-
709
- // ── Auth ──
710
- loginForm.addEventListener('submit', async (e) => {
711
- e.preventDefault();
712
- loginError.textContent = '';
713
- try {
714
- const res = await fetch('/api/login', {
715
- method: 'POST',
716
- headers: { 'Content-Type': 'application/json' },
717
- body: JSON.stringify({ token: loginToken.value }),
718
- });
719
- if (!res.ok) {
720
- const data = await res.json();
721
- loginError.textContent = data.error || 'Login failed';
722
- return;
723
- }
724
- loginView.classList.add('hidden');
725
- dashboard.classList.remove('hidden');
726
- startDashboard();
727
- } catch (err) {
728
- loginError.textContent = 'Connection failed';
729
- }
730
- });
731
-
732
- $('btn-logout').addEventListener('click', async () => {
733
- await fetch('/api/logout', { method: 'POST' });
734
- chatMessages = [];
735
- stopDashboard();
736
- dashboard.classList.add('hidden');
737
- loginView.classList.remove('hidden');
738
- loginToken.value = '';
739
- });
740
-
741
- // ── Init check (try with existing cookie) ──
742
- (async () => {
743
- try {
744
- const res = await fetch('/api/status');
745
- if (res.ok) {
746
- loginView.classList.add('hidden');
747
- dashboard.classList.remove('hidden');
748
- const data = await res.json();
749
- updateState(data);
750
- startDashboard();
751
- }
752
- } catch {}
753
- })();
754
-
755
- // ── Dashboard lifecycle ──
756
- function startDashboard() {
757
- initMesh();
758
- pollStatus();
759
- pollTimer = setInterval(pollStatus, 3000);
760
- }
761
-
762
- function stopDashboard() {
763
- if (pollTimer) { clearInterval(pollTimer); pollTimer = null; }
764
- }
765
-
766
- async function pollStatus() {
767
- try {
768
- const res = await fetch('/api/status');
769
- if (res.status === 401) {
770
- stopDashboard();
771
- dashboard.classList.add('hidden');
772
- loginView.classList.remove('hidden');
773
- return;
774
- }
775
- if (res.ok) {
776
- updateState(await res.json());
777
- }
778
- } catch {}
779
- }
780
-
781
- function updateState(data) {
782
- state = data;
783
- $('header-node').textContent = state.nodeId;
784
- $('header-status').className = 'status-dot online';
785
- $('header-uptime').textContent = formatUptime(state.uptime);
786
-
787
- const totalNodes = 1 + state.peers.length;
788
- $('peer-count').textContent = totalNodes + (totalNodes === 1 ? ' node' : ' nodes');
789
- $('peer-count').className = 'badge badge-self';
790
-
791
- updateMeshData();
792
- updateModelSelect();
793
- if (selectedNode) updateDetail(selectedNode);
794
- }
795
-
796
- function formatUptime(s) {
797
- if (s < 60) return s + 's';
798
- if (s < 3600) return Math.floor(s / 60) + 'm ' + (s % 60) + 's';
799
- const h = Math.floor(s / 3600);
800
- const m = Math.floor((s % 3600) / 60);
801
- return h + 'h ' + m + 'm';
802
- }
803
-
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
- };
812
-
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
- };
819
-
820
- let frameCount = 0;
821
-
822
- function initMesh() {
823
- const container = $('mesh-container');
824
-
825
- // Close button for detail panel
826
- $('detail-close').addEventListener('click', () => {
827
- selectedNode = null;
828
- $('node-detail').classList.add('hidden');
829
- setChatMode('model', null);
830
- });
831
-
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);
857
- }
858
-
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
- }
867
-
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();
876
-
877
- // Border
878
- ctx.strokeStyle = isSelected ? '#fff' : isHovered ? color : color + '50';
879
- ctx.lineWidth = isSelected ? 2 : isHovered ? 1.5 : 1;
880
- ctx.stroke();
881
-
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
- }
894
-
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);
941
-
942
- // Glow layer
943
- ctx.strokeStyle = 'rgba(' + rgb + ',0.12)';
944
- ctx.lineWidth = isDirect ? 6 : 4;
945
- ctx.setLineDash([]);
946
- ctx.stroke();
947
-
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);
1030
- }
1031
-
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++;
1055
- }
1056
- const c = LINK_COLORS[linkColorMap[key]];
1057
- return c[0] + ',' + c[1] + ',' + c[2];
1058
- }
1059
-
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);
1078
- }
1079
- }
1080
-
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
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
- }
1097
-
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');
1106
- }
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
- }
1115
- }
1116
- }
1117
- }
1118
-
1119
- return { nodes, links };
1120
- }
1121
-
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('::');
1126
- }
1127
-
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
- }
1151
- }
1152
-
1153
- if (!nodesChanged && !linksChanged) return; // topology unchanged, skip
1154
-
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 };
1159
- }
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;
1166
- }
1167
- }
1168
-
1169
- graph.graphData({ nodes: newNodes, links: newLinks });
1170
- }
1171
-
1172
- // ── Node detail panel ──
1173
- function updateDetail(nodeId) {
1174
- const isLocal = nodeId === state.nodeId;
1175
- const nodeData = isLocal ? state.local : state.peers.find(p => p.nodeId === nodeId);
1176
- if (!nodeData) {
1177
- $('node-detail').classList.add('hidden');
1178
- return;
1179
- }
1180
- const isSat = nodeData.connection === 'satellite';
1181
-
1182
- $('node-detail').classList.remove('hidden');
1183
- $('detail-title').textContent = nodeId;
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
-
1192
- const statusBadge = $('detail-status');
1193
- statusBadge.style.background = '';
1194
- statusBadge.style.color = '';
1195
- if (isLocal) {
1196
- statusBadge.textContent = 'Self';
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';
1203
- } else if (nodeData.online) {
1204
- statusBadge.textContent = nodeData.connection === 'direct' ? 'Direct' : 'Relay';
1205
- statusBadge.className = 'badge ' + (nodeData.connection === 'direct' ? 'badge-online' : 'badge-relay');
1206
- } else {
1207
- statusBadge.textContent = 'Offline';
1208
- statusBadge.className = 'badge badge-offline';
1209
- }
1210
-
1211
- let html = '';
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
-
1264
- // Connection info
1265
- if (!isLocal && nodeData.connection === 'relay' && nodeData.reachableVia) {
1266
- html += '<div class="detail-section">';
1267
- html += '<div class="detail-label">Route</div>';
1268
- html += '<div>via <strong>' + esc(nodeData.reachableVia) + '</strong></div>';
1269
- html += '</div>';
1270
- }
1271
-
1272
- // Models
1273
- if (nodeData.models?.length) {
1274
- html += '<div class="detail-section">';
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">';
1277
- for (const m of nodeData.models) {
1278
- html += '<div class="item-row"><span class="item-icon" style="background:var(--accent)"></span>';
1279
- html += '<span>' + esc(m.id) + '</span>';
1280
- if (m.description) html += '<span style="color:var(--text-dim);font-size:11px"> — ' + esc(m.description) + '</span>';
1281
- html += '</div>';
1282
- }
1283
- html += '</div></div>';
1284
- }
1285
-
1286
- // Agents
1287
- if (nodeData.agents?.length) {
1288
- html += '<div class="detail-section">';
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">';
1291
- for (const a of nodeData.agents) {
1292
- html += '<div class="item-row"><span class="item-icon" style="background:var(--green)"></span>';
1293
- html += '<span>' + esc(a.id) + '</span>';
1294
- if (a.description) html += '<span style="color:var(--text-dim);font-size:11px"> — ' + esc(a.description) + '</span>';
1295
- html += '</div>';
1296
- }
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>';
1325
- }
1326
-
1327
- // Tags
1328
- if (nodeData.tags?.length) {
1329
- html += '<div class="detail-section">';
1330
- html += '<div class="detail-label">Tags</div>';
1331
- html += '<div class="detail-tags">';
1332
- for (const t of nodeData.tags) {
1333
- html += '<span class="tag">' + esc(t) + '</span>';
1334
- }
1335
- html += '</div>';
1336
- html += '</div>';
1337
- }
1338
-
1339
- if (!html) {
1340
- html = '<div style="color:var(--text-dim)">No capabilities declared.</div>';
1341
- }
1342
-
1343
- $('detail-body').innerHTML = html;
1344
- }
1345
-
1346
- function esc(s) { return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;').replace(/'/g,'&#39;'); }
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
-
1393
- // ── Model select ──
1394
- function updateModelSelect() {
1395
- const sel = $('chat-model');
1396
- const current = sel.value;
1397
-
1398
- // Collect all models with node info
1399
- const models = [];
1400
- if (state.local?.models) {
1401
- for (const m of state.local.models) {
1402
- models.push({ id: m.id, nodeId: state.nodeId, label: m.id + ' @ ' + state.nodeId });
1403
- }
1404
- }
1405
- for (const p of state.peers) {
1406
- if (!p.online) continue;
1407
- for (const m of p.models) {
1408
- models.push({ id: m.id, nodeId: p.nodeId, label: m.id + ' @ ' + p.nodeId });
1409
- }
1410
- }
1411
-
1412
- sel.innerHTML = '<option value="">Select model...</option>';
1413
- for (const m of models) {
1414
- const opt = document.createElement('option');
1415
- opt.value = m.nodeId + '/' + m.id;
1416
- opt.textContent = m.label;
1417
- sel.appendChild(opt);
1418
- }
1419
-
1420
- if (current && sel.querySelector('option[value="' + CSS.escape(current) + '"]')) {
1421
- sel.value = current;
1422
- }
1423
- }
1424
-
1425
- // ── Chat ──
1426
- const chatInput = $('chat-input');
1427
- const chatForm = $('chat-form');
1428
- const chatMsgs = $('chat-messages');
1429
-
1430
- chatInput.addEventListener('input', () => {
1431
- chatInput.style.height = 'auto';
1432
- chatInput.style.height = Math.min(chatInput.scrollHeight, 120) + 'px';
1433
- });
1434
-
1435
- chatInput.addEventListener('keydown', (e) => {
1436
- if (e.key === 'Enter' && !e.shiftKey) {
1437
- e.preventDefault();
1438
- chatForm.requestSubmit();
1439
- }
1440
- });
1441
-
1442
- chatForm.addEventListener('submit', async (e) => {
1443
- e.preventDefault();
1444
- if (chatStreaming) return;
1445
-
1446
- const text = chatInput.value.trim();
1447
- if (!text) return;
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
-
1460
- chatInput.value = '';
1461
- chatInput.style.height = 'auto';
1462
-
1463
- const [nodeId, ...modelParts] = modelVal.split('/');
1464
- const model = modelParts.join('/');
1465
-
1466
- chatMessages.push({ role: 'user', content: text });
1467
- chatMessages.push({ role: 'assistant', content: '' });
1468
- renderChatMessages();
1469
-
1470
- chatStreaming = true;
1471
- $('chat-send').disabled = true;
1472
-
1473
- try {
1474
- const messages = chatMessages
1475
- .filter(m => m.role !== 'error' && m.content)
1476
- .map(m => ({ role: m.role, content: m.content }));
1477
-
1478
- const res = await fetch('/api/chat', {
1479
- method: 'POST',
1480
- headers: { 'Content-Type': 'application/json' },
1481
- body: JSON.stringify({ model, nodeId, messages: messages.slice(0, -1) }),
1482
- });
1483
-
1484
- if (!res.ok) {
1485
- const err = await res.text();
1486
- chatMessages[chatMessages.length - 1] = { role: 'error', content: 'Error: ' + err };
1487
- renderChatMessages();
1488
- return;
1489
- }
1490
-
1491
- const reader = res.body.getReader();
1492
- const decoder = new TextDecoder();
1493
- let buffer = '';
1494
-
1495
- while (true) {
1496
- const { done, value } = await reader.read();
1497
- if (done) break;
1498
-
1499
- buffer += decoder.decode(value, { stream: true });
1500
- const lines = buffer.split('\\n');
1501
- buffer = lines.pop();
1502
-
1503
- for (const line of lines) {
1504
- if (!line.startsWith('data: ')) continue;
1505
- const data = line.slice(6).trim();
1506
- if (data === '[DONE]') continue;
1507
-
1508
- try {
1509
- const parsed = JSON.parse(data);
1510
- const delta = parsed.choices?.[0]?.delta?.content;
1511
- if (delta) {
1512
- chatMessages[chatMessages.length - 1].content += delta;
1513
- renderChatMessages();
1514
- }
1515
- } catch {}
1516
- }
1517
- }
1518
-
1519
- if (!chatMessages[chatMessages.length - 1].content) {
1520
- chatMessages[chatMessages.length - 1] = { role: 'error', content: 'No response received' };
1521
- }
1522
- renderChatMessages();
1523
- } catch (err) {
1524
- chatMessages[chatMessages.length - 1] = { role: 'error', content: 'Error: ' + err.message };
1525
- renderChatMessages();
1526
- } finally {
1527
- chatStreaming = false;
1528
- $('chat-send').disabled = false;
1529
- chatInput.focus();
1530
- }
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
- }
1604
-
1605
- function renderChatMessages() {
1606
- if (chatMessages.length === 0) {
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>';
1611
- return;
1612
- }
1613
-
1614
- chatMsgs.innerHTML = chatMessages.map(m => {
1615
- const cls = m.role === 'user' ? 'user' : m.role === 'error' ? 'error' : 'assistant';
1616
- return '<div class="chat-msg ' + cls + '">' + esc(m.content || '...') + '</div>';
1617
- }).join('');
1618
-
1619
- chatMsgs.scrollTop = chatMsgs.scrollHeight;
1620
- }
1621
- })();
1622
- `;