From e7ad200bb02f643dfdb1289f039e7bb1933674cf Mon Sep 17 00:00:00 2001 From: Jeuners Date: Mon, 15 Jun 2026 14:35:16 +0200 Subject: [PATCH] Add Replay / timeline animation to web UI - 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. --- server.py | 74 +++++++++++++++++ web/app.js | 220 +++++++++++++++++++++++++++++++++++++++++++++++-- web/index.html | 18 ++++ web/style.css | 12 +++ 4 files changed, 317 insertions(+), 7 deletions(-) diff --git a/server.py b/server.py index 3bf17c2..35dbd30 100644 --- a/server.py +++ b/server.py @@ -221,6 +221,80 @@ async def texts(limit: int = 20): return out[:limit] +@app.get("/api/history") +async def history(hours: float = 12.0): + """Return a chronological replay stream from the events table. + + Query params: + hours: how far back to load (default 12). + + Each frame contains: + ts, tick, kind, agents: {id: {x,y,energy,knowledge,influence, + credits,mood,tool,model,tau,pace,rationale}}, + clocks, drift. + Frames are capped at 2000 to keep the payload small. + """ + import sqlite3 + + since = time.time() - hours * 3600 + rows = _query( + "SELECT ts, kind, actor, payload FROM events WHERE ts > ? ORDER BY id ASC", + (since,), + ) + frames = [] + last_agents: dict[str, dict] = {} + last_tick: dict = {} + last_clocks: dict = {} + last_drift: dict = {} + for r in rows: + try: + p = json.loads(r["payload"]) + except Exception: + continue + if r["kind"] == "tick": + last_tick = {"tick": p.get("tick"), "ts": r["ts"]} + last_clocks = p.get("clocks", {}) + last_drift = p.get("drift", {}) + elif r["kind"] == "action": + a = p.get("agent") + if a: + last_agents[a] = { + "x": p.get("x"), + "y": p.get("y"), + "energy": p.get("energy"), + "knowledge": p.get("knowledge"), + "influence": p.get("influence"), + "credits": p.get("credits"), + "mood": p.get("mood"), + "tool": p.get("tool"), + "model": p.get("model"), + "tau": p.get("tau"), + "pace": p.get("pace"), + "rationale": p.get("rationale"), + "name": p.get("name", a), + } + frames.append({ + "ts": r["ts"], + "kind": r["kind"], + "actor": r["actor"], + "tick": last_tick.get("tick"), + "agents": dict(last_agents), + "clocks": dict(last_clocks), + "drift": dict(last_drift) if last_drift else None, + "event": p if r["kind"] == "action" else None, + }) + # Cap size + if len(frames) > 2000: + frames = frames[-2000:] + return { + "hours": hours, + "frames": frames, + "total": len(frames), + "since": since, + "grid": {"w": world.GRID_W, "h": world.GRID_H}, + } + + @app.post("/api/turn/{agent_id}") async def force_turn(agent_id: str, body: dict): tool_name = body.get("tool") diff --git a/web/app.js b/web/app.js index cb41cca..81a1c6b 100644 --- a/web/app.js +++ b/web/app.js @@ -4,6 +4,8 @@ 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', @@ -27,8 +29,39 @@ const LANDMARK_COLORS = { 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() { - if (!snapshot) return; + const state = effectiveState(); + if (!state) return; const W = canvas.width, H = canvas.height; ctx.fillStyle = '#060a0e'; ctx.fillRect(0, 0, W, H); @@ -43,7 +76,7 @@ function draw() { } // landmarks - for (const lm of snapshot.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_'); @@ -60,7 +93,7 @@ function draw() { } // agents - for (const a of snapshot.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'; @@ -71,13 +104,42 @@ function draw() { ctx.font = 'bold 10px ui-monospace'; ctx.textAlign = 'center'; ctx.fillText(a.name[0], x, y + 3); - // energy halo 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) { @@ -205,8 +267,9 @@ function addFeed(entry) { } function refreshHeader() { - document.getElementById('tick').textContent = snapshot?.tick || 0; - document.getElementById('agentCount').textContent = snapshot?.agents.length || 0; + const state = effectiveState(); + document.getElementById('tick').textContent = state?.tick || 0; + document.getElementById('agentCount').textContent = state?.agents.length || 0; } async function refreshAll() { @@ -303,9 +366,152 @@ document.getElementById('manual').addEventListener('submit', async (e) => { 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 = 'Noch keine Texte. Agenten generieren welche ueber write_blog, add_to_billboard, speak_to_all, say_to_agent, add_to_longterm_memory.'; + 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 = ` +
+ ${t.agent} + ${shortModel(t.model)} + ${t.kind} +
+
${escapeHtml(t.body)}
+
${new Date(t.ts * 1000).toLocaleString()}
+ `; + 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); // periodic refresh of historical texts +setInterval(refreshTexts, 8000); diff --git a/web/index.html b/web/index.html index 3fcd922..c3d12ae 100644 --- a/web/index.html +++ b/web/index.html @@ -26,6 +26,24 @@ Lovely Spark + +
+

Replay

+
+ + + + + +
+
replay mode
+