forked from Rockachopa/the-matrix
3D visualization for AI agent swarms built with Three.js. Matrix green/noir cyberpunk aesthetic. - 4 agents: Timmy (orchestrator), Forge (builder), Seer (planner), Echo (comms) - Central core pillar, animated green grid, digital rain - Agent info panels, chat, task list, memory views - WebSocket protocol for real-time state updates - iPad-ready: touch controls, add-to-homescreen - Post-processing: bloom, scanlines, vignette - No build step — pure ES modules via esm.sh CDN Created with Perplexity Computer
315 lines
10 KiB
JavaScript
315 lines
10 KiB
JavaScript
// ===== UI: Panel, tabs, chat, task list, memory =====
|
|
import { AGENT_DEFS } from './websocket.js';
|
|
|
|
export class UIManager {
|
|
constructor(ws) {
|
|
this.ws = ws;
|
|
this.selectedAgent = null;
|
|
this.activeTab = 'chat';
|
|
this.onClose = null;
|
|
|
|
// Cache DOM refs
|
|
this.panel = document.getElementById('info-panel');
|
|
this.systemPanel = document.getElementById('system-panel');
|
|
this.panelName = document.getElementById('panel-agent-name');
|
|
this.panelRole = document.getElementById('panel-agent-role');
|
|
this.chatMessages = document.getElementById('chat-messages');
|
|
this.chatInput = document.getElementById('chat-input');
|
|
this.chatSend = document.getElementById('chat-send');
|
|
this.typingIndicator = document.getElementById('typing-indicator');
|
|
this.statusGrid = document.getElementById('status-grid');
|
|
this.tasksList = document.getElementById('tasks-list');
|
|
this.memoryList = document.getElementById('memory-list');
|
|
this.systemStatusGrid = document.getElementById('system-status-grid');
|
|
this.fpsCounter = document.getElementById('fps-counter');
|
|
|
|
this._bindEvents();
|
|
this._bindWSEvents();
|
|
}
|
|
|
|
_bindEvents() {
|
|
// Tab switching
|
|
document.querySelectorAll('.panel-tabs .tab').forEach(tab => {
|
|
tab.addEventListener('click', (e) => {
|
|
this._switchTab(e.target.dataset.tab);
|
|
});
|
|
});
|
|
|
|
// Close buttons
|
|
document.getElementById('panel-close').addEventListener('click', () => this.closePanel());
|
|
document.getElementById('system-panel-close').addEventListener('click', () => this.closeSystemPanel());
|
|
|
|
// Chat send
|
|
this.chatSend.addEventListener('click', () => this._sendChat());
|
|
this.chatInput.addEventListener('keydown', (e) => {
|
|
if (e.key === 'Enter') this._sendChat();
|
|
});
|
|
|
|
// Prevent canvas events when interacting with panel
|
|
[this.panel, this.systemPanel].forEach(el => {
|
|
el.addEventListener('pointerdown', e => e.stopPropagation());
|
|
el.addEventListener('touchstart', e => e.stopPropagation(), { passive: true });
|
|
});
|
|
}
|
|
|
|
_bindWSEvents() {
|
|
this.ws.on('message', (msg) => {
|
|
if (!this.selectedAgent) return;
|
|
|
|
if (msg.type === 'agent_message' && msg.agent_id === this.selectedAgent) {
|
|
this._hideTyping();
|
|
this._addChatMessage('assistant', msg.content, msg.agent_id);
|
|
}
|
|
|
|
if (msg.type === 'agent_state' && msg.agent_id === this.selectedAgent) {
|
|
if (this.activeTab === 'status') this._renderStatus(this.selectedAgent);
|
|
}
|
|
|
|
if (msg.type === 'task_update' || msg.type === 'task_created') {
|
|
if (this.activeTab === 'tasks') this._renderTasks(this.selectedAgent);
|
|
}
|
|
|
|
if (msg.type === 'memory_event' && msg.agent_id === this.selectedAgent) {
|
|
if (this.activeTab === 'memory') this._renderMemory(this.selectedAgent);
|
|
}
|
|
|
|
if (msg.type === 'system_status') {
|
|
this._renderSystemStatus(msg);
|
|
}
|
|
});
|
|
|
|
this.ws.on('typing', (data) => {
|
|
if (data.agent_id === this.selectedAgent) {
|
|
this._showTyping();
|
|
}
|
|
});
|
|
}
|
|
|
|
selectAgent(agentId) {
|
|
this.selectedAgent = agentId;
|
|
const def = AGENT_DEFS[agentId];
|
|
if (!def) return;
|
|
|
|
this.panelName.textContent = def.name.toUpperCase();
|
|
this.panelRole.textContent = def.role;
|
|
this.panelName.style.color = def.color;
|
|
this.panelName.style.textShadow = `0 0 10px ${def.color}80`;
|
|
|
|
this.systemPanel.classList.add('hidden');
|
|
this.panel.classList.remove('hidden');
|
|
|
|
this._switchTab('chat');
|
|
this._renderChat(agentId);
|
|
this._renderStatus(agentId);
|
|
this._renderTasks(agentId);
|
|
this._renderMemory(agentId);
|
|
}
|
|
|
|
selectCore() {
|
|
this.selectedAgent = null;
|
|
this.panel.classList.add('hidden');
|
|
this.systemPanel.classList.remove('hidden');
|
|
this._renderSystemStatus(this.ws.getSystemStatus());
|
|
}
|
|
|
|
closePanel() {
|
|
this.panel.classList.add('hidden');
|
|
this.selectedAgent = null;
|
|
if (this.onClose) this.onClose();
|
|
}
|
|
|
|
closeSystemPanel() {
|
|
this.systemPanel.classList.add('hidden');
|
|
if (this.onClose) this.onClose();
|
|
}
|
|
|
|
isPanelOpen() {
|
|
return !this.panel.classList.contains('hidden') || !this.systemPanel.classList.contains('hidden');
|
|
}
|
|
|
|
_switchTab(tabName) {
|
|
this.activeTab = tabName;
|
|
|
|
document.querySelectorAll('.panel-tabs .tab').forEach(t => {
|
|
t.classList.toggle('active', t.dataset.tab === tabName);
|
|
});
|
|
document.querySelectorAll('.tab-content').forEach(c => {
|
|
c.classList.toggle('active', c.id === `tab-${tabName}`);
|
|
});
|
|
|
|
// Refresh content
|
|
if (this.selectedAgent) {
|
|
if (tabName === 'status') this._renderStatus(this.selectedAgent);
|
|
if (tabName === 'tasks') this._renderTasks(this.selectedAgent);
|
|
if (tabName === 'memory') this._renderMemory(this.selectedAgent);
|
|
}
|
|
}
|
|
|
|
// ===== Chat =====
|
|
_renderChat(agentId) {
|
|
const agent = this.ws.getAgent(agentId);
|
|
if (!agent) return;
|
|
|
|
this.chatMessages.innerHTML = '';
|
|
agent.messages.forEach(msg => {
|
|
this._addChatMessage(msg.role, msg.content, agentId, false);
|
|
});
|
|
this._scrollChat();
|
|
}
|
|
|
|
_addChatMessage(role, content, agentId, scroll = true) {
|
|
const div = document.createElement('div');
|
|
div.className = `chat-msg ${role}`;
|
|
|
|
const roleLabel = document.createElement('div');
|
|
roleLabel.className = 'msg-role';
|
|
roleLabel.textContent = role === 'user' ? 'OPERATOR' : AGENT_DEFS[agentId]?.name?.toUpperCase() || 'AGENT';
|
|
div.appendChild(roleLabel);
|
|
|
|
const text = document.createElement('div');
|
|
text.textContent = content;
|
|
div.appendChild(text);
|
|
|
|
this.chatMessages.appendChild(div);
|
|
if (scroll) this._scrollChat();
|
|
}
|
|
|
|
_sendChat() {
|
|
const content = this.chatInput.value.trim();
|
|
if (!content || !this.selectedAgent) return;
|
|
|
|
this._addChatMessage('user', content, this.selectedAgent);
|
|
this.chatInput.value = '';
|
|
|
|
this.ws.send({
|
|
type: 'chat_message',
|
|
agent_id: this.selectedAgent,
|
|
content,
|
|
});
|
|
}
|
|
|
|
_scrollChat() {
|
|
requestAnimationFrame(() => {
|
|
this.chatMessages.scrollTop = this.chatMessages.scrollHeight;
|
|
});
|
|
}
|
|
|
|
_showTyping() {
|
|
this.typingIndicator.classList.remove('hidden');
|
|
}
|
|
|
|
_hideTyping() {
|
|
this.typingIndicator.classList.add('hidden');
|
|
}
|
|
|
|
// ===== Status =====
|
|
_renderStatus(agentId) {
|
|
const agent = this.ws.getAgent(agentId);
|
|
if (!agent) return;
|
|
|
|
const rows = [
|
|
['Name', agent.name],
|
|
['Role', agent.role],
|
|
['State', agent.state],
|
|
['Current Task', agent.current_task || '—'],
|
|
['Glow Intensity', (agent.glow_intensity * 100).toFixed(0) + '%'],
|
|
['Last Action', this._formatTime(agent.last_action)],
|
|
];
|
|
|
|
this.statusGrid.innerHTML = rows.map(([key, value]) => {
|
|
const stateClass = key === 'State' ? `state-${value}` : '';
|
|
return `<div class="status-row">
|
|
<span class="status-key">${key}</span>
|
|
<span class="status-value ${stateClass}">${value}</span>
|
|
</div>`;
|
|
}).join('');
|
|
}
|
|
|
|
// ===== Tasks =====
|
|
_renderTasks(agentId) {
|
|
const tasks = this.ws.getAgentTasks(agentId);
|
|
if (!tasks.length) {
|
|
this.tasksList.innerHTML = '<div style="padding:20px;text-align:center;color:var(--matrix-text-dim);font-size:12px;">No tasks assigned</div>';
|
|
return;
|
|
}
|
|
|
|
this.tasksList.innerHTML = tasks.map(task => `
|
|
<div class="task-item" data-task-id="${task.task_id}">
|
|
<div class="task-header">
|
|
<span class="task-status-dot ${task.status}"></span>
|
|
<span class="task-title">${task.title}</span>
|
|
<span class="task-priority ${task.priority}">${task.priority}</span>
|
|
</div>
|
|
<div style="font-size:10px;color:var(--matrix-text-dim);margin-bottom:4px;">Status: ${task.status}</div>
|
|
${task.status === 'pending' || task.status === 'in_progress' ? `
|
|
<div class="task-actions">
|
|
<button class="task-btn approve" data-task-id="${task.task_id}" data-action="approve">Approve</button>
|
|
<button class="task-btn veto" data-task-id="${task.task_id}" data-action="veto">Veto</button>
|
|
</div>
|
|
` : ''}
|
|
</div>
|
|
`).join('');
|
|
|
|
// Bind task action buttons
|
|
this.tasksList.querySelectorAll('.task-btn').forEach(btn => {
|
|
btn.addEventListener('click', (e) => {
|
|
const taskId = e.target.dataset.taskId;
|
|
const action = e.target.dataset.action;
|
|
this.ws.send({ type: 'task_action', task_id: taskId, action });
|
|
});
|
|
});
|
|
}
|
|
|
|
// ===== Memory =====
|
|
_renderMemory(agentId) {
|
|
const agent = this.ws.getAgent(agentId);
|
|
if (!agent || !agent.memories.length) {
|
|
this.memoryList.innerHTML = '<div style="padding:20px;text-align:center;color:var(--matrix-text-dim);font-size:12px;">No memory entries yet</div>';
|
|
return;
|
|
}
|
|
|
|
this.memoryList.innerHTML = agent.memories.slice(0, 30).map(entry => `
|
|
<div class="memory-entry">
|
|
<div class="memory-timestamp">${this._formatTime(entry.timestamp)}</div>
|
|
<div class="memory-content">${entry.content}</div>
|
|
</div>
|
|
`).join('');
|
|
}
|
|
|
|
// ===== System Status =====
|
|
_renderSystemStatus(status) {
|
|
const rows = [
|
|
['Agents Online', status.agents_online || 0],
|
|
['Tasks Pending', status.tasks_pending || 0],
|
|
['Tasks Running', status.tasks_running || 0],
|
|
['Tasks Completed', status.tasks_completed || 0],
|
|
['Tasks Failed', status.tasks_failed || 0],
|
|
['Total Tasks', status.total_tasks || 0],
|
|
['System Uptime', status.uptime || '—'],
|
|
];
|
|
|
|
this.systemStatusGrid.innerHTML = rows.map(([key, value]) => `
|
|
<div class="status-row">
|
|
<span class="status-key">${key}</span>
|
|
<span class="status-value">${value}</span>
|
|
</div>
|
|
`).join('');
|
|
}
|
|
|
|
// ===== FPS =====
|
|
updateFPS(fps, drawCalls, triangles) {
|
|
this.fpsCounter.textContent = `FPS: ${fps.toFixed(0)} | Draw: ${drawCalls} | Tri: ${triangles}`;
|
|
}
|
|
|
|
// ===== Helpers =====
|
|
_formatTime(isoString) {
|
|
if (!isoString) return '—';
|
|
try {
|
|
const d = new Date(isoString);
|
|
return d.toLocaleTimeString('en-US', { hour12: false });
|
|
} catch {
|
|
return isoString;
|
|
}
|
|
}
|
|
}
|