## Task Task #20: Timmy responds to Workshop input bar — make the "Say something to Timmy…" input bar actually trigger an AI response shown in Timmy's speech bubble. ## What was built ### Server (artifacts/api-server/src/lib/agent.ts) - Added `chatReply(userText)` method to AgentService - Uses claude-haiku (cheaper eval model) with a wizard persona system prompt - 150-token limit so replies fit in the speech bubble - Stub mode: returns one of 4 wizard-themed canned replies after 400ms delay - Real mode: calls Anthropic with wizard persona, truncates to 250 chars ### Server (artifacts/api-server/src/routes/events.ts) - Imported agentService - Added per-visitor rate limit system: 3 replies/minute per visitorId (in-memory Map) - Added broadcastToAll() helper for broadcasting to all WS clients - Updated visitor_message handler: 1. Broadcasts visitor message to all watchers as before 2. Checks rate limit — if exceeded, sends polite "I need a moment…" reply 3. Fire-and-forget async AI call: - Broadcasts agent_state: gamma=working (crystal ball pulses) - Calls agentService.chatReply() - Broadcasts agent_state: gamma=idle - Broadcasts chat: agentId=timmy, text=reply to ALL clients - Logs world event "visitor:reply" ### Frontend (the-matrix/js/websocket.js) - Updated case 'chat' handler to differentiate message sources: - agentId === 'timmy': speech bubble + event log entry "Timmy: <text>" - agentId === 'visitor': event log only (don't hijack speech bubble) - everything else (delta/alpha/beta payment notifications): speech bubble ## What was already working (no change needed) - Enter key on input bar (ui.js already had keydown listener) - Input clearing after send (already in ui.js) - Speech bubble rendering (setSpeechBubble already existed in agents.js) - WebSocket sendVisitorMessage already exported from websocket.js ## Tests - 27/27 testkit PASS (no regressions) - TypeScript: 0 errors - Vite build: clean (the-matrix rebuilt)
130 lines
3.3 KiB
JavaScript
130 lines
3.3 KiB
JavaScript
import { setAgentState, setSpeechBubble, applyAgentStates } from './agents.js';
|
|
import { appendSystemMessage } from './ui.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);
|
|
break;
|
|
}
|
|
|
|
case 'job_started': {
|
|
jobCount++;
|
|
if (msg.agentId) setAgentState(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');
|
|
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));
|
|
} 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_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; }
|