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.
@@ -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
- meshEdges.push({ from: state.nodeId, to: p.nodeId, type: 'direct' });
816
+ addEdge(state.nodeId, p.nodeId, 'direct');
761
817
  } else if (p.reachableVia) {
762
- meshEdges.push({ from: p.reachableVia, to: p.nodeId, type: 'relay' });
763
- // Also ensure edge from self to relay node
764
- if (!meshEdges.find(e => (e.from === state.nodeId && e.to === p.reachableVia) || (e.to === state.nodeId && e.from === p.reachableVia))) {
765
- meshEdges.push({ from: state.nodeId, to: p.reachableVia, type: 'direct' });
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(&#39;expanded&#39;);this.nextElementSibling.classList.toggle(&#39;expanded&#39;)">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(&#39;expanded&#39;);this.nextElementSibling.classList.toggle(&#39;expanded&#39;)">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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;').replace(/'/g,'&#39;'); }
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
- chatMsgs.innerHTML = '<div class="chat-empty">Select a model and start chatting with your cluster.</div>';
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