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:
@@ -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(),
|
||||
};
|
||||
|
||||
|
||||
@@ -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 },
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 : <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 : <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 : <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>
|
||||
`;
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user