clawmatrix 0.1.12 → 0.1.14

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 ADDED
@@ -0,0 +1,1270 @@
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
+ </head>
13
+ <body>
14
+
15
+ <!-- Login -->
16
+ <div id="login-view" class="login-view">
17
+ <div class="login-card">
18
+ <div class="login-logo">
19
+ <svg width="48" height="48" viewBox="0 0 48 48" fill="none">
20
+ <circle cx="24" cy="24" r="6" fill="#818cf8"/>
21
+ <circle cx="10" cy="12" r="4" fill="#6366f1" opacity=".7"/>
22
+ <circle cx="38" cy="12" r="4" fill="#6366f1" opacity=".7"/>
23
+ <circle cx="10" cy="36" r="4" fill="#6366f1" opacity=".7"/>
24
+ <circle cx="38" cy="36" r="4" fill="#6366f1" opacity=".7"/>
25
+ <line x1="24" y1="24" x2="10" y2="12" stroke="#6366f1" stroke-width="1.5" opacity=".5"/>
26
+ <line x1="24" y1="24" x2="38" y2="12" stroke="#6366f1" stroke-width="1.5" opacity=".5"/>
27
+ <line x1="24" y1="24" x2="10" y2="36" stroke="#6366f1" stroke-width="1.5" opacity=".5"/>
28
+ <line x1="24" y1="24" x2="38" y2="36" stroke="#6366f1" stroke-width="1.5" opacity=".5"/>
29
+ <line x1="10" y1="12" x2="38" y2="12" stroke="#6366f1" stroke-width="1" opacity=".3"/>
30
+ <line x1="38" y1="12" x2="38" y2="36" stroke="#6366f1" stroke-width="1" opacity=".3"/>
31
+ <line x1="38" y1="36" x2="10" y2="36" stroke="#6366f1" stroke-width="1" opacity=".3"/>
32
+ <line x1="10" y1="36" x2="10" y2="12" stroke="#6366f1" stroke-width="1" opacity=".3"/>
33
+ </svg>
34
+ </div>
35
+ <h1>ClawMatrix</h1>
36
+ <p class="login-subtitle">Mesh Cluster Dashboard</p>
37
+ <form id="login-form" autocomplete="off">
38
+ <input type="password" id="login-token" placeholder="Access Token" autocomplete="current-password" required>
39
+ <button type="submit">Connect</button>
40
+ <p id="login-error" class="login-error"></p>
41
+ </form>
42
+ </div>
43
+ </div>
44
+
45
+ <!-- Dashboard -->
46
+ <div id="dashboard" class="dashboard hidden">
47
+ <!-- Header -->
48
+ <header class="header">
49
+ <div class="header-left">
50
+ <svg width="28" height="28" viewBox="0 0 48 48" fill="none" class="header-logo">
51
+ <circle cx="24" cy="24" r="6" fill="#818cf8"/>
52
+ <circle cx="10" cy="12" r="4" fill="#6366f1" opacity=".7"/>
53
+ <circle cx="38" cy="12" r="4" fill="#6366f1" opacity=".7"/>
54
+ <circle cx="10" cy="36" r="4" fill="#6366f1" opacity=".7"/>
55
+ <circle cx="38" cy="36" r="4" fill="#6366f1" opacity=".7"/>
56
+ <line x1="24" y1="24" x2="10" y2="12" stroke="#6366f1" stroke-width="1.5" opacity=".5"/>
57
+ <line x1="24" y1="24" x2="38" y2="12" stroke="#6366f1" stroke-width="1.5" opacity=".5"/>
58
+ <line x1="24" y1="24" x2="10" y2="36" stroke="#6366f1" stroke-width="1.5" opacity=".5"/>
59
+ <line x1="24" y1="24" x2="38" y2="36" stroke="#6366f1" stroke-width="1.5" opacity=".5"/>
60
+ </svg>
61
+ <span class="header-title">ClawMatrix</span>
62
+ <span id="header-node" class="header-node"></span>
63
+ </div>
64
+ <div class="header-right">
65
+ <span id="header-status" class="status-dot offline"></span>
66
+ <span id="header-uptime" class="header-uptime"></span>
67
+ <button id="btn-logout" class="btn-ghost" title="Logout">
68
+ <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>
69
+ </button>
70
+ </div>
71
+ </header>
72
+
73
+ <!-- Main content -->
74
+ <div class="main">
75
+ <!-- Left: Mesh + Details -->
76
+ <div class="panel-left">
77
+ <div class="card mesh-card">
78
+ <div class="card-header">
79
+ <h2>Mesh Topology</h2>
80
+ <span id="peer-count" class="badge">0 nodes</span>
81
+ </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>
90
+ </div>
91
+ </div>
92
+
93
+ <!-- Right: Chat -->
94
+ <div class="panel-right">
95
+ <div class="card chat-card">
96
+ <div class="card-header">
97
+ <h2>Chat</h2>
98
+ <div class="chat-selects">
99
+ <select id="chat-model" title="Model">
100
+ <option value="">Select model...</option>
101
+ </select>
102
+ </div>
103
+ </div>
104
+ <div id="chat-messages" class="chat-messages">
105
+ <div class="chat-empty">Select a model and start chatting with your cluster.</div>
106
+ </div>
107
+ <form id="chat-form" class="chat-input-row">
108
+ <textarea id="chat-input" placeholder="Type a message..." rows="1"></textarea>
109
+ <button type="submit" id="chat-send" title="Send">
110
+ <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>
111
+ </button>
112
+ </form>
113
+ </div>
114
+ </div>
115
+ </div>
116
+ </div>
117
+
118
+ <script>
119
+ ${JS}
120
+ </script>
121
+ </body>
122
+ </html>`;
123
+ }
124
+
125
+ function esc(s: string): string {
126
+ return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#39;");
127
+ }
128
+
129
+ // ── CSS ─────────────────────────────────────────────────────────────
130
+ const CSS = `
131
+ :root {
132
+ --bg: #08090d;
133
+ --bg-card: #0f1117;
134
+ --bg-card-hover: #161822;
135
+ --border: #1e2030;
136
+ --border-subtle: #151725;
137
+ --text: #e2e8f0;
138
+ --text-secondary: #8b92a8;
139
+ --text-dim: #555d75;
140
+ --accent: #818cf8;
141
+ --accent-dim: #6366f1;
142
+ --accent-glow: rgba(99, 102, 241, 0.15);
143
+ --green: #34d399;
144
+ --green-dim: rgba(52, 211, 153, 0.15);
145
+ --orange: #fbbf24;
146
+ --orange-dim: rgba(251, 191, 36, 0.15);
147
+ --red: #f87171;
148
+ --red-dim: rgba(248, 113, 113, 0.15);
149
+ --radius: 10px;
150
+ --radius-sm: 6px;
151
+ }
152
+
153
+ *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
154
+
155
+ body {
156
+ font-family: -apple-system, BlinkMacSystemFont, 'SF Pro', 'Segoe UI', system-ui, sans-serif;
157
+ background: var(--bg);
158
+ color: var(--text);
159
+ overflow: hidden;
160
+ height: 100vh;
161
+ }
162
+
163
+ .hidden { display: none !important; }
164
+
165
+ /* Login */
166
+ .login-view {
167
+ height: 100vh;
168
+ display: flex;
169
+ align-items: center;
170
+ justify-content: center;
171
+ background: var(--bg);
172
+ background-image:
173
+ radial-gradient(ellipse at 30% 20%, rgba(99,102,241,0.08) 0%, transparent 50%),
174
+ radial-gradient(ellipse at 70% 80%, rgba(99,102,241,0.05) 0%, transparent 50%);
175
+ }
176
+
177
+ .login-card {
178
+ background: var(--bg-card);
179
+ border: 1px solid var(--border);
180
+ border-radius: 16px;
181
+ padding: 48px 40px 40px;
182
+ width: 380px;
183
+ text-align: center;
184
+ box-shadow: 0 0 80px rgba(99,102,241,0.06);
185
+ }
186
+
187
+ .login-logo { margin-bottom: 20px; }
188
+
189
+ .login-card h1 {
190
+ font-size: 24px;
191
+ font-weight: 700;
192
+ letter-spacing: -0.5px;
193
+ margin-bottom: 4px;
194
+ }
195
+
196
+ .login-subtitle {
197
+ color: var(--text-secondary);
198
+ font-size: 14px;
199
+ margin-bottom: 32px;
200
+ }
201
+
202
+ .login-card input {
203
+ width: 100%;
204
+ padding: 12px 16px;
205
+ background: var(--bg);
206
+ border: 1px solid var(--border);
207
+ border-radius: var(--radius-sm);
208
+ color: var(--text);
209
+ font-size: 14px;
210
+ outline: none;
211
+ transition: border-color 0.2s;
212
+ }
213
+
214
+ .login-card input:focus {
215
+ border-color: var(--accent-dim);
216
+ box-shadow: 0 0 0 3px var(--accent-glow);
217
+ }
218
+
219
+ .login-card button {
220
+ width: 100%;
221
+ padding: 12px;
222
+ margin-top: 16px;
223
+ background: var(--accent-dim);
224
+ color: #fff;
225
+ border: none;
226
+ border-radius: var(--radius-sm);
227
+ font-size: 14px;
228
+ font-weight: 600;
229
+ cursor: pointer;
230
+ transition: background 0.2s, transform 0.1s;
231
+ }
232
+
233
+ .login-card button:hover { background: var(--accent); }
234
+ .login-card button:active { transform: scale(0.98); }
235
+
236
+ .login-error {
237
+ color: var(--red);
238
+ font-size: 13px;
239
+ margin-top: 12px;
240
+ min-height: 20px;
241
+ }
242
+
243
+ /* Header */
244
+ .header {
245
+ display: flex;
246
+ align-items: center;
247
+ justify-content: space-between;
248
+ padding: 0 24px;
249
+ height: 56px;
250
+ border-bottom: 1px solid var(--border);
251
+ background: var(--bg-card);
252
+ flex-shrink: 0;
253
+ }
254
+
255
+ .header-left { display: flex; align-items: center; gap: 10px; }
256
+ .header-title { font-weight: 700; font-size: 16px; letter-spacing: -0.3px; }
257
+ .header-node {
258
+ font-size: 13px;
259
+ color: var(--text-secondary);
260
+ padding: 2px 10px;
261
+ background: var(--accent-glow);
262
+ border-radius: 12px;
263
+ border: 1px solid rgba(99,102,241,0.2);
264
+ }
265
+
266
+ .header-right { display: flex; align-items: center; gap: 14px; }
267
+
268
+ .header-uptime {
269
+ font-size: 12px;
270
+ color: var(--text-dim);
271
+ font-variant-numeric: tabular-nums;
272
+ }
273
+
274
+ .status-dot {
275
+ width: 8px; height: 8px;
276
+ border-radius: 50%;
277
+ display: inline-block;
278
+ }
279
+ .status-dot.online { background: var(--green); box-shadow: 0 0 8px var(--green); }
280
+ .status-dot.offline { background: var(--red); }
281
+
282
+ .btn-ghost {
283
+ background: transparent;
284
+ border: 1px solid var(--border);
285
+ color: var(--text-secondary);
286
+ border-radius: var(--radius-sm);
287
+ padding: 6px 8px;
288
+ cursor: pointer;
289
+ display: flex;
290
+ align-items: center;
291
+ transition: color 0.2s, border-color 0.2s;
292
+ }
293
+ .btn-ghost:hover { color: var(--text); border-color: var(--text-dim); }
294
+
295
+ /* Dashboard layout */
296
+ .dashboard {
297
+ display: flex;
298
+ flex-direction: column;
299
+ height: 100vh;
300
+ }
301
+
302
+ .main {
303
+ display: flex;
304
+ flex: 1;
305
+ min-height: 0;
306
+ overflow: hidden;
307
+ }
308
+
309
+ .panel-left {
310
+ flex: 1;
311
+ display: flex;
312
+ flex-direction: column;
313
+ padding: 16px;
314
+ gap: 16px;
315
+ min-width: 0;
316
+ overflow-y: auto;
317
+ }
318
+
319
+ .panel-right {
320
+ width: 420px;
321
+ min-width: 320px;
322
+ border-left: 1px solid var(--border);
323
+ display: flex;
324
+ flex-direction: column;
325
+ }
326
+
327
+ /* Cards */
328
+ .card {
329
+ background: var(--bg-card);
330
+ border: 1px solid var(--border);
331
+ border-radius: var(--radius);
332
+ overflow: hidden;
333
+ }
334
+
335
+ .card-header {
336
+ display: flex;
337
+ align-items: center;
338
+ justify-content: space-between;
339
+ padding: 14px 18px;
340
+ border-bottom: 1px solid var(--border-subtle);
341
+ }
342
+
343
+ .card-header h2 {
344
+ font-size: 13px;
345
+ font-weight: 600;
346
+ text-transform: uppercase;
347
+ letter-spacing: 0.5px;
348
+ color: var(--text-secondary);
349
+ }
350
+
351
+ .badge {
352
+ font-size: 11px;
353
+ padding: 2px 10px;
354
+ border-radius: 10px;
355
+ font-weight: 600;
356
+ }
357
+
358
+ .badge-online { background: var(--green-dim); color: var(--green); }
359
+ .badge-relay { background: var(--orange-dim); color: var(--orange); }
360
+ .badge-offline { background: var(--red-dim); color: var(--red); }
361
+ .badge-self { background: var(--accent-glow); color: var(--accent); }
362
+
363
+ /* Mesh canvas */
364
+ .mesh-card {
365
+ flex: 1;
366
+ display: flex;
367
+ flex-direction: column;
368
+ min-height: 300px;
369
+ }
370
+
371
+ #mesh-canvas {
372
+ flex: 1;
373
+ width: 100%;
374
+ cursor: default;
375
+ }
376
+
377
+ /* Node detail */
378
+ .detail-card { flex-shrink: 0; }
379
+
380
+ .detail-body {
381
+ padding: 16px 18px;
382
+ font-size: 13px;
383
+ line-height: 1.7;
384
+ }
385
+
386
+ .detail-body .detail-section {
387
+ margin-bottom: 12px;
388
+ }
389
+
390
+ .detail-body .detail-label {
391
+ color: var(--text-dim);
392
+ font-size: 11px;
393
+ text-transform: uppercase;
394
+ letter-spacing: 0.5px;
395
+ margin-bottom: 4px;
396
+ }
397
+
398
+ .detail-body .detail-tags {
399
+ display: flex;
400
+ flex-wrap: wrap;
401
+ gap: 6px;
402
+ }
403
+
404
+ .detail-body .tag {
405
+ font-size: 11px;
406
+ padding: 2px 8px;
407
+ border-radius: 4px;
408
+ background: var(--bg);
409
+ border: 1px solid var(--border);
410
+ color: var(--text-secondary);
411
+ }
412
+
413
+ .detail-body .item-row {
414
+ display: flex;
415
+ align-items: center;
416
+ gap: 8px;
417
+ padding: 4px 0;
418
+ }
419
+
420
+ .detail-body .item-icon {
421
+ width: 6px;
422
+ height: 6px;
423
+ border-radius: 50%;
424
+ flex-shrink: 0;
425
+ }
426
+
427
+ /* Chat */
428
+ .chat-card {
429
+ display: flex;
430
+ flex-direction: column;
431
+ height: 100%;
432
+ border-radius: 0;
433
+ border: none;
434
+ border-top: none;
435
+ }
436
+
437
+ .chat-selects {
438
+ display: flex;
439
+ gap: 8px;
440
+ }
441
+
442
+ .chat-selects select {
443
+ background: var(--bg);
444
+ border: 1px solid var(--border);
445
+ color: var(--text);
446
+ font-size: 12px;
447
+ padding: 4px 8px;
448
+ border-radius: var(--radius-sm);
449
+ outline: none;
450
+ cursor: pointer;
451
+ max-width: 200px;
452
+ }
453
+
454
+ .chat-selects select:focus {
455
+ border-color: var(--accent-dim);
456
+ }
457
+
458
+ .chat-messages {
459
+ flex: 1;
460
+ overflow-y: auto;
461
+ padding: 16px 18px;
462
+ display: flex;
463
+ flex-direction: column;
464
+ gap: 12px;
465
+ }
466
+
467
+ .chat-empty {
468
+ color: var(--text-dim);
469
+ font-size: 13px;
470
+ text-align: center;
471
+ margin: auto;
472
+ padding: 40px 20px;
473
+ }
474
+
475
+ .chat-msg {
476
+ max-width: 90%;
477
+ padding: 10px 14px;
478
+ border-radius: 12px;
479
+ font-size: 13px;
480
+ line-height: 1.6;
481
+ word-break: break-word;
482
+ white-space: pre-wrap;
483
+ }
484
+
485
+ .chat-msg.user {
486
+ align-self: flex-end;
487
+ background: var(--accent-dim);
488
+ color: #fff;
489
+ border-bottom-right-radius: 4px;
490
+ }
491
+
492
+ .chat-msg.assistant {
493
+ align-self: flex-start;
494
+ background: var(--bg);
495
+ border: 1px solid var(--border);
496
+ color: var(--text);
497
+ border-bottom-left-radius: 4px;
498
+ }
499
+
500
+ .chat-msg.error {
501
+ align-self: center;
502
+ background: var(--red-dim);
503
+ color: var(--red);
504
+ font-size: 12px;
505
+ }
506
+
507
+ .chat-input-row {
508
+ display: flex;
509
+ align-items: flex-end;
510
+ gap: 8px;
511
+ padding: 12px 16px;
512
+ border-top: 1px solid var(--border-subtle);
513
+ background: var(--bg-card);
514
+ }
515
+
516
+ .chat-input-row textarea {
517
+ flex: 1;
518
+ background: var(--bg);
519
+ border: 1px solid var(--border);
520
+ color: var(--text);
521
+ border-radius: var(--radius-sm);
522
+ padding: 10px 14px;
523
+ font-size: 13px;
524
+ font-family: inherit;
525
+ resize: none;
526
+ outline: none;
527
+ max-height: 120px;
528
+ line-height: 1.5;
529
+ transition: border-color 0.2s;
530
+ }
531
+
532
+ .chat-input-row textarea:focus {
533
+ border-color: var(--accent-dim);
534
+ box-shadow: 0 0 0 3px var(--accent-glow);
535
+ }
536
+
537
+ .chat-input-row button {
538
+ width: 38px;
539
+ height: 38px;
540
+ border-radius: var(--radius-sm);
541
+ background: var(--accent-dim);
542
+ color: #fff;
543
+ border: none;
544
+ cursor: pointer;
545
+ display: flex;
546
+ align-items: center;
547
+ justify-content: center;
548
+ flex-shrink: 0;
549
+ transition: background 0.2s, transform 0.1s;
550
+ }
551
+
552
+ .chat-input-row button:hover { background: var(--accent); }
553
+ .chat-input-row button:active { transform: scale(0.95); }
554
+ .chat-input-row button:disabled { opacity: 0.4; cursor: not-allowed; }
555
+
556
+ /* Scrollbar */
557
+ ::-webkit-scrollbar { width: 6px; }
558
+ ::-webkit-scrollbar-track { background: transparent; }
559
+ ::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; }
560
+ ::-webkit-scrollbar-thumb:hover { background: var(--text-dim); }
561
+
562
+ /* Responsive */
563
+ @media (max-width: 860px) {
564
+ .main { flex-direction: column; }
565
+ .panel-right {
566
+ width: 100%;
567
+ border-left: none;
568
+ border-top: 1px solid var(--border);
569
+ height: 50vh;
570
+ }
571
+ .panel-left { overflow-y: auto; }
572
+ }
573
+ `;
574
+
575
+ // ── JavaScript ──────────────────────────────────────────────────────
576
+ const JS = `
577
+ (function() {
578
+ 'use strict';
579
+
580
+ // ── State ──
581
+ let state = { local: null, peers: [], nodeId: '', uptime: 0 };
582
+ let selectedNode = null;
583
+ let chatMessages = [];
584
+ let chatStreaming = false;
585
+ let meshNodes = [];
586
+ let meshEdges = [];
587
+ let hoveredNode = null;
588
+ let dragNode = null;
589
+ let dragOffset = { x: 0, y: 0 };
590
+ let animFrame = null;
591
+ let pollTimer = null;
592
+
593
+ // ── DOM refs ──
594
+ const $ = (id) => document.getElementById(id);
595
+ const loginView = $('login-view');
596
+ const dashboard = $('dashboard');
597
+ const loginForm = $('login-form');
598
+ const loginToken = $('login-token');
599
+ const loginError = $('login-error');
600
+ const canvas = $('mesh-canvas');
601
+ const ctx = canvas.getContext('2d');
602
+
603
+ // ── Auth ──
604
+ loginForm.addEventListener('submit', async (e) => {
605
+ e.preventDefault();
606
+ loginError.textContent = '';
607
+ try {
608
+ const res = await fetch('/api/login', {
609
+ method: 'POST',
610
+ headers: { 'Content-Type': 'application/json' },
611
+ body: JSON.stringify({ token: loginToken.value }),
612
+ });
613
+ if (!res.ok) {
614
+ const data = await res.json();
615
+ loginError.textContent = data.error || 'Login failed';
616
+ return;
617
+ }
618
+ loginView.classList.add('hidden');
619
+ dashboard.classList.remove('hidden');
620
+ startDashboard();
621
+ } catch (err) {
622
+ loginError.textContent = 'Connection failed';
623
+ }
624
+ });
625
+
626
+ $('btn-logout').addEventListener('click', async () => {
627
+ await fetch('/api/logout', { method: 'POST' });
628
+ chatMessages = [];
629
+ stopDashboard();
630
+ dashboard.classList.add('hidden');
631
+ loginView.classList.remove('hidden');
632
+ loginToken.value = '';
633
+ });
634
+
635
+ // ── Init check (try with existing cookie) ──
636
+ (async () => {
637
+ try {
638
+ const res = await fetch('/api/status');
639
+ if (res.ok) {
640
+ loginView.classList.add('hidden');
641
+ dashboard.classList.remove('hidden');
642
+ const data = await res.json();
643
+ updateState(data);
644
+ startDashboard();
645
+ }
646
+ } catch {}
647
+ })();
648
+
649
+ // ── Dashboard lifecycle ──
650
+ function startDashboard() {
651
+ initMesh();
652
+ pollStatus();
653
+ pollTimer = setInterval(pollStatus, 3000);
654
+ requestAnimationFrame(renderLoop);
655
+ }
656
+
657
+ function stopDashboard() {
658
+ if (pollTimer) { clearInterval(pollTimer); pollTimer = null; }
659
+ if (animFrame) { cancelAnimationFrame(animFrame); animFrame = null; }
660
+ window.removeEventListener('resize', resizeCanvas);
661
+ }
662
+
663
+ async function pollStatus() {
664
+ try {
665
+ const res = await fetch('/api/status');
666
+ if (res.status === 401) {
667
+ stopDashboard();
668
+ dashboard.classList.add('hidden');
669
+ loginView.classList.remove('hidden');
670
+ return;
671
+ }
672
+ if (res.ok) {
673
+ updateState(await res.json());
674
+ }
675
+ } catch {}
676
+ }
677
+
678
+ function updateState(data) {
679
+ state = data;
680
+ $('header-node').textContent = state.nodeId;
681
+ $('header-status').className = 'status-dot online';
682
+ $('header-uptime').textContent = formatUptime(state.uptime);
683
+
684
+ const totalNodes = 1 + state.peers.length;
685
+ $('peer-count').textContent = totalNodes + (totalNodes === 1 ? ' node' : ' nodes');
686
+ $('peer-count').className = 'badge badge-self';
687
+
688
+ updateMeshData();
689
+ updateModelSelect();
690
+ if (selectedNode) updateDetail(selectedNode);
691
+ }
692
+
693
+ function formatUptime(s) {
694
+ if (s < 60) return s + 's';
695
+ if (s < 3600) return Math.floor(s / 60) + 'm ' + (s % 60) + 's';
696
+ const h = Math.floor(s / 3600);
697
+ const m = Math.floor((s % 3600) / 60);
698
+ return h + 'h ' + m + 'm';
699
+ }
700
+
701
+ // ── Mesh visualization ──
702
+ const DPR = window.devicePixelRatio || 1;
703
+ let W = 0, H = 0;
704
+
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
+ }
713
+
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
+ }
726
+
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
+ ];
736
+
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
+ };
754
+ });
755
+
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' });
766
+ }
767
+ }
768
+ }
769
+
770
+ layoutNodes();
771
+ }
772
+
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
+ }
778
+
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
+ }
803
+
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
+ }
819
+
820
+ // Gravity toward center
821
+ fx += (center.x - a.x) * 0.001;
822
+ fy += (center.y - a.y) * 0.001;
823
+
824
+ a.vx = (a.vx + fx) * damping;
825
+ a.vy = (a.vy + fy) * damping;
826
+ a.x += a.vx;
827
+ a.y += a.vy;
828
+
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
+ }
834
+ }
835
+
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();
850
+ }
851
+
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();
892
+ }
893
+ }
894
+
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);
911
+ }
912
+
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();
936
+ }
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);
957
+ }
958
+ }
959
+ }
960
+ }
961
+
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 + ')';
967
+ }
968
+
969
+ function renderLoop() {
970
+ simulateForces();
971
+ renderMesh();
972
+ animFrame = requestAnimationFrame(renderLoop);
973
+ }
974
+
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;
982
+ }
983
+ return null;
984
+ }
985
+
986
+ function getCanvasPos(e) {
987
+ const rect = canvas.getBoundingClientRect();
988
+ return { x: e.clientX - rect.left, y: e.clientY - rect.top };
989
+ }
990
+
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);
1003
+ }
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');
1034
+ }
1035
+ }
1036
+ }
1037
+
1038
+ // ── Node detail panel ──
1039
+ function updateDetail(nodeId) {
1040
+ const isLocal = nodeId === state.nodeId;
1041
+ const nodeData = isLocal ? state.local : state.peers.find(p => p.nodeId === nodeId);
1042
+ if (!nodeData) {
1043
+ $('node-detail').classList.add('hidden');
1044
+ return;
1045
+ }
1046
+
1047
+ $('node-detail').classList.remove('hidden');
1048
+ $('detail-title').textContent = nodeId;
1049
+
1050
+ const statusBadge = $('detail-status');
1051
+ if (isLocal) {
1052
+ statusBadge.textContent = 'Self';
1053
+ statusBadge.className = 'badge badge-self';
1054
+ } else if (nodeData.online) {
1055
+ statusBadge.textContent = nodeData.connection === 'direct' ? 'Direct' : 'Relay';
1056
+ statusBadge.className = 'badge ' + (nodeData.connection === 'direct' ? 'badge-online' : 'badge-relay');
1057
+ } else {
1058
+ statusBadge.textContent = 'Offline';
1059
+ statusBadge.className = 'badge badge-offline';
1060
+ }
1061
+
1062
+ let html = '';
1063
+
1064
+ // Connection info
1065
+ if (!isLocal && nodeData.connection === 'relay' && nodeData.reachableVia) {
1066
+ html += '<div class="detail-section">';
1067
+ html += '<div class="detail-label">Route</div>';
1068
+ html += '<div>via <strong>' + esc(nodeData.reachableVia) + '</strong></div>';
1069
+ html += '</div>';
1070
+ }
1071
+
1072
+ // Models
1073
+ if (nodeData.models?.length) {
1074
+ html += '<div class="detail-section">';
1075
+ html += '<div class="detail-label">Models</div>';
1076
+ for (const m of nodeData.models) {
1077
+ html += '<div class="item-row"><span class="item-icon" style="background:var(--accent)"></span>';
1078
+ html += '<span>' + esc(m.id) + '</span>';
1079
+ if (m.description) html += '<span style="color:var(--text-dim);font-size:11px"> — ' + esc(m.description) + '</span>';
1080
+ html += '</div>';
1081
+ }
1082
+ html += '</div>';
1083
+ }
1084
+
1085
+ // Agents
1086
+ if (nodeData.agents?.length) {
1087
+ html += '<div class="detail-section">';
1088
+ html += '<div class="detail-label">Agents</div>';
1089
+ for (const a of nodeData.agents) {
1090
+ html += '<div class="item-row"><span class="item-icon" style="background:var(--green)"></span>';
1091
+ html += '<span>' + esc(a.id) + '</span>';
1092
+ if (a.description) html += '<span style="color:var(--text-dim);font-size:11px"> — ' + esc(a.description) + '</span>';
1093
+ html += '</div>';
1094
+ }
1095
+ html += '</div>';
1096
+ }
1097
+
1098
+ // Tags
1099
+ if (nodeData.tags?.length) {
1100
+ html += '<div class="detail-section">';
1101
+ html += '<div class="detail-label">Tags</div>';
1102
+ html += '<div class="detail-tags">';
1103
+ for (const t of nodeData.tags) {
1104
+ html += '<span class="tag">' + esc(t) + '</span>';
1105
+ }
1106
+ html += '</div>';
1107
+ html += '</div>';
1108
+ }
1109
+
1110
+ if (!html) {
1111
+ html = '<div style="color:var(--text-dim)">No capabilities declared.</div>';
1112
+ }
1113
+
1114
+ $('detail-body').innerHTML = html;
1115
+ }
1116
+
1117
+ function esc(s) { return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;').replace(/'/g,'&#39;'); }
1118
+
1119
+ // ── Model select ──
1120
+ function updateModelSelect() {
1121
+ const sel = $('chat-model');
1122
+ const current = sel.value;
1123
+
1124
+ // Collect all models with node info
1125
+ const models = [];
1126
+ if (state.local?.models) {
1127
+ for (const m of state.local.models) {
1128
+ models.push({ id: m.id, nodeId: state.nodeId, label: m.id + ' @ ' + state.nodeId });
1129
+ }
1130
+ }
1131
+ for (const p of state.peers) {
1132
+ if (!p.online) continue;
1133
+ for (const m of p.models) {
1134
+ models.push({ id: m.id, nodeId: p.nodeId, label: m.id + ' @ ' + p.nodeId });
1135
+ }
1136
+ }
1137
+
1138
+ sel.innerHTML = '<option value="">Select model...</option>';
1139
+ for (const m of models) {
1140
+ const opt = document.createElement('option');
1141
+ opt.value = m.nodeId + '/' + m.id;
1142
+ opt.textContent = m.label;
1143
+ sel.appendChild(opt);
1144
+ }
1145
+
1146
+ if (current && sel.querySelector('option[value="' + CSS.escape(current) + '"]')) {
1147
+ sel.value = current;
1148
+ }
1149
+ }
1150
+
1151
+ // ── Chat ──
1152
+ const chatInput = $('chat-input');
1153
+ const chatForm = $('chat-form');
1154
+ const chatMsgs = $('chat-messages');
1155
+
1156
+ chatInput.addEventListener('input', () => {
1157
+ chatInput.style.height = 'auto';
1158
+ chatInput.style.height = Math.min(chatInput.scrollHeight, 120) + 'px';
1159
+ });
1160
+
1161
+ chatInput.addEventListener('keydown', (e) => {
1162
+ if (e.key === 'Enter' && !e.shiftKey) {
1163
+ e.preventDefault();
1164
+ chatForm.requestSubmit();
1165
+ }
1166
+ });
1167
+
1168
+ chatForm.addEventListener('submit', async (e) => {
1169
+ e.preventDefault();
1170
+ if (chatStreaming) return;
1171
+
1172
+ const modelVal = $('chat-model').value;
1173
+ if (!modelVal) { alert('Please select a model'); return; }
1174
+
1175
+ const text = chatInput.value.trim();
1176
+ if (!text) return;
1177
+
1178
+ chatInput.value = '';
1179
+ chatInput.style.height = 'auto';
1180
+
1181
+ const [nodeId, ...modelParts] = modelVal.split('/');
1182
+ const model = modelParts.join('/');
1183
+
1184
+ // Add user message
1185
+ chatMessages.push({ role: 'user', content: text });
1186
+ renderChatMessages();
1187
+
1188
+ // Add placeholder for assistant
1189
+ chatMessages.push({ role: 'assistant', content: '' });
1190
+ renderChatMessages();
1191
+
1192
+ chatStreaming = true;
1193
+ $('chat-send').disabled = true;
1194
+
1195
+ try {
1196
+ const messages = chatMessages
1197
+ .filter(m => m.role !== 'error' && m.content)
1198
+ .map(m => ({ role: m.role, content: m.content }));
1199
+
1200
+ const res = await fetch('/api/chat', {
1201
+ method: 'POST',
1202
+ headers: { 'Content-Type': 'application/json' },
1203
+ body: JSON.stringify({ model, nodeId, messages: messages.slice(0, -1) }),
1204
+ });
1205
+
1206
+ if (!res.ok) {
1207
+ const err = await res.text();
1208
+ chatMessages[chatMessages.length - 1] = { role: 'error', content: 'Error: ' + err };
1209
+ renderChatMessages();
1210
+ return;
1211
+ }
1212
+
1213
+ const reader = res.body.getReader();
1214
+ const decoder = new TextDecoder();
1215
+ let buffer = '';
1216
+
1217
+ while (true) {
1218
+ const { done, value } = await reader.read();
1219
+ if (done) break;
1220
+
1221
+ buffer += decoder.decode(value, { stream: true });
1222
+ const lines = buffer.split('\\n');
1223
+ buffer = lines.pop();
1224
+
1225
+ for (const line of lines) {
1226
+ if (!line.startsWith('data: ')) continue;
1227
+ const data = line.slice(6).trim();
1228
+ if (data === '[DONE]') continue;
1229
+
1230
+ try {
1231
+ const parsed = JSON.parse(data);
1232
+ const delta = parsed.choices?.[0]?.delta?.content;
1233
+ if (delta) {
1234
+ chatMessages[chatMessages.length - 1].content += delta;
1235
+ renderChatMessages();
1236
+ }
1237
+ } catch {}
1238
+ }
1239
+ }
1240
+
1241
+ // Clean up empty assistant message
1242
+ if (!chatMessages[chatMessages.length - 1].content) {
1243
+ chatMessages[chatMessages.length - 1] = { role: 'error', content: 'No response received' };
1244
+ }
1245
+ renderChatMessages();
1246
+ } catch (err) {
1247
+ chatMessages[chatMessages.length - 1] = { role: 'error', content: 'Error: ' + err.message };
1248
+ renderChatMessages();
1249
+ } finally {
1250
+ chatStreaming = false;
1251
+ $('chat-send').disabled = false;
1252
+ chatInput.focus();
1253
+ }
1254
+ });
1255
+
1256
+ function renderChatMessages() {
1257
+ if (chatMessages.length === 0) {
1258
+ chatMsgs.innerHTML = '<div class="chat-empty">Select a model and start chatting with your cluster.</div>';
1259
+ return;
1260
+ }
1261
+
1262
+ chatMsgs.innerHTML = chatMessages.map(m => {
1263
+ const cls = m.role === 'user' ? 'user' : m.role === 'error' ? 'error' : 'assistant';
1264
+ return '<div class="chat-msg ' + cls + '">' + esc(m.content || '...') + '</div>';
1265
+ }).join('');
1266
+
1267
+ chatMsgs.scrollTop = chatMsgs.scrollHeight;
1268
+ }
1269
+ })();
1270
+ `;