feat: Batcave workshop terminal — Hermes WS, session persistence, tool output

- Add 3D Workshop Console panel (left of main terminal arc) that renders
  live tool output history and Hermes connection status as a canvas texture
- Connect chat to Hermes backend via WebSocket (/api/world/ws) with
  automatic reconnect (5s backoff); falls back to simulated responses offline
- Session persistence via localStorage (last 60 messages restored on reload,
  including tool output blocks)
- Tool output rendering: addToolOutput() creates <pre class="tool-output">
  blocks with call/result direction indicators, CSS styled, max-height scroll
- Workshop 3D panel refreshes every 5s in game loop to show connection state
- HUD status indicator (● / ○ Hermes) updates on connect/disconnect
- WebSocket status dot in chat header changes color on connect/disconnect

Fixes #6
This commit is contained in:
Alexander Whitestone
2026-03-23 18:40:30 -04:00
parent 3725c933cf
commit 5577b74bbc
3 changed files with 369 additions and 15 deletions

358
app.js
View File

@@ -35,6 +35,17 @@ let frameCount = 0, lastFPSTime = 0, fps = 0;
let chatOpen = true; let chatOpen = true;
let loadProgress = 0; let loadProgress = 0;
// ═══ HERMES WS STATE ═══
let hermesWs = null;
let wsReconnectTimer = null;
let wsConnected = false;
let recentToolOutputs = [];
let workshopPanelCtx = null;
let workshopPanelTexture = null;
let workshopPanelCanvas = null;
let workshopScanMat = null;
let workshopPanelRefreshTimer = 0;
// ═══ INIT ═══ // ═══ INIT ═══
function init() { function init() {
clock = new THREE.Clock(); clock = new THREE.Clock();
@@ -70,6 +81,8 @@ function init() {
createFloor(); createFloor();
updateLoad(55); updateLoad(55);
createBatcaveTerminal(); createBatcaveTerminal();
updateLoad(65);
createWorkshopTerminal();
updateLoad(70); updateLoad(70);
createPortal(); createPortal();
updateLoad(80); updateLoad(80);
@@ -115,6 +128,10 @@ function init() {
setTimeout(() => { document.getElementById('loading-screen').remove(); }, 900); setTimeout(() => { document.getElementById('loading-screen').remove(); }, 900);
}, 600); }, 600);
// Session + Hermes
loadSession();
connectHermes();
// Start loop // Start loop
requestAnimationFrame(gameLoop); requestAnimationFrame(gameLoop);
} }
@@ -398,6 +415,149 @@ function createBatcaveTerminal() {
scene.add(termGroup); scene.add(termGroup);
} }
// ═══ WORKSHOP TERMINAL ═══
function createWorkshopTerminal() {
const group = new THREE.Group();
group.position.set(-14, 0, 0);
group.rotation.y = Math.PI / 4;
const w = 8, h = 5;
const panelY = h / 2 + 0.5;
// Background
const panelGeo = new THREE.PlaneGeometry(w, h);
const panelMat = new THREE.MeshBasicMaterial({
color: 0x000510, transparent: true, opacity: 0.85, side: THREE.DoubleSide,
});
const panel = new THREE.Mesh(panelGeo, panelMat);
panel.position.y = panelY;
group.add(panel);
// Border
const borderMat = new THREE.LineBasicMaterial({ color: 0x44ff88, transparent: true, opacity: 0.7 });
const border = new THREE.LineSegments(new THREE.EdgesGeometry(panelGeo), borderMat);
border.position.y = panelY;
group.add(border);
// Canvas texture
workshopPanelCanvas = document.createElement('canvas');
workshopPanelCanvas.width = 1024;
workshopPanelCanvas.height = 640;
workshopPanelCtx = workshopPanelCanvas.getContext('2d');
workshopPanelTexture = new THREE.CanvasTexture(workshopPanelCanvas);
workshopPanelTexture.minFilter = THREE.LinearFilter;
const textMat = new THREE.MeshBasicMaterial({
map: workshopPanelTexture, transparent: true, side: THREE.DoubleSide, depthWrite: false,
});
const textMesh = new THREE.Mesh(new THREE.PlaneGeometry(w * 0.95, h * 0.95), textMat);
textMesh.position.set(0, panelY, 0.01);
group.add(textMesh);
// Scanline overlay
workshopScanMat = new THREE.ShaderMaterial({
transparent: true, depthWrite: false,
uniforms: { uTime: { value: 0 }, uColor: { value: new THREE.Color(0x44ff88) } },
vertexShader: `varying vec2 vUv; void main() { vUv = uv; gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); }`,
fragmentShader: `uniform float uTime; uniform vec3 uColor; varying vec2 vUv;
void main() {
float s = pow(sin(vUv.y * 200.0 + uTime * 2.0) * 0.5 + 0.5, 8.0);
float sweep = 1.0 - (1.0 - smoothstep(0.0, 0.02, abs(fract(vUv.y - uTime * 0.1) - 0.5))) * 0.3;
gl_FragColor = vec4(uColor, s * 0.04 + (1.0 - sweep) * 0.07);
}`,
side: THREE.DoubleSide,
});
const scanMesh = new THREE.Mesh(new THREE.PlaneGeometry(w, h), workshopScanMat);
scanMesh.position.set(0, panelY, 0.02);
group.add(scanMesh);
// Glow behind
const glowMat = new THREE.MeshBasicMaterial({ color: 0x44ff88, transparent: true, opacity: 0.05, side: THREE.DoubleSide });
const glowMesh = new THREE.Mesh(new THREE.PlaneGeometry(w + 0.5, h + 0.5), glowMat);
glowMesh.position.set(0, panelY, -0.05);
group.add(glowMesh);
// Point light
const wLight = new THREE.PointLight(0x44ff88, 1.5, 15, 1.5);
wLight.position.set(0, panelY, 0.5);
group.add(wLight);
// Label
const lc = document.createElement('canvas');
lc.width = 512; lc.height = 64;
const lx = lc.getContext('2d');
lx.font = 'bold 28px "Orbitron", sans-serif';
lx.fillStyle = '#44ff88';
lx.textAlign = 'center';
lx.fillText('⚙ WORKSHOP', 256, 42);
const lt = new THREE.CanvasTexture(lc);
const labelMat = new THREE.MeshBasicMaterial({ map: lt, transparent: true, side: THREE.DoubleSide });
const labelMesh = new THREE.Mesh(new THREE.PlaneGeometry(3.5, 0.5), labelMat);
labelMesh.position.set(0, panelY + h / 2 + 0.5, 0);
group.add(labelMesh);
// Support column
const colGeo = new THREE.CylinderGeometry(0.15, 0.2, panelY, 8);
const colMat = new THREE.MeshStandardMaterial({ color: 0x0a0f1a, roughness: 0.4, metalness: 0.8, emissive: 0x44ff88, emissiveIntensity: 0.05 });
const col = new THREE.Mesh(colGeo, colMat);
col.position.y = panelY / 2;
col.castShadow = true;
group.add(col);
scene.add(group);
batcaveTerminals.push({ group, scanMat: workshopScanMat, borderMat });
refreshWorkshopPanel();
}
function refreshWorkshopPanel() {
if (!workshopPanelCtx) return;
const ctx = workshopPanelCtx;
const W = 1024, H = 640;
ctx.clearRect(0, 0, W, H);
// Title bar
ctx.font = 'bold 26px "JetBrains Mono", monospace';
ctx.fillStyle = '#44ff88';
ctx.fillText('⚙ WORKSHOP CONSOLE', 20, 40);
ctx.strokeStyle = '#44ff88';
ctx.globalAlpha = 0.3;
ctx.beginPath(); ctx.moveTo(20, 52); ctx.lineTo(W - 20, 52); ctx.stroke();
ctx.globalAlpha = 1;
if (recentToolOutputs.length === 0) {
ctx.font = '18px "JetBrains Mono", monospace';
ctx.fillStyle = '#5a6a8a';
ctx.fillText('Awaiting tool activity...', 20, 90);
ctx.fillText('Connect to Hermes to begin.', 20, 115);
} else {
let y = 76;
for (const item of recentToolOutputs.slice(0, 8)) {
if (y > H - 50) break;
const arrow = item.kind === 'call' ? '▶' : '◀';
const color = item.kind === 'call' ? '#ffaa44' : '#44ff88';
const ts = new Date(item.time).toLocaleTimeString();
ctx.font = '15px "JetBrains Mono", monospace';
ctx.fillStyle = color;
ctx.fillText(`${arrow} [${(item.tool || 'TOOL').toUpperCase()}] ${ts}`, 20, y);
y += 20;
ctx.font = '13px "JetBrains Mono", monospace';
ctx.fillStyle = '#8899bb';
const lines = String(item.content || '').replace(/\n+/g, ' ↩ ').slice(0, 110);
ctx.fillText(lines, 28, y);
y += 22;
}
}
// Status bar
ctx.fillStyle = wsConnected ? '#4af0c0' : '#ff4466';
ctx.font = 'bold 14px "JetBrains Mono", monospace';
ctx.fillText(`● HERMES ${wsConnected ? 'CONNECTED' : 'OFFLINE'} — session: ${getSessionId().slice(0, 16)}`, 20, H - 16);
if (workshopPanelTexture) workshopPanelTexture.needsUpdate = true;
}
function createHoloPanel(parent, opts) { function createHoloPanel(parent, opts) {
const { x, y, z, w, h, title, lines, color, rotY } = opts; const { x, y, z, w, h, title, lines, color, rotY } = opts;
const group = new THREE.Group(); const group = new THREE.Group();
@@ -843,21 +1003,34 @@ function sendChatMessage() {
addChatMessage('user', text); addChatMessage('user', text);
input.value = ''; input.value = '';
saveSession();
// Simulate Timmy response if (hermesWs && hermesWs.readyState === WebSocket.OPEN) {
setTimeout(() => { // Send to real Hermes backend
const responses = [ hermesWs.send(JSON.stringify({
'Processing your request through the harness...', type: 'message',
'I have noted this in my thought stream.', role: 'user',
'Acknowledged. Routing to appropriate agent loop.', content: text,
'The sovereign space recognizes your command.', session_id: getSessionId(),
'Running analysis. Results will appear on the main terminal.', }));
'My crystal ball says... yes. Implementing.', } else {
'Understood, Alexander. Adjusting priorities.', // Offline fallback
]; setTimeout(() => {
const resp = responses[Math.floor(Math.random() * responses.length)]; const responses = [
addChatMessage('timmy', resp); 'Processing your request through the harness...',
}, 500 + Math.random() * 1000); 'I have noted this in my thought stream.',
'Acknowledged. Routing to appropriate agent loop.',
'The sovereign space recognizes your command.',
'Running analysis. Results will appear on the main terminal.',
'My crystal ball says... yes. Implementing.',
'Understood, Alexander. Adjusting priorities.',
'[OFFLINE] Hermes is unreachable. Message queued for next connection.',
];
const resp = responses[Math.floor(Math.random() * responses.length)];
addChatMessage('timmy', resp);
saveSession();
}, 500 + Math.random() * 1000);
}
input.blur(); input.blur();
} }
@@ -872,6 +1045,156 @@ function addChatMessage(type, text) {
container.scrollTop = container.scrollHeight; container.scrollTop = container.scrollHeight;
} }
// ═══ SESSION PERSISTENCE ═══
const SESSION_KEY = 'nexus_v1_session';
const SESSION_ID_KEY = 'nexus_v1_session_id';
function getSessionId() {
let id = localStorage.getItem(SESSION_ID_KEY);
if (!id) {
id = 'sess_' + Date.now().toString(36) + '_' + Math.random().toString(36).slice(2);
localStorage.setItem(SESSION_ID_KEY, id);
}
return id;
}
function saveSession() {
const msgs = [...document.querySelectorAll('#chat-messages .chat-msg')].map(el => {
const prefix = el.querySelector('.chat-msg-prefix')?.textContent || '';
const pre = el.querySelector('.tool-output');
return {
classes: el.className,
prefix,
content: pre ? pre.textContent : el.textContent.replace(prefix, '').trim(),
isTool: !!pre,
};
}).slice(-60);
try { localStorage.setItem(SESSION_KEY, JSON.stringify(msgs)); } catch (_) {}
}
function loadSession() {
try {
const raw = localStorage.getItem(SESSION_KEY);
if (!raw) return;
const msgs = JSON.parse(raw);
if (!msgs.length) return;
const container = document.getElementById('chat-messages');
container.innerHTML = '';
msgs.forEach(m => {
const div = document.createElement('div');
div.className = m.classes;
if (m.isTool) {
div.innerHTML = `<span class="chat-msg-prefix">${m.prefix}</span><pre class="tool-output">${escapeHtml(m.content)}</pre>`;
} else {
div.innerHTML = `<span class="chat-msg-prefix">${m.prefix}</span> ${m.content}`;
}
container.appendChild(div);
});
container.scrollTop = container.scrollHeight;
addChatMessage('system', 'Session restored from localStorage.');
} catch (_) {}
}
// ═══ HERMES WEBSOCKET ═══
function connectHermes() {
if (hermesWs && (hermesWs.readyState === WebSocket.CONNECTING || hermesWs.readyState === WebSocket.OPEN)) return;
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
const url = `${proto}//${location.host}/api/world/ws`;
try {
hermesWs = new WebSocket(url);
} catch (_) {
scheduleWsReconnect();
return;
}
hermesWs.addEventListener('open', () => {
wsConnected = true;
updateWsStatusDot(true);
addChatMessage('system', 'Hermes backend connected.');
hermesWs.send(JSON.stringify({ type: 'session_init', session_id: getSessionId() }));
refreshWorkshopPanel();
saveSession();
});
hermesWs.addEventListener('message', (evt) => {
try { handleHermesMessage(JSON.parse(evt.data)); }
catch (_) { addChatMessage('timmy', evt.data); saveSession(); }
});
hermesWs.addEventListener('close', () => {
wsConnected = false;
updateWsStatusDot(false);
refreshWorkshopPanel();
scheduleWsReconnect();
});
hermesWs.addEventListener('error', () => hermesWs.close());
}
function scheduleWsReconnect() {
clearTimeout(wsReconnectTimer);
wsReconnectTimer = setTimeout(connectHermes, 5000);
}
function updateWsStatusDot(connected) {
const dot = document.querySelector('.chat-status-dot');
if (dot) {
dot.style.background = connected ? 'var(--color-primary)' : 'var(--color-danger)';
dot.style.boxShadow = `0 0 6px ${connected ? 'var(--color-primary)' : 'var(--color-danger)'}`;
}
const hudStatus = document.getElementById('ws-hud-status');
if (hudStatus) {
hudStatus.textContent = connected ? '● Hermes' : '○ Hermes';
hudStatus.style.color = connected ? 'var(--color-primary)' : 'var(--color-danger)';
}
}
function handleHermesMessage(data) {
const { type, role, content, tool, output } = data;
switch (type) {
case 'message':
if (role === 'assistant' || !role) addChatMessage('timmy', content || '');
break;
case 'tool_call':
addChatMessage('system', `Running tool: ${tool || 'unknown'}...`);
addToolOutput(tool, data.args ? JSON.stringify(data.args) : '', 'call');
break;
case 'tool_result':
addToolOutput(tool, content || output || '', 'result');
break;
case 'error':
addChatMessage('error', content || 'An error occurred.');
break;
default:
if (content) addChatMessage('timmy', content);
}
saveSession();
}
// ═══ TOOL OUTPUT ═══
function escapeHtml(str) {
return String(str).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
function addToolOutput(tool, content, kind) {
const container = document.getElementById('chat-messages');
const div = document.createElement('div');
div.className = 'chat-msg chat-msg-tool';
const label = kind === 'call'
? `[${(tool || 'TOOL').toUpperCase()} ▶]`
: `[${(tool || 'TOOL').toUpperCase()} ◀]`;
div.innerHTML = `<span class="chat-msg-prefix tool-prefix-${kind}">${label}</span><pre class="tool-output">${escapeHtml(String(content || '').slice(0, 2000))}</pre>`;
container.appendChild(div);
container.scrollTop = container.scrollHeight;
recentToolOutputs.unshift({ tool, content: String(content || '').slice(0, 200), kind, time: Date.now() });
recentToolOutputs = recentToolOutputs.slice(0, 10);
refreshWorkshopPanel();
saveSession();
}
// ═══ GAME LOOP ═══ // ═══ GAME LOOP ═══
function gameLoop() { function gameLoop() {
requestAnimationFrame(gameLoop); requestAnimationFrame(gameLoop);
@@ -912,6 +1235,13 @@ function gameLoop() {
if (t.scanMat?.uniforms) t.scanMat.uniforms.uTime.value = elapsed; if (t.scanMat?.uniforms) t.scanMat.uniforms.uTime.value = elapsed;
}); });
// Refresh workshop panel connection status every 5s
workshopPanelRefreshTimer += delta;
if (workshopPanelRefreshTimer > 5) {
workshopPanelRefreshTimer = 0;
refreshWorkshopPanel();
}
// Animate portal // Animate portal
if (portalMesh) { if (portalMesh) {
portalMesh.rotation.z = elapsed * 0.3; portalMesh.rotation.z = elapsed * 0.3;

View File

@@ -97,7 +97,7 @@
<!-- Minimap / Controls hint --> <!-- Minimap / Controls hint -->
<div class="hud-controls"> <div class="hud-controls">
<span>WASD</span> move &nbsp; <span>Mouse</span> look &nbsp; <span>Enter</span> chat <span>WASD</span> move &nbsp; <span>Mouse</span> look &nbsp; <span>Enter</span> chat &nbsp; <span id="ws-hud-status">○ Hermes</span>
</div> </div>
</div> </div>

View File

@@ -330,6 +330,30 @@ canvas#nexus-canvas {
background: rgba(74, 240, 192, 0.1); background: rgba(74, 240, 192, 0.1);
} }
/* === TOOL OUTPUT === */
.chat-msg-tool .chat-msg-prefix { color: var(--color-warning); }
.tool-prefix-result { color: var(--color-primary) !important; }
.tool-prefix-call { color: var(--color-warning) !important; }
.tool-output {
display: block;
margin-top: var(--space-1);
padding: var(--space-2) var(--space-3);
background: rgba(0, 0, 0, 0.4);
border-left: 2px solid var(--color-border);
border-radius: 0 4px 4px 0;
font-family: var(--font-body);
font-size: 10px;
line-height: 1.5;
color: #7a9ab8;
white-space: pre-wrap;
word-break: break-all;
max-height: 160px;
overflow-y: auto;
scrollbar-width: thin;
scrollbar-color: rgba(74,240,192,0.15) transparent;
}
/* === FOOTER === */ /* === FOOTER === */
.nexus-footer { .nexus-footer {
position: fixed; position: fixed;