Nexus Evolution: Batcave Terminal & Workshop Integration (#6)
Some checks failed
CI / validate (pull_request) Failing after 12s

- Integrated 3D Workshop Console with holographic panel
- Implemented Hermes WebSocket for real-time chat and tool output
- Added context-aware terminal logic for focused portals
- Implemented session persistence (localStorage + backend support)
- Styled tool output rendering in chat panel
This commit is contained in:
Google Agent
2026-03-24 03:26:34 +00:00
parent 75c9a3774b
commit 6f26f07bf4
3 changed files with 275 additions and 5 deletions

254
app.js
View File

@@ -45,6 +45,18 @@ let chatOpen = true;
let loadProgress = 0;
let performanceTier = 'high';
// ═══ 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;
let lastFocusedPortal = null;
// ═══ NAVIGATION SYSTEM ═══
const NAV_MODES = ['walk', 'orbit', 'fly'];
let navModeIdx = 0;
@@ -124,8 +136,12 @@ async function init() {
createThoughtStream();
createHarnessPulse();
createSessionPowerMeter();
createWorkshopTerminal();
updateLoad(90);
loadSession();
connectHermes();
composer = new EffectComposer(renderer);
composer.addPass(new RenderPass(scene, camera));
const bloom = new UnrealBloomPass(
@@ -352,6 +368,91 @@ function createBatcaveTerminal() {
scene.add(terminalGroup);
}
// ═══ WORKSHOP TERMINAL ═══
function createWorkshopTerminal() {
const w = 6, h = 4;
const group = new THREE.Group();
group.position.set(-14, 3, 0);
group.rotation.y = Math.PI / 4;
workshopPanelCanvas = document.createElement('canvas');
workshopPanelCanvas.width = 1024;
workshopPanelCanvas.height = 512;
workshopPanelCtx = workshopPanelCanvas.getContext('2d');
workshopPanelTexture = new THREE.CanvasTexture(workshopPanelCanvas);
workshopPanelTexture.minFilter = THREE.LinearFilter;
const panelGeo = new THREE.PlaneGeometry(w, h);
const panelMat = new THREE.MeshBasicMaterial({
map: workshopPanelTexture,
transparent: true,
opacity: 0.9,
side: THREE.DoubleSide
});
const panel = new THREE.Mesh(panelGeo, panelMat);
group.add(panel);
const scanGeo = new THREE.PlaneGeometry(w + 0.1, h + 0.1);
workshopScanMat = new THREE.ShaderMaterial({
transparent: true,
uniforms: { uTime: { value: 0 } },
vertexShader: `varying vec2 vUv; void main() { vUv = uv; gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); }`,
fragmentShader: `
uniform float uTime;
varying vec2 vUv;
void main() {
float scan = sin(vUv.y * 200.0 + uTime * 10.0) * 0.05;
float noise = fract(sin(dot(vUv, vec2(12.9898, 78.233))) * 43758.5453) * 0.05;
gl_FragColor = vec4(0.0, 0.1, 0.2, scan + noise);
}
`
});
const scan = new THREE.Mesh(scanGeo, workshopScanMat);
scan.position.z = 0.01;
group.add(scan);
scene.add(group);
refreshWorkshopPanel();
}
function refreshWorkshopPanel() {
if (!workshopPanelCtx) return;
const ctx = workshopPanelCtx;
const w = 1024, h = 512;
ctx.clearRect(0, 0, w, h);
ctx.fillStyle = 'rgba(10, 15, 40, 0.8)';
ctx.fillRect(0, 0, w, h);
ctx.fillStyle = '#4af0c0';
ctx.font = 'bold 40px "Orbitron", sans-serif';
ctx.fillText('WORKSHOP TERMINAL v1.0', 40, 60);
ctx.fillRect(40, 80, 944, 4);
ctx.font = '24px "JetBrains Mono", monospace';
ctx.fillStyle = wsConnected ? '#4af0c0' : '#ff4466';
ctx.fillText(`HERMES STATUS: ${wsConnected ? 'ONLINE' : 'OFFLINE'}`, 40, 120);
ctx.fillStyle = '#7b5cff';
const contextName = activePortal ? activePortal.name.toUpperCase() : 'NEXUS CORE';
ctx.fillText(`CONTEXT: ${contextName}`, 40, 160);
ctx.fillStyle = '#a0b8d0';
ctx.font = 'bold 20px "Orbitron", sans-serif';
ctx.fillText('TOOL OUTPUT STREAM', 40, 220);
ctx.fillRect(40, 230, 400, 2);
ctx.font = '16px "JetBrains Mono", monospace';
recentToolOutputs.slice(-10).forEach((out, i) => {
ctx.fillStyle = out.type === 'call' ? '#ffd700' : '#4af0c0';
const text = `[${out.agent}] ${out.content.substring(0, 80)}${out.content.length > 80 ? '...' : ''}`;
ctx.fillText(text, 40, 260 + i * 24);
});
workshopPanelTexture.needsUpdate = true;
}
function createTerminalPanel(parent, x, y, rot, title, color, lines) {
const w = 2.8, h = 3.5;
const group = new THREE.Group();
@@ -1081,14 +1182,153 @@ function sendChatMessage() {
input.blur();
}
function addChatMessage(type, text) {
// ═══ HERMES WEBSOCKET ═══
function connectHermes() {
if (hermesWs) return;
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const wsUrl = `${protocol}//${window.location.host}/api/world/ws`;
console.log(`Connecting to Hermes at ${wsUrl}...`);
hermesWs = new WebSocket(wsUrl);
hermesWs.onopen = () => {
console.log('Hermes connected.');
wsConnected = true;
addChatMessage('system', 'Hermes link established.');
updateWsHudStatus(true);
refreshWorkshopPanel();
};
hermesWs.onmessage = (evt) => {
try {
const data = JSON.parse(evt.data);
handleHermesMessage(data);
} catch (e) {
console.error('Failed to parse Hermes message:', e);
}
};
hermesWs.onclose = () => {
console.warn('Hermes disconnected. Retrying in 5s...');
wsConnected = false;
hermesWs = null;
updateWsHudStatus(false);
refreshWorkshopPanel();
if (wsReconnectTimer) clearTimeout(wsReconnectTimer);
wsReconnectTimer = setTimeout(connectHermes, 5000);
};
hermesWs.onerror = (err) => {
console.error('Hermes WS error:', err);
};
}
function handleHermesMessage(data) {
if (data.type === 'chat') {
addChatMessage(data.agent || 'timmy', data.text);
} else if (data.type === 'tool_call') {
const content = `Calling ${data.tool}(${JSON.stringify(data.args)})`;
recentToolOutputs.push({ type: 'call', agent: data.agent || 'SYSTEM', content });
addToolMessage(data.agent || 'SYSTEM', 'call', content);
refreshWorkshopPanel();
} else if (data.type === 'tool_result') {
const content = `Result: ${JSON.stringify(data.result)}`;
recentToolOutputs.push({ type: 'result', agent: data.agent || 'SYSTEM', content });
addToolMessage(data.agent || 'SYSTEM', 'result', content);
refreshWorkshopPanel();
} else if (data.type === 'history') {
const container = document.getElementById('chat-messages');
container.innerHTML = '';
data.messages.forEach(msg => {
if (msg.type === 'tool_call') addToolMessage(msg.agent, 'call', msg.content, false);
else if (msg.type === 'tool_result') addToolMessage(msg.agent, 'result', msg.content, false);
else addChatMessage(msg.agent, msg.text, false);
});
}
}
function updateWsHudStatus(connected) {
const dot = document.querySelector('.chat-status-dot');
if (dot) {
dot.style.background = connected ? '#4af0c0' : '#ff4466';
dot.style.boxShadow = connected ? '0 0 10px #4af0c0' : '0 0 10px #ff4466';
}
}
// ═══ SESSION PERSISTENCE ═══
function saveSession() {
const msgs = Array.from(document.querySelectorAll('.chat-msg')).slice(-60).map(el => ({
html: el.innerHTML,
className: el.className
}));
localStorage.setItem('nexus_chat_history', JSON.stringify(msgs));
}
function loadSession() {
const saved = localStorage.getItem('nexus_chat_history');
if (saved) {
const msgs = JSON.parse(saved);
const container = document.getElementById('chat-messages');
container.innerHTML = '';
msgs.forEach(m => {
const div = document.createElement('div');
div.className = m.className;
div.innerHTML = m.html;
container.appendChild(div);
});
container.scrollTop = container.scrollHeight;
}
}
function addChatMessage(agent, text, shouldSave = true) {
const container = document.getElementById('chat-messages');
const div = document.createElement('div');
div.className = `chat-msg chat-msg-${type}`;
const prefixes = { user: '[ALEXANDER]', timmy: '[TIMMY]', system: '[NEXUS]', error: '[ERROR]' };
div.innerHTML = `<span class="chat-msg-prefix">${prefixes[type] || '[???]'}</span> ${text}`;
div.className = `chat-msg chat-msg-${agent}`;
const prefixes = {
user: '[ALEXANDER]',
timmy: '[TIMMY]',
system: '[NEXUS]',
error: '[ERROR]',
kimi: '[KIMI]',
claude: '[CLAUDE]',
perplexity: '[PERPLEXITY]'
};
const prefix = document.createElement('span');
prefix.className = 'chat-msg-prefix';
prefix.textContent = `${prefixes[agent] || '[' + agent.toUpperCase() + ']'} `;
div.appendChild(prefix);
div.appendChild(document.createTextNode(text));
container.appendChild(div);
container.scrollTop = container.scrollHeight;
if (shouldSave) saveSession();
}
function addToolMessage(agent, type, content, shouldSave = true) {
const container = document.getElementById('chat-messages');
const div = document.createElement('div');
div.className = `chat-msg chat-msg-tool tool-${type}`;
const prefix = document.createElement('div');
prefix.className = 'chat-msg-prefix';
prefix.textContent = `[${agent.toUpperCase()} TOOL ${type.toUpperCase()}]`;
const pre = document.createElement('pre');
pre.className = 'tool-content';
pre.textContent = content;
div.appendChild(prefix);
div.appendChild(pre);
container.appendChild(div);
container.scrollTop = container.scrollHeight;
if (shouldSave) saveSession();
}
// ═══ PORTAL INTERACTION ═══
@@ -1395,6 +1635,12 @@ function gameLoop() {
composer.render();
if (workshopScanMat) workshopScanMat.uniforms.uTime.value = clock.getElapsedTime();
if (activePortal !== lastFocusedPortal) {
lastFocusedPortal = activePortal;
refreshWorkshopPanel();
}
frameCount++;
const now = performance.now();
if (now - lastFPSTime >= 1000) {

View File

@@ -106,6 +106,7 @@
<span>WASD</span> move &nbsp; <span>Mouse</span> look &nbsp; <span>Enter</span> chat &nbsp;
<span>V</span> mode: <span id="nav-mode-label">WALK</span>
<span id="nav-mode-hint" class="nav-mode-hint"></span>
&nbsp; <span class="ws-hud-status">HERMES: <span id="ws-status-dot" class="chat-status-dot"></span></span>
</div>
<!-- Portal Hint -->

View File

@@ -533,7 +533,7 @@ canvas#nexus-canvas {
border-radius: 50%;
background: var(--color-primary);
box-shadow: 0 0 6px var(--color-primary);
animation: dot-pulse 2s ease-in-out infinite;
transition: all 0.3s ease;
}
@keyframes dot-pulse {
0%, 100% { opacity: 0.6; }
@@ -570,6 +570,29 @@ canvas#nexus-canvas {
.chat-msg-prefix {
font-weight: 700;
}
.chat-msg-kimi .chat-msg-prefix { color: var(--color-secondary); }
.chat-msg-claude .chat-msg-prefix { color: var(--color-gold); }
.chat-msg-perplexity .chat-msg-prefix { color: #4488ff; }
/* Tool Output Styling */
.chat-msg-tool {
background: rgba(0, 0, 0, 0.3);
border-left: 2px solid #ffd700;
font-size: 11px;
padding: 8px;
margin: 4px 0;
border-radius: 4px;
}
.tool-call { border-left-color: #ffd700; }
.tool-result { border-left-color: #4af0c0; }
.tool-content {
font-family: 'JetBrains Mono', monospace;
white-space: pre-wrap;
word-break: break-all;
opacity: 0.8;
margin: 4px 0 0 0;
color: #a0b8d0;
}
.chat-msg-system .chat-msg-prefix { color: var(--color-text-muted); }
.chat-msg-timmy .chat-msg-prefix { color: var(--color-primary); }
.chat-msg-user .chat-msg-prefix { color: var(--color-gold); }