214 lines
7.1 KiB
JavaScript
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();
|
|
});
|