[claude] Batcave workshop terminal — Hermes WS, session persistence, tool output (#6) #31

Closed
claude wants to merge 1 commits from claude/the-nexus:claude/issue-6 into main
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 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, '&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 ═══
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;

View File

@@ -97,7 +97,7 @@
<!-- Minimap / Controls hint -->
<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>

View File

@@ -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;