fix(rnetwork): add auth headers to graph viewer API calls; add Blender multi-user

- Fix 401 errors on rNetwork by passing encryptid-token as Bearer auth
  on /api/info, /api/graph, /api/workspaces fetch calls
- Add blender-multiuser replication server (multi-user-server:0.5.8)
  to docker-compose with health check and resource limits
- Add Multiplayer tab to folk-blender shape with connection info,
  server status check, and setup instructions
- Add /api/blender-multiuser/status endpoint for TCP health probe

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-04-13 23:32:33 -04:00
parent dc30b8f2e6
commit 70cb919541
4 changed files with 209 additions and 5 deletions

View File

@ -283,6 +283,33 @@ services:
retries: 5
start_period: 10s
# ── Blender Multi-User replication server (always-on, persistent TCP) ──
blender-multiuser:
image: registry.gitlab.com/slumber/multi-user/multi-user-server:0.5.8
container_name: blender-multiuser
restart: unless-stopped
mem_limit: 512m
cpus: 1
ports:
- "5555:5555"
- "5556:5556"
- "5557:5557"
- "5558:5558"
environment:
- port=5555
- password=${BLENDER_MULTIUSER_PASSWORD}
- timeout=5000
- log_level=INFO
- log_file=multiuser_server.log
networks:
- rspace-internal
healthcheck:
test: ["CMD-SHELL", "python3 -c 'import socket; s=socket.socket(); s.settimeout(2); s.connect((\"localhost\",5555)); s.close()' || exit 1"]
interval: 30s
timeout: 5s
retries: 3
start_period: 10s
# ── On-demand sidecars (started/stopped by server/sidecar-manager.ts) ──
# Build: docker compose --profile sidecar build
# Create: docker compose --profile sidecar create

View File

@ -289,6 +289,94 @@ const styles = css`
font-size: 13px;
margin: 12px;
}
.multiplayer-area {
flex: 1;
overflow: auto;
padding: 12px;
display: none;
}
.mp-status {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 12px;
font-size: 13px;
font-weight: 600;
}
.mp-dot {
width: 10px;
height: 10px;
border-radius: 50%;
background: #94a3b8;
}
.mp-dot.online { background: #22c55e; }
.mp-dot.offline { background: #ef4444; }
.mp-field {
margin-bottom: 10px;
}
.mp-field label {
display: block;
font-size: 11px;
font-weight: 600;
color: #64748b;
margin-bottom: 4px;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.mp-conn {
display: flex;
gap: 6px;
}
.mp-conn input {
flex: 1;
padding: 7px 10px;
border: 1px solid #e2e8f0;
border-radius: 6px;
font-size: 13px;
font-family: monospace;
background: #f8fafc;
color: #1e293b;
}
.mp-copy-btn {
padding: 7px 12px;
border: 1px solid #e2e8f0;
border-radius: 6px;
background: #f8fafc;
cursor: pointer;
font-size: 13px;
}
.mp-copy-btn:hover { background: #e2e8f0; }
.mp-instructions {
font-size: 12px;
color: #64748b;
line-height: 1.6;
margin-top: 12px;
padding: 10px;
background: #f8fafc;
border-radius: 6px;
border: 1px solid #e2e8f0;
}
.mp-instructions ol {
margin: 4px 0 0 16px;
padding: 0;
}
.mp-instructions a {
color: #ea580c;
text-decoration: underline;
}
`;
declare global {
@ -318,11 +406,12 @@ export class FolkBlender extends FolkShape {
#renderUrl: string | null = null;
#script: string | null = null;
#blendUrl: string | null = null;
#activeTab: "preview" | "code" = "preview";
#activeTab: "preview" | "code" | "multiplayer" = "preview";
#promptInput: HTMLTextAreaElement | null = null;
#generateBtn: HTMLButtonElement | null = null;
#previewArea: HTMLElement | null = null;
#codeArea: HTMLElement | null = null;
#multiplayerArea: HTMLElement | null = null;
#downloadRow: HTMLElement | null = null;
override createRenderRoot() {
@ -350,6 +439,7 @@ export class FolkBlender extends FolkShape {
<div class="tabs">
<button class="tab active" data-tab="preview">🖼 Render</button>
<button class="tab" data-tab="code">📜 Script</button>
<button class="tab" data-tab="multiplayer">🌐 Multiplayer</button>
</div>
<div class="preview-area">
<div class="render-preview">
@ -361,6 +451,28 @@ export class FolkBlender extends FolkShape {
<div class="code-area" style="display:none">
<pre>// Blender Python script will appear here</pre>
</div>
<div class="multiplayer-area">
<div class="mp-status">
<span class="mp-dot"></span>
<span class="mp-status-text">Checking...</span>
</div>
<div class="mp-field">
<label>Connection</label>
<div class="mp-conn">
<input class="mp-conn-input" readonly value="" />
<button class="mp-copy-btn" title="Copy">📋</button>
</div>
</div>
<div class="mp-instructions">
<strong>How to connect:</strong>
<ol>
<li>Install <a href="https://extensions.blender.org/add-ons/multi-user/" target="_blank" rel="noopener">Multi-User addon</a> in Blender 4.3+</li>
<li>Open sidebar Multi-User tab</li>
<li>Paste the connection address above</li>
<li>Click Connect you're in the shared session!</li>
</ol>
</div>
</div>
</div>
<div class="download-row" style="display:none">
<a class="download-btn blend-dl" href="#" download> .blend</a>
@ -378,6 +490,7 @@ export class FolkBlender extends FolkShape {
this.#generateBtn = wrapper.querySelector(".generate-btn");
this.#previewArea = wrapper.querySelector(".render-preview");
this.#codeArea = wrapper.querySelector(".code-area");
this.#multiplayerArea = wrapper.querySelector(".multiplayer-area");
this.#downloadRow = wrapper.querySelector(".download-row");
const closeBtn = wrapper.querySelector(".close-btn") as HTMLButtonElement;
@ -385,12 +498,14 @@ export class FolkBlender extends FolkShape {
wrapper.querySelectorAll(".tab").forEach((tab) => {
tab.addEventListener("click", (e) => {
e.stopPropagation();
const tabName = (tab as HTMLElement).dataset.tab as "preview" | "code";
const tabName = (tab as HTMLElement).dataset.tab as "preview" | "code" | "multiplayer";
this.#activeTab = tabName;
wrapper.querySelectorAll(".tab").forEach((t) => t.classList.remove("active"));
tab.classList.add("active");
if (this.#previewArea) this.#previewArea.style.display = tabName === "preview" ? "flex" : "none";
if (this.#codeArea) this.#codeArea.style.display = tabName === "code" ? "block" : "none";
if (this.#multiplayerArea) this.#multiplayerArea.style.display = tabName === "multiplayer" ? "block" : "none";
if (tabName === "multiplayer") this.#fetchMultiplayerStatus();
});
});
@ -430,9 +545,42 @@ export class FolkBlender extends FolkShape {
}
}).catch(() => {});
// Multiplayer copy button
const copyBtn = wrapper.querySelector(".mp-copy-btn") as HTMLButtonElement;
const connInput = wrapper.querySelector(".mp-conn-input") as HTMLInputElement;
copyBtn?.addEventListener("click", (e) => {
e.stopPropagation();
if (connInput?.value) {
navigator.clipboard.writeText(connInput.value).then(() => {
copyBtn.textContent = "✅";
setTimeout(() => { copyBtn.textContent = "📋"; }, 1500);
});
}
});
return root;
}
async #fetchMultiplayerStatus() {
const dot = this.#multiplayerArea?.querySelector(".mp-dot");
const statusText = this.#multiplayerArea?.querySelector(".mp-status-text");
const connInput = this.#multiplayerArea?.querySelector(".mp-conn-input") as HTMLInputElement | null;
try {
const res = await fetch("/api/blender-multiuser/status");
const data = await res.json() as { available: boolean; host: string; port: number };
if (dot) {
dot.classList.toggle("online", data.available);
dot.classList.toggle("offline", !data.available);
}
if (statusText) statusText.textContent = data.available ? "Server Online" : "Server Offline";
if (connInput) connInput.value = `${data.host}:${data.port}`;
} catch {
if (dot) { dot.classList.remove("online"); dot.classList.add("offline"); }
if (statusText) statusText.textContent = "Unable to check";
}
}
async #generate() {
const prompt = this.#promptInput?.value.trim();
if (!prompt || this.#isLoading) return;

View File

@ -280,10 +280,12 @@ class FolkGraphViewer extends HTMLElement {
}
} catch { /* storage unavailable */ }
const token = localStorage.getItem("encryptid-token");
const headers: Record<string, string> = token ? { Authorization: `Bearer ${token}` } : {};
const [wsRes, infoRes, graphRes] = await Promise.all([
fetch(`${base}/api/workspaces`),
fetch(`${base}/api/info`),
graphData ? Promise.resolve(null) : fetch(`${base}/api/graph${trustParam}`),
fetch(`${base}/api/workspaces`, { headers }),
fetch(`${base}/api/info`, { headers }),
graphData ? Promise.resolve(null) : fetch(`${base}/api/graph${trustParam}`, { headers }),
]);
if (wsRes.ok) this.workspaces = await wsRes.json();
if (infoRes.ok) this.info = await infoRes.json();

View File

@ -2121,6 +2121,33 @@ Output ONLY the Python code, no explanations or comments outside the code.`);
}
});
// ── Blender Multi-User server status ──
const MULTIUSER_HOST = process.env.MULTIUSER_HOST || "159.195.32.209";
const MULTIUSER_PORT = 5555;
app.get("/api/blender-multiuser/status", async (c) => {
let available = false;
try {
const net = await import("node:net");
available = await new Promise<boolean>((resolve) => {
const sock = net.createConnection({ host: "blender-multiuser", port: MULTIUSER_PORT }, () => {
sock.destroy();
resolve(true);
});
sock.setTimeout(2000);
sock.on("timeout", () => { sock.destroy(); resolve(false); });
sock.on("error", () => resolve(false));
});
} catch {}
return c.json({
available,
host: MULTIUSER_HOST,
port: MULTIUSER_PORT,
instructions: "Install Multi-User addon from extensions.blender.org, connect to host:port in Blender",
});
});
// ── Design Agent (Scribus DTP via Gemini tool loop) ──
const SCRIBUS_BRIDGE_URL = process.env.SCRIBUS_BRIDGE_URL || "http://scribus-novnc:8765";
const SCRIBUS_BRIDGE_SECRET = process.env.SCRIBUS_BRIDGE_SECRET || "";