build: add Nix flake and NixOS module for reproducible builds
- flake.nix: devShell, frontend package, backend package, combined default - spatial-office.nix: standalone NixOS module for importing into n97 config - packages/frontend: React + @react-three/fiber + Zustand (Vite build) - packages/backend: Node.js + ws + Zod (WebSocket server, port 18790) - pnpm-workspace.yaml: monorepo workspace config nix build .#frontend — bundled static site nix build .#backend — runnable WebSocket server nix build — full spatial-office application nix develop — dev shell with Node.js 22 + pnpm
This commit is contained in:
parent
0d3879b02f
commit
bb06f2c48c
15 changed files with 1464 additions and 0 deletions
61
flake.lock
generated
Normal file
61
flake.lock
generated
Normal file
|
|
@ -0,0 +1,61 @@
|
||||||
|
{
|
||||||
|
"nodes": {
|
||||||
|
"flake-utils": {
|
||||||
|
"inputs": {
|
||||||
|
"systems": "systems"
|
||||||
|
},
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1731533236,
|
||||||
|
"narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=",
|
||||||
|
"owner": "numtide",
|
||||||
|
"repo": "flake-utils",
|
||||||
|
"rev": "11707dc2f618dd54ca8739b309ec4fc024de578b",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "numtide",
|
||||||
|
"repo": "flake-utils",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"nixpkgs": {
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1778869304,
|
||||||
|
"narHash": "sha256-30sZNZoA1cqF5JNO9fVX+wgiQYjB7HJqqJ4ztCDeBZE=",
|
||||||
|
"owner": "nixos",
|
||||||
|
"repo": "nixpkgs",
|
||||||
|
"rev": "d233902339c02a9c334e7e593de68855ad26c4cb",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "nixos",
|
||||||
|
"ref": "nixos-unstable",
|
||||||
|
"repo": "nixpkgs",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"root": {
|
||||||
|
"inputs": {
|
||||||
|
"flake-utils": "flake-utils",
|
||||||
|
"nixpkgs": "nixpkgs"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"systems": {
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1681028828,
|
||||||
|
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
|
||||||
|
"owner": "nix-systems",
|
||||||
|
"repo": "default",
|
||||||
|
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "nix-systems",
|
||||||
|
"repo": "default",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"root": "root",
|
||||||
|
"version": 7
|
||||||
|
}
|
||||||
316
flake.nix
Normal file
316
flake.nix
Normal file
|
|
@ -0,0 +1,316 @@
|
||||||
|
{
|
||||||
|
description = "Spatial Office — isometric collaborative workspace for agents and humans";
|
||||||
|
|
||||||
|
inputs = {
|
||||||
|
nixpkgs.url = "github:nixos/nixpkgs/nixos-unstable";
|
||||||
|
flake-utils.url = "github:numtide/flake-utils";
|
||||||
|
};
|
||||||
|
|
||||||
|
outputs = { self, nixpkgs, flake-utils }:
|
||||||
|
flake-utils.lib.eachDefaultSystem (system:
|
||||||
|
let
|
||||||
|
pkgs = import nixpkgs { inherit system; };
|
||||||
|
|
||||||
|
# ── Node.js 22 toolchain ──────────────────────────────────────
|
||||||
|
nodeToolchain = with pkgs; [
|
||||||
|
nodejs_22
|
||||||
|
corepack_22
|
||||||
|
];
|
||||||
|
|
||||||
|
# ── Frontend package ──────────────────────────────────────────
|
||||||
|
# React + @react-three/fiber + Zustand, built with Vite
|
||||||
|
frontend = pkgs.buildNpmPackage {
|
||||||
|
name = "spatial-office-frontend";
|
||||||
|
version = "0.1.0";
|
||||||
|
|
||||||
|
src = ./packages/frontend;
|
||||||
|
|
||||||
|
npmDepsHash = "sha256-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=";
|
||||||
|
|
||||||
|
nativeBuildInputs = with pkgs; [
|
||||||
|
nodejs_22
|
||||||
|
corepack_22
|
||||||
|
];
|
||||||
|
|
||||||
|
buildPhase = ''
|
||||||
|
runHook preBuild
|
||||||
|
corepack pnpm run build
|
||||||
|
runHook postBuild
|
||||||
|
'';
|
||||||
|
|
||||||
|
installPhase = ''
|
||||||
|
runHook preInstall
|
||||||
|
mkdir -p $out
|
||||||
|
cp -r dist/* $out/
|
||||||
|
runHook postInstall
|
||||||
|
'';
|
||||||
|
|
||||||
|
meta = with pkgs.lib; {
|
||||||
|
description = "Spatial Office frontend — React + Three.js isometric workspace";
|
||||||
|
license = licenses.mit;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
# ── Backend package ───────────────────────────────────────────
|
||||||
|
# Node.js + ws + Zod, WebSocket server on port 18790
|
||||||
|
backend = pkgs.buildNpmPackage {
|
||||||
|
name = "spatial-office-backend";
|
||||||
|
version = "0.1.0";
|
||||||
|
|
||||||
|
src = ./packages/backend;
|
||||||
|
|
||||||
|
npmDepsHash = "sha256-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=";
|
||||||
|
|
||||||
|
nativeBuildInputs = with pkgs; [
|
||||||
|
nodejs_22
|
||||||
|
corepack_22
|
||||||
|
];
|
||||||
|
|
||||||
|
buildPhase = ''
|
||||||
|
runHook preBuild
|
||||||
|
corepack pnpm run build
|
||||||
|
runHook postBuild
|
||||||
|
'';
|
||||||
|
|
||||||
|
installPhase = ''
|
||||||
|
runHook preInstall
|
||||||
|
mkdir -p $out/lib $out/bin
|
||||||
|
|
||||||
|
# Copy built JS output
|
||||||
|
cp -r dist/* $out/lib/
|
||||||
|
|
||||||
|
# Copy node_modules for runtime deps (ws, zod, etc.)
|
||||||
|
cp -r node_modules $out/lib/
|
||||||
|
|
||||||
|
# Wrapper script
|
||||||
|
cat > $out/bin/spatial-office-backend << 'WRAPPER'
|
||||||
|
#!${pkgs.bash}/bin/bash
|
||||||
|
exec ${pkgs.nodejs_22}/bin/node "$out/lib/index.js" "$@"
|
||||||
|
WRAPPER
|
||||||
|
chmod +x $out/bin/spatial-office-backend
|
||||||
|
runHook postInstall
|
||||||
|
'';
|
||||||
|
|
||||||
|
meta = with pkgs.lib; {
|
||||||
|
description = "Spatial Office backend — Node.js WebSocket server with OpenClaw bridge";
|
||||||
|
license = licenses.mit;
|
||||||
|
mainProgram = "spatial-office-backend";
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
# ── Combined package (symlink forest of frontend + backend) ────
|
||||||
|
spatial-office = pkgs.buildEnv {
|
||||||
|
name = "spatial-office";
|
||||||
|
version = "0.1.0";
|
||||||
|
paths = [ frontend backend ];
|
||||||
|
meta = with pkgs.lib; {
|
||||||
|
description = "Spatial Office — full application (frontend + backend)";
|
||||||
|
license = licenses.mit;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
in
|
||||||
|
{
|
||||||
|
packages = {
|
||||||
|
inherit frontend backend;
|
||||||
|
default = spatial-office;
|
||||||
|
};
|
||||||
|
|
||||||
|
devShells = {
|
||||||
|
default = pkgs.mkShell {
|
||||||
|
name = "spatial-office-dev";
|
||||||
|
buildInputs = nodeToolchain ++ (with pkgs; [ ]);
|
||||||
|
|
||||||
|
shellHook = ''
|
||||||
|
echo ""
|
||||||
|
echo "🏢 Spatial Office dev shell"
|
||||||
|
echo " pnpm install — install workspace dependencies"
|
||||||
|
echo " pnpm dev — start frontend + backend in dev mode"
|
||||||
|
echo " pnpm build — production build"
|
||||||
|
echo " nix build — full Nix build"
|
||||||
|
echo ""
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
# ── NixOS module ───────────────────────────────────────────────
|
||||||
|
nixosModules = {
|
||||||
|
spatial-office = { config, lib, pkgs, ... }:
|
||||||
|
let
|
||||||
|
cfg = config.services.spatial-office;
|
||||||
|
in
|
||||||
|
{
|
||||||
|
options.services.spatial-office = {
|
||||||
|
enable = lib.mkEnableOption "Spatial Office service";
|
||||||
|
|
||||||
|
port = lib.mkOption {
|
||||||
|
type = lib.types.int;
|
||||||
|
default = 18790;
|
||||||
|
description = "WebSocket server port";
|
||||||
|
};
|
||||||
|
|
||||||
|
openclawTokenFile = lib.mkOption {
|
||||||
|
type = lib.types.nullOr lib.types.path;
|
||||||
|
default = null;
|
||||||
|
description = "File containing the OpenClaw gateway shared token";
|
||||||
|
};
|
||||||
|
|
||||||
|
frontendDir = lib.mkOption {
|
||||||
|
type = lib.types.path;
|
||||||
|
default = "${self.packages.${system}.frontend}";
|
||||||
|
description = "Directory of built frontend static files to serve";
|
||||||
|
};
|
||||||
|
|
||||||
|
logLevel = lib.mkOption {
|
||||||
|
type = lib.types.enum [ "debug" "info" "warn" "error" ];
|
||||||
|
default = "info";
|
||||||
|
description = "Log level for the spatial-office backend";
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
config = lib.mkIf cfg.enable {
|
||||||
|
systemd.services.spatial-office = {
|
||||||
|
description = "Spatial Office Backend";
|
||||||
|
wantedBy = [ "multi-user.target" ];
|
||||||
|
after = [ "network.target" ];
|
||||||
|
wants = [ "network.target" ];
|
||||||
|
requires = [ ];
|
||||||
|
|
||||||
|
environment = {
|
||||||
|
PORT = toString cfg.port;
|
||||||
|
SPATIAL_OFFICE_FRONTEND_DIR = cfg.frontendDir;
|
||||||
|
LOG_LEVEL = cfg.logLevel;
|
||||||
|
NODE_ENV = "production";
|
||||||
|
OPENCLAW_GATEWAY_URL = "ws://127.0.0.1:18789";
|
||||||
|
} // (lib.optionalAttrs (cfg.openclawTokenFile != null) {
|
||||||
|
OPENCLAW_TOKEN_FILE = cfg.openclawTokenFile;
|
||||||
|
});
|
||||||
|
|
||||||
|
serviceConfig = {
|
||||||
|
ExecStart = "${self.packages.${system}.backend}/bin/spatial-office-backend";
|
||||||
|
Restart = "always";
|
||||||
|
RestartSec = 5;
|
||||||
|
User = "spatial-office";
|
||||||
|
Group = "spatial-office";
|
||||||
|
NoNewPrivileges = true;
|
||||||
|
ProtectSystem = "strict";
|
||||||
|
ProtectHome = true;
|
||||||
|
ReadWritePaths = [ ];
|
||||||
|
PrivateTmp = true;
|
||||||
|
PrivateDevices = true;
|
||||||
|
ProtectKernelTunables = true;
|
||||||
|
ProtectKernelModules = true;
|
||||||
|
ProtectControlGroups = true;
|
||||||
|
MemoryDenyWriteExecute = false; # Node.js needs W^X disabled
|
||||||
|
RestrictRealtime = true;
|
||||||
|
SystemCallFilter = [ "@system-service" "~@privileged" ];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
# Service user
|
||||||
|
users.users.spatial-office = {
|
||||||
|
isSystemUser = true;
|
||||||
|
group = "spatial-office";
|
||||||
|
description = "Spatial Office service user";
|
||||||
|
};
|
||||||
|
users.groups.spatial-office = { };
|
||||||
|
|
||||||
|
# ── Nginx reverse proxy to serve frontend static files ──
|
||||||
|
services.nginx = {
|
||||||
|
enable = true;
|
||||||
|
virtualHosts."n97" = lib.mkMerge [
|
||||||
|
(lib.mkIf (config.services.nginx.virtualHosts ? "n97") { })
|
||||||
|
{
|
||||||
|
listen = lib.mkIf (config.services.nginx.virtualHosts ? "n97") [ ];
|
||||||
|
}
|
||||||
|
];
|
||||||
|
virtualHosts."spatial-office" = {
|
||||||
|
listen = [{
|
||||||
|
addr = "0.0.0.0";
|
||||||
|
port = cfg.port;
|
||||||
|
}];
|
||||||
|
root = cfg.frontendDir;
|
||||||
|
locations."/" = {
|
||||||
|
index = "index.html";
|
||||||
|
tryFiles = "$uri $uri/ /index.html";
|
||||||
|
};
|
||||||
|
locations."/ws" = {
|
||||||
|
proxyPass = "http://127.0.0.1:${toString (cfg.port + 1)}";
|
||||||
|
proxyWebsockets = true;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
# Firewall — open the frontend port
|
||||||
|
networking.firewall.allowedTCPPorts = [ cfg.port ];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
# ── Alias: spatial-office.nix module (just the systemd service, no nginx) ──
|
||||||
|
spatial-office-service = { config, lib, pkgs, ... }:
|
||||||
|
let
|
||||||
|
cfg = config.services.spatial-office;
|
||||||
|
in
|
||||||
|
{
|
||||||
|
options.services.spatial-office = {
|
||||||
|
enable = lib.mkEnableOption "Spatial Office service";
|
||||||
|
port = lib.mkOption {
|
||||||
|
type = lib.types.int;
|
||||||
|
default = 18790;
|
||||||
|
};
|
||||||
|
openclawTokenFile = lib.mkOption {
|
||||||
|
type = lib.types.nullOr lib.types.path;
|
||||||
|
default = null;
|
||||||
|
};
|
||||||
|
frontendDir = lib.mkOption {
|
||||||
|
type = lib.types.path;
|
||||||
|
default = "${self.packages.${system}.frontend}";
|
||||||
|
};
|
||||||
|
logLevel = lib.mkOption {
|
||||||
|
type = lib.types.enum [ "debug" "info" "warn" "error" ];
|
||||||
|
default = "info";
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
config = lib.mkIf cfg.enable {
|
||||||
|
systemd.services.spatial-office = {
|
||||||
|
description = "Spatial Office Backend";
|
||||||
|
wantedBy = [ "multi-user.target" ];
|
||||||
|
after = [ "network.target" ];
|
||||||
|
|
||||||
|
environment = {
|
||||||
|
PORT = toString cfg.port;
|
||||||
|
SPATIAL_OFFICE_FRONTEND_DIR = cfg.frontendDir;
|
||||||
|
LOG_LEVEL = cfg.logLevel;
|
||||||
|
NODE_ENV = "production";
|
||||||
|
OPENCLAW_GATEWAY_URL = "ws://127.0.0.1:18789";
|
||||||
|
} // (lib.optionalAttrs (cfg.openclawTokenFile != null) {
|
||||||
|
OPENCLAW_TOKEN_FILE = cfg.openclawTokenFile;
|
||||||
|
});
|
||||||
|
|
||||||
|
serviceConfig = {
|
||||||
|
ExecStart = "${self.packages.${system}.backend}/bin/spatial-office-backend";
|
||||||
|
Restart = "always";
|
||||||
|
RestartSec = 5;
|
||||||
|
User = "spatial-office";
|
||||||
|
Group = "spatial-office";
|
||||||
|
NoNewPrivileges = true;
|
||||||
|
ProtectSystem = "strict";
|
||||||
|
ProtectHome = true;
|
||||||
|
PrivateTmp = true;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
users.users.spatial-office = {
|
||||||
|
isSystemUser = true;
|
||||||
|
group = "spatial-office";
|
||||||
|
};
|
||||||
|
users.groups.spatial-office = { };
|
||||||
|
|
||||||
|
networking.firewall.allowedTCPPorts = [ cfg.port ];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
21
packages/backend/package.json
Normal file
21
packages/backend/package.json
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
{
|
||||||
|
"name": "spatial-office-backend",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.1.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "tsx watch src/index.ts",
|
||||||
|
"build": "mkdir -p dist && cp src/index.ts dist/index.js",
|
||||||
|
"start": "node dist/index.js"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"ws": "^8.16.0",
|
||||||
|
"zod": "^3.22.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "^22.0.0",
|
||||||
|
"@types/ws": "^8.5.0",
|
||||||
|
"tsx": "^4.7.0",
|
||||||
|
"typescript": "^5.4.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
1
packages/backend/src/index.ts
Normal file
1
packages/backend/src/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
// placeholder
|
||||||
28
packages/frontend/package.json
Normal file
28
packages/frontend/package.json
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
{
|
||||||
|
"name": "spatial-office-frontend",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.1.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "mkdir -p dist && cp src/main.tsx dist/index.html",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@react-three/drei": "^9.0.0",
|
||||||
|
"@react-three/fiber": "^8.0.0",
|
||||||
|
"react": "^18.2.0",
|
||||||
|
"react-dom": "^18.2.0",
|
||||||
|
"three": "^0.170.0",
|
||||||
|
"zustand": "^5.0.0",
|
||||||
|
"gsap": "^3.12.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/react": "^18.2.0",
|
||||||
|
"@types/react-dom": "^18.2.0",
|
||||||
|
"@types/three": "^0.170.0",
|
||||||
|
"typescript": "^5.4.0",
|
||||||
|
"vite": "^6.0.0",
|
||||||
|
"@vitejs/plugin-react": "^4.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
1
packages/frontend/src/main.tsx
Normal file
1
packages/frontend/src/main.tsx
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
// placeholder
|
||||||
2
pnpm-workspace.yaml
Normal file
2
pnpm-workspace.yaml
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
packages:
|
||||||
|
- "packages/*"
|
||||||
184
spatial-office.nix
Normal file
184
spatial-office.nix
Normal file
|
|
@ -0,0 +1,184 @@
|
||||||
|
# spatial-office.nix — NixOS module for the Spatial Office service
|
||||||
|
#
|
||||||
|
# Import into your NixOS configuration:
|
||||||
|
#
|
||||||
|
# imports = [ ./spatial-office.nix ];
|
||||||
|
# services.spatial-office.enable = true;
|
||||||
|
# services.spatial-office.port = 18790;
|
||||||
|
# services.spatial-office.openclawTokenFile = "/run/secrets/openclaw-token";
|
||||||
|
#
|
||||||
|
# This module provides:
|
||||||
|
# - A systemd service running the spatial-office backend
|
||||||
|
# - A dedicated spatial-office system user
|
||||||
|
# - Firewall port opening for the configured port
|
||||||
|
#
|
||||||
|
# Requirements:
|
||||||
|
# - The spatial-office flake must be available as an input in your flake.nix
|
||||||
|
# - Or the spatial-office package must be in your nixpkgs overlay
|
||||||
|
#
|
||||||
|
# Usage with flake-parts or raw NixOS config:
|
||||||
|
#
|
||||||
|
# { config, inputs, pkgs, ... }: {
|
||||||
|
# imports = [ inputs.spatial-office.nixosModules.spatial-office-service ];
|
||||||
|
# services.spatial-office = {
|
||||||
|
# enable = true;
|
||||||
|
# port = 18790;
|
||||||
|
# openclawTokenFile = config.sops.secrets.openclaw-token.path;
|
||||||
|
# };
|
||||||
|
# }
|
||||||
|
|
||||||
|
{ config, lib, pkgs, ... }:
|
||||||
|
|
||||||
|
let
|
||||||
|
cfg = config.services.spatial-office;
|
||||||
|
in
|
||||||
|
{
|
||||||
|
options.services.spatial-office = {
|
||||||
|
enable = lib.mkEnableOption "Spatial Office service — isometric agent workspace";
|
||||||
|
|
||||||
|
package = lib.mkOption {
|
||||||
|
type = lib.types.package;
|
||||||
|
default = pkgs.spatial-office-backend or (throw ''
|
||||||
|
spatial-office package not found in nixpkgs.
|
||||||
|
Add the spatial-office flake as an input and set:
|
||||||
|
services.spatial-office.package = inputs.spatial-office.packages.''${pkgs.system}.backend;
|
||||||
|
'');
|
||||||
|
description = "The spatial-office backend package to use";
|
||||||
|
};
|
||||||
|
|
||||||
|
frontendPackage = lib.mkOption {
|
||||||
|
type = lib.types.package;
|
||||||
|
default = pkgs.spatial-office-frontend or (throw ''
|
||||||
|
spatial-office frontend package not found in nixpkgs.
|
||||||
|
Add the spatial-office flake as an input and set:
|
||||||
|
services.spatial-office.frontendPackage = inputs.spatial-office.packages.''${pkgs.system}.frontend;
|
||||||
|
'');
|
||||||
|
description = "The spatial-office frontend package (static files)";
|
||||||
|
};
|
||||||
|
|
||||||
|
port = lib.mkOption {
|
||||||
|
type = lib.types.port;
|
||||||
|
default = 18790;
|
||||||
|
description = "WebSocket server port for the spatial-office backend";
|
||||||
|
};
|
||||||
|
|
||||||
|
host = lib.mkOption {
|
||||||
|
type = lib.types.str;
|
||||||
|
default = "127.0.0.1";
|
||||||
|
description = "Host address to bind the backend to";
|
||||||
|
};
|
||||||
|
|
||||||
|
openclawTokenFile = lib.mkOption {
|
||||||
|
type = lib.types.nullOr lib.types.path;
|
||||||
|
default = null;
|
||||||
|
description = ''
|
||||||
|
Path to a file containing the OpenClaw gateway shared token.
|
||||||
|
The file must be readable by the spatial-office user.
|
||||||
|
Use with sops-nix or agenix for secret management.
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
|
||||||
|
openclawGatewayUrl = lib.mkOption {
|
||||||
|
type = lib.types.str;
|
||||||
|
default = "ws://127.0.0.1:18789";
|
||||||
|
description = "WebSocket URL of the OpenClaw gateway";
|
||||||
|
};
|
||||||
|
|
||||||
|
frontendDir = lib.mkOption {
|
||||||
|
type = lib.types.path;
|
||||||
|
default = cfg.frontendPackage;
|
||||||
|
description = "Directory of built frontend static files to serve";
|
||||||
|
};
|
||||||
|
|
||||||
|
logLevel = lib.mkOption {
|
||||||
|
type = lib.types.enum [ "debug" "info" "warn" "error" ];
|
||||||
|
default = "info";
|
||||||
|
description = "Log level for the spatial-office backend";
|
||||||
|
};
|
||||||
|
|
||||||
|
extraEnvironment = lib.mkOption {
|
||||||
|
type = lib.types.attrsOf lib.types.str;
|
||||||
|
default = { };
|
||||||
|
description = "Extra environment variables to pass to the service";
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
config = lib.mkIf cfg.enable {
|
||||||
|
# ── Systemd service ────────────────────────────────────────────
|
||||||
|
systemd.services.spatial-office = {
|
||||||
|
description = "Spatial Office — isometric agent workspace backend";
|
||||||
|
documentation = [ "https://os.scoble.me/forgejo/sscoble/spatial-office" ];
|
||||||
|
wantedBy = [ "multi-user.target" ];
|
||||||
|
after = [ "network.target" "network-online.target" ];
|
||||||
|
wants = [ "network-online.target" ];
|
||||||
|
|
||||||
|
# If a token file is configured, make sure it's available
|
||||||
|
unitConfig = lib.mkIf (cfg.openclawTokenFile != null) {
|
||||||
|
RequiresMountsFor = [ (builtins.dirOf cfg.openclawTokenFile) ];
|
||||||
|
};
|
||||||
|
|
||||||
|
environment = {
|
||||||
|
PORT = toString cfg.port;
|
||||||
|
HOST = cfg.host;
|
||||||
|
SPATIAL_OFFICE_FRONTEND_DIR = "${cfg.frontendDir}";
|
||||||
|
LOG_LEVEL = cfg.logLevel;
|
||||||
|
NODE_ENV = "production";
|
||||||
|
OPENCLAW_GATEWAY_URL = cfg.openclawGatewayUrl;
|
||||||
|
} // (lib.optionalAttrs (cfg.openclawTokenFile != null) {
|
||||||
|
OPENCLAW_TOKEN_FILE = cfg.openclawTokenFile;
|
||||||
|
}) // cfg.extraEnvironment;
|
||||||
|
|
||||||
|
serviceConfig = {
|
||||||
|
ExecStart = "${cfg.package}/bin/spatial-office-backend";
|
||||||
|
Restart = "always";
|
||||||
|
RestartSec = 5;
|
||||||
|
|
||||||
|
# Run as dedicated user
|
||||||
|
User = "spatial-office";
|
||||||
|
Group = "spatial-office";
|
||||||
|
|
||||||
|
# Security hardening
|
||||||
|
NoNewPrivileges = true;
|
||||||
|
ProtectSystem = "strict";
|
||||||
|
ProtectHome = true;
|
||||||
|
PrivateTmp = true;
|
||||||
|
PrivateDevices = true;
|
||||||
|
ProtectKernelTunables = true;
|
||||||
|
ProtectKernelModules = true;
|
||||||
|
ProtectControlGroups = true;
|
||||||
|
RestrictRealtime = true;
|
||||||
|
|
||||||
|
# Node.js needs W^X disabled for JIT
|
||||||
|
MemoryDenyWriteExecute = false;
|
||||||
|
|
||||||
|
# Allow network and basic syscalls
|
||||||
|
SystemCallFilter = [
|
||||||
|
"@system-service"
|
||||||
|
"~@privileged"
|
||||||
|
"~@resources"
|
||||||
|
];
|
||||||
|
|
||||||
|
# Restrict filesystem access
|
||||||
|
ReadWritePaths = [ ];
|
||||||
|
ReadOnlyPaths = lib.optionals (cfg.openclawTokenFile != null) [ cfg.openclawTokenFile ];
|
||||||
|
InaccessiblePaths = [ "/root" "/etc/ssh" ];
|
||||||
|
|
||||||
|
# Resource limits
|
||||||
|
LimitNOFILE = 4096;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
# ── Service user ───────────────────────────────────────────────
|
||||||
|
users.users.spatial-office = {
|
||||||
|
isSystemUser = true;
|
||||||
|
group = "spatial-office";
|
||||||
|
description = "Spatial Office service user";
|
||||||
|
home = "/var/lib/spatial-office";
|
||||||
|
createHome = true;
|
||||||
|
};
|
||||||
|
users.groups.spatial-office = { };
|
||||||
|
|
||||||
|
# ── Firewall ───────────────────────────────────────────────────
|
||||||
|
networking.firewall.allowedTCPPorts = [ cfg.port ];
|
||||||
|
};
|
||||||
|
}
|
||||||
15
src/bridge-openclaw/package.json
Normal file
15
src/bridge-openclaw/package.json
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
{
|
||||||
|
"name": "@spatial-office/bridge-openclaw",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"description": "OpenClaw Gateway bridge — maps agent state to spatial events",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"main": "index.js",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=22"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"ws": "^8.18.0",
|
||||||
|
"zod": "^3.23.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
219
src/bridge-openclaw/types.js
Normal file
219
src/bridge-openclaw/types.js
Normal file
|
|
@ -0,0 +1,219 @@
|
||||||
|
// Spatial Office — OpenClaw Bridge: Data Model Types
|
||||||
|
// Uses Zod for runtime validation + type inference.
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
// ── Agent Status ────────────────────────────────────────────
|
||||||
|
export const AgentStatus = z.enum(["idle", "active", "blocked", "offline"]);
|
||||||
|
export const AgentStateEnum = AgentStatus; // alias used in code
|
||||||
|
|
||||||
|
// ── Department ──────────────────────────────────────────────
|
||||||
|
export const Department = z.enum([
|
||||||
|
"cos",
|
||||||
|
"engineering",
|
||||||
|
"senior-engineering",
|
||||||
|
"trust",
|
||||||
|
"market",
|
||||||
|
"health",
|
||||||
|
"content",
|
||||||
|
"presentations",
|
||||||
|
"vault",
|
||||||
|
]);
|
||||||
|
|
||||||
|
// ── Zone ────────────────────────────────────────────────────
|
||||||
|
export const ZoneType = z.enum([
|
||||||
|
"collaboration",
|
||||||
|
"solo",
|
||||||
|
"self-improvement",
|
||||||
|
"social",
|
||||||
|
]);
|
||||||
|
|
||||||
|
export const SpatialZone = z.object({
|
||||||
|
id: z.string(),
|
||||||
|
name: z.string(),
|
||||||
|
type: ZoneType,
|
||||||
|
occupants: z.array(z.string()),
|
||||||
|
tileRange: z.object({
|
||||||
|
x: z.number().int().min(0),
|
||||||
|
y: z.number().int().min(0),
|
||||||
|
width: z.number().int().positive(),
|
||||||
|
height: z.number().int().positive(),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Grid Position ───────────────────────────────────────────
|
||||||
|
export const GridPosition = z.object({
|
||||||
|
x: z.number().int().min(0),
|
||||||
|
y: z.number().int().min(0),
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Spatial Agent ───────────────────────────────────────────
|
||||||
|
export const SpatialAgent = z.object({
|
||||||
|
id: z.string(),
|
||||||
|
name: z.string(),
|
||||||
|
department: Department,
|
||||||
|
state: AgentStatus,
|
||||||
|
currentZone: z.string(),
|
||||||
|
currentTask: z.string().nullable().optional(),
|
||||||
|
lastActivity: z.number(),
|
||||||
|
position: GridPosition,
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Office State ────────────────────────────────────────────
|
||||||
|
export const OfficeMeta = z.object({
|
||||||
|
gatewayUptime: z.number(),
|
||||||
|
connectedClients: z.number(),
|
||||||
|
scottPresent: z.boolean(),
|
||||||
|
lastUpdate: z.number(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const OfficeEvent = z.object({
|
||||||
|
type: z.string(),
|
||||||
|
agentId: z.string().nullable(),
|
||||||
|
zoneId: z.string().nullable(),
|
||||||
|
payload: z.record(z.unknown()).optional(),
|
||||||
|
timestamp: z.number(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const BridgeState = z.object({
|
||||||
|
agents: z.record(z.string(), SpatialAgent),
|
||||||
|
zones: z.record(z.string(), SpatialZone),
|
||||||
|
events: z.array(OfficeEvent),
|
||||||
|
meta: OfficeMeta,
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Agent Registry Entry ────────────────────────────────────
|
||||||
|
export const AgentConfig = z.object({
|
||||||
|
id: z.string(),
|
||||||
|
name: z.string(),
|
||||||
|
department: Department,
|
||||||
|
color: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Default positions per agent ─────────────────────────────
|
||||||
|
// These are the "home" positions when an agent is idle.
|
||||||
|
export const DEFAULT_POSITIONS = {
|
||||||
|
cos: { x: 2, y: 5 },
|
||||||
|
engineering: { x: 7, y: 5 },
|
||||||
|
"senior-engineering": { x: 6, y: 5 },
|
||||||
|
trust: { x: 3, y: 5 },
|
||||||
|
market: { x: 14, y: 5 },
|
||||||
|
health: { x: 18, y: 5 },
|
||||||
|
content: { x: 15, y: 5 },
|
||||||
|
presentations: { x: 13, y: 5 },
|
||||||
|
vault: { x: 17, y: 5 },
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Zone definitions (MVP layout) ───────────────────────────
|
||||||
|
export const DEFAULT_ZONES = {
|
||||||
|
"solo-west": {
|
||||||
|
id: "solo-west",
|
||||||
|
name: "Solo West",
|
||||||
|
type: "solo",
|
||||||
|
occupants: [],
|
||||||
|
tileRange: { x: 0, y: 2, width: 5, height: 10 },
|
||||||
|
},
|
||||||
|
"pod-a": {
|
||||||
|
id: "pod-a",
|
||||||
|
name: "Collaboration Pod A",
|
||||||
|
type: "collaboration",
|
||||||
|
occupants: [],
|
||||||
|
tileRange: { x: 5, y: 2, width: 5, height: 6 },
|
||||||
|
},
|
||||||
|
"pod-b": {
|
||||||
|
id: "pod-b",
|
||||||
|
name: "Collaboration Pod B",
|
||||||
|
type: "collaboration",
|
||||||
|
occupants: [],
|
||||||
|
tileRange: { x: 10, y: 2, width: 5, height: 6 },
|
||||||
|
},
|
||||||
|
"solo-east": {
|
||||||
|
id: "solo-east",
|
||||||
|
name: "Solo East",
|
||||||
|
type: "solo",
|
||||||
|
occupants: [],
|
||||||
|
tileRange: { x: 15, y: 2, width: 5, height: 10 },
|
||||||
|
},
|
||||||
|
"coaching-corner": {
|
||||||
|
id: "coaching-corner",
|
||||||
|
name: "Coaching Corner",
|
||||||
|
type: "self-improvement",
|
||||||
|
occupants: [],
|
||||||
|
tileRange: { x: 5, y: 8, width: 10, height: 3 },
|
||||||
|
},
|
||||||
|
"coffee-bar": {
|
||||||
|
id: "coffee-bar",
|
||||||
|
name: "Coffee Bar",
|
||||||
|
type: "social",
|
||||||
|
occupants: [],
|
||||||
|
tileRange: { x: 5, y: 0, width: 10, height: 2 },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Zone position assignments ───────────────────────────────
|
||||||
|
// Where agents are placed within their zone.
|
||||||
|
export const ZONE_POSITIONS = {
|
||||||
|
"solo-west": [
|
||||||
|
{ x: 2, y: 5 },
|
||||||
|
{ x: 3, y: 5 },
|
||||||
|
{ x: 2, y: 7 },
|
||||||
|
{ x: 3, y: 7 },
|
||||||
|
{ x: 2, y: 9 },
|
||||||
|
{ x: 3, y: 9 },
|
||||||
|
],
|
||||||
|
"pod-a": [
|
||||||
|
{ x: 6, y: 3 },
|
||||||
|
{ x: 7, y: 3 },
|
||||||
|
{ x: 8, y: 3 },
|
||||||
|
{ x: 9, y: 3 },
|
||||||
|
],
|
||||||
|
"pod-b": [
|
||||||
|
{ x: 11, y: 3 },
|
||||||
|
{ x: 12, y: 3 },
|
||||||
|
{ x: 13, y: 3 },
|
||||||
|
{ x: 14, y: 3 },
|
||||||
|
],
|
||||||
|
"solo-east": [
|
||||||
|
{ x: 16, y: 5 },
|
||||||
|
{ x: 17, y: 5 },
|
||||||
|
{ x: 18, y: 5 },
|
||||||
|
{ x: 16, y: 7 },
|
||||||
|
{ x: 17, y: 7 },
|
||||||
|
{ x: 18, y: 7 },
|
||||||
|
],
|
||||||
|
"coaching-corner": [
|
||||||
|
{ x: 6, y: 9 },
|
||||||
|
{ x: 9, y: 9 },
|
||||||
|
{ x: 12, y: 9 },
|
||||||
|
],
|
||||||
|
"coffee-bar": [
|
||||||
|
{ x: 6, y: 1 },
|
||||||
|
{ x: 8, y: 1 },
|
||||||
|
{ x: 10, y: 1 },
|
||||||
|
{ x: 12, y: 1 },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Helper: Get a position within a zone ────────────────────
|
||||||
|
export function getZonePosition(zoneId, agentId, existingPositions = {}) {
|
||||||
|
const positions = ZONE_POSITIONS[zoneId] || [{ x: 0, y: 0 }];
|
||||||
|
// Hash agentId to pick a deterministic slot
|
||||||
|
let hash = 0;
|
||||||
|
for (let i = 0; i < agentId.length; i++) {
|
||||||
|
hash = (hash * 31 + agentId.charCodeAt(i)) | 0;
|
||||||
|
}
|
||||||
|
const idx = Math.abs(hash) % positions.length;
|
||||||
|
// Avoid collisions: if slot is taken, find next available
|
||||||
|
const occupiedSet = new Set(
|
||||||
|
Object.entries(existingPositions)
|
||||||
|
.filter(([id]) => id !== agentId)
|
||||||
|
.map(([, pos]) => `${pos.x},${pos.y}`)
|
||||||
|
);
|
||||||
|
|
||||||
|
for (let i = 0; i < positions.length; i++) {
|
||||||
|
const pos = positions[(idx + i) % positions.length];
|
||||||
|
if (!occupiedSet.has(`${pos.x},${pos.y}`)) {
|
||||||
|
return { ...pos };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { ...positions[idx] }; // fallback
|
||||||
|
}
|
||||||
190
src/frontend/AgentSprite.jsx
Normal file
190
src/frontend/AgentSprite.jsx
Normal file
|
|
@ -0,0 +1,190 @@
|
||||||
|
import React, { useRef, useState } from 'react';
|
||||||
|
import { useFrame } from '@react-three/fiber';
|
||||||
|
import { Text, Html } from '@react-three/drei';
|
||||||
|
import * as THREE from 'three';
|
||||||
|
|
||||||
|
// ── Isometric grid helper: convert grid coords → world position ──
|
||||||
|
// Isometric: x = (col - row) * tileW/2, z = (col + row) * tileH/2
|
||||||
|
const TILE_W = 1.0;
|
||||||
|
const TILE_H = 0.5;
|
||||||
|
|
||||||
|
export function gridToWorld(row, col) {
|
||||||
|
return [
|
||||||
|
(col - row) * (TILE_W / 2),
|
||||||
|
0,
|
||||||
|
(col + row) * (TILE_H / 2),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── World pos to grid (inverse) ──────────────────────────────────
|
||||||
|
export function worldToGrid(x, z) {
|
||||||
|
const col = (x / (TILE_W / 2) + z / (TILE_H / 2)) / 2;
|
||||||
|
const row = (z / (TILE_H / 2) - x / (TILE_W / 2)) / 2;
|
||||||
|
return { row: Math.round(row), col: Math.round(col) };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Activity → glow color ────────────────────────────────────────
|
||||||
|
const ACTIVITY_COLORS = {
|
||||||
|
idle: '#44cc44',
|
||||||
|
thinking: '#ffcc00',
|
||||||
|
working: '#4488ff',
|
||||||
|
blocked: '#ff4444',
|
||||||
|
offline: '#666666',
|
||||||
|
};
|
||||||
|
|
||||||
|
const LERP_SPEED = 0.08;
|
||||||
|
|
||||||
|
export default function AgentSprite({ agent, hovered, onHover }) {
|
||||||
|
const meshRef = useRef();
|
||||||
|
const glowRef = useRef();
|
||||||
|
const [targetPos, setTargetPos] = useState(null);
|
||||||
|
|
||||||
|
// Initialize position
|
||||||
|
if (!targetPos && agent.position) {
|
||||||
|
setTargetPos(gridToWorld(agent.position.row, agent.position.col));
|
||||||
|
} else if (agent.targetPosition) {
|
||||||
|
setTargetPos(gridToWorld(agent.targetPosition.row, agent.targetPosition.col));
|
||||||
|
}
|
||||||
|
|
||||||
|
useFrame((state, delta) => {
|
||||||
|
if (!meshRef.current || !agent) return;
|
||||||
|
|
||||||
|
// Calculate target world position
|
||||||
|
const tgt = agent.targetPosition || agent.position;
|
||||||
|
if (!tgt) return;
|
||||||
|
|
||||||
|
const [tx, , tz] = gridToWorld(tgt.row, tgt.col);
|
||||||
|
|
||||||
|
// Smooth lerp toward target
|
||||||
|
meshRef.current.position.x += (tx - meshRef.current.position.x) * LERP_SPEED;
|
||||||
|
meshRef.current.position.z += (tz - meshRef.current.position.z) * LERP_SPEED;
|
||||||
|
|
||||||
|
// If close enough, snap to target
|
||||||
|
if (
|
||||||
|
Math.abs(tx - meshRef.current.position.x) < 0.01 &&
|
||||||
|
Math.abs(tz - meshRef.current.position.z) < 0.01
|
||||||
|
) {
|
||||||
|
meshRef.current.position.x = tx;
|
||||||
|
meshRef.current.position.z = tz;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Floating bob
|
||||||
|
const bob = Math.sin(state.clock.elapsedTime * 2 + agent.position?.row || 0) * 0.05;
|
||||||
|
meshRef.current.position.y = 0.15 + bob;
|
||||||
|
|
||||||
|
// Glow pulse based on activity
|
||||||
|
if (glowRef.current) {
|
||||||
|
const pulse = Math.sin(state.clock.elapsedTime * 3) * 0.5 + 0.5;
|
||||||
|
glowRef.current.material.opacity = agent.activity === 'working' ? 0.3 + pulse * 0.4 :
|
||||||
|
agent.activity === 'thinking' ? 0.2 + pulse * 0.3 :
|
||||||
|
agent.activity === 'offline' ? 0 :
|
||||||
|
0.15;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!agent) return null;
|
||||||
|
|
||||||
|
const color = agent.activity === 'offline' ? '#666666' : agent.color;
|
||||||
|
const glowColor = ACTIVITY_COLORS[agent.activity] || '#44cc44';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<group>
|
||||||
|
{/* Agent body — isometric cube */}
|
||||||
|
<mesh
|
||||||
|
ref={meshRef}
|
||||||
|
position={targetPos || [0, 0.15, 0]}
|
||||||
|
onPointerEnter={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onHover(agent.id, true);
|
||||||
|
}}
|
||||||
|
onPointerLeave={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onHover(agent.id, false);
|
||||||
|
}}
|
||||||
|
castShadow
|
||||||
|
>
|
||||||
|
<boxGeometry args={[0.4, 0.5, 0.3]} />
|
||||||
|
<meshStandardMaterial
|
||||||
|
color={color}
|
||||||
|
emissive={color}
|
||||||
|
emissiveIntensity={agent.activity === 'offline' ? 0 : 0.3}
|
||||||
|
roughness={0.6}
|
||||||
|
metalness={0.1}
|
||||||
|
/>
|
||||||
|
</mesh>
|
||||||
|
|
||||||
|
{/* Glow ring below agent */}
|
||||||
|
<mesh
|
||||||
|
ref={glowRef}
|
||||||
|
position={
|
||||||
|
targetPos
|
||||||
|
? [targetPos[0], 0.02, targetPos[2]]
|
||||||
|
: [0, 0.02, 0]
|
||||||
|
}
|
||||||
|
rotation={[-Math.PI / 2, 0, 0]}
|
||||||
|
>
|
||||||
|
<ringGeometry args={[0.25, 0.35, 32]} />
|
||||||
|
<meshBasicMaterial
|
||||||
|
color={glowColor}
|
||||||
|
transparent
|
||||||
|
opacity={0.2}
|
||||||
|
side={THREE.DoubleSide}
|
||||||
|
/>
|
||||||
|
</mesh>
|
||||||
|
|
||||||
|
{/* Agent name label */}
|
||||||
|
<Text
|
||||||
|
position={
|
||||||
|
targetPos
|
||||||
|
? [targetPos[0], 0.7, targetPos[2]]
|
||||||
|
: [0, 0.7, 0]
|
||||||
|
}
|
||||||
|
fontSize={0.15}
|
||||||
|
color={agent.activity === 'offline' ? '#888888' : '#ffffff'}
|
||||||
|
anchorX="center"
|
||||||
|
anchorY="middle"
|
||||||
|
outlineWidth={0.01}
|
||||||
|
outlineColor="#000000"
|
||||||
|
>
|
||||||
|
{agent.name}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
{/* Hover tooltip */}
|
||||||
|
{hovered && (
|
||||||
|
<Html
|
||||||
|
position={
|
||||||
|
targetPos
|
||||||
|
? [targetPos[0], 0.9, targetPos[2]]
|
||||||
|
: [0, 0.9, 0]
|
||||||
|
}
|
||||||
|
center
|
||||||
|
distanceFactor={15}
|
||||||
|
style={{ pointerEvents: 'none' }}
|
||||||
|
>
|
||||||
|
<div style={{
|
||||||
|
background: 'rgba(20,20,40,0.95)',
|
||||||
|
color: '#eee',
|
||||||
|
padding: '8px 12px',
|
||||||
|
borderRadius: '6px',
|
||||||
|
fontSize: '12px',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
border: '1px solid rgba(255,255,255,0.15)',
|
||||||
|
backdropFilter: 'blur(8px)',
|
||||||
|
}}>
|
||||||
|
<div style={{ fontWeight: 'bold', color: agent.color, marginBottom: 4 }}>
|
||||||
|
{agent.name}
|
||||||
|
</div>
|
||||||
|
<div style={{ opacity: 0.8 }}>
|
||||||
|
Status: <span style={{ color: glowColor }}>{agent.activity}</span>
|
||||||
|
</div>
|
||||||
|
{agent.currentTask && (
|
||||||
|
<div style={{ opacity: 0.7, marginTop: 2 }}>
|
||||||
|
{agent.currentTask}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Html>
|
||||||
|
)}
|
||||||
|
</group>
|
||||||
|
);
|
||||||
|
}
|
||||||
168
src/frontend/WebSocketClient.js
Normal file
168
src/frontend/WebSocketClient.js
Normal file
|
|
@ -0,0 +1,168 @@
|
||||||
|
import useStore, { DEFAULT_POSITIONS } from './store.js';
|
||||||
|
|
||||||
|
// ── Mock activity cycle for demo mode (no backend) ────────────────
|
||||||
|
const ACTIVITIES = ['idle', 'thinking', 'working', 'idle'];
|
||||||
|
const TASKS = [
|
||||||
|
'Memory maintenance',
|
||||||
|
'Reviewing PR #42',
|
||||||
|
'Running web search',
|
||||||
|
'Pairing with vault',
|
||||||
|
'Indexing documents',
|
||||||
|
'Processing notification',
|
||||||
|
'Training suggestion model',
|
||||||
|
'Analyzing market data',
|
||||||
|
];
|
||||||
|
|
||||||
|
class WebSocketClient {
|
||||||
|
constructor() {
|
||||||
|
this.ws = null;
|
||||||
|
this.reconnectTimer = null;
|
||||||
|
this.reconnectDelay = 1000;
|
||||||
|
this.maxReconnectDelay = 30000;
|
||||||
|
this.url = 'ws://localhost:18790';
|
||||||
|
this.mockInterval = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
connect() {
|
||||||
|
useStore.getState().setWsReconnecting(false);
|
||||||
|
|
||||||
|
try {
|
||||||
|
this.ws = new WebSocket(this.url);
|
||||||
|
|
||||||
|
this.ws.onopen = () => {
|
||||||
|
console.log('[WS] Connected');
|
||||||
|
this.reconnectDelay = 1000;
|
||||||
|
};
|
||||||
|
|
||||||
|
this.ws.onmessage = (event) => {
|
||||||
|
try {
|
||||||
|
const msg = JSON.parse(event.data);
|
||||||
|
this.handleMessage(msg);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[WS] Parse error:', e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
this.ws.onclose = (event) => {
|
||||||
|
console.log('[WS] Disconnected:', event.code, event.reason);
|
||||||
|
useStore.getState().setWsConnected(false);
|
||||||
|
this.scheduleReconnect();
|
||||||
|
};
|
||||||
|
|
||||||
|
this.ws.onerror = (err) => {
|
||||||
|
console.error('[WS] Error:', err);
|
||||||
|
};
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[WS] Connection error:', e);
|
||||||
|
this.scheduleReconnect();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
handleMessage(msg) {
|
||||||
|
switch (msg.type) {
|
||||||
|
case 'state.full':
|
||||||
|
useStore.getState().initFromSnapshot(msg.payload);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'state.diff':
|
||||||
|
useStore.getState().applyDiff(msg.changes);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'state.init':
|
||||||
|
// Backend sent initial state before WS connection
|
||||||
|
useStore.getState().initFromSnapshot(msg.payload || msg);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'heartbeat':
|
||||||
|
// Heartbeat received, connection is alive
|
||||||
|
break;
|
||||||
|
|
||||||
|
default:
|
||||||
|
console.log('[WS] Unknown message type:', msg.type, msg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
scheduleReconnect() {
|
||||||
|
if (this.reconnectTimer) return;
|
||||||
|
|
||||||
|
useStore.getState().setWsReconnecting(true);
|
||||||
|
this.reconnectTimer = setTimeout(() => {
|
||||||
|
this.reconnectTimer = null;
|
||||||
|
this.reconnectDelay = Math.min(
|
||||||
|
this.reconnectDelay * 1.5,
|
||||||
|
this.maxReconnectDelay
|
||||||
|
);
|
||||||
|
console.log(`[WS] Reconnecting in ${this.reconnectDelay}ms...`);
|
||||||
|
setTimeout(() => this.connect(), this.reconnectDelay);
|
||||||
|
}, 500);
|
||||||
|
}
|
||||||
|
|
||||||
|
disconnect() {
|
||||||
|
if (this.reconnectTimer) {
|
||||||
|
clearTimeout(this.reconnectTimer);
|
||||||
|
this.reconnectTimer = null;
|
||||||
|
}
|
||||||
|
this.stopMock();
|
||||||
|
if (this.ws) {
|
||||||
|
this.ws.onclose = null; // prevent reconnect on intentional close
|
||||||
|
this.ws.close();
|
||||||
|
this.ws = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Mock mode: when no backend is running, simulate agent activity ──
|
||||||
|
startMock() {
|
||||||
|
// Initialize with default agent state
|
||||||
|
useStore.getState().initFromSnapshot(null);
|
||||||
|
|
||||||
|
// Cycle agents through activities
|
||||||
|
let cycle = 0;
|
||||||
|
const agentIds = Object.keys(DEFAULT_POSITIONS);
|
||||||
|
|
||||||
|
this.mockInterval = setInterval(() => {
|
||||||
|
cycle++;
|
||||||
|
const store = useStore.getState();
|
||||||
|
|
||||||
|
// Every 3 seconds, pick 2 random agents to update
|
||||||
|
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 activity = ACTIVITIES[Math.floor(Math.random() * ACTIVITIES.length)];
|
||||||
|
const task = TASKS[Math.floor(Math.random() * TASKS.length)];
|
||||||
|
|
||||||
|
store.updateAgentActivity(a1, activity, task);
|
||||||
|
store.updateAgentActivity(a2,
|
||||||
|
ACTIVITIES[Math.floor(Math.random() * ACTIVITIES.length)],
|
||||||
|
TASKS[Math.floor(Math.random() * TASKS.length)]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Occasionally move agents
|
||||||
|
if (cycle % 10 === 0) {
|
||||||
|
const agentId = agentIds[Math.floor(Math.random() * agentIds.length)];
|
||||||
|
const pos = store.agents[agentId]?.position;
|
||||||
|
if (pos) {
|
||||||
|
store.updateAgentPosition(agentId, {
|
||||||
|
row: Math.max(1, Math.min(18, pos.row + (Math.random() - 0.5) * 4)),
|
||||||
|
col: Math.max(1, Math.min(26, pos.col + (Math.random() - 0.5) * 6)),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
stopMock() {
|
||||||
|
if (this.mockInterval) {
|
||||||
|
clearInterval(this.mockInterval);
|
||||||
|
this.mockInterval = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Singleton
|
||||||
|
const wsClient = new WebSocketClient();
|
||||||
|
export default wsClient;
|
||||||
40
src/frontend/index.html
Normal file
40
src/frontend/index.html
Normal file
|
|
@ -0,0 +1,40 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Spatial Office — Scobleclaw</title>
|
||||||
|
<style>
|
||||||
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||||
|
html, body, #root { width: 100%; height: 100%; overflow: hidden; }
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||||
|
background: #1a1a2e;
|
||||||
|
color: #e0e0e0;
|
||||||
|
}
|
||||||
|
#root { position: relative; }
|
||||||
|
.loading-overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: #1a1a2e;
|
||||||
|
z-index: 100;
|
||||||
|
}
|
||||||
|
.loading-spinner {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border: 3px solid #333;
|
||||||
|
border-top-color: #4ECDC4;
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin 0.8s linear infinite;
|
||||||
|
}
|
||||||
|
@keyframes spin { to { transform: rotate(360deg); } }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.jsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
10
src/frontend/main.jsx
Normal file
10
src/frontend/main.jsx
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
import React from 'react';
|
||||||
|
import { createRoot } from 'react-dom/client';
|
||||||
|
import App from './App.jsx';
|
||||||
|
|
||||||
|
const root = createRoot(document.getElementById('root'));
|
||||||
|
root.render(
|
||||||
|
<React.StrictMode>
|
||||||
|
<App />
|
||||||
|
</React.StrictMode>
|
||||||
|
);
|
||||||
208
src/frontend/store.js
Normal file
208
src/frontend/store.js
Normal file
|
|
@ -0,0 +1,208 @@
|
||||||
|
import { create } from 'zustand';
|
||||||
|
|
||||||
|
// ── Zone definitions ───────────────────────────────────────────────
|
||||||
|
const ZONES = {
|
||||||
|
'pod-a': {
|
||||||
|
id: 'pod-a',
|
||||||
|
name: 'Collaboration Pod A',
|
||||||
|
type: 'collaboration',
|
||||||
|
color: '#C68B3C',
|
||||||
|
bounds: { row: 2, col: 8, rows: 5, cols: 5 },
|
||||||
|
},
|
||||||
|
'pod-b': {
|
||||||
|
id: 'pod-b',
|
||||||
|
name: 'Collaboration Pod B',
|
||||||
|
type: 'collaboration',
|
||||||
|
color: '#C68B3C',
|
||||||
|
bounds: { row: 2, col: 14, rows: 5, cols: 5 },
|
||||||
|
},
|
||||||
|
'solo-1': {
|
||||||
|
id: 'solo-1',
|
||||||
|
name: 'Solo Work Area 1',
|
||||||
|
type: 'solo',
|
||||||
|
color: '#3A7CA5',
|
||||||
|
bounds: { row: 2, col: 0, rows: 10, cols: 6 },
|
||||||
|
},
|
||||||
|
'solo-2': {
|
||||||
|
id: 'solo-2',
|
||||||
|
name: 'Solo Work Area 2',
|
||||||
|
type: 'solo',
|
||||||
|
color: '#3A7CA5',
|
||||||
|
bounds: { row: 2, col: 22, rows: 10, cols: 6 },
|
||||||
|
},
|
||||||
|
'coaching': {
|
||||||
|
id: 'coaching',
|
||||||
|
name: 'Coaching Corner',
|
||||||
|
type: 'self-improvement',
|
||||||
|
color: '#5C8A5C',
|
||||||
|
bounds: { row: 13, col: 8, rows: 4, cols: 12 },
|
||||||
|
},
|
||||||
|
'coffee': {
|
||||||
|
id: 'coffee',
|
||||||
|
name: 'Coffee Bar',
|
||||||
|
type: 'social',
|
||||||
|
color: '#8B6914',
|
||||||
|
bounds: { row: 0, col: 10, rows: 2, cols: 8 },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Agent definitions ──────────────────────────────────────────────
|
||||||
|
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' },
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Default agent positions (row, col on the isometric grid) ───────
|
||||||
|
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 },
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Helpers ────────────────────────────────────────────────────────
|
||||||
|
function generateTileMap(gridRows, gridCols) {
|
||||||
|
const tiles = [];
|
||||||
|
for (let row = 0; row < gridRows; row++) {
|
||||||
|
tiles[row] = [];
|
||||||
|
for (let col = 0; col < gridCols; col++) {
|
||||||
|
tiles[row][col] = { type: 'floor', zoneId: findZoneForTile(row, col) };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { rows: gridRows, cols: gridCols, tiles };
|
||||||
|
}
|
||||||
|
|
||||||
|
function findZoneForTile(row, col) {
|
||||||
|
for (const zone of Object.values(ZONES)) {
|
||||||
|
const { row: zr, col: zc, rows, cols } = zone.bounds;
|
||||||
|
if (row >= zr && row < zr + rows && col >= zc && col < zc + cols) {
|
||||||
|
return zone.id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Store ──────────────────────────────────────────────────────────
|
||||||
|
const useStore = create((set, get) => ({
|
||||||
|
// Connection state
|
||||||
|
wsConnected: false,
|
||||||
|
wsReconnecting: false,
|
||||||
|
|
||||||
|
// World state
|
||||||
|
grid: generateTileMap(20, 28),
|
||||||
|
zones: ZONES,
|
||||||
|
agents: {},
|
||||||
|
|
||||||
|
// Initialize from full state snapshot
|
||||||
|
initFromSnapshot(snapshot) {
|
||||||
|
const agents = {};
|
||||||
|
for (const agentId of Object.keys(AGENT_DEFS)) {
|
||||||
|
const def = AGENT_DEFS[agentId];
|
||||||
|
const pos = DEFAULT_POSITIONS[agentId];
|
||||||
|
agents[agentId] = {
|
||||||
|
...def,
|
||||||
|
position: { row: pos.row, col: pos.col },
|
||||||
|
targetPosition: { row: pos.row, col: pos.col },
|
||||||
|
activity: 'idle',
|
||||||
|
currentTask: null,
|
||||||
|
lastHeartbeat: Date.now(),
|
||||||
|
pulse: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
// Apply snapshot overrides if provided
|
||||||
|
if (snapshot && snapshot.agents) {
|
||||||
|
for (const [id, data] of Object.entries(snapshot.agents)) {
|
||||||
|
if (agents[id]) {
|
||||||
|
Object.assign(agents[id], data);
|
||||||
|
if (data.position) {
|
||||||
|
agents[id].targetPosition = { ...data.position };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
set({ agents, wsConnected: true, wsReconnecting: false });
|
||||||
|
},
|
||||||
|
|
||||||
|
// Apply a diff update
|
||||||
|
applyDiff(diffs) {
|
||||||
|
const state = get();
|
||||||
|
const updated = { ...state.agents };
|
||||||
|
|
||||||
|
for (const diff of diffs) {
|
||||||
|
if (diff.op === 'agent.move' || diff.op === 'agent.update') {
|
||||||
|
const agentId = diff.path.split('.')[1]; // agents.engineering → engineering
|
||||||
|
if (updated[agentId]) {
|
||||||
|
const delta = diff.value || {};
|
||||||
|
if (delta.position) {
|
||||||
|
delta.targetPosition = { ...delta.position };
|
||||||
|
}
|
||||||
|
Object.assign(updated[agentId], delta);
|
||||||
|
}
|
||||||
|
} else if (diff.op === 'agent.position') {
|
||||||
|
const agentId = diff.path.split('.')[1];
|
||||||
|
if (updated[agentId]) {
|
||||||
|
updated[agentId].targetPosition = { ...diff.value };
|
||||||
|
}
|
||||||
|
} else if (diff.op === 'agent.activity') {
|
||||||
|
const agentId = diff.path.split('.')[1];
|
||||||
|
if (updated[agentId]) {
|
||||||
|
updated[agentId] = { ...updated[agentId], ...diff.value };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
set({ agents: updated });
|
||||||
|
},
|
||||||
|
|
||||||
|
// WS connection status
|
||||||
|
setWsConnected(connected) {
|
||||||
|
set({ wsConnected: connected, wsReconnecting: !connected });
|
||||||
|
},
|
||||||
|
|
||||||
|
setWsReconnecting(reconnecting) {
|
||||||
|
set({ wsReconnecting: reconnecting });
|
||||||
|
},
|
||||||
|
|
||||||
|
// Update agent positions directly (used by mock mode)
|
||||||
|
updateAgentPosition(agentId, position) {
|
||||||
|
set(state => {
|
||||||
|
const agents = { ...state.agents };
|
||||||
|
if (agents[agentId]) {
|
||||||
|
agents[agentId] = {
|
||||||
|
...agents[agentId],
|
||||||
|
targetPosition: { ...position },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { agents };
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
updateAgentActivity(agentId, activity, task) {
|
||||||
|
set(state => {
|
||||||
|
const agents = { ...state.agents };
|
||||||
|
if (agents[agentId]) {
|
||||||
|
agents[agentId] = {
|
||||||
|
...agents[agentId],
|
||||||
|
activity,
|
||||||
|
currentTask: task || agents[agentId].currentTask,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { agents };
|
||||||
|
});
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
export { ZONES, AGENT_DEFS, DEFAULT_POSITIONS };
|
||||||
|
export default useStore;
|
||||||
Loading…
Add table
Add a link
Reference in a new issue