My Mansion gets a CSS villa scene — dusk sky, glowing/flickering windows, waving flag, garden hedge — with the live services (Showcase, File-mapper, Net-disk) presented as 'chambers of the estate'. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
589 lines
38 KiB
JavaScript
589 lines
38 KiB
JavaScript
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||
import { useAuth, AuthPanel, AdminConsole } from './Auth.jsx';
|
||
import { AgentBrowse, LegionDashboard } from './AgentPlatform.jsx';
|
||
import Mansion from './Mansion.jsx';
|
||
import Account from './Account.jsx';
|
||
import TavernGame from './TavernGame.jsx';
|
||
import { GameDialog } from './GameDialog.jsx';
|
||
import { api } from './api.js';
|
||
|
||
// Inline hamburger + close icons (was lucide-react — dropped for one fewer dep).
|
||
const Menu = ({ size = 20 }) => (<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" aria-hidden="true"><line x1="3" y1="6" x2="21" y2="6" /><line x1="3" y1="12" x2="21" y2="12" /><line x1="3" y1="18" x2="21" y2="18" /></svg>);
|
||
const X = ({ size = 20 }) => (<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" aria-hidden="true"><line x1="18" y1="6" x2="6" y2="18" /><line x1="6" y1="6" x2="18" y2="18" /></svg>);
|
||
|
||
const RELEASE = '2.3.0-villa';
|
||
|
||
const NAV = [
|
||
{ id: 'home', label: 'Tavern' },
|
||
{ id: 'agents', label: 'Browse Agents' },
|
||
{ id: 'mansion', label: 'My Mansion' },
|
||
{ id: 'legion', label: 'My Legion' },
|
||
];
|
||
const VALID_ROUTES = ['home', 'agents', 'mansion', 'legion', 'account', 'admin', 'signin', 'status', 'changelog'];
|
||
const ROUTE_TITLES = {
|
||
home: 'amerc — hire agents like mercenaries', agents: 'Browse Agents · amerc', mansion: 'My Mansion · amerc',
|
||
legion: 'My Legion · amerc', account: 'Account · amerc', signin: 'Sign in · amerc', status: 'System status · amerc',
|
||
changelog: 'Changelog · amerc', admin: 'Quartermaster · amerc',
|
||
};
|
||
|
||
function useHashRoute() {
|
||
const normalize = () => {
|
||
// first path segment is the route; the rest (e.g. #/agents/<slug>) is a sub-path
|
||
let base = window.location.hash.replace(/^#\/?/, '').split('/')[0];
|
||
if (base === 'booth') base = 'legion'; // legacy alias for the renamed route
|
||
return VALID_ROUTES.includes(base) ? base : 'home';
|
||
};
|
||
const [route, setRouteState] = useState(normalize);
|
||
useEffect(() => {
|
||
const onHash = () => setRouteState(normalize());
|
||
window.addEventListener('hashchange', onHash);
|
||
window.addEventListener('popstate', onHash);
|
||
return () => { window.removeEventListener('hashchange', onHash); window.removeEventListener('popstate', onHash); };
|
||
}, []);
|
||
const setRoute = useCallback((next) => {
|
||
const nextHash = `/${next}`;
|
||
if (window.location.hash !== `#${nextHash}`) window.history.pushState(null, '', `${window.location.pathname}${window.location.search}#${nextHash}`);
|
||
setRouteState(next);
|
||
}, []);
|
||
return [route, setRoute];
|
||
}
|
||
|
||
function PixelTopbar({ route, setRoute, auth }) {
|
||
const [open, setOpen] = useState(false);
|
||
const go = (id) => { setRoute(id); setOpen(false); };
|
||
const loggedIn = !!auth?.user;
|
||
const isAdmin = auth?.user?.role === 'admin';
|
||
const nav = (
|
||
<>
|
||
{NAV.map((item) => (
|
||
<button key={item.id} className={`px-nav-btn${route === item.id ? ' active' : ''}`} onClick={() => go(item.id)} type="button">{item.label}</button>
|
||
))}
|
||
<a className="px-nav-btn" href="https://docs.amerc.ai/">Docs</a>
|
||
{isAdmin && <a className="px-nav-btn" href="https://pm.amerc.ai/">PM</a>}
|
||
{isAdmin && <button className={`px-nav-btn quartermaster${route === 'admin' ? ' active' : ''}`} onClick={() => go('admin')} type="button">Quartermaster</button>}
|
||
{loggedIn
|
||
? <>
|
||
<button className={`px-nav-btn px-nav-user${route === 'account' ? ' active' : ''}`} onClick={() => go('account')} type="button" title="Account">{auth.user.handle}</button>
|
||
<button className="px-nav-btn" onClick={() => auth.logout()} type="button" title="Log out">⏻</button>
|
||
</>
|
||
: <button className={`px-nav-btn${route === 'signin' ? ' active' : ''}`} onClick={() => go('signin')} type="button">Login</button>}
|
||
</>
|
||
);
|
||
return (
|
||
<header className="px-topbar">
|
||
<button className="px-logo" onClick={() => go('home')} type="button" title="Home">
|
||
<span className="px-logo-gem" aria-hidden="true" /><strong>amerc</strong><small>agent mercenary tavern</small>
|
||
</button>
|
||
<button className="px-ver" onClick={() => go('changelog')} type="button" title="What's new — changelog">v{RELEASE}</button>
|
||
<nav className="px-nav" aria-label="Primary">{nav}</nav>
|
||
<button className="px-menu" onClick={() => setOpen((v) => !v)} type="button" aria-label="Toggle menu">{open ? <X size={20} /> : <Menu size={20} />}</button>
|
||
{open && <><div className="px-mobile-backdrop" onClick={() => setOpen(false)} aria-hidden="true" /><div className="px-mobile">{nav}</div></>}
|
||
</header>
|
||
);
|
||
}
|
||
|
||
const prefersReducedMotion = () => typeof window !== 'undefined' && window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||
|
||
// Count up from 0 to n with an ease-out curve (delightful stat reveals).
|
||
function Count({ n }) {
|
||
const [v, setV] = useState(prefersReducedMotion() ? n : 0);
|
||
useEffect(() => {
|
||
if (prefersReducedMotion()) { setV(n); return; }
|
||
let raf, start; const dur = 750;
|
||
const tick = (t) => { if (!start) start = t; const p = Math.min(1, (t - start) / dur); setV(Math.round(n * (1 - Math.pow(1 - p, 3)))); if (p < 1) raf = requestAnimationFrame(tick); };
|
||
raf = requestAnimationFrame(tick); return () => cancelAnimationFrame(raf);
|
||
}, [n]);
|
||
return <>{v}</>;
|
||
}
|
||
|
||
// Fade-up as the element scrolls into view; always-visible fallback if no IO / reduced motion.
|
||
function Reveal({ children, className = '', delay = 0, tag: Tag = 'div' }) {
|
||
const ref = useRef(null);
|
||
const [armed] = useState(() => typeof window !== 'undefined' && 'IntersectionObserver' in window && !prefersReducedMotion());
|
||
const [shown, setShown] = useState(false);
|
||
useEffect(() => {
|
||
if (!armed || !ref.current) return;
|
||
const io = new IntersectionObserver((entries) => { entries.forEach((e) => { if (e.isIntersecting) { setShown(true); io.disconnect(); } }); }, { threshold: 0.12, rootMargin: '0px 0px -6% 0px' });
|
||
io.observe(ref.current); return () => io.disconnect();
|
||
}, [armed]);
|
||
return <Tag ref={ref} className={`${className} ${armed ? 'reveal' : ''} ${shown ? 'in' : ''}`.replace(/\s+/g, ' ').trim()} style={delay && armed ? { transitionDelay: `${delay}ms` } : undefined}>{children}</Tag>;
|
||
}
|
||
|
||
function Home({ setRoute, auth }) {
|
||
const [stats, setStats] = useState(null);
|
||
const [dlg, setDlg] = useState(null); // 'tour' | 'login' | null
|
||
useEffect(() => { api('/agents/stats').then(setStats).catch(() => {}); const t = setInterval(() => api('/agents/stats').then(setStats).catch(() => {}), 30000); return () => clearInterval(t); }, []);
|
||
const act = useCallback((a) => {
|
||
if (a === 'tour' || a === 'login') setDlg(a);
|
||
else setRoute(a);
|
||
}, [setRoute]);
|
||
return (
|
||
<div className="gm-home gm-game">
|
||
<div className="gm-stage3"><TavernGame onAction={act} stats={stats} auth={auth} /></div>
|
||
<div className="gm-bottombar">
|
||
<h1 className="gm-tagline">Hire agents like mercenaries<span className="gm-cursor">_</span></h1>
|
||
<p className="gm-tagsub">every fixture in the tavern is a door — walk in, or read the <button className="gm-link" onClick={() => setDlg('tour')}>tour</button></p>
|
||
{stats && (
|
||
<div className="hero-live">
|
||
<span><b><Count n={stats.classes} /></b> classes</span>
|
||
<span><b><Count n={stats.online} /></b> online</span>
|
||
<span><b><Count n={stats.sessions} /></b> sessions</span>
|
||
</div>
|
||
)}
|
||
</div>
|
||
{dlg === 'tour' && (
|
||
<GameDialog title="How amerc works" tag="✦ THE TOUR" wide onClose={() => setDlg(null)}>
|
||
<HomeFeatures setRoute={(r) => { setDlg(null); setRoute(r); }} />
|
||
</GameDialog>
|
||
)}
|
||
{dlg === 'login' && (
|
||
<GameDialog title="Welcome, mercenary" tag="🍺 THE LEDGER" onClose={() => setDlg(null)}>
|
||
<p className="gd-lead">One account opens every door — tavern, docs, PM and git.</p>
|
||
<AuthPanel auth={auth} onDone={() => { setDlg(null); setRoute('legion'); }} />
|
||
</GameDialog>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
const HF_STEPS = [
|
||
{ icon: '🍺', title: 'Browse & hire', body: 'Walk the tavern floor and pick a mercenary by skill and tag. See runtime, online status, and what each agent can do.', cta: ['Browse Agents', 'agents'] },
|
||
{ icon: '🗡️', title: 'Publish & run', body: 'Register your own agent class in My Legion and run instances — your broker brings the runtime. No infrastructure to manage.', cta: ['Open My Legion', 'legion'] },
|
||
{ icon: '🔌', title: 'Deliver anywhere', body: 'Serve users as an embedded chatbox, drive a live web terminal, or expose a local port to the internet via Showcase.', cta: ['My Mansion', 'mansion'] },
|
||
];
|
||
const HF_USES = [
|
||
{ icon: '💬', title: 'Chatbox', body: 'Drop one <script> tag on any site; the agent appears as a chat widget with your page skills injected.' },
|
||
{ icon: '🖥️', title: 'Web terminal', body: 'Open a relayed PTY to the agent’s terminal — watch and drive it right from the browser.' },
|
||
{ icon: '🌐', title: 'Reverse proxy', body: 'Map a local port to your own subdomain — your service goes live on the internet through your broker.' },
|
||
];
|
||
function relTime(at) { const s = Math.floor((Date.now() - at) / 1000); if (s < 60) return `${s}s ago`; const m = Math.floor(s / 60); if (m < 60) return `${m}m ago`; const h = Math.floor(m / 60); if (h < 24) return `${h}h ago`; return `${Math.floor(h / 24)}d ago`; }
|
||
const ACT_ICON = { class: '🗡️', instance: '⚙️', session: '💬', showcase: '🌐' };
|
||
function ActivityFeed() {
|
||
const [items, setItems] = useState([]);
|
||
useEffect(() => { const f = () => api('/agents/activity').then((d) => setItems(d.activity || [])).catch(() => {}); f(); const t = setInterval(f, 15000); return () => clearInterval(t); }, []);
|
||
if (!items.length) return null;
|
||
return (
|
||
<div className="hf-activity">
|
||
<h3 className="hf-sub">Live in the tavern</h3>
|
||
<ul className="act-list">
|
||
{items.map((a, i) => (
|
||
<Reveal tag="li" key={i} delay={Math.min(i, 6) * 50}><span className="act-ico">{ACT_ICON[a.kind] || '•'}</span><span className="act-text">{a.text}</span><span className="act-time">{relTime(a.at)}</span></Reveal>
|
||
))}
|
||
</ul>
|
||
</div>
|
||
);
|
||
}
|
||
const FLOW = [
|
||
{ ico: '💻', title: 'You', sub: 'broker + agent', body: 'Your broker runs the agent on your own machine — codex, claude, any CLI. It dials out over WebSocket, so there are no inbound ports to open.' },
|
||
{ ico: '💎', title: 'amerc edge', sub: 'relay · proxy · auth', body: 'amerc authenticates each session, relays the agent, and proxies traffic — all on the public edge at amerc.ai.' },
|
||
{ ico: '👥', title: 'Your users', sub: 'chatbox · terminal · url', body: 'They reach your agent as an embedded chatbox, a live web terminal, or a public Showcase subdomain.' },
|
||
];
|
||
function FlowDiagram() {
|
||
return (
|
||
<div className="fd">
|
||
{FLOW.map((n, i) => (
|
||
<React.Fragment key={i}>
|
||
<div className="fd-node">
|
||
<span className="fd-ico" aria-hidden="true">{n.ico}</span>
|
||
<strong>{n.title}</strong>
|
||
<span className="fd-sub">{n.sub}</span>
|
||
<p>{n.body}</p>
|
||
</div>
|
||
{i < FLOW.length - 1 && <div className="fd-link" aria-hidden="true"><i /></div>}
|
||
</React.Fragment>
|
||
))}
|
||
</div>
|
||
);
|
||
}
|
||
const DEMO_SCRIPT = [
|
||
{ who: 'user', text: 'Add a CSV export button to the orders table.' },
|
||
{ who: 'agent', text: 'Added an “Export CSV” button to the orders toolbar — it streams /api/orders.csv. Change pushed; your preview is rebuilding now.' },
|
||
{ who: 'user', text: 'Nice. Expose my local dev server so my client can review it.' },
|
||
{ who: 'agent', text: 'Live at demo1.you.amerc.ai over HTTPS — tunneled from localhost:3000 through Showcase. Share the link.' },
|
||
];
|
||
function ChatDemo() {
|
||
const [n, setN] = useState(prefersReducedMotion() ? DEMO_SCRIPT.length : 0);
|
||
const [typing, setTyping] = useState(false);
|
||
useEffect(() => {
|
||
if (prefersReducedMotion()) return;
|
||
const timers = []; let i = 0;
|
||
const at = (fn, ms) => timers.push(setTimeout(fn, ms));
|
||
const next = () => {
|
||
if (i >= DEMO_SCRIPT.length) { at(() => { setN(0); i = 0; next(); }, 4200); return; }
|
||
if (DEMO_SCRIPT[i].who === 'agent') { setTyping(true); at(() => { setTyping(false); i += 1; setN(i); at(next, 1000); }, 1400); }
|
||
else { i += 1; setN(i); at(next, 1200); }
|
||
};
|
||
at(next, 700);
|
||
return () => timers.forEach(clearTimeout);
|
||
}, []);
|
||
return (
|
||
<div className="cd" role="img" aria-label="Preview of an agent chat session">
|
||
<div className="cd-head"><span className="cd-ava" aria-hidden="true">💬</span><div className="cd-head-t"><strong>amerc agent</strong><span className="cd-status"><i />online · preview</span></div></div>
|
||
<div className="cd-body">
|
||
{DEMO_SCRIPT.slice(0, n).map((m, idx) => <div key={idx} className={`cd-msg ${m.who}`}>{m.text}</div>)}
|
||
{typing && <div className="cd-msg agent cd-typing" aria-hidden="true"><span /><span /><span /></div>}
|
||
</div>
|
||
<div className="cd-foot"><span className="cd-input">Ask the agent…</span><span className="cd-send" aria-hidden="true">➤</span></div>
|
||
</div>
|
||
);
|
||
}
|
||
// Live, public, read-only API peek — proves the agent-native claim with real data.
|
||
const PEEK = [
|
||
{ id: 'stats', path: '/agents/stats', label: 'stats' },
|
||
{ id: 'classes', path: '/agents/classes', label: 'roster' },
|
||
{ id: 'activity', path: '/agents/activity', label: 'activity' },
|
||
];
|
||
const jsonEsc = (s) => s.replace(/[&<>]/g, (c) => ({ '&': '&', '<': '<', '>': '>' }[c]));
|
||
function jsonHl(obj) {
|
||
if (obj == null) return '';
|
||
const json = JSON.stringify(obj, null, 2);
|
||
return jsonEsc(json).replace(
|
||
/("(?:\\.|[^"\\])*")(?=\s*:)|("(?:\\.|[^"\\])*")|\b(true|false|null)\b|(-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?)/g,
|
||
(m, key, str, lit, num) => {
|
||
if (key) return `<span class="j-key">${key}</span>`;
|
||
if (str) return `<span class="j-str">${str}</span>`;
|
||
if (lit) return `<span class="j-lit">${lit}</span>`;
|
||
return `<span class="j-num">${num}</span>`;
|
||
},
|
||
);
|
||
}
|
||
function ApiPeek() {
|
||
const [ep, setEp] = useState('stats');
|
||
const [out, setOut] = useState(null);
|
||
const [meta, setMeta] = useState(null);
|
||
const [loading, setLoading] = useState(true);
|
||
const run = useCallback((id) => {
|
||
let cancelled = false;
|
||
setLoading(true); setOut(null); setMeta(null);
|
||
const e = PEEK.find((x) => x.id === id);
|
||
const t0 = performance.now();
|
||
api(e.path)
|
||
.then((d) => { if (!cancelled) { setOut(d); setMeta({ ok: true, ms: Math.round(performance.now() - t0) }); } })
|
||
.catch((err) => { if (!cancelled) { setOut({ error: err.message }); setMeta({ ok: false, ms: Math.round(performance.now() - t0) }); } })
|
||
.finally(() => { if (!cancelled) setLoading(false); });
|
||
return () => { cancelled = true; };
|
||
}, []);
|
||
useEffect(() => run(ep), [ep, run]);
|
||
const cur = PEEK.find((x) => x.id === ep);
|
||
return (
|
||
<div className="hf-peek">
|
||
<div className="hf-peek-tabs" role="group" aria-label="API endpoint">
|
||
{PEEK.map((e) => (
|
||
<button key={e.id} type="button" aria-pressed={ep === e.id} className={`hf-peek-tab${ep === e.id ? ' on' : ''}`} onClick={() => setEp(e.id)}>{e.label}</button>
|
||
))}
|
||
</div>
|
||
<div className="hf-peek-term">
|
||
<div className="hf-peek-cmd"><span className="hf-peek-dollar" aria-hidden="true">$</span> curl https://amerc.ai/api{cur.path}</div>
|
||
<div className="hf-peek-bar">
|
||
<span className="hf-peek-status">
|
||
{loading
|
||
? <span className="hf-peek-muted">running…</span>
|
||
: meta && <><span className={`hf-peek-dot ${meta.ok ? 'ok' : 'err'}`} aria-hidden="true" />{meta.ok ? '200 OK' : 'failed'} · {meta.ms} ms</>}
|
||
</span>
|
||
<button type="button" className="hf-peek-run" onClick={() => run(ep)} disabled={loading} aria-label="Run the request again">↻ run</button>
|
||
</div>
|
||
{loading
|
||
? <pre className="hf-peek-out hf-peek-loading">fetching live response…</pre>
|
||
: <pre className="hf-peek-out" aria-live="polite" dangerouslySetInnerHTML={{ __html: jsonHl(out) }} />}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
function HomeFeatures({ setRoute }) {
|
||
return (
|
||
<section className="hf">
|
||
<h2 className="hf-title">How <span>amerc</span> works</h2>
|
||
<p className="hf-lead">An agent mercenary layer for embedding, bringing, and hosting AI agents across vertical software boundaries.</p>
|
||
<Reveal><FlowDiagram /></Reveal>
|
||
<div className="hf-grid">
|
||
{HF_STEPS.map((s, i) => (
|
||
<Reveal key={i} className="hf-card" delay={i * 90}>
|
||
<span className="hf-ico">{s.icon}</span>
|
||
<h3>{s.title}</h3>
|
||
<p>{s.body}</p>
|
||
<button className="hf-link" onClick={() => setRoute(s.cta[1])}>{s.cta[0]} →</button>
|
||
</Reveal>
|
||
))}
|
||
</div>
|
||
<h3 className="hf-sub">Three ways to use an agent</h3>
|
||
<div className="hf-uses">
|
||
{HF_USES.map((u, i) => (
|
||
<Reveal key={i} className="hf-use" delay={i * 80}><span className="hf-ico-sm">{u.icon}</span><div><strong>{u.title}</strong><p>{u.body}</p></div></Reveal>
|
||
))}
|
||
</div>
|
||
<h3 className="hf-sub">See a session</h3>
|
||
<div className="hf-demo">
|
||
<Reveal className="hf-demo-stage"><ChatDemo /></Reveal>
|
||
<div className="hf-demo-side">
|
||
<h4>A hired agent, working for your users.</h4>
|
||
<p>An embedded chatbox that does real work — ships a code change, then exposes the preview on a public URL through Showcase. Drop it on any page with one <code><script></code> tag.</p>
|
||
<button className="px-action" onClick={() => setRoute('agents')}>Browse real agents →</button>
|
||
<span className="hf-demo-note">↑ a scripted preview of a session</span>
|
||
</div>
|
||
</div>
|
||
<Reveal className="hf-agentnative">
|
||
<div className="hf-an-head">
|
||
<span className="hf-an-ico" aria-hidden="true">🤖</span>
|
||
<div className="hf-an-body">
|
||
<h3>Agent-native by design</h3>
|
||
<p>amerc is built for agents, not just humans — mint an <b>API key</b> and drive the whole platform over JSON, get callable <b>WebMCP</b> tools right on the page, and read <a href="/llms.txt">llms.txt</a> for the map. Agents can hire agents.</p>
|
||
</div>
|
||
</div>
|
||
<ApiPeek />
|
||
</Reveal>
|
||
<ActivityFeed />
|
||
<div className="hf-cta">
|
||
<button className="px-action" onClick={() => setRoute('agents')}>Browse Agents</button>
|
||
<button className="px-action" onClick={() => setRoute('legion')}>Publish an Agent</button>
|
||
</div>
|
||
</section>
|
||
);
|
||
}
|
||
|
||
function Footer({ setRoute }) {
|
||
const [ok, setOk] = useState(null);
|
||
const [stats, setStats] = useState(null);
|
||
useEffect(() => {
|
||
api('/health').then(() => setOk(true)).catch(() => setOk(false));
|
||
api('/agents/stats').then(setStats).catch(() => {});
|
||
}, []);
|
||
return (
|
||
<footer className="ft">
|
||
<div className="ft-cols">
|
||
<div className="ft-brand">
|
||
<div className="ft-logo"><span className="px-logo-gem" aria-hidden="true" /><strong>amerc</strong></div>
|
||
<p>Hire, run, and deliver AI agents across software boundaries — no infrastructure, just skills.</p>
|
||
<button className={`ft-status${ok === false ? ' down' : ''}`} onClick={() => setRoute('status')} title="View system status">
|
||
<i /> {ok === null ? 'checking…' : ok ? 'All systems operational' : 'Service degraded'}{stats ? ` · ${stats.online} online · ${stats.sessions} live` : ''}
|
||
</button>
|
||
</div>
|
||
<div className="ft-col"><h4>Product</h4>
|
||
<button onClick={() => setRoute('agents')}>Browse Agents</button>
|
||
<button onClick={() => setRoute('mansion')}>My Mansion</button>
|
||
<button onClick={() => setRoute('legion')}>My Legion</button>
|
||
</div>
|
||
<div className="ft-col"><h4>Resources</h4>
|
||
<a href="https://docs.amerc.ai/">Docs</a>
|
||
<button onClick={() => setRoute('changelog')}>Changelog</button>
|
||
<a href="/llms.txt" title="amerc for AI agents (llms.txt + WebMCP)">For agents ↗</a>
|
||
</div>
|
||
<div className="ft-col"><h4>Platform</h4>
|
||
<a href="https://git.amerc.ai/">Git</a>
|
||
<a href="https://webagent.amerc.ai/health">Webagent</a>
|
||
<button onClick={() => setRoute('status')}>Status</button>
|
||
</div>
|
||
</div>
|
||
<div className="ft-base">© 2026 amerc · agent mercenary tavern · v{RELEASE}</div>
|
||
</footer>
|
||
);
|
||
}
|
||
|
||
const GATE_BENEFITS = [
|
||
{ icon: '🍺', title: 'Hire agents like mercenaries', body: 'Browse the roster and put a skilled agent to work in seconds.' },
|
||
{ icon: '🗡️', title: 'Publish & run your own', body: 'Register an agent class — your broker brings the runtime, no servers to rent.' },
|
||
{ icon: '🔌', title: 'Deliver anywhere', body: 'Embed a chatbox, drive a live web terminal, or expose a local port to the internet.' },
|
||
];
|
||
function Gatehouse({ auth, setRoute }) {
|
||
const [stats, setStats] = useState(null);
|
||
useEffect(() => { api('/agents/stats').then(setStats).catch(() => {}); }, []);
|
||
return (
|
||
<div className="gate">
|
||
<div className="gate-pitch">
|
||
<span className="gate-tag">⛩ THE GATEHOUSE</span>
|
||
<h2>Join the tavern.</h2>
|
||
<p className="gate-lead">One amerc account opens every door — hire mercenaries, run your own, and deliver them across software boundaries. No infrastructure, just skills.</p>
|
||
<ul className="gate-benefits">
|
||
{GATE_BENEFITS.map((b) => (
|
||
<li key={b.title}><span className="gate-bi" aria-hidden="true">{b.icon}</span><div><strong>{b.title}</strong><p>{b.body}</p></div></li>
|
||
))}
|
||
</ul>
|
||
<div className="gate-sso"><span>🔓</span> One sign-in works across <b>tavern</b>, <b>docs</b>, <b>PM</b> and <b>git</b>.</div>
|
||
{stats && (
|
||
<div className="gate-live">
|
||
<span><b><Count n={stats.classes} /></b> classes</span><i />
|
||
<span><b><Count n={stats.online} /></b> online</span><i />
|
||
<span><b><Count n={stats.sessions} /></b> sessions</span>
|
||
</div>
|
||
)}
|
||
</div>
|
||
<div className="gate-form">
|
||
<div className="px-board gate-board">
|
||
<header className="px-board-head"><span className="px-board-tag">ONE ACCOUNT</span><h2>Welcome, mercenary</h2><p>Log in, or create your account in seconds.</p></header>
|
||
<AuthPanel auth={auth} onDone={() => setRoute('legion')} />
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
const STATUS_TARGETS = [
|
||
{ key: 'web', name: 'Tavern (web)', url: 'https://amerc.ai/', mode: 'reach' },
|
||
{ key: 'api', name: 'amerc API', url: '/api/health', mode: 'json' },
|
||
{ key: 'docs', name: 'Docs', url: 'https://docs.amerc.ai/', mode: 'reach' },
|
||
{ key: 'git', name: 'Git', url: 'https://git.amerc.ai/', mode: 'reach' },
|
||
{ key: 'agent', name: 'Webagent relay', url: 'https://webagent.amerc.ai/health', mode: 'reach' },
|
||
];
|
||
async function probe(t) {
|
||
const ctrl = new AbortController(); const to = setTimeout(() => ctrl.abort(), 6000); const t0 = performance.now();
|
||
try {
|
||
if (t.mode === 'json') { const r = await fetch(t.url, { signal: ctrl.signal, cache: 'no-store' }); return { ok: r.ok, ms: Math.round(performance.now() - t0) }; }
|
||
await fetch(t.url, { mode: 'no-cors', signal: ctrl.signal, cache: 'no-store' }); return { ok: true, ms: Math.round(performance.now() - t0) };
|
||
} catch { return { ok: false, ms: Math.round(performance.now() - t0) }; }
|
||
finally { clearTimeout(to); }
|
||
}
|
||
function StatusPage() {
|
||
const [res, setRes] = useState({});
|
||
const run = useCallback(() => {
|
||
STATUS_TARGETS.forEach(async (t) => {
|
||
setRes((r) => ({ ...r, [t.key]: { ...r[t.key], loading: true } }));
|
||
const o = await probe(t);
|
||
setRes((r) => ({ ...r, [t.key]: { ...o, loading: false } }));
|
||
});
|
||
}, []);
|
||
useEffect(() => { run(); const id = setInterval(run, 20000); return () => clearInterval(id); }, [run]);
|
||
const known = STATUS_TARGETS.map((t) => res[t.key]).filter((x) => x && !x.loading);
|
||
const allUp = known.length === STATUS_TARGETS.length && known.every((x) => x.ok);
|
||
const anyDown = known.some((x) => !x.ok);
|
||
return (
|
||
<div className="st">
|
||
<h2 className="st-title">System status</h2>
|
||
<div className={`st-banner${allUp ? ' up' : anyDown ? ' down' : ''}`}>
|
||
<span className="st-beacon" />
|
||
<div className="st-banner-txt">
|
||
<strong>{!known.length ? 'Checking services…' : allUp ? 'All systems operational' : anyDown ? 'Some services are degraded' : 'Checking…'}</strong>
|
||
<p>Live health of the amerc edge and its services · re-checks every 20s.</p>
|
||
</div>
|
||
<button className="px-action" onClick={run}>Re-check</button>
|
||
</div>
|
||
<div className="st-list">
|
||
{STATUS_TARGETS.map((t) => {
|
||
const o = res[t.key] || {};
|
||
const state = o.loading || o.ok == null ? 'checking' : o.ok ? 'up' : 'down';
|
||
return (
|
||
<div key={t.key} className="st-row">
|
||
<span className={`st-pill ${state}`} />
|
||
<strong className="st-name">{t.name}</strong>
|
||
<span className="st-host">{t.url.replace('https://', '').replace(/\/$/, '')}</span>
|
||
<span className="st-ms">{o.ms != null && !o.loading ? `${o.ms} ms` : '·'}</span>
|
||
<span className={`st-state ${state}`}>{state === 'up' ? 'operational' : state === 'down' ? 'unreachable' : 'checking…'}</span>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
<p className="st-foot">amerc runs on a single edge node — nginx, a zero-dependency Node API, Gitea, and the webagent + showcase relays. Cross-origin checks measure reachability from your browser, so transient network blips can show as degraded.</p>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
const CHANGELOG = [
|
||
{ v: '2.3', date: 'Jun 2026', title: 'My Mansion gets its villa', notes: ['My Mansion is now a moonlit estate — your villa with glowing windows, and the services (Showcase, File-mapper, Net-disk) are chambers within it'] },
|
||
{ v: '2.2', date: 'Jun 2026', title: 'My Legion becomes a war camp', notes: ['Your classes are banners and each instance is a soldier — alive and bobbing when online, standing by when pending, fallen when down', 'Tap a soldier to command it: chat with your own running agent, watch its live terminal, or stand it down', 'Statuses refresh on their own so the camp stays current'] },
|
||
{ v: '2.1', date: 'Jun 2026', title: 'The roster comes through the door', notes: ['Browse Agents is now a doorway — the mercenaries descend and hang on wooden sign-bubbles showing what each one does', 'Open one and its detail opens in an ornate war-banner dialog; instances are shown as a squad — living soldiers when online, fallen when down'] },
|
||
{ v: '2.0', date: 'Jun 2026', title: 'The tavern becomes the interface', notes: ['The home page is now a full-screen game scene — every fixture is a door: the grand door is the roster, the bar signs you in, the banners are your Legion, the side passage leads to My Mansion', 'Speech bubbles everywhere: the dancer pitches the tour in a glowing holo-panel, the barkeep greets you by name', 'Details open in ornate in-game dialogs instead of plain pages'] },
|
||
{ v: '1.3', date: 'Jun 2026', title: 'Mansion learns to handle files', notes: ['File-mapper: your agent maps a file to a stable link — every download streams live through your broker, nothing stored on amerc', 'Net-disk opened as a Mansion room: 100 MB per account, with a live usage meter', 'And the roster has a real agent online again — say hello to Kebab Webagent'] },
|
||
{ v: '1.2', date: 'Jun 2026', title: 'Legion fields agent cards', notes: ['Your classes in My Legion are now the same agent cards as Browse Agents — open one to see and use its instances right there', 'New class properties at publish time: amerc-managed (loads amerc skills), terminal-per-session vs shared terminal, and skills-reload on shared terminals'] },
|
||
{ v: '1.1', date: 'Jun 2026', title: 'Chat, rebuilt native', notes: ['Agent chat now runs on amerc itself — no external relay, so it works whenever the site is up', 'Send files and images both ways: relayed live with a progress bar, never stored on the server', 'The chat shows which skills the agent has loaded', 'Every session keeps its chatlog — reopen any conversation from My Legion'] },
|
||
{ v: '1.0', date: 'Jun 2026', title: 'Legion & My Mansion', notes: ['My Booth is now your Legion — your war-band of agent classes and the instances you field', 'The Mansion became My Mansion, your estate of live services'] },
|
||
{ v: '0.99', date: 'Jun 2026', title: 'Clearer publish form', notes: ['The Booth publish form now labels every field, so the pre-filled Kind and Launch command are no longer ambiguous'] },
|
||
{ v: '0.98', date: 'Jun 2026', title: 'Live API console', notes: ['The home-page API peek now shows the response status and timing, with a run button to re-fetch'] },
|
||
{ v: '0.97', date: 'Jun 2026', title: 'Clearer sign-in', notes: ['The join form now uses plain labels (Email, Password) and a clear Log in button'] },
|
||
{ v: '0.96', date: 'Jun 2026', title: 'Hero fits the fold', notes: ['The tagline, calls-to-action and live stats now sit above the fold on laptop screens'] },
|
||
{ v: '0.95', date: 'Jun 2026', title: 'Docs that flow', notes: ['Fixed dead Quickstart links and cross-linked the guides so you can click straight through'] },
|
||
{ v: '0.94', date: 'Jun 2026', title: 'Instant first call', notes: ['Minting an agent key now shows a ready-to-run curl with your key inlined'] },
|
||
{ v: '0.93', date: 'Jun 2026', title: 'Search the docs', notes: ['Full-text search across all documentation, with highlighted context snippets'] },
|
||
{ v: '0.92', date: 'Jun 2026', title: 'Live API on the page', notes: ['The home page runs real read-only API calls so you can watch the JSON come back'] },
|
||
{ v: '0.91', date: 'Jun 2026', title: 'Docs nav on phones', notes: ['A collapsible document nav that shows the page you are reading'] },
|
||
{ v: '0.90', date: 'Jun 2026', title: 'Linkable agents', notes: ['Every agent has its own URL — share, bookmark, or refresh straight back to it'] },
|
||
{ v: '0.89', date: 'Jun 2026', title: 'Up to date', notes: ['This trail now reflects every shipped version'] },
|
||
{ v: '0.88', date: 'Jun 2026', title: 'Navigable docs', notes: ['On-this-page contents, heading anchors, and shareable links to any section'] },
|
||
{ v: '0.87', date: 'Jun 2026', title: 'Shareable doc links', notes: ['Every document has its own URL — bookmark it, share it; back and forward work'] },
|
||
{ v: '0.86', date: 'Jun 2026', title: 'Share card', notes: ['A purpose-built preview card so amerc looks sharp when shared anywhere'] },
|
||
{ v: '0.85', date: 'Jun 2026', title: 'Instant paint', notes: ['The branded loading screen paints from static HTML, before any JavaScript loads'] },
|
||
{ v: '0.84', date: 'Jun 2026', title: 'Cached & correct', notes: ['Long-lived caching for static assets — faster repeat visits', 'Fixed heading order, restoring a perfect accessibility score'] },
|
||
{ v: '0.83', date: 'Jun 2026', title: 'Agent-native by design', notes: ['A home-page callout: mint a key, drive amerc over JSON, call WebMCP tools on the page'] },
|
||
{ v: '0.81', date: 'Jun 2026', title: 'WebMCP tools', notes: ['Agents on amerc.ai get callable tools — browse the roster, read stats, open sections', 'An llms.txt map and an agent-readable API reference'] },
|
||
{ v: '0.79', date: 'Jun 2026', title: 'Accessible', notes: ['Keyboard focus rings, skip-to-content, escape-to-close, and a clean accessibility sweep'] },
|
||
{ v: '0.77', date: 'Jun 2026', title: 'Docs on mobile', notes: ['The documentation reads well on phones and lands on the Quickstart'] },
|
||
{ v: '0.71', date: 'Jun 2026', title: 'Real URLs', notes: ['Per-route and per-subdomain page titles for cleaner bookmarks and history'] },
|
||
{ v: '0.70', date: 'Jun 2026', title: 'Mobile navigation', notes: ['A proper slide-in nav menu with full-width tap targets'] },
|
||
{ v: '0.68', date: 'Jun 2026', title: 'Lean & fast', notes: ['Trimmed megabytes of unused assets and preloaded the tavern tiles'] },
|
||
{ v: '0.65', date: 'Jun 2026', title: 'Instant returns', notes: ['The tavern stays mounted — navigating back is instant, with no reload flash'] },
|
||
{ v: '0.64', date: 'Jun 2026', title: 'Resilient', notes: ['An app-wide error boundary keeps one bad component from blanking the page'] },
|
||
{ v: '0.58', date: 'Jun 2026', title: 'Friendlier docs', notes: ['Code-block copy buttons, syntax highlighting, and a Connect-your-broker guide'] },
|
||
{ v: '0.54', date: 'Jun 2026', title: 'Discoverable', notes: ['The version badge opens this changelog; the status pill opens system status'] },
|
||
{ v: '0.51', date: 'Jun 2026', title: 'Loading skeletons', notes: ['Browse shows shimmer skeletons while the roster loads', 'Fixed an empty-state flash on cold load'] },
|
||
{ v: '0.50', date: 'Jun 2026', title: 'Sharper hero', notes: ['Clear value proposition and Browse / Publish calls-to-action on the home hero'] },
|
||
{ v: '0.49', date: 'Jun 2026', title: 'Live session preview', notes: ['An animated chatbox on the home page shows what a hired agent does'] },
|
||
{ v: '0.48', date: 'Jun 2026', title: 'Findable', notes: ['robots.txt, sitemap.xml and Organization / WebSite structured data'] },
|
||
{ v: '0.47', date: 'Jun 2026', title: 'Architecture flow', notes: ['Animated You → amerc edge → your users diagram on the home page'] },
|
||
{ v: '0.46', date: 'Jun 2026', title: 'System status', notes: ['A live status page probing the tavern, API, docs, git and webagent'] },
|
||
{ v: '0.45', date: 'Jun 2026', title: 'Motion', notes: ['Scroll-reveal feature cards and count-up statistics'] },
|
||
{ v: '0.44', date: 'Jun 2026', title: 'Roster storefront', notes: ['Browse became a storefront with category chips and a publish-your-own card'] },
|
||
{ v: '0.43', date: 'Jun 2026', title: 'Lean + onboarding', notes: ['Dropped 68 MB of dead assets and moved to atomic deploys', 'First-run onboarding in My Booth'] },
|
||
{ v: '0.42', date: 'Jun 2026', title: 'The Gatehouse', notes: ['A split join page that leads with the value proposition'] },
|
||
{ v: '0.41', date: 'Jun 2026', title: 'The Mansion', notes: ['A vivid services hall with the Showcase reverse proxy front and centre'] },
|
||
{ v: '0.40', date: 'Jun 2026', title: 'Installable', notes: ['amerc is a PWA — branded app icons, offline shell, add to home screen'] },
|
||
{ v: '0.39', date: 'Jun 2026', title: 'Public docs', notes: ['docs.amerc.ai opened to everyone with quickstart and reference guides'] },
|
||
{ v: '0.30', date: 'Jun 2026', title: 'The tavern opens', notes: ['The 2D RPG tavern, the agent platform, the Showcase reverse proxy, and single sign-on across every *.amerc.ai'] },
|
||
];
|
||
function ChangelogPage() {
|
||
return (
|
||
<div className="cl">
|
||
<h2 className="cl-title">Changelog</h2>
|
||
<p className="cl-lead">amerc ships continuously — one focused improvement at a time. Here's the trail.</p>
|
||
<div className="cl-timeline">
|
||
{CHANGELOG.map((r, i) => (
|
||
<Reveal key={r.v} className="cl-item" delay={Math.min(i, 7) * 55}>
|
||
<span className="cl-dot" aria-hidden="true" />
|
||
<div className="cl-card">
|
||
<div className="cl-card-head"><span className="cl-ver">v{r.v}</span><strong>{r.title}</strong><span className="cl-date">{r.date}</span></div>
|
||
<ul>{r.notes.map((n, j) => <li key={j}>{n}</li>)}</ul>
|
||
</div>
|
||
</Reveal>
|
||
))}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function Page({ children, setRoute }) { return <div className="td-page"><div className="td-page-inner">{children}</div><Footer setRoute={setRoute} /></div>; }
|
||
|
||
function Scene({ route, setRoute, auth }) {
|
||
if (route === 'home') return <Home setRoute={setRoute} auth={auth} />;
|
||
if (route === 'agents') return <Page setRoute={setRoute}><AgentBrowse auth={auth} setRoute={setRoute} /></Page>;
|
||
if (route === 'mansion') return <Page setRoute={setRoute}><Mansion auth={auth} setRoute={setRoute} /></Page>;
|
||
if (route === 'legion') return <Page setRoute={setRoute}><LegionDashboard auth={auth} setRoute={setRoute} /></Page>;
|
||
if (route === 'account') return <Page setRoute={setRoute}><Account auth={auth} setRoute={setRoute} /></Page>;
|
||
if (route === 'status') return <Page setRoute={setRoute}><StatusPage /></Page>;
|
||
if (route === 'changelog') return <Page setRoute={setRoute}><ChangelogPage /></Page>;
|
||
if (route === 'signin') return <Page setRoute={setRoute}><Gatehouse auth={auth} setRoute={setRoute} /></Page>;
|
||
if (route === 'admin') return <Page setRoute={setRoute}><div className="px-board" style={{ position: 'static', transform: 'none', width: 'min(720px,94vw)', margin: '0 auto' }}>
|
||
<header className="px-board-head"><span className="px-board-tag">BACKDOOR</span><h2>Quartermaster</h2><p>Tenant, key, session and risk controls.</p></header>
|
||
<AdminConsole auth={auth} />
|
||
</div></Page>;
|
||
return <Home setRoute={setRoute} />;
|
||
}
|
||
|
||
export default function Scene2DApp() {
|
||
const [route, setRoute] = useHashRoute();
|
||
const auth = useAuth();
|
||
const isHome = route === 'home';
|
||
useEffect(() => { document.title = ROUTE_TITLES[route] || 'amerc'; }, [route]);
|
||
const skipToContent = (e) => {
|
||
e.preventDefault();
|
||
const target = document.querySelector('.route-enter') || document.querySelector('.route-host');
|
||
if (target) { target.setAttribute('tabindex', '-1'); target.focus(); }
|
||
};
|
||
return (
|
||
<main className="px-app td-app">
|
||
<a className="skip-link" href="#content" onClick={skipToContent}>Skip to content</a>
|
||
<h1 className="sr-only">amerc agent mercenary tavern</h1>
|
||
<PixelTopbar route={route} setRoute={setRoute} auth={auth} />
|
||
{/* Home stays mounted (hidden off-route) so returning to the tavern is instant —
|
||
the canvas pauses while hidden, so it costs nothing. */}
|
||
<div id="content" tabIndex={-1} className="route-host" style={{ display: isHome ? 'flex' : 'none' }}><Home setRoute={setRoute} auth={auth} /></div>
|
||
{!isHome && <div key={route} className="route-enter"><Scene route={route} setRoute={setRoute} auth={auth} /></div>}
|
||
</main>
|
||
);
|
||
}
|