From 44a69e665ea5d2df55b762d65c269068c757e4d1 Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Wed, 25 Mar 2026 17:03:21 -0700 Subject: [PATCH 1/6] perf(rmaps): preload MapLibre GL, switch CDN from unpkg to jsDelivr Add hints for MapLibre JS+CSS in the module HTML so the browser starts fetching them in parallel with the main bundle, instead of waiting until joinRoom() calls loadMapLibre(). Switch from unpkg (slow, no HTTP/2) to jsDelivr (faster edge caching). Co-Authored-By: Claude Opus 4.6 --- modules/rmaps/components/folk-map-viewer.ts | 4 ++-- modules/rmaps/mod.ts | 8 ++++++-- 2 files changed, 8 insertions(+), 4 deletions(-) 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: ` + + `, })); }); From aa02473d0d5f2d95e324f05956d35d65c3dd9b3e Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Wed, 25 Mar 2026 17:10:23 -0700 Subject: [PATCH 2/6] fix(shell): evict tab pane from cache on script load failure When a module script (e.g. canvas-*.js) fails to load (502 during deploy, network error), the pane stayed in cache with a blank canvas. Subsequent tab switches showed the broken cached pane instead of re-fetching. Now script onerror removes the failed tag and evicts the pane, so the next switchTo does a fresh fetch. Co-Authored-By: Claude Opus 4.6 --- docker-compose.yml | 10 +++ docker/blender-worker/Dockerfile | 20 ++++++ docker/blender-worker/server.py | 104 +++++++++++++++++++++++++++++++ server/index.ts | 57 +++++++++-------- shared/tab-cache.ts | 16 +++++ 5 files changed, 182 insertions(+), 25 deletions(-) create mode 100644 docker/blender-worker/Dockerfile create mode 100644 docker/blender-worker/server.py 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..93e9e7a --- /dev/null +++ b/docker/blender-worker/Dockerfile @@ -0,0 +1,20 @@ +FROM debian:bookworm-slim + +RUN apt-get update && apt-get install -y --no-install-recommends \ + blender \ + python3 \ + ca-certificates \ + && 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..a119b02 --- /dev/null +++ b/docker/blender-worker/server.py @@ -0,0 +1,104 @@ +"""Headless Blender render worker — accepts scripts via HTTP, returns rendered images.""" + +import json +import os +import random +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) + os.rename(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/server/index.ts b/server/index.ts index e453264..9daf09d 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 }); }); @@ -1596,38 +1604,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/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); } From ba56697e23e0858a7f610ae024a76d0d77038b3f Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Wed, 25 Mar 2026 17:10:44 -0700 Subject: [PATCH 3/6] fix(rcal): show calendar event locations on map in all spaces, not just demo MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit REST API events use location_lat/location_lng while the map panel filters on latitude/longitude. Demo events set both, but non-demo events only had location_lat/location_lng — so the map was always empty outside demo. Normalize both REST and Automerge event data to include latitude/ longitude aliases. Co-Authored-By: Claude Opus 4.6 --- modules/rcal/components/folk-calendar-view.ts | 11 ++++++++++- modules/rcal/mod.ts | 2 +- 2 files changed, 11 insertions(+), 2 deletions(-) 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: ` `, })); From 555a51f8a74072268c742a374bbf9d5e063b29de Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Wed, 25 Mar 2026 17:17:34 -0700 Subject: [PATCH 4/6] fix(blender): add EGL/GL libs and use Cycles CPU renderer EEVEE needs GPU; Cycles CPU works headless. Added libegl1, libgl1-mesa-dri, libglx-mesa0 to Dockerfile. Updated Gemini prompt to specify Cycles engine with 64 samples. Co-Authored-By: Claude Opus 4.6 --- docker/blender-worker/Dockerfile | 3 +++ server/index.ts | 3 ++- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/docker/blender-worker/Dockerfile b/docker/blender-worker/Dockerfile index 93e9e7a..28c6a62 100644 --- a/docker/blender-worker/Dockerfile +++ b/docker/blender-worker/Dockerfile @@ -4,6 +4,9 @@ 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 diff --git a/server/index.ts b/server/index.ts index 9daf09d..05cf73b 100644 --- a/server/index.ts +++ b/server/index.ts @@ -1589,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 +- 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.`); From ac028cbe04005282712ef04ed57a0d6e01c6883c Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Wed, 25 Mar 2026 17:21:53 -0700 Subject: [PATCH 5/6] fix(blender): use shutil.move for cross-fs copy, disable denoiser MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit os.rename fails across Docker volume boundaries (different filesystems). Debian Blender 3.4 lacks OpenImageDenoiser — disable denoising in prompt. Co-Authored-By: Claude Opus 4.6 --- docker/blender-worker/server.py | 3 ++- server/index.ts | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/docker/blender-worker/server.py b/docker/blender-worker/server.py index a119b02..acd36f8 100644 --- a/docker/blender-worker/server.py +++ b/docker/blender-worker/server.py @@ -3,6 +3,7 @@ import json import os import random +import shutil import string import subprocess import time @@ -78,7 +79,7 @@ class RenderHandler(BaseHTTPRequestHandler): dest = os.path.join(GENERATED_DIR, filename) os.makedirs(GENERATED_DIR, exist_ok=True) - os.rename(render_path, dest) + shutil.move(render_path, dest) self._json_response(200, { "success": True, diff --git a/server/index.ts b/server/index.ts index 05cf73b..f09ae69 100644 --- a/server/index.ts +++ b/server/index.ts @@ -1589,7 +1589,7 @@ 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 -- 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 +- 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.`); From 8d4e1fd0ff9cd3684063e8c721689e528f3ac2d9 Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Wed, 25 Mar 2026 17:26:07 -0700 Subject: [PATCH 6/6] fix(mi,canvas): filter disabled modules from MI assistant and eliminate app-switcher flash MI now loads space doc to filter module list, capabilities, and fallback by enabledModules. Canvas fetches /api/modules and space modules in parallel via Promise.all, calling setModules once with filtered list. Co-Authored-By: Claude Opus 4.6 --- server/mi-routes.ts | 23 ++++++++++++++++++---- website/canvas.html | 48 +++++++++++++++++++++------------------------ 2 files changed, 41 insertions(+), 30 deletions(-) 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/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