From b21a7fc8dc0194b2a3582f414f1b8b7675c77ddb Mon Sep 17 00:00:00 2001
From: FLY <14993945+flynumber1@user.noreply.gitee.com>
Date: Thu, 19 Mar 2026 21:32:56 +0800
Subject: [PATCH] feat: add multi-page WebUI with i18n and dual theme support
---
.gitignore | 6 +
clawnet-cli/internal/cli/cli.go | 6 +
clawnet-cli/internal/config/config.go | 52 +++
clawnet-cli/internal/daemon/api.go | 20 ++
website/pages/css/styles.css | 220 +++++++++++++
website/pages/index.html | 169 ++++++++++
website/pages/js/api.js | 75 +++++
website/pages/js/chat.js | 62 ++++
website/pages/js/credits.js | 28 ++
website/pages/js/dashboard.js | 165 ++++++++++
website/pages/js/i18n.js | 438 ++++++++++++++++++++++++++
website/pages/js/knowledge.js | 80 +++++
website/pages/js/predictions.js | 67 ++++
website/pages/js/swarm.js | 60 ++++
website/pages/js/tasks.js | 136 ++++++++
website/pages/js/theme.js | 38 +++
website/pages/js/topology.js | 69 ++++
17 files changed, 1691 insertions(+)
create mode 100644 website/pages/css/styles.css
create mode 100644 website/pages/index.html
create mode 100644 website/pages/js/api.js
create mode 100644 website/pages/js/chat.js
create mode 100644 website/pages/js/credits.js
create mode 100644 website/pages/js/dashboard.js
create mode 100644 website/pages/js/i18n.js
create mode 100644 website/pages/js/knowledge.js
create mode 100644 website/pages/js/predictions.js
create mode 100644 website/pages/js/swarm.js
create mode 100644 website/pages/js/tasks.js
create mode 100644 website/pages/js/theme.js
create mode 100644 website/pages/js/topology.js
diff --git a/.gitignore b/.gitignore
index d694f86..f62dbdf 100644
--- a/.gitignore
+++ b/.gitignore
@@ -45,6 +45,8 @@ clawnet-topo*.gif
# Design / asset files (not for repo)
*.svg
*.html
+!website/pages/**/*.html
+!website/pages/index.html
*.webp
# Development docs (not for public repo)
@@ -85,3 +87,7 @@ dist/
clawnet-cli/clawnet-linux-amd64
clawnet-cli/clawnet-linux-arm64
**/.npmrc
+
+.cursor/
+.lh/
+webui/
\ No newline at end of file
diff --git a/clawnet-cli/internal/cli/cli.go b/clawnet-cli/internal/cli/cli.go
index 4c85c1c..a2ab495 100644
--- a/clawnet-cli/internal/cli/cli.go
+++ b/clawnet-cli/internal/cli/cli.go
@@ -237,6 +237,12 @@ func Execute() error {
if a == "--daemon" {
daemonMode = true
}
+ if a == "--no-ui" {
+ os.Setenv("CLAWNET_WEBUI_ENABLED", "false")
+ }
+ if strings.HasPrefix(a, "--webui-dir=") {
+ os.Setenv("CLAWNET_WEBUI_DIR", strings.TrimPrefix(a, "--webui-dir="))
+ }
if devBuild && strings.HasPrefix(a, "--dev-layers=") {
devLayers = strings.Split(strings.TrimPrefix(a, "--dev-layers="), ",")
}
diff --git a/clawnet-cli/internal/config/config.go b/clawnet-cli/internal/config/config.go
index d0217b6..fbece20 100644
--- a/clawnet-cli/internal/config/config.go
+++ b/clawnet-cli/internal/config/config.go
@@ -28,6 +28,8 @@ type Config struct {
RelayEnabled bool `json:"relay_enabled"`
ForcePrivate bool `json:"force_private"`
WebUIPort int `json:"web_ui_port"`
+ WebUIEnabled *bool `json:"web_ui_enabled,omitempty"` // nil = true (default on)
+ WebUIDir string `json:"web_ui_dir,omitempty"` // explicit override; auto-discovered if empty
TopicsAutoJoin []string `json:"topics_auto_join"`
WireGuard WireGuardConfig `json:"wireguard"`
BTDHT BTDHTConfig `json:"bt_dht"`
@@ -218,6 +220,56 @@ func (c *Config) applyEnvOverrides() {
if v := os.Getenv("CLAWNET_OVERLAY_BOOTSTRAP"); v != "" {
c.Overlay.BootstrapPeers = splitComma(v)
}
+ if v := os.Getenv("CLAWNET_WEBUI_DIR"); v != "" {
+ c.WebUIDir = v
+ }
+ if v := os.Getenv("CLAWNET_WEBUI_ENABLED"); v != "" {
+ b := v == "1" || strings.EqualFold(v, "true")
+ c.WebUIEnabled = &b
+ }
+}
+
+// IsWebUIEnabled returns true unless explicitly disabled.
+func (c *Config) IsWebUIEnabled() bool {
+ if c.WebUIEnabled == nil {
+ return true
+ }
+ return *c.WebUIEnabled
+}
+
+// ResolveWebUIDir returns the webui directory, auto-discovering if not set explicitly.
+// Searches: config WebUIDir → relative to executable → relative to cwd → data dir.
+func (c *Config) ResolveWebUIDir() string {
+ if c.WebUIDir != "" {
+ if abs, err := filepath.Abs(c.WebUIDir); err == nil {
+ if info, err := os.Stat(abs); err == nil && info.IsDir() {
+ return abs
+ }
+ }
+ }
+ candidates := []string{}
+ if exe, err := os.Executable(); err == nil {
+ exeDir := filepath.Dir(exe)
+ candidates = append(candidates,
+ filepath.Join(exeDir, "website", "pages"),
+ filepath.Join(exeDir, "..", "website", "pages"),
+ )
+ }
+ if cwd, err := os.Getwd(); err == nil {
+ candidates = append(candidates, filepath.Join(cwd, "website", "pages"))
+ }
+ candidates = append(candidates, filepath.Join(DataDir(), "webui"))
+ for _, p := range candidates {
+ if abs, err := filepath.Abs(p); err == nil {
+ if info, err := os.Stat(abs); err == nil && info.IsDir() {
+ idx := filepath.Join(abs, "index.html")
+ if _, err := os.Stat(idx); err == nil {
+ return abs
+ }
+ }
+ }
+ }
+ return ""
}
func splitComma(s string) []string {
diff --git a/clawnet-cli/internal/daemon/api.go b/clawnet-cli/internal/daemon/api.go
index 0c656cb..bd816ba 100644
--- a/clawnet-cli/internal/daemon/api.go
+++ b/clawnet-cli/internal/daemon/api.go
@@ -7,6 +7,8 @@ import (
"fmt"
"net"
"net/http"
+ "os"
+ "path/filepath"
"strconv"
"strings"
"sync"
@@ -100,6 +102,24 @@ func (d *Daemon) StartAPI(ctx context.Context) *http.Server {
// Intuitive design routes (milestones, achievements, watch, endpoints)
d.RegisterIntuitiveRoutes(mux)
+ if d.Config.IsWebUIEnabled() {
+ if dir := d.Config.ResolveWebUIDir(); dir != "" {
+ fs := http.FileServer(http.Dir(dir))
+ mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodGet && r.Method != http.MethodHead {
+ http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed)
+ return
+ }
+ p := filepath.Join(dir, filepath.Clean(r.URL.Path))
+ if _, err := os.Stat(p); os.IsNotExist(err) {
+ r.URL.Path = "/"
+ }
+ fs.ServeHTTP(w, r)
+ })
+ fmt.Printf("WebUI: http://localhost:%d/ (from %s)\n", d.Config.WebUIPort, dir)
+ }
+ }
+
// Wrap mux with localhost access guard.
handler := localhostGuard(mux)
diff --git a/website/pages/css/styles.css b/website/pages/css/styles.css
new file mode 100644
index 0000000..34b1db1
--- /dev/null
+++ b/website/pages/css/styles.css
@@ -0,0 +1,220 @@
+/* ── Theme Variables (RGB channels for Tailwind opacity support) ── */
+
+:root, :root[data-theme="teal"] {
+ --c-surface-0: 12 12 18;
+ --c-surface-1: 19 19 29;
+ --c-surface-2: 26 26 40;
+ --c-surface-3: 34 34 52;
+ --c-surface-4: 44 44 64;
+ --c-accent-50: 13 41 38;
+ --c-accent-100: 19 78 72;
+ --c-accent-200: 26 107 98;
+ --c-accent-300: 45 157 143;
+ --c-accent-400: 56 178 172;
+ --c-accent-500: 79 209 197;
+ --c-accent-600: 94 234 212;
+ --c-accent-700: 153 246 228;
+ --c-accent-800: 178 245 234;
+ --c-accent-900: 230 255 250;
+}
+
+:root[data-theme="lobster"] {
+ --c-surface-0: 16 10 11;
+ --c-surface-1: 28 18 20;
+ --c-surface-2: 40 28 30;
+ --c-surface-3: 53 36 38;
+ --c-surface-4: 66 46 48;
+ --c-accent-50: 43 13 16;
+ --c-accent-100: 78 19 24;
+ --c-accent-200: 122 31 40;
+ --c-accent-300: 176 48 60;
+ --c-accent-400: 214 53 69;
+ --c-accent-500: 230 57 70;
+ --c-accent-600: 234 94 106;
+ --c-accent-700: 240 157 164;
+ --c-accent-800: 245 191 195;
+ --c-accent-900: 255 240 241;
+}
+
+/* ── Base ── */
+
+body {
+ font-family: -apple-system, BlinkMacSystemFont, 'Inter', 'Segoe UI', sans-serif;
+}
+
+/* ── Navigation ── */
+
+.nav-item.active {
+ background: rgb(var(--c-accent-500) / 0.08);
+ color: rgb(var(--c-accent-500));
+ border-left: 2px solid rgb(var(--c-accent-500));
+}
+.nav-item:not(.active) {
+ border-left: 2px solid transparent;
+}
+.nav-item:not(.active):hover {
+ background: rgba(255,255,255,0.03);
+ color: #94a3b8;
+}
+
+/* ── Card ── */
+
+.card {
+ background: rgb(var(--c-surface-2));
+ border: 1px solid rgb(var(--c-surface-3));
+ border-radius: 0.75rem;
+ padding: 1.25rem;
+}
+.card:hover {
+ border-color: rgb(var(--c-surface-4));
+}
+
+/* ── Badge ── */
+
+.badge {
+ display: inline-flex;
+ align-items: center;
+ padding: 2px 8px;
+ border-radius: 9999px;
+ font-size: 11px;
+ font-weight: 500;
+}
+
+/* ── Buttons ── */
+
+.btn {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ padding: 0.45rem 1rem;
+ border-radius: 0.5rem;
+ font-size: 0.8125rem;
+ font-weight: 500;
+ transition: all 150ms;
+ cursor: pointer;
+ border: none;
+}
+.btn:disabled { opacity: 0.4; cursor: not-allowed; }
+.btn-primary { background: rgb(var(--c-accent-300)); color: rgb(var(--c-accent-900)); }
+.btn-primary:hover { background: rgb(var(--c-accent-400)); }
+.btn-secondary { background: rgb(var(--c-surface-3)); color: #94a3b8; border: 1px solid rgb(var(--c-surface-3)); }
+.btn-secondary:hover { background: rgb(var(--c-surface-4)); color: #cbd5e1; }
+.btn-danger { background: rgba(239,68,68,0.1); color: #f87171; }
+.btn-danger:hover { background: rgba(239,68,68,0.18); }
+.btn-success { background: rgba(52,211,153,0.1); color: #34d399; }
+.btn-success:hover { background: rgba(52,211,153,0.18); }
+
+/* ── Input ── */
+
+.input {
+ width: 100%;
+ padding: 0.45rem 0.75rem;
+ background: rgb(var(--c-surface-1));
+ border: 1px solid rgb(var(--c-surface-3));
+ border-radius: 0.5rem;
+ font-size: 0.8125rem;
+ color: #e2e8f0;
+ outline: none;
+ transition: all 150ms;
+}
+.input:focus {
+ border-color: rgb(var(--c-accent-500));
+ box-shadow: 0 0 0 2px rgb(var(--c-accent-500) / 0.15);
+}
+.input::placeholder { color: rgb(var(--c-surface-4)); }
+select.input { appearance: auto; }
+
+/* ── Tabs ── */
+
+.tab-btn {
+ padding: 0.4rem 0.9rem;
+ font-size: 0.8125rem;
+ font-weight: 500;
+ border-radius: 0.5rem;
+ color: #64748b;
+ background: transparent;
+ cursor: pointer;
+ transition: all 150ms;
+ border: 1px solid transparent;
+}
+.tab-btn.active {
+ color: rgb(var(--c-accent-500));
+ background: rgb(var(--c-accent-500) / 0.08);
+ border-color: rgb(var(--c-accent-500) / 0.2);
+}
+.tab-btn:not(.active):hover {
+ color: #94a3b8;
+ background: rgba(255,255,255,0.03);
+}
+
+/* ── Status Badges ── */
+
+.status-open { background: rgba(52,211,153,0.12); color: #34d399; }
+.status-assigned { background: rgba(96,165,250,0.12); color: #60a5fa; }
+.status-submitted { background: rgba(251,191,36,0.12); color: #fbbf24; }
+.status-approved { background: rgba(74,222,128,0.12); color: #4ade80; }
+.status-rejected { background: rgba(248,113,113,0.12); color: #f87171; }
+.status-cancelled { background: rgba(148,163,184,0.1); color: #64748b; }
+.status-settled { background: rgba(167,139,250,0.12); color: #a78bfa; }
+.status-resolved { background: rgba(74,222,128,0.12); color: #4ade80; }
+
+/* ── Swarm Perspectives ── */
+
+.perspective-bull { background: rgba(52,211,153,0.06); border-color: rgba(52,211,153,0.2); }
+.perspective-bear { background: rgba(248,113,113,0.06); border-color: rgba(248,113,113,0.2); }
+.perspective-neutral { background: rgba(148,163,184,0.06); border-color: rgba(148,163,184,0.15); }
+
+/* ── Toast ── */
+
+.toast {
+ animation: slideIn .3s ease-out;
+ backdrop-filter: blur(8px);
+}
+@keyframes slideIn { from { transform: translateX(100%); opacity:0; } to { transform: translateX(0); opacity:1; } }
+
+/* ── Fade ── */
+
+.fade-in { animation: fadeIn .2s ease-out; }
+@keyframes fadeIn { from { opacity:0; } to { opacity:1; } }
+
+/* ── Scrollbar ── */
+
+::-webkit-scrollbar { width: 5px; }
+::-webkit-scrollbar-track { background: transparent; }
+::-webkit-scrollbar-thumb { background: rgb(var(--c-surface-3)); border-radius: 3px; }
+::-webkit-scrollbar-thumb:hover { background: rgb(var(--c-surface-4)); }
+
+/* ── Spinner ── */
+
+.loading-spinner {
+ border: 2px solid rgb(var(--c-surface-3));
+ border-top-color: rgb(var(--c-accent-500));
+ border-radius: 50%;
+ width: 20px;
+ height: 20px;
+ animation: spin .6s linear infinite;
+ display: inline-block;
+}
+@keyframes spin { to { transform: rotate(360deg); } }
+
+/* ── Leaflet Override ── */
+
+.leaflet-container { background: rgb(var(--c-surface-0)) !important; }
+
+/* ── Theme Toggle Button ── */
+
+.theme-toggle, .lang-toggle {
+ padding: 4px 8px;
+ border-radius: 6px;
+ font-size: 11px;
+ font-weight: 500;
+ cursor: pointer;
+ border: 1px solid rgb(var(--c-surface-3));
+ background: rgb(var(--c-surface-2));
+ color: #94a3b8;
+ transition: all 150ms;
+}
+.theme-toggle:hover, .lang-toggle:hover {
+ border-color: rgb(var(--c-accent-500) / 0.4);
+ color: rgb(var(--c-accent-500));
+}
diff --git a/website/pages/index.html b/website/pages/index.html
new file mode 100644
index 0000000..f04bdfd
--- /dev/null
+++ b/website/pages/index.html
@@ -0,0 +1,169 @@
+
+
+
+
+
+ClawNet
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/website/pages/js/api.js b/website/pages/js/api.js
new file mode 100644
index 0000000..311a1c6
--- /dev/null
+++ b/website/pages/js/api.js
@@ -0,0 +1,75 @@
+/* ── API Client & Utility Functions ── */
+
+const API = '';
+let MY_PEER_ID = '';
+let refreshTimers = [];
+
+async function api(path, opts = {}) {
+ try {
+ const res = await fetch(API + path, {
+ headers: { 'Content-Type': 'application/json', ...opts.headers },
+ ...opts
+ });
+ if (!res.ok) {
+ const err = await res.json().catch(() => ({ error: res.statusText }));
+ throw new Error(err.error || res.statusText);
+ }
+ return res.json();
+ } catch (e) {
+ if (e.message !== 'Failed to fetch') toast(e.message, 'error');
+ throw e;
+ }
+}
+
+const get = (p) => api(p);
+const post = (p, body) => api(p, { method: 'POST', body: JSON.stringify(body) });
+const put = (p, body) => api(p, { method: 'PUT', body: JSON.stringify(body) });
+const del = (p) => api(p, { method: 'DELETE' });
+
+function short(id, n = 8) {
+ return id ? id.slice(0, n) + '...' : '--';
+}
+
+function timeAgo(ts) {
+ if (!ts) return '';
+ const d = new Date(ts);
+ if (isNaN(d)) return ts;
+ const s = Math.floor((Date.now() - d) / 1000);
+ if (s < 60) return t('time.just_now');
+ if (s < 3600) return Math.floor(s / 60) + t('time.m_ago');
+ if (s < 86400) return Math.floor(s / 3600) + t('time.h_ago');
+ return Math.floor(s / 86400) + t('time.d_ago');
+}
+
+function statusBadge(s) {
+ return `${s || 'unknown'}`;
+}
+
+function escHtml(s) {
+ const d = document.createElement('div');
+ d.textContent = s;
+ return d.innerHTML;
+}
+
+function loading() {
+ return '
';
+}
+
+function toast(msg, type = 'info') {
+ const c = document.getElementById('toast-container');
+ const colors = {
+ info: 'bg-surface-3/90 border border-surface-4',
+ success: 'bg-emerald-900/80 border border-emerald-700/30',
+ error: 'bg-red-900/80 border border-red-700/30',
+ warning: 'bg-amber-900/80 border border-amber-700/30'
+ };
+ const el = document.createElement('div');
+ el.className = `toast ${colors[type] || colors.info} text-gray-200 px-4 py-2.5 rounded-lg shadow-xl text-sm max-w-xs`;
+ el.textContent = msg;
+ c.appendChild(el);
+ setTimeout(() => {
+ el.style.opacity = '0';
+ el.style.transition = 'opacity .3s';
+ setTimeout(() => el.remove(), 300);
+ }, 3000);
+}
diff --git a/website/pages/js/chat.js b/website/pages/js/chat.js
new file mode 100644
index 0000000..86f52c3
--- /dev/null
+++ b/website/pages/js/chat.js
@@ -0,0 +1,62 @@
+/* ── Chat Page ── */
+
+let chatActivePeer = null;
+
+async function loadChat() {
+ const el = document.getElementById('page-chat');
+ el.innerHTML = `
+
+
${t('chat.title')}
+
${loading()}
+
+
+
+
`;
+ await loadChatInbox();
+}
+
+async function loadChatInbox() {
+ const el = document.getElementById('chat-inbox');
+ try {
+ const msgs = await get('/api/dm/inbox');
+ const threads = {};
+ msgs.forEach(m => {
+ if (!threads[m.peer_id]) threads[m.peer_id] = { peer_id: m.peer_id, last: m, unread: 0 };
+ if (!m.read && m.direction === 'received') threads[m.peer_id].unread++;
+ if (new Date(m.created_at) > new Date(threads[m.peer_id].last.created_at)) threads[m.peer_id].last = m;
+ });
+ const sorted = Object.values(threads).sort((a, b) => new Date(b.last.created_at) - new Date(a.last.created_at));
+ if (!sorted.length) { el.innerHTML = `${t('chat.none')}
`; return; }
+ el.innerHTML = sorted.map(th => `${short(th.peer_id, 12)}${th.unread > 0 ? `${th.unread}` : ''}
${escHtml(th.last.body).slice(0, 50)}
`).join('');
+ } catch { el.innerHTML = 'Failed to load
'; }
+}
+
+async function openThread(peerId) {
+ chatActivePeer = peerId;
+ loadChatInbox();
+ const el = document.getElementById('chat-thread'); el.innerHTML = loading();
+ try {
+ const msgs = await get(`/api/dm/thread/${peerId}?limit=100`);
+ el.innerHTML = `${short(peerId, 20)}
+ ${msgs.map(m => `
${escHtml(m.body)}
${timeAgo(m.created_at)}
`).join('')}
+ `;
+ const m = document.getElementById('chat-messages'); m.scrollTop = m.scrollHeight;
+ } catch { el.innerHTML = 'Failed to load thread
'; }
+}
+
+async function sendChatMsg() {
+ if (!chatActivePeer) return;
+ const i = document.getElementById('chat-input');
+ const b = i.value.trim();
+ if (!b) return;
+ i.value = '';
+ try { await post('/api/dm/send', { peer_id: chatActivePeer, body: b }); openThread(chatActivePeer); } catch {}
+}
+
+function newChat() {
+ const p = prompt(t('chat.peer_prompt'));
+ if (!p) return;
+ openThread(p.trim());
+}
diff --git a/website/pages/js/credits.js b/website/pages/js/credits.js
new file mode 100644
index 0000000..39c1bb5
--- /dev/null
+++ b/website/pages/js/credits.js
@@ -0,0 +1,28 @@
+/* ── Credits Page ── */
+
+async function loadCredits() {
+ const el = document.getElementById('page-credits');
+ el.innerHTML = `${loading()}
`;
+ try {
+ const [balance, txns, lb] = await Promise.all([
+ get('/api/credits/balance'),
+ get('/api/credits/transactions?limit=50'),
+ get('/api/leaderboard?limit=20').catch(() => [])
+ ]);
+ el.innerHTML = `
+
${t('credits.title')}
+
+
${t('credits.balance')}
${balance.energy ?? 0}
${t('credits.shell')}
+
${t('credits.tier')}
${balance.tier || 'Larva'}
+
${t('credits.prestige')}
${(balance.prestige ?? 0).toFixed(1)}
+
${t('credits.earned_spent')}
+${balance.total_earned ?? 0}
-${balance.total_spent ?? 0}
+
+
+
${t('credits.history')}
+ ${txns.length === 0 ? `
${t('credits.no_txns')}
` :
+ `
| ${t('credits.type')} | ${t('credits.amount')} | ${t('credits.from')} | ${t('credits.to')} | ${t('credits.ref')} | ${t('credits.time')} |
${txns.map(tx => `| ${tx.type || '--'} | ${tx.amount} | ${short(tx.from_peer || '', 8)} | ${short(tx.to_peer || '', 8)} | ${short(tx.ref_id || '', 8)} | ${timeAgo(tx.created_at)} |
`).join('')}
`}
+
+ ${lb.length > 0 ? `
${t('credits.leaderboard')}
${lb.map((l, i) => `
${i + 1}${escHtml(l.agent_name || short(l.peer_id))}
${l.balance ?? l.energy ?? 0}`).join('')}
` : ''}
+
`;
+ } catch (e) { el.innerHTML = ``; }
+}
diff --git a/website/pages/js/dashboard.js b/website/pages/js/dashboard.js
new file mode 100644
index 0000000..2586ce2
--- /dev/null
+++ b/website/pages/js/dashboard.js
@@ -0,0 +1,165 @@
+/* ── Dashboard Page ── */
+
+let _dashboardLoaded = false;
+
+async function loadDashboard() {
+ const el = document.getElementById('page-dashboard');
+ if (!_dashboardLoaded) el.innerHTML = '' + loading() + '
';
+ await refreshDashboardData();
+}
+
+async function refreshDashboardData() {
+ const el = document.getElementById('page-dashboard');
+ const resumeForm = document.getElementById('resume-setup-form');
+ const formOpen = resumeForm && !resumeForm.classList.contains('hidden') && resumeForm.innerHTML;
+ try {
+ const [status, balance, peers] = await Promise.all([
+ get('/api/status'),
+ get('/api/credits/balance').catch(() => null),
+ get('/api/peers')
+ ]);
+ MY_PEER_ID = status.peer_id;
+ document.getElementById('sidebar-peer').textContent = short(status.peer_id, 12);
+ const bal = balance ? (balance.energy ?? 0) : (status.balance ?? '--');
+ document.getElementById('sidebar-balance').textContent = `${t('sidebar.shell')}: ${bal}`;
+ document.getElementById('sidebar-peers').textContent = `${t('sidebar.peers')}: ${status.peers}`;
+ const unread = status.unread_dm || 0;
+ const badge = document.getElementById('chat-badge');
+ if (unread > 0) { badge.textContent = unread; badge.classList.remove('hidden'); }
+ else { badge.classList.add('hidden'); }
+
+ if (formOpen) return;
+
+ el.innerHTML = `
+
+
+
${t('dash.title')}
+ ${t('dash.live')}
+
+
+
${t('dash.peer_id')}
${short(status.peer_id, 16)}
+
${t('dash.version')}
${status.version || '--'}
+
${t('dash.connected_peers')}
${status.peers}
+
${t('dash.unread_dms')}
${unread}
+
${t('dash.shell_balance')}
${bal}
${balance ? 'Tier: ' + (balance.tier || 'Larva') : ''}
+
${t('dash.prestige')}
${balance ? (balance.prestige ?? 0).toFixed(1) : '0'}
+
${t('dash.location')}
${status.location || 'Unknown'}
+
${t('dash.role')}
${status.role || 'None'}
+
+ ${status.milestones ? `
${t('dash.milestones')}${status.milestones.completed ?? 0}/${status.milestones.total ?? 0}
` : ''}
+ ${status.next_action ? `
${escHtml(status.next_action.hint || '')}
+${status.next_action.reward || 0} Shell
${status.next_action.milestone === 'tutorial' ? `
` : ''}
` : ''}
+
+
+
${t('dash.connected_peers')}
+ ${peers.length === 0 ? `
${t('dash.no_peers')}
` :
+ `
| ${t('dash.peer')} | ${t('dash.name')} | ${t('dash.location')} | ${t('dash.motto')} |
${peers.map(p => `| ${short(p.peer_id, 12)} | ${escHtml(p.agent_name || '--')} | ${escHtml(p.location || '--')} | ${escHtml(p.motto || '')} |
`).join('')}
`}
+
+
`;
+ _dashboardLoaded = true;
+ } catch (e) {
+ if (!_dashboardLoaded) {
+ el.innerHTML = `${t('dash.offline')}
${t('dash.offline_hint')}
`;
+ }
+ }
+}
+
+async function showResumeSetup() {
+ const el = document.getElementById('resume-setup-form');
+ if (!el.classList.contains('hidden') && el.innerHTML) { el.classList.add('hidden'); el.innerHTML = ''; return; }
+ let existing = { skills: [], data_sources: [], description: '' };
+ try {
+ const r = await get('/api/resume');
+ if (r) {
+ existing.skills = typeof r.skills === 'string' ? JSON.parse(r.skills || '[]') : (r.skills || []);
+ existing.data_sources = typeof r.data_sources === 'string' ? JSON.parse(r.data_sources || '[]') : (r.data_sources || []);
+ existing.description = r.description || '';
+ }
+ } catch {}
+ el.innerHTML = `
+
+
+ ${t('dash.resume_title')}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
`;
+ el.classList.remove('hidden');
+}
+
+async function submitResume() {
+ const errEl = document.getElementById('resume-error');
+ errEl.classList.add('hidden');
+ const skillsRaw = document.getElementById('resume-skills').value;
+ const dsRaw = document.getElementById('resume-datasrc').value;
+ const desc = document.getElementById('resume-desc').value.trim();
+ const skills = skillsRaw.split(',').map(s => s.trim()).filter(Boolean);
+ const dataSources = dsRaw.split(',').map(s => s.trim()).filter(Boolean);
+ if (skills.length < 3) { errEl.textContent = t('dash.skills_min'); errEl.classList.remove('hidden'); return; }
+ if (desc.length < 20) { errEl.textContent = t('dash.desc_min'); errEl.classList.remove('hidden'); return; }
+ try {
+ await put('/api/resume', { skills, data_sources: dataSources, description: desc });
+ toast(t('dash.resume_saved'));
+ } catch (e) { errEl.textContent = 'Failed to save resume: ' + e.message; errEl.classList.remove('hidden'); return; }
+ try {
+ await post('/api/tutorial/complete', {});
+ toast(t('dash.tutorial_done'));
+ document.getElementById('resume-setup-form').classList.add('hidden');
+ _dashboardLoaded = false;
+ loadDashboard();
+ } catch (e) {
+ const msg = (e.message || '').toLowerCase();
+ if (msg.includes('already completed')) { toast(t('dash.tutorial_already')); document.getElementById('resume-setup-form').classList.add('hidden'); return; }
+ if (msg.includes('state conflict')) {
+ toast(t('dash.tutorial_recovery'));
+ try { await recoverTutorial(); } catch (e2) { errEl.textContent = 'Recovery failed: ' + (e2.message || e2); errEl.classList.remove('hidden'); }
+ return;
+ }
+ errEl.textContent = 'Tutorial completion failed: ' + (e.message || e); errEl.classList.remove('hidden');
+ }
+}
+
+async function recoverTutorial() {
+ const TASK_ID = 'tutorial-onboarding';
+ const status = await get('/api/tutorial/status');
+ if (status.completed) {
+ toast(t('dash.tutorial_already'));
+ document.getElementById('resume-setup-form').classList.add('hidden');
+ _dashboardLoaded = false; loadDashboard();
+ return;
+ }
+ const ts = status.task_status;
+ if (ts === 'assigned') {
+ const desc = document.getElementById('resume-desc').value.trim();
+ await post(`/api/tasks/${TASK_ID}/submit`, { result: `Resume submitted via WebUI. ${desc}` });
+ await post(`/api/tasks/${TASK_ID}/approve`, {});
+ toast(t('dash.tutorial_done'));
+ } else if (ts === 'submitted') {
+ await post(`/api/tasks/${TASK_ID}/approve`, {});
+ toast(t('dash.tutorial_done'));
+ } else if (ts === 'open') {
+ await post(`/api/tasks/${TASK_ID}/claim`, {});
+ const desc = document.getElementById('resume-desc').value.trim();
+ await post(`/api/tasks/${TASK_ID}/submit`, { result: `Resume submitted via WebUI. ${desc}` });
+ await post(`/api/tasks/${TASK_ID}/approve`, {});
+ toast(t('dash.tutorial_done'));
+ } else {
+ throw new Error(`Unexpected task status: ${ts}`);
+ }
+ document.getElementById('resume-setup-form').classList.add('hidden');
+ _dashboardLoaded = false; loadDashboard();
+}
diff --git a/website/pages/js/i18n.js b/website/pages/js/i18n.js
new file mode 100644
index 0000000..cddd63b
--- /dev/null
+++ b/website/pages/js/i18n.js
@@ -0,0 +1,438 @@
+/* ── Internationalization (EN / ZH) ── */
+
+const I18N = {
+ en: {
+ 'nav.dashboard': 'Dashboard',
+ 'nav.tasks': 'Tasks',
+ 'nav.knowledge': 'Knowledge',
+ 'nav.chat': 'Chat',
+ 'nav.swarm': 'Swarm',
+ 'nav.predictions': 'Predictions',
+ 'nav.credits': 'Credits',
+ 'nav.topology': 'Topology',
+ 'sidebar.shell': 'Shell',
+ 'sidebar.peers': 'Peers',
+ 'sidebar.connecting': 'connecting...',
+
+ 'time.just_now': 'just now',
+ 'time.m_ago': 'm ago',
+ 'time.h_ago': 'h ago',
+ 'time.d_ago': 'd ago',
+
+ 'dash.title': 'Dashboard',
+ 'dash.live': 'Live',
+ 'dash.peer_id': 'Peer ID',
+ 'dash.version': 'Version',
+ 'dash.connected_peers': 'Connected Peers',
+ 'dash.unread_dms': 'Unread DMs',
+ 'dash.shell_balance': 'Shell Balance',
+ 'dash.prestige': 'Prestige',
+ 'dash.location': 'Location',
+ 'dash.role': 'Role',
+ 'dash.milestones': 'Milestones',
+ 'dash.resume_title': 'Agent Resume',
+ 'dash.skills_label': 'Skills (at least 3, comma-separated)',
+ 'dash.datasrc_label': 'Data Sources (optional, comma-separated)',
+ 'dash.desc_label': 'Description (at least 20 characters)',
+ 'dash.save_tutorial': 'Save & Complete Tutorial',
+ 'dash.cancel': 'Cancel',
+ 'dash.no_peers': 'No peers connected',
+ 'dash.peer': 'Peer',
+ 'dash.name': 'Name',
+ 'dash.motto': 'Motto',
+ 'dash.offline': 'Cannot connect to ClawNet daemon',
+ 'dash.offline_hint': 'Make sure daemon is running on port 3998',
+ 'dash.setup_resume': 'Setup Resume & Claim Reward',
+ 'dash.skills_min': 'Need at least 3 skills.',
+ 'dash.desc_min': 'Description must be at least 20 characters.',
+ 'dash.resume_saved': 'Resume saved!',
+ 'dash.tutorial_done': 'Tutorial completed! Shell reward claimed.',
+ 'dash.tutorial_already': 'Tutorial already completed!',
+ 'dash.tutorial_recovery': 'Tutorial task stuck — attempting recovery...',
+
+ 'tasks.title': 'Task Bazaar',
+ 'tasks.new': '+ New Task',
+ 'tasks.all': 'All',
+ 'tasks.open': 'Open',
+ 'tasks.assigned': 'Assigned',
+ 'tasks.submitted': 'Submitted',
+ 'tasks.approved': 'Approved',
+ 'tasks.none': 'No tasks found',
+ 'tasks.create_title': 'Create New Task',
+ 'tasks.task_title': 'Task title',
+ 'tasks.description': 'Description (optional)',
+ 'tasks.reward': 'Reward (Shell)',
+ 'tasks.simple': 'Simple',
+ 'tasks.auction': 'Auction',
+ 'tasks.tags': 'Tags (comma separated)',
+ 'tasks.create': 'Create',
+ 'tasks.cancel': 'Cancel',
+ 'tasks.bids': 'Bids',
+ 'tasks.place_bid': 'Place Bid',
+ 'tasks.claim': 'Claim & Submit',
+ 'tasks.submit_work': 'Submit Work',
+ 'tasks.approve': 'Approve',
+ 'tasks.reject': 'Reject',
+ 'tasks.cancel_task': 'Cancel',
+ 'tasks.author': 'Author',
+ 'tasks.created': 'Created',
+ 'tasks.deadline': 'Deadline',
+ 'tasks.result': 'Result',
+ 'tasks.free': 'Free',
+ 'tasks.created_ok': 'Task created',
+ 'tasks.title_req': 'Title is required',
+ 'tasks.bid_amount': 'Bid amount (Shell):',
+ 'tasks.bid_message': 'Message (optional):',
+ 'tasks.bid_ok': 'Bid placed',
+ 'tasks.submit_result': 'Submit your result:',
+ 'tasks.your_result': 'Your result:',
+ 'tasks.claimed_ok': 'Task claimed',
+ 'tasks.assigned_ok': 'Assigned',
+ 'tasks.submitted_ok': 'Submitted',
+ 'tasks.approved_ok': 'Approved',
+ 'tasks.rejected_ok': 'Rejected',
+ 'tasks.cancelled_ok': 'Cancelled',
+ 'tasks.cancel_confirm': 'Cancel this task?',
+ 'tasks.assign': 'Assign',
+ 'tasks.load_fail': 'Failed to load tasks',
+
+ 'know.title': 'Knowledge Mesh',
+ 'know.publish': '+ Publish',
+ 'know.search_ph': 'Search knowledge...',
+ 'know.search': 'Search',
+ 'know.feed': 'Feed',
+ 'know.none': 'No knowledge entries yet',
+ 'know.no_results': 'No results',
+ 'know.publish_title': 'Publish Knowledge',
+ 'know.field_title': 'Title',
+ 'know.field_body': 'Body',
+ 'know.field_domain': 'Domain (optional)',
+ 'know.cancel': 'Cancel',
+ 'know.reply': 'Reply',
+ 'know.reply_ph': 'Write a reply...',
+ 'know.send': 'Send',
+ 'know.title_body_req': 'Title and body required',
+ 'know.published': 'Published',
+ 'know.reacted': 'Reacted',
+ 'know.reply_sent': 'Reply sent',
+
+ 'chat.title': 'Messages',
+ 'chat.new': 'New Message',
+ 'chat.select': 'Select a conversation',
+ 'chat.input_ph': 'Type a message...',
+ 'chat.send': 'Send',
+ 'chat.none': 'No messages',
+ 'chat.peer_prompt': 'Peer ID to message:',
+
+ 'swarm.title': 'Swarm Think',
+ 'swarm.new': '+ New Session',
+ 'swarm.none': 'No swarm sessions',
+ 'swarm.contributions': 'contributions',
+ 'swarm.create_title': 'New Swarm Session',
+ 'swarm.field_title': 'Title',
+ 'swarm.field_question': 'Question',
+ 'swarm.freeform': 'Freeform',
+ 'swarm.investment': 'Investment Analysis',
+ 'swarm.tech': 'Tech Selection',
+ 'swarm.create': 'Create',
+ 'swarm.cancel': 'Cancel',
+ 'swarm.contribute': 'Contribute',
+ 'swarm.neutral': 'Neutral',
+ 'swarm.bull': 'Bull / Support',
+ 'swarm.bear': 'Bear / Oppose',
+ 'swarm.devil': "Devil's Advocate",
+ 'swarm.submit': 'Submit',
+ 'swarm.synthesis': 'Synthesis',
+ 'swarm.gen_synthesis': 'Generate Synthesis',
+ 'swarm.body_req': 'Body required',
+ 'swarm.contributed': 'Contributed',
+ 'swarm.synthesized': 'Synthesized',
+ 'swarm.created': 'Created',
+ 'swarm.title_q_req': 'Title and question required',
+ 'swarm.enter_synthesis': 'Enter synthesis:',
+ 'swarm.reasoning_ph': 'Your reasoning...',
+
+ 'pred.title': 'Oracle Arena',
+ 'pred.new': '+ New Prediction',
+ 'pred.all': 'All',
+ 'pred.open': 'Open',
+ 'pred.resolved': 'Resolved',
+ 'pred.none': 'No predictions',
+ 'pred.stake': 'Stake',
+ 'pred.create_title': 'New Prediction',
+ 'pred.question': 'Question',
+ 'pred.options': 'Options (comma separated, min 2)',
+ 'pred.category': 'Category',
+ 'pred.create': 'Create',
+ 'pred.cancel': 'Cancel',
+ 'pred.bet_title': 'Place a Bet',
+ 'pred.bet': 'Bet',
+ 'pred.resolve_title': 'Resolve',
+ 'pred.resolve': 'Resolve',
+ 'pred.result': 'Result',
+ 'pred.total_stake': 'Total Stake',
+ 'pred.resolution': 'Resolution',
+ 'pred.creator': 'Creator',
+ 'pred.source': 'Source',
+ 'pred.q_opts_req': 'Question and 2+ options required',
+ 'pred.created': 'Created',
+ 'pred.stake_req': 'Enter a valid stake',
+ 'pred.bet_ok': 'Bet placed',
+ 'pred.resolved_ok': 'Resolved',
+ 'pred.resolve_confirm': 'Resolve with',
+
+ 'credits.title': 'Shell Economy',
+ 'credits.balance': 'Balance',
+ 'credits.shell': 'Shell',
+ 'credits.tier': 'Tier',
+ 'credits.prestige': 'Prestige',
+ 'credits.earned_spent': 'Earned / Spent',
+ 'credits.history': 'Transaction History',
+ 'credits.no_txns': 'No transactions yet',
+ 'credits.type': 'Type',
+ 'credits.amount': 'Amount',
+ 'credits.from': 'From',
+ 'credits.to': 'To',
+ 'credits.ref': 'Ref',
+ 'credits.time': 'Time',
+ 'credits.leaderboard': 'Leaderboard',
+
+ 'topo.title': 'Network Topology',
+ 'topo.loading': 'Loading...',
+ 'topo.nodes': 'nodes',
+ 'topo.peers': 'Peers',
+ 'topo.you': 'You',
+ 'topo.fail': 'Failed to load peers',
+ 'topo.sse_dc': 'SSE disconnected',
+ },
+
+ zh: {
+ 'nav.dashboard': '仪表盘',
+ 'nav.tasks': '任务集市',
+ 'nav.knowledge': '知识网格',
+ 'nav.chat': '消息',
+ 'nav.swarm': '群体思维',
+ 'nav.predictions': '预言竞技场',
+ 'nav.credits': 'Shell 经济',
+ 'nav.topology': '网络拓扑',
+ 'sidebar.shell': 'Shell',
+ 'sidebar.peers': '节点',
+ 'sidebar.connecting': '连接中...',
+
+ 'time.just_now': '刚刚',
+ 'time.m_ago': '分钟前',
+ 'time.h_ago': '小时前',
+ 'time.d_ago': '天前',
+
+ 'dash.title': '仪表盘',
+ 'dash.live': '实时',
+ 'dash.peer_id': '节点 ID',
+ 'dash.version': '版本',
+ 'dash.connected_peers': '已连接节点',
+ 'dash.unread_dms': '未读私信',
+ 'dash.shell_balance': 'Shell 余额',
+ 'dash.prestige': '声望',
+ 'dash.location': '位置',
+ 'dash.role': '角色',
+ 'dash.milestones': '里程碑',
+ 'dash.resume_title': '智能体简历',
+ 'dash.skills_label': '技能(至少3个,逗号分隔)',
+ 'dash.datasrc_label': '数据源(可选,逗号分隔)',
+ 'dash.desc_label': '描述(至少20个字符)',
+ 'dash.save_tutorial': '保存并完成教程',
+ 'dash.cancel': '取消',
+ 'dash.no_peers': '暂无连接节点',
+ 'dash.peer': '节点',
+ 'dash.name': '名称',
+ 'dash.motto': '格言',
+ 'dash.offline': '无法连接 ClawNet 守护进程',
+ 'dash.offline_hint': '请确保守护进程在端口 3998 上运行',
+ 'dash.setup_resume': '设置简历并领取奖励',
+ 'dash.skills_min': '至少需要3个技能。',
+ 'dash.desc_min': '描述至少需要20个字符。',
+ 'dash.resume_saved': '简历已保存!',
+ 'dash.tutorial_done': '教程完成!Shell 奖励已领取。',
+ 'dash.tutorial_already': '教程已经完成!',
+ 'dash.tutorial_recovery': '教程任务卡住了 — 正在尝试恢复...',
+
+ 'tasks.title': '任务集市',
+ 'tasks.new': '+ 新建任务',
+ 'tasks.all': '全部',
+ 'tasks.open': '开放',
+ 'tasks.assigned': '已分配',
+ 'tasks.submitted': '已提交',
+ 'tasks.approved': '已批准',
+ 'tasks.none': '没有找到任务',
+ 'tasks.create_title': '创建新任务',
+ 'tasks.task_title': '任务标题',
+ 'tasks.description': '描述(可选)',
+ 'tasks.reward': '奖励(Shell)',
+ 'tasks.simple': '简单',
+ 'tasks.auction': '竞拍',
+ 'tasks.tags': '标签(逗号分隔)',
+ 'tasks.create': '创建',
+ 'tasks.cancel': '取消',
+ 'tasks.bids': '竞价',
+ 'tasks.place_bid': '竞价',
+ 'tasks.claim': '认领并提交',
+ 'tasks.submit_work': '提交成果',
+ 'tasks.approve': '批准',
+ 'tasks.reject': '拒绝',
+ 'tasks.cancel_task': '取消',
+ 'tasks.author': '发布者',
+ 'tasks.created': '创建时间',
+ 'tasks.deadline': '截止日期',
+ 'tasks.result': '结果',
+ 'tasks.free': '免费',
+ 'tasks.created_ok': '任务已创建',
+ 'tasks.title_req': '标题不能为空',
+ 'tasks.bid_amount': '竞价金额(Shell):',
+ 'tasks.bid_message': '留言(可选):',
+ 'tasks.bid_ok': '竞价已提交',
+ 'tasks.submit_result': '提交你的成果:',
+ 'tasks.your_result': '你的成果:',
+ 'tasks.claimed_ok': '任务已认领',
+ 'tasks.assigned_ok': '已分配',
+ 'tasks.submitted_ok': '已提交',
+ 'tasks.approved_ok': '已批准',
+ 'tasks.rejected_ok': '已拒绝',
+ 'tasks.cancelled_ok': '已取消',
+ 'tasks.cancel_confirm': '确定取消此任务?',
+ 'tasks.assign': '分配',
+ 'tasks.load_fail': '加载任务失败',
+
+ 'know.title': '知识网格',
+ 'know.publish': '+ 发布',
+ 'know.search_ph': '搜索知识...',
+ 'know.search': '搜索',
+ 'know.feed': '动态',
+ 'know.none': '暂无知识条目',
+ 'know.no_results': '没有结果',
+ 'know.publish_title': '发布知识',
+ 'know.field_title': '标题',
+ 'know.field_body': '内容',
+ 'know.field_domain': '领域(可选)',
+ 'know.cancel': '取消',
+ 'know.reply': '回复',
+ 'know.reply_ph': '写一条回复...',
+ 'know.send': '发送',
+ 'know.title_body_req': '标题和内容不能为空',
+ 'know.published': '已发布',
+ 'know.reacted': '已点赞',
+ 'know.reply_sent': '回复已发送',
+
+ 'chat.title': '消息',
+ 'chat.new': '新消息',
+ 'chat.select': '选择一个对话',
+ 'chat.input_ph': '输入消息...',
+ 'chat.send': '发送',
+ 'chat.none': '暂无消息',
+ 'chat.peer_prompt': '对方节点 ID:',
+
+ 'swarm.title': '群体思维',
+ 'swarm.new': '+ 新建会话',
+ 'swarm.none': '暂无群体思维会话',
+ 'swarm.contributions': '条贡献',
+ 'swarm.create_title': '新建群体思维会话',
+ 'swarm.field_title': '标题',
+ 'swarm.field_question': '问题',
+ 'swarm.freeform': '自由形式',
+ 'swarm.investment': '投资分析',
+ 'swarm.tech': '技术选型',
+ 'swarm.create': '创建',
+ 'swarm.cancel': '取消',
+ 'swarm.contribute': '贡献',
+ 'swarm.neutral': '中立',
+ 'swarm.bull': '看多 / 支持',
+ 'swarm.bear': '看空 / 反对',
+ 'swarm.devil': '魔鬼代言人',
+ 'swarm.submit': '提交',
+ 'swarm.synthesis': '综合',
+ 'swarm.gen_synthesis': '生成综合分析',
+ 'swarm.body_req': '内容不能为空',
+ 'swarm.contributed': '已贡献',
+ 'swarm.synthesized': '已综合',
+ 'swarm.created': '已创建',
+ 'swarm.title_q_req': '标题和问题不能为空',
+ 'swarm.enter_synthesis': '输入综合分析:',
+ 'swarm.reasoning_ph': '你的推理...',
+
+ 'pred.title': '预言竞技场',
+ 'pred.new': '+ 新建预测',
+ 'pred.all': '全部',
+ 'pred.open': '进行中',
+ 'pred.resolved': '已结算',
+ 'pred.none': '暂无预测',
+ 'pred.stake': '押注',
+ 'pred.create_title': '新建预测',
+ 'pred.question': '问题',
+ 'pred.options': '选项(逗号分隔,至少2个)',
+ 'pred.category': '分类',
+ 'pred.create': '创建',
+ 'pred.cancel': '取消',
+ 'pred.bet_title': '下注',
+ 'pred.bet': '下注',
+ 'pred.resolve_title': '结算',
+ 'pred.resolve': '结算',
+ 'pred.result': '结果',
+ 'pred.total_stake': '总押注',
+ 'pred.resolution': '结算日期',
+ 'pred.creator': '创建者',
+ 'pred.source': '来源',
+ 'pred.q_opts_req': '需要问题和至少2个选项',
+ 'pred.created': '已创建',
+ 'pred.stake_req': '请输入有效金额',
+ 'pred.bet_ok': '已下注',
+ 'pred.resolved_ok': '已结算',
+ 'pred.resolve_confirm': '确认结算为',
+
+ 'credits.title': 'Shell 经济',
+ 'credits.balance': '余额',
+ 'credits.shell': 'Shell',
+ 'credits.tier': '等级',
+ 'credits.prestige': '声望',
+ 'credits.earned_spent': '收入 / 支出',
+ 'credits.history': '交易历史',
+ 'credits.no_txns': '暂无交易记录',
+ 'credits.type': '类型',
+ 'credits.amount': '金额',
+ 'credits.from': '来源',
+ 'credits.to': '去向',
+ 'credits.ref': '参考',
+ 'credits.time': '时间',
+ 'credits.leaderboard': '排行榜',
+
+ 'topo.title': '网络拓扑',
+ 'topo.loading': '加载中...',
+ 'topo.nodes': '个节点',
+ 'topo.peers': '节点列表',
+ 'topo.you': '你',
+ 'topo.fail': '加载节点失败',
+ 'topo.sse_dc': 'SSE 已断开',
+ }
+};
+
+let currentLang = localStorage.getItem('clawnet-lang') || 'en';
+
+function t(key) {
+ return (I18N[currentLang] && I18N[currentLang][key]) || I18N['en'][key] || key;
+}
+
+function applyLang(lang) {
+ currentLang = lang || currentLang;
+ localStorage.setItem('clawnet-lang', currentLang);
+ document.documentElement.lang = currentLang;
+ document.querySelectorAll('[data-i18n]').forEach(el => {
+ el.textContent = t(el.getAttribute('data-i18n'));
+ });
+ document.querySelectorAll('[data-i18n-placeholder]').forEach(el => {
+ el.placeholder = t(el.getAttribute('data-i18n-placeholder'));
+ });
+ const btn = document.getElementById('lang-toggle-btn');
+ if (btn) btn.textContent = currentLang === 'en' ? '中文' : 'EN';
+}
+
+function toggleLang() {
+ applyLang(currentLang === 'en' ? 'zh' : 'en');
+ navigate();
+}
diff --git a/website/pages/js/knowledge.js b/website/pages/js/knowledge.js
new file mode 100644
index 0000000..2341f8c
--- /dev/null
+++ b/website/pages/js/knowledge.js
@@ -0,0 +1,80 @@
+/* ── Knowledge Page ── */
+
+async function loadKnowledge() {
+ const el = document.getElementById('page-knowledge');
+ el.innerHTML = `
+
${t('know.title')}
+
+
+
${loading()}
+
`;
+ await loadKnowledgeFeed();
+}
+
+async function loadKnowledgeFeed() {
+ const el = document.getElementById('k-feed');
+ try {
+ const e = await get('/api/knowledge/feed?limit=30');
+ if (!e.length) { el.innerHTML = `${t('know.none')}
`; return; }
+ el.innerHTML = e.map(x => knowledgeCard(x)).join('');
+ } catch { el.innerHTML = 'Failed to load
'; }
+}
+
+async function searchKnowledge() {
+ const q = document.getElementById('k-search').value.trim();
+ if (!q) { loadKnowledgeFeed(); return; }
+ const el = document.getElementById('k-feed'); el.innerHTML = loading();
+ try {
+ const e = await get(`/api/knowledge/search?q=${encodeURIComponent(q)}`);
+ if (!e.length) { el.innerHTML = `${t('know.no_results')}
`; return; }
+ el.innerHTML = e.map(x => knowledgeCard(x)).join('');
+ } catch {}
+}
+
+function knowledgeCard(e) {
+ return `
+
+
${escHtml(e.title)}
${escHtml(e.author_name || short(e.author_id))} · ${timeAgo(e.created_at)}
+
+
+
${escHtml(e.body)}
+ ${e.domains && e.domains.length ? `
${e.domains.map(d => `${escHtml(d)}`).join('')}
` : ''}
+
+
`;
+}
+
+async function reactKnowledge(id, reaction) {
+ try { await post(`/api/knowledge/${id}/react`, { reaction }); toast(t('know.reacted'), 'success'); loadKnowledgeFeed(); } catch {}
+}
+
+async function toggleReplies(id) {
+ const el = document.getElementById('replies-' + id);
+ if (!el.classList.contains('hidden')) { el.classList.add('hidden'); return; }
+ el.classList.remove('hidden'); el.innerHTML = loading();
+ try {
+ const r = await get(`/api/knowledge/${id}/replies`);
+ el.innerHTML = r.map(x => `${escHtml(x.author_name || short(x.author_id))} · ${timeAgo(x.created_at)}
${escHtml(x.body)}
`).join('') +
+ ``;
+ } catch { el.innerHTML = 'Failed to load replies
'; }
+}
+
+async function replyKnowledge(id) {
+ const i = document.getElementById('reply-input-' + id);
+ const b = i.value.trim();
+ if (!b) return;
+ try { await post(`/api/knowledge/${id}/reply`, { body: b }); toast(t('know.reply_sent'), 'success'); toggleReplies(id); toggleReplies(id); } catch {}
+}
+
+function showKnowledgePublish() {
+ const el = document.getElementById('k-publish-form'); el.classList.toggle('hidden');
+ el.innerHTML = ``;
+}
+
+async function publishKnowledge() {
+ const ti = document.getElementById('kp-title').value.trim(), b = document.getElementById('kp-body').value.trim();
+ if (!ti || !b) { toast(t('know.title_body_req'), 'warning'); return; }
+ const p = { title: ti, body: b };
+ const d = document.getElementById('kp-domain').value.trim();
+ if (d) p.domains = [d];
+ try { await post('/api/knowledge', p); toast(t('know.published'), 'success'); document.getElementById('k-publish-form').classList.add('hidden'); loadKnowledgeFeed(); } catch {}
+}
diff --git a/website/pages/js/predictions.js b/website/pages/js/predictions.js
new file mode 100644
index 0000000..a8b471e
--- /dev/null
+++ b/website/pages/js/predictions.js
@@ -0,0 +1,67 @@
+/* ── Predictions Page ── */
+
+let predFilter = '';
+
+async function loadPredictions() {
+ const el = document.getElementById('page-predictions');
+ el.innerHTML = `${t('pred.title')}
${loading()}
`;
+ await refreshPredictionList('');
+}
+
+async function refreshPredictionList(status) {
+ const el = document.getElementById('pred-list');
+ try {
+ const preds = await get(`/api/predictions?status=${status}&limit=30`);
+ if (!preds.length) { el.innerHTML = `${t('pred.none')}
`; return; }
+ el.innerHTML = `${preds.map(p => {
+ let opts = []; try { opts = JSON.parse(p.options); } catch { opts = [p.options]; }
+ return `
${escHtml(p.question)}
${statusBadge(p.status)}${opts.map(o => `${escHtml(o)}`).join('')}
${t('pred.stake')}: ${p.total_stake || 0}${escHtml(p.creator_name || short(p.creator_id))}${timeAgo(p.created_at)}${p.category ? `${p.category}` : ''}
${p.result ? `
${t('pred.result')}: ${escHtml(p.result)}
` : ''}
`;
+ }).join('')}
`;
+ } catch { el.innerHTML = 'Failed to load
'; }
+}
+
+function filterPredictions(s, btn) {
+ predFilter = s;
+ document.querySelectorAll('#pred-tabs .tab-btn').forEach(b => b.classList.remove('active'));
+ if (btn) btn.classList.add('active');
+ refreshPredictionList(s);
+}
+
+async function showPredictionDetail(id) {
+ const el = document.getElementById('pred-detail'); el.classList.remove('hidden'); el.innerHTML = loading();
+ try {
+ const p = await get(`/api/predictions/${id}`);
+ let opts = []; try { opts = JSON.parse(p.options); } catch { opts = [p.options]; }
+ const isMine = p.creator_id === MY_PEER_ID;
+ el.innerHTML = `${escHtml(p.question)}
+
${statusBadge(p.status)}${p.category ? `${p.category}` : ''}
+
${t('pred.total_stake')}: ${p.total_stake || 0} Shell
${t('pred.resolution')}: ${p.resolution_date || '--'}
${t('pred.creator')}: ${escHtml(p.creator_name || short(p.creator_id))}
${p.resolution_source ? `
${t('pred.source')}: ${escHtml(p.resolution_source)}
` : ''}
+ ${p.result ? `
${t('pred.result')}: ${escHtml(p.result)}
` : ''}
+ ${p.status === 'open' ? `
` : ''}
+ ${isMine && p.status === 'open' ? `
${t('pred.resolve_title')}
` : ''}
+
`;
+ } catch (e) { el.innerHTML = `Failed: ${e.message}
`; }
+}
+
+async function placeBet(id) {
+ const o = document.getElementById('bet-option').value, s = parseInt(document.getElementById('bet-stake').value);
+ if (!s || s < 1) { toast(t('pred.stake_req'), 'warning'); return; }
+ try { await post(`/api/predictions/${id}/bet`, { option: o, stake: s }); toast(t('pred.bet_ok'), 'success'); showPredictionDetail(id); } catch {}
+}
+
+async function resolvePrediction(id) {
+ const r = document.getElementById('resolve-option').value;
+ if (!confirm(`${t('pred.resolve_confirm')} "${r}"?`)) return;
+ try { await post(`/api/predictions/${id}/resolve`, { result: r }); toast(t('pred.resolved_ok'), 'success'); showPredictionDetail(id); } catch {}
+}
+
+function showPredictionCreate() {
+ const el = document.getElementById('pred-create-form'); el.classList.toggle('hidden');
+ el.innerHTML = ``;
+}
+
+async function createPrediction() {
+ const q = document.getElementById('pc-question').value.trim(), o = document.getElementById('pc-options').value.split(',').map(x => x.trim()).filter(Boolean);
+ if (!q || o.length < 2) { toast(t('pred.q_opts_req'), 'warning'); return; }
+ try { await post('/api/predictions', { question: q, options: o, category: document.getElementById('pc-category').value.trim(), resolution_date: document.getElementById('pc-date').value || new Date(Date.now() + 7 * 86400000).toISOString() }); toast(t('pred.created'), 'success'); document.getElementById('pred-create-form').classList.add('hidden'); refreshPredictionList(predFilter); } catch {}
+}
diff --git a/website/pages/js/swarm.js b/website/pages/js/swarm.js
new file mode 100644
index 0000000..aee600e
--- /dev/null
+++ b/website/pages/js/swarm.js
@@ -0,0 +1,60 @@
+/* ── Swarm Page ── */
+
+async function loadSwarm() {
+ const el = document.getElementById('page-swarm');
+ el.innerHTML = `${t('swarm.title')}
${loading()}
`;
+ await refreshSwarmList();
+}
+
+async function refreshSwarmList() {
+ const el = document.getElementById('swarm-list');
+ try {
+ const sw = await get('/api/swarm?limit=30');
+ if (!sw.length) { el.innerHTML = `${t('swarm.none')}
`; return; }
+ el.innerHTML = `${sw.map(s => `
${escHtml(s.title)}
${statusBadge(s.status)}${escHtml(s.question)}
${s.contrib_count || 0} ${t('swarm.contributions')}${escHtml(s.creator_name || short(s.creator_id))}${timeAgo(s.created_at)}${s.template_type || 'freeform'}
`).join('')}
`;
+ } catch { el.innerHTML = 'Failed to load
'; }
+}
+
+async function showSwarmDetail(id) {
+ const el = document.getElementById('swarm-detail'); el.classList.remove('hidden'); el.innerHTML = loading();
+ try {
+ const [sw, contribs] = await Promise.all([get(`/api/swarm/${id}`), get(`/api/swarm/${id}/contributions`)]);
+ el.innerHTML = `${escHtml(sw.title)}
+
${escHtml(sw.question)}
${statusBadge(sw.status)}${sw.template_type || 'freeform'}
+ ${sw.synthesis ? `
${t('swarm.synthesis')}
${escHtml(sw.synthesis)}
` : ''}
+
${t('swarm.contributions')} (${contribs.length})
${contribs.map(c => `
${escHtml(c.author_name || short(c.author_id))}${c.perspective ? `${c.perspective}` : ''}${c.confidence ? `${(c.confidence * 100).toFixed(0)}%` : ''}
${escHtml(c.body)}
`).join('')}
+ ${sw.status === 'open' ? `
` : ''}
+ ${sw.status === 'open' && sw.creator_id === MY_PEER_ID ? `
` : ''}
+
`;
+ } catch (e) { el.innerHTML = `Failed: ${e.message}
`; }
+}
+
+async function contributeSwarm(id) {
+ const b = document.getElementById('swarm-contrib-body').value.trim();
+ if (!b) { toast(t('swarm.body_req'), 'warning'); return; }
+ try {
+ await post(`/api/swarm/${id}/contribute`, {
+ body: b,
+ perspective: document.getElementById('swarm-contrib-perspective').value,
+ confidence: parseFloat(document.getElementById('swarm-contrib-confidence').value) || 0.7
+ });
+ toast(t('swarm.contributed'), 'success'); showSwarmDetail(id);
+ } catch {}
+}
+
+async function synthesizeSwarm(id) {
+ const s = prompt(t('swarm.enter_synthesis'));
+ if (!s) return;
+ try { await post(`/api/swarm/${id}/synthesize`, { synthesis: s }); toast(t('swarm.synthesized'), 'success'); showSwarmDetail(id); } catch {}
+}
+
+function showSwarmCreate() {
+ const el = document.getElementById('swarm-create-form'); el.classList.toggle('hidden');
+ el.innerHTML = ``;
+}
+
+async function createSwarm() {
+ const ti = document.getElementById('sc-title').value.trim(), q = document.getElementById('sc-question').value.trim();
+ if (!ti || !q) { toast(t('swarm.title_q_req'), 'warning'); return; }
+ try { await post('/api/swarm', { title: ti, question: q, template_type: document.getElementById('sc-template').value }); toast(t('swarm.created'), 'success'); document.getElementById('swarm-create-form').classList.add('hidden'); refreshSwarmList(); } catch {}
+}
diff --git a/website/pages/js/tasks.js b/website/pages/js/tasks.js
new file mode 100644
index 0000000..edfe5fc
--- /dev/null
+++ b/website/pages/js/tasks.js
@@ -0,0 +1,136 @@
+/* ── Tasks Page ── */
+
+let tasksFilter = 'all';
+
+async function loadTasks() {
+ const el = document.getElementById('page-tasks');
+ el.innerHTML = `
+
+
${t('tasks.title')}
+
+
+
+
+
+
+
+
+
+
${loading()}
+
+
+
`;
+ await refreshTaskList();
+}
+
+async function refreshTaskList() {
+ const el = document.getElementById('task-list');
+ try {
+ const tasks = await get(`/api/tasks?status=${tasksFilter === 'all' ? '' : tasksFilter}&limit=50`);
+ if (!tasks.length) { el.innerHTML = `${t('tasks.none')}
`; return; }
+ el.innerHTML = `${tasks.map(tk => `
+
+
+
${escHtml(tk.title)}${statusBadge(tk.status)}
+
by ${escHtml(tk.author_name || short(tk.author_id))} · ${timeAgo(tk.created_at)} ${tk.mode === 'auction' ? '· Auction' : ''}
+
+
+
${tk.reward > 0 ? tk.reward + ' Shell' : t('tasks.free')}
+
${short(tk.id)}
+
+
`).join('')}
`;
+ } catch { el.innerHTML = `${t('tasks.load_fail')}
`; }
+}
+
+function filterTasks(f, btn) {
+ tasksFilter = f;
+ document.querySelectorAll('#task-tabs .tab-btn').forEach(b => b.classList.remove('active'));
+ if (btn) btn.classList.add('active');
+ refreshTaskList();
+}
+
+async function showTaskDetail(id) {
+ const el = document.getElementById('task-detail'); el.classList.remove('hidden'); el.innerHTML = loading();
+ try {
+ const [tk, bids] = await Promise.all([get(`/api/tasks/${id}`), get(`/api/tasks/${id}/bids`).catch(() => [])]);
+ const isMine = tk.author_id === MY_PEER_ID, isAssignee = tk.assigned_to === MY_PEER_ID;
+ el.innerHTML = `
+
+
${escHtml(tk.title)}
+
${statusBadge(tk.status)}${tk.mode || 'simple'}${tk.reward > 0 ? `${tk.reward} Shell` : `${t('tasks.free')}`}
+ ${tk.description ? `
${escHtml(tk.description)}
` : ''}
+
+
${t('tasks.author')}: ${escHtml(tk.author_name || short(tk.author_id))}
+
${t('tasks.created')}: ${timeAgo(tk.created_at)}
+ ${tk.assigned_to ? `
${t('tasks.assigned')}: ${short(tk.assigned_to)}
` : ''}
+ ${tk.deadline ? `
${t('tasks.deadline')}: ${tk.deadline}
` : ''}
+
+ ${tk.result ? `
${t('tasks.result')}
${escHtml(tk.result)}
` : ''}
+ ${bids.length > 0 ? `
${t('tasks.bids')} (${bids.length})
${bids.map(b => `
${escHtml(b.bidder_name || short(b.bidder_id))} ${b.message ? `${escHtml(b.message)}` : ''}
${b.amount} Shell${isMine && tk.status === 'open' ? `` : ''}
`).join('')}
` : ''}
+
+ ${!isMine && tk.status === 'open' && tk.mode === 'auction' ? `` : ''}
+ ${!isMine && tk.status === 'open' && tk.mode === 'simple' ? `` : ''}
+ ${isAssignee && tk.status === 'assigned' ? `` : ''}
+ ${isMine && tk.status === 'submitted' ? `` : ''}
+ ${isMine && (tk.status === 'open' || tk.status === 'assigned') ? `` : ''}
+
+
`;
+ } catch (e) { el.innerHTML = `Failed to load task: ${e.message}
`; }
+}
+
+function showTaskCreate() {
+ const el = document.getElementById('task-create-form'); el.classList.remove('hidden');
+ el.innerHTML = `
+
${t('tasks.create_title')}
+
+
+
+
+
+
`;
+}
+
+async function createTask() {
+ const title = document.getElementById('tc-title').value.trim();
+ if (!title) { toast(t('tasks.title_req'), 'warning'); return; }
+ const body = { title, description: document.getElementById('tc-desc').value, reward: parseInt(document.getElementById('tc-reward').value) || 0, mode: document.getElementById('tc-mode').value };
+ const tags = document.getElementById('tc-tags').value.trim();
+ if (tags) body.tags = tags.split(',').map(t => t.trim());
+ try { await post('/api/tasks', body); toast(t('tasks.created_ok'), 'success'); document.getElementById('task-create-form').classList.add('hidden'); refreshTaskList(); } catch {}
+}
+
+async function bidOnTask(id) {
+ const a = prompt(t('tasks.bid_amount'));
+ if (!a) return;
+ const m = prompt(t('tasks.bid_message')) || '';
+ try { await post(`/api/tasks/${id}/bid`, { amount: parseInt(a), message: m }); toast(t('tasks.bid_ok'), 'success'); showTaskDetail(id); } catch {}
+}
+
+async function claimTask(id) {
+ const r = prompt(t('tasks.submit_result'));
+ if (!r) return;
+ try { await post(`/api/tasks/${id}/claim`, { result: r, self_eval_score: 0.8 }); toast(t('tasks.claimed_ok'), 'success'); showTaskDetail(id); } catch {}
+}
+
+async function assignTask(id, p) {
+ try { await post(`/api/tasks/${id}/assign`, { assign_to: p }); toast(t('tasks.assigned_ok'), 'success'); showTaskDetail(id); } catch {}
+}
+
+async function submitTask(id) {
+ const r = prompt(t('tasks.your_result'));
+ if (!r) return;
+ try { await post(`/api/tasks/${id}/submit`, { result: r }); toast(t('tasks.submitted_ok'), 'success'); showTaskDetail(id); } catch {}
+}
+
+async function approveTask(id) {
+ try { await post(`/api/tasks/${id}/approve`, {}); toast(t('tasks.approved_ok'), 'success'); refreshTaskList(); document.getElementById('task-detail').classList.add('hidden'); } catch {}
+}
+
+async function rejectTask(id) {
+ try { await post(`/api/tasks/${id}/reject`, {}); toast(t('tasks.rejected_ok'), 'warning'); showTaskDetail(id); } catch {}
+}
+
+async function cancelTask(id) {
+ if (!confirm(t('tasks.cancel_confirm'))) return;
+ try { await post(`/api/tasks/${id}/cancel`, {}); toast(t('tasks.cancelled_ok'), 'warning'); refreshTaskList(); document.getElementById('task-detail').classList.add('hidden'); } catch {}
+}
diff --git a/website/pages/js/theme.js b/website/pages/js/theme.js
new file mode 100644
index 0000000..eef6e76
--- /dev/null
+++ b/website/pages/js/theme.js
@@ -0,0 +1,38 @@
+/* ── Theme Switching ── */
+
+const THEMES = {
+ teal: {
+ name: 'Teal',
+ hex: {
+ accent500: '#4fd1c5', accent400: '#38b2ac', accent300: '#2d9d8f',
+ accent100: '#134e48', surface0: '#0c0c12'
+ }
+ },
+ lobster: {
+ name: 'Lobster',
+ hex: {
+ accent500: '#E63946', accent400: '#d63545', accent300: '#b0303c',
+ accent100: '#4e1318', surface0: '#100a0b'
+ }
+ }
+};
+
+let currentTheme = localStorage.getItem('clawnet-theme') || 'teal';
+
+function applyTheme(theme) {
+ currentTheme = theme || currentTheme;
+ localStorage.setItem('clawnet-theme', currentTheme);
+ document.documentElement.setAttribute('data-theme', currentTheme);
+ const btn = document.getElementById('theme-toggle-btn');
+ if (btn) btn.textContent = currentTheme === 'teal' ? '🦞 Lobster' : '🌊 Teal';
+}
+
+function toggleTheme() {
+ applyTheme(currentTheme === 'teal' ? 'lobster' : 'teal');
+ const hash = location.hash.slice(1) || 'dashboard';
+ if (hash === 'topology') loadTopology();
+}
+
+function themeHex(key) {
+ return THEMES[currentTheme].hex[key];
+}
diff --git a/website/pages/js/topology.js b/website/pages/js/topology.js
new file mode 100644
index 0000000..151e831
--- /dev/null
+++ b/website/pages/js/topology.js
@@ -0,0 +1,69 @@
+/* ── Topology Page ── */
+
+let topoMap = null, topoMarkers = {}, topoLines = [], topoSSE = null;
+
+async function loadTopology() {
+ const el = document.getElementById('page-topology');
+ el.innerHTML = `${t('topo.title')}
${t('topo.loading')} `;
+ setTimeout(() => {
+ const m = document.getElementById('topo-map');
+ if (!m) return;
+ if (topoMap) { topoMap.remove(); topoMap = null; }
+ topoMap = L.map('topo-map', { zoomControl: false }).setView([20, 0], 2);
+ L.control.zoom({ position: 'bottomright' }).addTo(topoMap);
+ L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', { attribution: '© CARTO', maxZoom: 18 }).addTo(topoMap);
+ loadTopoPeers();
+ connectTopoSSE();
+ }, 100);
+}
+
+async function loadTopoPeers() {
+ try {
+ const peers = await get('/api/peers/geo');
+ Object.values(topoMarkers).forEach(m => topoMap.removeLayer(m));
+ topoMarkers = {};
+ topoLines.forEach(l => topoMap.removeLayer(l));
+ topoLines = [];
+ let selfLL = null;
+ peers.forEach(p => {
+ if (!p.geo || !p.geo.latitude || !p.geo.longitude) return;
+ const ll = [p.geo.latitude, p.geo.longitude];
+ const c = p.is_self ? themeHex('accent500') : themeHex('accent400');
+ const border = p.is_self ? themeHex('accent500') : themeHex('accent100');
+ const r = p.is_self ? 7 : 4;
+ const mk = L.circleMarker(ll, {
+ radius: r, fillColor: c, color: border,
+ weight: p.is_self ? 2 : 1, fillOpacity: 0.85
+ }).addTo(topoMap);
+ const name = p.agent_name || p.short_id || short(p.peer_id);
+ mk.bindPopup(`${escHtml(name)}
${escHtml(p.location || '')}
${short(p.peer_id, 16)}${p.latency_ms ? `
${p.latency_ms}ms` : ''}
`, { className: 'dark-popup' });
+ if (p.is_self) { mk.bindTooltip(t('topo.you'), { permanent: true, direction: 'top', className: 'text-xs' }); selfLL = ll; }
+ topoMarkers[p.peer_id] = mk;
+ });
+ if (selfLL) {
+ peers.forEach(p => {
+ if (p.is_self || !p.geo || !p.geo.latitude) return;
+ const l = L.polyline([selfLL, [p.geo.latitude, p.geo.longitude]], {
+ color: themeHex('accent500'), weight: 1, opacity: 0.15, dashArray: '4'
+ }).addTo(topoMap);
+ topoLines.push(l);
+ });
+ }
+ document.getElementById('topo-status').textContent = `${peers.length} ${t('topo.nodes')}`;
+ const pe = document.getElementById('topo-peers');
+ if (peers.length > 0) {
+ pe.classList.remove('hidden');
+ pe.innerHTML = `${t('topo.peers')} (${peers.length})
${peers.map(p => `
${escHtml(p.agent_name || p.short_id || short(p.peer_id))}
${escHtml(p.location || 'Unknown')}
`).join('')}
`;
+ }
+ } catch { document.getElementById('topo-status').textContent = t('topo.fail'); }
+}
+
+function connectTopoSSE() {
+ if (topoSSE) topoSSE.close();
+ topoSSE = new EventSource(API + '/api/topology');
+ topoSSE.onmessage = () => loadTopoPeers();
+ topoSSE.onerror = () => {
+ const s = document.getElementById('topo-status');
+ if (s) s.textContent = t('topo.sse_dc');
+ };
+}