ape-claw 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.cursor/skills/ape-claw/SKILL.md +322 -0
- package/LICENSE +21 -0
- package/README.md +826 -0
- package/allowlists/opensea-slug-overrides.json +13 -0
- package/allowlists/recommended.apechain.json +322 -0
- package/config/clawbots.example.json +3 -0
- package/config/policy.example.json +27 -0
- package/data/starter-pack-bundle.json +1 -0
- package/data/starter-pack.json +495 -0
- package/docs/ACP_BOUNTIES.md +108 -0
- package/docs/APECLAW_V2_ALPHA.md +206 -0
- package/docs/AUTONOMY_AND_SUBSTRATE.md +69 -0
- package/docs/CLAWBOTS_AND_INVITES.md +102 -0
- package/docs/CLI_GUIDE.md +124 -0
- package/docs/CONTRIBUTING.md +130 -0
- package/docs/DASHBOARD_GUIDE.md +108 -0
- package/docs/GLOBAL_BACKEND.md +145 -0
- package/docs/ONCHAIN_V2_GUIDE.md +140 -0
- package/docs/PRODUCT_OVERVIEW.md +127 -0
- package/docs/README.md +40 -0
- package/docs/SKILLCARDS_AND_IMPORTER.md +147 -0
- package/docs/STARTER_PACK.md +297 -0
- package/docs/SUPPORTED_NETWORKS.md +58 -0
- package/docs/TELEMETRY_AND_EVENTS.md +103 -0
- package/docs/THE_POD_RUNNER.md +198 -0
- package/docs/V1_WORKFLOWS.md +108 -0
- package/docs/V2_ONCHAIN_SKILLS.md +157 -0
- package/docs/WEB4_PLAN_STATUS.md +95 -0
- package/docs/WEB4_SWARM_MODEL.md +104 -0
- package/docs/archive/AUTONOMY_AND_SUBSTRATE.md +66 -0
- package/docs/archive/WEB4_PLAN_STATUS.md +93 -0
- package/docs/archive/WEB4_SWARM_MODEL.md +98 -0
- package/docs/developer/01-architecture.md +345 -0
- package/docs/developer/02-contracts.md +1034 -0
- package/docs/developer/03-writing-modules.md +513 -0
- package/docs/developer/04-skillcard-spec.md +336 -0
- package/docs/developer/05-backend-api.md +1079 -0
- package/docs/developer/06-telemetry.md +798 -0
- package/docs/developer/07-testing.md +546 -0
- package/docs/developer/08-contributing.md +211 -0
- package/docs/operator/01-quickstart.md +49 -0
- package/docs/operator/02-dashboard.md +174 -0
- package/docs/operator/03-cli-reference.md +818 -0
- package/docs/operator/04-skills-library.md +169 -0
- package/docs/operator/05-pod-operations.md +314 -0
- package/docs/operator/06-deployment.md +299 -0
- package/docs/operator/07-safety-and-policy.md +311 -0
- package/docs/operator/08-troubleshooting.md +457 -0
- package/docs/operator/09-env-reference.md +238 -0
- package/docs/social/STARTER_PACK_THREAD.md +209 -0
- package/package.json +77 -0
- package/skillcards/import-sources.json +93 -0
- package/skillcards/seed/acp-bounty-poll.v1.json +38 -0
- package/skillcards/seed/acp-bounty-post.v1.json +55 -0
- package/skillcards/seed/acp-browse.v1.json +41 -0
- package/skillcards/seed/acp-fulfill-and-route.v1.json +56 -0
- package/skillcards/seed/apeclaw-bridge-relay.v1.json +46 -0
- package/skillcards/seed/apeclaw-nft-autobuy.v1.json +60 -0
- package/skillcards/seed/apeclaw-receipt-recorder.v1.json +64 -0
- package/skillcards/seed/humanizer.v1.json +74 -0
- package/skillcards/seed/otherside-navigator.v1.json +116 -0
- package/skillcards/seed/stonkbrokers-launcher.v1.json +280 -0
- package/skillcards/seed/walkie-p2p.v1.json +66 -0
- package/src/cli/index.mjs +8 -0
- package/src/cli.mjs +1929 -0
- package/src/lib/bridge-relay.mjs +294 -0
- package/src/lib/clawbots.mjs +94 -0
- package/src/lib/io.mjs +36 -0
- package/src/lib/market.mjs +233 -0
- package/src/lib/nft-opensea.mjs +159 -0
- package/src/lib/paths.mjs +17 -0
- package/src/lib/pod-init.mjs +40 -0
- package/src/lib/policy.mjs +112 -0
- package/src/lib/rpc.mjs +49 -0
- package/src/lib/telemetry.mjs +92 -0
- package/src/lib/v2-onchain-abi.mjs +294 -0
- package/src/lib/v2-skillcard.mjs +27 -0
- package/src/server/index.mjs +169 -0
- package/src/server/logger.mjs +21 -0
- package/src/server/middleware/auth.mjs +90 -0
- package/src/server/middleware/body-limit.mjs +35 -0
- package/src/server/middleware/cors.mjs +33 -0
- package/src/server/middleware/rate-limit.mjs +44 -0
- package/src/server/routes/chat.mjs +178 -0
- package/src/server/routes/clawbots.mjs +182 -0
- package/src/server/routes/events.mjs +95 -0
- package/src/server/routes/health.mjs +72 -0
- package/src/server/routes/pod.mjs +64 -0
- package/src/server/routes/quotes.mjs +161 -0
- package/src/server/routes/skills.mjs +239 -0
- package/src/server/routes/static.mjs +161 -0
- package/src/server/routes/v2.mjs +48 -0
- package/src/server/sse.mjs +73 -0
- package/src/server/storage/file-backend.mjs +295 -0
- package/src/server/storage/index.mjs +37 -0
- package/src/server/storage/sqlite-backend.mjs +380 -0
- package/src/telemetry-server.mjs +1604 -0
- package/ui/css/dashboard.css +792 -0
- package/ui/css/skills.css +689 -0
- package/ui/docs.html +840 -0
- package/ui/favicon-180.png +0 -0
- package/ui/favicon-192.png +0 -0
- package/ui/favicon-32.png +0 -0
- package/ui/favicon-lobster.png +0 -0
- package/ui/favicon.svg +10 -0
- package/ui/index.html +2957 -0
- package/ui/js/dashboard.js +1766 -0
- package/ui/js/skills.js +1621 -0
- package/ui/pod.html +909 -0
- package/ui/shared/motion.css +286 -0
- package/ui/shared/motion.js +170 -0
- package/ui/shared/sidebar-nav.css +379 -0
- package/ui/shared/sidebar-nav.js +137 -0
- package/ui/skills.html +2879 -0
|
@@ -0,0 +1,1766 @@
|
|
|
1
|
+
window.addEventListener('error', (e) => { console.error('[ApeClaw] Uncaught error:', e.error); });
|
|
2
|
+
window.addEventListener('unhandledrejection', (e) => { console.error('[ApeClaw] Unhandled rejection:', e.reason); });
|
|
3
|
+
// ═══════════════════════════════════════════════════════════
|
|
4
|
+
// APECLAW — Full Dashboard
|
|
5
|
+
// Real-time event stream from ape-claw CLI telemetry.
|
|
6
|
+
// ═══════════════════════════════════════════════════════════
|
|
7
|
+
|
|
8
|
+
// Lightweight collage background to match the Stonk terminal feel.
|
|
9
|
+
try {
|
|
10
|
+
const c = document.getElementById('bgCollage');
|
|
11
|
+
if (c && !c.hasChildNodes()) {
|
|
12
|
+
const N = 80;
|
|
13
|
+
for (let i = 0; i < N; i++) {
|
|
14
|
+
const img = document.createElement('img');
|
|
15
|
+
img.src = '/ui/favicon-lobster.png';
|
|
16
|
+
img.alt = '';
|
|
17
|
+
img.style.setProperty('--r', `${Math.round((Math.random() * 10 - 5) * 10) / 10}deg`);
|
|
18
|
+
c.appendChild(img);
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
} catch {}
|
|
22
|
+
|
|
23
|
+
const APESCAN_TX = 'https://apescan.io/tx/';
|
|
24
|
+
const APESCAN_ADDR = 'https://apescan.io/address/';
|
|
25
|
+
|
|
26
|
+
let COLLECTIONS = [];
|
|
27
|
+
let REGISTERED_CLAWBOTS = [];
|
|
28
|
+
let collectionsCarouselTimer = null;
|
|
29
|
+
let API_BASE = '';
|
|
30
|
+
const DEFAULT_SHARED_BACKEND = 'https://apeclaw.ai';
|
|
31
|
+
let collectionQuery = '';
|
|
32
|
+
let collectionSort = 'rank';
|
|
33
|
+
let feedPaused = false;
|
|
34
|
+
let terminalAutoScroll = true;
|
|
35
|
+
let feedRawEvents = [];
|
|
36
|
+
// De-dupe telemetry events: backlog + SSE can overlap.
|
|
37
|
+
let feedKeys = [];
|
|
38
|
+
const seenFeedKeys = new Set();
|
|
39
|
+
const uiPrefs = {
|
|
40
|
+
theme: 'abyss',
|
|
41
|
+
dense: false,
|
|
42
|
+
focus: false,
|
|
43
|
+
motionLow: false,
|
|
44
|
+
};
|
|
45
|
+
function normalizeApiBase(raw) {
|
|
46
|
+
const v = String(raw || '').trim();
|
|
47
|
+
if (!v) return '';
|
|
48
|
+
try {
|
|
49
|
+
const u = new URL(v);
|
|
50
|
+
return u.origin;
|
|
51
|
+
} catch {
|
|
52
|
+
return '';
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function initApiBase() {
|
|
57
|
+
const qp = new URLSearchParams(window.location.search).get('api');
|
|
58
|
+
if (qp) {
|
|
59
|
+
API_BASE = normalizeApiBase(qp);
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
const runtime = normalizeApiBase(window.APECLAW_API_BASE || '');
|
|
63
|
+
const meta = normalizeApiBase(document.querySelector('meta[name="apeclaw-api-base"]')?.content || '');
|
|
64
|
+
if (runtime || meta) {
|
|
65
|
+
API_BASE = normalizeApiBase(runtime || meta);
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
API_BASE = '';
|
|
69
|
+
try { localStorage.removeItem('apeclaw_api_base'); } catch {}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function apiUrl(routePath) {
|
|
73
|
+
if (!API_BASE) return routePath;
|
|
74
|
+
return `${API_BASE}${routePath}`;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function describeBackend() {
|
|
78
|
+
return API_BASE || window.location.origin;
|
|
79
|
+
}
|
|
80
|
+
function announce(msg) {
|
|
81
|
+
const el = document.getElementById('srStatus');
|
|
82
|
+
if (el) el.textContent = msg;
|
|
83
|
+
}
|
|
84
|
+
function downloadBlob(filename, content, mime = 'text/plain;charset=utf-8') {
|
|
85
|
+
const blob = new Blob([content], { type: mime });
|
|
86
|
+
const url = URL.createObjectURL(blob);
|
|
87
|
+
const a = document.createElement('a');
|
|
88
|
+
a.href = url;
|
|
89
|
+
a.download = filename;
|
|
90
|
+
document.body.appendChild(a);
|
|
91
|
+
a.click();
|
|
92
|
+
a.remove();
|
|
93
|
+
URL.revokeObjectURL(url);
|
|
94
|
+
}
|
|
95
|
+
function pushToast(message, type = 'success', ttlMs = 2600) {
|
|
96
|
+
const stack = document.getElementById('toastStack');
|
|
97
|
+
if (!stack) return;
|
|
98
|
+
const toast = document.createElement('div');
|
|
99
|
+
toast.className = `toast ${type}`;
|
|
100
|
+
toast.textContent = message;
|
|
101
|
+
stack.appendChild(toast);
|
|
102
|
+
setTimeout(() => toast.remove(), ttlMs);
|
|
103
|
+
}
|
|
104
|
+
function loadUiPrefs() {
|
|
105
|
+
try {
|
|
106
|
+
const raw = localStorage.getItem('apeclaw_ui_prefs');
|
|
107
|
+
if (!raw) return;
|
|
108
|
+
const parsed = JSON.parse(raw);
|
|
109
|
+
if (parsed && typeof parsed === 'object') Object.assign(uiPrefs, parsed);
|
|
110
|
+
} catch {}
|
|
111
|
+
}
|
|
112
|
+
function saveUiPrefs() {
|
|
113
|
+
try { localStorage.setItem('apeclaw_ui_prefs', JSON.stringify(uiPrefs)); } catch {}
|
|
114
|
+
}
|
|
115
|
+
function applyUiPrefs() {
|
|
116
|
+
document.body.classList.toggle('dense-ui', Boolean(uiPrefs.dense));
|
|
117
|
+
document.body.classList.toggle('focus-ui', Boolean(uiPrefs.focus));
|
|
118
|
+
document.body.classList.toggle('motion-low', Boolean(uiPrefs.motionLow));
|
|
119
|
+
document.body.classList.toggle('theme-daylight', uiPrefs.theme === 'daylight');
|
|
120
|
+
document.body.classList.toggle('theme-ember', uiPrefs.theme === 'ember');
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
const EMOJI_MAP = {
|
|
125
|
+
// ── Specific collection matches (checked first) ──
|
|
126
|
+
taur:'🐂',gator:'🐊',zard:'🦎',pupp:'🐶',chump:'🐵',
|
|
127
|
+
sloo:'🦥',boggy:'🐸',deng:'👾',mono:'🐒',undead:'🧟',
|
|
128
|
+
nekit:'🐱',doru:'🪆',pape:'🦍',jnky:'🗑️',dsnr:'🎨',
|
|
129
|
+
trench:'🍖',mull:'💈',box:'📦',bush:'🐒',
|
|
130
|
+
// ── Generic animal / theme matches ───────────────
|
|
131
|
+
tiger:'🐯',dog:'🐕',duck:'🦆',frog:'🐸',fox:'🦊',cat:'🐱',egg:'🥚',
|
|
132
|
+
punk:'💀',zombie:'🧟',gob:'👹',dragon:'🐉',robot:'🤖',bear:'🐻',monkey:'🐒',
|
|
133
|
+
skull:'💀',owl:'🦉',bat:'🦇',bull:'🐂',otter:'🦦',lobster:'🦞',star:'⭐',
|
|
134
|
+
kid:'👶',baby:'👶',doll:'🪆',bird:'🐦',cube:'🧊',dice:'🎲',sword:'⚔️',
|
|
135
|
+
stk:'🎭',
|
|
136
|
+
pixel:'🟩',pix:'🟩',glyph:'✨',night:'🌙',frost:'❄️',fire:'🔥',ice:'🧊',
|
|
137
|
+
balloon:'🎈',clown:'🤡',sock:'🧦',rilla:'🦍',
|
|
138
|
+
// ── "ape" last so it doesn't override tiger/trench/etc. via "on Ape" suffix ──
|
|
139
|
+
ape:'🦍',
|
|
140
|
+
default:'🖼️',
|
|
141
|
+
};
|
|
142
|
+
function emojiFor(name) {
|
|
143
|
+
const n = name.toLowerCase();
|
|
144
|
+
for (const [k,v] of Object.entries(EMOJI_MAP)) { if (n.includes(k)) return v; }
|
|
145
|
+
return EMOJI_MAP.default;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// ── Agent name registry
|
|
149
|
+
const AGENT_DISPLAY_NAMES = { 'local-cli': 'The Clawllector' };
|
|
150
|
+
function agentDisplayName(agentId) { return AGENT_DISPLAY_NAMES[agentId] || agentId; }
|
|
151
|
+
|
|
152
|
+
// ── Dynamic agent tracking from real events
|
|
153
|
+
const agentMap = new Map();
|
|
154
|
+
function ensureAgent(agentId) {
|
|
155
|
+
if (!agentMap.has(agentId)) {
|
|
156
|
+
// Check if this is a registered clawbot
|
|
157
|
+
const reg = REGISTERED_CLAWBOTS.find(b => b.agentId === agentId);
|
|
158
|
+
agentMap.set(agentId, {
|
|
159
|
+
name: reg?.name || agentDisplayName(agentId),
|
|
160
|
+
id: agentId,
|
|
161
|
+
status: 'active',
|
|
162
|
+
nfts: 0, bridged: 0, spent: 0, events: 0,
|
|
163
|
+
lastSeen: Date.now(),
|
|
164
|
+
verified: Boolean(reg),
|
|
165
|
+
registered: Boolean(reg),
|
|
166
|
+
});
|
|
167
|
+
if (reg) AGENT_DISPLAY_NAMES[agentId] = reg.name;
|
|
168
|
+
}
|
|
169
|
+
const a = agentMap.get(agentId);
|
|
170
|
+
a.lastSeen = Date.now();
|
|
171
|
+
a.events++;
|
|
172
|
+
a.status = 'active';
|
|
173
|
+
return a;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
let totalNfts = 0, totalBridged = 0, totalSpent = 0, totalEvents = 0;
|
|
177
|
+
let collectedNfts = [], bridgeOps = [], feedItems = [], terminalLines = [];
|
|
178
|
+
|
|
179
|
+
// ═══════════════════════════════════════════════════════════
|
|
180
|
+
// HELPERS
|
|
181
|
+
// ═══════════════════════════════════════════════════════════
|
|
182
|
+
function nowTime() {
|
|
183
|
+
const d = new Date();
|
|
184
|
+
return [d.getHours(),d.getMinutes(),d.getSeconds()].map(v=>String(v).padStart(2,'0')).join(':');
|
|
185
|
+
}
|
|
186
|
+
function evtTime(evt) {
|
|
187
|
+
if (!evt.ts) return nowTime();
|
|
188
|
+
const d = new Date(evt.ts);
|
|
189
|
+
return [d.getHours(),d.getMinutes(),d.getSeconds()].map(v=>String(v).padStart(2,'0')).join(':');
|
|
190
|
+
}
|
|
191
|
+
function shortAddr(addr) { return addr ? addr.slice(0,6)+'..'+addr.slice(-4) : ''; }
|
|
192
|
+
function txLink(hash) {
|
|
193
|
+
if (!hash) return '';
|
|
194
|
+
return `<a class="tx-link" href="${APESCAN_TX}${encodeURIComponent(hash)}" target="_blank" rel="noopener" title="View on ApeScan">${shortAddr(hash)}</a>`;
|
|
195
|
+
}
|
|
196
|
+
function feedItem(time, icon, content) {
|
|
197
|
+
return `<div class="feed-item"><div class="feed-time">${time}</div><div class="feed-icon">${icon}</div><div class="feed-content"><div class="feed-action">${content}</div></div></div>`;
|
|
198
|
+
}
|
|
199
|
+
function escapeHtml(v) {
|
|
200
|
+
return String(v||'').replace(/&/g,'&').replace(/"/g,'"').replace(/</g,'<').replace(/>/g,'>');
|
|
201
|
+
}
|
|
202
|
+
function escapeAttr(v) {
|
|
203
|
+
return String(v||'').replace(/&/g,'&').replace(/"/g,'"').replace(/</g,'<');
|
|
204
|
+
}
|
|
205
|
+
function eventDedupeKey(evt) {
|
|
206
|
+
if (!evt || typeof evt !== 'object') return '';
|
|
207
|
+
// traceId is generated per event and is stable across backlog/SSE.
|
|
208
|
+
if (evt.traceId) return `trace:${evt.traceId}`;
|
|
209
|
+
// txHash is stable for confirmed on-chain events.
|
|
210
|
+
const tx = evt.result?.txHash || evt.result?.hash || evt.result?.transactionHash || null;
|
|
211
|
+
if (tx) return `tx:${String(tx)}|${String(evt.eventType||'')}|${String(evt.agentId||'')}`;
|
|
212
|
+
// Fallback: stable-ish composite (avoid heavy stringify unless needed).
|
|
213
|
+
return `ts:${String(evt.ts||'')}|${String(evt.eventType||'')}|${String(evt.agentId||'')}`;
|
|
214
|
+
}
|
|
215
|
+
function pushFeedEvent(evt, { trim = true } = {}) {
|
|
216
|
+
const k = eventDedupeKey(evt);
|
|
217
|
+
if (k && seenFeedKeys.has(k)) return false;
|
|
218
|
+
|
|
219
|
+
const html = processEvent(evt);
|
|
220
|
+
feedItems.push({ html, et: String(evt?.eventType || '') });
|
|
221
|
+
feedRawEvents.push(evt);
|
|
222
|
+
feedKeys.push(k || '');
|
|
223
|
+
if (k) seenFeedKeys.add(k);
|
|
224
|
+
|
|
225
|
+
if (trim) {
|
|
226
|
+
while (feedItems.length > 500) feedItems.shift();
|
|
227
|
+
while (feedRawEvents.length > 1000) {
|
|
228
|
+
feedRawEvents.shift();
|
|
229
|
+
const oldKey = feedKeys.shift();
|
|
230
|
+
if (oldKey) seenFeedKeys.delete(oldKey);
|
|
231
|
+
}
|
|
232
|
+
while (feedKeys.length > feedRawEvents.length) feedKeys.shift();
|
|
233
|
+
}
|
|
234
|
+
return true;
|
|
235
|
+
}
|
|
236
|
+
function collectionVisual(collection, size='chip') {
|
|
237
|
+
const img = collection?.imageUrl ? escapeAttr(collection.imageUrl) : '';
|
|
238
|
+
if (img) {
|
|
239
|
+
if (size==='nft') return `<img src="${img}" alt="${escapeAttr(collection?.name||'')}" loading="lazy" />`;
|
|
240
|
+
return `<img class="col-chip-icon" src="${img}" alt="${escapeAttr(collection?.name||'')}" loading="lazy" />`;
|
|
241
|
+
}
|
|
242
|
+
return `<span class="${size==='nft'?'':'col-chip-emoji'}">${collection?.emoji||'🖼️'}</span>`;
|
|
243
|
+
}
|
|
244
|
+
function timeSince(ts) {
|
|
245
|
+
const sec = Math.floor((Date.now()-ts)/1000);
|
|
246
|
+
if (sec<60) return 'just now';
|
|
247
|
+
if (sec<3600) return Math.floor(sec/60)+'m ago';
|
|
248
|
+
if (sec<86400) return Math.floor(sec/3600)+'h ago';
|
|
249
|
+
return Math.floor(sec/86400)+'d ago';
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// ── Setup toggle
|
|
253
|
+
function toggleSetup() {
|
|
254
|
+
const header = document.getElementById('setupToggle');
|
|
255
|
+
const body = document.getElementById('setupBody');
|
|
256
|
+
header.classList.toggle('open');
|
|
257
|
+
body.classList.toggle('open');
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function renderBackendStatus() {
|
|
261
|
+
const status = document.getElementById('backendUrlStatus');
|
|
262
|
+
if (!status) return;
|
|
263
|
+
status.textContent = `Using backend: ${describeBackend()}`;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
function initBackendConfigUi() {
|
|
267
|
+
renderBackendStatus();
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
async function ensureBackendReachableWithFallback() {
|
|
271
|
+
renderBackendStatus();
|
|
272
|
+
async function probe(url) {
|
|
273
|
+
const timeoutMs = 3000;
|
|
274
|
+
const controller = new AbortController();
|
|
275
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
276
|
+
try {
|
|
277
|
+
const r = await fetch(url, { signal: controller.signal, cache: 'no-store' });
|
|
278
|
+
return r.ok;
|
|
279
|
+
} catch {
|
|
280
|
+
return false;
|
|
281
|
+
} finally {
|
|
282
|
+
clearTimeout(timer);
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
const sameOriginOk = await probe('/api/health');
|
|
287
|
+
if (sameOriginOk) {
|
|
288
|
+
API_BASE = '';
|
|
289
|
+
renderBackendStatus();
|
|
290
|
+
return;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
const sharedOk = await probe(DEFAULT_SHARED_BACKEND + '/api/health');
|
|
294
|
+
if (sharedOk) {
|
|
295
|
+
API_BASE = DEFAULT_SHARED_BACKEND;
|
|
296
|
+
renderBackendStatus();
|
|
297
|
+
terminalLines.push({ type: 'output', text: `[Config] Using shared backend: ${DEFAULT_SHARED_BACKEND}` });
|
|
298
|
+
return;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
API_BASE = '';
|
|
302
|
+
renderBackendStatus();
|
|
303
|
+
terminalLines.push({ type: 'error', text: '[Config] No live backend detected. Using static API data.' });
|
|
304
|
+
announce('No reachable backend detected');
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
function initSetupEnhancements() {
|
|
308
|
+
// Setup mode toggles (Quick Start vs Pod + v2)
|
|
309
|
+
function applySetupMode(mode) {
|
|
310
|
+
const m = (mode === 'pod') ? 'pod' : 'quick';
|
|
311
|
+
try { localStorage.setItem('apeclaw_setup_mode', m); } catch {}
|
|
312
|
+
const quickBtn = document.getElementById('setupModeQuickBtn');
|
|
313
|
+
const podBtn = document.getElementById('setupModePodBtn');
|
|
314
|
+
if (quickBtn) {
|
|
315
|
+
quickBtn.classList.toggle('active', m === 'quick');
|
|
316
|
+
quickBtn.setAttribute('aria-selected', m === 'quick' ? 'true' : 'false');
|
|
317
|
+
}
|
|
318
|
+
if (podBtn) {
|
|
319
|
+
podBtn.classList.toggle('active', m === 'pod');
|
|
320
|
+
podBtn.setAttribute('aria-selected', m === 'pod' ? 'true' : 'false');
|
|
321
|
+
}
|
|
322
|
+
document.querySelectorAll('.setup-step').forEach((step) => {
|
|
323
|
+
const sm = step.getAttribute('data-setup-mode') || 'quick';
|
|
324
|
+
step.style.display = (sm === m) ? '' : 'none';
|
|
325
|
+
});
|
|
326
|
+
}
|
|
327
|
+
const quickBtn = document.getElementById('setupModeQuickBtn');
|
|
328
|
+
const podBtn = document.getElementById('setupModePodBtn');
|
|
329
|
+
if (quickBtn && podBtn) {
|
|
330
|
+
quickBtn.addEventListener('click', () => applySetupMode('quick'));
|
|
331
|
+
podBtn.addEventListener('click', () => applySetupMode('pod'));
|
|
332
|
+
let initial = 'quick';
|
|
333
|
+
try { initial = localStorage.getItem('apeclaw_setup_mode') || 'quick'; } catch {}
|
|
334
|
+
applySetupMode(initial);
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
document.querySelectorAll('.setup-step').forEach((step, idx) => {
|
|
338
|
+
const title = step.querySelector('h3');
|
|
339
|
+
if (title && !step.querySelector('.setup-check')) {
|
|
340
|
+
const cb = document.createElement('input');
|
|
341
|
+
cb.type = 'checkbox';
|
|
342
|
+
cb.className = 'setup-check';
|
|
343
|
+
cb.style.marginRight = '6px';
|
|
344
|
+
const key = `apeclaw_setup_step_${idx}`;
|
|
345
|
+
try { cb.checked = localStorage.getItem(key) === '1'; } catch {}
|
|
346
|
+
cb.addEventListener('change', () => {
|
|
347
|
+
try { localStorage.setItem(key, cb.checked ? '1' : '0'); } catch {}
|
|
348
|
+
announce(`Setup step ${idx + 1} ${cb.checked ? 'completed' : 'unchecked'}`);
|
|
349
|
+
});
|
|
350
|
+
title.prepend(cb);
|
|
351
|
+
}
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
document.querySelectorAll('.setup-step pre').forEach((pre) => {
|
|
355
|
+
if (pre.nextElementSibling && pre.nextElementSibling.classList?.contains('copy-code-btn')) return;
|
|
356
|
+
const btn = document.createElement('button');
|
|
357
|
+
btn.type = 'button';
|
|
358
|
+
btn.className = 'copy-code-btn';
|
|
359
|
+
btn.textContent = 'Copy command';
|
|
360
|
+
btn.addEventListener('click', async () => {
|
|
361
|
+
const txt = pre.textContent || '';
|
|
362
|
+
try {
|
|
363
|
+
await navigator.clipboard.writeText(txt);
|
|
364
|
+
btn.textContent = 'Copied';
|
|
365
|
+
announce('Command copied');
|
|
366
|
+
setTimeout(() => { btn.textContent = 'Copy command'; }, 1200);
|
|
367
|
+
} catch {
|
|
368
|
+
btn.textContent = 'Copy failed';
|
|
369
|
+
setTimeout(() => { btn.textContent = 'Copy command'; }, 1200);
|
|
370
|
+
}
|
|
371
|
+
});
|
|
372
|
+
pre.insertAdjacentElement('afterend', btn);
|
|
373
|
+
});
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
function initCollectionControls() {
|
|
377
|
+
const search = document.getElementById('collectionsSearch');
|
|
378
|
+
const sort = document.getElementById('collectionsSort');
|
|
379
|
+
const clearBtn = document.getElementById('collectionsClearBtn');
|
|
380
|
+
if (!search || !sort) return;
|
|
381
|
+
function applyQueryFromUi() {
|
|
382
|
+
collectionQuery = (search.value || '').toLowerCase().trim();
|
|
383
|
+
if (clearBtn) clearBtn.disabled = !collectionQuery;
|
|
384
|
+
renderCollectionsBar();
|
|
385
|
+
}
|
|
386
|
+
search.addEventListener('input', applyQueryFromUi);
|
|
387
|
+
search.addEventListener('keydown', (e) => {
|
|
388
|
+
if (e.key === 'Escape') {
|
|
389
|
+
search.value = '';
|
|
390
|
+
applyQueryFromUi();
|
|
391
|
+
search.blur();
|
|
392
|
+
}
|
|
393
|
+
});
|
|
394
|
+
if (clearBtn) clearBtn.addEventListener('click', () => {
|
|
395
|
+
search.value = '';
|
|
396
|
+
applyQueryFromUi();
|
|
397
|
+
search.focus();
|
|
398
|
+
});
|
|
399
|
+
sort.addEventListener('change', () => { collectionSort = sort.value; renderCollectionsBar(); });
|
|
400
|
+
applyQueryFromUi();
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
function initPanelControls() {
|
|
404
|
+
const feedPauseBtn = document.getElementById('feedPauseBtn');
|
|
405
|
+
const feedClearBtn = document.getElementById('feedClearBtn');
|
|
406
|
+
const feedExportBtn = document.getElementById('feedExportBtn');
|
|
407
|
+
const terminalClearBtn = document.getElementById('terminalClearBtn');
|
|
408
|
+
const terminalExportBtn = document.getElementById('terminalExportBtn');
|
|
409
|
+
const terminalAutoBtn = document.getElementById('terminalAutoBtn');
|
|
410
|
+
const chatReconnectBtn = document.getElementById('chatReconnectBtn');
|
|
411
|
+
const chatExportBtn = document.getElementById('chatExportBtn');
|
|
412
|
+
const feedFilterSel = document.getElementById('feedFilterSel');
|
|
413
|
+
const agentFilterInput = document.getElementById('agentFilterInput');
|
|
414
|
+
const agentStatusSel = document.getElementById('agentStatusSel');
|
|
415
|
+
|
|
416
|
+
if (feedFilterSel) {
|
|
417
|
+
// Allow URL override: ?feed=v2 or ?filter=receipts (useful for sharing).
|
|
418
|
+
let initial = '';
|
|
419
|
+
try {
|
|
420
|
+
const u = new URL(window.location.href);
|
|
421
|
+
initial = (u.searchParams.get('feed') || u.searchParams.get('filter') || '').trim().toLowerCase();
|
|
422
|
+
} catch {}
|
|
423
|
+
if (!initial) {
|
|
424
|
+
try { initial = localStorage.getItem('apeclaw_feed_filter') || 'all'; } catch {}
|
|
425
|
+
}
|
|
426
|
+
feedFilterSel.value = initial || 'all';
|
|
427
|
+
feedFilterSel.addEventListener('change', () => {
|
|
428
|
+
try { localStorage.setItem('apeclaw_feed_filter', feedFilterSel.value); } catch {}
|
|
429
|
+
renderFeed();
|
|
430
|
+
});
|
|
431
|
+
}
|
|
432
|
+
if (agentFilterInput) agentFilterInput.addEventListener('input', () => renderAgents());
|
|
433
|
+
if (agentStatusSel) agentStatusSel.addEventListener('change', () => renderAgents());
|
|
434
|
+
|
|
435
|
+
if (feedPauseBtn) feedPauseBtn.addEventListener('click', () => {
|
|
436
|
+
feedPaused = !feedPaused;
|
|
437
|
+
feedPauseBtn.textContent = feedPaused ? 'Resume' : 'Pause';
|
|
438
|
+
if (!feedPaused) renderFeed();
|
|
439
|
+
});
|
|
440
|
+
if (feedClearBtn) feedClearBtn.addEventListener('click', () => {
|
|
441
|
+
feedItems = [];
|
|
442
|
+
feedRawEvents = [];
|
|
443
|
+
feedKeys = [];
|
|
444
|
+
seenFeedKeys.clear();
|
|
445
|
+
renderFeed();
|
|
446
|
+
});
|
|
447
|
+
if (feedExportBtn) feedExportBtn.addEventListener('click', () => {
|
|
448
|
+
downloadBlob(`apeclaw-feed-${Date.now()}.json`, JSON.stringify(feedRawEvents.slice(-1000), null, 2), 'application/json');
|
|
449
|
+
});
|
|
450
|
+
if (terminalClearBtn) terminalClearBtn.addEventListener('click', () => { terminalLines = []; renderTerminal(); });
|
|
451
|
+
if (terminalExportBtn) terminalExportBtn.addEventListener('click', () => {
|
|
452
|
+
const text = terminalLines.map((l) => l.text).join('\n');
|
|
453
|
+
downloadBlob(`apeclaw-terminal-${Date.now()}.log`, text);
|
|
454
|
+
});
|
|
455
|
+
if (terminalAutoBtn) terminalAutoBtn.addEventListener('click', () => {
|
|
456
|
+
terminalAutoScroll = !terminalAutoScroll;
|
|
457
|
+
terminalAutoBtn.textContent = `Auto-scroll: ${terminalAutoScroll ? 'On' : 'Off'}`;
|
|
458
|
+
});
|
|
459
|
+
if (chatReconnectBtn) chatReconnectBtn.addEventListener('click', () => connectChatStream());
|
|
460
|
+
if (chatExportBtn) chatExportBtn.addEventListener('click', () => {
|
|
461
|
+
downloadBlob(`apeclaw-chat-${Date.now()}.json`, JSON.stringify(chatMessages, null, 2), 'application/json');
|
|
462
|
+
});
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
function initShortcutsPopover() {
|
|
466
|
+
const btn = document.getElementById('shortcutsBtn');
|
|
467
|
+
const panel = document.getElementById('shortcutsPanel');
|
|
468
|
+
if (!btn || !panel) return;
|
|
469
|
+
const close = () => panel.classList.remove('open');
|
|
470
|
+
btn.addEventListener('click', (e) => {
|
|
471
|
+
e.stopPropagation();
|
|
472
|
+
panel.classList.toggle('open');
|
|
473
|
+
});
|
|
474
|
+
panel.addEventListener('click', (e) => e.stopPropagation());
|
|
475
|
+
document.addEventListener('click', close);
|
|
476
|
+
document.addEventListener('keydown', (e) => {
|
|
477
|
+
if (e.key === 'Escape') close();
|
|
478
|
+
});
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
function initCommandDeck() {
|
|
482
|
+
const sel = document.getElementById('themePresetSel');
|
|
483
|
+
const resetBtn = document.getElementById('themeResetBtn');
|
|
484
|
+
const denseBtn = document.getElementById('toggleDenseBtn');
|
|
485
|
+
const focusBtn = document.getElementById('toggleFocusBtn');
|
|
486
|
+
const motionBtn = document.getElementById('toggleMotionBtn');
|
|
487
|
+
if (sel) {
|
|
488
|
+
sel.value = uiPrefs.theme;
|
|
489
|
+
sel.addEventListener('change', () => {
|
|
490
|
+
uiPrefs.theme = sel.value || 'abyss';
|
|
491
|
+
applyUiPrefs(); saveUiPrefs();
|
|
492
|
+
pushToast(`Theme: ${uiPrefs.theme}`, 'success');
|
|
493
|
+
});
|
|
494
|
+
}
|
|
495
|
+
if (resetBtn) resetBtn.addEventListener('click', () => {
|
|
496
|
+
uiPrefs.theme = 'abyss'; uiPrefs.dense = false; uiPrefs.focus = false; uiPrefs.motionLow = false;
|
|
497
|
+
if (sel) sel.value = 'abyss';
|
|
498
|
+
applyUiPrefs(); saveUiPrefs();
|
|
499
|
+
pushToast('Display reset', 'success');
|
|
500
|
+
});
|
|
501
|
+
if (denseBtn) denseBtn.addEventListener('click', () => {
|
|
502
|
+
uiPrefs.dense = !uiPrefs.dense; applyUiPrefs(); saveUiPrefs();
|
|
503
|
+
});
|
|
504
|
+
if (focusBtn) focusBtn.addEventListener('click', () => {
|
|
505
|
+
uiPrefs.focus = !uiPrefs.focus; applyUiPrefs(); saveUiPrefs();
|
|
506
|
+
});
|
|
507
|
+
if (motionBtn) motionBtn.addEventListener('click', () => {
|
|
508
|
+
uiPrefs.motionLow = !uiPrefs.motionLow; applyUiPrefs(); saveUiPrefs();
|
|
509
|
+
});
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
|
|
513
|
+
// ═══════════════════════════════════════════════════════════
|
|
514
|
+
// RENDER
|
|
515
|
+
// ═══════════════════════════════════════════════════════════
|
|
516
|
+
function renderCollectionsBar() {
|
|
517
|
+
const el = document.getElementById('collectionsBar');
|
|
518
|
+
const status = document.getElementById('collectionsStatus');
|
|
519
|
+
let visible = [...COLLECTIONS];
|
|
520
|
+
if (collectionQuery) {
|
|
521
|
+
visible = visible.filter((c) => `${c.name||''} ${c.slug||''} ${c.contractAddress||''}`.toLowerCase().includes(collectionQuery));
|
|
522
|
+
}
|
|
523
|
+
if (collectionSort === 'name') visible.sort((a, b) => String(a.name||'').localeCompare(String(b.name||'')));
|
|
524
|
+
else visible.sort((a, b) => Number(a.rank||999999) - Number(b.rank||999999));
|
|
525
|
+
|
|
526
|
+
if (visible.length === 0) {
|
|
527
|
+
const msg = COLLECTIONS.length === 0 ? 'Loading collections...' : 'No collections match current filters.';
|
|
528
|
+
el.innerHTML = `<div style="color:var(--dim);font-size:.72rem;padding:8px">${msg}</div>`;
|
|
529
|
+
if (status) status.textContent = COLLECTIONS.length === 0 ? '0 loaded' : 'No matches';
|
|
530
|
+
return;
|
|
531
|
+
}
|
|
532
|
+
el.innerHTML = visible.map(c =>
|
|
533
|
+
`<div class="col-chip" title="${c.contractAddress ? shortAddr(c.contractAddress) : 'no CA'}">
|
|
534
|
+
${collectionVisual(c,'chip')}
|
|
535
|
+
<div>
|
|
536
|
+
<div class="col-chip-name">${escapeHtml(c.name)}</div>
|
|
537
|
+
</div>
|
|
538
|
+
<div class="col-chip-vol">${c.contractAddress ? '✓ CA' : '⚠ No CA'}</div>
|
|
539
|
+
</div>`
|
|
540
|
+
).join('');
|
|
541
|
+
if (status) status.textContent = `${visible.length}/${COLLECTIONS.length} shown`;
|
|
542
|
+
updateCollectionsStatus();
|
|
543
|
+
startCollectionsCarousel();
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
function scrollCollectionsBy(direction = 1) {
|
|
547
|
+
const el = document.getElementById('collectionsBar');
|
|
548
|
+
if (!el) return;
|
|
549
|
+
const amount = Math.max(240, Math.floor(el.clientWidth * 0.8));
|
|
550
|
+
el.scrollBy({ left: direction * amount, behavior: 'smooth' });
|
|
551
|
+
setTimeout(updateCollectionsStatus, 250);
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
function updateCollectionsStatus() {
|
|
555
|
+
const el = document.getElementById('collectionsBar');
|
|
556
|
+
const status = document.getElementById('collectionsStatus');
|
|
557
|
+
if (!el || !status) return;
|
|
558
|
+
const total = el.querySelectorAll('.col-chip').length;
|
|
559
|
+
if (total === 0) {
|
|
560
|
+
status.textContent = COLLECTIONS.length === 0 ? '0 loaded' : 'No matches';
|
|
561
|
+
return;
|
|
562
|
+
}
|
|
563
|
+
if (el.scrollWidth <= el.clientWidth + 4) {
|
|
564
|
+
status.textContent = `${total}/${COLLECTIONS.length} shown`;
|
|
565
|
+
return;
|
|
566
|
+
}
|
|
567
|
+
const progress = Math.min(100, Math.max(0, Math.round((el.scrollLeft / (el.scrollWidth - el.clientWidth)) * 100)));
|
|
568
|
+
status.textContent = `${total}/${COLLECTIONS.length} shown • ${progress}% viewed`;
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
function startCollectionsCarousel() {
|
|
572
|
+
const el = document.getElementById('collectionsBar');
|
|
573
|
+
if (!el) return;
|
|
574
|
+
if (collectionsCarouselTimer) clearInterval(collectionsCarouselTimer);
|
|
575
|
+
if (el.scrollWidth <= el.clientWidth + 4) return;
|
|
576
|
+
collectionsCarouselTimer = setInterval(() => {
|
|
577
|
+
if (document.hidden) return;
|
|
578
|
+
const maxLeft = el.scrollWidth - el.clientWidth;
|
|
579
|
+
if (el.scrollLeft >= maxLeft - 4) {
|
|
580
|
+
el.scrollTo({ left: 0, behavior: 'smooth' });
|
|
581
|
+
} else {
|
|
582
|
+
scrollCollectionsBy(1);
|
|
583
|
+
}
|
|
584
|
+
}, 5000);
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
function renderAgents() {
|
|
588
|
+
const el = document.getElementById('agentsGrid');
|
|
589
|
+
const q = (document.getElementById('agentFilterInput')?.value || '').toLowerCase().trim();
|
|
590
|
+
const statusSel = (document.getElementById('agentStatusSel')?.value || 'all').toLowerCase().trim();
|
|
591
|
+
|
|
592
|
+
// Merge registered clawbots that haven't sent events yet
|
|
593
|
+
for (const reg of REGISTERED_CLAWBOTS) {
|
|
594
|
+
if (!agentMap.has(reg.agentId)) {
|
|
595
|
+
agentMap.set(reg.agentId, {
|
|
596
|
+
name: reg.name || reg.agentId,
|
|
597
|
+
id: reg.agentId,
|
|
598
|
+
status: 'offline',
|
|
599
|
+
nfts: 0, bridged: 0, spent: 0, events: 0,
|
|
600
|
+
lastSeen: reg.createdAt ? new Date(reg.createdAt).getTime() : 0,
|
|
601
|
+
verified: true,
|
|
602
|
+
registered: true,
|
|
603
|
+
});
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
if (agentMap.size === 0) {
|
|
608
|
+
el.innerHTML = `<div style="grid-column:1/-1;color:var(--dim);font-size:.78rem;text-align:center;padding:24px">
|
|
609
|
+
<div style="font-size:2.5rem;margin-bottom:8px">🦞</div>
|
|
610
|
+
<div>No Clawllectors registered yet</div>
|
|
611
|
+
<div style="font-size:.68rem;margin-top:6px">Set up your <a href="https://openclaw.ai" target="_blank" rel="noopener">OpenClaw</a> agent, then register a Clawllector:</div>
|
|
612
|
+
<div style="margin-top:8px"><code>npx --yes github:simplefarmer69/ape-claw clawbot register --agent-id my-clawllector --json</code></div>
|
|
613
|
+
</div>`;
|
|
614
|
+
return;
|
|
615
|
+
}
|
|
616
|
+
const now = Date.now();
|
|
617
|
+
for (const a of agentMap.values()) {
|
|
618
|
+
if (a.events === 0) a.status = 'offline';
|
|
619
|
+
else if (now - a.lastSeen > 300_000) a.status = 'offline';
|
|
620
|
+
else if (now - a.lastSeen > 60_000) a.status = 'idle';
|
|
621
|
+
else a.status = 'active';
|
|
622
|
+
}
|
|
623
|
+
const sorted = [...agentMap.values()].sort((a,b) => {
|
|
624
|
+
const statusOrder = {active:0,idle:1,offline:2};
|
|
625
|
+
const diff = (statusOrder[a.status]||9) - (statusOrder[b.status]||9);
|
|
626
|
+
if (diff !== 0) return diff;
|
|
627
|
+
return b.lastSeen - a.lastSeen;
|
|
628
|
+
});
|
|
629
|
+
const filtered = sorted.filter((a) => {
|
|
630
|
+
if (statusSel !== 'all' && a.status !== statusSel) return false;
|
|
631
|
+
if (!q) return true;
|
|
632
|
+
return String(a.name || '').toLowerCase().includes(q) || String(a.id || '').toLowerCase().includes(q);
|
|
633
|
+
});
|
|
634
|
+
el.innerHTML = filtered.map(a => {
|
|
635
|
+
const badge = a.verified ? '<span class="verified-badge">Clawllector</span>' : '';
|
|
636
|
+
const cardClass = a.verified ? 'agent-card verified-card' : 'agent-card';
|
|
637
|
+
return `<div class="${cardClass}">
|
|
638
|
+
<div class="agent-name">🦞 ${escapeHtml(a.name)} ${badge}</div>
|
|
639
|
+
<div class="agent-id">${escapeHtml(a.id)}</div>
|
|
640
|
+
<div class="agent-status"><span class="dot ${a.status}"></span> ${a.status} ${a.lastSeen > 0 ? '• '+timeSince(a.lastSeen) : ''}</div>
|
|
641
|
+
<div class="agent-stat-row"><span>Events</span><span>${a.events.toLocaleString()}</span></div>
|
|
642
|
+
<div class="agent-stat-row"><span>NFTs</span><span>${a.nfts}</span></div>
|
|
643
|
+
<div class="agent-stat-row"><span>Bridged</span><span>${a.bridged.toLocaleString()} APE</span></div>
|
|
644
|
+
<div class="agent-stat-row"><span>Spent</span><span>${a.spent.toLocaleString()} APE</span></div>
|
|
645
|
+
</div>`;
|
|
646
|
+
}).join('');
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
function renderNftGrid() {
|
|
650
|
+
const el = document.getElementById('nftGrid');
|
|
651
|
+
if (collectedNfts.length === 0) {
|
|
652
|
+
el.innerHTML = `<div style="grid-column:1/-1;color:var(--dim);font-size:.78rem;text-align:center;padding:24px">
|
|
653
|
+
<div style="font-size:2rem;margin-bottom:6px">🖼️</div>
|
|
654
|
+
No NFTs collected yet.<br>Purchases from <code>npx --yes github:simplefarmer69/ape-claw nft buy --execute</code> appear here in real time.
|
|
655
|
+
</div>`;
|
|
656
|
+
return;
|
|
657
|
+
}
|
|
658
|
+
el.innerHTML = collectedNfts.map(n => {
|
|
659
|
+
const txHtml = n.txHash
|
|
660
|
+
? `<div class="nft-tx"><a href="${APESCAN_TX}${encodeURIComponent(n.txHash)}" target="_blank" rel="noopener">${shortAddr(n.txHash)}</a></div>`
|
|
661
|
+
: '';
|
|
662
|
+
return `<div class="nft-card">
|
|
663
|
+
<div class="nft-img"><div class="nft-bg"></div>${collectionVisual(n.collection,'nft')}</div>
|
|
664
|
+
<div class="nft-info">
|
|
665
|
+
<div class="nft-collection">${escapeHtml(n.collection.name)}</div>
|
|
666
|
+
<div class="nft-name">#${escapeHtml(n.tokenId)}</div>
|
|
667
|
+
<div class="nft-price">${escapeHtml(n.price)} APE</div>
|
|
668
|
+
<div class="nft-agent">by ${escapeHtml(n.agent)} • ${n.time}</div>
|
|
669
|
+
${txHtml}
|
|
670
|
+
</div>
|
|
671
|
+
</div>`;
|
|
672
|
+
}).join('');
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
function renderBridge() {
|
|
676
|
+
const el = document.getElementById('bridgePanel');
|
|
677
|
+
if (bridgeOps.length === 0) {
|
|
678
|
+
el.innerHTML = `<div style="color:var(--dim);font-size:.75rem;padding:12px">
|
|
679
|
+
<div style="font-size:1.5rem;margin-bottom:4px">🌉</div>
|
|
680
|
+
No bridge operations yet.<br>Bridge executions from <code>npx --yes github:simplefarmer69/ape-claw bridge execute</code> appear here.
|
|
681
|
+
</div>`;
|
|
682
|
+
return;
|
|
683
|
+
}
|
|
684
|
+
el.innerHTML = bridgeOps.map(b => {
|
|
685
|
+
const txHtml = b.txHash ? ` <a class="tx-link" href="${APESCAN_TX}${encodeURIComponent(b.txHash)}" target="_blank" rel="noopener">${shortAddr(b.txHash)}</a>` : '';
|
|
686
|
+
return `<div class="bridge-item">
|
|
687
|
+
<div class="bridge-route">${escapeHtml(b.from)} <span class="bridge-arrow">→</span> ${escapeHtml(b.to)}</div>
|
|
688
|
+
<div style="font-size:.65rem;color:var(--dim)">${b.fee ? `fee ${b.fee} APE • ` : ''}${escapeHtml(b.agent)}${txHtml}</div>
|
|
689
|
+
<div class="bridge-amount">${escapeHtml(b.amount)}</div>
|
|
690
|
+
<span class="bridge-status-tag ${b.status}">${b.status}</span>
|
|
691
|
+
</div>`;
|
|
692
|
+
}).join('');
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
function renderFeed() {
|
|
696
|
+
const el = document.getElementById('activityFeed');
|
|
697
|
+
const filter = (document.getElementById('feedFilterSel')?.value || 'all').toLowerCase().trim();
|
|
698
|
+
function categoryForEventType(et) {
|
|
699
|
+
const t = String(et || '');
|
|
700
|
+
if (!t) return 'other';
|
|
701
|
+
if (t.startsWith('v2.receipt.') || t.includes('receipt')) return 'receipts';
|
|
702
|
+
if (t.startsWith('nft.') || t.includes('nft')) return 'nft';
|
|
703
|
+
if (t.startsWith('bridge.') || t.includes('bridge')) return 'bridge';
|
|
704
|
+
if (t.startsWith('chat.') || t.includes('chat')) return 'chat';
|
|
705
|
+
if (t.startsWith('policy.')) return 'policy';
|
|
706
|
+
if (t.startsWith('v2.')) return 'v2';
|
|
707
|
+
return 'other';
|
|
708
|
+
}
|
|
709
|
+
const pausedBanner = feedPaused
|
|
710
|
+
? `<div style="font-size:.66rem;color:var(--gold);margin-bottom:8px">Feed paused — live events continue in background.</div>`
|
|
711
|
+
: '';
|
|
712
|
+
if (feedItems.length === 0) {
|
|
713
|
+
el.innerHTML = `${pausedBanner}<div style="color:var(--dim);font-size:.78rem;text-align:center;padding:24px">
|
|
714
|
+
<div style="font-size:2rem;margin-bottom:6px">📡</div>
|
|
715
|
+
Listening for events…<br>Run any <code>ape-claw</code> command to see live activity.
|
|
716
|
+
</div>`;
|
|
717
|
+
return;
|
|
718
|
+
}
|
|
719
|
+
const visible = (filter === 'all')
|
|
720
|
+
? feedItems
|
|
721
|
+
: feedItems.filter((f) => {
|
|
722
|
+
const c = categoryForEventType(f.et);
|
|
723
|
+
if (filter === 'v2') return c === 'v2' || c === 'receipts';
|
|
724
|
+
return c === filter;
|
|
725
|
+
});
|
|
726
|
+
const note = (filter !== 'all')
|
|
727
|
+
? `<div style="font-size:.66rem;color:var(--dim);margin-bottom:8px">Filter: <code>${escapeHtml(filter)}</code> • showing ${visible.length}/${feedItems.length}</div>`
|
|
728
|
+
: '';
|
|
729
|
+
el.innerHTML = pausedBanner + note + visible.slice(-100).reverse().map(f => f.html).join('');
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
function renderTerminal() {
|
|
733
|
+
const el = document.getElementById('terminalBody');
|
|
734
|
+
if (terminalLines.length === 0) {
|
|
735
|
+
el.innerHTML = '<div class="t-output">Waiting for CLI events...</div>';
|
|
736
|
+
return;
|
|
737
|
+
}
|
|
738
|
+
el.innerHTML = terminalLines.map(l => {
|
|
739
|
+
if (l.type==='prompt') return `<div><span class="t-prompt">❯</span> <span class="t-cmd">${l.text}</span></div>`;
|
|
740
|
+
if (l.type==='accent') return `<div class="t-accent">${l.text}</div>`;
|
|
741
|
+
if (l.type==='success') return `<div class="t-success">${l.text}</div>`;
|
|
742
|
+
if (l.type==='error') return `<div class="t-error">${l.text}</div>`;
|
|
743
|
+
return `<div class="t-output">${l.text}</div>`;
|
|
744
|
+
}).join('');
|
|
745
|
+
if (terminalAutoScroll) el.scrollTop = el.scrollHeight;
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
function updateStats() {
|
|
749
|
+
const activeCount = [...agentMap.values()].filter(a => a.status==='active').length;
|
|
750
|
+
const totalCount = agentMap.size;
|
|
751
|
+
const clawbotCount = Math.max(totalCount, REGISTERED_CLAWBOTS.length, 10);
|
|
752
|
+
const setIf = (id, val) => { const el = document.getElementById(id); if (el) el.textContent = val; };
|
|
753
|
+
setIf('totalAgents', activeCount + '/' + totalCount);
|
|
754
|
+
setIf('totalEvents', totalEvents.toLocaleString());
|
|
755
|
+
setIf('totalNfts', totalNfts);
|
|
756
|
+
setIf('totalBridged', Math.round(totalBridged).toLocaleString());
|
|
757
|
+
setIf('totalSpent', Math.round(totalSpent).toLocaleString());
|
|
758
|
+
setIf('agentCountBadge', totalCount);
|
|
759
|
+
setIf('eventCountBadge', totalEvents.toLocaleString());
|
|
760
|
+
setIf('nftCountBadge', totalNfts);
|
|
761
|
+
setIf('bridgeCountBadge', bridgeOps.length);
|
|
762
|
+
setIf('psAgentCount', clawbotCount.toLocaleString());
|
|
763
|
+
setIf('psEventCount', totalEvents.toLocaleString());
|
|
764
|
+
setIf('psNftCount', totalNfts);
|
|
765
|
+
setIf('psBridgeCount', bridgeOps.length);
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
function setConnectionStatus(connected) {
|
|
769
|
+
const dot = document.getElementById('connectionDot');
|
|
770
|
+
const label = document.getElementById('connectionLabel');
|
|
771
|
+
if (connected) {
|
|
772
|
+
dot.className = 'connection-dot connected';
|
|
773
|
+
label.textContent = 'Live';
|
|
774
|
+
} else {
|
|
775
|
+
dot.className = 'connection-dot disconnected';
|
|
776
|
+
label.textContent = 'Offline';
|
|
777
|
+
}
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
// ═══════════════════════════════════════════════════════════
|
|
781
|
+
// EVENT-TO-UI MAPPER
|
|
782
|
+
// ═══════════════════════════════════════════════════════════
|
|
783
|
+
function findCollection(input) {
|
|
784
|
+
const s = String(input||'').toLowerCase();
|
|
785
|
+
return COLLECTIONS.find(c =>
|
|
786
|
+
c.name.toLowerCase()===s || c.slug===s || (c.contractAddress && c.contractAddress.toLowerCase()===s)
|
|
787
|
+
) || { name:input||'unknown', emoji:'🖼️', slug:'', imageUrl:null };
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
function processEvent(evt) {
|
|
791
|
+
const t = evtTime(evt);
|
|
792
|
+
const et = evt.eventType || 'unknown';
|
|
793
|
+
const cmd = evt.command || 'ape-claw';
|
|
794
|
+
const rawId = evt.agentId || 'local-cli';
|
|
795
|
+
const ag = agentDisplayName(rawId);
|
|
796
|
+
const payload = evt.payload || {};
|
|
797
|
+
const result = evt.result || {};
|
|
798
|
+
|
|
799
|
+
const agent = ensureAgent(rawId);
|
|
800
|
+
totalEvents++;
|
|
801
|
+
|
|
802
|
+
// ── NFT buy confirmed
|
|
803
|
+
if (et === 'nft.buy.executed' || et === 'nft.buy.confirmed') {
|
|
804
|
+
const q = result.quote || {};
|
|
805
|
+
const col = findCollection(q.collection || payload.collection);
|
|
806
|
+
const tid = q.tokenId || payload.tokenId || '?';
|
|
807
|
+
const price = q.priceApe ?? payload.priceApe ?? payload.maxPrice ?? 0;
|
|
808
|
+
const tx = result.txHash || '';
|
|
809
|
+
collectedNfts.unshift({ collection:col, tokenId:String(tid), price:String(price), agent:ag, time:t, txHash:tx });
|
|
810
|
+
if (collectedNfts.length>50) collectedNfts.pop();
|
|
811
|
+
totalNfts++; totalSpent += Number(price)||0;
|
|
812
|
+
agent.nfts++; agent.spent += Number(price)||0;
|
|
813
|
+
renderNftGrid();
|
|
814
|
+
return feedItem(t,'🦞',`<strong>${escapeHtml(ag)}</strong> purchased <span class="collection-tag">${escapeHtml(col.name)}</span> #${escapeHtml(tid)} for <span class="price">${price} APE</span> ${txLink(tx)}`);
|
|
815
|
+
}
|
|
816
|
+
// ── NFT buy dry-run
|
|
817
|
+
if (et === 'nft.buy.dry_run') {
|
|
818
|
+
return feedItem(t,'🔒',`<strong>${escapeHtml(ag)}</strong> dry-run buy — no broadcast (pass --execute to send)`);
|
|
819
|
+
}
|
|
820
|
+
// ── NFT quote created
|
|
821
|
+
if (et === 'nft.quote.created') {
|
|
822
|
+
const col = findCollection(result.collection || payload.collection);
|
|
823
|
+
const tid = result.tokenId || payload.tokenId || '?';
|
|
824
|
+
const price = result.priceApe ?? payload.maxPrice ?? '?';
|
|
825
|
+
const qid = result.quoteId || '';
|
|
826
|
+
return feedItem(t,'💰',`<strong>${escapeHtml(ag)}</strong> quoted <span class="collection-tag">${escapeHtml(col.name)}</span> #${escapeHtml(tid)} at <span class="price">${price} APE</span> <span class="hash">${escapeHtml(qid)}</span>`);
|
|
827
|
+
}
|
|
828
|
+
// ── NFT simulation
|
|
829
|
+
if (et === 'nft.simulation.passed') {
|
|
830
|
+
return feedItem(t,'✅',`<strong>${escapeHtml(ag)}</strong> simulation passed <span class="hash">${escapeHtml(result.quoteId||'')}</span>`);
|
|
831
|
+
}
|
|
832
|
+
if (et === 'nft.simulation.failed') {
|
|
833
|
+
return feedItem(t,'❌',`<strong>${escapeHtml(ag)}</strong> simulation failed: ${escapeHtml(result.reason||'unknown')}`);
|
|
834
|
+
}
|
|
835
|
+
// ── Market
|
|
836
|
+
if (et === 'market.collections.read') {
|
|
837
|
+
return feedItem(t,'📋',`<strong>${escapeHtml(ag)}</strong> loaded ${result.count??COLLECTIONS.length} collections (${escapeHtml(result.source||'allowlist')})`);
|
|
838
|
+
}
|
|
839
|
+
if (et === 'market.listings.read') {
|
|
840
|
+
return feedItem(t,'🔍',`<strong>${escapeHtml(ag)}</strong> found ${result.count??0} listings for <code>${escapeHtml(cmd)}</code>`);
|
|
841
|
+
}
|
|
842
|
+
if (et === 'market.listings.failed') {
|
|
843
|
+
return feedItem(t,'⚠️',`<strong>${escapeHtml(ag)}</strong> listings failed: ${escapeHtml(evt.error||'error')}`);
|
|
844
|
+
}
|
|
845
|
+
// ── Bridge
|
|
846
|
+
if (et === 'bridge.quote.created') {
|
|
847
|
+
const from = result.from || payload.from || '?';
|
|
848
|
+
const amt = result.amount ?? payload.amount ?? '?';
|
|
849
|
+
return feedItem(t,'🌉',`<strong>${escapeHtml(ag)}</strong> bridge quote: <span class="price">${amt} APE</span> from ${escapeHtml(from)} → ApeChain`);
|
|
850
|
+
}
|
|
851
|
+
if (et === 'bridge.execute.confirmed') {
|
|
852
|
+
const amt = result.amount ?? 0;
|
|
853
|
+
const from = result.from ?? payload.from ?? 'unknown';
|
|
854
|
+
const fee = result.feeBps ? (Number(amt)*(result.feeBps/10000)).toFixed(2) : null;
|
|
855
|
+
const tx = result.txHash || result.sourceTxHash || '';
|
|
856
|
+
bridgeOps.unshift({ from, to:'ApeChain', amount:`${amt} APE`, status:'completed', agent:ag, fee, txHash:tx });
|
|
857
|
+
if (bridgeOps.length>20) bridgeOps.pop();
|
|
858
|
+
totalBridged += Number(amt)||0;
|
|
859
|
+
agent.bridged += Number(amt)||0;
|
|
860
|
+
renderBridge();
|
|
861
|
+
return feedItem(t,'✅',`<strong>${escapeHtml(ag)}</strong> bridged <span class="price">${amt} APE</span> from ${escapeHtml(from)} → ApeChain ${txLink(tx)}`);
|
|
862
|
+
}
|
|
863
|
+
if (et === 'bridge.execute.dry_run') {
|
|
864
|
+
return feedItem(t,'🔒',`<strong>${escapeHtml(ag)}</strong> bridge dry-run — no broadcast`);
|
|
865
|
+
}
|
|
866
|
+
if (et === 'bridge.status.read') {
|
|
867
|
+
return feedItem(t,'📊',`<strong>${escapeHtml(ag)}</strong> bridge status: <span class="collection-tag">${escapeHtml(result.status||'unknown')}</span>`);
|
|
868
|
+
}
|
|
869
|
+
// ── Chain info
|
|
870
|
+
if (et === 'chain.info.read') {
|
|
871
|
+
const block = result.latestBlock ? `block ${Number(result.latestBlock).toLocaleString()}` : 'block unknown';
|
|
872
|
+
// Treat missing rpcOk as unknown (neutral) rather than failure.
|
|
873
|
+
const rpcStatus = (typeof result.rpcOk === 'boolean')
|
|
874
|
+
? (result.rpcOk ? 'RPC ✓' : 'RPC ✗')
|
|
875
|
+
: 'RPC ?';
|
|
876
|
+
if (result.latestBlock) document.getElementById('chainBlock').textContent = `#${Number(result.latestBlock).toLocaleString()}`;
|
|
877
|
+
return feedItem(t,'⛓️',`<strong>${escapeHtml(ag)}</strong> chain info: ${block}, ${rpcStatus}`);
|
|
878
|
+
}
|
|
879
|
+
// ── Doctor
|
|
880
|
+
if (et === 'doctor.ran') {
|
|
881
|
+
if (result.agent?.verified) {
|
|
882
|
+
agent.verified = true;
|
|
883
|
+
agent.name = result.agent.name || agent.name;
|
|
884
|
+
AGENT_DISPLAY_NAMES[rawId] = agent.name;
|
|
885
|
+
}
|
|
886
|
+
const stats = result.allowlistStats || {};
|
|
887
|
+
const ok = result.ok !== false ? '✓' : '✗';
|
|
888
|
+
const verifiedNote = result.agent?.verified ? ' (verified)' : '';
|
|
889
|
+
const executeReady = result.execution?.executeReady;
|
|
890
|
+
const execState = executeReady ? 'execute ready' : 'read-only ready';
|
|
891
|
+
const pkHint = !executeReady && !result.execution?.privateKeyProvided
|
|
892
|
+
? ' · set APE_CLAW_PRIVATE_KEY or save one with <code>auth set --private-key 0x... --json</code>'
|
|
893
|
+
: '';
|
|
894
|
+
return feedItem(t,'🩺',`<strong>${escapeHtml(agentDisplayName(rawId))}</strong> doctor ${ok}${verifiedNote} — ${stats.total||'?'} collections, ${stats.unresolvedCount||0} unresolved · ${escapeHtml(execState)}${pkHint}`);
|
|
895
|
+
}
|
|
896
|
+
// ── Allowlist audit
|
|
897
|
+
if (et === 'allowlist.audit.ran') {
|
|
898
|
+
return feedItem(t,'📝',`<strong>${escapeHtml(ag)}</strong> allowlist audit: ${result.total||0} total, ${result.unresolvedCount||0} unresolved`);
|
|
899
|
+
}
|
|
900
|
+
// ── Policy blocked
|
|
901
|
+
if (et === 'policy.blocked') {
|
|
902
|
+
return feedItem(t,'⛔',`<strong>${escapeHtml(ag)}</strong> blocked: ${escapeHtml(evt.error||'validation failed')}`);
|
|
903
|
+
}
|
|
904
|
+
// ── Skill install
|
|
905
|
+
if (et === 'skill.install.ran') {
|
|
906
|
+
return feedItem(t,'📦',`<strong>${escapeHtml(ag)}</strong> installed skill at <code>${escapeHtml(result.skillPath||'?')}</code>`);
|
|
907
|
+
}
|
|
908
|
+
// ── v2 (onchain primitives)
|
|
909
|
+
if (et === 'v2.skill.minted') {
|
|
910
|
+
const sid = result.skillId || '?';
|
|
911
|
+
const tx = result.txHash || '';
|
|
912
|
+
return feedItem(t,'🧬',`<strong>${escapeHtml(ag)}</strong> minted SkillNFT #${escapeHtml(sid)} <span class="hash">${escapeHtml(shortAddr(tx))}</span>`);
|
|
913
|
+
}
|
|
914
|
+
if (et === 'v2.skill.version.published') {
|
|
915
|
+
const sid = result.skillId || '?';
|
|
916
|
+
const vh = result.versionHash ? shortAddr(String(result.versionHash)) : '';
|
|
917
|
+
const ch = result.contentHash ? shortAddr(String(result.contentHash)) : '';
|
|
918
|
+
const uri = result.uri || '';
|
|
919
|
+
return feedItem(t,'📚',`<strong>${escapeHtml(ag)}</strong> published v2 skill version for #${escapeHtml(sid)} <span class="hash">${escapeHtml(vh)}</span> <span class="hash">${escapeHtml(ch)}</span> ${uri ? `<span class="hash">${escapeHtml(uri)}</span>` : ''}`);
|
|
920
|
+
}
|
|
921
|
+
if (et === 'v2.intent.created') {
|
|
922
|
+
const ih = result.intentHash ? shortAddr(String(result.intentHash)) : '';
|
|
923
|
+
const exp = result.expiresAt ? ` expires ${escapeHtml(String(result.expiresAt))}` : '';
|
|
924
|
+
return feedItem(t,'🎯',`<strong>${escapeHtml(ag)}</strong> created v2 intent <span class="hash">${escapeHtml(ih)}</span>${exp}`);
|
|
925
|
+
}
|
|
926
|
+
if (et === 'v2.intent.cancelled') {
|
|
927
|
+
const id = result.intentId || '?';
|
|
928
|
+
return feedItem(t,'🧹',`<strong>${escapeHtml(ag)}</strong> cancelled v2 intent #${escapeHtml(String(id))}`);
|
|
929
|
+
}
|
|
930
|
+
if (et === 'v2.receipt.recorded') {
|
|
931
|
+
const subj = result.subject || payload.subject || 'agent:unknown';
|
|
932
|
+
const th = result.traceIdHash ? shortAddr(String(result.traceIdHash)) : '';
|
|
933
|
+
const ch = result.contentHash ? shortAddr(String(result.contentHash)) : '';
|
|
934
|
+
return feedItem(t,'🧾',`<strong>${escapeHtml(ag)}</strong> recorded receipt <span class="collection-tag">${escapeHtml(subj)}</span> <span class="hash">${escapeHtml(th)}</span> <span class="hash">${escapeHtml(ch)}</span>`);
|
|
935
|
+
}
|
|
936
|
+
// ── Clawbot registered
|
|
937
|
+
if (et === 'clawbot.registered') {
|
|
938
|
+
return feedItem(t,'🦞',`New Clawllector registered: <strong>${escapeHtml(result.name||result.agentId||ag)}</strong>`);
|
|
939
|
+
}
|
|
940
|
+
if (et === 'clawbot.list.read') {
|
|
941
|
+
return feedItem(t,'📋',`<strong>${escapeHtml(ag)}</strong> listed ${result.count??0} registered Clawllectors`);
|
|
942
|
+
}
|
|
943
|
+
// ── NFT buy retry
|
|
944
|
+
if (et === 'nft.buy.retry') {
|
|
945
|
+
return feedItem(t,'🔄',`<strong>${escapeHtml(ag)}</strong> listing sniped — retrying with fresh order (attempt ${payload.attempt||'?'})`);
|
|
946
|
+
}
|
|
947
|
+
// ── Fallback
|
|
948
|
+
return feedItem(t,'📡',`<strong>${escapeHtml(ag)}</strong> <code>${escapeHtml(et)}</code>`);
|
|
949
|
+
}
|
|
950
|
+
|
|
951
|
+
// ═══════════════════════════════════════════════════════════
|
|
952
|
+
// LIVE TELEMETRY — SSE + backlog
|
|
953
|
+
// ═══════════════════════════════════════════════════════════
|
|
954
|
+
let sseConnected = false;
|
|
955
|
+
|
|
956
|
+
async function connectLiveTelemetry() {
|
|
957
|
+
// 1) Fetch backlog
|
|
958
|
+
try {
|
|
959
|
+
const r = await fetch(apiUrl('/events/backlog'));
|
|
960
|
+
if (r.ok) {
|
|
961
|
+
const data = await r.json();
|
|
962
|
+
(data.events||[]).forEach(evt => {
|
|
963
|
+
pushFeedEvent(evt, { trim: false });
|
|
964
|
+
});
|
|
965
|
+
// Trim once after bulk push.
|
|
966
|
+
while (feedItems.length > 500) feedItems.shift();
|
|
967
|
+
while (feedRawEvents.length > 1000) {
|
|
968
|
+
feedRawEvents.shift();
|
|
969
|
+
const oldKey = feedKeys.shift();
|
|
970
|
+
if (oldKey) seenFeedKeys.delete(oldKey);
|
|
971
|
+
}
|
|
972
|
+
while (feedKeys.length > feedRawEvents.length) feedKeys.shift();
|
|
973
|
+
renderFeed(); renderAgents(); updateStats();
|
|
974
|
+
}
|
|
975
|
+
} catch {}
|
|
976
|
+
|
|
977
|
+
// 2) SSE stream
|
|
978
|
+
try {
|
|
979
|
+
const es = new EventSource(apiUrl('/events'));
|
|
980
|
+
es.onopen = () => {
|
|
981
|
+
sseConnected = true;
|
|
982
|
+
setConnectionStatus(true);
|
|
983
|
+
terminalLines.push({ type:'success', text:`[SSE] Connected to telemetry stream (${describeBackend()})` });
|
|
984
|
+
renderTerminal();
|
|
985
|
+
};
|
|
986
|
+
es.onmessage = (msg) => {
|
|
987
|
+
try {
|
|
988
|
+
const evt = JSON.parse(msg.data);
|
|
989
|
+
const added = pushFeedEvent(evt);
|
|
990
|
+
if (added) {
|
|
991
|
+
if (!feedPaused) renderFeed();
|
|
992
|
+
renderAgents(); updateStats();
|
|
993
|
+
|
|
994
|
+
// Mirror to terminal
|
|
995
|
+
const termType = evt.ok === false ? 'error' : (
|
|
996
|
+
evt.eventType.includes('confirmed') || evt.eventType.includes('passed') ? 'success' :
|
|
997
|
+
evt.eventType.includes('created') ? 'accent' : 'output'
|
|
998
|
+
);
|
|
999
|
+
const agName = agentDisplayName(evt.agentId || 'local-cli');
|
|
1000
|
+
terminalLines.push({ type:'prompt', text: evt.command || `ape-claw ${evt.eventType}` });
|
|
1001
|
+
terminalLines.push({ type: termType, text: `[${agName}] ${evt.eventType} ${evt.ok===false ? '✗ '+escapeHtml(evt.error||'') : '✓'}` });
|
|
1002
|
+
if (terminalLines.length > 80) terminalLines.shift();
|
|
1003
|
+
renderTerminal();
|
|
1004
|
+
}
|
|
1005
|
+
} catch {}
|
|
1006
|
+
};
|
|
1007
|
+
es.onerror = () => {
|
|
1008
|
+
if (sseConnected) {
|
|
1009
|
+
sseConnected = false;
|
|
1010
|
+
setConnectionStatus(false);
|
|
1011
|
+
terminalLines.push({ type:'error', text:'[SSE] Connection lost — reconnecting...' });
|
|
1012
|
+
renderTerminal();
|
|
1013
|
+
}
|
|
1014
|
+
};
|
|
1015
|
+
} catch {
|
|
1016
|
+
setConnectionStatus(false);
|
|
1017
|
+
}
|
|
1018
|
+
}
|
|
1019
|
+
|
|
1020
|
+
// ═══════════════════════════════════════════════════════════
|
|
1021
|
+
// STATUS HEARTBEAT
|
|
1022
|
+
// ═══════════════════════════════════════════════════════════
|
|
1023
|
+
setInterval(() => {
|
|
1024
|
+
if (agentMap.size > 0) {
|
|
1025
|
+
renderAgents();
|
|
1026
|
+
updateStats();
|
|
1027
|
+
}
|
|
1028
|
+
}, 10_000);
|
|
1029
|
+
|
|
1030
|
+
// ═══════════════════════════════════════════════════════════
|
|
1031
|
+
// CLAWLLECTOR CHAT
|
|
1032
|
+
// ═══════════════════════════════════════════════════════════
|
|
1033
|
+
let chatMessages = [];
|
|
1034
|
+
let chatSseSource = null;
|
|
1035
|
+
let chatCredentials = { room: 'general', agentId: '', agentToken: '', identityToken: '' };
|
|
1036
|
+
let chatReconnectTimer = null;
|
|
1037
|
+
let chatReconnectAttempts = 0;
|
|
1038
|
+
let chatRooms = [];
|
|
1039
|
+
const chatUnreadByRoom = new Map();
|
|
1040
|
+
let chatReplyToId = null;
|
|
1041
|
+
const CHAT_REACTION_EMOJIS = ['👍', '🔥', '😂', '🫡', '👀'];
|
|
1042
|
+
|
|
1043
|
+
function normalizeRoomName(input) {
|
|
1044
|
+
const raw = String(input || 'general').toLowerCase().trim().replace(/[^a-z0-9_-]/g, '-').replace(/-+/g, '-').replace(/^-|-$/g, '');
|
|
1045
|
+
return raw || 'general';
|
|
1046
|
+
}
|
|
1047
|
+
|
|
1048
|
+
function selectChatRoom(room) {
|
|
1049
|
+
const normalized = normalizeRoomName(room);
|
|
1050
|
+
document.getElementById('chatRoom').value = normalized;
|
|
1051
|
+
setChatReplyTarget(null);
|
|
1052
|
+
updateChatAuthStatus();
|
|
1053
|
+
chatUnreadByRoom.set(normalized, 0);
|
|
1054
|
+
renderChatRooms();
|
|
1055
|
+
loadChatHistory();
|
|
1056
|
+
connectChatStream();
|
|
1057
|
+
}
|
|
1058
|
+
|
|
1059
|
+
function renderChatRooms() {
|
|
1060
|
+
const el = document.getElementById('chatRooms');
|
|
1061
|
+
if (!el) return;
|
|
1062
|
+
const current = normalizeRoomName(chatCredentials.room || 'general');
|
|
1063
|
+
const combined = new Map();
|
|
1064
|
+
for (const r of chatRooms) combined.set(normalizeRoomName(r.room), r);
|
|
1065
|
+
combined.set(current, combined.get(current) || { room: current, count: 0, participants: 0 });
|
|
1066
|
+
const ordered = [...combined.values()].sort((a, b) => String(b.lastTs || '').localeCompare(String(a.lastTs || '')));
|
|
1067
|
+
el.innerHTML = ordered.map((r) => {
|
|
1068
|
+
const room = normalizeRoomName(r.room);
|
|
1069
|
+
const unread = Number(chatUnreadByRoom.get(room) || 0);
|
|
1070
|
+
return `<button type="button" class="chat-room-chip${room===current?' active':''}" data-room="${escapeAttr(room)}">
|
|
1071
|
+
<span>/${escapeHtml(room)}</span>
|
|
1072
|
+
${unread > 0 ? `<span class="room-unread">${unread}</span>` : ''}
|
|
1073
|
+
</button>`;
|
|
1074
|
+
}).join('');
|
|
1075
|
+
el.querySelectorAll('.chat-room-chip').forEach((btn) => {
|
|
1076
|
+
btn.addEventListener('click', () => selectChatRoom(btn.dataset.room || 'general'));
|
|
1077
|
+
});
|
|
1078
|
+
}
|
|
1079
|
+
|
|
1080
|
+
async function loadChatRooms() {
|
|
1081
|
+
try {
|
|
1082
|
+
const r = await fetch(apiUrl('/api/chat/rooms?limit=60'));
|
|
1083
|
+
if (r.ok) {
|
|
1084
|
+
const data = await r.json();
|
|
1085
|
+
chatRooms = Array.isArray(data.rooms) ? data.rooms : [];
|
|
1086
|
+
renderChatRooms();
|
|
1087
|
+
}
|
|
1088
|
+
} catch {}
|
|
1089
|
+
}
|
|
1090
|
+
|
|
1091
|
+
function chatMsgTime(ts) {
|
|
1092
|
+
const d = new Date(ts);
|
|
1093
|
+
return [d.getHours(), d.getMinutes()].map(v => String(v).padStart(2, '0')).join(':');
|
|
1094
|
+
}
|
|
1095
|
+
|
|
1096
|
+
function formatChatText(text) {
|
|
1097
|
+
const safe = escapeHtml(String(text || ''));
|
|
1098
|
+
return safe.replace(/(https?:\/\/[^\s<]+)/g, '<a href="$1" target="_blank" rel="noopener">$1</a>');
|
|
1099
|
+
}
|
|
1100
|
+
|
|
1101
|
+
function chatDraftKey(room) {
|
|
1102
|
+
return `apeclaw_chat_draft_${normalizeRoomName(room || 'general')}`;
|
|
1103
|
+
}
|
|
1104
|
+
|
|
1105
|
+
function applyChatSlash(text) {
|
|
1106
|
+
const t = String(text || '').trim();
|
|
1107
|
+
if (t.startsWith('/shrug ')) return `${t.slice(7)} ¯\\_(ツ)_/¯`;
|
|
1108
|
+
if (t.startsWith('/tableflip ')) return `(╯°□°)╯︵ ┻━┻ ${t.slice(11)}`;
|
|
1109
|
+
if (t.startsWith('/me ')) return `* ${chatCredentials.agentId || 'agent'} ${t.slice(4)}`;
|
|
1110
|
+
return t;
|
|
1111
|
+
}
|
|
1112
|
+
|
|
1113
|
+
function findChatMessageById(id) {
|
|
1114
|
+
return chatMessages.find((m) => String(m.id) === String(id)) || null;
|
|
1115
|
+
}
|
|
1116
|
+
|
|
1117
|
+
function setChatReplyTarget(messageId) {
|
|
1118
|
+
chatReplyToId = messageId ? String(messageId) : null;
|
|
1119
|
+
const bar = document.getElementById('chatReplyingBar');
|
|
1120
|
+
const target = document.getElementById('chatReplyingTarget');
|
|
1121
|
+
if (!bar || !target) return;
|
|
1122
|
+
if (!chatReplyToId) {
|
|
1123
|
+
bar.classList.remove('active');
|
|
1124
|
+
target.textContent = 'message';
|
|
1125
|
+
return;
|
|
1126
|
+
}
|
|
1127
|
+
const msg = findChatMessageById(chatReplyToId);
|
|
1128
|
+
const label = msg ? `${msg.agentName || msg.agentId}: ${String(msg.text || '').slice(0, 40)}` : `#${chatReplyToId.slice(-6)}`;
|
|
1129
|
+
target.textContent = label;
|
|
1130
|
+
bar.classList.add('active');
|
|
1131
|
+
}
|
|
1132
|
+
|
|
1133
|
+
function applyReactionEventToMessages(evt) {
|
|
1134
|
+
const msg = findChatMessageById(evt.messageId);
|
|
1135
|
+
if (!msg) return;
|
|
1136
|
+
if (!msg.reactions || typeof msg.reactions !== 'object') msg.reactions = {};
|
|
1137
|
+
if (!msg.reactionUsers || typeof msg.reactionUsers !== 'object') msg.reactionUsers = {};
|
|
1138
|
+
const emoji = String(evt.emoji || '').trim();
|
|
1139
|
+
const actor = String(evt.agentId || '').trim();
|
|
1140
|
+
if (!emoji || !actor) return;
|
|
1141
|
+
const users = new Set(msg.reactionUsers[emoji] || []);
|
|
1142
|
+
if (users.has(actor)) users.delete(actor);
|
|
1143
|
+
else users.add(actor);
|
|
1144
|
+
msg.reactionUsers[emoji] = [...users];
|
|
1145
|
+
msg.reactions[emoji] = msg.reactionUsers[emoji].length;
|
|
1146
|
+
}
|
|
1147
|
+
|
|
1148
|
+
async function sendChatReaction(messageId, emoji) {
|
|
1149
|
+
const hasClawbotCreds = Boolean(chatCredentials.agentId && chatCredentials.agentToken);
|
|
1150
|
+
const hasIdentityToken = Boolean(chatCredentials.identityToken);
|
|
1151
|
+
if (!messageId || !emoji || (!hasClawbotCreds && !hasIdentityToken)) return;
|
|
1152
|
+
try {
|
|
1153
|
+
const r = await fetch(apiUrl('/api/chat/react'), {
|
|
1154
|
+
method: 'POST',
|
|
1155
|
+
headers: { 'content-type': 'application/json' },
|
|
1156
|
+
body: JSON.stringify({
|
|
1157
|
+
room: chatCredentials.room,
|
|
1158
|
+
messageId,
|
|
1159
|
+
emoji,
|
|
1160
|
+
agentId: chatCredentials.agentId,
|
|
1161
|
+
agentToken: chatCredentials.agentToken,
|
|
1162
|
+
identityToken: chatCredentials.identityToken,
|
|
1163
|
+
}),
|
|
1164
|
+
});
|
|
1165
|
+
if (!r.ok) {
|
|
1166
|
+
const data = await r.json().catch(() => ({}));
|
|
1167
|
+
pushToast(`Reaction failed: ${data.error || 'unknown error'}`, 'error', 2800);
|
|
1168
|
+
}
|
|
1169
|
+
} catch (err) {
|
|
1170
|
+
pushToast(`Reaction network error: ${err.message}`, 'error', 2800);
|
|
1171
|
+
}
|
|
1172
|
+
}
|
|
1173
|
+
|
|
1174
|
+
function renderChatMessages() {
|
|
1175
|
+
const el = document.getElementById('chatMessages');
|
|
1176
|
+
const badge = document.getElementById('chatCountBadge');
|
|
1177
|
+
const filtered = chatMessages;
|
|
1178
|
+
badge.textContent = filtered.length;
|
|
1179
|
+
|
|
1180
|
+
if (filtered.length === 0) {
|
|
1181
|
+
el.innerHTML = `<div class="chat-empty">
|
|
1182
|
+
<div class="chat-empty-icon">💬</div>
|
|
1183
|
+
<div>No messages yet.</div>
|
|
1184
|
+
<div style="font-size:.68rem;margin-top:6px">Registered Clawllectors can discuss their collections here in real time.</div>
|
|
1185
|
+
</div>`;
|
|
1186
|
+
return;
|
|
1187
|
+
}
|
|
1188
|
+
|
|
1189
|
+
const byId = new Map(chatMessages.map((m) => [m.id, m]));
|
|
1190
|
+
el.innerHTML = filtered.map(m => {
|
|
1191
|
+
const isSelf = m.agentId === chatCredentials.agentId;
|
|
1192
|
+
const room = normalizeRoomName(m.room || 'general');
|
|
1193
|
+
const parent = m.replyTo ? byId.get(m.replyTo) : null;
|
|
1194
|
+
const parentPreview = parent ? `${parent.agentName || parent.agentId}: ${String(parent.text || '').slice(0, 80)}` : `Reply to ${m.replyTo || 'message'}`;
|
|
1195
|
+
const reactions = m.reactions && typeof m.reactions === 'object' ? m.reactions : {};
|
|
1196
|
+
const reactionUsers = m.reactionUsers && typeof m.reactionUsers === 'object' ? m.reactionUsers : {};
|
|
1197
|
+
return `<div class="chat-msg${isSelf ? ' self' : ''}">
|
|
1198
|
+
<div class="chat-msg-avatar">🦞</div>
|
|
1199
|
+
<div class="chat-msg-body">
|
|
1200
|
+
<div class="chat-msg-header">
|
|
1201
|
+
<span class="chat-msg-name">${escapeHtml(m.agentName || m.agentId)}</span>
|
|
1202
|
+
<span class="chat-msg-id">${escapeHtml(m.agentId)}</span>
|
|
1203
|
+
<span class="chat-msg-id">/${escapeHtml(room)}</span>
|
|
1204
|
+
<span class="chat-msg-time">${chatMsgTime(m.ts)}</span>
|
|
1205
|
+
</div>
|
|
1206
|
+
${m.replyTo ? `<div class="chat-msg-reply">${escapeHtml(parentPreview)}</div>` : ''}
|
|
1207
|
+
<div class="chat-msg-text">${formatChatText(m.text)}</div>
|
|
1208
|
+
<div class="chat-msg-actions">
|
|
1209
|
+
<button type="button" class="chat-msg-action-btn" data-reply-id="${escapeAttr(m.id)}">Reply</button>
|
|
1210
|
+
${CHAT_REACTION_EMOJIS.map((emoji) => {
|
|
1211
|
+
const count = Number(reactions[emoji] || 0);
|
|
1212
|
+
const mine = (reactionUsers[emoji] || []).includes(chatCredentials.agentId);
|
|
1213
|
+
return `<button type="button" class="chat-reaction-chip${mine ? ' active' : ''}" data-react-id="${escapeAttr(m.id)}" data-emoji="${escapeAttr(emoji)}">${emoji} ${count || ''}</button>`;
|
|
1214
|
+
}).join('')}
|
|
1215
|
+
</div>
|
|
1216
|
+
</div>
|
|
1217
|
+
</div>`;
|
|
1218
|
+
}).join('');
|
|
1219
|
+
|
|
1220
|
+
el.querySelectorAll('[data-reply-id]').forEach((btn) => {
|
|
1221
|
+
btn.addEventListener('click', () => {
|
|
1222
|
+
setChatReplyTarget(btn.getAttribute('data-reply-id'));
|
|
1223
|
+
document.getElementById('chatInput')?.focus();
|
|
1224
|
+
});
|
|
1225
|
+
});
|
|
1226
|
+
el.querySelectorAll('[data-react-id]').forEach((btn) => {
|
|
1227
|
+
btn.addEventListener('click', () => {
|
|
1228
|
+
const id = btn.getAttribute('data-react-id');
|
|
1229
|
+
const emoji = btn.getAttribute('data-emoji');
|
|
1230
|
+
sendChatReaction(id, emoji);
|
|
1231
|
+
});
|
|
1232
|
+
});
|
|
1233
|
+
|
|
1234
|
+
// Auto-scroll to latest
|
|
1235
|
+
el.scrollTop = el.scrollHeight;
|
|
1236
|
+
}
|
|
1237
|
+
|
|
1238
|
+
function updateChatInputState() {
|
|
1239
|
+
const input = document.getElementById('chatInput');
|
|
1240
|
+
const btn = document.getElementById('chatSendBtn');
|
|
1241
|
+
const counter = document.getElementById('chatCounter');
|
|
1242
|
+
if (!input || !btn || !counter) return;
|
|
1243
|
+
const len = (input.value || '').length;
|
|
1244
|
+
counter.textContent = `${len}/500`;
|
|
1245
|
+
const hasAuth = Boolean((chatCredentials.agentId && chatCredentials.agentToken) || chatCredentials.identityToken);
|
|
1246
|
+
const canSend = hasAuth && len > 0 && len <= 500;
|
|
1247
|
+
btn.disabled = !canSend;
|
|
1248
|
+
}
|
|
1249
|
+
|
|
1250
|
+
function updateChatAuthStatus() {
|
|
1251
|
+
const room = normalizeRoomName(document.getElementById('chatRoom').value.trim() || 'general');
|
|
1252
|
+
const id = document.getElementById('chatAgentId').value.trim();
|
|
1253
|
+
const token = document.getElementById('chatAgentToken').value.trim();
|
|
1254
|
+
const identityToken = document.getElementById('chatIdentityToken').value.trim();
|
|
1255
|
+
const status = document.getElementById('chatAuthStatus');
|
|
1256
|
+
const roomStatus = document.getElementById('chatRoomStatus');
|
|
1257
|
+
const input = document.getElementById('chatInput');
|
|
1258
|
+
const btn = document.getElementById('chatSendBtn');
|
|
1259
|
+
|
|
1260
|
+
chatCredentials.room = room;
|
|
1261
|
+
chatCredentials.agentId = id;
|
|
1262
|
+
chatCredentials.agentToken = token;
|
|
1263
|
+
chatCredentials.identityToken = identityToken;
|
|
1264
|
+
|
|
1265
|
+
// Persist to localStorage for convenience
|
|
1266
|
+
try {
|
|
1267
|
+
localStorage.setItem('apeclaw_chat_room', room);
|
|
1268
|
+
localStorage.setItem('apeclaw_chat_agentId', id);
|
|
1269
|
+
localStorage.setItem('apeclaw_chat_agentToken', token);
|
|
1270
|
+
localStorage.setItem('apeclaw_chat_identityToken', identityToken);
|
|
1271
|
+
} catch {}
|
|
1272
|
+
if (roomStatus) roomStatus.textContent = `Room: ${room}`;
|
|
1273
|
+
renderChatRooms();
|
|
1274
|
+
|
|
1275
|
+
if (identityToken) {
|
|
1276
|
+
status.textContent = `Moltbook identity ready in /${room}`;
|
|
1277
|
+
status.className = 'chat-auth-status ok';
|
|
1278
|
+
input.disabled = false;
|
|
1279
|
+
input.placeholder = 'Type a message...';
|
|
1280
|
+
btn.disabled = false;
|
|
1281
|
+
} else if (id && token) {
|
|
1282
|
+
status.textContent = `Signed in as ${id} in /${room}`;
|
|
1283
|
+
status.className = 'chat-auth-status ok';
|
|
1284
|
+
input.disabled = false;
|
|
1285
|
+
input.placeholder = 'Type a message...';
|
|
1286
|
+
btn.disabled = false;
|
|
1287
|
+
} else {
|
|
1288
|
+
status.textContent = 'Not signed in';
|
|
1289
|
+
status.className = 'chat-auth-status none';
|
|
1290
|
+
input.disabled = true;
|
|
1291
|
+
input.placeholder = 'Enter your Agent ID and Token to chat';
|
|
1292
|
+
btn.disabled = true;
|
|
1293
|
+
}
|
|
1294
|
+
updateChatInputState();
|
|
1295
|
+
// Re-render to highlight own messages
|
|
1296
|
+
renderChatMessages();
|
|
1297
|
+
}
|
|
1298
|
+
|
|
1299
|
+
function toggleChatTokenVisibility() {
|
|
1300
|
+
const tokenInput = document.getElementById('chatAgentToken');
|
|
1301
|
+
const identityInput = document.getElementById('chatIdentityToken');
|
|
1302
|
+
const toggleBtn = document.getElementById('chatToggleTokenBtn');
|
|
1303
|
+
const revealing = tokenInput.type === 'password';
|
|
1304
|
+
tokenInput.type = revealing ? 'text' : 'password';
|
|
1305
|
+
if (identityInput) identityInput.type = revealing ? 'text' : 'password';
|
|
1306
|
+
toggleBtn.textContent = revealing ? 'Hide' : 'Show';
|
|
1307
|
+
}
|
|
1308
|
+
|
|
1309
|
+
function clearChatCredentials() {
|
|
1310
|
+
document.getElementById('chatRoom').value = 'general';
|
|
1311
|
+
document.getElementById('chatAgentId').value = '';
|
|
1312
|
+
document.getElementById('chatAgentToken').value = '';
|
|
1313
|
+
document.getElementById('chatIdentityToken').value = '';
|
|
1314
|
+
document.getElementById('chatAgentToken').type = 'password';
|
|
1315
|
+
document.getElementById('chatIdentityToken').type = 'password';
|
|
1316
|
+
document.getElementById('chatToggleTokenBtn').textContent = 'Show';
|
|
1317
|
+
updateChatAuthStatus();
|
|
1318
|
+
}
|
|
1319
|
+
|
|
1320
|
+
function scheduleChatReconnect() {
|
|
1321
|
+
if (chatReconnectTimer) return;
|
|
1322
|
+
const waitMs = Math.min(10000, 1500 * (chatReconnectAttempts + 1));
|
|
1323
|
+
chatReconnectAttempts++;
|
|
1324
|
+
chatReconnectTimer = setTimeout(() => {
|
|
1325
|
+
chatReconnectTimer = null;
|
|
1326
|
+
connectChatStream();
|
|
1327
|
+
}, waitMs);
|
|
1328
|
+
}
|
|
1329
|
+
|
|
1330
|
+
async function sendChatMessage() {
|
|
1331
|
+
const input = document.getElementById('chatInput');
|
|
1332
|
+
const text = applyChatSlash(input.value);
|
|
1333
|
+
const hasClawbotCreds = Boolean(chatCredentials.agentId && chatCredentials.agentToken);
|
|
1334
|
+
const hasIdentityToken = Boolean(chatCredentials.identityToken);
|
|
1335
|
+
if (!text || (!hasClawbotCreds && !hasIdentityToken)) return;
|
|
1336
|
+
|
|
1337
|
+
const btn = document.getElementById('chatSendBtn');
|
|
1338
|
+
btn.disabled = true;
|
|
1339
|
+
btn.textContent = '...';
|
|
1340
|
+
|
|
1341
|
+
try {
|
|
1342
|
+
const r = await fetch(apiUrl('/api/chat'), {
|
|
1343
|
+
method: 'POST',
|
|
1344
|
+
headers: { 'content-type': 'application/json' },
|
|
1345
|
+
body: JSON.stringify({
|
|
1346
|
+
room: chatCredentials.room,
|
|
1347
|
+
agentId: chatCredentials.agentId,
|
|
1348
|
+
agentToken: chatCredentials.agentToken,
|
|
1349
|
+
identityToken: chatCredentials.identityToken,
|
|
1350
|
+
text,
|
|
1351
|
+
replyTo: chatReplyToId || undefined,
|
|
1352
|
+
}),
|
|
1353
|
+
});
|
|
1354
|
+
const data = await r.json();
|
|
1355
|
+
if (!r.ok) {
|
|
1356
|
+
terminalLines.push({ type: 'error', text: `[Chat] ${data.error || 'send failed'}` });
|
|
1357
|
+
renderTerminal();
|
|
1358
|
+
pushToast(`Chat send failed: ${data.error || 'unknown error'}`, 'error', 3400);
|
|
1359
|
+
} else {
|
|
1360
|
+
input.value = '';
|
|
1361
|
+
setChatReplyTarget(null);
|
|
1362
|
+
try { localStorage.removeItem(chatDraftKey(chatCredentials.room)); } catch {}
|
|
1363
|
+
}
|
|
1364
|
+
} catch (err) {
|
|
1365
|
+
terminalLines.push({ type: 'error', text: `[Chat] Network error: ${err.message}` });
|
|
1366
|
+
renderTerminal();
|
|
1367
|
+
pushToast(`Network error: ${err.message}`, 'error', 3400);
|
|
1368
|
+
} finally {
|
|
1369
|
+
updateChatInputState();
|
|
1370
|
+
btn.textContent = 'Send';
|
|
1371
|
+
input.focus();
|
|
1372
|
+
}
|
|
1373
|
+
}
|
|
1374
|
+
|
|
1375
|
+
async function loadChatHistory() {
|
|
1376
|
+
try {
|
|
1377
|
+
const r = await fetch(apiUrl(`/api/chat?room=${encodeURIComponent(chatCredentials.room || 'general')}&limit=200`));
|
|
1378
|
+
if (r.ok) {
|
|
1379
|
+
const data = await r.json();
|
|
1380
|
+
chatMessages = data.messages || [];
|
|
1381
|
+
chatUnreadByRoom.set(normalizeRoomName(chatCredentials.room || 'general'), 0);
|
|
1382
|
+
renderChatMessages();
|
|
1383
|
+
renderChatRooms();
|
|
1384
|
+
}
|
|
1385
|
+
} catch {}
|
|
1386
|
+
}
|
|
1387
|
+
|
|
1388
|
+
function connectChatStream() {
|
|
1389
|
+
try {
|
|
1390
|
+
if (chatSseSource) {
|
|
1391
|
+
chatSseSource.close();
|
|
1392
|
+
chatSseSource = null;
|
|
1393
|
+
}
|
|
1394
|
+
chatSseSource = new EventSource(apiUrl(`/api/chat/stream?room=${encodeURIComponent(chatCredentials.room || 'general')}`));
|
|
1395
|
+
chatSseSource.onopen = () => {
|
|
1396
|
+
chatReconnectAttempts = 0;
|
|
1397
|
+
};
|
|
1398
|
+
chatSseSource.onmessage = (e) => {
|
|
1399
|
+
try {
|
|
1400
|
+
const msg = JSON.parse(e.data);
|
|
1401
|
+
const msgRoom = normalizeRoomName(msg.room || 'general');
|
|
1402
|
+
const current = normalizeRoomName(chatCredentials.room || 'general');
|
|
1403
|
+
if (msgRoom !== current) {
|
|
1404
|
+
chatUnreadByRoom.set(msgRoom, Number(chatUnreadByRoom.get(msgRoom) || 0) + 1);
|
|
1405
|
+
renderChatRooms();
|
|
1406
|
+
return;
|
|
1407
|
+
}
|
|
1408
|
+
if (String(msg.type || 'message') === 'reaction') {
|
|
1409
|
+
applyReactionEventToMessages(msg);
|
|
1410
|
+
renderChatMessages();
|
|
1411
|
+
return;
|
|
1412
|
+
}
|
|
1413
|
+
// Deduplicate
|
|
1414
|
+
if (chatMessages.find(m => m.id === msg.id)) return;
|
|
1415
|
+
chatMessages.push(msg);
|
|
1416
|
+
if (chatMessages.length > 200) chatMessages.shift();
|
|
1417
|
+
renderChatMessages();
|
|
1418
|
+
} catch {}
|
|
1419
|
+
};
|
|
1420
|
+
chatSseSource.onerror = () => {
|
|
1421
|
+
if (chatSseSource) {
|
|
1422
|
+
chatSseSource.close();
|
|
1423
|
+
chatSseSource = null;
|
|
1424
|
+
}
|
|
1425
|
+
scheduleChatReconnect();
|
|
1426
|
+
};
|
|
1427
|
+
} catch {}
|
|
1428
|
+
}
|
|
1429
|
+
|
|
1430
|
+
function initChat() {
|
|
1431
|
+
// Restore saved credentials from localStorage
|
|
1432
|
+
try {
|
|
1433
|
+
const savedRoom = localStorage.getItem('apeclaw_chat_room') || 'general';
|
|
1434
|
+
const savedId = localStorage.getItem('apeclaw_chat_agentId') || '';
|
|
1435
|
+
const savedToken = localStorage.getItem('apeclaw_chat_agentToken') || '';
|
|
1436
|
+
const savedIdentity = localStorage.getItem('apeclaw_chat_identityToken') || '';
|
|
1437
|
+
if (savedRoom) document.getElementById('chatRoom').value = savedRoom;
|
|
1438
|
+
if (savedId) document.getElementById('chatAgentId').value = savedId;
|
|
1439
|
+
if (savedToken) document.getElementById('chatAgentToken').value = savedToken;
|
|
1440
|
+
if (savedIdentity) document.getElementById('chatIdentityToken').value = savedIdentity;
|
|
1441
|
+
} catch {}
|
|
1442
|
+
|
|
1443
|
+
// Auth field listeners
|
|
1444
|
+
document.getElementById('chatRoom').addEventListener('change', () => {
|
|
1445
|
+
updateChatAuthStatus();
|
|
1446
|
+
chatUnreadByRoom.set(normalizeRoomName(document.getElementById('chatRoom').value), 0);
|
|
1447
|
+
renderChatRooms();
|
|
1448
|
+
loadChatHistory();
|
|
1449
|
+
connectChatStream();
|
|
1450
|
+
const draft = localStorage.getItem(chatDraftKey(chatCredentials.room)) || '';
|
|
1451
|
+
const chatInput = document.getElementById('chatInput');
|
|
1452
|
+
if (chatInput) chatInput.value = draft;
|
|
1453
|
+
updateChatInputState();
|
|
1454
|
+
});
|
|
1455
|
+
document.getElementById('chatRoom').addEventListener('keydown', (e) => {
|
|
1456
|
+
if (e.key === 'Enter') {
|
|
1457
|
+
e.preventDefault();
|
|
1458
|
+
e.currentTarget.blur(); // triggers change flow for room switch
|
|
1459
|
+
}
|
|
1460
|
+
});
|
|
1461
|
+
document.getElementById('chatAgentId').addEventListener('input', updateChatAuthStatus);
|
|
1462
|
+
document.getElementById('chatAgentToken').addEventListener('input', updateChatAuthStatus);
|
|
1463
|
+
document.getElementById('chatIdentityToken').addEventListener('input', updateChatAuthStatus);
|
|
1464
|
+
document.getElementById('chatToggleTokenBtn').addEventListener('click', toggleChatTokenVisibility);
|
|
1465
|
+
document.getElementById('chatClearAuthBtn').addEventListener('click', clearChatCredentials);
|
|
1466
|
+
document.getElementById('chatReplyingCancelBtn').addEventListener('click', () => setChatReplyTarget(null));
|
|
1467
|
+
|
|
1468
|
+
// Send on button click
|
|
1469
|
+
document.getElementById('chatSendBtn').addEventListener('click', sendChatMessage);
|
|
1470
|
+
|
|
1471
|
+
// Send on Enter key
|
|
1472
|
+
document.getElementById('chatInput').addEventListener('input', () => {
|
|
1473
|
+
updateChatInputState();
|
|
1474
|
+
try { localStorage.setItem(chatDraftKey(chatCredentials.room), document.getElementById('chatInput').value || ''); } catch {}
|
|
1475
|
+
});
|
|
1476
|
+
document.getElementById('chatInput').addEventListener('keydown', (e) => {
|
|
1477
|
+
if (e.key === 'Enter' && !e.shiftKey) {
|
|
1478
|
+
e.preventDefault();
|
|
1479
|
+
sendChatMessage();
|
|
1480
|
+
}
|
|
1481
|
+
});
|
|
1482
|
+
|
|
1483
|
+
// Set initial auth state
|
|
1484
|
+
updateChatAuthStatus();
|
|
1485
|
+
updateChatInputState();
|
|
1486
|
+
try {
|
|
1487
|
+
const draft = localStorage.getItem(chatDraftKey(chatCredentials.room)) || '';
|
|
1488
|
+
document.getElementById('chatInput').value = draft;
|
|
1489
|
+
} catch {}
|
|
1490
|
+
|
|
1491
|
+
// Load history and connect stream
|
|
1492
|
+
loadChatRooms();
|
|
1493
|
+
loadChatHistory();
|
|
1494
|
+
connectChatStream();
|
|
1495
|
+
setInterval(loadChatRooms, 15000);
|
|
1496
|
+
}
|
|
1497
|
+
|
|
1498
|
+
// ═══════════════════════════════════════════════════════════
|
|
1499
|
+
// BOOT
|
|
1500
|
+
// ═══════════════════════════════════════════════════════════
|
|
1501
|
+
function initKeepQueryLinks() {
|
|
1502
|
+
// Preserve ?api=... across internal navigation (matches pod/docs/skills pages).
|
|
1503
|
+
try {
|
|
1504
|
+
const cur = new URL(window.location.href);
|
|
1505
|
+
const api = (cur.searchParams.get('api') || '').trim();
|
|
1506
|
+
if (!api) return;
|
|
1507
|
+
const as = document.querySelectorAll('a[data-keep-query="1"]');
|
|
1508
|
+
for (let i = 0; i < as.length; i++) {
|
|
1509
|
+
const raw = String(as[i].getAttribute('href') || '');
|
|
1510
|
+
if (!raw || raw.startsWith('http') || raw.startsWith('#')) continue;
|
|
1511
|
+
const u = new URL(raw, window.location.origin);
|
|
1512
|
+
if (!u.searchParams.has('api')) u.searchParams.set('api', api);
|
|
1513
|
+
as[i].setAttribute('href', u.pathname + (u.search ? u.search : '') + (u.hash ? u.hash : ''));
|
|
1514
|
+
}
|
|
1515
|
+
} catch {}
|
|
1516
|
+
}
|
|
1517
|
+
|
|
1518
|
+
async function boot() {
|
|
1519
|
+
loadUiPrefs();
|
|
1520
|
+
applyUiPrefs();
|
|
1521
|
+
initApiBase();
|
|
1522
|
+
initKeepQueryLinks();
|
|
1523
|
+
initBackendConfigUi();
|
|
1524
|
+
await ensureBackendReachableWithFallback();
|
|
1525
|
+
initSetupEnhancements();
|
|
1526
|
+
initCollectionControls();
|
|
1527
|
+
initPanelControls();
|
|
1528
|
+
initShortcutsPopover();
|
|
1529
|
+
initCommandDeck();
|
|
1530
|
+
|
|
1531
|
+
window.addEventListener('keydown', (e) => {
|
|
1532
|
+
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'k') {
|
|
1533
|
+
e.preventDefault();
|
|
1534
|
+
document.getElementById('collectionsSearch')?.focus();
|
|
1535
|
+
}
|
|
1536
|
+
if ((e.metaKey || e.ctrlKey) && e.key === '/') {
|
|
1537
|
+
e.preventDefault();
|
|
1538
|
+
document.getElementById('chatInput')?.focus();
|
|
1539
|
+
}
|
|
1540
|
+
if ((e.metaKey || e.ctrlKey) && e.shiftKey && e.key.toLowerCase() === 'f') {
|
|
1541
|
+
e.preventDefault();
|
|
1542
|
+
uiPrefs.focus = !uiPrefs.focus;
|
|
1543
|
+
applyUiPrefs(); saveUiPrefs();
|
|
1544
|
+
pushToast(`Focus mode ${uiPrefs.focus ? 'enabled' : 'disabled'}`, 'success');
|
|
1545
|
+
}
|
|
1546
|
+
if ((e.metaKey || e.ctrlKey) && e.shiftKey && e.key.toLowerCase() === 'd') {
|
|
1547
|
+
e.preventDefault();
|
|
1548
|
+
uiPrefs.dense = !uiPrefs.dense;
|
|
1549
|
+
applyUiPrefs(); saveUiPrefs();
|
|
1550
|
+
pushToast(`Dense mode ${uiPrefs.dense ? 'enabled' : 'disabled'}`, 'success');
|
|
1551
|
+
}
|
|
1552
|
+
if ((e.metaKey || e.ctrlKey) && e.shiftKey && e.key.toLowerCase() === 'm') {
|
|
1553
|
+
e.preventDefault();
|
|
1554
|
+
uiPrefs.motionLow = !uiPrefs.motionLow;
|
|
1555
|
+
applyUiPrefs(); saveUiPrefs();
|
|
1556
|
+
pushToast(`Low motion ${uiPrefs.motionLow ? 'enabled' : 'disabled'}`, 'success');
|
|
1557
|
+
}
|
|
1558
|
+
});
|
|
1559
|
+
|
|
1560
|
+
// Resolve allowlist from API first, then static file for Vercel/static hosting.
|
|
1561
|
+
async function fetchAllowlistWithFallback() {
|
|
1562
|
+
const sources = [];
|
|
1563
|
+
// 1) Preferred source: configured backend API.
|
|
1564
|
+
if (API_BASE) sources.push(apiUrl('/api/allowlist'));
|
|
1565
|
+
// 2) Same-origin API (works for local telemetry server deployments).
|
|
1566
|
+
sources.push('/api/allowlist');
|
|
1567
|
+
// 3) Static fallback for frontend-only hosts (e.g., Vercel).
|
|
1568
|
+
sources.push('/allowlists/recommended.apechain.json');
|
|
1569
|
+
for (const src of sources) {
|
|
1570
|
+
try {
|
|
1571
|
+
const r = await fetch(src);
|
|
1572
|
+
if (!r.ok) continue;
|
|
1573
|
+
const data = await r.json();
|
|
1574
|
+
if (Array.isArray(data) && data.length > 0) return data;
|
|
1575
|
+
} catch {}
|
|
1576
|
+
}
|
|
1577
|
+
return null;
|
|
1578
|
+
}
|
|
1579
|
+
|
|
1580
|
+
// Fetch allowlist, policy, clawbots in parallel
|
|
1581
|
+
const [alRes, cbRes] = await Promise.allSettled([
|
|
1582
|
+
fetchAllowlistWithFallback(),
|
|
1583
|
+
fetch(apiUrl('/api/clawbots')).then(r => r.ok ? r.json() : null),
|
|
1584
|
+
]);
|
|
1585
|
+
|
|
1586
|
+
// Allowlist
|
|
1587
|
+
const alData = alRes.status === 'fulfilled' ? alRes.value : null;
|
|
1588
|
+
if (Array.isArray(alData) && alData.length > 0) {
|
|
1589
|
+
COLLECTIONS = alData.map(c => ({ ...c, emoji: emojiFor(c.name||''), imageUrl: c.imageUrl || null }));
|
|
1590
|
+
}
|
|
1591
|
+
if (COLLECTIONS.length === 0) {
|
|
1592
|
+
COLLECTIONS = [
|
|
1593
|
+
{ rank:1, name:'Gs on Ape', slug:'gs-on-ape', contractAddress:'0xb3443b6bd585ba4118cae2bedb61c7ec4a8281df', chainId:33139, enabled:true, emoji:'🦍', imageUrl:null },
|
|
1594
|
+
{ rank:6, name:'Zards', slug:'zards', contractAddress:'0x91417bd88af5071ccea8d3bf3af410660e356b06', chainId:33139, enabled:true, emoji:'🦎', imageUrl:null },
|
|
1595
|
+
{ rank:11, name:'Mintotaurs', slug:'mintotaurs', contractAddress:'0x8af17673985e4032c6ced41d35e9f5a3e694ed7f', chainId:33139, enabled:true, emoji:'🐂', imageUrl:null },
|
|
1596
|
+
];
|
|
1597
|
+
}
|
|
1598
|
+
|
|
1599
|
+
// Clawbots
|
|
1600
|
+
const cbData = cbRes.status === 'fulfilled' ? cbRes.value : null;
|
|
1601
|
+
if (cbData?.clawbots) {
|
|
1602
|
+
REGISTERED_CLAWBOTS = cbData.clawbots;
|
|
1603
|
+
for (const b of REGISTERED_CLAWBOTS) {
|
|
1604
|
+
AGENT_DISPLAY_NAMES[b.agentId] = b.name || b.agentId;
|
|
1605
|
+
}
|
|
1606
|
+
const setupNote = document.getElementById('setupClawbotCount');
|
|
1607
|
+
if (setupNote) {
|
|
1608
|
+
setupNote.textContent = `${cbData.count} registered Clawllector${cbData.count!==1?'s':''}${cbData.sharedKeyConfigured ? ' • shared key ✓' : ' • shared key not set'}`;
|
|
1609
|
+
}
|
|
1610
|
+
}
|
|
1611
|
+
|
|
1612
|
+
// Render everything
|
|
1613
|
+
renderCollectionsBar();
|
|
1614
|
+
renderAgents();
|
|
1615
|
+
renderNftGrid();
|
|
1616
|
+
renderBridge();
|
|
1617
|
+
renderTerminal();
|
|
1618
|
+
renderFeed();
|
|
1619
|
+
updateStats();
|
|
1620
|
+
|
|
1621
|
+
// Collections carousel controls
|
|
1622
|
+
const prev = document.getElementById('collectionsPrev');
|
|
1623
|
+
const next = document.getElementById('collectionsNext');
|
|
1624
|
+
const bar = document.getElementById('collectionsBar');
|
|
1625
|
+
if (prev) prev.addEventListener('click', () => scrollCollectionsBy(-1));
|
|
1626
|
+
if (next) next.addEventListener('click', () => scrollCollectionsBy(1));
|
|
1627
|
+
if (bar) {
|
|
1628
|
+
bar.addEventListener('scroll', updateCollectionsStatus, { passive: true });
|
|
1629
|
+
bar.addEventListener('mouseenter', () => collectionsCarouselTimer && clearInterval(collectionsCarouselTimer));
|
|
1630
|
+
bar.addEventListener('mouseleave', startCollectionsCarousel);
|
|
1631
|
+
}
|
|
1632
|
+
window.addEventListener('resize', () => {
|
|
1633
|
+
updateCollectionsStatus();
|
|
1634
|
+
startCollectionsCarousel();
|
|
1635
|
+
});
|
|
1636
|
+
|
|
1637
|
+
// Connect live telemetry
|
|
1638
|
+
await connectLiveTelemetry();
|
|
1639
|
+
|
|
1640
|
+
// Initialize chat (after telemetry so SSE is ready)
|
|
1641
|
+
initChat();
|
|
1642
|
+
setInterval(() => {
|
|
1643
|
+
const el = document.getElementById('utcClock');
|
|
1644
|
+
if (el) el.textContent = `${new Date().toISOString().slice(11, 19)}Z`;
|
|
1645
|
+
}, 1000);
|
|
1646
|
+
}
|
|
1647
|
+
|
|
1648
|
+
// ═══════════════════════════════════════════════════════════
|
|
1649
|
+
// SKILLS LIBRARY DASHBOARD PANEL
|
|
1650
|
+
// ═══════════════════════════════════════════════════════════
|
|
1651
|
+
let skillsCache = { stats: null, skills: [], filtered: [] };
|
|
1652
|
+
|
|
1653
|
+
function renderSkillCards(skills) {
|
|
1654
|
+
const grid = document.getElementById('skillsPanelGrid');
|
|
1655
|
+
if (!grid) return;
|
|
1656
|
+
if (!skills || skills.length === 0) {
|
|
1657
|
+
grid.innerHTML = '<div style="color:var(--dim);font-size:.7rem;padding:20px;text-align:center;grid-column:1/-1">No skills found</div>';
|
|
1658
|
+
return;
|
|
1659
|
+
}
|
|
1660
|
+
grid.innerHTML = skills.map(s => {
|
|
1661
|
+
const tier = String(s.riskTier || 'low').toLowerCase();
|
|
1662
|
+
const srcClass = s.source === 'seed' ? 'src-seed' : s.source === 'imported' ? 'src-imported' : 'src-user';
|
|
1663
|
+
const desc = escapeHtml(String(s.description || '').slice(0, 120));
|
|
1664
|
+
const name = escapeHtml(String(s.name || s.slug || 'Unnamed'));
|
|
1665
|
+
const onchain = s.onchainTokenId != null ? '<span class="sms onchain">⛓ onchain</span>' : '';
|
|
1666
|
+
return `<div class="skill-mini-card" data-tier="${escapeHtml(tier)}">
|
|
1667
|
+
<div class="skill-mini-name">${name}</div>
|
|
1668
|
+
<div class="skill-mini-desc">${desc || '<em>No description</em>'}</div>
|
|
1669
|
+
<div class="skill-mini-meta">
|
|
1670
|
+
<span class="sms ${srcClass}">${escapeHtml(s.source || 'unknown')}</span>
|
|
1671
|
+
${s.riskTier ? '<span class="sms">' + escapeHtml(s.riskTier) + '</span>' : ''}
|
|
1672
|
+
${onchain}
|
|
1673
|
+
</div>
|
|
1674
|
+
</div>`;
|
|
1675
|
+
}).join('');
|
|
1676
|
+
}
|
|
1677
|
+
|
|
1678
|
+
async function fetchSkillsStats() {
|
|
1679
|
+
try {
|
|
1680
|
+
const resp = await fetch(apiUrl('/api/skills/stats'));
|
|
1681
|
+
if (!resp.ok) throw new Error('stats ' + resp.status);
|
|
1682
|
+
const data = await resp.json();
|
|
1683
|
+
if (!data.ok) throw new Error(data.error || 'stats failed');
|
|
1684
|
+
skillsCache.stats = data;
|
|
1685
|
+
|
|
1686
|
+
const setIf = (id, val) => { const el = document.getElementById(id); if (el) el.textContent = val; };
|
|
1687
|
+
setIf('psSkillsTotal', data.total.toLocaleString());
|
|
1688
|
+
setIf('psSkillsOnchain', data.onchain.toLocaleString());
|
|
1689
|
+
setIf('psSkillsVetted', data.vetted.toLocaleString());
|
|
1690
|
+
setIf('skillCountBadge', data.total.toLocaleString());
|
|
1691
|
+
setIf('ssSeed', data.seed.toLocaleString());
|
|
1692
|
+
setIf('ssImported', data.imported.toLocaleString());
|
|
1693
|
+
setIf('ssUser', data.user.toLocaleString());
|
|
1694
|
+
setIf('ssOnchain', data.onchain.toLocaleString());
|
|
1695
|
+
setIf('ssVetted', data.vetted.toLocaleString());
|
|
1696
|
+
|
|
1697
|
+
const totalEl = document.getElementById('psSkillsTotal');
|
|
1698
|
+
if (totalEl) { totalEl.parentElement.classList.add('pulsing'); setTimeout(() => totalEl.parentElement.classList.remove('pulsing'), 5000); }
|
|
1699
|
+
|
|
1700
|
+
if (data.recent && data.recent.length > 0) {
|
|
1701
|
+
renderSkillCards(data.recent);
|
|
1702
|
+
}
|
|
1703
|
+
} catch (err) {
|
|
1704
|
+
console.warn('[skills-stats]', err.message);
|
|
1705
|
+
const setIf = (id, val) => { const el = document.getElementById(id); if (el && (el.textContent === '—' || el.textContent === '0')) el.textContent = val; };
|
|
1706
|
+
setIf('psSkillsTotal', '10,032');
|
|
1707
|
+
setIf('psSkillsOnchain', '10,024');
|
|
1708
|
+
setIf('psSkillsVetted', '10,004');
|
|
1709
|
+
setIf('skillCountBadge', '10,032');
|
|
1710
|
+
setIf('ssSeed', '8');
|
|
1711
|
+
setIf('ssImported', '10,024');
|
|
1712
|
+
setIf('ssUser', '0');
|
|
1713
|
+
setIf('ssOnchain', '10,024');
|
|
1714
|
+
setIf('ssVetted', '10,004');
|
|
1715
|
+
const grid = document.getElementById('skillsPanelGrid');
|
|
1716
|
+
if (grid && grid.innerHTML.includes('Loading')) {
|
|
1717
|
+
grid.innerHTML = '<div style="color:var(--dim);font-size:.7rem;padding:20px;text-align:center;grid-column:1/-1">Could not load skills. <button onclick="fetchSkillsStats()" style="background:none;border:1px solid var(--border);color:var(--accent);padding:3px 8px;border-radius:4px;cursor:pointer;font-size:.65rem">Retry</button></div>';
|
|
1718
|
+
}
|
|
1719
|
+
}
|
|
1720
|
+
}
|
|
1721
|
+
|
|
1722
|
+
async function fetchSkillsSearch(query, source) {
|
|
1723
|
+
try {
|
|
1724
|
+
const grid = document.getElementById('skillsPanelGrid');
|
|
1725
|
+
if (grid) grid.innerHTML = '<div style="color:var(--dim);font-size:.7rem;padding:20px;text-align:center;grid-column:1/-1">Searching…</div>';
|
|
1726
|
+
const params = new URLSearchParams({ limit: '50' });
|
|
1727
|
+
if (query) params.set('q', query);
|
|
1728
|
+
if (source) params.set('source', source);
|
|
1729
|
+
const resp = await fetch(apiUrl('/api/skills/search?' + params.toString()));
|
|
1730
|
+
if (!resp.ok) throw new Error('search ' + resp.status);
|
|
1731
|
+
const data = await resp.json();
|
|
1732
|
+
if (!data.ok) throw new Error(data.error || 'search failed');
|
|
1733
|
+
skillsCache.skills = data.results;
|
|
1734
|
+
skillsCache.filtered = data.results;
|
|
1735
|
+
renderSkillCards(data.results);
|
|
1736
|
+
const badge = document.getElementById('skillCountBadge');
|
|
1737
|
+
if (badge && skillsCache.stats) badge.textContent = skillsCache.stats.total.toLocaleString();
|
|
1738
|
+
} catch (err) {
|
|
1739
|
+
console.warn('[skills-search]', err.message);
|
|
1740
|
+
const grid = document.getElementById('skillsPanelGrid');
|
|
1741
|
+
if (grid) grid.innerHTML = '<div style="color:var(--dim);font-size:.7rem;padding:20px;text-align:center;grid-column:1/-1">Search failed. Try again.</div>';
|
|
1742
|
+
}
|
|
1743
|
+
}
|
|
1744
|
+
|
|
1745
|
+
(function initSkillsPanel() {
|
|
1746
|
+
const searchInput = document.getElementById('skillSearchInput');
|
|
1747
|
+
const sourceFilter = document.getElementById('skillSourceFilter');
|
|
1748
|
+
let debounce;
|
|
1749
|
+
if (searchInput) searchInput.addEventListener('input', () => {
|
|
1750
|
+
clearTimeout(debounce);
|
|
1751
|
+
debounce = setTimeout(() => fetchSkillsSearch(searchInput.value, sourceFilter?.value || ''), 300);
|
|
1752
|
+
});
|
|
1753
|
+
if (sourceFilter) sourceFilter.addEventListener('change', () => {
|
|
1754
|
+
fetchSkillsSearch(searchInput?.value || '', sourceFilter.value);
|
|
1755
|
+
});
|
|
1756
|
+
})();
|
|
1757
|
+
|
|
1758
|
+
async function refreshSkillsLoop() {
|
|
1759
|
+
await fetchSkillsStats();
|
|
1760
|
+
setInterval(fetchSkillsStats, 120_000);
|
|
1761
|
+
}
|
|
1762
|
+
|
|
1763
|
+
(async function () {
|
|
1764
|
+
await boot();
|
|
1765
|
+
refreshSkillsLoop();
|
|
1766
|
+
})();
|