live activity feed (0.31.0): 'Live in the tavern' on the landing

- /api/agents/activity: derived recent events (classes published, instances run,
  sessions started, showcases opened) — no new schema
- landing feed with per-kind icons + relative time, auto-refreshes every 15s
- social proof / 'platform is alive' on the home page
This commit is contained in:
artheru 2026-06-10 05:18:13 +08:00
parent 4bcad5f9c7
commit 0de81620b0
5 changed files with 46 additions and 3 deletions

View File

@ -7,7 +7,7 @@
name="description"
content="amerc is an agent mercenary layer for embedding, bringing, and hosting agents across vertical software boundaries."
/>
<meta name="version" content="0.30.0-landing" />
<meta name="version" content="0.31.0-activity" />
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />

View File

@ -1,6 +1,6 @@
{
"name": "amerc-site",
"version": "0.30.0-landing",
"version": "0.31.0-activity",
"private": true,
"type": "module",
"scripts": {

View File

@ -349,6 +349,21 @@ const server = http.createServer(async (req, res) => {
return send(res, 200, { ok: true, classes, instances: insts.length, online, sessions, owners, tags });
}
// live activity feed (public, derived from existing tables)
if (path === '/agents/activity' && method === 'GET') {
const items = [];
for (const c of db.prepare("SELECT name, owner, created_at FROM agent_classes WHERE visibility='public' ORDER BY created_at DESC LIMIT 8").all())
items.push({ kind: 'class', text: `${c.owner} published “${c.name}`, at: c.created_at });
for (const i of db.prepare("SELECT i.created_at, i.owner, c.name cn FROM agent_instances i JOIN agent_classes c ON c.id=i.class_id WHERE i.visibility='public' ORDER BY i.created_at DESC LIMIT 8").all())
items.push({ kind: 'instance', text: `${i.owner} ran an instance of “${i.cn}`, at: i.created_at });
for (const s of db.prepare("SELECT s.started_at, c.name cn FROM agent_sessions s LEFT JOIN agent_classes c ON c.id=s.class_id ORDER BY s.started_at DESC LIMIT 8").all())
items.push({ kind: 'session', text: `a session started on “${s.cn || 'an agent'}`, at: s.started_at });
for (const sc of db.prepare('SELECT owner, host, created_at FROM showcases ORDER BY created_at DESC LIMIT 5').all())
items.push({ kind: 'showcase', text: `${sc.owner} opened a showcase ${sc.host}`, at: sc.created_at });
items.sort((a, b) => b.at - a.at);
return send(res, 200, { ok: true, activity: items.slice(0, 12) });
}
// browse classes (public + own private)
if (path === '/agents/classes' && method === 'GET') {
const tag = url.searchParams.get('tag'); const q = (url.searchParams.get('q') || '').toLowerCase(); const mine = url.searchParams.get('mine');

View File

@ -6,7 +6,7 @@ import Mansion from './Mansion.jsx';
import TavernGame from './TavernGame.jsx';
import { api } from './api.js';
const RELEASE = '0.30.0-landing';
const RELEASE = '0.31.0-activity';
const NAV = [
{ id: 'home', label: 'Tavern' },
@ -102,6 +102,23 @@ const HF_USES = [
{ icon: '🖥️', title: 'Web terminal', body: 'Open a relayed PTY to the agents terminal — watch and drive it right from the browser.' },
{ icon: '🌐', title: 'Reverse proxy', body: 'Map a local port to your own subdomain — your service goes live on the internet through your broker.' },
];
function relTime(at) { const s = Math.floor((Date.now() - at) / 1000); if (s < 60) return `${s}s ago`; const m = Math.floor(s / 60); if (m < 60) return `${m}m ago`; const h = Math.floor(m / 60); if (h < 24) return `${h}h ago`; return `${Math.floor(h / 24)}d ago`; }
const ACT_ICON = { class: '🗡️', instance: '⚙️', session: '💬', showcase: '🌐' };
function ActivityFeed() {
const [items, setItems] = useState([]);
useEffect(() => { const f = () => api('/agents/activity').then((d) => setItems(d.activity || [])).catch(() => {}); f(); const t = setInterval(f, 15000); return () => clearInterval(t); }, []);
if (!items.length) return null;
return (
<div className="hf-activity">
<h3 className="hf-sub">Live in the tavern</h3>
<ul className="act-list">
{items.map((a, i) => (
<li key={i}><span className="act-ico">{ACT_ICON[a.kind] || '•'}</span><span className="act-text">{a.text}</span><span className="act-time">{relTime(a.at)}</span></li>
))}
</ul>
</div>
);
}
function HomeFeatures({ setRoute }) {
return (
<section className="hf">
@ -123,6 +140,7 @@ function HomeFeatures({ setRoute }) {
<div key={i} className="hf-use"><span className="hf-ico-sm">{u.icon}</span><div><strong>{u.title}</strong><p>{u.body}</p></div></div>
))}
</div>
<ActivityFeed />
<div className="hf-cta">
<button className="px-action" onClick={() => setRoute('agents')}>Browse Agents</button>
<button className="px-action" onClick={() => setRoute('booth')}>Publish an Agent</button>

View File

@ -1136,3 +1136,13 @@ button {
.hf-use strong { color: #fff; font-size: 14px; } .hf-use p { margin: 4px 0 0; color: #9a86b8; font-size: 12px; line-height: 1.5; }
.hf-cta { display: flex; gap: 12px; justify-content: center; margin: 38px 0 0; }
@media (max-width: 760px) { .hf-grid, .hf-uses { grid-template-columns: 1fr; } }
/* live activity feed (0.31) */
.hf-activity { max-width: 640px; margin: 0 auto; }
.act-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 2px; }
.act-list li { display: flex; align-items: center; gap: 12px; padding: 11px 14px; border-radius: 10px; background: #0e0a18; border: 1px solid #1d1430; animation: act-in .4s ease; }
.act-list li:hover { border-color: #2e2440; }
@keyframes act-in { from { opacity: 0; transform: translateX(-6px); } to { opacity: 1; transform: none; } }
.act-ico { font-size: 16px; flex: none; }
.act-text { font-size: 13px; color: #c9b8e6; flex: 1; }
.act-time { font-size: 11px; color: #6f5b8c; flex: none; }