Files
the-matrix/js/websocket.js
Perplexity Computer a9da7393c7 feat: Workshop interaction layer — chat input, visitor presence, bark display (#40, #41, #42)
Implements the minimum viable conversation loop for Workshop #222:
visitor arrives → sends message → Timmy barks back.

- js/visitor.js: Visitor presence protocol (#41)
  - visitor_entered on load (with device detection: ipad/desktop/mobile)
  - visitor_left on unload or 30s hidden (iPad tab suspend)
  - visitor_message dispatched from chat input
  - visitor_interaction export for future tap-to-interact (#44)
  - Session duration tracking

- js/bark.js: Bark display system (#42)
  - showBark() renders prominent viewport toasts with typing animation
  - Auto-dismiss after display time + typing duration
  - Queue system (max 3 simultaneous, overflow queued)
  - Demo barks in mock mode (Workshop-themed: 222, sovereignty, chain)
  - Barks also logged permanently in chat panel

- index.html: Chat input bar (#40)
  - Terminal-styled input + send button at viewport bottom
  - Enter to send (desktop), button tap (iPad)
  - Safe-area padding for notched devices
  - Chat panel repositioned above input bar
  - Bark container in upper viewport third

- js/websocket.js: New message handlers
  - 'bark' message → showBark() dispatch
  - 'ambient_state' message → placeholder for #43
  - Demo barks start in mock mode

- js/ui.js: appendChatMessage() accepts optional CSS class
  - Visitor messages styled differently from agent messages

Build: 18 modules, 0 errors
Tested: desktop (1280x800) + mobile (390x844) via Playwright

Closes #40, #41, #42
Ref: rockachopa/Timmy-time-dashboard#222, #243
2026-03-19 01:46:04 +00:00

285 lines
7.6 KiB
JavaScript

/**
* websocket.js — WebSocket client for The Matrix.
*
* Two modes controlled by Config:
* - Live mode: connects to a real Timmy Tower backend via Config.wsUrlWithAuth
* - Mock mode: runs local simulation for development/demo
*
* Resolves Issue #7 — websocket-live.js with reconnection + backoff
* Resolves Issue #11 — WS auth token sent via query param on connect
*/
import { AGENT_DEFS, colorToCss } from './agent-defs.js';
import { setAgentState, addAgent } from './agents.js';
import { appendChatMessage } from './ui.js';
import { Config } from './config.js';
import { showBark, startDemoBarks, stopDemoBarks } from './bark.js';
const agentById = Object.fromEntries(AGENT_DEFS.map(d => [d.id, d]));
let ws = null;
let connectionState = 'disconnected';
let jobCount = 0;
let reconnectTimer = null;
let reconnectAttempts = 0;
let heartbeatTimer = null;
let heartbeatTimeout = null;
/* ── Public API ── */
export function initWebSocket(_scene) {
if (Config.isLive) {
logEvent('Connecting to ' + Config.wsUrl + '…');
connect();
} else {
connectionState = 'mock';
logEvent('Mock mode — no live backend');
// Start demo barks in mock mode to show the system working (#42)
startDemoBarks();
}
}
export function getConnectionState() {
return connectionState;
}
export function getJobCount() {
return jobCount;
}
/**
* Send a message to the backend. In mock mode this is a no-op.
* @param {object} msg — message object (will be JSON-stringified)
*/
export function sendMessage(msg) {
if (!ws || ws.readyState !== WebSocket.OPEN) return;
try {
ws.send(JSON.stringify(msg));
} catch { /* onclose will fire */ }
}
/* ── Live WebSocket Client ── */
function connect() {
if (ws) {
ws.onclose = null;
ws.close();
}
connectionState = 'connecting';
const url = Config.wsUrlWithAuth;
if (!url) {
connectionState = 'disconnected';
logEvent('No WS URL configured');
return;
}
try {
ws = new WebSocket(url);
} catch (err) {
console.warn('[Matrix WS] Connection failed:', err.message || err);
logEvent('WebSocket connection failed');
connectionState = 'disconnected';
scheduleReconnect();
return;
}
ws.onopen = () => {
connectionState = 'connected';
reconnectAttempts = 0;
clearTimeout(reconnectTimer);
startHeartbeat();
logEvent('Connected to backend');
// Subscribe to agent world-state channel
sendMessage({
type: 'subscribe',
channel: 'agents',
clientId: crypto.randomUUID(),
});
};
ws.onmessage = (event) => {
resetHeartbeatTimeout();
try {
handleMessage(JSON.parse(event.data));
} catch (err) {
console.warn('[Matrix WS] Parse error:', err.message, '| raw:', event.data?.slice?.(0, 200));
}
};
ws.onerror = (event) => {
console.warn('[Matrix WS] Error event:', event);
connectionState = 'disconnected';
};
ws.onclose = (event) => {
connectionState = 'disconnected';
stopHeartbeat();
// Don't reconnect on clean close (1000) or going away (1001)
if (event.code === 1000 || event.code === 1001) {
console.info('[Matrix WS] Clean close (code ' + event.code + '), not reconnecting');
logEvent('Disconnected (clean)');
return;
}
console.warn('[Matrix WS] Unexpected close — code:', event.code, 'reason:', event.reason || '(none)');
logEvent('Connection lost — reconnecting…');
scheduleReconnect();
};
}
/* ── Reconnection with exponential backoff ── */
function scheduleReconnect() {
clearTimeout(reconnectTimer);
const delay = Math.min(
Config.reconnectBaseMs * Math.pow(2, reconnectAttempts),
Config.reconnectMaxMs,
);
reconnectAttempts++;
console.info('[Matrix WS] Reconnecting in', Math.round(delay / 1000), 's (attempt', reconnectAttempts + ')');
reconnectTimer = setTimeout(connect, delay);
}
/* ── Heartbeat / zombie detection ── */
function startHeartbeat() {
stopHeartbeat();
heartbeatTimer = setInterval(() => {
if (ws && ws.readyState === WebSocket.OPEN) {
try {
ws.send(JSON.stringify({ type: 'ping' }));
} catch { /* ignore, onclose will fire */ }
heartbeatTimeout = setTimeout(() => {
console.warn('[Matrix WS] Heartbeat timeout — closing zombie connection');
if (ws) ws.close(4000, 'heartbeat timeout');
}, Config.heartbeatTimeoutMs);
}
}, Config.heartbeatIntervalMs);
}
function stopHeartbeat() {
clearInterval(heartbeatTimer);
clearTimeout(heartbeatTimeout);
heartbeatTimer = null;
heartbeatTimeout = null;
}
function resetHeartbeatTimeout() {
clearTimeout(heartbeatTimeout);
heartbeatTimeout = null;
}
/* ── Message dispatcher ── */
function handleMessage(msg) {
switch (msg.type) {
case 'agent_state': {
if (msg.agentId && msg.state) {
setAgentState(msg.agentId, msg.state);
}
break;
}
case 'job_started': {
jobCount++;
if (msg.agentId) setAgentState(msg.agentId, 'active');
logEvent(`JOB ${(msg.jobId || '').slice(0, 8)} started`);
break;
}
case 'job_completed': {
if (jobCount > 0) jobCount--;
if (msg.agentId) setAgentState(msg.agentId, 'idle');
logEvent(`JOB ${(msg.jobId || '').slice(0, 8)} completed`);
break;
}
case 'chat': {
const def = agentById[msg.agentId];
if (def && msg.text) {
appendChatMessage(def.label, msg.text, colorToCss(def.color));
}
break;
}
/**
* Bark display (Issue #42).
* Timmy's short, in-character reactions displayed prominently in the viewport.
*/
case 'bark': {
if (msg.text) {
showBark({
text: msg.text,
agentId: msg.agent_id || msg.agentId || 'timmy',
emotion: msg.emotion || 'calm',
color: msg.color,
});
}
break;
}
/**
* Ambient state (placeholder for Issue #43).
* Will be handled by ambient.js when implemented.
*/
case 'ambient_state': {
console.info('[Matrix WS] Ambient state:', msg.state);
// TODO: dispatch to ambient.js setAmbientState(msg.state)
break;
}
/**
* Dynamic agent hot-add (Issue #12).
*
* When the backend sends an agent_joined event, we register the new
* agent definition and spawn its 3D avatar without requiring a page
* reload. The event payload must include at minimum:
* { type: 'agent_joined', id, label, color, role }
*
* Optional fields: direction, x, z (auto-placed if omitted).
*/
case 'agent_joined': {
if (!msg.id || !msg.label) {
console.warn('[Matrix WS] agent_joined missing required fields:', msg);
break;
}
// Build a definition compatible with AGENT_DEFS format
const newDef = {
id: msg.id,
label: msg.label,
color: typeof msg.color === 'number' ? msg.color : parseInt(msg.color, 16) || 0x00ff88,
role: msg.role || 'agent',
direction: msg.direction || 'north',
x: msg.x ?? null,
z: msg.z ?? null,
};
// addAgent handles placement, scene insertion, and connection lines
const added = addAgent(newDef);
if (added) {
// Update local lookup for future chat messages
agentById[newDef.id] = newDef;
logEvent(`Agent ${newDef.label} joined the swarm`);
}
break;
}
case 'pong':
case 'agent_count':
break;
default:
console.debug('[Matrix WS] Unhandled message type:', msg.type);
break;
}
}
function logEvent(text) {
appendChatMessage('SYS', text, '#005500');
}