clawmatrix 0.1.14 → 0.1.15
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/BOOTSTRAP.md +40 -8
- package/package.json +4 -2
- package/src/cluster-service.ts +5 -4
- package/src/config.ts +57 -6
- package/src/connection.ts +20 -3
- package/src/device-info.ts +48 -0
- package/src/handoff.ts +20 -5
- package/src/index.ts +14 -4
- package/src/model-proxy.ts +530 -229
- package/src/peer-manager.ts +11 -2
- package/src/router.ts +31 -23
- package/src/types.ts +24 -0
- package/src/web-ui.ts +227 -20
- package/src/web.ts +55 -1
package/src/peer-manager.ts
CHANGED
|
@@ -5,9 +5,11 @@ import type { ClawMatrixConfig, PeerConfig } from "./config.ts";
|
|
|
5
5
|
import { Connection } from "./connection.ts";
|
|
6
6
|
import type { WsTransport } from "./connection.ts";
|
|
7
7
|
import { Router } from "./router.ts";
|
|
8
|
+
import { collectDeviceInfo } from "./device-info.ts";
|
|
8
9
|
import type {
|
|
9
10
|
AnyClusterFrame,
|
|
10
11
|
ClusterFrame,
|
|
12
|
+
DeviceInfo,
|
|
11
13
|
NodeCapabilities,
|
|
12
14
|
PeerInfo,
|
|
13
15
|
PeerSync,
|
|
@@ -24,6 +26,7 @@ export interface PeerManagerEvents {
|
|
|
24
26
|
|
|
25
27
|
export class PeerManager extends EventEmitter<PeerManagerEvents> {
|
|
26
28
|
readonly router: Router;
|
|
29
|
+
readonly localDeviceInfo: DeviceInfo;
|
|
27
30
|
private config: ClawMatrixConfig;
|
|
28
31
|
private localCapabilities: NodeCapabilities;
|
|
29
32
|
private httpServer: Server | null = null;
|
|
@@ -35,19 +38,22 @@ export class PeerManager extends EventEmitter<PeerManagerEvents> {
|
|
|
35
38
|
private inboundConnections = new Map<WsWebSocket, Connection>();
|
|
36
39
|
private gossipDebounceTimer: ReturnType<typeof setTimeout> | null = null;
|
|
37
40
|
|
|
38
|
-
constructor(config: ClawMatrixConfig) {
|
|
41
|
+
constructor(config: ClawMatrixConfig, openclawVersion?: string) {
|
|
39
42
|
super();
|
|
40
43
|
this.config = config;
|
|
44
|
+
this.localDeviceInfo = collectDeviceInfo(openclawVersion);
|
|
41
45
|
this.localCapabilities = {
|
|
42
46
|
nodeId: config.nodeId,
|
|
43
47
|
agents: config.agents,
|
|
44
48
|
models: config.models,
|
|
45
49
|
tags: config.tags,
|
|
50
|
+
deviceInfo: this.localDeviceInfo,
|
|
46
51
|
};
|
|
47
52
|
this.router = new Router(config.nodeId, {
|
|
48
53
|
agents: config.agents,
|
|
49
54
|
models: config.models,
|
|
50
55
|
tags: config.tags,
|
|
56
|
+
deviceInfo: this.localDeviceInfo,
|
|
51
57
|
});
|
|
52
58
|
}
|
|
53
59
|
|
|
@@ -230,6 +236,7 @@ export class PeerManager extends EventEmitter<PeerManagerEvents> {
|
|
|
230
236
|
this.router.addDirectPeer(nodeId, conn, caps);
|
|
231
237
|
|
|
232
238
|
conn.on("message", (frame) => this.onFrame(frame, conn));
|
|
239
|
+
conn.on("latency", (ms) => this.router.updateLatency(nodeId, ms));
|
|
233
240
|
conn.on("close", () => this.onPeerDisconnected(conn));
|
|
234
241
|
|
|
235
242
|
this.sendPeerSync(conn);
|
|
@@ -329,8 +336,10 @@ export class PeerManager extends EventEmitter<PeerManagerEvents> {
|
|
|
329
336
|
if (peer.nodeId === from.remoteNodeId) {
|
|
330
337
|
const prev = this.router.getRoute(peer.nodeId);
|
|
331
338
|
const hadAgents = prev?.agents.length ?? 0;
|
|
339
|
+
const hadDirectPeers = prev?.directPeers.length ?? 0;
|
|
332
340
|
this.router.updatePeerCapabilities(peer.nodeId, peer);
|
|
333
|
-
if (peer.agents.length !== hadAgents || peer.models.length !== (prev?.models.length ?? 0)
|
|
341
|
+
if (peer.agents.length !== hadAgents || peer.models.length !== (prev?.models.length ?? 0)
|
|
342
|
+
|| (peer.directPeers?.length ?? 0) !== hadDirectPeers) {
|
|
334
343
|
changed = true;
|
|
335
344
|
}
|
|
336
345
|
} else {
|
package/src/router.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { ClusterFrame, AnyClusterFrame, PeerInfo, AgentInfo, ModelInfo } from "./types.ts";
|
|
1
|
+
import type { ClusterFrame, AnyClusterFrame, PeerInfo, AgentInfo, ModelInfo, DeviceInfo } from "./types.ts";
|
|
2
2
|
import type { Connection } from "./connection.ts";
|
|
3
3
|
|
|
4
4
|
const DEFAULT_TTL = 3;
|
|
@@ -15,6 +15,8 @@ export interface RouteEntry {
|
|
|
15
15
|
reachableVia: string | null; // relay nodeId, null = direct
|
|
16
16
|
lastSeen: number;
|
|
17
17
|
latencyMs: number;
|
|
18
|
+
directPeers: string[]; // nodeIds this node has direct connections to
|
|
19
|
+
deviceInfo?: DeviceInfo;
|
|
18
20
|
}
|
|
19
21
|
|
|
20
22
|
export class Router {
|
|
@@ -22,6 +24,7 @@ export class Router {
|
|
|
22
24
|
private localAgents: AgentInfo[];
|
|
23
25
|
private localModels: ModelInfo[];
|
|
24
26
|
private localTags: string[];
|
|
27
|
+
private localDeviceInfo?: DeviceInfo;
|
|
25
28
|
private routes = new Map<string, RouteEntry>();
|
|
26
29
|
private connections = new Map<string, Connection>(); // nodeId → direct connection
|
|
27
30
|
private seenFrames = new Map<string, number>(); // frameId → timestamp
|
|
@@ -29,12 +32,13 @@ export class Router {
|
|
|
29
32
|
|
|
30
33
|
constructor(
|
|
31
34
|
nodeId: string,
|
|
32
|
-
localCapabilities?: { agents: AgentInfo[]; models: ModelInfo[]; tags: string[] },
|
|
35
|
+
localCapabilities?: { agents: AgentInfo[]; models: ModelInfo[]; tags: string[]; deviceInfo?: DeviceInfo },
|
|
33
36
|
) {
|
|
34
37
|
this.nodeId = nodeId;
|
|
35
38
|
this.localAgents = localCapabilities?.agents ?? [];
|
|
36
39
|
this.localModels = localCapabilities?.models ?? [];
|
|
37
40
|
this.localTags = localCapabilities?.tags ?? [];
|
|
41
|
+
this.localDeviceInfo = localCapabilities?.deviceInfo;
|
|
38
42
|
|
|
39
43
|
this.pruneTimer = setInterval(() => this.pruneSeenFrames(true), PRUNE_INTERVAL);
|
|
40
44
|
}
|
|
@@ -52,7 +56,7 @@ export class Router {
|
|
|
52
56
|
addDirectPeer(
|
|
53
57
|
nodeId: string,
|
|
54
58
|
connection: Connection,
|
|
55
|
-
capabilities: { agents: AgentInfo[]; models: ModelInfo[]; tags: string[] },
|
|
59
|
+
capabilities: { agents: AgentInfo[]; models: ModelInfo[]; tags: string[]; deviceInfo?: DeviceInfo },
|
|
56
60
|
) {
|
|
57
61
|
this.connections.set(nodeId, connection);
|
|
58
62
|
this.routes.set(nodeId, {
|
|
@@ -64,6 +68,8 @@ export class Router {
|
|
|
64
68
|
reachableVia: null,
|
|
65
69
|
lastSeen: Date.now(),
|
|
66
70
|
latencyMs: 0,
|
|
71
|
+
directPeers: [],
|
|
72
|
+
deviceInfo: capabilities.deviceInfo,
|
|
67
73
|
});
|
|
68
74
|
}
|
|
69
75
|
|
|
@@ -83,6 +89,8 @@ export class Router {
|
|
|
83
89
|
reachableVia: viaNodeId,
|
|
84
90
|
lastSeen: Date.now(),
|
|
85
91
|
latencyMs: 0,
|
|
92
|
+
directPeers: peer.directPeers ?? [],
|
|
93
|
+
deviceInfo: peer.deviceInfo,
|
|
86
94
|
});
|
|
87
95
|
}
|
|
88
96
|
|
|
@@ -99,17 +107,30 @@ export class Router {
|
|
|
99
107
|
|
|
100
108
|
updatePeerCapabilities(
|
|
101
109
|
nodeId: string,
|
|
102
|
-
capabilities: { agents: AgentInfo[]; models: ModelInfo[]; tags: string[] },
|
|
110
|
+
capabilities: { agents: AgentInfo[]; models: ModelInfo[]; tags: string[]; directPeers?: string[]; deviceInfo?: DeviceInfo },
|
|
103
111
|
) {
|
|
104
112
|
const entry = this.routes.get(nodeId);
|
|
105
113
|
if (entry) {
|
|
106
114
|
entry.agents = capabilities.agents;
|
|
107
115
|
entry.models = capabilities.models;
|
|
108
116
|
entry.tags = capabilities.tags;
|
|
117
|
+
if (capabilities.directPeers) {
|
|
118
|
+
entry.directPeers = capabilities.directPeers;
|
|
119
|
+
}
|
|
120
|
+
if (capabilities.deviceInfo) {
|
|
121
|
+
entry.deviceInfo = capabilities.deviceInfo;
|
|
122
|
+
}
|
|
109
123
|
entry.lastSeen = Date.now();
|
|
110
124
|
}
|
|
111
125
|
}
|
|
112
126
|
|
|
127
|
+
updateLatency(nodeId: string, latencyMs: number) {
|
|
128
|
+
const entry = this.routes.get(nodeId);
|
|
129
|
+
if (entry) {
|
|
130
|
+
entry.latencyMs = latencyMs;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
113
134
|
// ── Routing ────────────────────────────────────────────────────
|
|
114
135
|
getRoute(nodeId: string): RouteEntry | undefined {
|
|
115
136
|
return this.routes.get(nodeId);
|
|
@@ -152,24 +173,6 @@ export class Router {
|
|
|
152
173
|
return candidates[0];
|
|
153
174
|
}
|
|
154
175
|
|
|
155
|
-
/** Find node that has a specific model. */
|
|
156
|
-
resolveModel(modelId: string): RouteEntry | undefined {
|
|
157
|
-
let candidates: RouteEntry[] = [];
|
|
158
|
-
for (const entry of this.routes.values()) {
|
|
159
|
-
if (entry.models.some((m) => m.id === modelId)) {
|
|
160
|
-
candidates.push(entry);
|
|
161
|
-
}
|
|
162
|
-
}
|
|
163
|
-
if (candidates.length === 0) return undefined;
|
|
164
|
-
|
|
165
|
-
candidates.sort((a, b) => {
|
|
166
|
-
const aDirect = a.connection ? 0 : 1;
|
|
167
|
-
const bDirect = b.connection ? 0 : 1;
|
|
168
|
-
if (aDirect !== bDirect) return aDirect - bDirect;
|
|
169
|
-
return a.latencyMs - b.latencyMs;
|
|
170
|
-
});
|
|
171
|
-
return candidates[0];
|
|
172
|
-
}
|
|
173
176
|
|
|
174
177
|
/** Resolve a node target (nodeId or "tags:<tag>"). */
|
|
175
178
|
resolveNode(target: string): RouteEntry | undefined {
|
|
@@ -275,12 +278,15 @@ export class Router {
|
|
|
275
278
|
/** Build PeerInfo list for peer_sync. */
|
|
276
279
|
buildPeerSyncPayload(): PeerInfo[] {
|
|
277
280
|
const peers: PeerInfo[] = [];
|
|
278
|
-
// Include ourselves
|
|
281
|
+
// Include ourselves with our direct peer list
|
|
282
|
+
const myDirectPeers = [...this.connections.keys()];
|
|
279
283
|
peers.push({
|
|
280
284
|
nodeId: this.nodeId,
|
|
281
285
|
agents: this.localAgents,
|
|
282
286
|
models: this.localModels,
|
|
283
287
|
tags: this.localTags,
|
|
288
|
+
directPeers: myDirectPeers,
|
|
289
|
+
deviceInfo: this.localDeviceInfo,
|
|
284
290
|
});
|
|
285
291
|
for (const entry of this.routes.values()) {
|
|
286
292
|
peers.push({
|
|
@@ -289,6 +295,8 @@ export class Router {
|
|
|
289
295
|
models: entry.models,
|
|
290
296
|
tags: entry.tags,
|
|
291
297
|
reachableVia: entry.reachableVia ?? undefined,
|
|
298
|
+
directPeers: entry.directPeers.length > 0 ? entry.directPeers : undefined,
|
|
299
|
+
deviceInfo: entry.deviceInfo,
|
|
292
300
|
});
|
|
293
301
|
}
|
|
294
302
|
return peers;
|
package/src/types.ts
CHANGED
|
@@ -23,6 +23,7 @@ export interface AuthRequest extends ClusterFrame {
|
|
|
23
23
|
agents?: AgentInfo[];
|
|
24
24
|
models?: ModelInfo[];
|
|
25
25
|
tags?: string[];
|
|
26
|
+
deviceInfo?: DeviceInfo;
|
|
26
27
|
};
|
|
27
28
|
}
|
|
28
29
|
|
|
@@ -33,6 +34,7 @@ export interface AuthOk extends ClusterFrame {
|
|
|
33
34
|
agents: AgentInfo[];
|
|
34
35
|
models: ModelInfo[];
|
|
35
36
|
tags: string[];
|
|
37
|
+
deviceInfo?: DeviceInfo;
|
|
36
38
|
};
|
|
37
39
|
}
|
|
38
40
|
|
|
@@ -74,6 +76,8 @@ export interface ModelRequest extends ClusterFrame {
|
|
|
74
76
|
id: string;
|
|
75
77
|
payload: {
|
|
76
78
|
model: string;
|
|
79
|
+
provider?: string;
|
|
80
|
+
api?: string;
|
|
77
81
|
messages: unknown[];
|
|
78
82
|
temperature?: number;
|
|
79
83
|
maxTokens?: number;
|
|
@@ -87,6 +91,9 @@ export interface ModelResponse extends ClusterFrame {
|
|
|
87
91
|
payload: {
|
|
88
92
|
success: boolean;
|
|
89
93
|
content?: string;
|
|
94
|
+
/** Full message object from upstream (choices[0].message or responses output).
|
|
95
|
+
* Carries tool_calls, refusal, etc. that `content` alone cannot represent. */
|
|
96
|
+
message?: unknown;
|
|
90
97
|
usage?: { inputTokens: number; outputTokens: number };
|
|
91
98
|
error?: string;
|
|
92
99
|
};
|
|
@@ -97,6 +104,9 @@ export interface ModelStreamChunk extends ClusterFrame {
|
|
|
97
104
|
id: string;
|
|
98
105
|
payload: {
|
|
99
106
|
delta: string;
|
|
107
|
+
/** Full delta object from upstream (choices[0].delta).
|
|
108
|
+
* Carries tool_calls chunks that `delta` string cannot represent. */
|
|
109
|
+
deltaObj?: unknown;
|
|
100
110
|
done: boolean;
|
|
101
111
|
usage?: { inputTokens: number; outputTokens: number };
|
|
102
112
|
};
|
|
@@ -163,6 +173,17 @@ export interface ToolProxyResponse extends ClusterFrame {
|
|
|
163
173
|
};
|
|
164
174
|
}
|
|
165
175
|
|
|
176
|
+
// ── Device info ───────────────────────────────────────────────────
|
|
177
|
+
export interface DeviceInfo {
|
|
178
|
+
os: string; // e.g. "Darwin 24.6.0", "Linux 6.1.0"
|
|
179
|
+
arch: string; // e.g. "arm64", "x64"
|
|
180
|
+
cpuModel: string; // e.g. "Apple M1 Pro"
|
|
181
|
+
cpuCores: number; // logical CPU cores
|
|
182
|
+
totalMemoryMB: number; // total system memory in MB
|
|
183
|
+
hostname: string; // machine hostname
|
|
184
|
+
openclawVersion?: string; // e.g. "2026.3.7"
|
|
185
|
+
}
|
|
186
|
+
|
|
166
187
|
// ── Shared info types ──────────────────────────────────────────────
|
|
167
188
|
export interface AgentInfo {
|
|
168
189
|
id: string;
|
|
@@ -198,6 +219,8 @@ export interface PeerInfo {
|
|
|
198
219
|
models: ModelInfo[];
|
|
199
220
|
tags: string[];
|
|
200
221
|
reachableVia?: string; // nodeId of the relay node
|
|
222
|
+
directPeers?: string[]; // nodeIds this node has direct connections to
|
|
223
|
+
deviceInfo?: DeviceInfo; // system/hardware info
|
|
201
224
|
}
|
|
202
225
|
|
|
203
226
|
export interface NodeCapabilities {
|
|
@@ -205,6 +228,7 @@ export interface NodeCapabilities {
|
|
|
205
228
|
agents: AgentInfo[];
|
|
206
229
|
models: ModelInfo[];
|
|
207
230
|
tags: string[];
|
|
231
|
+
deviceInfo?: DeviceInfo;
|
|
208
232
|
}
|
|
209
233
|
|
|
210
234
|
// ── Union of all frame types ───────────────────────────────────────
|
package/src/web-ui.ts
CHANGED
|
@@ -94,11 +94,14 @@ ${CSS}
|
|
|
94
94
|
<div class="panel-right">
|
|
95
95
|
<div class="card chat-card">
|
|
96
96
|
<div class="card-header">
|
|
97
|
-
<h2>Chat</h2>
|
|
97
|
+
<h2 id="chat-title">Chat</h2>
|
|
98
98
|
<div class="chat-selects">
|
|
99
99
|
<select id="chat-model" title="Model">
|
|
100
100
|
<option value="">Select model...</option>
|
|
101
101
|
</select>
|
|
102
|
+
<select id="chat-agent" title="Agent" class="hidden">
|
|
103
|
+
<option value="">Select agent...</option>
|
|
104
|
+
</select>
|
|
102
105
|
</div>
|
|
103
106
|
</div>
|
|
104
107
|
<div id="chat-messages" class="chat-messages">
|
|
@@ -381,6 +384,8 @@ body {
|
|
|
381
384
|
padding: 16px 18px;
|
|
382
385
|
font-size: 13px;
|
|
383
386
|
line-height: 1.7;
|
|
387
|
+
max-height: 320px;
|
|
388
|
+
overflow-y: auto;
|
|
384
389
|
}
|
|
385
390
|
|
|
386
391
|
.detail-body .detail-section {
|
|
@@ -395,6 +400,34 @@ body {
|
|
|
395
400
|
margin-bottom: 4px;
|
|
396
401
|
}
|
|
397
402
|
|
|
403
|
+
.detail-body .detail-label.collapsible {
|
|
404
|
+
cursor: pointer;
|
|
405
|
+
user-select: none;
|
|
406
|
+
display: flex;
|
|
407
|
+
align-items: center;
|
|
408
|
+
gap: 4px;
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
.detail-body .detail-label.collapsible::before {
|
|
412
|
+
content: '▶';
|
|
413
|
+
font-size: 8px;
|
|
414
|
+
transition: transform 0.15s;
|
|
415
|
+
display: inline-block;
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
.detail-body .detail-label.collapsible.expanded::before {
|
|
419
|
+
transform: rotate(90deg);
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
.detail-body .detail-items {
|
|
423
|
+
display: none;
|
|
424
|
+
padding-top: 2px;
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
.detail-body .detail-items.expanded {
|
|
428
|
+
display: block;
|
|
429
|
+
}
|
|
430
|
+
|
|
398
431
|
.detail-body .detail-tags {
|
|
399
432
|
display: flex;
|
|
400
433
|
flex-wrap: wrap;
|
|
@@ -410,6 +443,18 @@ body {
|
|
|
410
443
|
color: var(--text-secondary);
|
|
411
444
|
}
|
|
412
445
|
|
|
446
|
+
.detail-body .detail-grid {
|
|
447
|
+
display: grid;
|
|
448
|
+
grid-template-columns: auto 1fr;
|
|
449
|
+
gap: 4px 12px;
|
|
450
|
+
font-size: 12px;
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
.detail-body .detail-key {
|
|
454
|
+
color: var(--text-dim);
|
|
455
|
+
font-weight: 500;
|
|
456
|
+
}
|
|
457
|
+
|
|
413
458
|
.detail-body .item-row {
|
|
414
459
|
display: flex;
|
|
415
460
|
align-items: center;
|
|
@@ -584,6 +629,8 @@ const JS = `
|
|
|
584
629
|
let chatStreaming = false;
|
|
585
630
|
let meshNodes = [];
|
|
586
631
|
let meshEdges = [];
|
|
632
|
+
let chatMode = 'model'; // 'model' | 'handoff'
|
|
633
|
+
let handoffNodeId = null;
|
|
587
634
|
let hoveredNode = null;
|
|
588
635
|
let dragNode = null;
|
|
589
636
|
let dragOffset = { x: 0, y: 0 };
|
|
@@ -755,14 +802,31 @@ const JS = `
|
|
|
755
802
|
|
|
756
803
|
// Build edges
|
|
757
804
|
meshEdges = [];
|
|
805
|
+
const edgeSet = new Set();
|
|
806
|
+
function addEdge(a, b, type) {
|
|
807
|
+
const key = [a, b].sort().join('::') + '::' + type;
|
|
808
|
+
if (edgeSet.has(key)) return;
|
|
809
|
+
edgeSet.add(key);
|
|
810
|
+
meshEdges.push({ from: a, to: b, type: type });
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
// Edges from self to direct peers
|
|
758
814
|
for (const p of state.peers) {
|
|
759
815
|
if (p.connection === 'direct') {
|
|
760
|
-
|
|
816
|
+
addEdge(state.nodeId, p.nodeId, 'direct');
|
|
761
817
|
} else if (p.reachableVia) {
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
818
|
+
addEdge(p.reachableVia, p.nodeId, 'relay');
|
|
819
|
+
addEdge(state.nodeId, p.reachableVia, 'direct');
|
|
820
|
+
}
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
// Edges between remote peers (from directPeers data)
|
|
824
|
+
for (const p of state.peers) {
|
|
825
|
+
if (p.directPeers) {
|
|
826
|
+
for (const dp of p.directPeers) {
|
|
827
|
+
if (dp !== state.nodeId) {
|
|
828
|
+
addEdge(p.nodeId, dp, 'direct');
|
|
829
|
+
}
|
|
766
830
|
}
|
|
767
831
|
}
|
|
768
832
|
}
|
|
@@ -1000,6 +1064,7 @@ const JS = `
|
|
|
1000
1064
|
// Also select on click
|
|
1001
1065
|
selectedNode = hit.id;
|
|
1002
1066
|
updateDetail(hit.id);
|
|
1067
|
+
setChatMode(hit.id !== state.nodeId ? 'handoff' : 'model', hit.id !== state.nodeId ? hit.id : null);
|
|
1003
1068
|
}
|
|
1004
1069
|
}
|
|
1005
1070
|
|
|
@@ -1031,6 +1096,7 @@ const JS = `
|
|
|
1031
1096
|
if (!hit) {
|
|
1032
1097
|
selectedNode = null;
|
|
1033
1098
|
$('node-detail').classList.add('hidden');
|
|
1099
|
+
setChatMode('model', null);
|
|
1034
1100
|
}
|
|
1035
1101
|
}
|
|
1036
1102
|
}
|
|
@@ -1061,6 +1127,22 @@ const JS = `
|
|
|
1061
1127
|
|
|
1062
1128
|
let html = '';
|
|
1063
1129
|
|
|
1130
|
+
// Device info
|
|
1131
|
+
if (nodeData.deviceInfo) {
|
|
1132
|
+
const d = nodeData.deviceInfo;
|
|
1133
|
+
html += '<div class="detail-section">';
|
|
1134
|
+
html += '<div class="detail-label">System</div>';
|
|
1135
|
+
html += '<div class="detail-grid">';
|
|
1136
|
+
html += '<span class="detail-key">OS</span><span>' + esc(d.os) + '</span>';
|
|
1137
|
+
html += '<span class="detail-key">Arch</span><span>' + esc(d.arch) + '</span>';
|
|
1138
|
+
html += '<span class="detail-key">Host</span><span>' + esc(d.hostname) + '</span>';
|
|
1139
|
+
html += '<span class="detail-key">CPU</span><span>' + esc(d.cpuModel) + ' (' + d.cpuCores + ' cores)</span>';
|
|
1140
|
+
html += '<span class="detail-key">Memory</span><span>' + formatMemory(d.totalMemoryMB) + '</span>';
|
|
1141
|
+
if (d.openclawVersion && d.openclawVersion !== 'unknown') html += '<span class="detail-key">OpenClaw</span><span>' + esc(d.openclawVersion) + '</span>';
|
|
1142
|
+
html += '</div>';
|
|
1143
|
+
html += '</div>';
|
|
1144
|
+
}
|
|
1145
|
+
|
|
1064
1146
|
// Connection info
|
|
1065
1147
|
if (!isLocal && nodeData.connection === 'relay' && nodeData.reachableVia) {
|
|
1066
1148
|
html += '<div class="detail-section">';
|
|
@@ -1072,27 +1154,29 @@ const JS = `
|
|
|
1072
1154
|
// Models
|
|
1073
1155
|
if (nodeData.models?.length) {
|
|
1074
1156
|
html += '<div class="detail-section">';
|
|
1075
|
-
html += '<div class="detail-label">Models</div>';
|
|
1157
|
+
html += '<div class="detail-label collapsible" onclick="this.classList.toggle('expanded');this.nextElementSibling.classList.toggle('expanded')">Models (' + nodeData.models.length + ')</div>';
|
|
1158
|
+
html += '<div class="detail-items">';
|
|
1076
1159
|
for (const m of nodeData.models) {
|
|
1077
1160
|
html += '<div class="item-row"><span class="item-icon" style="background:var(--accent)"></span>';
|
|
1078
1161
|
html += '<span>' + esc(m.id) + '</span>';
|
|
1079
1162
|
if (m.description) html += '<span style="color:var(--text-dim);font-size:11px"> — ' + esc(m.description) + '</span>';
|
|
1080
1163
|
html += '</div>';
|
|
1081
1164
|
}
|
|
1082
|
-
html += '</div>';
|
|
1165
|
+
html += '</div></div>';
|
|
1083
1166
|
}
|
|
1084
1167
|
|
|
1085
1168
|
// Agents
|
|
1086
1169
|
if (nodeData.agents?.length) {
|
|
1087
1170
|
html += '<div class="detail-section">';
|
|
1088
|
-
html += '<div class="detail-label">Agents</div>';
|
|
1171
|
+
html += '<div class="detail-label collapsible" onclick="this.classList.toggle('expanded');this.nextElementSibling.classList.toggle('expanded')">Agents (' + nodeData.agents.length + ')</div>';
|
|
1172
|
+
html += '<div class="detail-items">';
|
|
1089
1173
|
for (const a of nodeData.agents) {
|
|
1090
1174
|
html += '<div class="item-row"><span class="item-icon" style="background:var(--green)"></span>';
|
|
1091
1175
|
html += '<span>' + esc(a.id) + '</span>';
|
|
1092
1176
|
if (a.description) html += '<span style="color:var(--text-dim);font-size:11px"> — ' + esc(a.description) + '</span>';
|
|
1093
1177
|
html += '</div>';
|
|
1094
1178
|
}
|
|
1095
|
-
html += '</div>';
|
|
1179
|
+
html += '</div></div>';
|
|
1096
1180
|
}
|
|
1097
1181
|
|
|
1098
1182
|
// Tags
|
|
@@ -1116,6 +1200,51 @@ const JS = `
|
|
|
1116
1200
|
|
|
1117
1201
|
function esc(s) { return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"').replace(/'/g,'''); }
|
|
1118
1202
|
|
|
1203
|
+
function formatMemory(mb) {
|
|
1204
|
+
if (mb >= 1024) return (mb / 1024).toFixed(1) + ' GB';
|
|
1205
|
+
return mb + ' MB';
|
|
1206
|
+
}
|
|
1207
|
+
|
|
1208
|
+
// ── Chat mode ──
|
|
1209
|
+
function setChatMode(mode, nodeId) {
|
|
1210
|
+
if (chatMode === mode && handoffNodeId === nodeId) return;
|
|
1211
|
+
chatMode = mode;
|
|
1212
|
+
handoffNodeId = nodeId;
|
|
1213
|
+
|
|
1214
|
+
const title = $('chat-title');
|
|
1215
|
+
const modelSel = $('chat-model');
|
|
1216
|
+
const agentSel = $('chat-agent');
|
|
1217
|
+
|
|
1218
|
+
if (mode === 'handoff' && nodeId) {
|
|
1219
|
+
title.textContent = 'Handoff \\u2192 ' + nodeId;
|
|
1220
|
+
modelSel.classList.add('hidden');
|
|
1221
|
+
agentSel.classList.remove('hidden');
|
|
1222
|
+
updateAgentSelect();
|
|
1223
|
+
} else {
|
|
1224
|
+
title.textContent = 'Chat';
|
|
1225
|
+
modelSel.classList.remove('hidden');
|
|
1226
|
+
agentSel.classList.add('hidden');
|
|
1227
|
+
}
|
|
1228
|
+
|
|
1229
|
+
// Clear conversation when switching modes
|
|
1230
|
+
chatMessages = [];
|
|
1231
|
+
renderChatMessages();
|
|
1232
|
+
}
|
|
1233
|
+
|
|
1234
|
+
function updateAgentSelect() {
|
|
1235
|
+
const sel = $('chat-agent');
|
|
1236
|
+
const node = state.peers.find(p => p.nodeId === handoffNodeId);
|
|
1237
|
+
sel.innerHTML = '<option value="">Select agent...</option>';
|
|
1238
|
+
if (node?.agents) {
|
|
1239
|
+
for (const a of node.agents) {
|
|
1240
|
+
const opt = document.createElement('option');
|
|
1241
|
+
opt.value = a.id;
|
|
1242
|
+
opt.textContent = a.id + (a.description ? ' \\u2014 ' + a.description : '');
|
|
1243
|
+
sel.appendChild(opt);
|
|
1244
|
+
}
|
|
1245
|
+
}
|
|
1246
|
+
}
|
|
1247
|
+
|
|
1119
1248
|
// ── Model select ──
|
|
1120
1249
|
function updateModelSelect() {
|
|
1121
1250
|
const sel = $('chat-model');
|
|
@@ -1169,23 +1298,27 @@ const JS = `
|
|
|
1169
1298
|
e.preventDefault();
|
|
1170
1299
|
if (chatStreaming) return;
|
|
1171
1300
|
|
|
1172
|
-
const modelVal = $('chat-model').value;
|
|
1173
|
-
if (!modelVal) { alert('Please select a model'); return; }
|
|
1174
|
-
|
|
1175
1301
|
const text = chatInput.value.trim();
|
|
1176
1302
|
if (!text) return;
|
|
1177
1303
|
|
|
1304
|
+
if (chatMode === 'handoff') {
|
|
1305
|
+
await submitHandoff(text);
|
|
1306
|
+
} else {
|
|
1307
|
+
await submitChat(text);
|
|
1308
|
+
}
|
|
1309
|
+
});
|
|
1310
|
+
|
|
1311
|
+
async function submitChat(text) {
|
|
1312
|
+
const modelVal = $('chat-model').value;
|
|
1313
|
+
if (!modelVal) { alert('Please select a model'); return; }
|
|
1314
|
+
|
|
1178
1315
|
chatInput.value = '';
|
|
1179
1316
|
chatInput.style.height = 'auto';
|
|
1180
1317
|
|
|
1181
1318
|
const [nodeId, ...modelParts] = modelVal.split('/');
|
|
1182
1319
|
const model = modelParts.join('/');
|
|
1183
1320
|
|
|
1184
|
-
// Add user message
|
|
1185
1321
|
chatMessages.push({ role: 'user', content: text });
|
|
1186
|
-
renderChatMessages();
|
|
1187
|
-
|
|
1188
|
-
// Add placeholder for assistant
|
|
1189
1322
|
chatMessages.push({ role: 'assistant', content: '' });
|
|
1190
1323
|
renderChatMessages();
|
|
1191
1324
|
|
|
@@ -1238,7 +1371,6 @@ const JS = `
|
|
|
1238
1371
|
}
|
|
1239
1372
|
}
|
|
1240
1373
|
|
|
1241
|
-
// Clean up empty assistant message
|
|
1242
1374
|
if (!chatMessages[chatMessages.length - 1].content) {
|
|
1243
1375
|
chatMessages[chatMessages.length - 1] = { role: 'error', content: 'No response received' };
|
|
1244
1376
|
}
|
|
@@ -1251,11 +1383,86 @@ const JS = `
|
|
|
1251
1383
|
$('chat-send').disabled = false;
|
|
1252
1384
|
chatInput.focus();
|
|
1253
1385
|
}
|
|
1254
|
-
}
|
|
1386
|
+
}
|
|
1387
|
+
|
|
1388
|
+
async function submitHandoff(text) {
|
|
1389
|
+
const agent = $('chat-agent').value;
|
|
1390
|
+
if (!agent) { alert('Please select an agent'); return; }
|
|
1391
|
+
|
|
1392
|
+
chatInput.value = '';
|
|
1393
|
+
chatInput.style.height = 'auto';
|
|
1394
|
+
|
|
1395
|
+
chatMessages.push({ role: 'user', content: text });
|
|
1396
|
+
chatMessages.push({ role: 'assistant', content: '' });
|
|
1397
|
+
renderChatMessages();
|
|
1398
|
+
|
|
1399
|
+
chatStreaming = true;
|
|
1400
|
+
$('chat-send').disabled = true;
|
|
1401
|
+
|
|
1402
|
+
try {
|
|
1403
|
+
const res = await fetch('/api/handoff', {
|
|
1404
|
+
method: 'POST',
|
|
1405
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1406
|
+
body: JSON.stringify({ nodeId: handoffNodeId, agent, task: text }),
|
|
1407
|
+
});
|
|
1408
|
+
|
|
1409
|
+
if (!res.ok) {
|
|
1410
|
+
const err = await res.text();
|
|
1411
|
+
chatMessages[chatMessages.length - 1] = { role: 'error', content: 'Error: ' + err };
|
|
1412
|
+
renderChatMessages();
|
|
1413
|
+
return;
|
|
1414
|
+
}
|
|
1415
|
+
|
|
1416
|
+
const reader = res.body.getReader();
|
|
1417
|
+
const decoder = new TextDecoder();
|
|
1418
|
+
let buffer = '';
|
|
1419
|
+
|
|
1420
|
+
while (true) {
|
|
1421
|
+
const { done, value } = await reader.read();
|
|
1422
|
+
if (done) break;
|
|
1423
|
+
|
|
1424
|
+
buffer += decoder.decode(value, { stream: true });
|
|
1425
|
+
const lines = buffer.split('\\n');
|
|
1426
|
+
buffer = lines.pop();
|
|
1427
|
+
|
|
1428
|
+
for (const line of lines) {
|
|
1429
|
+
if (!line.startsWith('data: ')) continue;
|
|
1430
|
+
const data = line.slice(6).trim();
|
|
1431
|
+
|
|
1432
|
+
try {
|
|
1433
|
+
const parsed = JSON.parse(data);
|
|
1434
|
+
if (parsed.type === 'delta') {
|
|
1435
|
+
chatMessages[chatMessages.length - 1].content += parsed.content;
|
|
1436
|
+
renderChatMessages();
|
|
1437
|
+
} else if (parsed.type === 'error') {
|
|
1438
|
+
chatMessages[chatMessages.length - 1] = { role: 'error', content: 'Error: ' + parsed.error };
|
|
1439
|
+
renderChatMessages();
|
|
1440
|
+
}
|
|
1441
|
+
// type === 'done' — stream already accumulated the content
|
|
1442
|
+
} catch {}
|
|
1443
|
+
}
|
|
1444
|
+
}
|
|
1445
|
+
|
|
1446
|
+
if (!chatMessages[chatMessages.length - 1].content) {
|
|
1447
|
+
chatMessages[chatMessages.length - 1] = { role: 'error', content: 'No response received' };
|
|
1448
|
+
}
|
|
1449
|
+
renderChatMessages();
|
|
1450
|
+
} catch (err) {
|
|
1451
|
+
chatMessages[chatMessages.length - 1] = { role: 'error', content: 'Error: ' + err.message };
|
|
1452
|
+
renderChatMessages();
|
|
1453
|
+
} finally {
|
|
1454
|
+
chatStreaming = false;
|
|
1455
|
+
$('chat-send').disabled = false;
|
|
1456
|
+
chatInput.focus();
|
|
1457
|
+
}
|
|
1458
|
+
}
|
|
1255
1459
|
|
|
1256
1460
|
function renderChatMessages() {
|
|
1257
1461
|
if (chatMessages.length === 0) {
|
|
1258
|
-
|
|
1462
|
+
const hint = chatMode === 'handoff'
|
|
1463
|
+
? 'Select an agent and describe your task.'
|
|
1464
|
+
: 'Select a model and start chatting with your cluster.';
|
|
1465
|
+
chatMsgs.innerHTML = '<div class="chat-empty">' + hint + '</div>';
|
|
1259
1466
|
return;
|
|
1260
1467
|
}
|
|
1261
1468
|
|