feat: add Kimi & Perplexity as visible Workshop agents (#11)

- Add Kimi (Long Context Analysis) and Perplexity (Real-time Research)
  to AGENT_DEFS with specialization and external flags
- Build distinct 3D bodies for each: Kimi gets a tapered hexagonal
  obelisk with 3 orbiting torus rings; Perplexity gets an icosahedron
  core with wireframe shell and 4 orbiting dot spheres
- Animate 3D bodies each frame: orbit speed and light intensity scale
  with agent state (idle = dim/slow, active/working = bright/fast)
- Track _extAgentStates and sync via setAgentState/applyAgentStates
- Add kimi and perplexity to server-side world-state initial states
- HUD labels pick up both agents automatically via AGENT_DEFS iteration
- Store specialization per label; show in inspect popup
- Export setAgentLastTask from hud-labels for last-task tooltip
- Call setAgentLastTask on job_completed events in websocket handler

Fixes #11
This commit is contained in:
Alexander Whitestone
2026-03-23 16:36:16 -04:00
parent e41d30d308
commit 1c17b09e09
5 changed files with 214 additions and 10 deletions

View File

@@ -16,7 +16,7 @@ const DEFAULT_TIMMY: TimmyState = {
const _state: WorldState = {
timmyState: { ...DEFAULT_TIMMY },
agentStates: { alpha: "idle", beta: "idle", gamma: "idle", delta: "idle" },
agentStates: { alpha: "idle", beta: "idle", gamma: "idle", delta: "idle", kimi: "idle", perplexity: "idle" },
updatedAt: new Date().toISOString(),
};

View File

@@ -9,14 +9,18 @@
* label — display name shown in the 3D HUD and chat panel
* color — hex integer (0xRRGGBB) used for Three.js materials and lights
* role — human-readable role string shown under the label sprite
* direction — cardinal facing direction (for future mesh orientation use)
* x, z — world-space position on the horizontal plane (y is always 0)
* direction — cardinal facing direction (for future mesh orientation use)
* x, z — world-space position on the horizontal plane (y is always 0)
* specialization — optional skill label shown in HUD inspect popup
* external — true for external agents (Kimi, Perplexity) that render 3D bodies
*/
export const AGENT_DEFS = [
{ id: 'alpha', label: 'ALPHA', color: 0x00ff88, role: 'orchestrator', direction: 'north', x: 0, z: -6 },
{ id: 'beta', label: 'BETA', color: 0x00aaff, role: 'worker', direction: 'east', x: 6, z: 0 },
{ id: 'gamma', label: 'GAMMA', color: 0xff6600, role: 'validator', direction: 'south', x: 0, z: 6 },
{ id: 'delta', label: 'DELTA', color: 0xaa00ff, role: 'monitor', direction: 'west', x: -6, z: 0 },
{ id: 'kimi', label: 'KIMI', color: 0x00d4ff, role: 'analyst', specialization: 'Long Context Analysis', external: true, direction: 'northwest', x: -9, z: -5 },
{ id: 'perplexity', label: 'PERPLEXITY', color: 0xff4488, role: 'researcher', specialization: 'Real-time Research', external: true, direction: 'northeast', x: 9, z: -5 },
];
/**

View File

@@ -1,6 +1,10 @@
import * as THREE from 'three';
import { AGENT_DEFS } from './agent-defs.js';
// ── External agent 3D bodies (Kimi, Perplexity) ──────────────────────────────
const _extAgents = [];
const _extAgentStates = {}; // id -> state string
const TIMMY_POS = new THREE.Vector3(0, 0, -2);
export const TIMMY_WORLD_POS = TIMMY_POS.clone();
const CRYSTAL_POS = new THREE.Vector3(0.6, 1.15, -4.1);
@@ -100,6 +104,15 @@ function _pickMouthGeo(smileAmount) {
export function initAgents(sceneRef) {
scene = sceneRef;
timmy = buildTimmy(scene);
// Build 3D bodies for external agents (Kimi, Perplexity)
for (const def of AGENT_DEFS.filter(d => d.external)) {
const body = buildExternalAgentBody(def);
body.group.position.set(def.x, 0, def.z);
scene.add(body.group);
_extAgents.push(body);
_extAgentStates[def.id] = 'idle';
}
}
function buildTimmy(sc) {
@@ -537,6 +550,52 @@ export function updateAgents(time) {
if (timmy.mouth.geometry !== nextMouthGeo) {
timmy.mouth.geometry = nextMouthGeo;
}
// ── Animate external agents (Kimi, Perplexity) ────────────────────────────
for (const agent of _extAgents) {
const state = _extAgentStates[agent.id] || 'idle';
const active = state !== 'idle';
// Light intensity and orbit speed scale with activity
const lightBase = active ? 1.6 : 0.4;
const lightPulse = Math.sin(t * (active ? 3.2 : 1.1)) * (active ? 0.5 : 0.15);
agent.light.intensity = lightBase + lightPulse;
const speedMult = active ? 2.2 : 0.55;
// Animate orbiters
for (const orb of agent.orbiters) {
const angle = t * orb.speed * speedMult + orb.phase;
orb.mesh.position.set(
Math.cos(angle) * orb.radius,
orb.yOffset,
Math.sin(angle) * orb.radius,
);
}
// Gentle whole-group rotation for Kimi's pillar, pulsing Y bob for Perplexity's core
if (agent.id === 'kimi') {
agent.group.rotation.y += 0.003 * speedMult;
// Emissive pulse on pillar
const pillarMesh = agent.meshes[0];
if (pillarMesh?.material) {
pillarMesh.material.emissiveIntensity = active
? 0.25 + Math.sin(t * 2.8) * 0.12
: 0.08 + Math.sin(t * 0.9) * 0.03;
}
} else if (agent.id === 'perplexity') {
// Slowly spin the icosahedron core and shell
const core = agent.meshes[0];
const shell = agent.meshes[1];
if (core) { core.rotation.y += 0.008 * speedMult; core.rotation.x += 0.004 * speedMult; }
if (shell) { shell.rotation.y -= 0.005 * speedMult; }
if (core?.material) {
core.material.emissiveIntensity = active
? 0.30 + Math.sin(t * 3.5) * 0.15
: 0.10 + Math.sin(t * 0.8) * 0.04;
}
}
}
}
// ── setFaceEmotion — public API ───────────────────────────────────────────────
@@ -807,6 +866,7 @@ export function getCameraShakeStrength() {
export function setAgentState(agentId, state) {
if (agentId in agentStates) agentStates[agentId] = state;
if (agentId in _extAgentStates) _extAgentStates[agentId] = state;
}
export function setSpeechBubble(text) {
@@ -868,6 +928,7 @@ export function applyAgentStates(snapshot) {
if (!snapshot) return;
for (const [k, v] of Object.entries(snapshot)) {
if (k in agentStates) agentStates[k] = v;
if (k in _extAgentStates) _extAgentStates[k] = v;
}
}
@@ -875,6 +936,115 @@ export function getAgentDefs() {
return [{ id: 'timmy', label: 'TIMMY', role: 'wizard', color: 0x5599ff, state: deriveTimmyState() }];
}
// ── External agent 3D body builder ────────────────────────────────────────────
function buildExternalAgentBody(def) {
const group = new THREE.Group();
const col = def.color;
const meshes = []; // for disposal
const orbiters = []; // { mesh, radius, speed, phase, yOffset }
if (def.id === 'kimi') {
// Kimi: tall tapered obelisk/pillar (CylinderGeometry) + 3 flat ring-discs orbiting it
const pillarMat = new THREE.MeshStandardMaterial({
color: col,
emissive: col,
emissiveIntensity: 0.15,
roughness: 0.4,
metalness: 0.6,
});
const pillar = new THREE.Mesh(new THREE.CylinderGeometry(0.10, 0.28, 2.2, 6), pillarMat);
pillar.position.y = 1.1;
pillar.castShadow = true;
group.add(pillar);
meshes.push(pillar);
// 3 flat torus-ring discs at different heights, orbiting the pillar
const ringMat = new THREE.MeshStandardMaterial({
color: col,
emissive: col,
emissiveIntensity: 0.35,
roughness: 0.3,
metalness: 0.7,
transparent: true,
opacity: 0.82,
});
const ringHeights = [0.6, 1.1, 1.7];
const ringRadii = [0.55, 0.45, 0.38];
const ringPhases = [0, Math.PI * 0.66, Math.PI * 1.33];
const ringSpeed = [0.55, 0.72, 0.48];
for (let i = 0; i < 3; i++) {
const ring = new THREE.Mesh(
new THREE.TorusGeometry(0.18, 0.028, 6, 20),
ringMat.clone()
);
ring.rotation.x = Math.PI / 2;
group.add(ring);
meshes.push(ring);
orbiters.push({ mesh: ring, radius: ringRadii[i], speed: ringSpeed[i], phase: ringPhases[i], yOffset: ringHeights[i] });
}
} else if (def.id === 'perplexity') {
// Perplexity: icosahedron core + 4 small orbiting dot-spheres
const coreGeo = new THREE.IcosahedronGeometry(0.34, 1);
const coreMat = new THREE.MeshStandardMaterial({
color: col,
emissive: col,
emissiveIntensity: 0.18,
roughness: 0.25,
metalness: 0.55,
wireframe: false,
});
const core = new THREE.Mesh(coreGeo, coreMat);
core.position.y = 1.1;
core.castShadow = true;
group.add(core);
meshes.push(core);
// Thin wireframe shell just slightly larger
const shellMat = new THREE.MeshBasicMaterial({ color: col, wireframe: true, transparent: true, opacity: 0.22 });
const shell = new THREE.Mesh(new THREE.IcosahedronGeometry(0.46, 1), shellMat);
shell.position.y = 1.1;
group.add(shell);
meshes.push(shell);
// 4 small orbiting spheres
const dotMat = new THREE.MeshStandardMaterial({
color: col,
emissive: col,
emissiveIntensity: 0.5,
roughness: 0.3,
metalness: 0.4,
});
const dotCount = 4;
for (let i = 0; i < dotCount; i++) {
const dot = new THREE.Mesh(new THREE.SphereGeometry(0.07, 8, 8), dotMat.clone());
group.add(dot);
meshes.push(dot);
orbiters.push({
mesh: dot,
radius: 0.58,
speed: 0.9 + i * 0.15,
phase: (i / dotCount) * Math.PI * 2,
yOffset: 1.1 + Math.sin((i / dotCount) * Math.PI * 2) * 0.22,
});
}
}
// Shared point light for glow
const light = new THREE.PointLight(col, 0.6, 4.5);
light.position.y = 1.1;
group.add(light);
// Small base platform
const baseMat = new THREE.MeshStandardMaterial({ color: col, emissive: col, emissiveIntensity: 0.08, roughness: 0.8, metalness: 0.3 });
const base = new THREE.Mesh(new THREE.CylinderGeometry(0.32, 0.38, 0.08, 8), baseMat);
base.position.y = 0.04;
group.add(base);
meshes.push(base);
return { id: def.id, group, meshes, orbiters, light, def };
}
export function disposeAgents() {
if (!timmy) return;
[timmy.robe, timmy.head, timmy.hat, timmy.cb, timmy.pip].forEach(m => {
@@ -889,5 +1059,17 @@ export function disposeAgents() {
timmy.bubbleTex?.dispose();
timmy.bubbleMat?.dispose();
timmy = null;
// Dispose external agent bodies
for (const agent of _extAgents) {
for (const mesh of agent.meshes) {
mesh.geometry?.dispose();
if (Array.isArray(mesh.material)) mesh.material.forEach(m => m.dispose());
else mesh.material?.dispose();
}
scene?.remove(agent.group);
}
_extAgents.length = 0;
scene = null;
}

View File

@@ -19,7 +19,8 @@ let _camera = null;
let _labels = []; // { el, worldPos: THREE.Vector3, id }
// ── State cache (updated from WS) ────────────────────────────────────────────
const _states = {};
const _states = {};
const _lastTasks = {}; // id -> last task summary string
// ── Inspect popup ─────────────────────────────────────────────────────────────
let _inspectEl = null;
@@ -44,7 +45,7 @@ export function initHudLabels(camera, agentDefs, timmyWorldPos) {
for (const def of agentDefs) {
const col = colorToCss(def.color);
const pos = new THREE.Vector3(def.x, 2.8, def.z);
_labels.push(_makeLabel(container, def.id, def.label, def.role, col, pos));
_labels.push(_makeLabel(container, def.id, def.label, def.role, col, pos, def.specialization));
_states[def.id] = 'idle';
}
@@ -65,7 +66,7 @@ export function initHudLabels(camera, agentDefs, timmyWorldPos) {
document.body.appendChild(_inspectEl);
}
function _makeLabel(container, id, name, role, color, worldPos) {
function _makeLabel(container, id, name, role, color, worldPos, specialization) {
const el = document.createElement('div');
el.className = 'ar-label';
el.dataset.id = id;
@@ -97,7 +98,7 @@ function _makeLabel(container, id, name, role, color, worldPos) {
`;
container.appendChild(el);
return { el, worldPos, id, color };
return { el, worldPos, id, color, specialization: specialization || null };
}
export function setLabelState(id, state) {
@@ -111,18 +112,31 @@ export function setLabelState(id, state) {
if (dot) dot.style.animation = pulse ? 'ar-pulse 1s ease-in-out infinite' : '';
}
export function setAgentLastTask(id, taskSummary) {
_lastTasks[id] = taskSummary;
}
export function showInspectPopup(id, screenX, screenY) {
if (!_inspectEl) return;
const entry = _labels.find(l => l.id === id);
if (!entry) return;
const state = _states[id] || 'idle';
const uptime = Math.floor(performance.now() / 1000);
const state = _states[id] || 'idle';
const uptime = Math.floor(performance.now() / 1000);
const specLine = entry.specialization
? `<div style="color:#aaa;margin-bottom:2px;">spec&nbsp;&nbsp;&nbsp;: <span style="color:${entry.color}88">${entry.specialization}</span></div>`
: '';
const lastTask = _lastTasks[id];
const taskLine = lastTask
? `<div style="color:#aaa;margin-bottom:2px;max-width:200px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;">last&nbsp;&nbsp;&nbsp;: <span style="color:#cccccc">${lastTask}</span></div>`
: '';
_inspectEl.innerHTML = `
<div style="color:${entry.color};font-weight:bold;letter-spacing:2px;font-size:12px;margin-bottom:6px;">
${id.toUpperCase()}
</div>
<div style="color:#aaa;margin-bottom:2px;">state&nbsp;&nbsp;: <span style="color:${entry.color}">${state}</span></div>
${specLine}
${taskLine}
<div style="color:#aaa;margin-bottom:2px;">uptime : ${uptime}s</div>
<div style="color:#aaa;">network: <span style="color:#44ff88">connected</span></div>
`;

View File

@@ -1,7 +1,7 @@
import { setAgentState, setSpeechBubble, applyAgentStates, setMood } from './agents.js';
import { appendSystemMessage, appendDebateMessage, showCostTicker, updateCostTicker } from './ui.js';
import { sentiment } from './edge-worker-client.js';
import { setLabelState } from './hud-labels.js';
import { setLabelState, setAgentLastTask } from './hud-labels.js';
function resolveWsUrl() {
const explicit = import.meta.env.VITE_WS_URL;
@@ -103,6 +103,10 @@ function handleMessage(msg) {
if (msg.agentId) {
setAgentState(msg.agentId, 'idle');
setLabelState(msg.agentId, 'idle');
if (msg.agentId === 'kimi' || msg.agentId === 'perplexity') {
const summary = msg.summary || msg.result || `job ${(msg.jobId || '').slice(0, 8)}`;
setAgentLastTask(msg.agentId, summary);
}
}
appendSystemMessage(`job ${(msg.jobId || '').slice(0, 8)} complete`);
break;