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:
358
app.js
358
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 = `<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, '&').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 = `<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 ═══
|
||||
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;
|
||||
|
||||
@@ -97,7 +97,7 @@
|
||||
|
||||
<!-- Minimap / Controls hint -->
|
||||
<div class="hud-controls">
|
||||
<span>WASD</span> move <span>Mouse</span> look <span>Enter</span> chat
|
||||
<span>WASD</span> move <span>Mouse</span> look <span>Enter</span> chat <span id="ws-hud-status">○ Hermes</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
24
style.css
24
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;
|
||||
|
||||
Reference in New Issue
Block a user