amerc/src/AgentPlatform.jsx
artheru c2a8616255 legion: rename My Booth -> My Legion, Mansion -> My Mansion (1.0.0)
Route 'legion' with a legacy '#/booth' alias; BoothDashboard -> LegionDashboard;
nav, footer, hotspots, WebMCP enum, gate copy all renamed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 02:33:41 +08:00

421 lines
24 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';
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%))` }; }
// ---- 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">
<div className="ap-store-head">
<div className="ap-store-intro">
<h2>The mercenary roster</h2>
<p>Hire a ready-made agent by skill and tag chat with it, embed it on your site, or drive its terminal. Bring your own and it joins the roster.</p>
</div>
<input className="ap-search" placeholder="search the roster…" value={q} onChange={(e) => setQ(e.target.value)} />
</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="ap-grid">
{!loaded && Array.from({ length: 3 }).map((_, i) => (
<div key={`sk${i}`} className="ap-card ap-skel" aria-hidden="true">
<div className="ap-skel-head"><span className="sk-av" /><span className="sk-tt"><i /><i /></span></div>
<span className="sk-p" /><span className="sk-p sk-short" />
<div className="ap-skel-foot"><i /><i /></div>
</div>
))}
{loaded && classes.map((c) => (
<button key={c.id} className="ap-card" onClick={() => openClass(c)}>
<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}</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>
))}
{loaded && classes.length > 0 && (
<button className="ap-card ap-card-add" onClick={() => setRoute('legion')}>
<span className="ap-add-plus" aria-hidden="true"></span>
<strong>Publish your own</strong>
<p>Register an agent class and field it from your legion your mercenary joins the roster right here.</p>
<span className="ap-add-cta">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 match {q ? `${q}` : `#${tag}`}.</h3>
<p>Try a different term or clear the filter.</p>
<button className="px-action" onClick={() => { setQ(''); setTag(''); }}>Clear search</button>
</div>
)}
{loaded && !classes.length && !q && !tag && (
<div className="ap-empty-start">
<h3>No agents here yet be the first.</h3>
<ol>
<li><b>Publish a class</b> in <button className="ap-link" onClick={() => setRoute('legion')}>My Legion</button> name it, tag it (e.g. <code>backend</code>, <code>model</code>).</li>
<li><b>Run an instance</b> your broker connects and it goes <span style={{ color: STATUS_COLOR.online }}>online</span>.</li>
<li><b>Use it</b> embed it as a chatbox or drive it through a web 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 (
<div className="ap-modal" onClick={onClose}>
<div className="ap-modal-box" onClick={(e) => e.stopPropagation()}>
<div className="ap-modal-head">
<div className="ap-modal-title">
<span className="ap-avatar" style={avatarStyle(c.name)}>{classEmoji(c)}</span>
<div><h3>{c.name}</h3><span className="ap-kind">{c.kind}</span> <span className="ap-by">by {c.owner}</span></div>
</div>
<button onClick={onClose}></button>
</div>
<p className="ap-desc">{c.description}</p>
<div className="ap-card-tags">{c.tags.map((t) => <span key={t}>{t}</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">Instances</h4>
<div className="ap-inst-list">
{data.instances.map((i) => (
<div key={i.id} className="ap-inst">
<span className="ap-inst-status" style={{ background: STATUS_COLOR[i.status] }} title={i.status} />
<span className="ap-inst-name">#{i.id} {i.name}</span>
<span className="ap-inst-meta">{i.status}{i.broker ? ` · ${i.broker}` : ''} · {i.visibility}</span>
<button className="px-action" disabled={i.status !== 'online'} onClick={() => setUse(i)}>Use </button>
</div>
))}
{!data.instances.length && <p className="ap-hint">No instances published. The owner can field one in My Legion.</p>}
</div>
{use && <UsePanel instance={use} onClose={() => setUse(null)} />}
</div>
</div>
);
}
// ---- Use an instance: chatbox + 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 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]);
useEffect(() => { if (instance.accesskey) start(); }, []); // owner: auto-start
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>
{!sess && (
<div className="ap-use-start">
{!instance.accesskey && instance.visibility !== 'public' && (
<input placeholder="instance accesskey (private)" value={accesskey} onChange={(e) => setAccesskey(e.target.value)} />
)}
<button className="px-action" onClick={start}>Connect {mode}</button>
{err && <p className="px-auth-err">{err}</p>}
</div>
)}
{sess && (
<>
<iframe title="agent" className="ap-iframe" src={`https://webagent.amerc.ai/chat_session?session=${encodeURIComponent(sess.relayToken)}`} />
{mode === 'chatbox' && (
<div className="ap-embed">
<div className="ap-embed-head"><span>📎 Embed this agent on any website</span><button className="px-action" onClick={() => copyToast(`<script src="${sess.chatboxJs}"></script>`, 'Embed snippet copied')}>Copy snippet</button></div>
<code>{`<script src="${sess.chatboxJs}"></script>`}</code>
<p className="ap-hint">Paste before <code>&lt;/body&gt;</code> a chat bubble appears bottom-right, and on connect your page's skills are injected into the agent.</p>
</div>
)}
{mode === 'webpty' && <p className="ap-hint ap-pty-note">🖥 Switch to the <b>后台 / Backstage</b> tab above to watch and drive the agent's live terminal.</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>
);
}
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' });
const [newInst, setNewInst] = useState(null);
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) load(); }, [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); }
};
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>
{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>
<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">My classes</h4>
{classes.map((c) => (
<div key={c.id} className="ap-mine-class">
<div className="ap-mine-row">
<span className="ap-avatar ap-avatar-sm" style={avatarStyle(c.name)}>{classEmoji(c)}</span>
<strong>{c.name}</strong><span className="ap-kind">{c.visibility}</span>
<span className="ap-online"><i className={c.onlineCount ? 'pulse' : ''} style={{ background: c.onlineCount ? STATUS_COLOR.online : '#555' }} />{c.onlineCount}/{c.instanceCount}</span>
<button className="px-action" onClick={() => publishInstance(c.id)}>+ Publish instance</button></div>
<div className="ap-card-tags">{c.tags.map((t) => <span key={t}>{t}</span>)}</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>
</div>
<KeysCard />
{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>
);
}