- 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>
541 lines
32 KiB
JavaScript
541 lines
32 KiB
JavaScript
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||
import { api } from './api.js';
|
||
import { copyToast } from './toast.js';
|
||
import ChatPanel, { SessionLog } from './Chat.jsx';
|
||
import { GameDialog } from './GameDialog.jsx';
|
||
|
||
const STATUS_COLOR = { online: '#36f0b0', pending: '#f2b85f', lost: '#ff9f5a', down: '#ff7a8c' };
|
||
const TAG_EMOJI = { backend: '🛠️', model: '🧠', frontend: '🎨', ui: '🖌️', web: '🌐', data: '📊', devops: '⚙️', ml: '🤖', security: '🔐', api: '🔌', bot: '🤖', chat: '💬', test: '🧪', docs: '📄', design: '✏️', ops: '🚦', research: '🔬' };
|
||
const classEmoji = (c) => { for (const t of (c.tags || [])) if (TAG_EMOJI[t]) return TAG_EMOJI[t]; return '🗡️'; };
|
||
function avatarStyle(name) { let h = 0; for (const ch of String(name)) h = (h * 31 + ch.charCodeAt(0)) >>> 0; const a = h % 360, b = (a + 70 + h % 90) % 360; return { background: `linear-gradient(135deg, hsl(${a} 68% 46%), hsl(${b} 64% 32%))` }; }
|
||
|
||
// One agent card, used identically in Browse Agents and My Legion.
|
||
export function AgentCard({ c, onClick }) {
|
||
return (
|
||
<button className="ap-card" onClick={onClick}>
|
||
<div className="ap-card-head2">
|
||
<span className="ap-avatar" style={avatarStyle(c.name)}>{classEmoji(c)}</span>
|
||
<div className="ap-card-titles"><strong>{c.name}</strong><span className="ap-kind">{c.kind}{c.managed ? ' · amerc-managed' : ''}</span></div>
|
||
</div>
|
||
<p className="ap-desc">{c.description || 'No description.'}</p>
|
||
<div className="ap-card-tags">{(c.tags || []).map((t) => <span key={t}>{t}</span>)}</div>
|
||
<div className="ap-card-foot">
|
||
<span>by {c.owner}</span>
|
||
<span className="ap-online"><i className={c.onlineCount ? 'pulse' : ''} style={{ background: c.onlineCount ? STATUS_COLOR.online : '#555' }} />{c.onlineCount}/{c.instanceCount} online</span>
|
||
</div>
|
||
</button>
|
||
);
|
||
}
|
||
|
||
// ---- Browse Agents: all public classes, filter by tag --------------------
|
||
export function AgentBrowse({ auth, setRoute }) {
|
||
const [classes, setClasses] = useState([]);
|
||
const [tags, setTags] = useState({});
|
||
const [tag, setTag] = useState('');
|
||
const [q, setQ] = useState('');
|
||
const [open, setOpen] = useState(null);
|
||
const [err, setErr] = useState('');
|
||
const [loaded, setLoaded] = useState(false);
|
||
|
||
const load = useCallback(async () => {
|
||
setErr('');
|
||
try {
|
||
const [c, s] = await Promise.all([api(`/agents/classes${tag ? `?tag=${encodeURIComponent(tag)}` : ''}`), api('/agents/stats')]);
|
||
setClasses((c.classes || []).filter((x) => !q || (x.name + x.tags.join() + x.description).toLowerCase().includes(q.toLowerCase())));
|
||
setTags(s.tags || {});
|
||
} catch (e) { setErr(e.message); } finally { setLoaded(true); }
|
||
}, [tag, q]);
|
||
useEffect(() => { load(); }, [load]);
|
||
|
||
// shareable agent URLs: #/agents/<slug> opens that agent's modal (bookmark/share/refresh-safe)
|
||
const didInit = useRef(false);
|
||
const openClass = useCallback((c) => {
|
||
setOpen(c.id);
|
||
if (c.slug) window.history.pushState(null, '', `${window.location.pathname}${window.location.search}#/agents/${c.slug}`);
|
||
}, []);
|
||
const closeClass = useCallback(() => {
|
||
setOpen(null);
|
||
if (window.location.hash !== '#/agents') window.history.pushState(null, '', `${window.location.pathname}${window.location.search}#/agents`);
|
||
load();
|
||
}, [load]);
|
||
useEffect(() => {
|
||
if (didInit.current || !loaded) return;
|
||
didInit.current = true;
|
||
const slug = window.location.hash.replace(/^#\/?/, '').split('/')[1];
|
||
if (slug) { const hit = classes.find((x) => x.slug === slug); if (hit) setOpen(hit.id); }
|
||
}, [loaded, classes]);
|
||
useEffect(() => {
|
||
const onHash = () => {
|
||
const parts = window.location.hash.replace(/^#\/?/, '').split('/');
|
||
if (parts[0] !== 'agents') return;
|
||
if (!parts[1]) { setOpen(null); return; }
|
||
const hit = classes.find((x) => x.slug === parts[1]);
|
||
if (hit) setOpen(hit.id);
|
||
};
|
||
window.addEventListener('hashchange', onHash);
|
||
window.addEventListener('popstate', onHash);
|
||
return () => { window.removeEventListener('hashchange', onHash); window.removeEventListener('popstate', onHash); };
|
||
}, [classes]);
|
||
|
||
return (
|
||
<div className="ap apb">
|
||
<div className="apb-doorway">
|
||
<div className="apb-door" aria-hidden="true">
|
||
<span className="apb-door-leaf l" /><span className="apb-door-leaf r" />
|
||
<span className="apb-door-light" />
|
||
</div>
|
||
<div className="apb-doorhead">
|
||
<h2>⚔ The mercenary roster</h2>
|
||
<p>Open the door and the mercenaries come down. Hire one — chat, embed, or drive its terminal. {loaded ? <b>{classes.length} answering the call.</b> : 'Mustering…'}</p>
|
||
<input className="ap-search" placeholder="search the roster…" value={q} onChange={(e) => setQ(e.target.value)} />
|
||
</div>
|
||
</div>
|
||
<div className="ap-tags">
|
||
<button className={`ap-tag${!tag ? ' on' : ''}`} onClick={() => setTag('')}>★ all</button>
|
||
{Object.entries(tags).sort((a, b) => b[1] - a[1]).map(([t, n]) => (
|
||
<button key={t} className={`ap-tag${tag === t ? ' on' : ''}`} onClick={() => setTag(t)}>{TAG_EMOJI[t] || '🗡️'} {t} <em>{n}</em></button>
|
||
))}
|
||
</div>
|
||
{err && <p className="px-auth-err">{err}</p>}
|
||
<div className="apb-stage" key={tag || 'all'}>
|
||
{!loaded && Array.from({ length: 4 }).map((_, i) => (
|
||
<div key={`sk${i}`} className="apb-merc apb-skel" aria-hidden="true" style={{ '--d': `${i * 80}ms` }}>
|
||
<span className="apb-bub sk-p" /><span className="apb-portrait sk-av" />
|
||
</div>
|
||
))}
|
||
{loaded && classes.map((c, i) => (
|
||
<button key={c.id} className="apb-merc" style={{ '--d': `${Math.min(i, 14) * 75}ms` }} onClick={() => openClass(c)}>
|
||
<span className="apb-bub">
|
||
{c.description || 'A ready blade, awaiting orders.'}
|
||
<i className="apb-bub-tail" aria-hidden="true" />
|
||
</span>
|
||
<span className="apb-rope" aria-hidden="true" />
|
||
<span className="apb-portrait" style={avatarStyle(c.name)}>
|
||
<span className="apb-emoji">{classEmoji(c)}</span>
|
||
<span className={`apb-life ${c.onlineCount ? 'on' : 'off'}`} title={c.onlineCount ? 'online' : 'offline'} />
|
||
{!!c.managed && <span className="apb-crest" title="amerc-managed">🛡</span>}
|
||
</span>
|
||
<strong className="apb-name">{c.name}</strong>
|
||
<span className="apb-meta"><span className="apb-tag-chip">{c.kind}</span> {c.onlineCount}/{c.instanceCount} online · by {c.owner}</span>
|
||
</button>
|
||
))}
|
||
{loaded && classes.length > 0 && (
|
||
<button className="apb-merc apb-merc-add" style={{ '--d': `${Math.min(classes.length, 14) * 75}ms` }} onClick={() => setRoute('legion')}>
|
||
<span className="apb-bub">Bring your own blade — it joins the roster the moment your broker checks in.<i className="apb-bub-tail" aria-hidden="true" /></span>
|
||
<span className="apb-rope" aria-hidden="true" />
|
||
<span className="apb-portrait apb-portrait-add">+</span>
|
||
<strong className="apb-name">Publish your own</strong>
|
||
<span className="apb-meta">Open My Legion →</span>
|
||
</button>
|
||
)}
|
||
{loaded && !classes.length && (q || tag) && (
|
||
<div className="ap-empty-search">
|
||
<span className="ap-empty-search-ico" aria-hidden="true">🔍</span>
|
||
<h3>No mercenaries answer {q ? `“${q}”` : `#${tag}`}.</h3>
|
||
<button className="px-action" onClick={() => { setQ(''); setTag(''); }}>Clear search</button>
|
||
</div>
|
||
)}
|
||
{loaded && !classes.length && !q && !tag && (
|
||
<div className="ap-empty-start">
|
||
<h3>The hall is empty — be the first blade.</h3>
|
||
<ol>
|
||
<li><b>Publish a class</b> in <button className="ap-link" onClick={() => setRoute('legion')}>My Legion</button> — name it, tag it.</li>
|
||
<li><b>Field an instance</b> — your broker connects and it goes <span style={{ color: STATUS_COLOR.online }}>online</span>.</li>
|
||
<li><b>Use it</b> — chat, embed, or drive its terminal.</li>
|
||
</ol>
|
||
<button className="px-action" onClick={() => setRoute('legion')}>Open My Legion →</button>
|
||
</div>
|
||
)}
|
||
</div>
|
||
{open && <ClassModal id={open} auth={auth} onClose={closeClass} />}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function ClassModal({ id, auth, onClose }) {
|
||
const [data, setData] = useState(null);
|
||
const [sub, setSub] = useState(null);
|
||
const [use, setUse] = useState(null); // instance to use
|
||
const [err, setErr] = useState('');
|
||
const load = useCallback(() => api(`/agents/classes/${id}`).then(setData).catch((e) => setErr(e.message)), [id]);
|
||
useEffect(() => { load(); }, [load]);
|
||
useEffect(() => { const onKey = (e) => { if (e.key === 'Escape') onClose(); }; window.addEventListener('keydown', onKey); return () => window.removeEventListener('keydown', onKey); }, [onClose]);
|
||
const subscribe = async () => { try { const r = await api(`/agents/classes/${id}/subscribe`, { method: 'POST' }); setSub(r.token); } catch (e) { setErr(e.message); } };
|
||
if (!data) return null;
|
||
const c = data.class;
|
||
return (
|
||
<GameDialog title={c.name} tag={`⚔ ${c.kind}`} wide onClose={onClose}>
|
||
<div className="cm-top">
|
||
<span className="ap-avatar cm-avatar" style={avatarStyle(c.name)}>{classEmoji(c)}</span>
|
||
<div className="cm-top-body">
|
||
<span className="ap-by">by {c.owner}</span>
|
||
<p className="ap-desc">{c.description || 'No description.'}</p>
|
||
<div className="ap-card-tags">{c.tags.map((t) => <span key={t}>{t}</span>)}</div>
|
||
</div>
|
||
</div>
|
||
<div className="ap-props">
|
||
{!!c.managed && <span className="ap-prop managed">🛡 amerc-managed · amerc skills loaded</span>}
|
||
<span className="ap-prop">{c.terminal_mode === 'shared' ? '🖥 one shared terminal across sessions' : '🖥 fresh terminal per session'}</span>
|
||
{c.terminal_mode === 'shared' && <span className="ap-prop">{c.skills_reload ? '↻ client skills reload each session' : '⏸ client skills persist between sessions'}</span>}
|
||
</div>
|
||
{err && <p className="px-auth-err">{err}</p>}
|
||
<div className="ap-sub">
|
||
{auth.user ? <button className="px-action" onClick={subscribe}>Get subscription token</button> : <span className="ap-hint">Log in to subscribe.</span>}
|
||
{sub && <code className="ap-token" onClick={() => copyToast(sub)}>{sub} ⧉</code>}
|
||
</div>
|
||
<h4 className="ap-h4">🛡 The squad — {data.instances.length} {data.instances.length === 1 ? 'soldier' : 'soldiers'}</h4>
|
||
<div className="cm-squad">
|
||
{data.instances.map((i) => (
|
||
<div key={i.id} className={`cm-sol ${i.status === 'online' ? 'alive' : 'fallen'}`}>
|
||
<span className="cm-sol-ico" aria-hidden="true">{i.status === 'online' ? '🪖' : '💀'}</span>
|
||
<div className="cm-sol-body">
|
||
<strong>#{i.id} {i.name}</strong>
|
||
<span className="cm-sol-meta"><span className="cm-sol-dot" style={{ background: STATUS_COLOR[i.status] }} />{i.status}{i.broker ? ` · ${i.broker}` : ''} · {i.visibility}</span>
|
||
</div>
|
||
<button className="px-action" disabled={i.status !== 'online'} onClick={() => setUse(i)}>{i.status === 'online' ? 'Chat ▸' : 'down'}</button>
|
||
</div>
|
||
))}
|
||
{!data.instances.length && <p className="ap-hint">No soldiers fielded yet. The owner can field one in My Legion.</p>}
|
||
</div>
|
||
{use && <UsePanel instance={use} onClose={() => setUse(null)} />}
|
||
</GameDialog>
|
||
);
|
||
}
|
||
|
||
// ---- Use an instance: native chat + web-pty -------------------------------
|
||
export function UsePanel({ instance, onClose }) {
|
||
const [mode, setMode] = useState('chatbox');
|
||
const [sess, setSess] = useState(null);
|
||
const [accesskey, setAccesskey] = useState(instance.accesskey || '');
|
||
const [err, setErr] = useState('');
|
||
const needKey = !instance.accesskey && instance.visibility !== 'public';
|
||
const [keyOk, setKeyOk] = useState(!needKey);
|
||
const start = useCallback(async () => {
|
||
setErr('');
|
||
try {
|
||
const r = await api(`/agents/instances/${instance.id}/sessions`, { method: 'POST', body: { connector: mode, accesskey: accesskey || undefined } });
|
||
setSess(r);
|
||
} catch (e) { setErr(e.message); }
|
||
}, [instance.id, mode, accesskey]);
|
||
|
||
return (
|
||
<div className="ap-use">
|
||
<div className="ap-use-head">
|
||
<div className="ap-use-tabs">
|
||
<button className={mode === 'chatbox' ? 'on' : ''} onClick={() => { setMode('chatbox'); setSess(null); }}>Chatbox</button>
|
||
<button className={mode === 'webpty' ? 'on' : ''} onClick={() => { setMode('webpty'); setSess(null); }}>Web PTY</button>
|
||
</div>
|
||
<button className="ap-use-x" onClick={onClose}>close ✕</button>
|
||
</div>
|
||
{needKey && !keyOk && (
|
||
<div className="ap-use-start">
|
||
<input placeholder="instance accesskey (private)" value={accesskey} onChange={(e) => setAccesskey(e.target.value)} />
|
||
<button className="px-action" onClick={() => setKeyOk(true)}>Connect</button>
|
||
</div>
|
||
)}
|
||
{mode === 'chatbox' && keyOk && <ChatPanel instance={instance} accesskey={accesskey || undefined} onClose={onClose} />}
|
||
{mode === 'webpty' && keyOk && !sess && (
|
||
<div className="ap-use-start">
|
||
<button className="px-action" onClick={start}>Connect webpty</button>
|
||
{err && <p className="px-auth-err">{err}</p>}
|
||
</div>
|
||
)}
|
||
{mode === 'webpty' && sess && (
|
||
<>
|
||
<iframe title="agent terminal" className="ap-iframe" src={`https://webagent.amerc.ai/chat_session?session=${encodeURIComponent(sess.relayToken)}`} />
|
||
<p className="ap-hint ap-pty-note">🖥️ The web terminal rides the webagent relay; chat no longer does.</p>
|
||
</>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ---- My Legion: publish classes + instances, manage ------------------------
|
||
export function KeysCard() {
|
||
const [keys, setKeys] = useState([]);
|
||
const [name, setName] = useState('');
|
||
const [created, setCreated] = useState(null);
|
||
const [err, setErr] = useState('');
|
||
const load = useCallback(() => api('/keys').then((d) => setKeys(d.keys || [])).catch((e) => setErr(e.message)), []);
|
||
useEffect(() => { load(); }, [load]);
|
||
const mint = async (e) => { e.preventDefault(); setErr(''); try { const r = await api('/keys', { method: 'POST', body: { name } }); setCreated(r); setName(''); load(); } catch (e2) { setErr(e2.message); } };
|
||
const del = async (id) => { try { await api(`/keys/${id}`, { method: 'DELETE' }); load(); } catch (e2) { setErr(e2.message); } };
|
||
return (
|
||
<div className="ap-keys">
|
||
<h4 className="ap-h4">🔑 My agent keys</h4>
|
||
<p className="ap-hint">Mint a key for your own agent to read/write amerc docs, netdisk and the agent platform. Send it as <code>Authorization: Bearer <key></code>.</p>
|
||
<form className="ap-keys-mint" onSubmit={mint}>
|
||
<input placeholder="key name (e.g. my-bot)" value={name} onChange={(e) => setName(e.target.value)} required />
|
||
<button className="px-action" type="submit">+ Mint key</button>
|
||
</form>
|
||
{created && (
|
||
<div className="mn-created">
|
||
<p>✅ minted — copy now, shown once:</p>
|
||
<code className="ap-token" onClick={() => copyToast(created.key)}>{created.key} ⧉</code>
|
||
<p className="ap-keys-try">Try it — your key drives the whole API:</p>
|
||
<div className="ap-keys-curl">
|
||
<code>{`curl -H "Authorization: Bearer ${created.key}" https://amerc.ai/api/agents/classes`}</code>
|
||
<button type="button" className="ap-keys-copy" onClick={() => copyToast(`curl -H "Authorization: Bearer ${created.key}" https://amerc.ai/api/agents/classes`, 'Copied')}>copy</button>
|
||
</div>
|
||
<p className="ap-hint">Full endpoints in the <a href="https://docs.amerc.ai/#/api-reference">API reference</a>.</p>
|
||
</div>
|
||
)}
|
||
{err && <p className="px-auth-err">{err}</p>}
|
||
{keys.map((k) => (
|
||
<div key={k.id} className="ap-mine-inst">
|
||
<code className="ap-token">{k.prefix}…</code> <em>{k.name}</em>
|
||
<span className="ap-hint" style={{ marginLeft: 'auto' }}>{k.last_used ? `used ${new Date(k.last_used).toLocaleDateString()}` : 'never used'}</span>
|
||
<button className="px-action" onClick={() => del(k.id)}>revoke</button>
|
||
</div>
|
||
))}
|
||
{!keys.length && <p className="ap-hint">No keys yet — mint one above.</p>}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// 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([]);
|
||
const [err, setErr] = useState('');
|
||
const [form, setForm] = useState({ name: '', tags: '', description: '', kind: 'codex', command: 'codex --yolo', visibility: 'public', managed: false, terminalMode: 'new', skillsReload: true });
|
||
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;
|
||
const onKey = (e) => { if (e.key === 'Escape') setNewInst(null); };
|
||
window.addEventListener('keydown', onKey);
|
||
return () => window.removeEventListener('keydown', onKey);
|
||
}, [newInst]);
|
||
|
||
const load = useCallback(async () => {
|
||
setErr('');
|
||
try {
|
||
const c = await api('/agents/classes?mine=1');
|
||
setClasses(c.classes || []);
|
||
const all = await Promise.all((c.classes || []).map((x) => api(`/agents/instances?classId=${x.id}`).then((r) => r.instances).catch(() => [])));
|
||
setInsts(all.flat());
|
||
} catch (e) { setErr(e.message); } finally { setLoaded(true); }
|
||
}, []);
|
||
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 (
|
||
<div className="ap ap-gate ap-gate-rich">
|
||
<span className="ap-gate-ico" aria-hidden="true">🗡️</span>
|
||
<h2>Your legion awaits</h2>
|
||
<p>Log in to publish agent classes, run instances, and put your mercenaries to work — as an embedded chatbox, a live web terminal, or behind a public URL.</p>
|
||
<div className="ap-gate-cta">
|
||
<button className="gm-cta-btn gm-cta-primary" onClick={() => setRoute('signin')}>Log in / Sign up</button>
|
||
<button className="gm-cta-btn gm-cta-secondary" onClick={() => setRoute('agents')}>Browse agents</button>
|
||
</div>
|
||
</div>
|
||
);
|
||
|
||
const publishClass = async (e) => {
|
||
e.preventDefault();
|
||
try { await api('/agents/classes', { method: 'POST', body: { ...form, tags: form.tags.split(',').map((t) => t.trim()).filter(Boolean) } }); setForm({ ...form, name: '', tags: '', description: '' }); load(); }
|
||
catch (e2) { setErr(e2.message); }
|
||
};
|
||
const publishInstance = async (classId) => {
|
||
try { const r = await api('/agents/instances', { method: 'POST', body: { classId, visibility: 'public' } }); setNewInst(r); 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>
|
||
{err && <p className="px-auth-err">{err}</p>}
|
||
<div className="ap-booth-grid">
|
||
<form className="ap-publish" onSubmit={publishClass}>
|
||
<h4 className="ap-h4">Publish an agent class</h4>
|
||
<label className="ap-pub-field"><span>Class name</span>
|
||
<input placeholder="e.g. Backend Helper" value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} required />
|
||
</label>
|
||
<label className="ap-pub-field"><span>Tags</span>
|
||
<input placeholder="comma-separated: backend, model" value={form.tags} onChange={(e) => setForm({ ...form, tags: e.target.value })} />
|
||
</label>
|
||
<label className="ap-pub-field"><span>Description</span>
|
||
<textarea placeholder="What does it do for users?" value={form.description} onChange={(e) => setForm({ ...form, description: e.target.value })} />
|
||
</label>
|
||
<div className="ap-row">
|
||
<label className="ap-pub-field"><span>Kind</span>
|
||
<input placeholder="codex" value={form.kind} onChange={(e) => setForm({ ...form, kind: e.target.value })} />
|
||
</label>
|
||
<label className="ap-pub-field"><span>Visibility</span>
|
||
<select value={form.visibility} onChange={(e) => setForm({ ...form, visibility: e.target.value })}><option value="public">public</option><option value="private">private publish</option></select>
|
||
</label>
|
||
</div>
|
||
<label className="ap-pub-field"><span>Launch command</span>
|
||
<input placeholder="codex --yolo" value={form.command} onChange={(e) => setForm({ ...form, command: e.target.value })} />
|
||
</label>
|
||
<p className="ap-hint">Kind is your agent's runtime; the launch command is how your broker starts it.</p>
|
||
<div className="ap-pub-props">
|
||
<label className="ap-check"><input type="checkbox" checked={form.managed} onChange={(e) => setForm({ ...form, managed: e.target.checked })} /> amerc-managed — load amerc skills into the agent</label>
|
||
<label className="ap-pub-field"><span>Terminal per session</span>
|
||
<select value={form.terminalMode} onChange={(e) => setForm({ ...form, terminalMode: e.target.value })}>
|
||
<option value="new">new terminal for each session</option>
|
||
<option value="shared">same terminal, shared across sessions</option>
|
||
</select>
|
||
</label>
|
||
{form.terminalMode === 'shared' && (
|
||
<label className="ap-check"><input type="checkbox" checked={form.skillsReload} onChange={(e) => setForm({ ...form, skillsReload: e.target.checked })} /> reload the client's skills on each new session</label>
|
||
)}
|
||
</div>
|
||
<button className="px-action px-auth-submit" type="submit">Publish class</button>
|
||
</form>
|
||
<div className="ap-mine">
|
||
{!loaded ? (
|
||
<div className="ap-mine-skel" aria-hidden="true">
|
||
<span className="sk-p" style={{ width: '40%', height: 12 }} />
|
||
<div className="ap-skel-row"><span className="sk-av" /><span className="sk-p" style={{ flex: 1 }} /></div>
|
||
<div className="ap-skel-row"><span className="sk-av" /><span className="sk-p" style={{ flex: 1 }} /></div>
|
||
<span className="sk-p" style={{ width: '30%', height: 12, marginTop: 14 }} />
|
||
<div className="ap-skel-row"><span className="sk-p" style={{ flex: 1 }} /></div>
|
||
</div>
|
||
) : !classes.length ? (
|
||
<div className="ap-booth-welcome">
|
||
<span className="ap-booth-welcome-ico" aria-hidden="true">🗡️</span>
|
||
<h3>Welcome to your legion, {auth.user.handle}.</h3>
|
||
<p>This is where you arm and dispatch your mercenaries. Three steps to go live:</p>
|
||
<ol>
|
||
<li><b>Publish a class</b> — name your agent and tag its skills, in the form on the left.</li>
|
||
<li><b>Run an instance</b> — your broker connects and it turns <span className="ap-on-word">online</span>.</li>
|
||
<li><b>Deliver it</b> — embed a chatbox, drive a web terminal, or expose a local port via <b>My Mansion</b>.</li>
|
||
</ol>
|
||
<p className="ap-hint">Your broker authenticates with an instance accesskey (shown when you publish one) or an agent key — mint one below.</p>
|
||
</div>
|
||
) : (
|
||
<>
|
||
<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>
|
||
</div>
|
||
<div className="ap-chatlog">
|
||
<h4 className="ap-h4">💬 My chatlog</h4>
|
||
<p className="ap-hint">Every session keeps its log — reopen one to read it back, or continue it if still active.</p>
|
||
<SessionLog onOpen={setLogOpen} />
|
||
</div>
|
||
<KeysCard />
|
||
{logOpen && (
|
||
<div className="ap-modal" onClick={() => setLogOpen(null)}>
|
||
<div className="ap-modal-box ap-modal-chat" onClick={(e) => e.stopPropagation()}>
|
||
<ChatPanel instance={{ id: logOpen.instance_id, name: logOpen.inst_name || logOpen.class_name }} sessionId={logOpen.id} onClose={() => setLogOpen(null)} />
|
||
</div>
|
||
</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 (
|
||
<div className="ap-modal" onClick={() => setNewInst(null)}>
|
||
<div className="ap-modal-box" onClick={(e) => e.stopPropagation()}>
|
||
<div className="ap-modal-head"><h3>Instance #{newInst.id} — bring it online</h3><button onClick={() => setNewInst(null)}>✕</button></div>
|
||
<p className="ap-hint">Registered, but <b style={{ color: '#ff9f5a' }}>offline</b> until a broker checks in. Two steps bring it to life:</p>
|
||
<ol className="ap-steps">
|
||
<li><b>Heartbeat</b> — keeps it marked online (check in every <30s). Paste this anywhere with <code>curl</code>:
|
||
<div className="ap-codeblock"><code>{hb}</code><button className="px-action" onClick={() => copyToast(hb, 'Heartbeat loop copied')}>copy</button></div>
|
||
</li>
|
||
<li><b>Relay</b> — bridge your agent so live sessions reach it, over the broker WebSocket:
|
||
<code className="ap-token" onClick={() => copyToast(newInst.relayWs)}>{newInst.relayWs} ⧉</code>
|
||
<span className="ap-hint">The amerc webagent broker speaks this relay for you — point it at the accesskey below.</span>
|
||
</li>
|
||
</ol>
|
||
<label className="ap-field">accesskey · your broker's secret<code className="ap-token" onClick={() => copyToast(newInst.accesskey)}>{newInst.accesskey} ⧉</code></label>
|
||
</div>
|
||
</div>
|
||
);
|
||
})()}
|
||
</div>
|
||
);
|
||
}
|