Merge branch 'dev'
This commit is contained in:
commit
ba7795a171
|
|
@ -282,6 +282,16 @@ services:
|
||||||
networks:
|
networks:
|
||||||
- rspace-internal
|
- 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 (rDesign DTP workspace) ──
|
||||||
scribus-novnc:
|
scribus-novnc:
|
||||||
build:
|
build:
|
||||||
|
|
|
||||||
|
|
@ -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"]
|
||||||
|
|
@ -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()
|
||||||
|
|
@ -261,6 +261,8 @@ class FolkCalendarView extends HTMLElement {
|
||||||
source_name: e.sourceName, source_color: e.sourceColor,
|
source_name: e.sourceName, source_color: e.sourceColor,
|
||||||
location_name: e.locationName,
|
location_name: e.locationName,
|
||||||
location_lat: e.locationLat, location_lng: e.locationLng,
|
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
|
// Only use doc events if REST hasn't loaded yet
|
||||||
if (this.events.length === 0 && docEvents.length > 0) {
|
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(`${base}/api/lunar?start=${start}&end=${end}`),
|
||||||
fetch(`${schedBase}/api/reminders?upcoming=true`).catch(() => null),
|
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 (sourcesRes.ok) { const data = await sourcesRes.json(); this.sources = data.results || []; }
|
||||||
if (lunarRes.ok) { this.lunarData = await lunarRes.json(); }
|
if (lunarRes.ok) { this.lunarData = await lunarRes.json(); }
|
||||||
if (remindersRes?.ok) { const data = await remindersRes.json(); this.reminders = data.reminders || []; }
|
if (remindersRes?.ok) { const data = await remindersRes.json(); this.reminders = data.reminders || []; }
|
||||||
|
|
|
||||||
|
|
@ -985,7 +985,7 @@ routes.get("/", (c) => {
|
||||||
modules: getModuleInfoList(),
|
modules: getModuleInfoList(),
|
||||||
theme: "dark",
|
theme: "dark",
|
||||||
body: `<folk-calendar-view space="${space}"></folk-calendar-view>`,
|
body: `<folk-calendar-view space="${space}"></folk-calendar-view>`,
|
||||||
scripts: `<script type="module" src="/modules/rcal/folk-calendar-view.js?v=3"></script>`,
|
scripts: `<script type="module" src="/modules/rcal/folk-calendar-view.js?v=4"></script>`,
|
||||||
styles: `<link rel="stylesheet" href="/modules/rcal/cal.css">
|
styles: `<link rel="stylesheet" href="/modules/rcal/cal.css">
|
||||||
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" crossorigin="">`,
|
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" crossorigin="">`,
|
||||||
}));
|
}));
|
||||||
|
|
|
||||||
|
|
@ -23,8 +23,8 @@ import { startPresenceHeartbeat } from '../../../shared/collab-presence';
|
||||||
|
|
||||||
// MapLibre loaded via CDN — use window access with type assertion
|
// 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_CSS = "https://cdn.jsdelivr.net/npm/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_JS = "https://cdn.jsdelivr.net/npm/maplibre-gl@4.1.2/dist/maplibre-gl.js";
|
||||||
|
|
||||||
const OSM_ATTRIBUTION = '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors';
|
const OSM_ATTRIBUTION = '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors';
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -274,7 +274,9 @@ routes.get("/", (c) => {
|
||||||
spaceSlug: space,
|
spaceSlug: space,
|
||||||
modules: getModuleInfoList(),
|
modules: getModuleInfoList(),
|
||||||
body: `<folk-map-viewer space="${space}"></folk-map-viewer>`,
|
body: `<folk-map-viewer space="${space}"></folk-map-viewer>`,
|
||||||
scripts: `<script type="module" src="/modules/rmaps/folk-map-viewer.js?v=4"></script>`,
|
scripts: `<link rel="preload" href="https://cdn.jsdelivr.net/npm/maplibre-gl@4.1.2/dist/maplibre-gl.js" as="script">
|
||||||
|
<link rel="preload" href="https://cdn.jsdelivr.net/npm/maplibre-gl@4.1.2/dist/maplibre-gl.css" as="style">
|
||||||
|
<script type="module" src="/modules/rmaps/folk-map-viewer.js?v=5"></script>`,
|
||||||
styles: `<link rel="stylesheet" href="/modules/rmaps/maps.css?v=3">`,
|
styles: `<link rel="stylesheet" href="/modules/rmaps/maps.css?v=3">`,
|
||||||
}));
|
}));
|
||||||
});
|
});
|
||||||
|
|
@ -291,7 +293,9 @@ routes.get("/:room", (c) => {
|
||||||
modules: getModuleInfoList(),
|
modules: getModuleInfoList(),
|
||||||
styles: `<link rel="stylesheet" href="/modules/rmaps/maps.css?v=3">`,
|
styles: `<link rel="stylesheet" href="/modules/rmaps/maps.css?v=3">`,
|
||||||
body: `<folk-map-viewer space="${space}" room="${room}"></folk-map-viewer>`,
|
body: `<folk-map-viewer space="${space}" room="${room}"></folk-map-viewer>`,
|
||||||
scripts: `<script type="module" src="/modules/rmaps/folk-map-viewer.js?v=4"></script>`,
|
scripts: `<link rel="preload" href="https://cdn.jsdelivr.net/npm/maplibre-gl@4.1.2/dist/maplibre-gl.js" as="script">
|
||||||
|
<link rel="preload" href="https://cdn.jsdelivr.net/npm/maplibre-gl@4.1.2/dist/maplibre-gl.css" as="style">
|
||||||
|
<script type="module" src="/modules/rmaps/folk-map-viewer.js?v=5"></script>`,
|
||||||
}));
|
}));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1550,14 +1550,22 @@ app.get("/api/3d-gen/:jobId", async (c) => {
|
||||||
return c.json(response);
|
return c.json(response);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Blender 3D generation via LLM + RunPod
|
// Blender 3D generation via LLM + headless worker sidecar
|
||||||
const RUNPOD_API_KEY = process.env.RUNPOD_API_KEY || "";
|
const BLENDER_WORKER_URL = process.env.BLENDER_WORKER_URL || "http://blender-worker:8810";
|
||||||
|
|
||||||
app.get("/api/blender-gen/health", async (c) => {
|
app.get("/api/blender-gen/health", async (c) => {
|
||||||
const issues: string[] = [];
|
const issues: string[] = [];
|
||||||
const warnings: string[] = [];
|
const warnings: string[] = [];
|
||||||
if (!GEMINI_API_KEY) issues.push("GEMINI_API_KEY not configured");
|
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 });
|
return c.json({ available: issues.length === 0, issues, warnings });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -1581,7 +1589,8 @@ The script should:
|
||||||
- Clear the default scene (delete all default objects)
|
- Clear the default scene (delete all default objects)
|
||||||
- Create the described objects with materials and colors
|
- Create the described objects with materials and colors
|
||||||
- Set up basic lighting (sun + area light) and camera positioned to frame the scene
|
- 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.`);
|
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);
|
return c.json({ error: "Failed to generate Blender script" }, 502);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step 2: Execute on RunPod (headless Blender) — optional
|
// Step 2: Execute on blender-worker sidecar (headless Blender)
|
||||||
if (!RUNPOD_API_KEY) {
|
|
||||||
return c.json({ script, render_url: null, blend_url: null });
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const runpodRes = await fetch("https://api.runpod.ai/v2/blender/runsync", {
|
const workerRes = await fetch(`${BLENDER_WORKER_URL}/render`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: { "Content-Type": "application/json" },
|
||||||
Authorization: `Bearer ${RUNPOD_API_KEY}`,
|
body: JSON.stringify({ script }),
|
||||||
"Content-Type": "application/json",
|
signal: AbortSignal.timeout(95_000), // 95s — worker has 90s internal timeout
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
input: {
|
|
||||||
script,
|
|
||||||
render: true,
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!runpodRes.ok) {
|
const data = await workerRes.json() as {
|
||||||
return c.json({ script, error_detail: "RunPod execution failed" });
|
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({
|
return c.json({
|
||||||
render_url: runpodData.output?.render_url || null,
|
|
||||||
script,
|
script,
|
||||||
blend_url: runpodData.output?.blend_url || null,
|
render_url: null,
|
||||||
|
error_detail: data.error || "Render failed",
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} 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" });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ import type { MiMessage } from "./mi-provider";
|
||||||
import { getModuleInfoList, getAllModules } from "../shared/module";
|
import { getModuleInfoList, getAllModules } from "../shared/module";
|
||||||
import { resolveCallerRole, roleAtLeast } from "./spaces";
|
import { resolveCallerRole, roleAtLeast } from "./spaces";
|
||||||
import type { SpaceRoleString } from "./spaces";
|
import type { SpaceRoleString } from "./spaces";
|
||||||
|
import { loadCommunity, getDocumentData } from "./community-store";
|
||||||
import { verifyToken, extractToken } from "./auth";
|
import { verifyToken, extractToken } from "./auth";
|
||||||
import type { EncryptIDClaims } from "./auth";
|
import type { EncryptIDClaims } from "./auth";
|
||||||
import { buildModuleCapabilities, MODULE_ROUTES } from "../lib/mi-module-routes";
|
import { buildModuleCapabilities, MODULE_ROUTES } from "../lib/mi-module-routes";
|
||||||
|
|
@ -58,6 +59,14 @@ mi.post("/ask", async (c) => {
|
||||||
callerRole = "member";
|
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 ──
|
// ── Resolve model ──
|
||||||
const modelId = requestedModel || miRegistry.getDefaultModel();
|
const modelId = requestedModel || miRegistry.getDefaultModel();
|
||||||
let providerInfo = miRegistry.resolveModel(modelId);
|
let providerInfo = miRegistry.resolveModel(modelId);
|
||||||
|
|
@ -75,7 +84,11 @@ mi.post("/ask", async (c) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Build system prompt ──
|
// ── 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}`)
|
.map((m) => `- **${m.name}** (${m.id}): ${m.icon} ${m.description}`)
|
||||||
.join("\n");
|
.join("\n");
|
||||||
|
|
||||||
|
|
@ -123,8 +136,10 @@ mi.post("/ask", async (c) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Module capabilities for enabled modules
|
// Module capabilities for enabled modules
|
||||||
const enabledModuleIds = Object.keys(MODULE_ROUTES);
|
const capabilityModuleIds = enabledModuleIds
|
||||||
const moduleCapabilities = buildModuleCapabilities(enabledModuleIds);
|
? Object.keys(MODULE_ROUTES).filter(id => enabledModuleIds!.includes(id))
|
||||||
|
: Object.keys(MODULE_ROUTES);
|
||||||
|
const moduleCapabilities = buildModuleCapabilities(capabilityModuleIds);
|
||||||
|
|
||||||
// Role-permission mapping
|
// Role-permission mapping
|
||||||
const rolePermissions: Record<SpaceRoleString, string> = {
|
const rolePermissions: Record<SpaceRoleString, string> = {
|
||||||
|
|
@ -285,7 +300,7 @@ Use requireConfirm:true for destructive batches.`;
|
||||||
});
|
});
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
console.error("mi: Provider error:", e.message);
|
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 });
|
return c.json({ response: fallback });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -344,6 +344,22 @@ export class TabCache {
|
||||||
const el = document.createElement("script");
|
const el = document.createElement("script");
|
||||||
el.type = "module";
|
el.type = "module";
|
||||||
el.src = src;
|
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);
|
document.body.appendChild(el);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2534,36 +2534,32 @@
|
||||||
});
|
});
|
||||||
|
|
||||||
// Load module list for app switcher and tab bar + menu
|
// Load module list for app switcher and tab bar + menu
|
||||||
|
// Parallel fetch: modules + space-specific filter — setModules called once
|
||||||
let moduleList = [];
|
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 || [];
|
moduleList = data.modules || [];
|
||||||
window.__rspaceAllModules = moduleList;
|
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 enabledIds = spaceData?.enabledModules ?? null;
|
||||||
const spaceSlug = window.location.pathname.split("/").filter(Boolean)[0] || "demo";
|
window.__rspaceEnabledModules = enabledIds;
|
||||||
fetch(`/api/spaces/${encodeURIComponent(spaceSlug)}/modules`)
|
|
||||||
.then(r => r.ok ? r.json() : null)
|
const visible = enabledIds
|
||||||
.then(spaceData => {
|
? moduleList.filter(m => m.id === "rspace" || new Set(enabledIds).has(m.id))
|
||||||
if (!spaceData) return;
|
: moduleList;
|
||||||
const enabledIds = spaceData.enabledModules; // null = all
|
|
||||||
window.__rspaceEnabledModules = enabledIds;
|
document.querySelector("rstack-app-switcher")?.setModules(visible);
|
||||||
if (enabledIds) {
|
const tb = document.querySelector("rstack-tab-bar");
|
||||||
const enabledSet = new Set(enabledIds);
|
if (tb) tb.setModules(visible);
|
||||||
const filtered = moduleList.filter(m => m.id === "rspace" || enabledSet.has(m.id));
|
|
||||||
document.querySelector("rstack-app-switcher")?.setModules(filtered);
|
// Initialize folk-rapp filtering
|
||||||
const tb2 = document.querySelector("rstack-tab-bar");
|
customElements.whenDefined("folk-rapp").then(() => {
|
||||||
if (tb2) tb2.setModules(filtered);
|
const FolkRApp = customElements.get("folk-rapp");
|
||||||
}
|
if (FolkRApp?.setEnabledModules) FolkRApp.setEnabledModules(enabledIds);
|
||||||
// Initialize folk-rapp filtering
|
});
|
||||||
customElements.whenDefined("folk-rapp").then(() => {
|
|
||||||
const FolkRApp = customElements.get("folk-rapp");
|
|
||||||
if (FolkRApp?.setEnabledModules) FolkRApp.setEnabledModules(enabledIds);
|
|
||||||
});
|
|
||||||
})
|
|
||||||
.catch(() => {});
|
|
||||||
}).catch(() => {});
|
}).catch(() => {});
|
||||||
|
|
||||||
// React to runtime module toggling from app switcher
|
// React to runtime module toggling from app switcher
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue