amerc/src/AgentPlatform.jsx
artheru e308c9be4d 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>
2026-06-11 09:22:56 +08:00

541 lines
32 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 &lt;key&gt;</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 &lt;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>
);
}