From 5577b74bbcdeb0952ab7623a5a080ef733ef3112 Mon Sep 17 00:00:00 2001 From: Alexander Whitestone Date: Mon, 23 Mar 2026 18:40:30 -0400 Subject: [PATCH] =?UTF-8?q?feat:=20Batcave=20workshop=20terminal=20?= =?UTF-8?q?=E2=80=94=20Hermes=20WS,=20session=20persistence,=20tool=20outp?= =?UTF-8?q?ut?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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
  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
---
 app.js     | 358 ++++++++++++++++++++++++++++++++++++++++++++++++++---
 index.html |   2 +-
 style.css  |  24 ++++
 3 files changed, 369 insertions(+), 15 deletions(-)

diff --git a/app.js b/app.js
index 60689a0..aa7f062 100644
--- a/app.js
+++ b/app.js
@@ -35,6 +35,17 @@ let frameCount = 0, lastFPSTime = 0, fps = 0;
 let chatOpen = true;
 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 ═══
 function init() {
   clock = new THREE.Clock();
@@ -70,6 +81,8 @@ function init() {
   createFloor();
   updateLoad(55);
   createBatcaveTerminal();
+  updateLoad(65);
+  createWorkshopTerminal();
   updateLoad(70);
   createPortal();
   updateLoad(80);
@@ -115,6 +128,10 @@ function init() {
     setTimeout(() => { document.getElementById('loading-screen').remove(); }, 900);
   }, 600);
 
+  // Session + Hermes
+  loadSession();
+  connectHermes();
+
   // Start loop
   requestAnimationFrame(gameLoop);
 }
@@ -398,6 +415,149 @@ function createBatcaveTerminal() {
   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) {
   const { x, y, z, w, h, title, lines, color, rotY } = opts;
   const group = new THREE.Group();
@@ -843,21 +1003,34 @@ function sendChatMessage() {
 
   addChatMessage('user', text);
   input.value = '';
+  saveSession();
 
-  // Simulate Timmy response
-  setTimeout(() => {
-    const responses = [
-      'Processing your request through the harness...',
-      '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.',
-    ];
-    const resp = responses[Math.floor(Math.random() * responses.length)];
-    addChatMessage('timmy', resp);
-  }, 500 + Math.random() * 1000);
+  if (hermesWs && hermesWs.readyState === WebSocket.OPEN) {
+    // Send to real Hermes backend
+    hermesWs.send(JSON.stringify({
+      type: 'message',
+      role: 'user',
+      content: text,
+      session_id: getSessionId(),
+    }));
+  } else {
+    // Offline fallback
+    setTimeout(() => {
+      const responses = [
+        'Processing your request through the harness...',
+        '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();
 }
@@ -872,6 +1045,156 @@ function addChatMessage(type, text) {
   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 = `${m.prefix}
${escapeHtml(m.content)}
`; + } else { + div.innerHTML = `${m.prefix} ${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, '&').replace(//g, '>'); +} + +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 = `${label}
${escapeHtml(String(content || '').slice(0, 2000))}
`; + 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 ═══ function gameLoop() { requestAnimationFrame(gameLoop); @@ -912,6 +1235,13 @@ function gameLoop() { 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 if (portalMesh) { portalMesh.rotation.z = elapsed * 0.3; diff --git a/index.html b/index.html index 3a2c6ea..416ac35 100644 --- a/index.html +++ b/index.html @@ -97,7 +97,7 @@
- WASD move   Mouse look   Enter chat + WASD move   Mouse look   Enter chat   ○ Hermes
diff --git a/style.css b/style.css index 519b05e..aa8c8bd 100644 --- a/style.css +++ b/style.css @@ -330,6 +330,30 @@ canvas#nexus-canvas { 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 === */ .nexus-footer { position: fixed;