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.
- package/README.md +36 -24
- package/docs/CLI_GUIDE.md +5 -0
- package/docs/GLOBAL_BACKEND.md +3 -1
- package/docs/PRODUCT_OVERVIEW.md +15 -1
- package/docs/STARTER_PACK.md +7 -5
- package/docs/SUPPORTED_NETWORKS.md +1 -1
- package/docs/operator/01-quickstart.md +100 -9
- package/docs/operator/02-dashboard.md +1 -1
- package/docs/operator/03-cli-reference.md +19 -7
- package/docs/operator/04-skills-library.md +13 -12
- package/docs/operator/06-deployment.md +60 -7
- package/docs/operator/09-env-reference.md +61 -0
- package/docs/social/STARTER_PACK_THREAD.md +3 -3
- package/package.json +1 -1
- package/src/cli.mjs +132 -16
- package/src/server/index.mjs +6 -2
- package/src/server/routes/forge-agent.mjs +820 -0
- package/src/server/routes/skills.mjs +39 -6
- package/src/server/storage/file-backend.mjs +31 -0
- package/src/telemetry-server.mjs +90 -4
- package/ui/forge/css/forge.css +30 -2
- package/ui/forge/index.html +20 -7
- package/ui/forge/js/forge-attachments.js +26 -38
- package/ui/forge/js/forge-chat.js +240 -20
- package/ui/forge/js/forge-data.js +76 -14
- package/ui/forge/js/forge-scene.js +1943 -391
- package/ui/index.html +53 -54
- package/ui/js/skills.js +57 -42
- package/ui/shared/sidebar-nav.js +1 -1
- package/ui/skills.html +80 -53
|
@@ -1,6 +1,10 @@
|
|
|
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
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, "<")
|
|
31
|
+
.replace(/>/g, ">")
|
|
32
|
+
.replace(/"/g, """)
|
|
33
|
+
.replace(/'/g, "'");
|
|
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
|
-
|
|
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.
|
|
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
|
|
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({
|
|
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 || "
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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
|
-
|
|
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) {
|