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.
- package/README.md +157 -8
- package/docs/operator/01-quickstart.md +81 -72
- package/docs/operator/03-cli-reference.md +46 -5
- package/package.json +1 -1
- package/src/cli.mjs +551 -15
- package/src/lib/openclaw-paths.mjs +65 -0
- package/src/server/index.mjs +8 -2
- package/src/server/middleware/auth.mjs +10 -0
- package/src/server/routes/forge-agent.mjs +438 -58
- package/src/server/routes/openclaw-env.mjs +206 -0
- package/src/server/routes/skills.mjs +80 -3
- package/ui/forge/css/forge.css +153 -2
- package/ui/forge/index.html +40 -6
- package/ui/forge/js/forge-attachments.js +616 -377
- package/ui/forge/js/forge-chat.js +201 -90
- package/ui/forge/js/forge-data.js +305 -11
- package/ui/forge/js/forge-scene.js +178 -18
- package/ui/index.html +3 -3
|
@@ -32,6 +32,12 @@ let currentAttachments = [];
|
|
|
32
32
|
let searchPage = 1;
|
|
33
33
|
let searchTotal = 0;
|
|
34
34
|
const SEARCH_LIMIT = 30;
|
|
35
|
+
let dragDropInstallBound = false;
|
|
36
|
+
|
|
37
|
+
function isHostedForge() {
|
|
38
|
+
const host = String(window.location.hostname || "");
|
|
39
|
+
return host.includes("apeclaw.ai") || host.includes("vercel.app") || host.includes("railway.app");
|
|
40
|
+
}
|
|
35
41
|
|
|
36
42
|
/* ══════════════════════════════════════════════════════════
|
|
37
43
|
Fetch helpers
|
|
@@ -165,23 +171,27 @@ function inferCategory(skill) {
|
|
|
165
171
|
}
|
|
166
172
|
|
|
167
173
|
/* ══════════════════════════════════════════════════════════
|
|
168
|
-
Load installed skills (
|
|
174
|
+
Load installed skills (OpenClaw files + user index)
|
|
169
175
|
The full 10K+ catalog is for the browser drawer, not the robot.
|
|
170
176
|
══════════════════════════════════════════════════════════ */
|
|
171
177
|
async function loadInstalledSkills() {
|
|
172
|
-
const [seedRes, userRes, starterRes] = await Promise.all([
|
|
178
|
+
const [seedRes, userRes, starterRes, openclawRes] = await Promise.all([
|
|
173
179
|
fetchJSON("/api/skills/search?source=seed&limit=500", {}, { retries: 1 }),
|
|
174
180
|
fetchJSON("/api/skillcards/user", {}, { retries: 1 }),
|
|
175
181
|
fetchJSON("/api/pod/starter-pack", {}, { retries: 1, timeoutMs: 12000 }),
|
|
182
|
+
isHostedForge()
|
|
183
|
+
? Promise.resolve(null)
|
|
184
|
+
: fetchJSON("/api/skills/openclaw-installed?limit=2000", {}, { retries: 1, timeoutMs: 10000 }),
|
|
176
185
|
]);
|
|
177
186
|
|
|
178
187
|
const seedSkills = seedRes?.results || [];
|
|
179
188
|
const userSkills = (userRes?.skills || []).map(s => ({ ...s, source: "user" }));
|
|
180
189
|
const starterSkills = (starterRes?.skills || []).map(s => ({ ...s, source: "starter-pack" }));
|
|
190
|
+
const openclawSkills = (openclawRes?.skills || []).map(s => ({ ...s, source: "openclaw-installed" }));
|
|
181
191
|
|
|
182
192
|
const seen = new Set();
|
|
183
193
|
const merged = [];
|
|
184
|
-
for (const s of [...
|
|
194
|
+
for (const s of [...openclawSkills, ...userSkills, ...starterSkills, ...seedSkills]) {
|
|
185
195
|
if (s.slug && !seen.has(s.slug)) {
|
|
186
196
|
seen.add(s.slug);
|
|
187
197
|
merged.push(s);
|
|
@@ -404,10 +414,6 @@ function refreshIdentityStatus() {
|
|
|
404
414
|
async function installSkill(skill) {
|
|
405
415
|
saveAuth();
|
|
406
416
|
const headers = authHeaders();
|
|
407
|
-
if (!headers["x-agent-id"]) {
|
|
408
|
-
toast("Enter Agent ID and Token to install skills", "error");
|
|
409
|
-
return;
|
|
410
|
-
}
|
|
411
417
|
|
|
412
418
|
toast(`Installing ${skill.name || skill.slug}...`, "info", 2000);
|
|
413
419
|
|
|
@@ -451,10 +457,6 @@ async function installSkill(skill) {
|
|
|
451
457
|
async function uninstallSkill(fileName, displayName) {
|
|
452
458
|
saveAuth();
|
|
453
459
|
const headers = authHeaders();
|
|
454
|
-
if (!headers["x-agent-id"]) {
|
|
455
|
-
toast("Enter Agent ID and Token to uninstall skills", "error");
|
|
456
|
-
return;
|
|
457
|
-
}
|
|
458
460
|
|
|
459
461
|
if (!confirm(`Uninstall "${displayName}"? This will remove the skill from your agent.`)) return;
|
|
460
462
|
|
|
@@ -581,6 +583,285 @@ function initSkillBrowser() {
|
|
|
581
583
|
document.getElementById("forgeFilterOnchain")?.addEventListener("change", () => { searchPage = 1; searchSkills(); });
|
|
582
584
|
document.getElementById("forgeFilterRisk")?.addEventListener("change", () => { searchPage = 1; searchSkills(); });
|
|
583
585
|
document.getElementById("forgeFilterSource")?.addEventListener("change", () => { searchPage = 1; searchSkills(); });
|
|
586
|
+
initSkillDragDropInstall();
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
function initSkillDragDropInstall() {
|
|
590
|
+
if (dragDropInstallBound) return;
|
|
591
|
+
const viewport = document.getElementById("forgeViewport");
|
|
592
|
+
if (!viewport) return;
|
|
593
|
+
dragDropInstallBound = true;
|
|
594
|
+
|
|
595
|
+
function clearDropState() {
|
|
596
|
+
viewport.classList.remove("forge-drop-active");
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
viewport.addEventListener("dragenter", (e) => {
|
|
600
|
+
const slug = e.dataTransfer?.getData("text/forge-skill-slug");
|
|
601
|
+
if (!slug) return;
|
|
602
|
+
e.preventDefault();
|
|
603
|
+
viewport.classList.add("forge-drop-active");
|
|
604
|
+
});
|
|
605
|
+
|
|
606
|
+
viewport.addEventListener("dragover", (e) => {
|
|
607
|
+
const hasSkillSlug = Array.from(e.dataTransfer?.types || []).includes("text/forge-skill-slug");
|
|
608
|
+
if (!hasSkillSlug) return;
|
|
609
|
+
e.preventDefault();
|
|
610
|
+
e.dataTransfer.dropEffect = "copy";
|
|
611
|
+
viewport.classList.add("forge-drop-active");
|
|
612
|
+
});
|
|
613
|
+
|
|
614
|
+
viewport.addEventListener("dragleave", (e) => {
|
|
615
|
+
if (!viewport.contains(e.relatedTarget)) clearDropState();
|
|
616
|
+
});
|
|
617
|
+
|
|
618
|
+
viewport.addEventListener("drop", async (e) => {
|
|
619
|
+
const slug = e.dataTransfer?.getData("text/forge-skill-slug");
|
|
620
|
+
clearDropState();
|
|
621
|
+
if (!slug) return;
|
|
622
|
+
e.preventDefault();
|
|
623
|
+
const skill = allLibrarySkills.find((s) => s.slug === slug);
|
|
624
|
+
if (!skill) {
|
|
625
|
+
toast("Could not resolve dropped skill", "error");
|
|
626
|
+
return;
|
|
627
|
+
}
|
|
628
|
+
if (isInstalled(skill.slug)) {
|
|
629
|
+
toast(`${skill.name || skill.slug} is already installed`, "info");
|
|
630
|
+
return;
|
|
631
|
+
}
|
|
632
|
+
await installSkill(skill);
|
|
633
|
+
});
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
/* ══════════════════════════════════════════════════════════
|
|
637
|
+
OpenClaw env editor (top-right HUD button)
|
|
638
|
+
══════════════════════════════════════════════════════════ */
|
|
639
|
+
const OPENCLAW_ENV_FIELDS = [
|
|
640
|
+
{ key: "OPENAI_API_KEY", sensitive: true },
|
|
641
|
+
{ key: "ANTHROPIC_API_KEY", sensitive: true },
|
|
642
|
+
{ key: "PERPLEXITY_API_KEY", sensitive: true },
|
|
643
|
+
{ key: "GROQ_API_KEY", sensitive: true },
|
|
644
|
+
{ key: "TOGETHER_API_KEY", sensitive: true },
|
|
645
|
+
{ key: "OLLAMA_HOST", sensitive: false },
|
|
646
|
+
{ key: "OLLAMA_BASE_URL", sensitive: false },
|
|
647
|
+
];
|
|
648
|
+
|
|
649
|
+
function initOpenClawEnvEditor() {
|
|
650
|
+
const openBtn = document.getElementById("forgeEnvBtn");
|
|
651
|
+
const closeBtn = document.getElementById("forgeEnvClose");
|
|
652
|
+
const saveBtn = document.getElementById("forgeEnvSave");
|
|
653
|
+
const reloadBtn = document.getElementById("forgeEnvReload");
|
|
654
|
+
const backdrop = document.getElementById("forgeEnvBackdrop");
|
|
655
|
+
const modal = document.getElementById("forgeEnvModal");
|
|
656
|
+
const note = document.getElementById("forgeEnvNote");
|
|
657
|
+
const providerStatus = document.getElementById("forgeEnvProviderStatus");
|
|
658
|
+
const form = document.getElementById("forgeEnvForm");
|
|
659
|
+
if (!openBtn || !modal || !form) return;
|
|
660
|
+
if (isHostedForge()) {
|
|
661
|
+
openBtn.style.display = "none";
|
|
662
|
+
return;
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
let envValues = {};
|
|
666
|
+
let customEnvValues = {};
|
|
667
|
+
const removedCustomKeys = new Set();
|
|
668
|
+
|
|
669
|
+
function setOpen(isOpen) {
|
|
670
|
+
modal.setAttribute("data-open", isOpen ? "1" : "0");
|
|
671
|
+
backdrop?.setAttribute("data-open", isOpen ? "1" : "0");
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
function renderProviderChip(status) {
|
|
675
|
+
if (!providerStatus) return;
|
|
676
|
+
if (!status) {
|
|
677
|
+
providerStatus.dataset.state = "err";
|
|
678
|
+
providerStatus.textContent = "Provider unknown (could not reach gateway status)";
|
|
679
|
+
return;
|
|
680
|
+
}
|
|
681
|
+
if (status.configured) {
|
|
682
|
+
providerStatus.dataset.state = "ok";
|
|
683
|
+
const transport = `${status.provider || "openclaw-gateway"} · ${status.model || "main session"}`;
|
|
684
|
+
const hintProvider = String(status.llmProviderHint || "").trim();
|
|
685
|
+
const hintModel = String(status.llmModelHint || "").trim();
|
|
686
|
+
if (hintProvider || hintModel) {
|
|
687
|
+
providerStatus.textContent = `Active: ${transport} (LLM: ${hintProvider || "unknown"}${hintModel ? `/${hintModel}` : ""})`;
|
|
688
|
+
} else {
|
|
689
|
+
providerStatus.textContent = `Active: ${transport}`;
|
|
690
|
+
}
|
|
691
|
+
return;
|
|
692
|
+
}
|
|
693
|
+
providerStatus.dataset.state = "warn";
|
|
694
|
+
providerStatus.textContent = "Gateway not ready yet. Save env and run: openclaw gateway start";
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
async function refreshProviderChip() {
|
|
698
|
+
if (!providerStatus) return;
|
|
699
|
+
providerStatus.dataset.state = "loading";
|
|
700
|
+
providerStatus.textContent = "Checking active provider...";
|
|
701
|
+
const status = await fetchJSON("/api/forge/status", {}, { retries: 1, timeoutMs: 8000 });
|
|
702
|
+
renderProviderChip(status);
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
function buildEnvRow({ key, value, sensitive = false, isCustom = false }) {
|
|
706
|
+
const row = document.createElement("div");
|
|
707
|
+
row.className = "forge-env-row";
|
|
708
|
+
const keyEl = document.createElement("div");
|
|
709
|
+
keyEl.className = "forge-env-key";
|
|
710
|
+
keyEl.textContent = key;
|
|
711
|
+
const valueWrap = document.createElement("div");
|
|
712
|
+
valueWrap.className = "forge-env-value-wrap";
|
|
713
|
+
const input = document.createElement("input");
|
|
714
|
+
input.className = "forge-env-input";
|
|
715
|
+
input.type = sensitive ? "password" : "text";
|
|
716
|
+
input.autocomplete = "off";
|
|
717
|
+
input.spellcheck = false;
|
|
718
|
+
input.value = String(value || "");
|
|
719
|
+
input.placeholder = sensitive ? "Enter key (leave empty to disable)" : "Optional";
|
|
720
|
+
if (isCustom) input.dataset.customEnvKey = key;
|
|
721
|
+
else input.dataset.envKey = key;
|
|
722
|
+
valueWrap.appendChild(input);
|
|
723
|
+
if (isCustom) {
|
|
724
|
+
const removeBtn = document.createElement("button");
|
|
725
|
+
removeBtn.type = "button";
|
|
726
|
+
removeBtn.className = "forge-env-remove";
|
|
727
|
+
removeBtn.textContent = "Remove";
|
|
728
|
+
removeBtn.addEventListener("click", () => {
|
|
729
|
+
removedCustomKeys.add(key);
|
|
730
|
+
row.remove();
|
|
731
|
+
});
|
|
732
|
+
valueWrap.appendChild(removeBtn);
|
|
733
|
+
}
|
|
734
|
+
row.append(keyEl, valueWrap);
|
|
735
|
+
return row;
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
function buildAddCustomRow() {
|
|
739
|
+
const row = document.createElement("div");
|
|
740
|
+
row.className = "forge-env-add-row";
|
|
741
|
+
row.innerHTML = `
|
|
742
|
+
<input class="forge-env-input" id="forgeEnvNewKey" type="text" placeholder="CUSTOM_API_KEY" autocomplete="off" spellcheck="false" />
|
|
743
|
+
<input class="forge-env-input" id="forgeEnvNewValue" type="text" placeholder="Value" autocomplete="off" spellcheck="false" />
|
|
744
|
+
<button class="forge-btn forge-btn-secondary" id="forgeEnvAddCustom" type="button">Add</button>
|
|
745
|
+
`;
|
|
746
|
+
row.querySelector("#forgeEnvAddCustom")?.addEventListener("click", () => {
|
|
747
|
+
const keyEl = row.querySelector("#forgeEnvNewKey");
|
|
748
|
+
const valEl = row.querySelector("#forgeEnvNewValue");
|
|
749
|
+
const rawKey = String(keyEl?.value || "").trim();
|
|
750
|
+
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(rawKey)) {
|
|
751
|
+
toast("Invalid env key format", "error");
|
|
752
|
+
return;
|
|
753
|
+
}
|
|
754
|
+
const key = rawKey;
|
|
755
|
+
if (OPENCLAW_ENV_FIELDS.some((f) => f.key === key) || form.querySelector(`[data-custom-env-key="${key}"]`)) {
|
|
756
|
+
toast("Env key already exists in editor", "info");
|
|
757
|
+
return;
|
|
758
|
+
}
|
|
759
|
+
const rowNode = buildEnvRow({ key, value: String(valEl?.value || ""), isCustom: true });
|
|
760
|
+
row.before(rowNode);
|
|
761
|
+
if (keyEl) keyEl.value = "";
|
|
762
|
+
if (valEl) valEl.value = "";
|
|
763
|
+
removedCustomKeys.delete(key);
|
|
764
|
+
});
|
|
765
|
+
return row;
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
function renderForm(values = {}) {
|
|
769
|
+
form.replaceChildren();
|
|
770
|
+
for (const field of OPENCLAW_ENV_FIELDS) {
|
|
771
|
+
form.appendChild(buildEnvRow({
|
|
772
|
+
key: field.key,
|
|
773
|
+
value: String(values[field.key] || ""),
|
|
774
|
+
sensitive: field.sensitive,
|
|
775
|
+
}));
|
|
776
|
+
}
|
|
777
|
+
const sub = document.createElement("div");
|
|
778
|
+
sub.className = "forge-env-subtitle";
|
|
779
|
+
sub.textContent = "Additional .env keys";
|
|
780
|
+
form.appendChild(sub);
|
|
781
|
+
const customKeys = Object.keys(customEnvValues).sort((a, b) => a.localeCompare(b));
|
|
782
|
+
for (const key of customKeys) {
|
|
783
|
+
form.appendChild(buildEnvRow({ key, value: customEnvValues[key], isCustom: true }));
|
|
784
|
+
}
|
|
785
|
+
form.appendChild(buildAddCustomRow());
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
async function loadEnv() {
|
|
789
|
+
note.textContent = "Loading OpenClaw env from local machine...";
|
|
790
|
+
try {
|
|
791
|
+
const data = await fetchJSON("/api/openclaw/env", {}, { retries: 1, timeoutMs: 7000 });
|
|
792
|
+
if (!data?.ok) {
|
|
793
|
+
note.textContent = "Could not load OpenClaw env. This endpoint is local-only.";
|
|
794
|
+
if (providerStatus) {
|
|
795
|
+
providerStatus.dataset.state = "warn";
|
|
796
|
+
providerStatus.textContent = "Env editor unavailable in this browser context";
|
|
797
|
+
}
|
|
798
|
+
toast("Unable to load OpenClaw env from this browser context", "error");
|
|
799
|
+
return;
|
|
800
|
+
}
|
|
801
|
+
envValues = data.values || {};
|
|
802
|
+
customEnvValues = data.customValues || {};
|
|
803
|
+
removedCustomKeys.clear();
|
|
804
|
+
renderForm(envValues);
|
|
805
|
+
note.textContent = `Editing ${data.envPath}. Changes apply to Forge/OpenClaw provider detection.`;
|
|
806
|
+
} finally {
|
|
807
|
+
// Always refresh gateway/provider chip, even if env endpoint is unavailable.
|
|
808
|
+
await refreshProviderChip();
|
|
809
|
+
}
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
async function saveEnv() {
|
|
813
|
+
const updates = {};
|
|
814
|
+
form.querySelectorAll("[data-env-key]").forEach((input) => {
|
|
815
|
+
const key = input.dataset.envKey;
|
|
816
|
+
const value = String(input.value || "").trim();
|
|
817
|
+
if (envValues[key] !== value) updates[key] = value;
|
|
818
|
+
});
|
|
819
|
+
form.querySelectorAll("[data-custom-env-key]").forEach((input) => {
|
|
820
|
+
const key = input.dataset.customEnvKey;
|
|
821
|
+
const value = String(input.value || "").trim();
|
|
822
|
+
if ((customEnvValues[key] || "") !== value) updates[key] = value;
|
|
823
|
+
});
|
|
824
|
+
for (const key of removedCustomKeys) updates[key] = "";
|
|
825
|
+
if (Object.keys(updates).length === 0) {
|
|
826
|
+
toast("No env changes to save", "info");
|
|
827
|
+
return;
|
|
828
|
+
}
|
|
829
|
+
if (saveBtn) saveBtn.disabled = true;
|
|
830
|
+
try {
|
|
831
|
+
const res = await fetch("/api/openclaw/env", {
|
|
832
|
+
method: "POST",
|
|
833
|
+
headers: { "content-type": "application/json" },
|
|
834
|
+
body: JSON.stringify({ updates }),
|
|
835
|
+
});
|
|
836
|
+
const data = await res.json().catch(() => null);
|
|
837
|
+
if (!res.ok || !data?.ok) {
|
|
838
|
+
throw new Error(data?.error || `HTTP ${res.status}`);
|
|
839
|
+
}
|
|
840
|
+
toast("OpenClaw env saved. Forge will use new values immediately.", "success");
|
|
841
|
+
await loadEnv();
|
|
842
|
+
const status = await fetchJSON("/api/forge/status", {}, { retries: 1, timeoutMs: 10000 });
|
|
843
|
+
forgeAgentOnline = !!(status?.configured);
|
|
844
|
+
renderProviderChip(status);
|
|
845
|
+
updateHeader();
|
|
846
|
+
} catch (err) {
|
|
847
|
+
toast(`Env save failed: ${err.message}`, "error", 5000);
|
|
848
|
+
} finally {
|
|
849
|
+
if (saveBtn) saveBtn.disabled = false;
|
|
850
|
+
}
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
openBtn.addEventListener("click", async () => {
|
|
854
|
+
setOpen(true);
|
|
855
|
+
await loadEnv();
|
|
856
|
+
form.querySelector(".forge-env-input")?.focus();
|
|
857
|
+
});
|
|
858
|
+
closeBtn?.addEventListener("click", () => setOpen(false));
|
|
859
|
+
backdrop?.addEventListener("click", () => setOpen(false));
|
|
860
|
+
reloadBtn?.addEventListener("click", async () => { await loadEnv(); });
|
|
861
|
+
saveBtn?.addEventListener("click", saveEnv);
|
|
862
|
+
window.addEventListener("keydown", (e) => {
|
|
863
|
+
if (e.key === "Escape" && modal.getAttribute("data-open") === "1") setOpen(false);
|
|
864
|
+
});
|
|
584
865
|
}
|
|
585
866
|
|
|
586
867
|
async function searchSkills() {
|
|
@@ -661,6 +942,18 @@ function renderSearchResults(results) {
|
|
|
661
942
|
}).join("");
|
|
662
943
|
|
|
663
944
|
container.querySelectorAll("[data-install-slug]").forEach(btn => {
|
|
945
|
+
const card = btn.closest(".forge-skill-card");
|
|
946
|
+
if (card) {
|
|
947
|
+
card.draggable = true;
|
|
948
|
+
card.addEventListener("dragstart", (e) => {
|
|
949
|
+
e.dataTransfer?.setData("text/forge-skill-slug", btn.getAttribute("data-install-slug") || "");
|
|
950
|
+
if (e.dataTransfer) e.dataTransfer.effectAllowed = "copy";
|
|
951
|
+
toast("Drop this skill onto the bot viewport to install", "info", 1200);
|
|
952
|
+
});
|
|
953
|
+
card.addEventListener("dragend", () => {
|
|
954
|
+
document.getElementById("forgeViewport")?.classList.remove("forge-drop-active");
|
|
955
|
+
});
|
|
956
|
+
}
|
|
664
957
|
btn.addEventListener("click", (e) => {
|
|
665
958
|
e.stopPropagation();
|
|
666
959
|
const slug = btn.getAttribute("data-install-slug");
|
|
@@ -785,6 +1078,7 @@ async function init() {
|
|
|
785
1078
|
updateHeader();
|
|
786
1079
|
initShareToX();
|
|
787
1080
|
initSkillBrowser();
|
|
1081
|
+
initOpenClawEnvEditor();
|
|
788
1082
|
|
|
789
1083
|
if (!loaded || loaded.length === 0) {
|
|
790
1084
|
updateProgress(0, 0);
|
|
@@ -238,7 +238,7 @@ export let robotGroup, attachmentGroup, energyNetworkGroup, platformGroup;
|
|
|
238
238
|
export let coreMesh, visorMesh, spineSegments = [];
|
|
239
239
|
let ambientParticles, energyStreams, bloomPass, ssaoPass, filmGrainPass;
|
|
240
240
|
let autoRotateTimer = null;
|
|
241
|
-
let bloomEnabled =
|
|
241
|
+
let bloomEnabled = false;
|
|
242
242
|
let selectedAttachment = null;
|
|
243
243
|
let glowRings = [];
|
|
244
244
|
let exhaustFlames = [];
|
|
@@ -251,9 +251,23 @@ let heatShimmerMeshes = [];
|
|
|
251
251
|
let robotPowered = false;
|
|
252
252
|
let powerLevel = 0;
|
|
253
253
|
let powerDownTimer = null;
|
|
254
|
+
let robotEmissiveMaterials = [];
|
|
254
255
|
const POWER_UP_SPEED = 2.5;
|
|
255
256
|
const POWER_DOWN_SPEED = 1.2;
|
|
256
257
|
const POWER_DOWN_DELAY = 4000;
|
|
258
|
+
const MOTION_BOUNDS_RADIUS = 2.0;
|
|
259
|
+
const MOTION_REACH_EPSILON = 0.25;
|
|
260
|
+
const MOTION_TURN_LERP = 1.2;
|
|
261
|
+
const MOTION_SPEED_LERP = 1.0;
|
|
262
|
+
const MOTION_WALK_SPEED = 0.3;
|
|
263
|
+
const MOTION_REPICK_MS = 12_000;
|
|
264
|
+
const motionState = {
|
|
265
|
+
speed: 0,
|
|
266
|
+
targetX: 0,
|
|
267
|
+
targetZ: 0,
|
|
268
|
+
activeUntil: 0,
|
|
269
|
+
lastPickAt: 0,
|
|
270
|
+
};
|
|
257
271
|
|
|
258
272
|
const viewportEl = () => document.getElementById("forgeViewport");
|
|
259
273
|
const canvasEl = () => document.getElementById("forgeCanvas");
|
|
@@ -2330,6 +2344,132 @@ function schedulePowerDown() {
|
|
|
2330
2344
|
}, POWER_DOWN_DELAY);
|
|
2331
2345
|
}
|
|
2332
2346
|
|
|
2347
|
+
function pickAutonomousTarget() {
|
|
2348
|
+
const r = 0.4 + Math.random() * (MOTION_BOUNDS_RADIUS - 0.4);
|
|
2349
|
+
const a = Math.random() * Math.PI * 2;
|
|
2350
|
+
motionState.targetX = Math.cos(a) * r;
|
|
2351
|
+
motionState.targetZ = Math.sin(a) * r;
|
|
2352
|
+
motionState.lastPickAt = Date.now();
|
|
2353
|
+
}
|
|
2354
|
+
|
|
2355
|
+
function normalizeAngle(v) {
|
|
2356
|
+
let a = v;
|
|
2357
|
+
while (a > Math.PI) a -= Math.PI * 2;
|
|
2358
|
+
while (a < -Math.PI) a += Math.PI * 2;
|
|
2359
|
+
return a;
|
|
2360
|
+
}
|
|
2361
|
+
|
|
2362
|
+
function updateAutonomousMotion(dt) {
|
|
2363
|
+
if (!robotGroup) return;
|
|
2364
|
+
const now = Date.now();
|
|
2365
|
+
const active = now < motionState.activeUntil;
|
|
2366
|
+
if (active && (motionState.lastPickAt === 0 || now - motionState.lastPickAt > MOTION_REPICK_MS)) {
|
|
2367
|
+
pickAutonomousTarget();
|
|
2368
|
+
}
|
|
2369
|
+
|
|
2370
|
+
const cx = robotGroup.position.x;
|
|
2371
|
+
const cz = robotGroup.position.z;
|
|
2372
|
+
const dx = motionState.targetX - cx;
|
|
2373
|
+
const dz = motionState.targetZ - cz;
|
|
2374
|
+
const dist = Math.hypot(dx, dz);
|
|
2375
|
+
const hasTarget = active && dist > MOTION_REACH_EPSILON;
|
|
2376
|
+
const desiredSpeed = hasTarget ? MOTION_WALK_SPEED : 0;
|
|
2377
|
+
motionState.speed += (desiredSpeed - motionState.speed) * Math.min(1, dt * MOTION_SPEED_LERP);
|
|
2378
|
+
if (motionState.speed < 0.002) motionState.speed = 0;
|
|
2379
|
+
|
|
2380
|
+
if (motionState.speed > 0.002 && dist > 0.001) {
|
|
2381
|
+
const step = Math.min(dist, motionState.speed * dt);
|
|
2382
|
+
const nx = cx + (dx / dist) * step;
|
|
2383
|
+
const nz = cz + (dz / dist) * step;
|
|
2384
|
+
const rr = Math.hypot(nx, nz);
|
|
2385
|
+
if (rr > MOTION_BOUNDS_RADIUS) {
|
|
2386
|
+
robotGroup.position.x = (nx / rr) * MOTION_BOUNDS_RADIUS;
|
|
2387
|
+
robotGroup.position.z = (nz / rr) * MOTION_BOUNDS_RADIUS;
|
|
2388
|
+
} else {
|
|
2389
|
+
robotGroup.position.x = nx;
|
|
2390
|
+
robotGroup.position.z = nz;
|
|
2391
|
+
}
|
|
2392
|
+
const desiredYaw = Math.atan2(dx, dz);
|
|
2393
|
+
const delta = normalizeAngle(desiredYaw - robotGroup.rotation.y);
|
|
2394
|
+
robotGroup.rotation.y += delta * Math.min(1, dt * MOTION_TURN_LERP);
|
|
2395
|
+
} else if (hasTarget && dist <= MOTION_REACH_EPSILON) {
|
|
2396
|
+
motionState.lastPickAt = now;
|
|
2397
|
+
}
|
|
2398
|
+
}
|
|
2399
|
+
|
|
2400
|
+
export function setMotionIntent(intent = {}) {
|
|
2401
|
+
const type = String(intent?.type || "").toLowerCase();
|
|
2402
|
+
if (!type) return;
|
|
2403
|
+
if (type === "halt") {
|
|
2404
|
+
motionState.activeUntil = 0;
|
|
2405
|
+
motionState.speed = 0;
|
|
2406
|
+
return;
|
|
2407
|
+
}
|
|
2408
|
+
if (type === "patrol" || type === "wander") {
|
|
2409
|
+
const dur = Math.max(4000, Math.min(30_000, Number(intent.durationMs || 15000)));
|
|
2410
|
+
motionState.activeUntil = Date.now() + dur;
|
|
2411
|
+
pickAutonomousTarget();
|
|
2412
|
+
powerOn();
|
|
2413
|
+
schedulePowerDown();
|
|
2414
|
+
return;
|
|
2415
|
+
}
|
|
2416
|
+
if (type === "goto") {
|
|
2417
|
+
const x = Number(intent.x);
|
|
2418
|
+
const z = Number(intent.z);
|
|
2419
|
+
if (!Number.isFinite(x) || !Number.isFinite(z)) return;
|
|
2420
|
+
const rr = Math.hypot(x, z);
|
|
2421
|
+
if (rr > MOTION_BOUNDS_RADIUS) {
|
|
2422
|
+
motionState.targetX = (x / rr) * MOTION_BOUNDS_RADIUS;
|
|
2423
|
+
motionState.targetZ = (z / rr) * MOTION_BOUNDS_RADIUS;
|
|
2424
|
+
} else {
|
|
2425
|
+
motionState.targetX = x;
|
|
2426
|
+
motionState.targetZ = z;
|
|
2427
|
+
}
|
|
2428
|
+
const dur = Math.max(3000, Math.min(30_000, Number(intent.durationMs || 12000)));
|
|
2429
|
+
motionState.activeUntil = Date.now() + dur;
|
|
2430
|
+
motionState.lastPickAt = Date.now();
|
|
2431
|
+
powerOn();
|
|
2432
|
+
schedulePowerDown();
|
|
2433
|
+
}
|
|
2434
|
+
}
|
|
2435
|
+
|
|
2436
|
+
function cacheRobotEmissiveMaterials() {
|
|
2437
|
+
robotEmissiveMaterials = [];
|
|
2438
|
+
if (!robotGroup) return;
|
|
2439
|
+
robotGroup.traverse((node) => {
|
|
2440
|
+
if (!node?.isMesh || !node.material) return;
|
|
2441
|
+
const mats = Array.isArray(node.material) ? node.material : [node.material];
|
|
2442
|
+
for (const mat of mats) {
|
|
2443
|
+
if (typeof mat?.emissiveIntensity !== "number") continue;
|
|
2444
|
+
if (mat.userData?._baseRobotEmissive === undefined) {
|
|
2445
|
+
if (!mat.userData) mat.userData = {};
|
|
2446
|
+
mat.userData._baseRobotEmissive = mat.emissiveIntensity;
|
|
2447
|
+
}
|
|
2448
|
+
robotEmissiveMaterials.push(mat);
|
|
2449
|
+
}
|
|
2450
|
+
});
|
|
2451
|
+
}
|
|
2452
|
+
|
|
2453
|
+
function applyRobotPowerToStaticEmissive(power) {
|
|
2454
|
+
for (const mat of robotEmissiveMaterials) {
|
|
2455
|
+
const base = Number(mat?.userData?._baseRobotEmissive ?? 0);
|
|
2456
|
+
mat.emissiveIntensity = base * power;
|
|
2457
|
+
const isGlowMaterial = base >= 0.6;
|
|
2458
|
+
if (isGlowMaterial && mat?.transparent) {
|
|
2459
|
+
if (mat.userData._baseRobotOpacity === undefined) {
|
|
2460
|
+
mat.userData._baseRobotOpacity = Number(mat.opacity ?? 1);
|
|
2461
|
+
}
|
|
2462
|
+
mat.opacity = Number(mat.userData._baseRobotOpacity || 1) * power;
|
|
2463
|
+
}
|
|
2464
|
+
if (isGlowMaterial && typeof mat?.transmission === "number") {
|
|
2465
|
+
if (mat.userData._baseRobotTransmission === undefined) {
|
|
2466
|
+
mat.userData._baseRobotTransmission = Number(mat.transmission ?? 0);
|
|
2467
|
+
}
|
|
2468
|
+
mat.transmission = Number(mat.userData._baseRobotTransmission || 0) * power;
|
|
2469
|
+
}
|
|
2470
|
+
}
|
|
2471
|
+
}
|
|
2472
|
+
|
|
2333
2473
|
export function toggleRobotPower() {
|
|
2334
2474
|
if (robotPowered) {
|
|
2335
2475
|
robotPowered = false;
|
|
@@ -2347,6 +2487,7 @@ function animate() {
|
|
|
2347
2487
|
const dt = 1 / 60;
|
|
2348
2488
|
|
|
2349
2489
|
controls.update();
|
|
2490
|
+
updateAutonomousMotion(dt);
|
|
2350
2491
|
|
|
2351
2492
|
// ── Power level ramp (smooth on/off transition) ──
|
|
2352
2493
|
const targetPower = robotPowered ? 1.0 : 0.0;
|
|
@@ -2356,6 +2497,7 @@ function animate() {
|
|
|
2356
2497
|
powerLevel = Math.max(powerLevel - dt * POWER_DOWN_SPEED, 0.0);
|
|
2357
2498
|
}
|
|
2358
2499
|
const pw = powerLevel;
|
|
2500
|
+
applyRobotPowerToStaticEmissive(pw);
|
|
2359
2501
|
|
|
2360
2502
|
// Core reactor pulse
|
|
2361
2503
|
if (coreMesh) {
|
|
@@ -2378,10 +2520,17 @@ function animate() {
|
|
|
2378
2520
|
const scanPulse = Math.exp(-30 * (scanPhase - 0.5) * (scanPhase - 0.5)) * 0.12;
|
|
2379
2521
|
const speakBurst = agentSpeaking ? Math.sin(time * 8) * 0.04 + Math.random() * 0.03 : 0;
|
|
2380
2522
|
|
|
2381
|
-
|
|
2382
|
-
|
|
2383
|
-
|
|
2384
|
-
|
|
2523
|
+
if (pw <= 0.0001) {
|
|
2524
|
+
visorMesh.material.transmission = 0;
|
|
2525
|
+
visorMesh.material.emissiveIntensity = 0;
|
|
2526
|
+
visorMesh.material.opacity = 0.02;
|
|
2527
|
+
visorMesh.material.iridescence = 0;
|
|
2528
|
+
} else {
|
|
2529
|
+
visorMesh.material.transmission = 0.02 + (0.4 + 0.42 * pw + (vBreath + vNoise + scanPulse * 0.3) * pw) * pw;
|
|
2530
|
+
visorMesh.material.emissiveIntensity = ((2.0 + 0.4 * Math.sin(time * 2.5) + scanPulse * 2.0 + speakBurst) * sp) * pw;
|
|
2531
|
+
visorMesh.material.opacity = (0.3 + 0.34 * pw + (vBreath * 0.5 + scanPulse * 0.1) * pw) * pw;
|
|
2532
|
+
visorMesh.material.iridescence = (0.5 + 0.15 * Math.sin(time * 1.2)) * pw;
|
|
2533
|
+
}
|
|
2385
2534
|
}
|
|
2386
2535
|
|
|
2387
2536
|
// Joint glow rings pulse
|
|
@@ -2430,25 +2579,33 @@ function animate() {
|
|
|
2430
2579
|
|
|
2431
2580
|
if (idleAnimatorFn) idleAnimatorFn(time);
|
|
2432
2581
|
|
|
2433
|
-
// ── Procedural idle motion ──
|
|
2434
|
-
|
|
2582
|
+
// ── Procedural idle + walk motion ──
|
|
2583
|
+
// sp: subtle emphasis when speaking (1.15x, not 1.8x — keep it grounded)
|
|
2584
|
+
// gait: 0 = idle, 1 = full walk (normalized to MOTION_WALK_SPEED)
|
|
2585
|
+
const sp = agentSpeaking ? 1.15 : 1.0;
|
|
2586
|
+
const gait = Math.min(1, Math.max(0, motionState.speed / MOTION_WALK_SPEED));
|
|
2587
|
+
const walkFreq = 2.8;
|
|
2588
|
+
|
|
2435
2589
|
if (robotGroup) {
|
|
2436
|
-
|
|
2437
|
-
robotGroup.
|
|
2438
|
-
|
|
2590
|
+
// Idle: very subtle breathing bob. Walk: gentle step-bob.
|
|
2591
|
+
robotGroup.position.y = Math.sin(time * (1.0 + gait * walkFreq)) * (0.015 + gait * 0.01) * sp;
|
|
2592
|
+
// Body lean into walk direction
|
|
2593
|
+
robotGroup.rotation.x = Math.sin(time * (0.6 + gait * walkFreq)) * (0.003 + gait * 0.006) * sp;
|
|
2439
2594
|
}
|
|
2440
2595
|
if (headPivotRef) {
|
|
2441
|
-
|
|
2442
|
-
headPivotRef.rotation.
|
|
2443
|
-
headPivotRef.rotation.
|
|
2596
|
+
// Head look: slow idle scan; during walk, slight stabilization counter-sway
|
|
2597
|
+
headPivotRef.rotation.y = Math.sin(time * (0.25 + gait * 0.6)) * (0.06 + gait * 0.015) * sp;
|
|
2598
|
+
headPivotRef.rotation.x = Math.sin(time * (0.4 + gait * 0.5) + 0.3) * (0.02 + gait * 0.008) * sp;
|
|
2599
|
+
headPivotRef.rotation.z = Math.sin(time * 0.18) * 0.01 * sp;
|
|
2444
2600
|
}
|
|
2445
2601
|
if (leftArmPivotRef) {
|
|
2446
|
-
|
|
2447
|
-
leftArmPivotRef.rotation.
|
|
2602
|
+
// Arms: very gentle idle sway; during walk, mild opposite-phase pendulum swing
|
|
2603
|
+
leftArmPivotRef.rotation.x = Math.sin(time * (0.6 + gait * walkFreq)) * (0.015 + gait * 0.05) * sp;
|
|
2604
|
+
leftArmPivotRef.rotation.z = Math.sin(time * (0.4 + gait * 1.2)) * (0.01 + gait * 0.018) * sp;
|
|
2448
2605
|
}
|
|
2449
2606
|
if (rightArmPivotRef) {
|
|
2450
|
-
rightArmPivotRef.rotation.x = Math.sin(time * 0.
|
|
2451
|
-
rightArmPivotRef.rotation.z = -Math.sin(time * 0.
|
|
2607
|
+
rightArmPivotRef.rotation.x = Math.sin(time * (0.6 + gait * walkFreq) + Math.PI) * (0.015 + gait * 0.05) * sp;
|
|
2608
|
+
rightArmPivotRef.rotation.z = -Math.sin(time * (0.4 + gait * 1.2) + 0.4) * (0.01 + gait * 0.018) * sp;
|
|
2452
2609
|
}
|
|
2453
2610
|
|
|
2454
2611
|
// Handheld camera sway (applied to scene root to avoid fighting OrbitControls)
|
|
@@ -2693,6 +2850,8 @@ export function initForgeScene() {
|
|
|
2693
2850
|
requestAnimationFrame(() => {
|
|
2694
2851
|
const t0 = performance.now();
|
|
2695
2852
|
robotGroup.add(buildChassis());
|
|
2853
|
+
cacheRobotEmissiveMaterials();
|
|
2854
|
+
applyRobotPowerToStaticEmissive(0);
|
|
2696
2855
|
console.log(`[forge] chassis built in ${(performance.now() - t0).toFixed(0)}ms, geo cache: ${_geoCache.size} entries`);
|
|
2697
2856
|
resolve();
|
|
2698
2857
|
});
|
|
@@ -2711,7 +2870,7 @@ export function initForgeScene() {
|
|
|
2711
2870
|
composer.addPass(ssaoPass);
|
|
2712
2871
|
} catch { /* SSAO unavailable — continue without it */ }
|
|
2713
2872
|
|
|
2714
|
-
bloomPass = new UnrealBloomPass(new THREE.Vector2(w, h), 0.
|
|
2873
|
+
bloomPass = new UnrealBloomPass(new THREE.Vector2(w, h), bloomEnabled ? 0.55 : 0, 0.35, 0.85);
|
|
2715
2874
|
composer.addPass(bloomPass);
|
|
2716
2875
|
|
|
2717
2876
|
outlinePass = new OutlinePass(new THREE.Vector2(w, h), scene, camera);
|
|
@@ -2753,6 +2912,7 @@ export function initForgeScene() {
|
|
|
2753
2912
|
animate();
|
|
2754
2913
|
|
|
2755
2914
|
window.__forgeSetSpeaking = setAgentSpeaking;
|
|
2915
|
+
window.__forgeSetMotionIntent = setMotionIntent;
|
|
2756
2916
|
|
|
2757
2917
|
_chassisReady.then(() => {
|
|
2758
2918
|
playCinematicIntro(() => {
|
package/ui/index.html
CHANGED
|
@@ -869,9 +869,9 @@ footer::before{
|
|
|
869
869
|
<div class="setup-step" data-setup-mode="quick">
|
|
870
870
|
<div class="setup-step-num">1</div>
|
|
871
871
|
<h3>Install ApeClaw</h3>
|
|
872
|
-
<pre>npx ape-claw skill install</pre>
|
|
873
|
-
<p class="note">Run in any terminal. Requires <a href="https://openclaw.ai" target="_blank" rel="noopener">OpenClaw</a> + Node.js. Installs the core skill
|
|
874
|
-
<p class="note" style="margin-top:4px">Add <code>--starter-pack</code> to skip the prompt. Add <code>--scope local</code> to install into the current project only
|
|
872
|
+
<pre>npx --yes ape-claw@latest skill install</pre>
|
|
873
|
+
<p class="note">Run in any terminal. Requires <a href="https://openclaw.ai" target="_blank" rel="noopener">OpenClaw</a> + Node.js. Installs the core skill, prompts to add 61 curated skills (starter pack), and prompts for the local Forge dashboard upgrade flow. Skills are installed to <code>~/.openclaw/skills/</code>.</p>
|
|
874
|
+
<p class="note" style="margin-top:4px">Add <code>--starter-pack</code> to skip the prompt. Add <code>--scope local</code> to install into the current project only. Open Forge any time with <code>npx --yes ape-claw@latest dashboard</code>.</p>
|
|
875
875
|
</div>
|
|
876
876
|
<div class="setup-step" data-setup-mode="quick">
|
|
877
877
|
<div class="setup-step-num">2</div>
|