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()}
+
+
+
+
${t('chat.select')}
+
+
`; + 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')}

` : + `
${txns.map(tx => ``).join('')}
${t('credits.type')}${t('credits.amount')}${t('credits.from')}${t('credits.to')}${t('credits.ref')}${t('credits.time')}
${tx.type || '--'}${tx.amount}${short(tx.from_peer || '', 8)}${short(tx.to_peer || '', 8)}${short(tx.ref_id || '', 8)}${timeAgo(tx.created_at)}
`} +
+ ${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 = `
Failed: ${e.message}
`; } +} 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')}

` : + `
${peers.map(p => ``).join('')}
${t('dash.peer')}${t('dash.name')}${t('dash.location')}${t('dash.motto')}
${short(p.peer_id, 12)}${escHtml(p.agent_name || '--')}${escHtml(p.location || '--')}${escHtml(p.motto || '')}
`} +
+
`; + _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 = `

${t('know.publish_title')}

`; +} + +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' ? `

${t('pred.bet_title')}

` : ''} + ${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 = `

${t('pred.create_title')}

`; +} + +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' ? `

${t('swarm.contribute')}

` : ''} + ${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 = `

${t('swarm.create_title')}

`; +} + +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'); + }; +}