- 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).
311 lines
10 KiB
JavaScript
311 lines
10 KiB
JavaScript
// 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
|