legion: war camp — class banners + alive/fallen soldier instances (2.2.0)

- LegionDashboard 'war camp': each class is a pennant banner (click =
  detail dialog), each instance a soldier grouped under its banner —
  bobbing 🪖 when online, 🧍 pending, 💀 fallen when down
- SoldierDialog: command a soldier in an ornate dialog — chat with your
  own running agent (skills shown), a live terminal snapshot polled every
  5s (the heartbeat tui field), rouse instructions when down, stand-down
- legion auto-refreshes every 15s so statuses stay live
- verified locally: 2 banners, 3 soldiers, ON DUTY chat dialog

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
artheru 2026-06-11 09:22:56 +08:00
parent 374440a392
commit e308c9be4d
5 changed files with 134 additions and 25 deletions

View File

@ -17,7 +17,7 @@
@media (prefers-reduced-motion: reduce) { .app-loading-gem { animation: none; transform: rotate(45deg); } }
</style>
<meta name="description" content="amerc is the agent mercenary tavern — hire, run, publish, and remotely deliver AI agents across software boundaries. No infrastructure, just skills." />
<meta name="version" content="2.1.0-roster" />
<meta name="version" content="2.2.0-warcamp" />
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
<link rel="icon" href="/app-192.png" type="image/png" sizes="192x192" />
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />

View File

@ -1,6 +1,6 @@
{
"name": "amerc-site",
"version": "2.1.0-roster",
"version": "2.2.0-warcamp",
"private": true,
"type": "module",
"scripts": {

View File

@ -293,6 +293,73 @@ export function KeysCard() {
);
}
// A fielded instance = a soldier: alive (online), standing-by (pending), fallen (lost/down).
const SOL_FIG = { online: '🪖', pending: '🧍', lost: '🛡️', down: '💀' };
function relHb(at) { if (!at) return 'never'; 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`; return `${Math.floor(m / 60)}h ago`; }
function Soldier({ i, onOpen }) {
const klass = i.status === 'online' ? 'alive' : i.status === 'pending' ? 'pending' : 'fallen';
return (
<button className={`lg-sol ${klass}`} onClick={() => onOpen(i)} title={`#${i.id} · ${i.status}`}>
<span className="lg-sol-fig" aria-hidden="true">{SOL_FIG[i.status] || '💀'}</span>
<span className="lg-sol-id">#{i.id}</span>
<span className="lg-sol-status">{i.status}</span>
</button>
);
}
// Soldier detail: live status + terminal snapshot, and chat with your own soldier.
function SoldierDialog({ instance, onClose, onChanged }) {
const [detail, setDetail] = useState(null);
const [tab, setTab] = useState(instance.status === 'online' ? 'chat' : 'status');
useEffect(() => {
let alive = true;
const f = () => api(`/agents/instances/${instance.id}${instance.accesskey ? `?accesskey=${encodeURIComponent(instance.accesskey)}` : ''}`).then((d) => { if (alive && d.instance) setDetail({ ...d.instance, tui: d.tui }); }).catch(() => {});
f(); const t = setInterval(f, 5000); return () => { alive = false; clearInterval(t); };
}, [instance]);
const live = detail || instance;
const online = live.status === 'online';
const hb = `while true; do curl -s -XPOST https://amerc.ai/api/agents/instances/${instance.id}/heartbeat -H 'content-type: application/json' -d '{"accesskey":"${instance.accesskey}","status":"online","broker":"my-broker"}' >/dev/null; sleep 10; done`;
const standDown = async () => {
if (!window.confirm('Stand this soldier down? (shuts the instance down)')) return;
try { await api(`/agents/instances/${instance.id}`, { method: 'DELETE', body: { accesskey: instance.accesskey } }); onChanged?.(); onClose(); } catch { /* ignore */ }
};
return (
<GameDialog title={live.name || `Soldier #${instance.id}`} tag={online ? '🪖 ON DUTY' : live.status === 'pending' ? '🧍 STANDING BY' : '💀 FALLEN'} wide onClose={onClose}>
<div className="lg-sd-head">
<span className={`ch-dot ${live.status}`} />
<div className="lg-sd-meta">
<strong>#{instance.id} {live.name}</strong>
<span>{live.status}{live.broker ? ` · broker ${live.broker}` : ''} · heartbeat {relHb(live.last_heartbeat)}</span>
</div>
{instance.accesskey && <code className="ap-token lg-sd-key" onClick={() => copyToast(instance.accesskey)} title="broker accesskey">{instance.accesskey.slice(0, 14)} </code>}
<button className="px-action lg-standdown" onClick={standDown}>stand down</button>
</div>
<div className="lg-sd-tabs">
<button className={tab === 'chat' ? 'on' : ''} onClick={() => setTab('chat')}>Chat</button>
<button className={tab === 'status' ? 'on' : ''} onClick={() => setTab('status')}>Status</button>
</div>
{tab === 'chat' && (online
? <ChatPanel instance={live} accesskey={instance.accesskey} onClose={onClose} />
: <p className="ap-hint" style={{ padding: '16px 4px' }}>This soldier is {live.status}. Rouse it from the Status tab, then chat.</p>)}
{tab === 'status' && (
<div className="lg-sd-status">
{live.skills?.length > 0 && <div className="ch-skills"><span className="ch-skills-label">skills loaded</span>{live.skills.map((s) => <span key={s} className="ch-skill">{s}</span>)}</div>}
<div className="lg-tui">
<div className="lg-tui-head">live terminal · refreshes every 5s</div>
<pre>{live.tui || '(no terminal snapshot yet — your broker can post one via the heartbeat `tui` field)'}</pre>
</div>
{!online && (
<div className="lg-rouse">
<p className="ap-hint">Fallen until a broker checks in. Paste this anywhere with <code>curl</code> to rouse it:</p>
<div className="ap-codeblock"><code>{hb}</code><button className="px-action" onClick={() => copyToast(hb, 'Heartbeat loop copied')}>copy</button></div>
</div>
)}
</div>
)}
</GameDialog>
);
}
export function LegionDashboard({ auth, setRoute }) {
const [classes, setClasses] = useState([]);
const [insts, setInsts] = useState([]);
@ -301,6 +368,7 @@ export function LegionDashboard({ auth, setRoute }) {
const [newInst, setNewInst] = useState(null);
const [logOpen, setLogOpen] = useState(null); // chatlog session to reopen
const [openCls, setOpenCls] = useState(null); // class modal (same as Browse)
const [soldier, setSoldier] = useState(null); // soldier (instance) detail
const [loaded, setLoaded] = useState(false);
useEffect(() => {
if (!newInst) return;
@ -318,7 +386,7 @@ export function LegionDashboard({ auth, setRoute }) {
setInsts(all.flat());
} catch (e) { setErr(e.message); } finally { setLoaded(true); }
}, []);
useEffect(() => { if (auth.user) load(); }, [auth.user, load]);
useEffect(() => { if (!auth.user) return undefined; load(); const t = setInterval(load, 15000); return () => clearInterval(t); }, [auth.user, load]);
if (!auth.ready) return <div className="ap"><p>Loading</p></div>;
if (!auth.user) return (
@ -342,8 +410,6 @@ export function LegionDashboard({ auth, setRoute }) {
try { const r = await api('/agents/instances', { method: 'POST', body: { classId, visibility: 'public' } }); setNewInst(r); load(); }
catch (e2) { setErr(e2.message); }
};
const shutdown = async (i) => { try { await api(`/agents/instances/${i.id}`, { method: 'DELETE', body: { accesskey: i.accesskey } }); load(); } catch (e2) { setErr(e2.message); } };
return (
<div className="ap">
<div className="ap-head"><h2>My Legion</h2><span className="ap-by">{auth.user.handle}</span></div>
@ -409,27 +475,26 @@ export function LegionDashboard({ auth, setRoute }) {
</div>
) : (
<>
<h4 className="ap-h4">My classes your banners on the roster</h4>
<div className="ap-mine-cards">
{classes.map((c) => (
<div key={c.id} className="ap-mine-cardwrap">
<AgentCard c={c} onClick={() => setOpenCls(c.id)} />
<div className="ap-mine-actions">
<span className="ap-kind">{c.visibility}</span>
<button className="px-action" onClick={() => publishInstance(c.id)}>+ Field an instance</button>
<h4 className="ap-h4"> The war camp tap a banner for details, a soldier to command</h4>
<div className="lg-camp">
{classes.map((c) => {
const squad = insts.filter((i) => i.class_id === c.id);
return (
<div key={c.id} className="lg-unit">
<button className="lg-banner" onClick={() => setOpenCls(c.id)} title={`${c.name} — open details`}>
<span className="lg-banner-crest" style={avatarStyle(c.name)}>{classEmoji(c)}</span>
<strong>{c.name}</strong>
<span className="lg-banner-sub">{c.visibility}{c.managed ? ' · 🛡' : ''}</span>
<span className="lg-banner-count"><i className={c.onlineCount ? 'pulse' : ''} style={{ background: c.onlineCount ? STATUS_COLOR.online : '#555' }} />{c.onlineCount}/{c.instanceCount}</span>
</button>
<div className="lg-soldiers">
{squad.map((i) => <Soldier key={i.id} i={i} onOpen={setSoldier} />)}
<button className="lg-recruit" onClick={() => publishInstance(c.id)} title="field a new soldier"></button>
</div>
</div>
</div>
))}
);
})}
</div>
<h4 className="ap-h4">My instances</h4>
{insts.map((i) => (
<div key={i.id} className="ap-mine-inst">
<span className={`ap-inst-status${i.status === 'online' ? ' pulse' : ''}`} style={{ background: STATUS_COLOR[i.status] }} /> #{i.id} <em>{i.status}</em>
{i.accesskey && <code className="ap-token" title="accesskey — your broker connects with this" onClick={() => copyToast(i.accesskey)}>{i.accesskey.slice(0, 14)} </code>}
<button className="px-action" onClick={() => shutdown(i)}>shut down</button>
</div>
))}
{!insts.length && <p className="ap-hint">No instances. Publish one from a class above.</p>}
</>
)}
</div>
@ -448,6 +513,7 @@ export function LegionDashboard({ auth, setRoute }) {
</div>
)}
{openCls && <ClassModal id={openCls} auth={auth} onClose={() => { setOpenCls(null); load(); }} />}
{soldier && <SoldierDialog instance={soldier} onClose={() => { setSoldier(null); load(); }} onChanged={load} />}
{newInst && (() => {
const hb = `while true; do curl -s -XPOST https://amerc.ai/api/agents/instances/${newInst.id}/heartbeat -H 'content-type: application/json' -d '{"accesskey":"${newInst.accesskey}","status":"online","broker":"my-broker"}' >/dev/null; sleep 10; done`;
return (

View File

@ -11,7 +11,7 @@ import { api } from './api.js';
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.1.0-roster';
const RELEASE = '2.2.0-warcamp';
const NAV = [
{ id: 'home', label: 'Tavern' },
@ -476,6 +476,7 @@ function StatusPage() {
}
const CHANGELOG = [
{ 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'] },

View File

@ -1765,3 +1765,45 @@ button {
.cm-sol-meta { display: flex; align-items: center; gap: 6px; color: #8a7bb0; font-size: 11px; }
.cm-sol-dot { width: 8px; height: 8px; border-radius: 50%; flex: 0 0 auto; }
@media (max-width: 620px) { .apb-merc { width: 132px; } .apb-doorway { flex-wrap: wrap; } }
/* ===== My Legion = the war camp (2.2): banners + soldiers ===== */
.lg-camp { display: grid; grid-template-columns: repeat(auto-fill, minmax(184px, 1fr)); gap: 18px; margin-bottom: 8px; }
.lg-unit { display: flex; flex-direction: column; align-items: center; gap: 10px; }
.lg-banner { position: relative; width: 100%; padding: 14px 10px 26px; display: flex; flex-direction: column; align-items: center; gap: 5px; cursor: pointer; font-family: inherit; color: #eee6ff; background: linear-gradient(180deg, #2a1d4e, #170f2c); border: 1px solid #443a7e; border-top: 4px solid #7a6ab0; clip-path: polygon(0 0, 100% 0, 100% 88%, 50% 100%, 0 88%); transition: transform 0.14s, border-color 0.14s; }
.lg-banner:hover { transform: translateY(-3px); border-top-color: #ffd75e; }
.lg-banner::before { content: ''; position: absolute; top: -10px; left: 50%; width: 8px; height: 8px; border-radius: 50%; background: #ffd75e; transform: translateX(-50%); box-shadow: 0 0 8px #ffd75e; }
.lg-banner-crest { width: 50px; height: 50px; border-radius: 12px; display: flex; align-items: center; justify-content: center; font-size: 24px; box-shadow: 0 0 0 2px rgba(255,255,255,0.08); }
.lg-banner strong { font-size: 13.5px; text-align: center; line-height: 1.2; }
.lg-banner-sub { font-size: 10.5px; color: #b6a7d4; }
.lg-banner-count { display: flex; align-items: center; gap: 5px; font-size: 11px; color: #9fb2c8; }
.lg-banner-count i { width: 7px; height: 7px; border-radius: 50%; }
.lg-soldiers { display: flex; flex-wrap: wrap; gap: 7px; justify-content: center; min-height: 30px; }
.lg-sol { display: flex; flex-direction: column; align-items: center; gap: 1px; width: 50px; padding: 5px 2px; border-radius: 9px; cursor: pointer; font-family: inherit; background: #11101e; border: 1px solid #2a2440; transition: transform 0.12s, border-color 0.12s; }
.lg-sol:hover { transform: translateY(-2px); border-color: #6a5a9a; }
.lg-sol-fig { font-size: 22px; line-height: 1; }
.lg-sol-id { font-size: 9.5px; color: #8a7bb0; }
.lg-sol-status { font-size: 8.5px; text-transform: uppercase; letter-spacing: 0.4px; }
.lg-sol.alive { border-color: #1f5642; box-shadow: 0 0 10px rgba(54,240,176,0.12); }
.lg-sol.alive .lg-sol-status { color: #36f0b0; }
@keyframes solbob { 0%, 100% { transform: translateY(0); } 50% { transform: translateY(-3px); } }
.lg-sol.alive .lg-sol-fig { animation: solbob 1.7s ease-in-out infinite; }
.lg-sol.pending .lg-sol-status { color: #f2b85f; }
.lg-sol.fallen { opacity: 0.5; }
.lg-sol.fallen .lg-sol-status { color: #ff7a8c; }
.lg-recruit { width: 50px; height: 56px; border-radius: 9px; border: 1px dashed #4a3f6e; background: #0d0b18; color: #6a5a9a; font-size: 22px; cursor: pointer; transition: border-color 0.12s, color 0.12s; }
.lg-recruit:hover { border-color: #46c8e0; color: #7fd9ea; }
/* soldier dialog */
.lg-sd-head { display: flex; align-items: center; gap: 10px; margin-bottom: 10px; flex-wrap: wrap; }
.lg-sd-meta { flex: 1; min-width: 120px; display: flex; flex-direction: column; gap: 1px; }
.lg-sd-meta strong { color: #eee6ff; font-size: 13px; }
.lg-sd-meta span { color: #8a7bb0; font-size: 11px; }
.lg-sd-key { font-size: 11px; }
.lg-standdown { background: #2a1620 !important; border: 1px solid #5a2030 !important; color: #ff9fb0 !important; }
.lg-sd-tabs { display: flex; gap: 6px; margin-bottom: 10px; }
.lg-sd-tabs button { background: #11101e; border: 1px solid #2a2440; color: #b6a7d4; border-radius: 8px; padding: 5px 14px; font-size: 12px; cursor: pointer; font-family: inherit; }
.lg-sd-tabs button.on { background: #2a2360; border-color: #6a5a9a; color: #fff; }
.lg-sd-status { display: flex; flex-direction: column; gap: 10px; }
.lg-tui { background: #05070c; border: 1px solid #1c2438; border-radius: 9px; overflow: hidden; }
.lg-tui-head { padding: 5px 11px; background: #0d1320; color: #5b6b7d; font-size: 10px; letter-spacing: 0.6px; text-transform: uppercase; border-bottom: 1px solid #1c2438; }
.lg-tui pre { margin: 0; padding: 11px; font-family: ui-monospace, Menlo, monospace; font-size: 11px; color: #9fe6c0; max-height: 240px; overflow: auto; white-space: pre-wrap; word-break: break-word; }
.lg-rouse { margin-top: 2px; }