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