- 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
289 lines
16 KiB
JavaScript
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 <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></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>
|
|
);
|
|
}
|