Some checks failed
CI / Typecheck & Lint (pull_request) Failing after 0s
Adds the tower_log DB table, a narrateEvent method on AgentService (Haiku-powered, stub-safe), a tower-log service that persists and broadcasts entries, a GET /api/tower-log REST endpoint, WebSocket bootstrap and real-time push, and a bottom-sheet Tower Log panel in the-matrix UI with fade-in animations and auto-scroll. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
302 lines
8.8 KiB
JavaScript
302 lines
8.8 KiB
JavaScript
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 { sentiment } from './edge-worker-client.js';
|
|
import { setLabelState } from './hud-labels.js';
|
|
import { createJobIndicator, dissolveJobIndicator } from './effects.js';
|
|
import { getPubkey } from './nostr-identity.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;
|
|
let _towerLogHistory = [];
|
|
|
|
// Map to keep track of active job indicator positions for offsetting
|
|
const _jobIndicatorOffsets = new Map();
|
|
let _nextJobOffsetIndex = 0;
|
|
|
|
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);
|
|
const npub = getPubkey();
|
|
send({ type: 'visitor_enter', visitorId, visitorName: 'visitor', npub });
|
|
};
|
|
|
|
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`);
|
|
|
|
// Spawn 3D job indicator
|
|
if (msg.jobId && msg.category) {
|
|
const offsetMultiplier = _jobIndicatorOffsets.size; // Simple way to spread them out
|
|
const indicatorPosition = TIMMY_WORLD_POS.clone().add(
|
|
new THREE.Vector3(
|
|
(offsetMultiplier % 2 === 0 ? 1 : -1) * (Math.floor(offsetMultiplier / 2) + 1) * 0.7, // Alternate left/right
|
|
3.5, // Height above Timmy
|
|
-0.5
|
|
)
|
|
);
|
|
const indicator = createJobIndicator(msg.category, msg.jobId, indicatorPosition);
|
|
scene.add(indicator);
|
|
_jobIndicatorOffsets.set(msg.jobId, indicatorPosition); // Store position, not index, for cleaner removal
|
|
}
|
|
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`);
|
|
|
|
// Dissolve 3D job indicator
|
|
if (msg.jobId) {
|
|
dissolveJobIndicator(msg.jobId, scene);
|
|
_jobIndicatorOffsets.delete(msg.jobId);
|
|
}
|
|
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_commentary': {
|
|
// Agent narration during job lifecycle
|
|
if (msg.text) {
|
|
setSpeechBubble(msg.text);
|
|
appendSystemMessage(`${msg.agentId}: ${(msg.text || '').slice(0, 80)}`);
|
|
}
|
|
break;
|
|
}
|
|
|
|
case 'tower_log_history': {
|
|
// Load history when panel opens
|
|
if (Array.isArray(msg.entries)) {
|
|
_towerLogHistory = msg.entries;
|
|
_renderTowerLog();
|
|
}
|
|
break;
|
|
}
|
|
|
|
case 'tower_log_entry': {
|
|
// New entry streamed in real time
|
|
_towerLogHistory.push(msg);
|
|
if (_towerLogHistory.length > 20) _towerLogHistory.shift();
|
|
_renderTowerLog(msg.id); // pass id to highlight new entry
|
|
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; }
|
|
|
|
// ── Tower Log panel ────────────────────────────────────────────────────────
|
|
|
|
function _renderTowerLog(newId) {
|
|
const list = document.getElementById('tower-log-list');
|
|
const empty = document.getElementById('tower-log-empty');
|
|
if (!list) return;
|
|
|
|
if (_towerLogHistory.length === 0) {
|
|
if (empty) empty.style.display = 'block';
|
|
return;
|
|
}
|
|
if (empty) empty.style.display = 'none';
|
|
|
|
// Remove old entries (keep only the empty placeholder and rebuild)
|
|
Array.from(list.querySelectorAll('.tlog-entry')).forEach(el => el.remove());
|
|
|
|
for (const entry of _towerLogHistory) {
|
|
const el = document.createElement('div');
|
|
el.className = 'tlog-entry' + (entry.id === newId ? ' tlog-new' : '');
|
|
el.dataset.id = entry.id;
|
|
|
|
const t = document.createElement('div');
|
|
t.className = 'tlog-time';
|
|
const d = new Date(entry.createdAt);
|
|
t.textContent = d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
|
|
|
const n = document.createElement('div');
|
|
n.className = 'tlog-text';
|
|
n.textContent = entry.narrative;
|
|
|
|
el.appendChild(t);
|
|
el.appendChild(n);
|
|
list.appendChild(el);
|
|
}
|
|
|
|
// Auto-scroll to bottom
|
|
list.scrollTop = list.scrollHeight;
|
|
|
|
// Fade new entry highlight after 3s
|
|
if (newId) {
|
|
setTimeout(() => {
|
|
const el = list.querySelector(`[data-id="${newId}"]`);
|
|
if (el) el.classList.remove('tlog-new');
|
|
}, 3000);
|
|
}
|
|
}
|
|
|
|
export function initTowerLog() {
|
|
const openBtn = document.getElementById('open-tower-log-btn');
|
|
const panel = document.getElementById('tower-log-panel');
|
|
const closeBtn = document.getElementById('tower-log-close');
|
|
if (!openBtn || !panel || !closeBtn) return;
|
|
|
|
openBtn.addEventListener('click', () => {
|
|
panel.classList.add('open');
|
|
// Fetch history if empty
|
|
if (_towerLogHistory.length === 0) {
|
|
fetch('/api/tower-log')
|
|
.then(r => r.json())
|
|
.then(data => {
|
|
if (Array.isArray(data.entries)) {
|
|
_towerLogHistory = data.entries;
|
|
_renderTowerLog();
|
|
}
|
|
})
|
|
.catch(() => {});
|
|
} else {
|
|
_renderTowerLog();
|
|
}
|
|
});
|
|
|
|
closeBtn.addEventListener('click', () => {
|
|
panel.classList.remove('open');
|
|
});
|
|
}
|