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