amerc/src/AgentPlatform.jsx
artheru 0ecf037b5a account/settings page (0.35.0)
- new #/account: profile card (avatar initials + role badge + quick links),
  change-password, and agent keys consolidated in one place
- topbar username now opens Account; separate logout button
- password change moved out of the tavern signed-in panel into a proper settings surface
2026-06-10 05:39:56 +08:00

289 lines
16 KiB
JavaScript

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 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); }
}, [tag, q]);
useEffect(() => { load(); }, [load]);
return (
<div className="ap">
<div className="ap-head">
<h2>Browse Agents</h2>
<input className="ap-search" placeholder="search classes…" 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)}>{t} <em>{n}</em></button>
))}
</div>
{err && <p className="px-auth-err">{err}</p>}
<div className="ap-grid">
{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>
))}
{!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' && (
<details className="ap-embed">
<summary>Embed this agent on any site</summary>
<code>{`<script src="${sess.chatboxJs}"></script>`}</code>
</details>
)}
</>
)}
</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 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); }
}, []);
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">
<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>
))}
{!classes.length && <p className="ap-hint">No classes yet publish one on the left.</p>}
<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 && (
<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} created</h3><button onClick={() => setNewInst(null)}></button></div>
<p className="ap-hint">Run your broker against this instance, then it shows online. The accesskey is the relay token.</p>
<label className="ap-field">accesskey<code className="ap-token" onClick={() => copyToast(newInst.accesskey)}>{newInst.accesskey} </code></label>
<label className="ap-field">broker WS<code className="ap-token" onClick={() => copyToast(newInst.relayWs)}>{newInst.relayWs} </code></label>
<label className="ap-field">heartbeat<code className="ap-token">POST /api/agents/instances/{newInst.id}/heartbeat {'{'}accesskey,broker,tui{'}'}</code></label>
</div>
</div>
)}
</div>
);
}