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;