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/package.json +4 -4
- package/src/auth.ts +11 -7
- package/src/cluster-service.ts +8 -0
- package/src/config.ts +6 -0
- package/src/peer-manager.ts +13 -3
- package/src/tool-proxy.ts +6 -0
- package/src/web-ui.ts +1270 -0
- package/src/web.ts +230 -0
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, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
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,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"').replace(/'/g,'''); }
|
|
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
|
+
`;
|