spatial-office/server.js

214 lines
7.1 KiB
JavaScript

// Spatial Office server — static files + WebSocket + OpenClaw bridge
// Run: node server.js
// Serves built frontend from src/frontend/dist on port 18790
const http = require('http');
const fs = require('fs');
const path = require('path');
const { WebSocketServer, WebSocket } = require('ws');
const PORT = process.env.PORT || 18790;
const DIST_DIR = path.join(__dirname, 'src', 'frontend', 'dist');
// ── MIME types ──────────────────────────────────────────────
const MIME = {
'.html': 'text/html',
'.js': 'application/javascript',
'.css': 'text/css',
'.json': 'application/json',
'.png': 'image/png',
'.svg': 'image/svg+xml',
'.woff2': 'font/woff2',
'.map': 'application/json',
};
function serveFile(res, filePath) {
const ext = path.extname(filePath);
const mime = MIME[ext] || 'application/octet-stream';
try {
const content = fs.readFileSync(filePath);
res.writeHead(200, { 'Content-Type': mime, 'Content-Length': content.length });
res.end(content);
} catch (e) {
res.writeHead(404);
res.end('Not found');
}
}
// ── HTTP server (static file serving) ──────────────────────
const server = http.createServer((req, res) => {
const url = new URL(req.url, `http://${req.headers.host}`);
if (url.pathname === '/health') {
res.writeHead(200, { 'Content-Type': 'text/plain' });
res.end('ok');
return;
}
// Strip /office prefix if present (nginx passes it through, but Vite builds with base=/office/)
let cleanPath = url.pathname;
// Vite-built files have /office/ prefix baked in, serve relative to dist
if (cleanPath.startsWith('/office/')) {
cleanPath = cleanPath.slice('/office'.length);
}
let filePath = path.join(DIST_DIR, cleanPath === '/' ? 'index.html' : cleanPath);
// Try exact path first
const ext = path.extname(filePath);
if (ext) {
serveFile(res, filePath);
} else {
// No extension — try as directory or fallback to index.html (SPA)
try {
fs.statSync(path.join(filePath, 'index.html'));
serveFile(res, path.join(filePath, 'index.html'));
} catch {
serveFile(res, path.join(DIST_DIR, 'index.html'));
}
}
});
// ── WebSocket server (agent state from OpenClaw bridge) ────
const wss = new WebSocketServer({ noServer: true });
let mockInterval = null;
// Agent definitions for mock mode (when bridge isn't available)
const AGENT_DEFS = {
cos: { id: 'cos', name: 'COS', department: 'cos', color: '#FF6B35' },
engineering: { id: 'engineering', name: 'Engineering', department: 'engineering', color: '#4ECDC4' },
'senior-eng': { id: 'senior-eng', name: 'Senior Eng', department: 'senior-eng', color: '#7B2D8E' },
trust: { id: 'trust', name: 'Trust', department: 'trust', color: '#45B7D1' },
market: { id: 'market', name: 'Market', department: 'market', color: '#96CEB4' },
health: { id: 'health', name: 'Health', department: 'health', color: '#FFEAA7' },
content: { id: 'content', name: 'Content', department: 'content', color: '#DDA0DD' },
presentations: { id: 'presentations', name: 'Presentations', department: 'presentations', color: '#FF8A80' },
vault: { id: 'vault', name: 'Vault', department: 'vault', color: '#A5D6A7' },
};
const DEFAULT_POSITIONS = {
cos: { row: 3, col: 2 },
engineering: { row: 4, col: 10 },
'senior-eng': { row: 5, col: 10 },
trust: { row: 7, col: 2 },
market: { row: 4, col: 16 },
health: { row: 3, col: 24 },
content: { row: 5, col: 16 },
presentations: { row: 6, col: 16 },
vault: { row: 7, col: 24 },
};
const ACTIVITIES = ['idle', 'thinking', 'working', 'idle', 'idle'];
const TASKS = [
'Memory maintenance',
'Reviewing PRs',
'Running web search',
'Pairing with vault',
'Indexing documents',
'Processing heartbeat',
'Analyzing market data',
'Writing content draft',
];
function buildFullState() {
const agents = {};
for (const [id, def] of Object.entries(AGENT_DEFS)) {
const pos = DEFAULT_POSITIONS[id];
agents[id] = {
...def,
position: { ...pos },
targetPosition: { ...pos },
activity: 'idle',
currentTask: null,
lastHeartbeat: Date.now(),
pulse: 0,
};
}
return { agents };
}
function startMockMode() {
console.log('[server] Starting mock agent activity...');
const agentIds = Object.keys(AGENT_DEFS);
let cycle = 0;
mockInterval = setInterval(() => {
cycle++;
if (cycle % 3 === 0) {
const a1 = agentIds[Math.floor(Math.random() * agentIds.length)];
let a2 = agentIds[Math.floor(Math.random() * agentIds.length)];
while (a2 === a1 && agentIds.length > 1) {
a2 = agentIds[Math.floor(Math.random() * agentIds.length)];
}
const activity1 = ACTIVITIES[Math.floor(Math.random() * ACTIVITIES.length)];
const activity2 = ACTIVITIES[Math.floor(Math.random() * ACTIVITIES.length)];
const task1 = TASKS[Math.floor(Math.random() * TASKS.length)];
const task2 = TASKS[Math.floor(Math.random() * TASKS.length)];
broadcast({
type: 'state.diff',
changes: [
{ op: 'agent.activity', path: `agents.${a1}`, value: { activity: activity1, currentTask: task1 } },
{ op: 'agent.activity', path: `agents.${a2}`, value: { activity: activity2, currentTask: task2 } },
],
});
}
if (cycle % 10 === 0) {
const agentId = agentIds[Math.floor(Math.random() * agentIds.length)];
const pos = DEFAULT_POSITIONS[agentId];
const newPos = {
row: Math.max(1, Math.min(18, pos.row + Math.floor((Math.random() - 0.5) * 6))),
col: Math.max(1, Math.min(26, pos.col + Math.floor((Math.random() - 0.5) * 8))),
};
broadcast({
type: 'state.diff',
changes: [
{ op: 'agent.position', path: `agents.${agentId}`, value: newPos },
],
});
}
}, 1000);
}
function broadcast(msg) {
const data = JSON.stringify(msg);
for (const ws of wss.clients) {
if (ws.readyState === WebSocket.OPEN) {
ws.send(data);
}
}
}
wss.on('connection', (ws) => {
console.log('[server] WS client connected');
// Send full state
const state = buildFullState();
ws.send(JSON.stringify({ type: 'state.full', payload: state }));
console.log(`[server] Sent state.full: ${Object.keys(state.agents).length} agents`);
ws.on('close', () => {
console.log('[server] WS client disconnected');
});
});
// Handle WS upgrades on any path (nginx handles routing)
server.on('upgrade', (req, socket, head) => {
wss.handleUpgrade(req, socket, head, (ws) => {
wss.emit('connection', ws, req);
});
});
// ── Heartbeat to all WS clients ────────────────────────────
setInterval(() => {
broadcast({ type: 'heartbeat', timestamp: Date.now() });
}, 15000);
// ── Start ──────────────────────────────────────────────────
server.listen(PORT, () => {
console.log(`[server] Spatial Office on http://localhost:${PORT}`);
startMockMode();
});