amerc/src/AgentPlatform.jsx
artheru fb3c523d4a booth + mansion: fix loading-state flashes (0.57.0)
- BoothDashboard had no loaded flag, so returning publishers briefly saw the
  'Welcome to your booth' new-user onboarding before their classes loaded
- add loaded-gating + a shimmer skeleton; welcome shows only after load
- same fix for the Mansion 'My showcases' list ('No showcases yet' flash)
- completes the loading-skeleton pattern across all data surfaces
  (Browse, Booth, Mansion)
- verified live (throttled): skeleton during load, no welcome flash,
  welcome/content only after loaded
2026-06-10 08:10:50 +08:00

344 lines
20 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, 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]);
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={() => setOpen(c.id)}>
<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('booth')}>
<span className="ap-add-plus" aria-hidden="true"></span>
<strong>Publish your own</strong>
<p>Register an agent class and run it from your booth your mercenary joins the roster right here.</p>
<span className="ap-add-cta">Open My Booth </span>
</button>
)}
{loaded && !classes.length && (
<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('booth')}>My Booth</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('booth')}>Open My Booth </button>
</div>
)}
</div>
{open && <ClassModal id={open} auth={auth} onClose={() => { setOpen(null); load(); }} />}
</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]);
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 publish one in My Booth.</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 Booth: 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></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 BoothDashboard({ 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);
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"><h2>My Booth</h2><p>Log in to publish and run agents.</p><button className="px-action" onClick={() => setRoute('signin')}>Go to Login</button></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 Booth</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>
<input placeholder="class name" value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} required />
<input placeholder="tags (comma): backend, model" value={form.tags} onChange={(e) => setForm({ ...form, tags: e.target.value })} />
<textarea placeholder="description" value={form.description} onChange={(e) => setForm({ ...form, description: e.target.value })} />
<div className="ap-row">
<input placeholder="kind (codex)" value={form.kind} onChange={(e) => setForm({ ...form, kind: e.target.value })} />
<select value={form.visibility} onChange={(e) => setForm({ ...form, visibility: e.target.value })}><option value="public">public</option><option value="private">private publish</option></select>
</div>
<input placeholder="launch command" value={form.command} onChange={(e) => setForm({ ...form, command: e.target.value })} />
<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 booth, {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 the <b>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>
);
}