feat: Workshop Phase 4 — visitor chat via WebSocket bark engine (#394)

Co-authored-by: Kimi Agent <kimi@timmy.local>
Co-committed-by: Kimi Agent <kimi@timmy.local>
This commit is contained in:
2026-03-19 01:54:06 -04:00
committed by hermes
parent 2209ac82d2
commit 86224d042d
2 changed files with 206 additions and 3 deletions

View File

@@ -5,7 +5,8 @@ The primary consumer is the browser on first load — before any
WebSocket events arrive, the client needs a full state snapshot.
The ``/ws/world`` endpoint streams ``timmy_state`` messages whenever
the heartbeat detects a state change.
the heartbeat detects a state change. It also accepts ``visitor_message``
frames from the 3D client and responds with ``timmy_speech`` barks.
Source of truth: ``~/.timmy/presence.json`` written by
:class:`~timmy.workshop_state.WorkshopHeartbeat`.
@@ -13,9 +14,11 @@ Falls back to a live ``get_state_dict()`` call if the file is stale
or missing.
"""
import asyncio
import json
import logging
import time
from collections import deque
from datetime import UTC, datetime
from fastapi import APIRouter, WebSocket
@@ -35,6 +38,13 @@ _ws_clients: list[WebSocket] = []
_STALE_THRESHOLD = 90 # seconds — file older than this triggers live rebuild
# Recent conversation buffer — kept in memory for the Workshop overlay.
# Stores the last _MAX_EXCHANGES (visitor_text, timmy_text) pairs.
_MAX_EXCHANGES = 3
_conversation: deque[dict] = deque(maxlen=_MAX_EXCHANGES)
_WORKSHOP_SESSION_ID = "workshop"
def _read_presence_file() -> dict | None:
"""Read presence.json if it exists and is fresh enough."""
@@ -116,7 +126,8 @@ async def world_ws(websocket: WebSocket) -> None:
"""Accept a Workshop client and keep it alive for state broadcasts.
Sends a full ``world_state`` snapshot immediately on connect so the
client never starts from a blank slate.
client never starts from a blank slate. Incoming frames are parsed
as JSON — ``visitor_message`` triggers a bark response.
"""
await websocket.accept()
_ws_clients.append(websocket)
@@ -130,7 +141,8 @@ async def world_ws(websocket: WebSocket) -> None:
logger.warning("Failed to send WS snapshot: %s", exc)
try:
while True:
await websocket.receive_text() # keep-alive
raw = await websocket.receive_text()
await _handle_client_message(raw)
except Exception:
pass
finally:
@@ -156,3 +168,71 @@ async def broadcast_world_state(presence: dict) -> None:
for ws in dead:
if ws in _ws_clients:
_ws_clients.remove(ws)
# ---------------------------------------------------------------------------
# Visitor chat — bark engine
# ---------------------------------------------------------------------------
async def _handle_client_message(raw: str) -> None:
"""Dispatch an incoming WebSocket frame from the Workshop client."""
try:
data = json.loads(raw)
except (json.JSONDecodeError, TypeError):
return # ignore non-JSON keep-alive pings
if data.get("type") == "visitor_message":
text = (data.get("text") or "").strip()
if text:
asyncio.create_task(_bark_and_broadcast(text))
async def _bark_and_broadcast(visitor_text: str) -> None:
"""Generate a bark response and broadcast it to all Workshop clients."""
# Signal "thinking" state
await _broadcast_speech({"type": "timmy_thinking"})
reply = await _generate_bark(visitor_text)
# Store exchange in conversation buffer
_conversation.append({"visitor": visitor_text, "timmy": reply})
# Broadcast speech bubble + conversation history
await _broadcast_speech(
{
"type": "timmy_speech",
"text": reply,
"recentExchanges": list(_conversation),
}
)
async def _generate_bark(visitor_text: str) -> str:
"""Generate a short in-character bark response.
Uses the existing Timmy session with a dedicated workshop session ID.
Gracefully degrades to a canned response if inference fails.
"""
try:
from timmy import session as _session
response = await _session.chat(visitor_text, session_id=_WORKSHOP_SESSION_ID)
return response
except Exception as exc:
logger.warning("Bark generation failed: %s", exc)
return "Hmm, my thoughts are a bit tangled right now."
async def _broadcast_speech(payload: dict) -> None:
"""Broadcast a speech message to all connected Workshop clients."""
message = json.dumps(payload)
dead: list[WebSocket] = []
for ws in _ws_clients:
try:
await ws.send_text(message)
except Exception:
dead.append(ws)
for ws in dead:
if ws in _ws_clients:
_ws_clients.remove(ws)