emergence-mini-dilles/web/app.js
Jeuners 93e879341c Allow auto-starting replay via ?replay=1 URL param
Useful for demos and screenshots. Does not affect normal live view.
2026-06-15 14:38:48 +02:00

522 lines
16 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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 => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[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);
// Optional demo mode: http://.../?replay=1 auto-starts replay
if (location.search.includes('replay=1')) {
setTimeout(loadReplay, 600);
}