emergence-mini-dilles/web/app.js
Jeuners 2b943aed18 Show current LLM model next to each agent in the UI
- engine/agents.all_agents() now enriches each agent row with its
  currently-assigned model (model_for_agent) and provider
  (provider_for_model). Same source of truth as the LLM client.
- web/app.js refreshAgentCards() shows a coloured tag next to the
  agent name: a green '💻 <model>' badge for local Ollama models,
  a yellow '☁ <model>' badge for OpenRouter models. The full
  model name is in the title attribute (hover).
- web/style.css: .model-tag.local / .cloud colour palette.
- Short name helper strips 'org/' prefix and ':latest' suffix for
  readability (e.g. 'anthropic/claude-3.5-haiku' -> 'claude-3.5-haiku').

All 100 tests still pass.

Live: Anchor + Flora show '☁ claude-3.5-haiku' / '☁ gpt-4o-mini',
Lovely + Spark show '💻 gemma4' (running on 192.168.1.245).
2026-06-15 03:15:53 +02:00

311 lines
10 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 = [];
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 draw() {
if (!snapshot) 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 snapshot.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 snapshot.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);
// 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();
}
}
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() {
document.getElementById('tick').textContent = snapshot?.tick || 0;
document.getElementById('agentCount').textContent = snapshot?.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);
});
refreshAll();
populateManual();
refreshTexts();
connectWS();
setInterval(refreshProposals, 5000);
setInterval(refreshTexts, 8000); // periodic refresh of historical texts