legion: agent cards reused + class runtime properties (1.2.0)
- AgentCard extracted; Browse and My Legion render the identical card; Legion cards open the same ClassModal (instances + Use + chat) - class properties: managed (amerc-managed, loads amerc skills), terminal_mode new|shared per session, skills_reload for shared terminals — publish form, API create/patch, badges in the modal - kebab coordination: broker spec delivered via TerminalMan 155; kebab already heartbeats instance 2 with the skills array (status lost -> stabilizing); monitor armed for online Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
2941f14583
commit
b4b5df91f0
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "amerc-site",
|
||||
"version": "1.1.0-chat",
|
||||
"version": "1.2.0-legioncards",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@ -76,6 +76,12 @@ db.exec(`
|
||||
|
||||
try { db.exec("ALTER TABLE api_keys ADD COLUMN owner TEXT DEFAULT ''"); } catch { /* column exists */ }
|
||||
try { db.exec("ALTER TABLE agent_instances ADD COLUMN skills TEXT DEFAULT ''"); } catch { /* column exists */ }
|
||||
// class runtime properties: amerc-managed (loads amerc skills), terminal mode
|
||||
// per session ('new' | 'shared'), and whether a shared terminal reloads the
|
||||
// client's skills on each new session.
|
||||
try { db.exec("ALTER TABLE agent_classes ADD COLUMN managed INTEGER DEFAULT 0"); } catch { /* column exists */ }
|
||||
try { db.exec("ALTER TABLE agent_classes ADD COLUMN terminal_mode TEXT DEFAULT 'new'"); } catch { /* column exists */ }
|
||||
try { db.exec("ALTER TABLE agent_classes ADD COLUMN skills_reload INTEGER DEFAULT 1"); } catch { /* column exists */ }
|
||||
|
||||
// ---- live chat plumbing (in-memory; survives nothing, stores nothing) -------
|
||||
// waiters: long-poll watchers per session, woken when a message lands.
|
||||
@ -464,8 +470,9 @@ const server = http.createServer(async (req, res) => {
|
||||
const tags = Array.isArray(b.tags) ? b.tags.join(',') : (b.tags || '');
|
||||
let slug = slugify(b.slug || b.name); let n = 1;
|
||||
while (db.prepare('SELECT id FROM agent_classes WHERE slug=?').get(slug)) slug = `${slugify(b.name)}-${++n}`;
|
||||
const info = db.prepare('INSERT INTO agent_classes(slug,name,owner,tags,description,kind,command,visibility,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?,?,?)')
|
||||
.run(slug, b.name || 'Agent', who(id), tags, b.description || '', b.kind || 'codex', b.command || '', b.visibility === 'private' ? 'private' : 'public', now, now);
|
||||
const info = db.prepare('INSERT INTO agent_classes(slug,name,owner,tags,description,kind,command,visibility,managed,terminal_mode,skills_reload,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?)')
|
||||
.run(slug, b.name || 'Agent', who(id), tags, b.description || '', b.kind || 'codex', b.command || '', b.visibility === 'private' ? 'private' : 'public',
|
||||
b.managed ? 1 : 0, b.terminalMode === 'shared' ? 'shared' : 'new', b.skillsReload === false ? 0 : 1, now, now);
|
||||
return send(res, 200, { ok: true, id: Number(info.lastInsertRowid), slug });
|
||||
}
|
||||
if ((m = path.match(/^\/agents\/classes\/(\d+)$/)) && method === 'GET') {
|
||||
@ -481,8 +488,10 @@ const server = http.createServer(async (req, res) => {
|
||||
if (!owns(c)) return send(res, 403, { ok: false, error: 'not owner' });
|
||||
if (method === 'DELETE') { db.prepare('DELETE FROM agent_classes WHERE id=?').run(c.id); db.prepare('DELETE FROM agent_instances WHERE class_id=?').run(c.id); return send(res, 200, { ok: true }); }
|
||||
const b = await readBody(req) || {}; const tags = Array.isArray(b.tags) ? b.tags.join(',') : (b.tags ?? c.tags);
|
||||
db.prepare('UPDATE agent_classes SET name=?,tags=?,description=?,kind=?,command=?,visibility=?,updated_at=? WHERE id=?')
|
||||
.run(b.name ?? c.name, tags, b.description ?? c.description, b.kind ?? c.kind, b.command ?? c.command, b.visibility ?? c.visibility, Date.now(), c.id);
|
||||
db.prepare('UPDATE agent_classes SET name=?,tags=?,description=?,kind=?,command=?,visibility=?,managed=?,terminal_mode=?,skills_reload=?,updated_at=? WHERE id=?')
|
||||
.run(b.name ?? c.name, tags, b.description ?? c.description, b.kind ?? c.kind, b.command ?? c.command, b.visibility ?? c.visibility,
|
||||
b.managed != null ? (b.managed ? 1 : 0) : c.managed, b.terminalMode != null ? (b.terminalMode === 'shared' ? 'shared' : 'new') : c.terminal_mode,
|
||||
b.skillsReload != null ? (b.skillsReload ? 1 : 0) : c.skills_reload, Date.now(), c.id);
|
||||
return send(res, 200, { ok: true });
|
||||
}
|
||||
// subscription token for a class
|
||||
|
||||
@ -8,6 +8,24 @@ const TAG_EMOJI = { backend: '🛠️', model: '🧠', frontend: '🎨', ui: '
|
||||
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([]);
|
||||
@ -82,20 +100,7 @@ export function AgentBrowse({ auth, setRoute }) {
|
||||
<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.map((c) => <AgentCard key={c.id} c={c} onClick={() => openClass(c)} />)}
|
||||
{loaded && classes.length > 0 && (
|
||||
<button className="ap-card ap-card-add" onClick={() => setRoute('legion')}>
|
||||
<span className="ap-add-plus" aria-hidden="true">+</span>
|
||||
@ -152,6 +157,11 @@ function ClassModal({ id, auth, onClose }) {
|
||||
</div>
|
||||
<p className="ap-desc">{c.description}</p>
|
||||
<div className="ap-card-tags">{c.tags.map((t) => <span key={t}>{t}</span>)}</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>}
|
||||
@ -270,9 +280,10 @@ 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 [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 [loaded, setLoaded] = useState(false);
|
||||
useEffect(() => {
|
||||
if (!newInst) return;
|
||||
@ -344,6 +355,18 @@ export function LegionDashboard({ auth, setRoute }) {
|
||||
<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">
|
||||
@ -369,17 +392,18 @@ export function LegionDashboard({ auth, setRoute }) {
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<h4 className="ap-h4">My classes</h4>
|
||||
<h4 className="ap-h4">My classes — your banners on the roster</h4>
|
||||
<div className="ap-mine-cards">
|
||||
{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 key={c.id} className="ap-mine-cardwrap">
|
||||
<AgentCard c={c} onClick={() => setOpenCls(c.id)} />
|
||||
<div className="ap-mine-actions">
|
||||
<span className="ap-kind">{c.visibility}</span>
|
||||
<button className="px-action" onClick={() => publishInstance(c.id)}>+ Field an instance</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<h4 className="ap-h4">My instances</h4>
|
||||
{insts.map((i) => (
|
||||
<div key={i.id} className="ap-mine-inst">
|
||||
@ -406,6 +430,7 @@ export function LegionDashboard({ auth, setRoute }) {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{openCls && <ClassModal id={openCls} auth={auth} onClose={() => { setOpenCls(null); 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 (
|
||||
|
||||
@ -10,7 +10,7 @@ import { api } from './api.js';
|
||||
const Menu = ({ size = 20 }) => (<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" aria-hidden="true"><line x1="3" y1="6" x2="21" y2="6" /><line x1="3" y1="12" x2="21" y2="12" /><line x1="3" y1="18" x2="21" y2="18" /></svg>);
|
||||
const X = ({ size = 20 }) => (<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" aria-hidden="true"><line x1="18" y1="6" x2="6" y2="18" /><line x1="6" y1="6" x2="18" y2="18" /></svg>);
|
||||
|
||||
const RELEASE = '1.1.0-chat';
|
||||
const RELEASE = '1.2.0-legioncards';
|
||||
|
||||
const NAV = [
|
||||
{ id: 'home', label: 'Tavern' },
|
||||
@ -477,6 +477,7 @@ function StatusPage() {
|
||||
}
|
||||
|
||||
const CHANGELOG = [
|
||||
{ v: '1.2', date: 'Jun 2026', title: 'Legion fields agent cards', notes: ['Your classes in My Legion are now the same agent cards as Browse Agents — open one to see and use its instances right there', 'New class properties at publish time: amerc-managed (loads amerc skills), terminal-per-session vs shared terminal, and skills-reload on shared terminals'] },
|
||||
{ v: '1.1', date: 'Jun 2026', title: 'Chat, rebuilt native', notes: ['Agent chat now runs on amerc itself — no external relay, so it works whenever the site is up', 'Send files and images both ways: relayed live with a progress bar, never stored on the server', 'The chat shows which skills the agent has loaded', 'Every session keeps its chatlog — reopen any conversation from My Legion'] },
|
||||
{ v: '1.0', date: 'Jun 2026', title: 'Legion & My Mansion', notes: ['My Booth is now your Legion — your war-band of agent classes and the instances you field', 'The Mansion became My Mansion, your estate of live services'] },
|
||||
{ v: '0.99', date: 'Jun 2026', title: 'Clearer publish form', notes: ['The Booth publish form now labels every field, so the pre-filled Kind and Launch command are no longer ambiguous'] },
|
||||
|
||||
@ -1628,3 +1628,15 @@ button {
|
||||
.ch-log-last { color: #8595ab; font-size: 11.5px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.ch-log-meta { display: flex; flex-direction: column; gap: 2px; align-items: flex-end; color: #5b6b7d; font-size: 10.5px; flex: 0 0 auto; }
|
||||
.ap-modal-chat { width: min(660px, 94vw); padding: 10px; }
|
||||
|
||||
/* class runtime properties + Legion card reuse (1.2) */
|
||||
.ap-props { display: flex; flex-wrap: wrap; gap: 6px; margin: 10px 0 4px; }
|
||||
.ap-prop { background: #131a29; border: 1px solid #243049; color: #9fb2c8; border-radius: 999px; padding: 3px 11px; font-size: 11px; }
|
||||
.ap-prop.managed { background: rgba(54, 240, 176, 0.07); border-color: #1f5642; color: #5fe0b0; }
|
||||
.ap-pub-props { display: flex; flex-direction: column; gap: 9px; margin: 4px 0 2px; padding: 10px 12px; background: #0d1320; border: 1px solid #20283c; border-radius: 10px; }
|
||||
.ap-check { display: flex; align-items: center; gap: 8px; color: #b6c5da; font-size: 12px; cursor: pointer; }
|
||||
.ap-check input { accent-color: #46c8e0; width: 14px; height: 14px; }
|
||||
.ap-mine-cards { display: grid; grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); gap: 12px; margin-bottom: 14px; }
|
||||
.ap-mine-cardwrap { display: flex; flex-direction: column; gap: 7px; }
|
||||
.ap-mine-cardwrap .ap-card { width: 100%; }
|
||||
.ap-mine-actions { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
|
||||
|
||||
Loading…
Reference in New Issue
Block a user