Replaces the static React-Flow world forest with a real-time WebGL view of the swarm working, plus the live event contract that feeds it. Phase A — the live contract + feed: - frontend/src/lib/live/taxonomy.ts — the 13-event Clawmates Event Taxonomy as typed TS (the shared World/Observe contract). - frontend/src/lib/live/useClawmatesLive.ts — native shared-singleton live client (EventSource to /api/world/live, typed fan-out, replay-of-last-state to late subscribers, refcounted, synthetic fallback so views are always alive). - crates/cm-api/src/routes/world.rs — GET /api/world/live, authed + workspace- scoped SSE emitting the taxonomy (real agent.status from live-container state, a topology.update of the workspace's agents, telemetry with real doorsPending), mirroring run_events_sse. normalize() seam documented for run_events->world.touch. Phase B — the WebGL engine: - frontend/src/components/world/engine.ts — Gource-inspired force-directed model: org/company/team tree (sibling repulsion + parent spring + friction), agent pawns that converge on the touched node and beam it, world nodes that glow with heat and fade when idle. Framework-agnostic (renderer-independent) state+math. - frontend/src/components/world/WorldCanvas.tsx — three.js scene (ortho cam, UnrealBloom), render loop syncing engine state, camera auto-fit, HTML labels, raycast click->select, Hierarchy/Flat/Live formation switch. - Dashboard.tsx: swap <WorldFlow/> -> <WorldCanvas/> at the world-tier seam (shared claw-tier pieces untouched; WorldFlow.tsx kept for now). - Adds three (+ @types/three). Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
82 lines
3.6 KiB
JavaScript
82 lines
3.6 KiB
JavaScript
// Clawmates Live — client adapter
|
|
// --------------------------------
|
|
// Drop-in: include this in any visualization page. It connects to the live bridge and
|
|
// dispatches typed events. If no live URL is provided, it does NOTHING and the page keeps
|
|
// its built-in synthetic animation (safe fallback).
|
|
//
|
|
// <script src="clawmates-live.js"></script>
|
|
//
|
|
// Live URL resolution order:
|
|
// 1. window.CLAWMATES_LIVE_URL
|
|
// 2. ?live=<url> query param
|
|
// 3. (none) → inert
|
|
//
|
|
// Usage from a visualization's logic:
|
|
// ClawmatesLive.on('world.touch', e => worldEngine.touch(e.agentId, e.nodeId));
|
|
// ClawmatesLive.on('telemetry', e => setTopbar(e));
|
|
// Handlers registered AFTER events have arrived still receive the last replayed value
|
|
// for stateful event types (status/task/telemetry/topology/routine/node).
|
|
|
|
(function () {
|
|
const TYPES = [
|
|
'agent.status', 'agent.task.update', 'agent.reasoning.delta', 'agent.computer.frame',
|
|
'agent.tool.call', 'door.request', 'door.resolve', 'agent.message',
|
|
'world.touch', 'node.activity', 'topology.update', 'telemetry', 'routine.update',
|
|
];
|
|
const STATEFUL = new Set(['agent.status', 'agent.task.update', 'node.activity', 'telemetry', 'topology.update', 'routine.update']);
|
|
|
|
const handlers = {}; // type → Set<fn>
|
|
const last = {}; // stateKey → payload (replay buffer)
|
|
TYPES.forEach(t => (handlers[t] = new Set()));
|
|
|
|
function stateKey(type, d) {
|
|
if (type === 'agent.status' || type === 'agent.task.update') return type + ':' + d.agentId;
|
|
if (type === 'node.activity') return type + ':' + d.nodeId;
|
|
if (type === 'routine.update') return type + ':' + d.routineId;
|
|
return type; // telemetry, topology.update
|
|
}
|
|
|
|
function dispatch(type, data) {
|
|
if (STATEFUL.has(type)) last[stateKey(type, data)] = data;
|
|
(handlers[type] || []).forEach(fn => { try { fn(data); } catch (e) { console.warn('[ClawmatesLive]', type, e); } });
|
|
try { window.dispatchEvent(new CustomEvent('clawmates:' + type, { detail: data })); } catch {}
|
|
}
|
|
|
|
function resolveUrl() {
|
|
if (window.CLAWMATES_LIVE_URL) return window.CLAWMATES_LIVE_URL;
|
|
try { return new URLSearchParams(location.search).get('live') || null; } catch { return null; }
|
|
}
|
|
|
|
const API = {
|
|
connected: false,
|
|
url: null,
|
|
on(type, fn) {
|
|
if (!handlers[type]) handlers[type] = new Set();
|
|
handlers[type].add(fn);
|
|
// replay last-known stateful value so late subscribers paint immediately
|
|
for (const k in last) if (k === type || k.startsWith(type + ':')) { try { fn(last[k]); } catch {} }
|
|
return () => handlers[type].delete(fn);
|
|
},
|
|
off(type, fn) { handlers[type] && handlers[type].delete(fn); },
|
|
connect(url) {
|
|
url = url || resolveUrl();
|
|
if (!url) { console.info('[ClawmatesLive] no live URL — running in synthetic/offline mode'); return; }
|
|
this.url = url;
|
|
const es = new EventSource(url);
|
|
es.onopen = () => { this.connected = true; window.dispatchEvent(new CustomEvent('clawmates:open')); };
|
|
es.onerror = () => { this.connected = false; window.dispatchEvent(new CustomEvent('clawmates:reconnecting')); };
|
|
TYPES.forEach(type => es.addEventListener(type, ev => {
|
|
let data; try { data = JSON.parse(ev.data); } catch { return; }
|
|
dispatch(type, data);
|
|
}));
|
|
this._es = es;
|
|
},
|
|
disconnect() { if (this._es) { this._es.close(); this._es = null; this.connected = false; } },
|
|
};
|
|
|
|
window.ClawmatesLive = API;
|
|
// auto-connect on load if a URL is resolvable
|
|
if (document.readyState !== 'loading') API.connect();
|
|
else window.addEventListener('DOMContentLoaded', () => API.connect());
|
|
})();
|