ape-claw 0.1.6 → 0.1.8

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.
@@ -1,10 +1,9 @@
1
1
  /**
2
2
  * forge-chat.js — Agent chat panel for ClawBot Forge.
3
3
  *
4
- * Three modes (auto-detected):
5
- * 1. Website (apeclaw.ai / vercel.app): always uses /api/forge/chat (The Clawllector)
6
- * 2. Local + PERPLEXITY_API_KEY set: uses /api/forge/chat (your own OpenClaw agent)
7
- * 3. Local without key: falls back to /api/chat (basic message relay)
4
+ * Gateway mode:
5
+ * - Forge always uses /api/forge/chat (OpenClaw gateway takeover path)
6
+ * - No fallback to legacy /api/chat relay
8
7
  */
9
8
 
10
9
  /* ══════════════════════════════════════════════════════════
@@ -15,12 +14,16 @@ const input = () => document.getElementById("forgeChatInput");
15
14
  const sendBtn = () => document.getElementById("forgeChatSendBtn");
16
15
  const counter = () => document.getElementById("forgeChatCounter");
17
16
  const badge = () => document.getElementById("forgeChatBadge");
17
+ const gatewayChip = () => document.getElementById("forgeGatewayChip");
18
+ const gatewayRefreshBtn = () => document.getElementById("forgeGatewayRefresh");
19
+ const gatewayRestartBtn = () => document.getElementById("forgeGatewayRestart");
18
20
 
19
21
  let unread = 0;
20
22
  let streaming = false;
21
23
  let forgeAgentAvailable = null; // null = unknown, true/false after probe
22
24
  let localAgentName = "Agent";
23
- let localProvider = "";
25
+ let gatewayPollTimer = null;
26
+ let lastMotionIntentAt = 0;
24
27
 
25
28
  const conversationHistory = [];
26
29
 
@@ -35,6 +38,8 @@ function escapeHtml(s) {
35
38
 
36
39
  function renderChatText(raw) {
37
40
  let text = String(raw || "");
41
+ // Optional motion directives for Forge scene control (hidden from chat UI).
42
+ text = text.replace(/\[\[MOTION:[^\]]+\]\]/gi, "");
38
43
  // Strip common citation markers from provider answers (e.g. [1], [2]).
39
44
  text = text.replace(/\[(\d+)\]/g, "");
40
45
 
@@ -57,6 +62,50 @@ function renderChatText(raw) {
57
62
  return html;
58
63
  }
59
64
 
65
+ function userAskedForMotion(prompt) {
66
+ const s = String(prompt || "").toLowerCase();
67
+ if (!s) return false;
68
+ return /\b(move|walk|patrol|wander|goto|go to|navigate|step|turn|come here|go there)\b/.test(s);
69
+ }
70
+
71
+ function applyMotionIntentsFromText(raw, opts = {}) {
72
+ const allowActiveMotion = Boolean(opts.allowActiveMotion);
73
+ const s = String(raw || "");
74
+ const intents = [];
75
+ const matches = s.match(/\[\[MOTION:[^\]]+\]\]/gi) || [];
76
+ for (const m of matches) {
77
+ const payload = m.replace(/^\[\[MOTION:/i, "").replace(/\]\]$/, "").trim();
78
+ const upper = payload.toUpperCase();
79
+ if (upper === "HALT") intents.push({ type: "halt" });
80
+ else if (upper === "PATROL") intents.push({ type: "patrol", durationMs: 20000 });
81
+ else if (upper === "WANDER") intents.push({ type: "wander", durationMs: 10000 });
82
+ else if (upper.startsWith("GOTO")) {
83
+ const nums = payload.match(/-?\d+(\.\d+)?/g) || [];
84
+ if (nums.length >= 2) {
85
+ intents.push({ type: "goto", x: Number(nums[0]), z: Number(nums[1]), durationMs: 15000 });
86
+ }
87
+ }
88
+ }
89
+ if (!intents.length) return;
90
+ const setMotion = window.__forgeSetMotionIntent;
91
+ if (typeof setMotion !== "function") return;
92
+ // Stability rule: apply only the final directive in the response.
93
+ // Some model outputs include multiple tags while "thinking", which causes jitter.
94
+ const intent = intents[intents.length - 1];
95
+
96
+ // Unless the user explicitly asked for movement, keep posture steady.
97
+ if (!allowActiveMotion && intent.type !== "halt") {
98
+ setMotion({ type: "halt" });
99
+ return;
100
+ }
101
+
102
+ // Throttle active motion changes to avoid rapid target resets.
103
+ const now = Date.now();
104
+ if (intent.type !== "halt" && now - lastMotionIntentAt < 5000) return;
105
+ if (intent.type !== "halt") lastMotionIntentAt = now;
106
+ setMotion(intent);
107
+ }
108
+
60
109
  /* ══════════════════════════════════════════════════════════
61
110
  Environment detection
62
111
  ══════════════════════════════════════════════════════════ */
@@ -67,17 +116,74 @@ function isWebsiteMode() {
67
116
 
68
117
  async function probeForgeAgent() {
69
118
  try {
70
- const res = await fetch("/api/forge/status", { signal: AbortSignal.timeout(3000) });
119
+ const res = await fetch("/api/forge/status", { signal: AbortSignal.timeout(10000) });
71
120
  if (!res.ok) return false;
72
121
  const data = await res.json();
73
122
  if (data.agentName) localAgentName = data.agentName;
74
- if (data.provider) localProvider = data.provider;
75
123
  return data.configured === true;
76
124
  } catch {
77
125
  return false;
78
126
  }
79
127
  }
80
128
 
129
+ async function fetchGatewayStatus() {
130
+ try {
131
+ const res = await fetch("/api/forge/status", { signal: AbortSignal.timeout(9000) });
132
+ if (!res.ok) return null;
133
+ return await res.json();
134
+ } catch {
135
+ return null;
136
+ }
137
+ }
138
+
139
+ function setGatewayChip(state, text) {
140
+ const chip = gatewayChip();
141
+ if (!chip) return;
142
+ chip.dataset.state = state;
143
+ chip.textContent = text;
144
+ }
145
+
146
+ async function refreshGatewayChip() {
147
+ const status = await fetchGatewayStatus();
148
+ if (!status) {
149
+ setGatewayChip("err", "Gateway: unreachable");
150
+ return null;
151
+ }
152
+ if (status.gatewayReady || status.configured) {
153
+ setGatewayChip("ok", "Gateway: online");
154
+ } else if (status.gatewayCli) {
155
+ setGatewayChip("warn", "Gateway: starting/idle");
156
+ } else {
157
+ setGatewayChip("err", "Gateway: OpenClaw CLI missing");
158
+ }
159
+ return status;
160
+ }
161
+
162
+ async function runGatewayAction(action) {
163
+ const restart = gatewayRestartBtn();
164
+ const refresh = gatewayRefreshBtn();
165
+ if (restart) restart.disabled = true;
166
+ if (refresh) refresh.disabled = true;
167
+ setGatewayChip("warn", action === "restart" ? "Gateway: restarting..." : "Gateway: updating...");
168
+ try {
169
+ const res = await fetch("/api/forge/gateway/control", {
170
+ method: "POST",
171
+ headers: { "content-type": "application/json" },
172
+ body: JSON.stringify({ action }),
173
+ });
174
+ const data = await res.json().catch(() => null);
175
+ if (!res.ok || !data?.ok) {
176
+ throw new Error(data?.error || `HTTP ${res.status}`);
177
+ }
178
+ await refreshGatewayChip();
179
+ } catch (err) {
180
+ setGatewayChip("err", `Gateway: ${err.message}`);
181
+ } finally {
182
+ if (restart) restart.disabled = false;
183
+ if (refresh) refresh.disabled = false;
184
+ }
185
+ }
186
+
81
187
  /* ══════════════════════════════════════════════════════════
82
188
  Message rendering
83
189
  ══════════════════════════════════════════════════════════ */
@@ -149,11 +255,7 @@ async function sendMessage() {
149
255
  const btn = sendBtn();
150
256
  if (btn) btn.disabled = true;
151
257
 
152
- if (isWebsiteMode() || forgeAgentAvailable) {
153
- await sendToForgeAgent(text);
154
- } else {
155
- await sendToLocalChat(text);
156
- }
258
+ await sendToForgeAgent(text);
157
259
  }
158
260
 
159
261
  /* ══════════════════════════════════════════════════════════
@@ -163,6 +265,40 @@ async function sendToForgeAgent(text) {
163
265
  const btn = sendBtn();
164
266
  const bodyEl = appendMsg("agent", "");
165
267
  let buffer = "";
268
+ let firstChunkSeen = false;
269
+ let pendingTimer = null;
270
+ const pendingStartedAt = Date.now();
271
+
272
+ function renderPending() {
273
+ if (!bodyEl || firstChunkSeen) return;
274
+ const sec = Math.max(0, Math.floor((Date.now() - pendingStartedAt) / 1000));
275
+ const stage = sec < 5
276
+ ? "Analyzing request..."
277
+ : sec < 12
278
+ ? "Checking OpenClaw context..."
279
+ : "Waiting for gateway response...";
280
+ bodyEl.classList.add("chat-pending");
281
+ bodyEl.innerHTML = `
282
+ <span class="chat-pending-wrap">
283
+ <span class="chat-pending-label">OpenClaw agent is responding</span>
284
+ <span class="chat-pending-dots"><i></i><i></i><i></i></span>
285
+ <span class="chat-pending-stage">${stage}</span>
286
+ <span class="chat-pending-time">${sec}s</span>
287
+ </span>
288
+ `;
289
+ }
290
+
291
+ function stopPending() {
292
+ firstChunkSeen = true;
293
+ if (pendingTimer) {
294
+ clearInterval(pendingTimer);
295
+ pendingTimer = null;
296
+ }
297
+ if (bodyEl) bodyEl.classList.remove("chat-pending");
298
+ }
299
+
300
+ renderPending();
301
+ pendingTimer = setInterval(renderPending, 700);
166
302
 
167
303
  try {
168
304
  async function requestOnce() {
@@ -192,6 +328,7 @@ async function sendToForgeAgent(text) {
192
328
  if (errData.retryAfter) {
193
329
  errMsg += `, retry in ${errData.retryAfter}s`;
194
330
  }
331
+ stopPending();
195
332
  if (bodyEl) bodyEl.textContent = `Error: ${errMsg}`;
196
333
  finishStreaming();
197
334
  return;
@@ -200,9 +337,12 @@ async function sendToForgeAgent(text) {
200
337
  const contentType = res.headers.get("content-type") || "";
201
338
  if (!contentType.includes("text/event-stream")) {
202
339
  const data = await res.json().catch(() => ({}));
340
+ stopPending();
203
341
  if (bodyEl) bodyEl.innerHTML = renderChatText(data.reply || data.message || "No response");
204
342
  if (data.reply || data.message) {
205
- conversationHistory.push({ role: "assistant", content: data.reply || data.message });
343
+ const replyText = data.reply || data.message;
344
+ applyMotionIntentsFromText(replyText, { allowActiveMotion: userAskedForMotion(text) });
345
+ conversationHistory.push({ role: "assistant", content: replyText });
206
346
  }
207
347
  finishStreaming();
208
348
  return;
@@ -228,6 +368,7 @@ async function sendToForgeAgent(text) {
228
368
  const chunk = JSON.parse(data);
229
369
  const t = chunk.text || chunk.content || "";
230
370
  if (t) {
371
+ if (!firstChunkSeen) stopPending();
231
372
  buffer += t;
232
373
  if (bodyEl) bodyEl.innerHTML = renderChatText(buffer);
233
374
  const box = msgBox();
@@ -238,52 +379,21 @@ async function sendToForgeAgent(text) {
238
379
  }
239
380
 
240
381
  if (buffer) {
382
+ applyMotionIntentsFromText(buffer, { allowActiveMotion: userAskedForMotion(text) });
241
383
  conversationHistory.push({ role: "assistant", content: buffer });
242
384
  } else if (bodyEl && !bodyEl.textContent) {
385
+ stopPending();
243
386
  bodyEl.textContent = "No response received";
244
387
  }
245
388
  } catch (err) {
389
+ stopPending();
246
390
  if (bodyEl) {
247
391
  const msg = buffer || `Connection error: ${err.message}`;
248
392
  bodyEl.innerHTML = renderChatText(msg);
249
393
  }
250
394
  }
251
395
 
252
- finishStreaming();
253
- }
254
-
255
- /* ══════════════════════════════════════════════════════════
256
- Local fallback: POST /api/chat (basic message relay)
257
- ══════════════════════════════════════════════════════════ */
258
- async function sendToLocalChat(text) {
259
- const btn = sendBtn();
260
-
261
- try {
262
- const res = await fetch("/api/chat", {
263
- method: "POST",
264
- headers: { "content-type": "application/json" },
265
- body: JSON.stringify({ text, room: "forge" }),
266
- });
267
-
268
- if (!res.ok) {
269
- appendMsg("agent", `Error: ${res.status} ${res.statusText}`);
270
- finishStreaming();
271
- return;
272
- }
273
-
274
- const data = await res.json();
275
- if (data.reply) {
276
- appendMsg("agent", data.reply);
277
- } else if (data.streamId) {
278
- streamResponse(data.streamId);
279
- return;
280
- } else {
281
- appendMsg("agent", data.message?.text || "Message sent");
282
- }
283
- } catch (err) {
284
- appendMsg("agent", `Connection error: ${err.message}`);
285
- }
286
-
396
+ stopPending();
287
397
  finishStreaming();
288
398
  }
289
399
 
@@ -295,36 +405,6 @@ function finishStreaming() {
295
405
  input()?.focus();
296
406
  }
297
407
 
298
- /* ══════════════════════════════════════════════════════════
299
- SSE streaming for local mode long responses
300
- ══════════════════════════════════════════════════════════ */
301
- function streamResponse(streamId) {
302
- const bodyEl = appendMsg("agent", "");
303
- const evtSrc = new EventSource(`/api/chat/stream?id=${encodeURIComponent(streamId)}`);
304
- let buffer = "";
305
-
306
- evtSrc.onmessage = (e) => {
307
- if (e.data === "[DONE]") {
308
- evtSrc.close();
309
- finishStreaming();
310
- return;
311
- }
312
- try {
313
- const chunk = JSON.parse(e.data);
314
- buffer += chunk.text || chunk.content || "";
315
- if (bodyEl) bodyEl.innerHTML = renderChatText(buffer);
316
- const box = msgBox();
317
- if (box) box.scrollTop = box.scrollHeight;
318
- } catch { /* ignore parse errors */ }
319
- };
320
-
321
- evtSrc.onerror = () => {
322
- evtSrc.close();
323
- if (!buffer && bodyEl) bodyEl.textContent = "Stream interrupted";
324
- finishStreaming();
325
- };
326
- }
327
-
328
408
  /* ══════════════════════════════════════════════════════════
329
409
  Character counter
330
410
  ══════════════════════════════════════════════════════════ */
@@ -341,43 +421,62 @@ async function init() {
341
421
  const inp = input();
342
422
  const btn = sendBtn();
343
423
  const indicator = document.getElementById("forgeChatAgentIndicator");
424
+ const refreshBtn = gatewayRefreshBtn();
425
+ const restartBtn = gatewayRestartBtn();
426
+
427
+ refreshBtn?.addEventListener("click", async () => { await refreshGatewayChip(); });
428
+ restartBtn?.addEventListener("click", async () => { await runGatewayAction("restart"); });
344
429
 
345
430
  if (isWebsiteMode()) {
431
+ refreshBtn?.setAttribute("style", "display:none");
432
+ restartBtn?.setAttribute("style", "display:none");
433
+ setGatewayChip("ok", "Gateway: main session");
346
434
  forgeAgentAvailable = true;
347
435
  if (inp) inp.disabled = false;
348
436
  if (btn) btn.disabled = false;
349
437
  if (inp) inp.placeholder = "Ask The Clawllector anything...";
350
438
  if (indicator) {
351
- indicator.textContent = "\u{1F99E} Talking to The Clawllector (OpenClaw agent)";
439
+ indicator.textContent = "Connected via OpenClaw Gateway (main session)";
352
440
  indicator.style.display = "block";
353
441
  }
354
442
  } else {
355
- forgeAgentAvailable = await probeForgeAgent();
443
+ const status = await fetchGatewayStatus();
444
+ let warmedStatus = status;
445
+ if (!status?.configured) {
446
+ // First-run recovery: auto-attempt gateway restart once before warning.
447
+ await runGatewayAction("restart");
448
+ warmedStatus = await fetchGatewayStatus();
449
+ }
450
+ forgeAgentAvailable = Boolean(warmedStatus?.configured || (await probeForgeAgent()));
451
+ if (warmedStatus?.agentName) localAgentName = warmedStatus.agentName;
356
452
 
357
453
  if (forgeAgentAvailable) {
358
454
  if (inp) { inp.disabled = false; inp.placeholder = `Ask ${localAgentName} anything...`; }
359
455
  if (btn) btn.disabled = false;
360
456
  if (indicator) {
361
- const providerTag = localProvider ? ` via ${localProvider}` : "";
362
- indicator.textContent = `\u{1F99E} Connected to ${localAgentName}${providerTag}`;
457
+ indicator.textContent = "Connected via OpenClaw Gateway (main session)";
363
458
  indicator.style.display = "block";
364
459
  }
365
460
  } else {
366
- if (inp) inp.disabled = true;
367
- if (btn) btn.disabled = true;
461
+ if (inp) inp.disabled = false;
462
+ if (btn) btn.disabled = false;
368
463
  if (indicator) {
369
464
  indicator.innerHTML =
370
- '\u{1F512} Forge agent not configured. Set an LLM API key ' +
371
- '(<code style="color:var(--neon-cyan,#63d7ff)">OPENAI_API_KEY</code>, ' +
372
- '<code style="color:var(--neon-cyan,#63d7ff)">ANTHROPIC_API_KEY</code>, ' +
373
- '<code style="color:var(--neon-cyan,#63d7ff)">PERPLEXITY_API_KEY</code>, ' +
374
- 'or <code style="color:var(--neon-cyan,#63d7ff)">OLLAMA_HOST</code>) to enable. ' +
375
- '<a href="/docs" style="color:var(--accent,#cfff04)">Setup guide</a>';
465
+ '\u{26A0}\uFE0F OpenClaw Gateway not ready. Start/restart gateway, then retry. ' +
466
+ '<code style="color:var(--neon-cyan,#63d7ff)">openclaw gateway start</code>';
376
467
  indicator.style.display = "block";
377
468
  }
378
469
  }
379
470
  }
380
471
 
472
+ if (!isWebsiteMode()) {
473
+ await refreshGatewayChip();
474
+ if (gatewayPollTimer) clearInterval(gatewayPollTimer);
475
+ gatewayPollTimer = setInterval(() => {
476
+ refreshGatewayChip().catch(() => {});
477
+ }, 20_000);
478
+ }
479
+
381
480
  if (inp) {
382
481
  inp.addEventListener("input", updateCounter);
383
482
  inp.addEventListener("keydown", (e) => {
@@ -392,6 +491,18 @@ async function init() {
392
491
  btn.addEventListener("click", sendMessage);
393
492
  }
394
493
 
494
+ document.querySelectorAll(".forge-chat-quick-btn").forEach((el) => {
495
+ el.addEventListener("click", () => {
496
+ const prompt = String(el.getAttribute("data-prompt") || "").trim();
497
+ if (!prompt) return;
498
+ const inp = input();
499
+ if (!inp) return;
500
+ inp.value = prompt;
501
+ updateCounter();
502
+ sendMessage();
503
+ });
504
+ });
505
+
395
506
  const box = msgBox();
396
507
  if (box) {
397
508
  box.addEventListener("click", () => {