diff --git a/artifacts/api-server/src/lib/agent.ts b/artifacts/api-server/src/lib/agent.ts index b92a95f..41a2c85 100644 --- a/artifacts/api-server/src/lib/agent.ts +++ b/artifacts/api-server/src/lib/agent.ts @@ -280,6 +280,48 @@ No hedging, no steering them back to the hosted version. The magic is meant to b if (block.type !== "text") return "The crystal ball is cloudy… try again."; return block.text!.trim(); } + + async generateVisitorGreeting(ip: string): Promise { + if (STUB_MODE) { + return STUB_CHAT_REPLIES[Math.floor(Math.random() * STUB_CHAT_REPLIES.length)]!; + } + + const client = await getClient(); + const now = new Date(); + const hour = now.getHours(); + let timeOfDay: string; + if (hour < 12) timeOfDay = "morning"; + else if (hour < 18) timeOfDay = "afternoon"; + else timeOfDay = "evening"; + + const message = await client.messages.create({ + model: this.evalModel, + max_tokens: 100, + system: `You are Timmy, a whimsical wizard who runs a mystical workshop powered by Bitcoin Lightning. You are greeting a new visitor. Make it short (1-2 sentences), personalized to the time of day, and welcoming. Reference the current time of day (${timeOfDay}).`, + messages: [{ role: "user", content: `A new visitor has arrived with IP address ${ip}. Greet them!` }], + }); + const block = message.content[0]; + if (block.type !== "text") return "A new visitor has arrived!"; + return block.text!.trim(); + } + + async generateVisitorFarewell(): Promise { + if (STUB_MODE) { + return "Farewell, traveler!"; + } + + const client = await getClient(); + const message = await client.messages.create({ + model: this.evalModel, + max_tokens: 100, + system: `You are Timmy, a whimsical wizard who runs a mystical workshop powered by Bitcoin Lightning. A visitor has just left. Bid them a short (1-2 sentences) and warm farewell.`, + messages: [{ role: "user", content: `A visitor has just left. Bid them farewell!` }], + }); + const block = message.content[0]; + if (block.type !== "text") return "A visitor has departed!"; + return block.text!.trim(); + } + /** * Run a mini debate on a borderline eval request (#21). * Two opposing Haiku calls argue accept vs reject, then a third synthesizes. diff --git a/artifacts/api-server/src/lib/world-state.ts b/artifacts/api-server/src/lib/world-state.ts index 5e869bc..a9a5c24 100644 --- a/artifacts/api-server/src/lib/world-state.ts +++ b/artifacts/api-server/src/lib/world-state.ts @@ -6,6 +6,7 @@ export interface TimmyState { export interface WorldState { timmyState: TimmyState; agentStates: Record; + visitorCount: number; updatedAt: string; } @@ -17,9 +18,22 @@ const DEFAULT_TIMMY: TimmyState = { const _state: WorldState = { timmyState: { ...DEFAULT_TIMMY }, agentStates: { alpha: "idle", beta: "idle", gamma: "idle", delta: "idle" }, + visitorCount: 0, updatedAt: new Date().toISOString(), }; +export function incrementVisitorCount(): number { + _state.visitorCount++; + _state.updatedAt = new Date().toISOString(); + return _state.visitorCount; +} + +export function decrementVisitorCount(): number { + _state.visitorCount--; + _state.updatedAt = new Date().toISOString(); + return _state.visitorCount; +} + export function getWorldState(): WorldState { return { timmyState: { ..._state.timmyState }, diff --git a/artifacts/api-server/src/routes/events.ts b/artifacts/api-server/src/routes/events.ts index f644ded..14a6ae2 100644 --- a/artifacts/api-server/src/routes/events.ts +++ b/artifacts/api-server/src/routes/events.ts @@ -30,7 +30,12 @@ import { WebSocketServer } from "ws"; import type { Server } from "http"; import { eventBus, type BusEvent } from "../lib/event-bus.js"; import { makeLogger } from "../lib/logger.js"; -import { getWorldState, setAgentStateInWorld } from "../lib/world-state.js"; +import { + getWorldState, + setAgentStateInWorld, + incrementVisitorCount, + decrementVisitorCount, +} from "../lib/world-state.js"; import { agentService } from "../lib/agent.js"; import { db, worldEvents } from "@workspace/db"; @@ -315,6 +320,13 @@ export function attachWebSocketServer(server: Server): void { const ip = req.headers["x-forwarded-for"] ?? req.socket.remoteAddress ?? "unknown"; logger.info("ws client connected", { ip, clients: wss.clients.size }); + const newCount = incrementVisitorCount(); + broadcastToAll(wss, { type: "visitor_count", count: newCount }); + void (async () => { + const greeting = await agentService.generateVisitorGreeting(ip.toString()); + broadcastToAll(wss, { type: "chat", agentId: "timmy", text: greeting }); + })(); + void sendWorldStateBootstrap(socket); const busHandler = (ev: BusEvent) => broadcast(socket, ev); @@ -329,33 +341,7 @@ export function attachWebSocketServer(server: Server): void { const msg = JSON.parse(raw.toString()) as { type?: string; text?: string; visitorId?: string; npub?: string }; if (msg.type === "pong") return; if (msg.type === "subscribe") { - send(socket, { type: "agent_count", count: wss.clients.size }); - } - if (msg.type === "visitor_enter") { - const { visitorId, npub } = msg; - if (visitorId && npub) { - connectedVisitors.set(visitorId, npub); - const formattedNpub = `${npub.slice(0, 8)}…${npub.slice(-4)}`; - broadcastToAll(wss, { type: "chat", agentId: "timmy", text: `Welcome, Nostr user ${formattedNpub}! What can I help you with?` }); - } - - wss.clients.forEach(c => { - if (c !== socket && c.readyState === 1) { - c.send(JSON.stringify({ type: "visitor_count", count: wss.clients.size })); - } - }); - send(socket, { type: "visitor_count", count: wss.clients.size }); - } - if (msg.type === "visitor_leave") { - const { visitorId } = msg; - if (visitorId) { - connectedVisitors.delete(visitorId); - } - wss.clients.forEach(c => { - if (c !== socket && c.readyState === 1) { - c.send(JSON.stringify({ type: "visitor_count", count: Math.max(0, wss.clients.size - 1) })); - } - }); + send(socket, { type: "visitor_count", count: getWorldState().visitorCount }); } if (msg.type === "visitor_message" && msg.text) { const text = String(msg.text).slice(0, 500); @@ -401,10 +387,25 @@ export function attachWebSocketServer(server: Server): void { } }); + const VISITOR_FAREWELL_THROTTLE_MS = 30_000; + let lastFarewellTime = 0; + socket.on("close", () => { clearInterval(pingTimer); eventBus.off("bus", busHandler); logger.info("ws client disconnected", { clients: wss.clients.size - 1 }); + + const newCount = decrementVisitorCount(); + broadcastToAll(wss, { type: "visitor_count", count: newCount }); + + const now = Date.now(); + if (now - lastFarewellTime > VISITOR_FAREWELL_THROTTLE_MS) { + void (async () => { + const farewell = await agentService.generateVisitorFarewell(); + broadcastToAll(wss, { type: "chat", agentId: "timmy", text: farewell }); + })(); + lastFarewellTime = now; + } }); socket.on("error", (err) => { diff --git a/the-matrix/index.html b/the-matrix/index.html index f3ac471..35d450e 100644 --- a/the-matrix/index.html +++ b/the-matrix/index.html @@ -37,6 +37,18 @@ font-size: 13px; letter-spacing: 3px; margin-bottom: 4px; color: #7799cc; text-shadow: 0 0 10px #4466aa; } + #visitor-count-display { + margin-top: 5px; + font-size: 11px; color: #5588bb; + text-shadow: 0 0 6px #2244aa; + } + #visitor-count-display .count-number { + font-weight: bold; + } + @media (max-width: 600px) { + #visitor-count-display .desktop-only { display: none; } + #visitor-count-display .count-number::before { content: '👤 '; } + } /* Nostr Identity UI */ .nostr-btn { @@ -606,6 +618,7 @@

THE WORKSHOP

FPS: --
JOBS: 0
+
VISITORS: 0
Balance: -- sats ⚡ Top Up diff --git a/the-matrix/js/ui.js b/the-matrix/js/ui.js index 801482a..7c43896 100644 --- a/the-matrix/js/ui.js +++ b/the-matrix/js/ui.js @@ -344,6 +344,19 @@ export function updateUI({ fps, jobCount, connectionState }) { } } +export function updateVisitorCount(count) { + const $visitorCountDisplay = document.querySelector('#visitor-count-display .count-number'); + if ($visitorCountDisplay) { + $visitorCountDisplay.textContent = count; + const $desktopOnly = document.querySelector('#visitor-count-display .desktop-only'); + if (window.innerWidth > 600) { + if ($desktopOnly) $desktopOnly.textContent = `VISITORS:`; + } else { + if ($desktopOnly) $desktopOnly.textContent = ``; // Hide 'VISITORS:' text on mobile + } + } +} + export function appendSystemMessage(text) { if (!$log) return; const el = document.createElement('div'); diff --git a/the-matrix/js/websocket.js b/the-matrix/js/websocket.js index 36ea5c2..18ad89a 100644 --- a/the-matrix/js/websocket.js +++ b/the-matrix/js/websocket.js @@ -1,7 +1,7 @@ import * as THREE from 'three'; import { scene } from './world.js'; // Import the scene import { setAgentState, setSpeechBubble, applyAgentStates, setMood, TIMMY_WORLD_POS } from './agents.js'; -import { appendSystemMessage, appendDebateMessage, showCostTicker, updateCostTicker } from './ui.js'; +import { appendSystemMessage, appendDebateMessage, showCostTicker, updateCostTicker, updateVisitorCount } from './ui.js'; import { sentiment } from './edge-worker-client.js'; import { setLabelState } from './hud-labels.js'; import { createJobIndicator, dissolveJobIndicator } from './effects.js'; @@ -47,8 +47,6 @@ function connect() { ws.onopen = () => { connectionState = 'connected'; clearTimeout(reconnectTimer); - const npub = getPubkey(); - send({ type: 'visitor_enter', visitorId, visitorName: 'visitor', npub }); }; ws.onmessage = event => { @@ -190,8 +188,10 @@ function handleMessage(msg) { break; } - case 'agent_count': case 'visitor_count': + if (typeof msg.count === 'number') { + updateVisitorCount(msg.count); + } break; default: