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'; function resolveWsUrl() { const explicit = import.meta.env.VITE_WS_URL; if (explicit) return explicit; const proto = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; return `${proto}//${window.location.host}/api/ws`; } const WS_URL = resolveWsUrl(); let ws = null; let connectionState = 'disconnected'; let jobCount = 0; let reconnectTimer = null; let visitorId = null; const RECONNECT_DELAY_MS = 5000; export function initWebSocket(_scene) { visitorId = crypto.randomUUID(); connect(); } function connect() { if (ws) { ws.onclose = null; ws.close(); } connectionState = 'connecting'; try { ws = new WebSocket(WS_URL); } catch { connectionState = 'disconnected'; scheduleReconnect(); return; } ws.onopen = () => { connectionState = 'connected'; clearTimeout(reconnectTimer); send({ type: 'visitor_enter', visitorId, visitorName: 'visitor' }); }; ws.onmessage = event => { try { handleMessage(JSON.parse(event.data)); } catch { /* ignore */ } }; ws.onerror = () => { connectionState = 'disconnected'; }; ws.onclose = () => { connectionState = 'disconnected'; scheduleReconnect(); }; } function scheduleReconnect() { clearTimeout(reconnectTimer); reconnectTimer = setTimeout(connect, RECONNECT_DELAY_MS); } function send(payload) { if (ws && ws.readyState === WebSocket.OPEN) { ws.send(JSON.stringify(payload)); } } function handleMessage(msg) { switch (msg.type) { case 'ping': send({ type: 'pong' }); break; case 'world_state': { if (msg.agentStates) applyAgentStates(msg.agentStates); if (msg.recentEvents) { const last = msg.recentEvents.slice(-3); last.forEach(ev => appendSystemMessage(ev.summary || ev.type)); } break; } case 'timmy_state': { break; } case 'agent_state': { if (msg.agentId && msg.state) { setAgentState(msg.agentId, msg.state); setLabelState(msg.agentId, msg.state); } break; } case 'job_started': { jobCount++; if (msg.agentId) { setAgentState(msg.agentId, 'active'); setLabelState(msg.agentId, 'active'); } appendSystemMessage(`job ${(msg.jobId || '').slice(0, 8)} started`); break; } case 'job_completed': { if (jobCount > 0) jobCount--; if (msg.agentId) { setAgentState(msg.agentId, 'idle'); setLabelState(msg.agentId, 'idle'); } appendSystemMessage(`job ${(msg.jobId || '').slice(0, 8)} complete`); break; } case 'chat': { if (msg.agentId === 'timmy') { // Timmy's AI reply: show in speech bubble + event log if (msg.text) setSpeechBubble(msg.text); appendSystemMessage('Timmy: ' + (msg.text || '').slice(0, 80)); // Sentiment-driven facial expression on inbound Timmy messages if (msg.text) { sentiment(msg.text).then(s => { setMood(s.label); setTimeout(() => setMood(null), 10_000); }).catch(() => {}); } } else if (msg.agentId === 'visitor') { // Another visitor's message: event log only (don't hijack the speech bubble) appendSystemMessage((msg.text || '').slice(0, 80)); } else { // System agent messages (delta payment confirmations, etc.): speech bubble if (msg.text) setSpeechBubble(msg.text); } break; } case 'agent_debate': { // Debate messages from Beta-A, Beta-B, or final verdict (#21) const isVerdict = msg.position === 'verdict'; appendDebateMessage(msg.agent, msg.argument, isVerdict, msg.accepted); if (isVerdict) { setSpeechBubble(msg.argument); } break; } case 'cost_update': { // Real-time cost ticker (#68): show estimated cost when job starts, // update to final charged amount when job completes. if (msg.isFinal) { updateCostTicker(msg.sats, true); } else { showCostTicker(msg.sats); } break; } case 'agent_count': case 'visitor_count': break; default: break; } } export function sendVisitorMessage(text) { send({ type: 'visitor_message', visitorId, text }); } export function getConnectionState() { return connectionState; } export function getJobCount() { return jobCount; }