diff --git a/docker-compose.yml b/docker-compose.yml
index e3b6c37..48af458 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -282,6 +282,16 @@ services:
networks:
- rspace-internal
+ # ── Blender headless render worker ──
+ blender-worker:
+ build: ./docker/blender-worker
+ container_name: blender-worker
+ restart: unless-stopped
+ volumes:
+ - rspace-files:/data/files
+ networks:
+ - rspace-internal
+
# ── Scribus noVNC (rDesign DTP workspace) ──
scribus-novnc:
build:
diff --git a/docker/blender-worker/Dockerfile b/docker/blender-worker/Dockerfile
new file mode 100644
index 0000000..28c6a62
--- /dev/null
+++ b/docker/blender-worker/Dockerfile
@@ -0,0 +1,23 @@
+FROM debian:bookworm-slim
+
+RUN apt-get update && apt-get install -y --no-install-recommends \
+ blender \
+ python3 \
+ ca-certificates \
+ libegl1 \
+ libgl1-mesa-dri \
+ libglx-mesa0 \
+ && rm -rf /var/lib/apt/lists/*
+
+ENV QT_QPA_PLATFORM=offscreen
+ENV DISPLAY=""
+
+WORKDIR /app
+
+COPY server.py .
+
+RUN mkdir -p /data/files/generated
+
+EXPOSE 8810
+
+CMD ["python3", "server.py"]
diff --git a/docker/blender-worker/server.py b/docker/blender-worker/server.py
new file mode 100644
index 0000000..acd36f8
--- /dev/null
+++ b/docker/blender-worker/server.py
@@ -0,0 +1,105 @@
+"""Headless Blender render worker — accepts scripts via HTTP, returns rendered images."""
+
+import json
+import os
+import random
+import shutil
+import string
+import subprocess
+import time
+from http.server import HTTPServer, BaseHTTPRequestHandler
+
+GENERATED_DIR = "/data/files/generated"
+BLENDER_TIMEOUT = 90 # seconds (fits within CF 100s limit)
+
+
+class RenderHandler(BaseHTTPRequestHandler):
+ def do_GET(self):
+ if self.path == "/health":
+ self._json_response(200, {"ok": True, "service": "blender-worker"})
+ else:
+ self._json_response(404, {"error": "not found"})
+
+ def do_POST(self):
+ if self.path != "/render":
+ self._json_response(404, {"error": "not found"})
+ return
+
+ try:
+ length = int(self.headers.get("Content-Length", 0))
+ body = json.loads(self.rfile.read(length))
+ except (json.JSONDecodeError, ValueError):
+ self._json_response(400, {"error": "invalid JSON"})
+ return
+
+ script = body.get("script", "").strip()
+ if not script:
+ self._json_response(400, {"error": "script required"})
+ return
+
+ # Write script to temp file
+ script_path = "/tmp/scene.py"
+ render_path = "/tmp/render.png"
+
+ with open(script_path, "w") as f:
+ f.write(script)
+
+ # Clean any previous render
+ if os.path.exists(render_path):
+ os.remove(render_path)
+
+ # Run Blender headless
+ try:
+ result = subprocess.run(
+ ["blender", "--background", "--python", script_path],
+ capture_output=True,
+ text=True,
+ timeout=BLENDER_TIMEOUT,
+ )
+ except subprocess.TimeoutExpired:
+ self._json_response(504, {
+ "success": False,
+ "error": f"Blender timed out after {BLENDER_TIMEOUT}s",
+ })
+ return
+
+ # Check if render was produced
+ if not os.path.exists(render_path):
+ self._json_response(422, {
+ "success": False,
+ "error": "Blender finished but no render output at /tmp/render.png",
+ "stdout": result.stdout[-2000:] if result.stdout else "",
+ "stderr": result.stderr[-2000:] if result.stderr else "",
+ })
+ return
+
+ # Move render to shared volume with unique name
+ rand = "".join(random.choices(string.ascii_lowercase + string.digits, k=6))
+ filename = f"blender-{int(time.time())}-{rand}.png"
+ dest = os.path.join(GENERATED_DIR, filename)
+
+ os.makedirs(GENERATED_DIR, exist_ok=True)
+ shutil.move(render_path, dest)
+
+ self._json_response(200, {
+ "success": True,
+ "render_url": f"/data/files/generated/{filename}",
+ "filename": filename,
+ })
+
+ def _json_response(self, status, data):
+ body = json.dumps(data).encode("utf-8")
+ self.send_response(status)
+ self.send_header("Content-Type", "application/json")
+ self.send_header("Content-Length", str(len(body)))
+ self.end_headers()
+ self.wfile.write(body)
+
+ def log_message(self, fmt, *args):
+ print(f"[blender-worker] {fmt % args}")
+
+
+if __name__ == "__main__":
+ server = HTTPServer(("0.0.0.0", 8810), RenderHandler)
+ print("[blender-worker] listening on :8810")
+ server.serve_forever()
diff --git a/modules/rcal/components/folk-calendar-view.ts b/modules/rcal/components/folk-calendar-view.ts
index 0f8e529..1c33d0a 100644
--- a/modules/rcal/components/folk-calendar-view.ts
+++ b/modules/rcal/components/folk-calendar-view.ts
@@ -261,6 +261,8 @@ class FolkCalendarView extends HTMLElement {
source_name: e.sourceName, source_color: e.sourceColor,
location_name: e.locationName,
location_lat: e.locationLat, location_lng: e.locationLng,
+ latitude: e.locationLat ?? null,
+ longitude: e.locationLng ?? null,
}));
// Only use doc events if REST hasn't loaded yet
if (this.events.length === 0 && docEvents.length > 0) {
@@ -692,7 +694,14 @@ class FolkCalendarView extends HTMLElement {
fetch(`${base}/api/lunar?start=${start}&end=${end}`),
fetch(`${schedBase}/api/reminders?upcoming=true`).catch(() => null),
]);
- if (eventsRes.ok) { const data = await eventsRes.json(); this.events = data.results || []; }
+ if (eventsRes.ok) {
+ const data = await eventsRes.json();
+ this.events = (data.results || []).map((e: any) => ({
+ ...e,
+ latitude: e.latitude ?? e.location_lat ?? null,
+ longitude: e.longitude ?? e.location_lng ?? null,
+ }));
+ }
if (sourcesRes.ok) { const data = await sourcesRes.json(); this.sources = data.results || []; }
if (lunarRes.ok) { this.lunarData = await lunarRes.json(); }
if (remindersRes?.ok) { const data = await remindersRes.json(); this.reminders = data.reminders || []; }
diff --git a/modules/rcal/mod.ts b/modules/rcal/mod.ts
index 2e5a22c..490a2ab 100644
--- a/modules/rcal/mod.ts
+++ b/modules/rcal/mod.ts
@@ -985,7 +985,7 @@ routes.get("/", (c) => {
modules: getModuleInfoList(),
theme: "dark",
body: ``,
- scripts: ``,
+ scripts: ``,
styles: `
`,
}));
diff --git a/modules/rmaps/components/folk-map-viewer.ts b/modules/rmaps/components/folk-map-viewer.ts
index 868a8ed..6df9c44 100644
--- a/modules/rmaps/components/folk-map-viewer.ts
+++ b/modules/rmaps/components/folk-map-viewer.ts
@@ -23,8 +23,8 @@ import { startPresenceHeartbeat } from '../../../shared/collab-presence';
// MapLibre loaded via CDN — use window access with type assertion
-const MAPLIBRE_CSS = "https://unpkg.com/maplibre-gl@4.1.2/dist/maplibre-gl.css";
-const MAPLIBRE_JS = "https://unpkg.com/maplibre-gl@4.1.2/dist/maplibre-gl.js";
+const MAPLIBRE_CSS = "https://cdn.jsdelivr.net/npm/maplibre-gl@4.1.2/dist/maplibre-gl.css";
+const MAPLIBRE_JS = "https://cdn.jsdelivr.net/npm/maplibre-gl@4.1.2/dist/maplibre-gl.js";
const OSM_ATTRIBUTION = '© OpenStreetMap contributors';
diff --git a/modules/rmaps/mod.ts b/modules/rmaps/mod.ts
index 68606aa..9e7b050 100644
--- a/modules/rmaps/mod.ts
+++ b/modules/rmaps/mod.ts
@@ -274,7 +274,9 @@ routes.get("/", (c) => {
spaceSlug: space,
modules: getModuleInfoList(),
body: ``,
- scripts: ``,
+ scripts: `
+
+ `,
styles: ``,
}));
});
@@ -291,7 +293,9 @@ routes.get("/:room", (c) => {
modules: getModuleInfoList(),
styles: ``,
body: ``,
- scripts: ``,
+ scripts: `
+
+ `,
}));
});
diff --git a/server/index.ts b/server/index.ts
index e453264..f09ae69 100644
--- a/server/index.ts
+++ b/server/index.ts
@@ -1550,14 +1550,22 @@ app.get("/api/3d-gen/:jobId", async (c) => {
return c.json(response);
});
-// Blender 3D generation via LLM + RunPod
-const RUNPOD_API_KEY = process.env.RUNPOD_API_KEY || "";
+// Blender 3D generation via LLM + headless worker sidecar
+const BLENDER_WORKER_URL = process.env.BLENDER_WORKER_URL || "http://blender-worker:8810";
app.get("/api/blender-gen/health", async (c) => {
const issues: string[] = [];
const warnings: string[] = [];
if (!GEMINI_API_KEY) issues.push("GEMINI_API_KEY not configured");
- if (!RUNPOD_API_KEY) warnings.push("RunPod not configured — script-only mode");
+
+ // Check blender-worker health
+ try {
+ const res = await fetch(`${BLENDER_WORKER_URL}/health`, { signal: AbortSignal.timeout(3000) });
+ if (!res.ok) warnings.push("blender-worker unhealthy");
+ } catch {
+ warnings.push("blender-worker unreachable — script-only mode");
+ }
+
return c.json({ available: issues.length === 0, issues, warnings });
});
@@ -1581,7 +1589,8 @@ The script should:
- Clear the default scene (delete all default objects)
- Create the described objects with materials and colors
- Set up basic lighting (sun + area light) and camera positioned to frame the scene
-- Render to /tmp/render.png at 1024x1024
+- Use Cycles render engine with CPU device: bpy.context.scene.render.engine = "CYCLES" and bpy.context.scene.cycles.device = "CPU" and bpy.context.scene.cycles.samples = 64 and bpy.context.scene.cycles.use_denoising = False
+- Render to /tmp/render.png at 1024x1024 with bpy.context.scene.render.image_settings.file_format = "PNG"
Output ONLY the Python code, no explanations or comments outside the code.`);
@@ -1596,38 +1605,37 @@ Output ONLY the Python code, no explanations or comments outside the code.`);
return c.json({ error: "Failed to generate Blender script" }, 502);
}
- // Step 2: Execute on RunPod (headless Blender) — optional
- if (!RUNPOD_API_KEY) {
- return c.json({ script, render_url: null, blend_url: null });
- }
-
+ // Step 2: Execute on blender-worker sidecar (headless Blender)
try {
- const runpodRes = await fetch("https://api.runpod.ai/v2/blender/runsync", {
+ const workerRes = await fetch(`${BLENDER_WORKER_URL}/render`, {
method: "POST",
- headers: {
- Authorization: `Bearer ${RUNPOD_API_KEY}`,
- "Content-Type": "application/json",
- },
- body: JSON.stringify({
- input: {
- script,
- render: true,
- },
- }),
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ script }),
+ signal: AbortSignal.timeout(95_000), // 95s — worker has 90s internal timeout
});
- if (!runpodRes.ok) {
- return c.json({ script, error_detail: "RunPod execution failed" });
+ const data = await workerRes.json() as {
+ success?: boolean;
+ render_url?: string;
+ error?: string;
+ stdout?: string;
+ stderr?: string;
+ };
+
+ if (data.success && data.render_url) {
+ return c.json({ script, render_url: data.render_url });
}
- const runpodData = await runpodRes.json();
+ // Worker ran but render failed — return script + error details
return c.json({
- render_url: runpodData.output?.render_url || null,
script,
- blend_url: runpodData.output?.blend_url || null,
+ render_url: null,
+ error_detail: data.error || "Render failed",
});
} catch (e) {
- return c.json({ script, error_detail: "RunPod unavailable" });
+ console.error("[blender-gen] worker error:", e);
+ // Worker unreachable — return script only
+ return c.json({ script, render_url: null, error_detail: "blender-worker unavailable" });
}
});
diff --git a/server/mi-routes.ts b/server/mi-routes.ts
index 5ab318d..2269fc5 100644
--- a/server/mi-routes.ts
+++ b/server/mi-routes.ts
@@ -13,6 +13,7 @@ import type { MiMessage } from "./mi-provider";
import { getModuleInfoList, getAllModules } from "../shared/module";
import { resolveCallerRole, roleAtLeast } from "./spaces";
import type { SpaceRoleString } from "./spaces";
+import { loadCommunity, getDocumentData } from "./community-store";
import { verifyToken, extractToken } from "./auth";
import type { EncryptIDClaims } from "./auth";
import { buildModuleCapabilities, MODULE_ROUTES } from "../lib/mi-module-routes";
@@ -58,6 +59,14 @@ mi.post("/ask", async (c) => {
callerRole = "member";
}
+ // ── Resolve space's enabled modules ──
+ let enabledModuleIds: string[] | null = null;
+ if (space) {
+ await loadCommunity(space);
+ const spaceDoc = getDocumentData(space);
+ enabledModuleIds = spaceDoc?.meta?.enabledModules ?? null;
+ }
+
// ── Resolve model ──
const modelId = requestedModel || miRegistry.getDefaultModel();
let providerInfo = miRegistry.resolveModel(modelId);
@@ -75,7 +84,11 @@ mi.post("/ask", async (c) => {
}
// ── Build system prompt ──
- const moduleList = getModuleInfoList()
+ const allModuleInfo = getModuleInfoList();
+ const filteredModuleInfo = enabledModuleIds
+ ? allModuleInfo.filter(m => m.id === "rspace" || enabledModuleIds!.includes(m.id))
+ : allModuleInfo;
+ const moduleList = filteredModuleInfo
.map((m) => `- **${m.name}** (${m.id}): ${m.icon} ${m.description}`)
.join("\n");
@@ -123,8 +136,10 @@ mi.post("/ask", async (c) => {
}
// Module capabilities for enabled modules
- const enabledModuleIds = Object.keys(MODULE_ROUTES);
- const moduleCapabilities = buildModuleCapabilities(enabledModuleIds);
+ const capabilityModuleIds = enabledModuleIds
+ ? Object.keys(MODULE_ROUTES).filter(id => enabledModuleIds!.includes(id))
+ : Object.keys(MODULE_ROUTES);
+ const moduleCapabilities = buildModuleCapabilities(capabilityModuleIds);
// Role-permission mapping
const rolePermissions: Record = {
@@ -285,7 +300,7 @@ Use requireConfirm:true for destructive batches.`;
});
} catch (e: any) {
console.error("mi: Provider error:", e.message);
- const fallback = generateFallbackResponse(query, currentModule, space, getModuleInfoList());
+ const fallback = generateFallbackResponse(query, currentModule, space, filteredModuleInfo);
return c.json({ response: fallback });
}
});
diff --git a/shared/tab-cache.ts b/shared/tab-cache.ts
index 1ac9a2a..0d1713b 100644
--- a/shared/tab-cache.ts
+++ b/shared/tab-cache.ts
@@ -344,6 +344,22 @@ export class TabCache {
const el = document.createElement("script");
el.type = "module";
el.src = src;
+ // If a script fails (502, network error), evict the pane from cache
+ // so the next switchTo re-fetches everything instead of showing a blank pane.
+ el.onerror = () => {
+ console.warn("[TabCache] script load failed:", src, "— evicting pane", moduleId);
+ el.remove();
+ if (moduleId) {
+ // moduleId here is "space-module" format — find and remove the pane
+ for (const [key, pane] of this.panes) {
+ if (pane.dataset.moduleId && moduleId.endsWith(pane.dataset.moduleId)) {
+ pane.remove();
+ this.panes.delete(key);
+ break;
+ }
+ }
+ }
+ };
document.body.appendChild(el);
}
diff --git a/website/canvas.html b/website/canvas.html
index 08dd91e..3a17634 100644
--- a/website/canvas.html
+++ b/website/canvas.html
@@ -2534,36 +2534,32 @@
});
// Load module list for app switcher and tab bar + menu
+ // Parallel fetch: modules + space-specific filter — setModules called once
let moduleList = [];
- fetch("/api/modules").then(r => r.json()).then(data => {
+ const spaceSlug = window.location.pathname.split("/").filter(Boolean)[0] || "demo";
+ Promise.all([
+ fetch("/api/modules").then(r => r.json()),
+ fetch(`/api/spaces/${encodeURIComponent(spaceSlug)}/modules`).then(r => r.ok ? r.json() : null),
+ ]).then(([data, spaceData]) => {
moduleList = data.modules || [];
window.__rspaceAllModules = moduleList;
- document.querySelector("rstack-app-switcher")?.setModules(moduleList);
- const tb = document.querySelector("rstack-tab-bar");
- if (tb) tb.setModules(moduleList);
- // Fetch space-specific enabled modules and apply filtering
- const spaceSlug = window.location.pathname.split("/").filter(Boolean)[0] || "demo";
- fetch(`/api/spaces/${encodeURIComponent(spaceSlug)}/modules`)
- .then(r => r.ok ? r.json() : null)
- .then(spaceData => {
- if (!spaceData) return;
- const enabledIds = spaceData.enabledModules; // null = all
- window.__rspaceEnabledModules = enabledIds;
- if (enabledIds) {
- const enabledSet = new Set(enabledIds);
- const filtered = moduleList.filter(m => m.id === "rspace" || enabledSet.has(m.id));
- document.querySelector("rstack-app-switcher")?.setModules(filtered);
- const tb2 = document.querySelector("rstack-tab-bar");
- if (tb2) tb2.setModules(filtered);
- }
- // Initialize folk-rapp filtering
- customElements.whenDefined("folk-rapp").then(() => {
- const FolkRApp = customElements.get("folk-rapp");
- if (FolkRApp?.setEnabledModules) FolkRApp.setEnabledModules(enabledIds);
- });
- })
- .catch(() => {});
+ const enabledIds = spaceData?.enabledModules ?? null;
+ window.__rspaceEnabledModules = enabledIds;
+
+ const visible = enabledIds
+ ? moduleList.filter(m => m.id === "rspace" || new Set(enabledIds).has(m.id))
+ : moduleList;
+
+ document.querySelector("rstack-app-switcher")?.setModules(visible);
+ const tb = document.querySelector("rstack-tab-bar");
+ if (tb) tb.setModules(visible);
+
+ // Initialize folk-rapp filtering
+ customElements.whenDefined("folk-rapp").then(() => {
+ const FolkRApp = customElements.get("folk-rapp");
+ if (FolkRApp?.setEnabledModules) FolkRApp.setEnabledModules(enabledIds);
+ });
}).catch(() => {});
// React to runtime module toggling from app switcher