- New backend endpoint /api/history?hours=N returns chronological replay frames reconstructed from the events table: agent positions, energy/knowledge/influence/credits/mood, tool, model, tau, pace, clocks and drift per tick/action. - Frontend: new Replay panel with Play/Pause, timeline slider, Live button and history window selector (1h/3h/6h/12h/24h). - Canvas draw() now renders either the live snapshot or the current replay frame; agent cards and drift/clock panels sync in replay mode. - Overlay on the canvas shows current tick, timestamp, agent action and model while replaying. - Also adds the missing refreshTexts() implementation so the Generated Texts panel is populated. Verified: /api/history returns frames; JS syntax check passes; pytest 100/100; web UI renders with new Replay controls.
517 lines
16 KiB
JavaScript
517 lines
16 KiB
JavaScript
// Emergence-Mini live view
|
||
const canvas = document.getElementById('world');
|
||
const ctx = canvas.getContext('2d');
|
||
const GRID = 240;
|
||
let snapshot = null;
|
||
let actions = [];
|
||
let replay = { frames: [], idx: 0, playing: false, raf: null, lastFrame: 0 };
|
||
let mode = 'live'; // 'live' or 'replay'
|
||
const AGENT_COLORS = {
|
||
anchor: '#ffd166',
|
||
flora: '#6cf0c2',
|
||
lovely: '#ff8fb1',
|
||
spark: '#82aaff',
|
||
};
|
||
const LANDMARK_COLORS = {
|
||
town_hall: '#ff6c6c',
|
||
library: '#82aaff',
|
||
plaza: '#ff8fb1',
|
||
park: '#6cf08a',
|
||
victory_arch: '#ffd166',
|
||
police: '#cccccc',
|
||
bookworm: '#a47cff',
|
||
cafe: '#ffb066',
|
||
billboard: '#dddddd',
|
||
techhub: '#6cf0c2',
|
||
home_anchor: '#3a3a3a',
|
||
home_flora: '#3a3a3a',
|
||
home_lovely: '#3a3a3a',
|
||
home_spark: '#3a3a3a',
|
||
};
|
||
|
||
function effectiveState() {
|
||
if (mode === 'replay' && replay.frames.length) {
|
||
const f = replay.frames[Math.min(replay.idx, replay.frames.length - 1)];
|
||
const agents = [];
|
||
for (const id of Object.keys(f.agents)) {
|
||
const a = f.agents[id];
|
||
agents.push({
|
||
id, name: a.name || id,
|
||
x: a.x, y: a.y,
|
||
energy: a.energy ?? 0,
|
||
knowledge: a.knowledge ?? 0,
|
||
influence: a.influence ?? 0,
|
||
credits: a.credits ?? 0,
|
||
mood: a.mood || 'neutral',
|
||
model: a.model || '',
|
||
tool: a.tool || '',
|
||
});
|
||
}
|
||
return {
|
||
agents,
|
||
landmarks: snapshot ? snapshot.landmarks : [],
|
||
clocks: f.clocks || {},
|
||
drift: f.drift,
|
||
tick: f.tick,
|
||
_replayFrame: f,
|
||
};
|
||
}
|
||
return snapshot;
|
||
}
|
||
|
||
function draw() {
|
||
const state = effectiveState();
|
||
if (!state) return;
|
||
const W = canvas.width, H = canvas.height;
|
||
ctx.fillStyle = '#060a0e';
|
||
ctx.fillRect(0, 0, W, H);
|
||
|
||
// grid
|
||
ctx.strokeStyle = '#0e1620';
|
||
ctx.lineWidth = 1;
|
||
for (let i = 0; i <= GRID; i += 30) {
|
||
const px = (i / GRID) * W;
|
||
ctx.beginPath(); ctx.moveTo(px, 0); ctx.lineTo(px, H); ctx.stroke();
|
||
ctx.beginPath(); ctx.moveTo(0, px); ctx.lineTo(W, px); ctx.stroke();
|
||
}
|
||
|
||
// landmarks
|
||
for (const lm of state.landmarks) {
|
||
const x = (lm.x / GRID) * W;
|
||
const y = (lm.y / GRID) * H;
|
||
const isHome = lm.id.startsWith('home_');
|
||
ctx.fillStyle = LANDMARK_COLORS[lm.id] || '#888';
|
||
ctx.beginPath();
|
||
ctx.arc(x, y, isHome ? 5 : 7, 0, Math.PI * 2);
|
||
ctx.fill();
|
||
if (!isHome) {
|
||
ctx.fillStyle = '#0a1018';
|
||
ctx.font = '10px ui-monospace';
|
||
ctx.textAlign = 'center';
|
||
ctx.fillText(lm.name, x, y - 9);
|
||
}
|
||
}
|
||
|
||
// agents
|
||
for (const a of state.agents) {
|
||
const x = (a.x / GRID) * W;
|
||
const y = (a.y / GRID) * H;
|
||
ctx.fillStyle = AGENT_COLORS[a.id] || '#fff';
|
||
ctx.beginPath();
|
||
ctx.arc(x, y, 8, 0, Math.PI * 2);
|
||
ctx.fill();
|
||
ctx.fillStyle = '#0a1018';
|
||
ctx.font = 'bold 10px ui-monospace';
|
||
ctx.textAlign = 'center';
|
||
ctx.fillText(a.name[0], x, y + 3);
|
||
ctx.strokeStyle = '#ffd166';
|
||
ctx.lineWidth = 1;
|
||
ctx.beginPath();
|
||
ctx.arc(x, y, 8 + (100 - a.energy) * 0.06, 0, Math.PI * 2);
|
||
ctx.stroke();
|
||
}
|
||
|
||
if (mode === 'replay' && state._replayFrame) {
|
||
drawReplayOverlay(state._replayFrame);
|
||
}
|
||
}
|
||
|
||
function drawReplayOverlay(f) {
|
||
const W = canvas.width, H = canvas.height;
|
||
ctx.fillStyle = 'rgba(6,10,14,0.75)';
|
||
ctx.fillRect(8, H - 66, 220, 58);
|
||
ctx.strokeStyle = '#1c2733';
|
||
ctx.lineWidth = 1;
|
||
ctx.strokeRect(8, H - 66, 220, 58);
|
||
ctx.fillStyle = '#6cf0c2';
|
||
ctx.font = '11px ui-monospace';
|
||
ctx.textAlign = 'left';
|
||
const t = new Date(f.ts * 1000).toLocaleTimeString();
|
||
ctx.fillText(`tick ${f.tick ?? '---'} | ${t}`, 14, H - 50);
|
||
if (f.kind === 'action' && f.event) {
|
||
const a = f.event.agent || f.actor;
|
||
const tool = f.event.tool || '';
|
||
ctx.fillStyle = AGENT_COLORS[a] || '#d6e2ee';
|
||
ctx.fillText(`${a} -> ${tool}`, 14, H - 34);
|
||
const model = shortModel(f.event.model || '');
|
||
ctx.fillStyle = '#8aa1b6';
|
||
ctx.fillText(model || 'fallback', 14, H - 18);
|
||
} else {
|
||
ctx.fillStyle = '#8aa1b6';
|
||
ctx.fillText('tick update', 14, H - 34);
|
||
}
|
||
}
|
||
|
||
function shortModel(name) {
|
||
if (!name) return '';
|
||
// Strip org/ prefix and :tag for compactness
|
||
const s = name.replace(/^.*\//, '').replace(/:latest$/, '');
|
||
return s;
|
||
}
|
||
|
||
function modelTag(m, provider) {
|
||
if (!m) return '';
|
||
const isCloud = provider === 'openrouter' || (m || '').includes('/');
|
||
const cls = isCloud ? 'cloud' : 'local';
|
||
const label = isCloud ? '☁ ' + shortModel(m) : '💻 ' + shortModel(m);
|
||
return `<span class="model-tag ${cls}" title="${m}">${label}</span>`;
|
||
}
|
||
|
||
function refreshAgentCards() {
|
||
const wrap = document.getElementById('agentList');
|
||
wrap.innerHTML = '';
|
||
for (const a of snapshot.agents) {
|
||
const div = document.createElement('div');
|
||
div.className = 'agent-card';
|
||
div.innerHTML = `
|
||
<h3>${a.name}${modelTag(a.model, a.provider)}</h3>
|
||
<div style="font-size:11px; color:#8aa1b6;">${a.role}</div>
|
||
<div>at (${a.x}, ${a.y}) · ${a.mood}</div>
|
||
<div class="bar energy"><i style="width:${a.energy}%"></i></div>
|
||
<div class="bar knowledge"><i style="width:${a.knowledge}%"></i></div>
|
||
<div class="bar influence"><i style="width:${a.influence}%"></i></div>
|
||
<div class="bar credits"><i style="width:${Math.min(100, a.credits * 5)}%"></i></div>
|
||
<small>${a.energy.toFixed(0)}E · ${a.knowledge.toFixed(0)}K · ${a.influence.toFixed(0)}I · ${a.credits.toFixed(1)} CC</small>
|
||
`;
|
||
wrap.appendChild(div);
|
||
}
|
||
}
|
||
|
||
function refreshClocks() {
|
||
if (!snapshot || !snapshot.clocks) return;
|
||
const wrap = document.getElementById('clocks');
|
||
wrap.innerHTML = '';
|
||
const entries = Object.entries(snapshot.clocks);
|
||
// sort by τ descending
|
||
entries.sort((a, b) => b[1].tau - a[1].tau);
|
||
const maxTau = Math.max(1, entries.length ? entries[0][1].tau : 1);
|
||
const colorFor = { anchor: '#ffd166', flora: '#6cf0c2', lovely: '#ff8fb1', spark: '#82aaff' };
|
||
for (const [aid, c] of entries) {
|
||
const div = document.createElement('div');
|
||
div.className = 'clock-card';
|
||
const pct = (c.tau / maxTau) * 100;
|
||
div.innerHTML = `
|
||
<div class="name" style="color:${colorFor[aid] || '#fff'}">${aid}</div>
|
||
<div class="tau-bar"><i style="width:${pct}%; background:${colorFor[aid] || '#fff'}"></i></div>
|
||
<div class="meta">τ = ${c.tau.toFixed(2)} · pace = ${c.pace.toFixed(2)} op/s · ${c.n_ops} ops</div>
|
||
`;
|
||
wrap.appendChild(div);
|
||
}
|
||
}
|
||
|
||
function refreshDrift() {
|
||
if (!snapshot || !snapshot.drift) return;
|
||
const wrap = document.getElementById('drift');
|
||
const d = snapshot.drift;
|
||
if (!d.pairs || d.pairs.length === 0) {
|
||
wrap.innerHTML = '<small>No multi-agent drift yet.</small>';
|
||
return;
|
||
}
|
||
const top = d.pairs[0];
|
||
if (top.divergent) {
|
||
wrap.className = 'drift-warn';
|
||
wrap.innerHTML = `<b>⚠ DILATION DRIFT</b> · ${top.a}↔${top.b}: |Δτ| = ${top.drift.toFixed(1)} (γ=${top.gamma_ab})<br>
|
||
<small>${top.a} has experienced ${top.tau_a} units, ${top.b} ${top.tau_b}. Frame transformation exceeds threshold ${d.threshold}.</small>`;
|
||
} else {
|
||
wrap.className = 'drift-ok';
|
||
wrap.innerHTML = `<b>✓ COHERENT</b> · max drift = ${d.max_drift.toFixed(2)} (threshold ${d.threshold})`;
|
||
}
|
||
}
|
||
|
||
function refreshProposals() {
|
||
const wrap = document.getElementById('proposals');
|
||
wrap.innerHTML = '';
|
||
fetch('/api/proposals').then(r => r.json()).then(d => {
|
||
if (!d.active.length) {
|
||
wrap.innerHTML = '<small>No active proposals.</small>';
|
||
return;
|
||
}
|
||
for (const p of d.active) {
|
||
const el = document.createElement('div');
|
||
el.className = 'proposal';
|
||
el.innerHTML = `<h4>#${p.id} ${p.title}</h4>
|
||
<small>by ${p.author} · ${p.category} · ${p.votes} votes</small>
|
||
<p>${p.body}</p>`;
|
||
wrap.appendChild(el);
|
||
}
|
||
});
|
||
}
|
||
|
||
function refreshConstitution() {
|
||
const wrap = document.getElementById('constitution');
|
||
wrap.innerHTML = '';
|
||
fetch('/api/constitution').then(r => r.json()).then(c => {
|
||
for (const a of c.articles) {
|
||
const el = document.createElement('div');
|
||
el.className = 'article';
|
||
el.innerHTML = `<b>Article ${a.id} — ${a.title}</b><p>${a.body}</p>`;
|
||
wrap.appendChild(el);
|
||
}
|
||
});
|
||
}
|
||
|
||
function addFeed(entry) {
|
||
const ul = document.getElementById('feed');
|
||
const li = document.createElement('li');
|
||
const t = new Date().toLocaleTimeString();
|
||
if (entry.type === 'action') {
|
||
li.innerHTML = `<span class="who">${entry.name}</span>
|
||
<span class="tool">${entry.tool}</span>
|
||
<span class="why">— ${entry.rationale || ''}</span>
|
||
<small> · ${t}</small>`;
|
||
} else {
|
||
li.innerHTML = `<small>${entry.type} · ${t}</small>`;
|
||
}
|
||
ul.prepend(li);
|
||
while (ul.children.length > 80) ul.removeChild(ul.lastChild);
|
||
}
|
||
|
||
function refreshHeader() {
|
||
const state = effectiveState();
|
||
document.getElementById('tick').textContent = state?.tick || 0;
|
||
document.getElementById('agentCount').textContent = state?.agents.length || 0;
|
||
}
|
||
|
||
async function refreshAll() {
|
||
const r = await fetch('/api/state');
|
||
snapshot = await r.json();
|
||
draw();
|
||
refreshHeader();
|
||
refreshAgentCards();
|
||
refreshClocks();
|
||
refreshDrift();
|
||
refreshProposals();
|
||
refreshConstitution();
|
||
// LLM info
|
||
if (snapshot.llm) {
|
||
const info = document.getElementById('llmInfo');
|
||
info.textContent = `${snapshot.llm.provider} · ${snapshot.llm.model}`;
|
||
}
|
||
}
|
||
|
||
function connectWS() {
|
||
const ws = new WebSocket((location.protocol === 'https:' ? 'wss://' : 'ws://') + location.host + '/ws');
|
||
const status = document.getElementById('wsStatus');
|
||
ws.onopen = () => { status.textContent = 'live'; status.className = 'ws-status connected'; };
|
||
ws.onclose = () => {
|
||
status.textContent = 'disconnected — retrying'; status.className = 'ws-status disconnected';
|
||
setTimeout(connectWS, 2000);
|
||
};
|
||
ws.onmessage = (ev) => {
|
||
const msg = JSON.parse(ev.data);
|
||
if (msg.type === 'snapshot') {
|
||
snapshot = msg.data;
|
||
draw(); refreshHeader(); refreshAgentCards();
|
||
} else if (msg.type === 'action') {
|
||
// update local agent snapshot
|
||
if (snapshot) {
|
||
const a = snapshot.agents.find(x => x.id === msg.agent);
|
||
if (a) {
|
||
a.x = msg.x; a.y = msg.y;
|
||
a.energy = msg.energy; a.knowledge = msg.knowledge;
|
||
a.influence = msg.influence; a.credits = msg.credits;
|
||
a.mood = msg.mood;
|
||
}
|
||
draw(); refreshAgentCards();
|
||
}
|
||
addFeed(msg);
|
||
// refresh proposals / constitution occasionally
|
||
if (msg.tool && msg.tool.startsWith('vote_on') || msg.tool === 'submit_townhall_proposal') {
|
||
refreshProposals();
|
||
}
|
||
if (msg.tool === 'submit_townhall_proposal') {
|
||
setTimeout(refreshConstitution, 500);
|
||
}
|
||
} else if (msg.type === 'tick') {
|
||
document.getElementById('tick').textContent = msg.tick;
|
||
if (msg.clocks) snapshot.clocks = msg.clocks;
|
||
if (msg.drift) snapshot.drift = msg.drift;
|
||
refreshClocks();
|
||
refreshDrift();
|
||
}
|
||
};
|
||
}
|
||
|
||
async function populateManual() {
|
||
const a = await (await fetch('/api/agents')).json();
|
||
const selA = document.querySelector('#manual select[name=agent]');
|
||
for (const ag of a) {
|
||
const o = document.createElement('option');
|
||
o.value = ag.id; o.textContent = ag.name; selA.appendChild(o);
|
||
}
|
||
const tools = [
|
||
'go_to_place','go_home','say_to_agent','speak_to_all',
|
||
'show_emoticon','recharge_energy','add_to_longterm_memory',
|
||
'write_blog','add_to_billboard','submit_townhall_proposal',
|
||
'vote_on_proposal','idle',
|
||
];
|
||
const selT = document.querySelector('#manual select[name=tool]');
|
||
for (const t of tools) {
|
||
const o = document.createElement('option');
|
||
o.value = t; o.textContent = t; selT.appendChild(o);
|
||
}
|
||
}
|
||
|
||
document.getElementById('manual').addEventListener('submit', async (e) => {
|
||
e.preventDefault();
|
||
const f = e.target;
|
||
let args = {};
|
||
try { args = JSON.parse(f.args.value || '{}'); } catch { alert('args must be JSON'); return; }
|
||
const body = { tool: f.tool.value, args };
|
||
const r = await fetch(`/api/turn/${f.agent.value}`, {
|
||
method: 'POST', headers: {'Content-Type': 'application/json'},
|
||
body: JSON.stringify(body),
|
||
});
|
||
const out = await r.json();
|
||
console.log('manual turn result', out);
|
||
});
|
||
|
||
function refreshTexts() {
|
||
fetch('/api/texts').then(r => r.json()).then(d => {
|
||
document.getElementById('textsCount').textContent = `(${d.length})`;
|
||
const wrap = document.getElementById('texts');
|
||
if (!d.length) {
|
||
wrap.innerHTML = '<small>Noch keine Texte. Agenten generieren welche ueber write_blog, add_to_billboard, speak_to_all, say_to_agent, add_to_longterm_memory.</small>';
|
||
return;
|
||
}
|
||
wrap.innerHTML = '';
|
||
for (const t of d) {
|
||
const el = document.createElement('div');
|
||
el.className = 'text-card';
|
||
const isLocal = !((t.model || '').includes('/'));
|
||
const src = t.source === 'fallback' ? 'fallback' : 'llm';
|
||
el.innerHTML = `
|
||
<div class="head">
|
||
<span class="agent ${t.agent}">${t.agent}</span>
|
||
<span class="model ${isLocal ? 'local' : 'cloud'}">${shortModel(t.model)}</span>
|
||
<span class="kind">${t.kind}</span>
|
||
</div>
|
||
<div class="body ${src}">${escapeHtml(t.body)}</div>
|
||
<div class="ts">${new Date(t.ts * 1000).toLocaleString()}</div>
|
||
`;
|
||
wrap.appendChild(el);
|
||
}
|
||
});
|
||
}
|
||
|
||
function escapeHtml(s) {
|
||
return s.replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
||
}
|
||
|
||
/* ---------- Replay ---------- */
|
||
const slider = document.getElementById('replaySlider');
|
||
const playBtn = document.getElementById('replayPlay');
|
||
const liveBtn = document.getElementById('replayLive');
|
||
const hoursSel = document.getElementById('replayHours');
|
||
const replayTime = document.getElementById('replayTime');
|
||
const replayPanel = document.getElementById('replayPanel');
|
||
|
||
function setReplayUI() {
|
||
if (mode === 'live') {
|
||
replayPanel.classList.remove('active');
|
||
liveBtn.classList.add('active');
|
||
playBtn.textContent = 'Play';
|
||
replayTime.textContent = 'live';
|
||
stopReplay();
|
||
return;
|
||
}
|
||
replayPanel.classList.add('active');
|
||
liveBtn.classList.remove('active');
|
||
slider.max = Math.max(0, replay.frames.length - 1);
|
||
}
|
||
|
||
function updateReplayFrame() {
|
||
replay.idx = Math.max(0, Math.min(replay.idx, replay.frames.length - 1));
|
||
slider.value = replay.idx;
|
||
const f = replay.frames[replay.idx];
|
||
if (f) {
|
||
const t = new Date(f.ts * 1000);
|
||
replayTime.textContent = `tick ${f.tick ?? '---'} | ${t.toLocaleTimeString()}`;
|
||
if (mode === 'replay') {
|
||
refreshHeader();
|
||
refreshClocks();
|
||
refreshDrift();
|
||
draw();
|
||
}
|
||
}
|
||
}
|
||
|
||
function stopReplay() {
|
||
replay.playing = false;
|
||
if (replay.raf) cancelAnimationFrame(replay.raf);
|
||
replay.raf = null;
|
||
}
|
||
|
||
function playReplay() {
|
||
if (!replay.frames.length) {
|
||
loadReplay();
|
||
return;
|
||
}
|
||
replay.playing = !replay.playing;
|
||
playBtn.textContent = replay.playing ? 'Pause' : 'Play';
|
||
if (replay.playing) replayLoop();
|
||
}
|
||
|
||
function replayLoop() {
|
||
if (!replay.playing || mode !== 'replay') return;
|
||
const now = performance.now();
|
||
if (!replay.lastFrame) replay.lastFrame = now;
|
||
const dt = now - replay.lastFrame;
|
||
if (dt > 80) {
|
||
replay.idx = Math.min(replay.idx + 1, replay.frames.length - 1);
|
||
updateReplayFrame();
|
||
replay.lastFrame = now;
|
||
if (replay.idx >= replay.frames.length - 1) {
|
||
replay.playing = false;
|
||
playBtn.textContent = 'Play';
|
||
}
|
||
}
|
||
replay.raf = requestAnimationFrame(replayLoop);
|
||
}
|
||
|
||
async function loadReplay() {
|
||
stopReplay();
|
||
const hours = parseFloat(hoursSel.value);
|
||
const r = await fetch(`/api/history?hours=${hours}`);
|
||
const data = await r.json();
|
||
replay.frames = data.frames || [];
|
||
replay.idx = 0;
|
||
if (replay.frames.length) {
|
||
mode = 'replay';
|
||
setReplayUI();
|
||
updateReplayFrame();
|
||
playReplay();
|
||
}
|
||
}
|
||
|
||
function enterLive() {
|
||
mode = 'live';
|
||
setReplayUI();
|
||
refreshAll();
|
||
}
|
||
|
||
function enterReplayFromSlider() {
|
||
mode = 'replay';
|
||
setReplayUI();
|
||
}
|
||
|
||
slider.addEventListener('input', () => {
|
||
if (mode !== 'replay') enterReplayFromSlider();
|
||
replay.idx = parseInt(slider.value, 10);
|
||
updateReplayFrame();
|
||
});
|
||
|
||
playBtn.addEventListener('click', () => {
|
||
if (mode !== 'replay') loadReplay();
|
||
else playReplay();
|
||
});
|
||
|
||
liveBtn.addEventListener('click', enterLive);
|
||
hoursSel.addEventListener('change', loadReplay);
|
||
|
||
refreshAll();
|
||
populateManual();
|
||
refreshTexts();
|
||
connectWS();
|
||
setInterval(refreshProposals, 5000);
|
||
setInterval(refreshTexts, 8000);
|