ape-claw 0.1.3 → 0.1.5

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,6 +1,10 @@
1
1
  /**
2
2
  * forge-chat.js — Agent chat panel for ClawBot Forge.
3
- * Posts to /api/chat, streams responses via SSE at /api/chat/stream.
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
8
  */
5
9
 
6
10
  /* ══════════════════════════════════════════════════════════
@@ -14,6 +18,65 @@ const badge = () => document.getElementById("forgeChatBadge");
14
18
 
15
19
  let unread = 0;
16
20
  let streaming = false;
21
+ let forgeAgentAvailable = null; // null = unknown, true/false after probe
22
+ let localAgentName = "Agent";
23
+ let localProvider = "";
24
+
25
+ const conversationHistory = [];
26
+
27
+ function escapeHtml(s) {
28
+ return String(s || "")
29
+ .replace(/&/g, "&")
30
+ .replace(/</g, "&lt;")
31
+ .replace(/>/g, "&gt;")
32
+ .replace(/"/g, "&quot;")
33
+ .replace(/'/g, "&#39;");
34
+ }
35
+
36
+ function renderChatText(raw) {
37
+ let text = String(raw || "");
38
+ // Strip common citation markers from provider answers (e.g. [1], [2]).
39
+ text = text.replace(/\[(\d+)\]/g, "");
40
+
41
+ // Escape first for safety, then apply a tiny markdown subset.
42
+ let html = escapeHtml(text);
43
+
44
+ // Fenced code blocks.
45
+ html = html.replace(/```([\s\S]*?)```/g, (_m, code) => `<pre><code>${code}</code></pre>`);
46
+ // Inline code.
47
+ html = html.replace(/`([^`]+?)`/g, "<code>$1</code>");
48
+ // Bold.
49
+ html = html.replace(/\*\*(.+?)\*\*/g, "<strong>$1</strong>");
50
+
51
+ // Basic bullet list support.
52
+ html = html.replace(/(?:^|\n)- (.+?)(?=\n|$)/g, "<li>$1</li>");
53
+ html = html.replace(/(<li>[\s\S]*?<\/li>)/g, "<ul>$1</ul>");
54
+
55
+ // Preserve line breaks.
56
+ html = html.replace(/\n/g, "<br>");
57
+ return html;
58
+ }
59
+
60
+ /* ══════════════════════════════════════════════════════════
61
+ Environment detection
62
+ ══════════════════════════════════════════════════════════ */
63
+ function isWebsiteMode() {
64
+ const host = window.location.hostname;
65
+ return host.includes("apeclaw.ai") || host.includes("vercel.app") || host.includes("railway.app");
66
+ }
67
+
68
+ async function probeForgeAgent() {
69
+ try {
70
+ const res = await fetch("/api/forge/status", { signal: AbortSignal.timeout(3000) });
71
+ if (!res.ok) return false;
72
+ const data = await res.json();
73
+ if (data.agentName) localAgentName = data.agentName;
74
+ if (data.provider) localProvider = data.provider;
75
+ return data.configured === true;
76
+ } catch {
77
+ return false;
78
+ }
79
+ }
17
80
 
18
81
  /* ══════════════════════════════════════════════════════════
19
82
  Message rendering
@@ -35,7 +98,13 @@ function appendMsg(role, text) {
35
98
 
36
99
  const name = document.createElement("span");
37
100
  name.className = "chat-msg-name";
38
- name.textContent = role === "user" ? "You" : "Agent";
101
+ if (role === "user") {
102
+ name.textContent = "You";
103
+ } else if (isWebsiteMode()) {
104
+ name.textContent = "The Clawllector";
105
+ } else {
106
+ name.textContent = forgeAgentAvailable ? localAgentName : "Agent";
107
+ }
39
108
  header.appendChild(name);
40
109
 
41
110
  const time = document.createElement("span");
@@ -45,7 +114,8 @@ function appendMsg(role, text) {
45
114
 
46
115
  const body = document.createElement("div");
47
116
  body.className = "chat-msg-text";
48
- body.textContent = text;
117
+ if (role === "agent") body.innerHTML = renderChatText(text);
118
+ else body.textContent = text;
49
119
 
50
120
  el.appendChild(header);
51
121
  el.appendChild(body);
@@ -62,7 +132,7 @@ function appendMsg(role, text) {
62
132
  }
63
133
 
64
134
  /* ══════════════════════════════════════════════════════════
65
- Send message via POST /api/chat
135
+ Send message routes to the right endpoint
66
136
  ══════════════════════════════════════════════════════════ */
67
137
  async function sendMessage() {
68
138
  const inp = input();
@@ -70,22 +140,134 @@ async function sendMessage() {
70
140
  if (!text || streaming) return;
71
141
 
72
142
  appendMsg("user", text);
143
+ conversationHistory.push({ role: "user", content: text });
73
144
  inp.value = "";
74
145
  updateCounter();
75
146
 
76
147
  streaming = true;
148
+ if (window.__forgeSetSpeaking) window.__forgeSetSpeaking(true);
77
149
  const btn = sendBtn();
78
150
  if (btn) btn.disabled = true;
79
151
 
152
+ if (isWebsiteMode() || forgeAgentAvailable) {
153
+ await sendToForgeAgent(text);
154
+ } else {
155
+ await sendToLocalChat(text);
156
+ }
157
+ }
158
+
159
+ /* ══════════════════════════════════════════════════════════
160
+ Forge agent: POST /api/forge/chat (SSE stream)
161
+ ══════════════════════════════════════════════════════════ */
162
+ async function sendToForgeAgent(text) {
163
+ const btn = sendBtn();
164
+ const bodyEl = appendMsg("agent", "");
165
+ let buffer = "";
166
+
167
+ try {
168
+ async function requestOnce() {
169
+ return fetch("/api/forge/chat", {
170
+ method: "POST",
171
+ headers: { "content-type": "application/json" },
172
+ body: JSON.stringify({
173
+ message: text,
174
+ history: conversationHistory.slice(-20),
175
+ }),
176
+ });
177
+ }
178
+
179
+ let res = await requestOnce();
180
+ if (!res.ok && (res.status === 429 || res.status === 502 || res.status === 503 || res.status === 504)) {
181
+ const waitMs = 500;
182
+ await new Promise((resolve) => setTimeout(resolve, waitMs));
183
+ res = await requestOnce();
184
+ }
185
+
186
+ if (!res.ok) {
187
+ const errData = await res.json().catch(() => ({}));
188
+ let errMsg = errData.error || errData.message || res.statusText || `HTTP ${res.status}`;
189
+ if (errData.status && !String(errMsg).includes(String(errData.status))) {
190
+ errMsg += ` (${errData.status})`;
191
+ }
192
+ if (errData.retryAfter) {
193
+ errMsg += `, retry in ${errData.retryAfter}s`;
194
+ }
195
+ if (bodyEl) bodyEl.textContent = `Error: ${errMsg}`;
196
+ finishStreaming();
197
+ return;
198
+ }
199
+
200
+ const contentType = res.headers.get("content-type") || "";
201
+ if (!contentType.includes("text/event-stream")) {
202
+ const data = await res.json().catch(() => ({}));
203
+ if (bodyEl) bodyEl.innerHTML = renderChatText(data.reply || data.message || "No response");
204
+ if (data.reply || data.message) {
205
+ conversationHistory.push({ role: "assistant", content: data.reply || data.message });
206
+ }
207
+ finishStreaming();
208
+ return;
209
+ }
210
+
211
+ const reader = res.body.getReader();
212
+ const decoder = new TextDecoder();
213
+ let sseBuffer = "";
214
+
215
+ while (true) {
216
+ const { done, value } = await reader.read();
217
+ if (done) break;
218
+
219
+ sseBuffer += decoder.decode(value, { stream: true });
220
+ const lines = sseBuffer.split("\n");
221
+ sseBuffer = lines.pop() || "";
222
+
223
+ for (const line of lines) {
224
+ if (!line.startsWith("data: ")) continue;
225
+ const data = line.slice(6).trim();
226
+ if (data === "[DONE]") continue;
227
+ try {
228
+ const chunk = JSON.parse(data);
229
+ const t = chunk.text || chunk.content || "";
230
+ if (t) {
231
+ buffer += t;
232
+ if (bodyEl) bodyEl.innerHTML = renderChatText(buffer);
233
+ const box = msgBox();
234
+ if (box) box.scrollTop = box.scrollHeight;
235
+ }
236
+ } catch { /* ignore parse errors */ }
237
+ }
238
+ }
239
+
240
+ if (buffer) {
241
+ conversationHistory.push({ role: "assistant", content: buffer });
242
+ } else if (bodyEl && !bodyEl.textContent) {
243
+ bodyEl.textContent = "No response received";
244
+ }
245
+ } catch (err) {
246
+ if (bodyEl) {
247
+ const msg = buffer || `Connection error: ${err.message}`;
248
+ bodyEl.innerHTML = renderChatText(msg);
249
+ }
250
+ }
251
+
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
+
80
261
  try {
81
262
  const res = await fetch("/api/chat", {
82
263
  method: "POST",
83
264
  headers: { "content-type": "application/json" },
84
- body: JSON.stringify({ message: text, room: "forge" }),
265
+ body: JSON.stringify({ text, room: "forge" }),
85
266
  });
86
267
 
87
268
  if (!res.ok) {
88
269
  appendMsg("agent", `Error: ${res.status} ${res.statusText}`);
270
+ finishStreaming();
89
271
  return;
90
272
  }
91
273
 
@@ -96,19 +278,25 @@ async function sendMessage() {
96
278
  streamResponse(data.streamId);
97
279
  return;
98
280
  } else {
99
- appendMsg("agent", data.message || "No response");
281
+ appendMsg("agent", data.message?.text || "Message sent");
100
282
  }
101
283
  } catch (err) {
102
284
  appendMsg("agent", `Connection error: ${err.message}`);
103
- } finally {
104
- streaming = false;
105
- if (btn) btn.disabled = false;
106
- inp?.focus();
107
285
  }
286
+
287
+ finishStreaming();
288
+ }
289
+
290
+ function finishStreaming() {
291
+ streaming = false;
292
+ if (window.__forgeSetSpeaking) window.__forgeSetSpeaking(false);
293
+ const btn = sendBtn();
294
+ if (btn) btn.disabled = false;
295
+ input()?.focus();
108
296
  }
109
297
 
110
298
  /* ══════════════════════════════════════════════════════════
111
- SSE streaming for long responses
299
+ SSE streaming for local mode long responses
112
300
  ══════════════════════════════════════════════════════════ */
113
301
  function streamResponse(streamId) {
114
302
  const bodyEl = appendMsg("agent", "");
@@ -118,16 +306,13 @@ function streamResponse(streamId) {
118
306
  evtSrc.onmessage = (e) => {
119
307
  if (e.data === "[DONE]") {
120
308
  evtSrc.close();
121
- streaming = false;
122
- const btn = sendBtn();
123
- if (btn) btn.disabled = false;
124
- input()?.focus();
309
+ finishStreaming();
125
310
  return;
126
311
  }
127
312
  try {
128
313
  const chunk = JSON.parse(e.data);
129
314
  buffer += chunk.text || chunk.content || "";
130
- if (bodyEl) bodyEl.textContent = buffer;
315
+ if (bodyEl) bodyEl.innerHTML = renderChatText(buffer);
131
316
  const box = msgBox();
132
317
  if (box) box.scrollTop = box.scrollHeight;
133
318
  } catch { /* ignore parse errors */ }
@@ -136,9 +321,7 @@ function streamResponse(streamId) {
136
321
  evtSrc.onerror = () => {
137
322
  evtSrc.close();
138
323
  if (!buffer && bodyEl) bodyEl.textContent = "Stream interrupted";
139
- streaming = false;
140
- const btn = sendBtn();
141
- if (btn) btn.disabled = false;
324
+ finishStreaming();
142
325
  };
143
326
  }
144
327
 
@@ -154,9 +337,46 @@ function updateCounter() {
154
337
  /* ══════════════════════════════════════════════════════════
155
338
  Init
156
339
  ══════════════════════════════════════════════════════════ */
157
- function init() {
340
+ async function init() {
158
341
  const inp = input();
159
342
  const btn = sendBtn();
343
+ const indicator = document.getElementById("forgeChatAgentIndicator");
344
+
345
+ if (isWebsiteMode()) {
346
+ forgeAgentAvailable = true;
347
+ if (inp) inp.disabled = false;
348
+ if (btn) btn.disabled = false;
349
+ if (inp) inp.placeholder = "Ask The Clawllector anything...";
350
+ if (indicator) {
351
+ indicator.textContent = "\u{1F99E} Talking to The Clawllector (OpenClaw agent)";
352
+ indicator.style.display = "block";
353
+ }
354
+ } else {
355
+ forgeAgentAvailable = await probeForgeAgent();
356
+
357
+ if (forgeAgentAvailable) {
358
+ if (inp) { inp.disabled = false; inp.placeholder = `Ask ${localAgentName} anything...`; }
359
+ if (btn) btn.disabled = false;
360
+ if (indicator) {
361
+ const providerTag = localProvider ? ` via ${localProvider}` : "";
362
+ indicator.textContent = `\u{1F99E} Connected to ${localAgentName}${providerTag}`;
363
+ indicator.style.display = "block";
364
+ }
365
+ } else {
366
+ if (inp) inp.disabled = true;
367
+ if (btn) btn.disabled = true;
368
+ if (indicator) {
369
+ 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>';
376
+ indicator.style.display = "block";
377
+ }
378
+ }
379
+ }
160
380
 
161
381
  if (inp) {
162
382
  inp.addEventListener("input", updateCounter);
@@ -22,6 +22,7 @@ import { CSS2DObject } from "three/addons/renderers/CSS2DRenderer.js";
22
22
  let skills = [];
23
23
  let allLibrarySkills = [];
24
24
  let podStatus = null;
25
+ let forgeAgentOnline = false;
25
26
  let podFiles = {};
26
27
  let clawbots = [];
27
28
  let agentName = "The Clawllector";
@@ -35,12 +36,23 @@ const SEARCH_LIMIT = 30;
35
36
  /* ══════════════════════════════════════════════════════════
36
37
  Fetch helpers
37
38
  ══════════════════════════════════════════════════════════ */
38
- async function fetchJSON(url, options = {}) {
39
- try {
40
- const r = await fetch(url, options);
41
- if (!r.ok) return null;
42
- return await r.json();
43
- } catch { return null; }
39
+ async function fetchJSON(url, options = {}, config = {}) {
40
+ const retries = Number(config.retries ?? 0);
41
+ const timeoutMs = Number(config.timeoutMs ?? 8000);
42
+ for (let attempt = 0; attempt <= retries; attempt++) {
43
+ try {
44
+ const r = await fetch(url, {
45
+ ...options,
46
+ signal: options.signal || AbortSignal.timeout(timeoutMs),
47
+ });
48
+ if (!r.ok) throw new Error(`HTTP ${r.status}`);
49
+ return await r.json();
50
+ } catch {
51
+ if (attempt >= retries) return null;
52
+ await new Promise((resolve) => setTimeout(resolve, 250 * (attempt + 1)));
53
+ }
54
+ }
55
+ return null;
44
56
  }
45
57
 
46
58
  function authHeaders() {
@@ -158,9 +170,9 @@ function inferCategory(skill) {
158
170
  ══════════════════════════════════════════════════════════ */
159
171
  async function loadInstalledSkills() {
160
172
  const [seedRes, userRes, starterRes] = await Promise.all([
161
- fetchJSON("/api/skills/search?source=seed&limit=500"),
162
- fetchJSON("/api/skillcards/user"),
163
- fetchJSON("/api/pod/starter-pack"),
173
+ fetchJSON("/api/skills/search?source=seed&limit=500", {}, { retries: 1 }),
174
+ fetchJSON("/api/skillcards/user", {}, { retries: 1 }),
175
+ fetchJSON("/api/pod/starter-pack", {}, { retries: 1, timeoutMs: 12000 }),
164
176
  ]);
165
177
 
166
178
  const seedSkills = seedRes?.results || [];
@@ -176,19 +188,33 @@ async function loadInstalledSkills() {
176
188
  }
177
189
  }
178
190
 
191
+ // Fallback: if live endpoints briefly fail, populate from stats.recent so the bot still has visible parts.
192
+ if (merged.length === 0) {
193
+ const stats = await fetchJSON("/api/skills/stats", {}, { retries: 1 });
194
+ const recent = Array.isArray(stats?.recent) ? stats.recent : [];
195
+ for (const s of recent) {
196
+ if (s?.slug && !seen.has(s.slug)) {
197
+ seen.add(s.slug);
198
+ merged.push({ ...s, source: s.source || "fallback-recent" });
199
+ }
200
+ }
201
+ }
202
+
179
203
  skills = merged.map(s => ({ ...s, category: inferCategory(s) }));
180
204
  return skills;
181
205
  }
182
206
 
183
207
  async function loadAllData() {
184
- const [_, statusRes, filesRes, botsRes] = await Promise.all([
208
+ const [_, statusRes, filesRes, botsRes, forgeRes] = await Promise.all([
185
209
  loadInstalledSkills(),
186
210
  fetchJSON("/api/pod/status"),
187
211
  fetchJSON("/api/pod/files", { headers: authHeaders() }),
188
212
  fetchJSON("/api/clawbots"),
213
+ fetchJSON("/api/forge/status", {}, { retries: 2, timeoutMs: 12000 }),
189
214
  ]);
190
215
 
191
216
  podStatus = statusRes;
217
+ forgeAgentOnline = !!(forgeRes?.configured);
192
218
  if (filesRes?.files) podFiles = filesRes.files;
193
219
  if (botsRes && Array.isArray(botsRes.clawbots)) clawbots = botsRes.clawbots;
194
220
 
@@ -218,7 +244,7 @@ function updateHeader() {
218
244
  if (statusEl) {
219
245
  const dot = statusEl.querySelector(".forge-status-dot");
220
246
  const txt = statusEl.querySelector(".forge-status-text");
221
- const running = podStatus && podStatus.running;
247
+ const running = (podStatus && podStatus.running) || forgeAgentOnline;
222
248
  if (dot) dot.className = `forge-status-dot ${running ? "running" : "stopped"}`;
223
249
  if (txt) txt.textContent = running ? "ONLINE" : "OFFLINE";
224
250
  }
@@ -344,8 +370,9 @@ function buildIdentityPlate() {
344
370
 
345
371
  const statusDiv = document.createElement("div");
346
372
  statusDiv.className = "forge-identity-status";
373
+ statusDiv.id = "forgeIdentityStatus";
347
374
  const dot = document.createElement("span");
348
- const running = podStatus && podStatus.running;
375
+ const running = (podStatus && podStatus.running) || forgeAgentOnline;
349
376
  dot.className = `forge-identity-dot ${running ? "running" : podStatus ? "stopped" : "uninitialized"}`;
350
377
  statusDiv.appendChild(dot);
351
378
  const txt = document.createElement("span");
@@ -361,6 +388,16 @@ function buildIdentityPlate() {
361
388
  return label;
362
389
  }
363
390
 
391
+ function refreshIdentityStatus() {
392
+ const statusDiv = document.getElementById("forgeIdentityStatus");
393
+ if (!statusDiv) return;
394
+ const running = (podStatus && podStatus.running) || forgeAgentOnline;
395
+ const dot = statusDiv.querySelector(".forge-identity-dot");
396
+ const txt = statusDiv.querySelector("span:last-child");
397
+ if (dot) dot.className = `forge-identity-dot ${running ? "running" : podStatus ? "stopped" : "uninitialized"}`;
398
+ if (txt) txt.textContent = running ? "ONLINE" : "OFFLINE";
399
+ }
400
+
364
401
  /* ══════════════════════════════════════════════════════════
365
402
  Install skill
366
403
  ══════════════════════════════════════════════════════════ */
@@ -740,7 +777,10 @@ async function init() {
740
777
  document.getElementById("forgeAuthAgentId")?.addEventListener("change", saveAuth);
741
778
  document.getElementById("forgeAuthAgentToken")?.addEventListener("change", saveAuth);
742
779
 
743
- window.addEventListener("forge:ready", async () => {
780
+ let started = false;
781
+ async function startForgeAssembly() {
782
+ if (started) return;
783
+ started = true;
744
784
  const loaded = await loadAllData();
745
785
  updateHeader();
746
786
  initShareToX();
@@ -762,6 +802,17 @@ async function init() {
762
802
  robotGroup.add(identityLabel);
763
803
  }
764
804
 
805
+ if (!forgeAgentOnline) {
806
+ setTimeout(async () => {
807
+ const retry = await fetchJSON("/api/forge/status", {}, { retries: 2, timeoutMs: 12000 });
808
+ if (retry?.configured) {
809
+ forgeAgentOnline = true;
810
+ updateHeader();
811
+ refreshIdentityStatus();
812
+ }
813
+ }, 3000);
814
+ }
815
+
765
816
  animateAssembly(currentAttachments, () => {
766
817
  updateProgress(skills.length, skills.length);
767
818
  const idleAnim = makeIdleAnimator(currentAttachments);
@@ -779,7 +830,18 @@ async function init() {
779
830
 
780
831
  window.addEventListener("forge:select", (e) => showInspector(e.detail));
781
832
  window.addEventListener("forge:deselect", () => hideInspector());
782
- });
833
+ }
834
+
835
+ window.addEventListener("forge:ready", startForgeAssembly, { once: true });
836
+
837
+ // Fallback for race conditions where forge:ready fired before listener attach.
838
+ if (window.__forgeSceneReady === true) {
839
+ startForgeAssembly();
840
+ } else {
841
+ setTimeout(() => {
842
+ if (!started) startForgeAssembly();
843
+ }, 2500);
844
+ }
783
845
  }
784
846
 
785
847
  function groupByCategory(arr) {