openclaw-navigator 5.6.3 → 5.7.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.
Files changed (2) hide show
  1. package/cli.mjs +155 -113
  2. package/package.json +1 -1
package/cli.mjs CHANGED
@@ -1137,11 +1137,12 @@ function handleRequest(req, res) {
1137
1137
  }
1138
1138
 
1139
1139
  // ── Chat send: store locally + respond immediately ─────────────────────
1140
- // 1. Store user message in bridge memory (for MCP tools to read)
1141
- // 2. Respond to Navigator immediately (don't block on gateway)
1142
- // 3. In background: try relaying to gateway for AI processing
1143
- // - If gateway responds store assistant message + broadcast via WS
1144
- // - If gateway hangs/fails agent will respond via MCP tools later
1140
+ // ── Chat send: Navigator sidepane sends a message ─────────────────────
1141
+ // 1. Store user message in bridge memory
1142
+ // 2. Respond to Navigator immediately
1143
+ // 3. In background: relay to the BFF on port 4000 (/api/chat) for AI processing
1144
+ // The AI lives behind the BFF, NOT on the gateway (port 18789 returns 405).
1145
+ // The BFF returns SSE — collect it, store the response, broadcast via WS.
1145
1146
  if (path === "/api/sessions/send" && req.method === "POST") {
1146
1147
  readBody(req)
1147
1148
  .then((bodyStr) => {
@@ -1172,47 +1173,63 @@ function handleRequest(req, res) {
1172
1173
  // 2. Respond immediately — don't block Navigator
1173
1174
  sendJSON(res, 200, { ok: true, stored: true, messageCount: session.messages.length });
1174
1175
 
1175
- // 3. Background: relay to gateway for AI processing
1176
- const proxyBody = JSON.stringify({ message, sessionKey });
1176
+ // 3. Background: relay to BFF (port 4000) /api/chat for AI processing
1177
+ // Format: OpenAI-compatible chat completions with message history
1178
+ const chatHistory = session.messages.map((m) => ({
1179
+ role: m.role,
1180
+ content: m.content,
1181
+ }));
1182
+ const proxyBody = JSON.stringify({
1183
+ messages: chatHistory,
1184
+ stream: true,
1185
+ });
1177
1186
  const proxyOpts = {
1178
1187
  hostname: "127.0.0.1",
1179
- port: ocGatewayPort,
1180
- path: `/api/sessions/send`,
1188
+ port: ocUIPort,
1189
+ path: `/api/chat`,
1181
1190
  method: "POST",
1182
- timeout: 60000, // 60s — agent may take a while to respond
1191
+ timeout: 120000, // 2 min — agent may take a while
1183
1192
  headers: {
1184
1193
  "content-type": "application/json",
1185
1194
  "content-length": Buffer.byteLength(proxyBody),
1186
1195
  },
1187
1196
  };
1188
1197
 
1198
+ console.log(` ${DIM}→ Relaying to BFF /api/chat (port ${ocUIPort}) with ${chatHistory.length} messages${RESET}`);
1199
+
1189
1200
  const proxyReq = httpRequest(proxyOpts, (proxyRes) => {
1190
1201
  const contentType = (proxyRes.headers["content-type"] || "").toLowerCase();
1191
1202
  const isSSE = contentType.includes("text/event-stream");
1192
-
1193
- if (isSSE) {
1194
- // SSE response: collect stream, broadcast chunks via WebSocket
1195
- let fullText = "";
1196
- let buffer = "";
1197
- proxyRes.setEncoding("utf-8");
1198
- proxyRes.on("data", (chunk) => {
1199
- buffer += chunk;
1200
- const lines = buffer.split("\n");
1201
- buffer = lines.pop() || "";
1203
+ console.log(` ${DIM}← BFF response: ${proxyRes.statusCode} ${contentType || "no-content-type"}${RESET}`);
1204
+
1205
+ // Collect the response (SSE or JSON) and extract the assistant's text
1206
+ let fullText = "";
1207
+ let sseBuffer = "";
1208
+
1209
+ proxyRes.setEncoding("utf-8");
1210
+ proxyRes.on("data", (chunk) => {
1211
+ if (isSSE) {
1212
+ sseBuffer += chunk;
1213
+ const lines = sseBuffer.split("\n");
1214
+ sseBuffer = lines.pop() || "";
1202
1215
  for (const line of lines) {
1203
1216
  if (line.startsWith("data: ")) {
1204
1217
  const raw = line.slice(6).trim();
1205
- if (raw === "[DONE]") continue;
1218
+ if (raw === "[DONE]" || !raw) continue;
1206
1219
  try {
1207
1220
  const evt = JSON.parse(raw);
1208
- const text =
1209
- evt.text || evt.content || evt.delta?.text || evt.delta?.content || "";
1210
- if (text) {
1211
- fullText += text;
1221
+ const delta =
1222
+ evt.choices?.[0]?.delta?.content ||
1223
+ evt.delta?.text ||
1224
+ evt.text ||
1225
+ evt.content ||
1226
+ "";
1227
+ if (delta) {
1228
+ fullText += delta;
1212
1229
  broadcastToWS({
1213
1230
  type: "chat.delta",
1214
1231
  text: fullText,
1215
- delta: text,
1232
+ delta,
1216
1233
  sessionKey,
1217
1234
  timestamp: Date.now(),
1218
1235
  });
@@ -1222,71 +1239,64 @@ function handleRequest(req, res) {
1222
1239
  }
1223
1240
  }
1224
1241
  }
1225
- });
1226
- proxyRes.on("end", () => {
1227
- if (buffer.startsWith("data: ")) {
1228
- const raw = buffer.slice(6).trim();
1229
- if (raw && raw !== "[DONE]") {
1230
- try {
1231
- const evt = JSON.parse(raw);
1232
- fullText += evt.text || evt.content || evt.delta?.text || "";
1233
- } catch {
1234
- fullText += raw;
1242
+ } else {
1243
+ sseBuffer += chunk; // Collect JSON body
1244
+ }
1245
+ });
1246
+
1247
+ proxyRes.on("end", () => {
1248
+ // Handle remaining buffer for SSE
1249
+ if (isSSE && sseBuffer) {
1250
+ for (const line of sseBuffer.split("\n")) {
1251
+ if (line.startsWith("data: ")) {
1252
+ const raw = line.slice(6).trim();
1253
+ if (raw && raw !== "[DONE]") {
1254
+ try {
1255
+ const evt = JSON.parse(raw);
1256
+ const delta = evt.choices?.[0]?.delta?.content || evt.delta?.text || evt.text || evt.content || "";
1257
+ if (delta) fullText += delta;
1258
+ } catch { fullText += raw; }
1235
1259
  }
1236
1260
  }
1237
1261
  }
1238
- if (fullText) {
1239
- session.messages.push({ role: "assistant", content: fullText, timestamp: Date.now() });
1240
- broadcastToWS({
1241
- type: "chat.final",
1242
- text: fullText,
1243
- content: fullText,
1244
- sessionKey,
1245
- role: "assistant",
1246
- timestamp: Date.now(),
1247
- });
1248
- console.log(` ${GREEN}✓${RESET} Gateway SSE response: ${fullText.substring(0, 80)}...`);
1249
- }
1250
- });
1251
- proxyRes.on("error", () => {});
1252
- } else {
1253
- // JSON response from gateway
1254
- const chunks = [];
1255
- proxyRes.on("data", (c) => chunks.push(c));
1256
- proxyRes.on("end", () => {
1257
- const body = Buffer.concat(chunks).toString("utf-8");
1262
+ }
1263
+
1264
+ // Handle JSON response
1265
+ if (!isSSE && sseBuffer) {
1258
1266
  try {
1259
- const jsonBody = JSON.parse(body);
1260
- const inlineResponse =
1261
- jsonBody.response || jsonBody.message || jsonBody.answer || jsonBody.text || "";
1262
- if (inlineResponse) {
1263
- session.messages.push({ role: "assistant", content: inlineResponse, timestamp: Date.now() });
1264
- broadcastToWS({
1265
- type: "chat.final",
1266
- text: inlineResponse,
1267
- content: inlineResponse,
1268
- sessionKey,
1269
- role: "assistant",
1270
- timestamp: Date.now(),
1271
- });
1272
- console.log(` ${GREEN}✓${RESET} Gateway JSON response: ${inlineResponse.substring(0, 80)}...`);
1273
- } else {
1274
- console.log(` ${DIM}Gateway returned JSON with no response field — waiting for MCP${RESET}`);
1275
- }
1267
+ const jsonBody = JSON.parse(sseBuffer);
1268
+ fullText = jsonBody.choices?.[0]?.message?.content || jsonBody.response || jsonBody.message || jsonBody.text || "";
1276
1269
  } catch {
1277
- console.log(` ${DIM}Gateway returned non-JSON waiting for MCP${RESET}`);
1270
+ console.log(` ${DIM}BFF returned non-JSON (${sseBuffer.length} bytes): ${sseBuffer.substring(0, 200)}${RESET}`);
1278
1271
  }
1279
- });
1280
- }
1272
+ }
1273
+
1274
+ if (fullText) {
1275
+ session.messages.push({ role: "assistant", content: fullText, timestamp: Date.now() });
1276
+ broadcastToWS({
1277
+ type: "chat.final",
1278
+ text: fullText,
1279
+ content: fullText,
1280
+ sessionKey,
1281
+ role: "assistant",
1282
+ timestamp: Date.now(),
1283
+ });
1284
+ console.log(` ${GREEN}✓${RESET} AI response (${fullText.length} chars): ${fullText.substring(0, 80)}...`);
1285
+ } else {
1286
+ console.log(` ${DIM}BFF returned no content${RESET}`);
1287
+ }
1288
+ });
1289
+
1290
+ proxyRes.on("error", () => {});
1281
1291
  });
1282
1292
 
1283
1293
  proxyReq.on("timeout", () => {
1284
1294
  proxyReq.destroy();
1285
- console.log(` ${DIM}Gateway relay timed out — agent will respond via MCP${RESET}`);
1295
+ console.log(` ${DIM}BFF relay timed out${RESET}`);
1286
1296
  });
1287
1297
 
1288
1298
  proxyReq.on("error", (err) => {
1289
- console.log(` ${DIM}Gateway relay failed (${err.message}) — agent will respond via MCP${RESET}`);
1299
+ console.log(` ${DIM}BFF relay failed: ${err.message}${RESET}`);
1290
1300
  });
1291
1301
 
1292
1302
  proxyReq.write(proxyBody);
@@ -1492,53 +1502,81 @@ function handleRequest(req, res) {
1492
1502
  }
1493
1503
 
1494
1504
  // ── SSE passthrough for streaming endpoints (/api/chat) ──────────
1495
- // Let the SSE stream through to the browser AND tap it to broadcast
1496
- // chunks via our WebSocket (so Navigator's sidepane chat gets them).
1505
+ // Pipe SSE through transparently. Use setNoDelay to prevent TCP buffering
1506
+ // that would cause the browser to receive multiple SSE events in one chunk.
1507
+ // Also tap the stream to broadcast via WebSocket (for Navigator sidepane)
1508
+ // and store the response in the bridge chat session.
1497
1509
  if (isSSE && isStreamingEndpoint) {
1498
- console.log(` ${DIM}SSE passthrough + WS tap: ${path}${RESET}`);
1510
+ console.log(` ${DIM}SSE passthrough: ${path}${RESET}`);
1511
+
1512
+ // Disable TCP Nagle for immediate per-event delivery
1513
+ if (res.socket) res.socket.setNoDelay(true);
1514
+
1515
+ // Clean transfer headers — let Node.js manage chunked encoding
1516
+ delete headers["content-length"];
1517
+ delete headers["transfer-encoding"];
1499
1518
  res.writeHead(proxyRes.statusCode ?? 200, headers);
1500
1519
 
1501
1520
  let fullText = "";
1502
- proxyRes.on("data", (chunk) => {
1503
- // Forward the chunk to the browser immediately
1504
- res.write(chunk);
1521
+ let sseBuffer = "";
1505
1522
 
1506
- // Also parse and broadcast to WebSocket
1507
- const text = chunk.toString("utf-8");
1508
- for (const line of text.split("\n")) {
1509
- if (line.startsWith("data: ")) {
1510
- const raw = line.slice(6).trim();
1511
- if (raw === "[DONE]" || !raw) {
1512
- continue;
1513
- }
1514
- try {
1515
- const evt = JSON.parse(raw);
1516
- const delta =
1517
- evt.choices?.[0]?.delta?.content ||
1518
- evt.delta?.text ||
1519
- evt.text ||
1520
- evt.content ||
1521
- "";
1522
- if (delta) {
1523
- fullText += delta;
1524
- broadcastToWS({
1525
- type: "chat.delta",
1526
- text: fullText,
1527
- delta,
1528
- sessionKey: "main",
1529
- timestamp: Date.now(),
1530
- });
1523
+ proxyRes.setEncoding("utf-8");
1524
+ proxyRes.on("data", (chunk) => {
1525
+ sseBuffer += chunk;
1526
+
1527
+ // Split by double-newline (SSE event boundary)
1528
+ const events = sseBuffer.split("\n\n");
1529
+ sseBuffer = events.pop() || ""; // Keep incomplete event in buffer
1530
+
1531
+ for (const event of events) {
1532
+ const trimmed = event.trim();
1533
+ if (!trimmed) continue;
1534
+
1535
+ // Forward the COMPLETE SSE event to browser (preserving data: prefix)
1536
+ res.write(trimmed + "\n\n");
1537
+
1538
+ // Tap for WebSocket broadcast
1539
+ for (const line of trimmed.split("\n")) {
1540
+ if (line.startsWith("data: ")) {
1541
+ const raw = line.slice(6).trim();
1542
+ if (raw === "[DONE]" || !raw) continue;
1543
+ try {
1544
+ const evt = JSON.parse(raw);
1545
+ const delta =
1546
+ evt.choices?.[0]?.delta?.content ||
1547
+ evt.delta?.text ||
1548
+ evt.text ||
1549
+ evt.content ||
1550
+ "";
1551
+ if (delta) {
1552
+ fullText += delta;
1553
+ broadcastToWS({
1554
+ type: "chat.delta",
1555
+ text: fullText,
1556
+ delta,
1557
+ sessionKey: "main",
1558
+ timestamp: Date.now(),
1559
+ });
1560
+ }
1561
+ } catch {
1562
+ /* non-JSON SSE event */
1531
1563
  }
1532
- } catch {
1533
- /* non-JSON SSE event — skip */
1534
1564
  }
1535
1565
  }
1536
1566
  }
1537
1567
  });
1538
1568
 
1539
1569
  proxyRes.on("end", () => {
1540
- // Stream ended — broadcast final message via WebSocket
1570
+ // Flush remaining buffer
1571
+ if (sseBuffer.trim()) {
1572
+ res.write(sseBuffer + "\n\n");
1573
+ }
1574
+
1575
+ // Store and broadcast final response
1541
1576
  if (fullText) {
1577
+ const session = getChatSession("main");
1578
+ session.messages.push({ role: "assistant", content: fullText, timestamp: Date.now() });
1579
+
1542
1580
  broadcastToWS({
1543
1581
  type: "chat.final",
1544
1582
  text: fullText,
@@ -1547,11 +1585,15 @@ function handleRequest(req, res) {
1547
1585
  role: "assistant",
1548
1586
  timestamp: Date.now(),
1549
1587
  });
1588
+ console.log(` ${GREEN}✓${RESET} Chat response (${fullText.length} chars): ${fullText.substring(0, 80)}...`);
1589
+ } else {
1590
+ console.log(` ${DIM}SSE stream ended with no content${RESET}`);
1550
1591
  }
1551
1592
  res.end();
1552
1593
  });
1553
1594
 
1554
- proxyRes.on("error", () => {
1595
+ proxyRes.on("error", (err) => {
1596
+ console.log(` ${DIM}SSE stream error: ${err.message}${RESET}`);
1555
1597
  res.end();
1556
1598
  });
1557
1599
  return;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openclaw-navigator",
3
- "version": "5.6.3",
3
+ "version": "5.7.0",
4
4
  "description": "One-command bridge + tunnel for the Navigator browser — works on any machine, any OS",
5
5
  "keywords": [
6
6
  "browser",