clawmatrix 0.2.11 → 0.4.0
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/LICENSE +27 -0
- package/README.md +123 -12
- package/cli/bin/clawmatrix.mjs +1006 -0
- package/cli/package.json +27 -0
- package/cli/skills/clawmatrix/SKILL.md +104 -0
- package/openclaw.plugin.json +1 -0
- package/package.json +3 -1
- package/src/acp-proxy.ts +820 -96
- package/src/cluster-service.ts +186 -16
- package/src/compat.ts +0 -6
- package/src/config.ts +8 -5
- package/src/connection.ts +61 -55
- package/src/e2e/helpers.ts +1 -5
- package/src/file-transfer.ts +64 -14
- package/src/handoff.ts +21 -8
- package/src/health-tracker.ts +40 -11
- package/src/index.ts +686 -14
- package/src/knowledge-sync.ts +62 -10
- package/src/model-proxy.ts +40 -10
- package/src/peer-manager.ts +114 -17
- package/src/rate-limiter.ts +16 -10
- package/src/router.ts +115 -33
- package/src/sentinel-manager.ts +51 -0
- package/src/sentinel.ts +13 -3
- package/src/tool-proxy.ts +52 -6
- package/src/tools/cluster-diagnostic.ts +3 -2
- package/src/tools/cluster-edit.ts +2 -1
- package/src/tools/cluster-events.ts +3 -1
- package/src/tools/cluster-exec.ts +2 -0
- package/src/tools/cluster-handoff.ts +3 -1
- package/src/tools/cluster-notify.ts +132 -0
- package/src/tools/cluster-peers.ts +3 -1
- package/src/tools/cluster-read.ts +4 -1
- package/src/tools/cluster-send.ts +2 -1
- package/src/tools/cluster-terminal.ts +4 -7
- package/src/tools/cluster-tool.ts +2 -2
- package/src/tools/cluster-write.ts +3 -1
- package/src/types.ts +103 -1
- package/src/web.ts +2 -10
- package/src/cli.ts +0 -243
- package/src/web-ui.ts +0 -1622
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, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
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('expanded');this.nextElementSibling.classList.toggle('expanded')">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('expanded');this.nextElementSibling.classList.toggle('expanded')">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('expanded');this.nextElementSibling.classList.toggle('expanded')">Cluster Tools (' + nodeData.clusterTools.length + ')</div>';
|
|
1304
|
-
html += '<div class="detail-items">';
|
|
1305
|
-
for (const t of nodeData.clusterTools) {
|
|
1306
|
-
html += '<div class="item-row"><span class="item-icon" style="background:var(--orange, #f59e0b)"></span>';
|
|
1307
|
-
html += '<span>' + esc(t) + '</span></div>';
|
|
1308
|
-
}
|
|
1309
|
-
html += '</div></div>';
|
|
1310
|
-
}
|
|
1311
|
-
|
|
1312
|
-
// Tool Proxy
|
|
1313
|
-
if (nodeData.toolProxy) {
|
|
1314
|
-
html += '<div class="detail-section">';
|
|
1315
|
-
html += '<div class="detail-label">Tool Proxy</div>';
|
|
1316
|
-
html += '<div class="detail-grid">';
|
|
1317
|
-
html += '<span class="detail-key">Status</span><span>' + (nodeData.toolProxy.enabled ? '<span style="color:var(--green)">Enabled</span>' : '<span style="color:var(--text-dim)">Disabled</span>') + '</span>';
|
|
1318
|
-
if (nodeData.toolProxy.enabled && nodeData.toolProxy.allow?.length) {
|
|
1319
|
-
html += '<span class="detail-key">Allow</span><span>' + nodeData.toolProxy.allow.map(function(t) { return esc(t); }).join(', ') + '</span>';
|
|
1320
|
-
}
|
|
1321
|
-
if (nodeData.toolProxy.enabled && nodeData.toolProxy.deny?.length) {
|
|
1322
|
-
html += '<span class="detail-key">Deny</span><span>' + nodeData.toolProxy.deny.map(function(t) { return esc(t); }).join(', ') + '</span>';
|
|
1323
|
-
}
|
|
1324
|
-
html += '</div></div>';
|
|
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,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"').replace(/'/g,'''); }
|
|
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
|
-
`;
|