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.
This commit is contained in:
parent
94025e03a0
commit
e7ad200bb0
4 changed files with 317 additions and 7 deletions
74
server.py
74
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")
|
||||
|
|
|
|||
220
web/app.js
220
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 = '<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); // periodic refresh of historical texts
|
||||
setInterval(refreshTexts, 8000);
|
||||
|
|
|
|||
|
|
@ -26,6 +26,24 @@
|
|||
<span class="dot lovely"></span>Lovely
|
||||
<span class="dot spark"></span>Spark
|
||||
</div>
|
||||
|
||||
<section class="replay-panel" id="replayPanel">
|
||||
<h2>Replay</h2>
|
||||
<div class="replay-controls">
|
||||
<button id="replayPlay">▶ Play</button>
|
||||
<button id="replayLive" class="active">● Live</button>
|
||||
<input type="range" id="replaySlider" min="0" max="100" value="0" step="1" />
|
||||
<span id="replayTime">—</span>
|
||||
<select id="replayHours">
|
||||
<option value="1">last 1h</option>
|
||||
<option value="3">last 3h</option>
|
||||
<option value="6">last 6h</option>
|
||||
<option value="12" selected>last 12h</option>
|
||||
<option value="24">last 24h</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="replay-mode">replay mode</div>
|
||||
</section>
|
||||
</section>
|
||||
|
||||
<aside>
|
||||
|
|
|
|||
|
|
@ -78,3 +78,15 @@ form#manual select, form#manual input { background: #060a0e; color: #d6e2ee;
|
|||
form#manual button { background: #6cf0c2; color: #060a0e; border: 0; padding: 6px;
|
||||
font-weight: bold; cursor: pointer; }
|
||||
form#manual button:hover { background: #82ffd6; }
|
||||
|
||||
.replay-panel { margin-top: 12px; padding: 10px; background: #0a1018; border: 1px solid #1c2733; }
|
||||
.replay-panel h2 { margin: 0 0 8px; font-size: 12px; text-transform: uppercase; letter-spacing: 1px; color: #6cf0c2; }
|
||||
.replay-controls { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
|
||||
.replay-controls button { background: #1c2733; color: #d6e2ee; border: 1px solid #2c3a4a; padding: 5px 10px; font-family: inherit; cursor: pointer; border-radius: 2px; }
|
||||
.replay-controls button:hover { background: #2c3a4a; }
|
||||
.replay-controls button.active { background: #6cf0c2; color: #060a0e; font-weight: bold; border-color: #6cf0c2; }
|
||||
.replay-controls input[type=range] { flex: 1; min-width: 120px; }
|
||||
.replay-controls select { background: #060a0e; color: #d6e2ee; border: 1px solid #1c2733; padding: 4px; font-family: inherit; }
|
||||
#replayTime { font-size: 11px; color: #8aa1b6; min-width: 90px; }
|
||||
.replay-mode { display: none; font-size: 10px; color: #ff8fb1; margin-top: 6px; text-transform: uppercase; }
|
||||
.replay-panel.active .replay-mode { display: block; }
|
||||
|
|
|
|||
Loading…
Reference in a new issue