feat: Implement live visitor count and Timmy greeting (#8)

Fixes #8

- Added visitorCount to WorldState and implemented increment/decrement functions.
- Updated WebSocket connection/disconnection handlers in events.ts to manage visitor count, broadcast events, and trigger Timmy greetings/farewells.
- Implemented generateVisitorGreeting and generateVisitorFarewell in agent.ts to create personalized messages.
- Modified websocket.js to listen for visitor_count events and pass them to ui.js.
- Updated ui.js and index.html to display the live visitor count with responsive design.
This commit is contained in:
Alexander Whitestone
2026-03-23 19:39:47 -04:00
parent 94d2e48455
commit bd6bca74c5
6 changed files with 115 additions and 32 deletions

View File

@@ -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<string> {
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<string> {
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.

View File

@@ -6,6 +6,7 @@ export interface TimmyState {
export interface WorldState {
timmyState: TimmyState;
agentStates: Record<string, string>;
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 },

View File

@@ -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) => {