diff --git a/docker-compose.yml b/docker-compose.yml
index bb8ccfd..1957fba 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -56,6 +56,9 @@ services:
- LISTMONK_URL=https://newsletter.cosmolocal.world
- NOTEBOOK_API_URL=http://open-notebook:5055
- SPLIT_360_URL=http://video360-splitter:5000
+ - SCRIBUS_BRIDGE_URL=http://scribus-novnc:8765
+ - SCRIBUS_BRIDGE_SECRET=${SCRIBUS_BRIDGE_SECRET}
+ - SCRIBUS_NOVNC_URL=https://design.rspace.online
depends_on:
rspace-db:
condition: service_healthy
@@ -259,6 +262,32 @@ services:
retries: 5
start_period: 10s
+ # ── Scribus noVNC (rDesign DTP workspace) ──
+ scribus-novnc:
+ build:
+ context: ./docker/scribus-novnc
+ container_name: scribus-novnc
+ restart: unless-stopped
+ volumes:
+ - scribus-designs:/data/designs
+ environment:
+ - BRIDGE_SECRET=${SCRIBUS_BRIDGE_SECRET}
+ - BRIDGE_PORT=8765
+ - NOVNC_PORT=6080
+ - SCREEN_WIDTH=1920
+ - SCREEN_HEIGHT=1080
+ - SCREEN_DEPTH=24
+ labels:
+ - "traefik.enable=true"
+ - "traefik.http.routers.scribus-novnc.rule=Host(`design.rspace.online`)"
+ - "traefik.http.routers.scribus-novnc.entrypoints=web"
+ - "traefik.http.routers.scribus-novnc.priority=150"
+ - "traefik.http.services.scribus-novnc.loadbalancer.server.port=6080"
+ - "traefik.docker.network=traefik-public"
+ networks:
+ - traefik-public
+ - rspace-internal
+
# ── Open Notebook (NotebookLM-like RAG service) ──
open-notebook:
image: ghcr.io/lfnovo/open-notebook:v1-latest-single
@@ -295,6 +324,7 @@ volumes:
rspace-backups:
rspace-pgdata:
encryptid-pgdata:
+ scribus-designs:
open-notebook-data:
open-notebook-db:
diff --git a/docker/scribus-novnc/Dockerfile b/docker/scribus-novnc/Dockerfile
new file mode 100644
index 0000000..2b38f9c
--- /dev/null
+++ b/docker/scribus-novnc/Dockerfile
@@ -0,0 +1,51 @@
+FROM ubuntu:22.04
+
+ENV DEBIAN_FRONTEND=noninteractive \
+ DISPLAY=:1 \
+ VNC_PORT=5900 \
+ NOVNC_PORT=6080 \
+ BRIDGE_PORT=8765 \
+ SCREEN_WIDTH=1920 \
+ SCREEN_HEIGHT=1080 \
+ SCREEN_DEPTH=24
+
+# System packages: Scribus, Xvfb, VNC, noVNC, Python, supervisor
+RUN apt-get update && apt-get install -y --no-install-recommends \
+ scribus \
+ xvfb \
+ x11vnc \
+ novnc \
+ websockify \
+ supervisor \
+ python3 \
+ python3-pip \
+ fonts-liberation \
+ fonts-dejavu \
+ wget \
+ curl \
+ procps \
+ && rm -rf /var/lib/apt/lists/*
+
+# Python bridge dependencies
+COPY bridge/requirements.txt /opt/bridge/requirements.txt
+RUN pip3 install --no-cache-dir -r /opt/bridge/requirements.txt
+
+# Copy bridge server and Scribus runner
+COPY bridge/ /opt/bridge/
+
+# Supervisord config
+COPY supervisord.conf /etc/supervisor/conf.d/supervisord.conf
+
+# Startup script
+COPY startup.sh /opt/startup.sh
+RUN chmod +x /opt/startup.sh
+
+# Data directory for design files
+RUN mkdir -p /data/designs
+
+EXPOSE ${NOVNC_PORT} ${BRIDGE_PORT}
+
+HEALTHCHECK --interval=30s --timeout=5s --retries=3 \
+ CMD curl -sf http://localhost:${BRIDGE_PORT}/health || exit 1
+
+ENTRYPOINT ["/opt/startup.sh"]
diff --git a/docker/scribus-novnc/bridge/requirements.txt b/docker/scribus-novnc/bridge/requirements.txt
new file mode 100644
index 0000000..3b7e753
--- /dev/null
+++ b/docker/scribus-novnc/bridge/requirements.txt
@@ -0,0 +1,3 @@
+flask==3.1.0
+flask-socketio==5.4.1
+watchdog==6.0.0
diff --git a/docker/scribus-novnc/bridge/scribus_runner.py b/docker/scribus-novnc/bridge/scribus_runner.py
new file mode 100644
index 0000000..b5b69d2
--- /dev/null
+++ b/docker/scribus-novnc/bridge/scribus_runner.py
@@ -0,0 +1,339 @@
+"""
+Scribus Bridge Runner — runs inside the Scribus Python scripting environment.
+
+Listens on a Unix socket for JSON commands from the Flask bridge server
+and dispatches them to the Scribus Python API.
+
+Launched via: scribus --python-script scribus_runner.py
+"""
+
+import json
+import os
+import socket
+import sys
+import threading
+import traceback
+from pathlib import Path
+
+try:
+ import scribus
+except ImportError:
+ # Running outside Scribus for testing
+ scribus = None
+ print("[runner] WARNING: scribus module not available (running outside Scribus?)")
+
+SOCKET_PATH = "/tmp/scribus_bridge.sock"
+DESIGNS_DIR = Path("/data/designs")
+SCREENSHOT_DIR = Path("/tmp/scribus_screenshots")
+
+
+def _ensure_dirs():
+ DESIGNS_DIR.mkdir(parents=True, exist_ok=True)
+ SCREENSHOT_DIR.mkdir(parents=True, exist_ok=True)
+
+
+# ── Command handlers ──
+
+def cmd_new_document(args: dict) -> dict:
+ """Create a new Scribus document."""
+ width = args.get("width", 210) # mm, A4 default
+ height = args.get("height", 297)
+ margins = args.get("margins", 10)
+ pages = args.get("pages", 1)
+ unit = args.get("unit", 0) # 0=points, 1=mm, 2=inches, 3=picas
+
+ if scribus:
+ # newDocument(width, height, topMargin, leftMargin, rightMargin, bottomMargin, ..., unit, pages, ...)
+ scribus.newDocument(
+ (width, height),
+ (margins, margins, margins, margins),
+ scribus.PORTRAIT, pages, unit, scribus.FACINGPAGES, scribus.FIRSTPAGELEFT, 1
+ )
+ return {"ok": True, "message": f"Created {width}x{height}mm document with {pages} page(s)"}
+
+
+def cmd_add_text_frame(args: dict) -> dict:
+ """Create a text frame and optionally set its content."""
+ x = args.get("x", 10)
+ y = args.get("y", 10)
+ w = args.get("width", 100)
+ h = args.get("height", 30)
+ text = args.get("text", "")
+ font_size = args.get("fontSize", 12)
+ font_name = args.get("fontName", "Liberation Sans")
+ name = args.get("name")
+
+ if scribus:
+ frame = scribus.createText(x, y, w, h, name or "")
+ if text:
+ scribus.setText(text, frame)
+ scribus.setFontSize(font_size, frame)
+ try:
+ scribus.setFont(font_name, frame)
+ except Exception:
+ scribus.setFont("Liberation Sans", frame)
+ return {"ok": True, "frame": frame}
+
+ frame_name = name or f"text_{x}_{y}"
+ return {"ok": True, "frame": frame_name, "simulated": True}
+
+
+def cmd_add_image_frame(args: dict) -> dict:
+ """Create an image frame, optionally loading an image from a path or URL."""
+ x = args.get("x", 10)
+ y = args.get("y", 10)
+ w = args.get("width", 100)
+ h = args.get("height", 100)
+ image_path = args.get("imagePath", "")
+ name = args.get("name")
+
+ if scribus:
+ frame = scribus.createImage(x, y, w, h, name or "")
+ if image_path and os.path.exists(image_path):
+ scribus.loadImage(image_path, frame)
+ scribus.setScaleImageToFrame(True, True, frame)
+ return {"ok": True, "frame": frame}
+
+ frame_name = name or f"image_{x}_{y}"
+ return {"ok": True, "frame": frame_name, "simulated": True}
+
+
+def cmd_add_shape(args: dict) -> dict:
+ """Create a geometric shape (rectangle or ellipse)."""
+ shape_type = args.get("shapeType", "rect")
+ x = args.get("x", 10)
+ y = args.get("y", 10)
+ w = args.get("width", 50)
+ h = args.get("height", 50)
+ fill = args.get("fill")
+ name = args.get("name")
+
+ if scribus:
+ if shape_type == "ellipse":
+ frame = scribus.createEllipse(x, y, w, h, name or "")
+ else:
+ frame = scribus.createRect(x, y, w, h, name or "")
+ if fill:
+ # Define and set fill color
+ color_name = f"fill_{frame}"
+ r, g, b = _parse_color(fill)
+ scribus.defineColorRGB(color_name, r, g, b)
+ scribus.setFillColor(color_name, frame)
+ return {"ok": True, "frame": frame}
+
+ return {"ok": True, "frame": name or f"{shape_type}_{x}_{y}", "simulated": True}
+
+
+def cmd_get_doc_state(args: dict) -> dict:
+ """Return a full snapshot of the current document state."""
+ if not scribus:
+ return {"error": "No scribus module", "simulated": True}
+
+ try:
+ page_count = scribus.pageCount()
+ except Exception:
+ return {"pages": [], "frames": [], "message": "No document open"}
+
+ pages = []
+ for p in range(1, page_count + 1):
+ scribus.gotoPage(p)
+ w, h = scribus.getPageSize()
+ pages.append({"number": p, "width": w, "height": h})
+
+ frames = []
+ all_objects = scribus.getAllObjects()
+ for obj_name in all_objects:
+ obj_type = scribus.getObjectType(obj_name)
+ x, y = scribus.getPosition(obj_name)
+ w, h = scribus.getSize(obj_name)
+ frame_info = {
+ "name": obj_name,
+ "type": obj_type,
+ "x": x, "y": y,
+ "width": w, "height": h,
+ }
+ if obj_type == "TextFrame":
+ try:
+ frame_info["text"] = scribus.getText(obj_name)
+ frame_info["fontSize"] = scribus.getFontSize(obj_name)
+ frame_info["fontName"] = scribus.getFont(obj_name)
+ except Exception:
+ pass
+ frames.append(frame_info)
+
+ return {"pages": pages, "frames": frames}
+
+
+def cmd_screenshot(args: dict) -> dict:
+ """Export the current page as PNG."""
+ dpi = args.get("dpi", 72)
+ _ensure_dirs()
+ path = str(SCREENSHOT_DIR / "current_page.png")
+
+ if scribus:
+ try:
+ scribus.savePageAsEPS(path.replace(".png", ".eps"))
+ # Fallback: use scribus PDF export + convert, or direct image export
+ # Scribus 1.5 has limited direct PNG export; use saveDocAs + external convert
+ scribus.saveDocAs(path.replace(".png", ".sla"))
+ return {"ok": True, "path": path, "note": "SLA saved; PNG conversion may require external tool"}
+ except Exception as e:
+ return {"error": f"Screenshot failed: {str(e)}"}
+
+ return {"ok": True, "path": path, "simulated": True}
+
+
+def cmd_save_as_sla(args: dict) -> dict:
+ """Save the document as .sla file."""
+ space = args.get("space", "default")
+ filename = args.get("filename", "design.sla")
+ _ensure_dirs()
+ save_dir = DESIGNS_DIR / space
+ save_dir.mkdir(parents=True, exist_ok=True)
+ save_path = str(save_dir / filename)
+
+ if scribus:
+ scribus.saveDocAs(save_path)
+ return {"ok": True, "path": save_path}
+
+ return {"ok": True, "path": save_path, "simulated": True}
+
+
+def cmd_move_frame(args: dict) -> dict:
+ """Move a frame by relative or absolute coordinates."""
+ name = args.get("name", "")
+ x = args.get("x", 0)
+ y = args.get("y", 0)
+ absolute = args.get("absolute", False)
+
+ if scribus and name:
+ if absolute:
+ scribus.moveObjectAbs(x, y, name)
+ else:
+ scribus.moveObject(x, y, name)
+ return {"ok": True}
+
+ return {"ok": True, "simulated": True}
+
+
+def cmd_delete_frame(args: dict) -> dict:
+ """Delete a frame by name."""
+ name = args.get("name", "")
+ if scribus and name:
+ scribus.deleteObject(name)
+ return {"ok": True}
+ return {"ok": True, "simulated": True}
+
+
+def cmd_set_background_color(args: dict) -> dict:
+ """Set the page background color."""
+ color = args.get("color", "#ffffff")
+ if scribus:
+ r, g, b = _parse_color(color)
+ color_name = "page_bg"
+ scribus.defineColorRGB(color_name, r, g, b)
+ # Scribus doesn't have direct page background — create a full-page rect
+ w, h = scribus.getPageSize()
+ bg = scribus.createRect(0, 0, w, h, "background_rect")
+ scribus.setFillColor(color_name, bg)
+ scribus.setLineWidth(0, bg)
+ scribus.sentToLayer("Background", bg) if False else None
+ # Send to back
+ try:
+ for _ in range(50):
+ scribus.moveSelectionToBack()
+ except Exception:
+ pass
+ return {"ok": True, "frame": bg}
+ return {"ok": True, "simulated": True}
+
+
+# ── Helpers ──
+
+def _parse_color(color_str: str) -> tuple:
+ """Parse hex color string to (r, g, b) tuple."""
+ color_str = color_str.lstrip("#")
+ if len(color_str) == 6:
+ return (int(color_str[0:2], 16), int(color_str[2:4], 16), int(color_str[4:6], 16))
+ return (0, 0, 0)
+
+
+COMMAND_MAP = {
+ "new_document": cmd_new_document,
+ "add_text_frame": cmd_add_text_frame,
+ "add_image_frame": cmd_add_image_frame,
+ "add_shape": cmd_add_shape,
+ "get_doc_state": cmd_get_doc_state,
+ "screenshot": cmd_screenshot,
+ "save_as_sla": cmd_save_as_sla,
+ "move_frame": cmd_move_frame,
+ "delete_frame": cmd_delete_frame,
+ "set_background_color": cmd_set_background_color,
+}
+
+
+def handle_command(data: dict) -> dict:
+ """Dispatch a command to the appropriate handler."""
+ action = data.get("action", "")
+ args = data.get("args", {})
+
+ handler = COMMAND_MAP.get(action)
+ if not handler:
+ return {"error": f"Unknown action: {action}", "available": list(COMMAND_MAP.keys())}
+
+ try:
+ return handler(args)
+ except Exception as e:
+ return {"error": f"Command '{action}' failed: {str(e)}", "traceback": traceback.format_exc()}
+
+
+def run_socket_server():
+ """Listen on Unix socket for commands from the Flask bridge."""
+ if os.path.exists(SOCKET_PATH):
+ os.remove(SOCKET_PATH)
+
+ server = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
+ server.bind(SOCKET_PATH)
+ os.chmod(SOCKET_PATH, 0o666)
+ server.listen(5)
+ print(f"[runner] Listening on {SOCKET_PATH}")
+
+ while True:
+ try:
+ conn, _ = server.accept()
+ data = b""
+ while True:
+ chunk = conn.recv(4096)
+ if not chunk:
+ break
+ data += chunk
+ if b"\n" in data:
+ break
+
+ if data:
+ cmd = json.loads(data.decode("utf-8").strip())
+ result = handle_command(cmd)
+ response = json.dumps(result) + "\n"
+ conn.sendall(response.encode("utf-8"))
+ conn.close()
+ except Exception as e:
+ print(f"[runner] Socket error: {e}", file=sys.stderr)
+
+
+if __name__ == "__main__":
+ _ensure_dirs()
+ print("[runner] Scribus bridge runner starting...")
+ # Run socket server in a thread so Scribus event loop can continue
+ t = threading.Thread(target=run_socket_server, daemon=True)
+ t.start()
+ print("[runner] Socket server thread started")
+
+ # Keep the script alive
+ # When run via --python-script, Scribus will execute this then exit
+ # We need to keep it running for the socket server
+ try:
+ while True:
+ import time
+ time.sleep(1)
+ except KeyboardInterrupt:
+ print("[runner] Shutting down")
diff --git a/docker/scribus-novnc/bridge/server.py b/docker/scribus-novnc/bridge/server.py
new file mode 100644
index 0000000..6747c9d
--- /dev/null
+++ b/docker/scribus-novnc/bridge/server.py
@@ -0,0 +1,162 @@
+"""
+Scribus Bridge Server — HTTP API for controlling Scribus from rSpace.
+
+Architecture:
+ rspace container → HTTP → this Flask server (port 8765)
+ │ Unix socket
+ scribus --python-script scribus_runner.py
+
+The runner script executes inside the Scribus process (required by Scribus
+Python API). It listens on a Unix socket for JSON commands. This Flask
+server translates HTTP requests into socket commands.
+"""
+
+import json
+import os
+import socket
+import subprocess
+import time
+from pathlib import Path
+from flask import Flask, request, jsonify
+
+app = Flask(__name__)
+
+BRIDGE_SECRET = os.environ.get("BRIDGE_SECRET", "")
+SOCKET_PATH = "/tmp/scribus_bridge.sock"
+SCRIBUS_RUNNER = "/opt/bridge/scribus_runner.py"
+DESIGNS_DIR = Path("/data/designs")
+DISPLAY = os.environ.get("DISPLAY", ":1")
+
+_scribus_proc = None
+
+
+def _check_auth():
+ """Verify X-Bridge-Secret header."""
+ if not BRIDGE_SECRET:
+ return None # No secret configured, allow all
+ token = request.headers.get("X-Bridge-Secret", "")
+ if token != BRIDGE_SECRET:
+ return jsonify({"error": "Unauthorized"}), 401
+ return None
+
+
+def _send_command(cmd: dict, timeout: float = 30.0) -> dict:
+ """Send a JSON command to the Scribus runner via Unix socket."""
+ if not os.path.exists(SOCKET_PATH):
+ return {"error": "Scribus runner not connected. Call /api/scribus/start first."}
+
+ try:
+ sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
+ sock.settimeout(timeout)
+ sock.connect(SOCKET_PATH)
+ payload = json.dumps(cmd) + "\n"
+ sock.sendall(payload.encode("utf-8"))
+
+ # Read response (newline-delimited JSON)
+ buf = b""
+ while True:
+ chunk = sock.recv(4096)
+ if not chunk:
+ break
+ buf += chunk
+ if b"\n" in buf:
+ break
+ sock.close()
+ return json.loads(buf.decode("utf-8").strip())
+ except socket.timeout:
+ return {"error": "Command timed out"}
+ except ConnectionRefusedError:
+ return {"error": "Scribus runner not responding"}
+ except Exception as e:
+ return {"error": f"Bridge error: {str(e)}"}
+
+
+@app.before_request
+def before_request():
+ auth = _check_auth()
+ if auth:
+ return auth
+
+
+@app.route("/health", methods=["GET"])
+def health():
+ runner_alive = os.path.exists(SOCKET_PATH)
+ scribus_alive = _scribus_proc is not None and _scribus_proc.poll() is None
+ return jsonify({
+ "ok": True,
+ "service": "scribus-bridge",
+ "runner_connected": runner_alive,
+ "scribus_alive": scribus_alive,
+ })
+
+
+@app.route("/api/scribus/start", methods=["POST"])
+def start_scribus():
+ """Launch Scribus with the bridge runner script."""
+ global _scribus_proc
+
+ if _scribus_proc and _scribus_proc.poll() is None:
+ return jsonify({"ok": True, "message": "Scribus already running", "pid": _scribus_proc.pid})
+
+ # Clean up stale socket
+ if os.path.exists(SOCKET_PATH):
+ os.remove(SOCKET_PATH)
+
+ env = os.environ.copy()
+ env["DISPLAY"] = DISPLAY
+
+ _scribus_proc = subprocess.Popen(
+ ["scribus", "--python-script", SCRIBUS_RUNNER, "--no-gui"],
+ env=env,
+ stdout=subprocess.PIPE,
+ stderr=subprocess.PIPE,
+ )
+
+ # Wait for runner socket to appear (up to 15s)
+ for _ in range(30):
+ if os.path.exists(SOCKET_PATH):
+ return jsonify({"ok": True, "message": "Scribus started", "pid": _scribus_proc.pid})
+ time.sleep(0.5)
+
+ return jsonify({"ok": False, "error": "Scribus runner did not connect in time"}), 500
+
+
+@app.route("/api/scribus/command", methods=["POST"])
+def scribus_command():
+ """Execute a Scribus command via the runner."""
+ body = request.get_json(silent=True)
+ if not body or "action" not in body:
+ return jsonify({"error": "Missing 'action' in request body"}), 400
+
+ result = _send_command(body)
+ status = 200 if "error" not in result else 500
+ return jsonify(result), status
+
+
+@app.route("/api/scribus/state", methods=["GET"])
+def scribus_state():
+ """Return the full document state as JSON."""
+ result = _send_command({"action": "get_doc_state"})
+ status = 200 if "error" not in result else 500
+ return jsonify(result), status
+
+
+@app.route("/api/scribus/screenshot", methods=["GET"])
+def scribus_screenshot():
+ """Export the current page as PNG."""
+ dpi = request.args.get("dpi", "72", type=str)
+ result = _send_command({"action": "screenshot", "args": {"dpi": int(dpi)}})
+ if "error" in result:
+ return jsonify(result), 500
+
+ png_path = result.get("path")
+ if png_path and os.path.exists(png_path):
+ return app.send_static_file(png_path) if False else \
+ (open(png_path, "rb").read(), 200, {"Content-Type": "image/png"})
+
+ return jsonify({"error": "Screenshot not generated"}), 500
+
+
+if __name__ == "__main__":
+ DESIGNS_DIR.mkdir(parents=True, exist_ok=True)
+ app.run(host="0.0.0.0", port=int(os.environ.get("BRIDGE_PORT", 8765)))
diff --git a/docker/scribus-novnc/startup.sh b/docker/scribus-novnc/startup.sh
new file mode 100644
index 0000000..ecb274c
--- /dev/null
+++ b/docker/scribus-novnc/startup.sh
@@ -0,0 +1,12 @@
+#!/bin/bash
+set -e
+
+# Ensure data directories exist
+mkdir -p /data/designs
+mkdir -p /var/log/supervisor
+
+echo "[rDesign] Starting Scribus noVNC container..."
+echo "[rDesign] Screen: ${SCREEN_WIDTH}x${SCREEN_HEIGHT}x${SCREEN_DEPTH}"
+echo "[rDesign] noVNC port: ${NOVNC_PORT}, Bridge port: ${BRIDGE_PORT}"
+
+exec /usr/bin/supervisord -c /etc/supervisor/conf.d/supervisord.conf
diff --git a/docker/scribus-novnc/supervisord.conf b/docker/scribus-novnc/supervisord.conf
new file mode 100644
index 0000000..7eac59c
--- /dev/null
+++ b/docker/scribus-novnc/supervisord.conf
@@ -0,0 +1,29 @@
+[supervisord]
+nodaemon=true
+logfile=/var/log/supervisor/supervisord.log
+pidfile=/var/run/supervisord.pid
+
+[program:xvfb]
+command=Xvfb :1 -screen 0 %(ENV_SCREEN_WIDTH)sx%(ENV_SCREEN_HEIGHT)sx%(ENV_SCREEN_DEPTH)s
+autorestart=true
+priority=10
+
+[program:x11vnc]
+command=x11vnc -display :1 -nopw -listen 0.0.0.0 -xkb -ncache 10 -ncache_cr -forever -shared
+autorestart=true
+priority=20
+startsecs=3
+
+[program:websockify]
+command=websockify --web /usr/share/novnc %(ENV_NOVNC_PORT)s localhost:%(ENV_VNC_PORT)s
+autorestart=true
+priority=30
+startsecs=5
+
+[program:bridge]
+command=python3 /opt/bridge/server.py
+autorestart=true
+priority=40
+environment=DISPLAY=":1"
+stdout_logfile=/var/log/supervisor/bridge.log
+stderr_logfile=/var/log/supervisor/bridge_err.log
diff --git a/lib/canvas-tools.ts b/lib/canvas-tools.ts
index 0678f45..8e6579d 100644
--- a/lib/canvas-tools.ts
+++ b/lib/canvas-tools.ts
@@ -281,6 +281,24 @@ const registry: CanvasToolDefinition[] = [
},
];
+// ── Design Agent Tool ──
+registry.push({
+ declaration: {
+ name: "create_design_agent",
+ description: "Open the design agent to create print layouts in Scribus. Use when the user wants to design a poster, flyer, brochure, or any print-ready document.",
+ parameters: {
+ type: "object",
+ properties: {
+ brief: { type: "string", description: "Design brief describing what to create (e.g. 'A4 event poster for Mushroom Festival with title, date, and image area')" },
+ },
+ required: ["brief"],
+ },
+ },
+ tagName: "folk-design-agent",
+ buildProps: (args) => ({ brief: args.brief || "" }),
+ actionLabel: (args) => `Opened design agent${args.brief ? `: ${args.brief.slice(0, 50)}` : ""}`,
+});
+
export const CANVAS_TOOLS: CanvasToolDefinition[] = [...registry];
export const CANVAS_TOOL_DECLARATIONS = CANVAS_TOOLS.map((t) => t.declaration);
diff --git a/lib/design-tool-declarations.ts b/lib/design-tool-declarations.ts
new file mode 100644
index 0000000..2e0a478
--- /dev/null
+++ b/lib/design-tool-declarations.ts
@@ -0,0 +1,133 @@
+/**
+ * Gemini function declarations for the design agent.
+ * These map to Scribus bridge commands executed via the Python bridge server.
+ */
+
+export const DESIGN_TOOL_DECLARATIONS = [
+ {
+ name: "new_document",
+ description: "Create a new Scribus document with specified dimensions and margins.",
+ parameters: {
+ type: "object",
+ properties: {
+ width: { type: "number", description: "Document width in mm (default: 210 for A4)" },
+ height: { type: "number", description: "Document height in mm (default: 297 for A4)" },
+ margins: { type: "number", description: "Page margins in mm (default: 10)" },
+ pages: { type: "integer", description: "Number of pages (default: 1)" },
+ },
+ required: [],
+ },
+ },
+ {
+ name: "add_text_frame",
+ description: "Add a text frame to the page at the specified position. Coordinates and dimensions in mm from top-left.",
+ parameters: {
+ type: "object",
+ properties: {
+ x: { type: "number", description: "X position in mm from left edge" },
+ y: { type: "number", description: "Y position in mm from top edge" },
+ width: { type: "number", description: "Frame width in mm" },
+ height: { type: "number", description: "Frame height in mm" },
+ text: { type: "string", description: "Text content for the frame" },
+ fontSize: { type: "number", description: "Font size in points (default: 12)" },
+ fontName: { type: "string", description: "Font name. Safe fonts: Liberation Sans, Liberation Serif, DejaVu Sans" },
+ name: { type: "string", description: "Optional frame name for later reference" },
+ },
+ required: ["x", "y", "width", "height"],
+ },
+ },
+ {
+ name: "add_image_frame",
+ description: "Add an image frame to the page. If imagePath is provided, the image will be loaded into the frame.",
+ parameters: {
+ type: "object",
+ properties: {
+ x: { type: "number", description: "X position in mm from left edge" },
+ y: { type: "number", description: "Y position in mm from top edge" },
+ width: { type: "number", description: "Frame width in mm" },
+ height: { type: "number", description: "Frame height in mm" },
+ imagePath: { type: "string", description: "Path to image file to load into frame" },
+ name: { type: "string", description: "Optional frame name for later reference" },
+ },
+ required: ["x", "y", "width", "height"],
+ },
+ },
+ {
+ name: "add_shape",
+ description: "Add a geometric shape (rectangle or ellipse) to the page.",
+ parameters: {
+ type: "object",
+ properties: {
+ shapeType: { type: "string", description: "Shape type: 'rect' or 'ellipse'", enum: ["rect", "ellipse"] },
+ x: { type: "number", description: "X position in mm from left edge" },
+ y: { type: "number", description: "Y position in mm from top edge" },
+ width: { type: "number", description: "Shape width in mm" },
+ height: { type: "number", description: "Shape height in mm" },
+ fill: { type: "string", description: "Fill color as hex string (e.g. '#ff6600')" },
+ name: { type: "string", description: "Optional shape name for later reference" },
+ },
+ required: ["x", "y", "width", "height"],
+ },
+ },
+ {
+ name: "set_background_color",
+ description: "Set the page background color by creating a full-page rectangle.",
+ parameters: {
+ type: "object",
+ properties: {
+ color: { type: "string", description: "Background color as hex string (e.g. '#1a1a2e')" },
+ },
+ required: ["color"],
+ },
+ },
+ {
+ name: "get_state",
+ description: "Get the current document state including all pages and frames. Use this to verify layout after making changes.",
+ parameters: {
+ type: "object",
+ properties: {},
+ required: [],
+ },
+ },
+ {
+ name: "save_document",
+ description: "Save the current document as a .sla file.",
+ parameters: {
+ type: "object",
+ properties: {
+ space: { type: "string", description: "Space slug for the save directory" },
+ filename: { type: "string", description: "Filename for the .sla file" },
+ },
+ required: ["filename"],
+ },
+ },
+ {
+ name: "generate_image",
+ description: "Generate an AI image from a text prompt using fal.ai and place it in an image frame on the page.",
+ parameters: {
+ type: "object",
+ properties: {
+ prompt: { type: "string", description: "Text prompt describing the image to generate" },
+ x: { type: "number", description: "X position for the image frame in mm" },
+ y: { type: "number", description: "Y position for the image frame in mm" },
+ width: { type: "number", description: "Image frame width in mm" },
+ height: { type: "number", description: "Image frame height in mm" },
+ },
+ required: ["prompt", "x", "y", "width", "height"],
+ },
+ },
+];
+
+export type DesignToolName = (typeof DESIGN_TOOL_DECLARATIONS)[number]["name"];
+
+export const DESIGN_SYSTEM_PROMPT = `You are a professional graphic designer using Scribus DTP software. Given a design brief:
+1. Create a document with appropriate dimensions
+2. Establish visual hierarchy with text frames (heading > subheading > body)
+3. Place image frames for visual elements
+4. Add geometric shapes for structure and decoration
+5. Verify layout with get_state
+6. Save the document
+
+Coordinates are in mm from top-left. Safe fonts: Liberation Sans, Liberation Serif, DejaVu Sans.
+Minimum margins: 10mm. Standard sizes: A4 (210x297), A5 (148x210), Letter (216x279).
+Always create the document first before adding frames.`;
diff --git a/lib/folk-design-agent.ts b/lib/folk-design-agent.ts
new file mode 100644
index 0000000..1be18bd
--- /dev/null
+++ b/lib/folk-design-agent.ts
@@ -0,0 +1,457 @@
+import { FolkShape } from "./folk-shape";
+import { css, html } from "./tags";
+
+const styles = css`
+ :host {
+ background: var(--rs-bg-surface, #fff);
+ color: var(--rs-text-primary, #1e293b);
+ border-radius: 8px;
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
+ min-width: 420px;
+ min-height: 500px;
+ }
+
+ .header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 8px 12px;
+ background: linear-gradient(135deg, #7c3aed, #a78bfa);
+ color: white;
+ border-radius: 8px 8px 0 0;
+ font-size: 12px;
+ font-weight: 600;
+ cursor: move;
+ }
+
+ .header-title {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ }
+
+ .header-actions {
+ display: flex;
+ gap: 4px;
+ align-items: center;
+ }
+
+ .header-actions button {
+ background: transparent;
+ border: none;
+ color: white;
+ cursor: pointer;
+ padding: 2px 6px;
+ border-radius: 4px;
+ font-size: 14px;
+ }
+
+ .header-actions button:hover {
+ background: rgba(255, 255, 255, 0.2);
+ }
+
+ .state-badge {
+ font-size: 9px;
+ padding: 2px 6px;
+ border-radius: 10px;
+ text-transform: uppercase;
+ letter-spacing: 0.5px;
+ background: rgba(255,255,255,0.2);
+ }
+
+ .content {
+ display: flex;
+ flex-direction: column;
+ height: calc(100% - 36px);
+ overflow: hidden;
+ }
+
+ .prompt-area {
+ padding: 12px;
+ border-bottom: 1px solid var(--rs-border, #e2e8f0);
+ }
+
+ .prompt-input {
+ width: 100%;
+ padding: 10px 12px;
+ border: 2px solid var(--rs-input-border, #e2e8f0);
+ border-radius: 8px;
+ font-size: 13px;
+ resize: none;
+ outline: none;
+ font-family: inherit;
+ background: var(--rs-input-bg, #fff);
+ color: var(--rs-input-text, inherit);
+ box-sizing: border-box;
+ }
+
+ .prompt-input:focus {
+ border-color: #7c3aed;
+ }
+
+ .btn-row {
+ display: flex;
+ gap: 6px;
+ margin-top: 8px;
+ }
+
+ .btn {
+ padding: 6px 14px;
+ border-radius: 6px;
+ border: none;
+ font-size: 12px;
+ font-weight: 600;
+ cursor: pointer;
+ transition: background 0.15s;
+ }
+
+ .btn-primary {
+ background: linear-gradient(135deg, #7c3aed, #a78bfa);
+ color: white;
+ }
+ .btn-primary:hover { background: linear-gradient(135deg, #6d28d9, #8b5cf6); }
+ .btn-primary:disabled { opacity: 0.5; cursor: default; }
+
+ .btn-secondary {
+ background: var(--rs-bg-elevated, #f1f5f9);
+ color: var(--rs-text-primary, #475569);
+ }
+ .btn-secondary:hover { background: var(--rs-bg-hover, #e2e8f0); }
+
+ .status-area {
+ flex: 1;
+ overflow-y: auto;
+ padding: 12px;
+ font-size: 12px;
+ }
+
+ .step {
+ padding: 6px 0;
+ border-bottom: 1px solid var(--rs-border, #f1f5f9);
+ display: flex;
+ align-items: flex-start;
+ gap: 8px;
+ }
+
+ .step-icon {
+ flex-shrink: 0;
+ width: 18px;
+ height: 18px;
+ border-radius: 50%;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ font-size: 10px;
+ margin-top: 1px;
+ }
+
+ .step-icon.thinking { background: #dbeafe; color: #2563eb; }
+ .step-icon.executing { background: #fef3c7; color: #d97706; }
+ .step-icon.done { background: #d1fae5; color: #059669; }
+ .step-icon.error { background: #fee2e2; color: #dc2626; }
+
+ .step-content {
+ flex: 1;
+ line-height: 1.4;
+ }
+
+ .step-tool {
+ font-family: monospace;
+ background: var(--rs-bg-elevated, #f1f5f9);
+ padding: 1px 4px;
+ border-radius: 3px;
+ font-size: 11px;
+ }
+
+ .export-row {
+ display: flex;
+ gap: 6px;
+ padding: 8px 12px;
+ border-top: 1px solid var(--rs-border, #e2e8f0);
+ justify-content: center;
+ }
+
+ .placeholder {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 8px;
+ padding: 2rem;
+ color: #94a3b8;
+ text-align: center;
+ }
+
+ .placeholder-icon { font-size: 2rem; }
+
+ .spinner {
+ display: inline-block;
+ width: 12px;
+ height: 12px;
+ border: 2px solid rgba(255,255,255,0.3);
+ border-top-color: #fff;
+ border-radius: 50%;
+ animation: spin 0.8s linear infinite;
+ vertical-align: middle;
+ margin-right: 4px;
+ }
+
+ @keyframes spin { to { transform: rotate(360deg); } }
+`;
+
+declare global {
+ interface HTMLElementTagNameMap {
+ "folk-design-agent": FolkDesignAgent;
+ }
+}
+
+type AgentState = "idle" | "planning" | "executing" | "verifying" | "done" | "error";
+
+export class FolkDesignAgent extends FolkShape {
+ static override tagName = "folk-design-agent";
+
+ static {
+ const sheet = new CSSStyleSheet();
+ const parentRules = Array.from(FolkShape.styles.cssRules)
+ .map((r) => r.cssText)
+ .join("\n");
+ const childRules = Array.from(styles.cssRules)
+ .map((r) => r.cssText)
+ .join("\n");
+ sheet.replaceSync(`${parentRules}\n${childRules}`);
+ this.styles = sheet;
+ }
+
+ #state: AgentState = "idle";
+ #abortController: AbortController | null = null;
+ #promptInput: HTMLTextAreaElement | null = null;
+ #statusArea: HTMLElement | null = null;
+ #generateBtn: HTMLButtonElement | null = null;
+ #stopBtn: HTMLButtonElement | null = null;
+ #exportRow: HTMLElement | null = null;
+ #stateBadge: HTMLElement | null = null;
+
+ override createRenderRoot() {
+ const root = super.createRenderRoot();
+
+ const wrapper = document.createElement("div");
+ wrapper.innerHTML = html`
+
+
+
+
+
+
+
+
+
+
+
+ 📐
+ Enter a design brief to get started.
The agent will create a Scribus document step by step.
+
+
+
+
+ `;
+
+ // Replace slot container with our wrapper
+ const slot = root.querySelector("slot");
+ const containerDiv = slot?.parentElement as HTMLElement;
+ if (containerDiv) containerDiv.replaceWith(wrapper);
+
+ this.#promptInput = wrapper.querySelector(".prompt-input");
+ this.#statusArea = wrapper.querySelector('[data-ref="status-area"]');
+ this.#generateBtn = wrapper.querySelector('[data-ref="generate-btn"]');
+ this.#stopBtn = wrapper.querySelector('[data-ref="stop-btn"]');
+ this.#exportRow = wrapper.querySelector('[data-ref="export-row"]');
+ this.#stateBadge = wrapper.querySelector('[data-ref="state-badge"]');
+
+ // Set initial brief from attribute
+ const brief = this.getAttribute("brief");
+ if (brief && this.#promptInput) this.#promptInput.value = brief;
+
+ // Generate button
+ this.#generateBtn?.addEventListener("click", (e) => {
+ e.stopPropagation();
+ this.#generate();
+ });
+
+ // Stop button
+ this.#stopBtn?.addEventListener("click", (e) => {
+ e.stopPropagation();
+ this.#stop();
+ });
+
+ // Enter key
+ this.#promptInput?.addEventListener("keydown", (e) => {
+ if (e.key === "Enter" && !e.shiftKey) {
+ e.preventDefault();
+ this.#generate();
+ }
+ });
+
+ // Prevent canvas drag
+ this.#promptInput?.addEventListener("pointerdown", (e) => e.stopPropagation());
+ this.#statusArea?.addEventListener("pointerdown", (e) => e.stopPropagation());
+
+ // Close button
+ const closeBtn = wrapper.querySelector(".close-btn") as HTMLButtonElement;
+ closeBtn?.addEventListener("click", (e) => {
+ e.stopPropagation();
+ this.dispatchEvent(new CustomEvent("close"));
+ });
+
+ return root;
+ }
+
+ #setState(state: AgentState) {
+ this.#state = state;
+ if (this.#stateBadge) this.#stateBadge.textContent = state.charAt(0).toUpperCase() + state.slice(1);
+
+ const isWorking = state !== "idle" && state !== "done" && state !== "error";
+ if (this.#generateBtn) {
+ this.#generateBtn.disabled = isWorking;
+ this.#generateBtn.innerHTML = isWorking
+ ? ' Working...'
+ : "Generate Design";
+ }
+ if (this.#stopBtn) this.#stopBtn.style.display = isWorking ? "" : "none";
+ if (this.#exportRow) this.#exportRow.style.display = state === "done" ? "" : "none";
+ if (this.#promptInput) this.#promptInput.disabled = isWorking;
+ }
+
+ #addStep(icon: string, cls: string, text: string) {
+ if (!this.#statusArea) return;
+ // Remove placeholder on first step
+ const placeholder = this.#statusArea.querySelector(".placeholder");
+ if (placeholder) placeholder.remove();
+
+ const step = document.createElement("div");
+ step.className = "step";
+ step.innerHTML = `${icon}
${text}
`;
+ this.#statusArea.appendChild(step);
+ this.#statusArea.scrollTop = this.#statusArea.scrollHeight;
+ }
+
+ async #generate() {
+ const brief = this.#promptInput?.value.trim();
+ if (!brief || (this.#state !== "idle" && this.#state !== "done" && this.#state !== "error")) return;
+
+ // Clear previous steps
+ if (this.#statusArea) this.#statusArea.innerHTML = "";
+ this.#setState("planning");
+ this.#abortController = new AbortController();
+
+ try {
+ const space = this.closest("[data-space]")?.getAttribute("data-space") || "demo";
+ const res = await fetch("/api/design-agent", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ brief, space }),
+ signal: this.#abortController.signal,
+ });
+
+ if (!res.ok || !res.body) {
+ this.#addStep("!", "error", `Request failed: ${res.status}`);
+ this.#setState("error");
+ return;
+ }
+
+ const reader = res.body.getReader();
+ const decoder = new TextDecoder();
+ let buffer = "";
+
+ while (true) {
+ const { done, value } = await reader.read();
+ if (done) break;
+
+ buffer += decoder.decode(value, { stream: true });
+ const lines = buffer.split("\n");
+ buffer = lines.pop() || "";
+
+ for (const line of lines) {
+ if (line.startsWith("data:")) {
+ try {
+ const data = JSON.parse(line.slice(5).trim());
+ this.#processEvent(data);
+ } catch {}
+ }
+ }
+ }
+
+ if (this.#state !== "error") this.#setState("done");
+ } catch (e: any) {
+ if (e.name !== "AbortError") {
+ this.#addStep("!", "error", `Error: ${e.message}`);
+ this.#setState("error");
+ }
+ }
+
+ this.#abortController = null;
+ }
+
+ #processEvent(data: any) {
+ switch (data.action) {
+ case "starting_scribus":
+ this.#addStep("~", "thinking", data.status || "Starting Scribus...");
+ break;
+ case "scribus_ready":
+ this.#addStep("✓", "done", "Scribus ready");
+ break;
+ case "thinking":
+ this.#setState("planning");
+ this.#addStep("~", "thinking", data.status || "Thinking...");
+ break;
+ case "executing":
+ this.#setState("executing");
+ this.#addStep("▶", "executing",
+ `${data.status || "Executing"}: ${data.tool}`);
+ break;
+ case "tool_result":
+ if (data.result?.error) {
+ this.#addStep("!", "error", `${data.tool} failed: ${data.result.error}`);
+ } else {
+ this.#addStep("✓", "done", `${data.tool} completed`);
+ }
+ break;
+ case "verifying":
+ this.#setState("verifying");
+ this.#addStep("~", "thinking", data.status || "Verifying...");
+ break;
+ case "complete":
+ this.#addStep("✓", "done", data.message || "Design complete");
+ break;
+ case "done":
+ this.#addStep("✓", "done", data.status || "Done!");
+ if (data.state?.frames) {
+ this.#addStep("✓", "done", `${data.state.frames.length} frame(s) in document`);
+ }
+ break;
+ case "error":
+ this.#addStep("!", "error", data.error || "Unknown error");
+ this.#setState("error");
+ break;
+ }
+ }
+
+ #stop() {
+ this.#abortController?.abort();
+ this.#setState("idle");
+ this.#addStep("!", "error", "Stopped by user");
+ }
+}
+
+if (!customElements.get(FolkDesignAgent.tagName)) {
+ customElements.define(FolkDesignAgent.tagName, FolkDesignAgent);
+}
diff --git a/modules/rdesign/design-agent-route.ts b/modules/rdesign/design-agent-route.ts
new file mode 100644
index 0000000..2027259
--- /dev/null
+++ b/modules/rdesign/design-agent-route.ts
@@ -0,0 +1,261 @@
+/**
+ * Design Agent Route — Gemini tool-calling loop that drives Scribus via the bridge.
+ *
+ * POST /api/design-agent { brief, space, model? }
+ * Returns SSE stream of agent steps.
+ */
+
+import { Hono } from "hono";
+import { streamSSE } from "hono/streaming";
+import { DESIGN_TOOL_DECLARATIONS, DESIGN_SYSTEM_PROMPT } from "../../lib/design-tool-declarations";
+
+const SCRIBUS_BRIDGE_URL = process.env.SCRIBUS_BRIDGE_URL || "http://scribus-novnc:8765";
+const BRIDGE_SECRET = process.env.SCRIBUS_BRIDGE_SECRET || "";
+const MAX_TURNS = 10;
+
+export const designAgentRoutes = new Hono();
+
+/** Forward a command to the Scribus bridge. */
+async function bridgeCommand(action: string, args: Record = {}): Promise {
+ const headers: Record = { "Content-Type": "application/json" };
+ if (BRIDGE_SECRET) headers["X-Bridge-Secret"] = BRIDGE_SECRET;
+
+ try {
+ const res = await fetch(`${SCRIBUS_BRIDGE_URL}/api/scribus/command`, {
+ method: "POST",
+ headers,
+ body: JSON.stringify({ action, args }),
+ signal: AbortSignal.timeout(30_000),
+ });
+ return await res.json();
+ } catch (e: any) {
+ return { error: `Bridge unreachable: ${e.message}` };
+ }
+}
+
+/** Get bridge state. */
+async function bridgeState(): Promise {
+ const headers: Record = {};
+ if (BRIDGE_SECRET) headers["X-Bridge-Secret"] = BRIDGE_SECRET;
+
+ try {
+ const res = await fetch(`${SCRIBUS_BRIDGE_URL}/api/scribus/state`, { headers, signal: AbortSignal.timeout(10_000) });
+ return await res.json();
+ } catch (e: any) {
+ return { error: `Bridge unreachable: ${e.message}` };
+ }
+}
+
+/** Start Scribus if not running. */
+async function ensureScribusRunning(): Promise {
+ const headers: Record = { "Content-Type": "application/json" };
+ if (BRIDGE_SECRET) headers["X-Bridge-Secret"] = BRIDGE_SECRET;
+
+ try {
+ const res = await fetch(`${SCRIBUS_BRIDGE_URL}/api/scribus/start`, {
+ method: "POST",
+ headers,
+ signal: AbortSignal.timeout(20_000),
+ });
+ return await res.json();
+ } catch (e: any) {
+ return { error: `Bridge unreachable: ${e.message}` };
+ }
+}
+
+/** Translate a Gemini tool call into a bridge command. */
+async function executeToolCall(name: string, args: Record, space: string): Promise {
+ switch (name) {
+ case "new_document":
+ return bridgeCommand("new_document", args);
+ case "add_text_frame":
+ return bridgeCommand("add_text_frame", args);
+ case "add_image_frame":
+ return bridgeCommand("add_image_frame", args);
+ case "add_shape":
+ return bridgeCommand("add_shape", args);
+ case "set_background_color":
+ return bridgeCommand("set_background_color", args);
+ case "get_state":
+ return bridgeState();
+ case "save_document":
+ return bridgeCommand("save_as_sla", { ...args, space });
+ case "generate_image": {
+ // Generate image via fal.ai, then place it
+ const imageResult = await generateAndPlaceImage(args);
+ return imageResult;
+ }
+ default:
+ return { error: `Unknown tool: ${name}` };
+ }
+}
+
+/** Generate an image via the rSpace /api/image-gen endpoint and download it for Scribus. */
+async function generateAndPlaceImage(args: Record): Promise {
+ try {
+ // Call internal image gen API
+ const res = await fetch(`http://localhost:${process.env.PORT || 3000}/api/image-gen`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ prompt: args.prompt, provider: "fal", model: "flux-pro" }),
+ signal: AbortSignal.timeout(60_000),
+ });
+ const data = await res.json() as any;
+ if (!data.url) return { error: "Image generation failed", details: data };
+
+ // Download the image to a local path inside the Scribus container
+ const imageUrl = data.url;
+ const downloadRes = await fetch(imageUrl, { signal: AbortSignal.timeout(30_000) });
+ if (!downloadRes.ok) return { error: "Failed to download generated image" };
+
+ const imageName = `gen_${Date.now()}.png`;
+ const imagePath = `/data/designs/_generated/${imageName}`;
+
+ // Write image to bridge container via a bridge command
+ // For now, place the frame with the URL reference
+ const placeResult = await bridgeCommand("add_image_frame", {
+ x: args.x,
+ y: args.y,
+ width: args.width,
+ height: args.height,
+ imagePath,
+ name: `gen_image_${Date.now()}`,
+ });
+
+ return { ...placeResult, imageUrl, imagePath };
+ } catch (e: any) {
+ return { error: `Image generation failed: ${e.message}` };
+ }
+}
+
+/** Call Gemini with tool declarations. */
+async function callGemini(messages: any[], model: string): Promise {
+ // Use the Gemini SDK from the AI services
+ const { GoogleGenAI } = await import("@google/genai");
+ const apiKey = process.env.GEMINI_API_KEY;
+ if (!apiKey) return { error: "GEMINI_API_KEY not configured" };
+
+ const genai = new GoogleGenAI({ apiKey });
+
+ const tools: any[] = [{
+ functionDeclarations: DESIGN_TOOL_DECLARATIONS.map(d => ({
+ name: d.name,
+ description: d.description,
+ parameters: d.parameters,
+ })),
+ }];
+
+ const response = await genai.models.generateContent({
+ model: model || "gemini-2.0-flash",
+ contents: messages,
+ config: {
+ tools,
+ systemInstruction: DESIGN_SYSTEM_PROMPT,
+ },
+ } as any);
+
+ return response;
+}
+
+designAgentRoutes.post("/api/design-agent", async (c) => {
+ const body = await c.req.json().catch(() => null);
+ if (!body?.brief) return c.json({ error: "Missing 'brief' in request body" }, 400);
+
+ const { brief, space = "demo", model = "gemini-2.0-flash" } = body;
+
+ return streamSSE(c, async (stream) => {
+ let eventId = 0;
+ const sendEvent = async (data: any) => {
+ await stream.writeSSE({ data: JSON.stringify(data), event: "step", id: String(++eventId) });
+ };
+
+ try {
+ // Step 1: Ensure Scribus is running
+ await sendEvent({ step: 1, action: "starting_scribus", status: "Starting Scribus..." });
+ const startResult = await ensureScribusRunning();
+ if (startResult.error) {
+ await sendEvent({ step: 1, action: "error", error: startResult.error });
+ return;
+ }
+ await sendEvent({ step: 1, action: "scribus_ready", result: startResult });
+
+ // Step 2: Agentic loop
+ const messages: any[] = [
+ { role: "user", parts: [{ text: `Design brief: ${brief}` }] },
+ ];
+
+ for (let turn = 0; turn < MAX_TURNS; turn++) {
+ await sendEvent({ step: turn + 2, action: "thinking", status: `Turn ${turn + 1}: Asking Gemini...` });
+
+ const response = await callGemini(messages, model);
+ const candidate = response?.candidates?.[0];
+ if (!candidate) {
+ await sendEvent({ step: turn + 2, action: "error", error: "No response from Gemini" });
+ break;
+ }
+
+ const parts = candidate.content?.parts || [];
+ const textParts = parts.filter((p: any) => p.text);
+ const toolCalls = parts.filter((p: any) => p.functionCall);
+
+ // If Gemini returned text without tool calls, we're done
+ if (textParts.length > 0 && toolCalls.length === 0) {
+ await sendEvent({
+ step: turn + 2,
+ action: "complete",
+ message: textParts.map((p: any) => p.text).join("\n"),
+ });
+ break;
+ }
+
+ // Execute tool calls
+ const toolResults: any[] = [];
+ for (const part of toolCalls) {
+ const { name, args } = part.functionCall;
+ await sendEvent({
+ step: turn + 2,
+ action: "executing",
+ tool: name,
+ args,
+ status: `Executing: ${name}`,
+ });
+
+ const result = await executeToolCall(name, args || {}, space);
+ await sendEvent({
+ step: turn + 2,
+ action: "tool_result",
+ tool: name,
+ result,
+ });
+
+ toolResults.push({
+ functionResponse: {
+ name,
+ response: result,
+ },
+ });
+ }
+
+ // Add assistant response + tool results to conversation
+ messages.push({ role: "model", parts });
+ messages.push({ role: "user", parts: toolResults });
+ }
+
+ // Final state check
+ await sendEvent({ step: MAX_TURNS + 2, action: "verifying", status: "Getting final state..." });
+ const finalState = await bridgeState();
+ await sendEvent({
+ step: MAX_TURNS + 2,
+ action: "done",
+ state: finalState,
+ status: "Design complete!",
+ });
+ } catch (e: any) {
+ await sendEvent({ step: 0, action: "error", error: e.message });
+ }
+ });
+});
+
+designAgentRoutes.get("/api/design-agent/health", (c) => {
+ return c.json({ ok: true, bridge: SCRIBUS_BRIDGE_URL });
+});
diff --git a/modules/rdesign/local-first-client.ts b/modules/rdesign/local-first-client.ts
index d8a900f..fb34413 100644
--- a/modules/rdesign/local-first-client.ts
+++ b/modules/rdesign/local-first-client.ts
@@ -1,5 +1,5 @@
/**
- * rDesign Local-First Client — syncs linked Affine projects.
+ * rDesign Local-First Client — syncs design state (pages + frames) via Automerge.
*/
import { DocumentManager } from '../../shared/local-first/document';
@@ -8,7 +8,7 @@ import { EncryptedDocStore } from '../../shared/local-first/storage';
import { DocSyncManager } from '../../shared/local-first/sync';
import { DocCrypto } from '../../shared/local-first/crypto';
import { designSchema, designDocId } from './schemas';
-import type { DesignDoc, LinkedProject } from './schemas';
+import type { DesignDoc, DesignFrame, DesignPage } from './schemas';
export class DesignLocalFirstClient {
#space: string; #documents: DocumentManager; #store: EncryptedDocStore; #sync: DocSyncManager; #initialized = false;
@@ -25,7 +25,7 @@ export class DesignLocalFirstClient {
async init(): Promise {
if (this.#initialized) return;
await this.#store.open();
- const cachedIds = await this.#store.listByModule('design', 'projects');
+ const cachedIds = await this.#store.listByModule('design', 'doc');
const cached = await this.#store.loadMany(cachedIds);
for (const [docId, binary] of cached) this.#documents.open(docId, designSchema, binary);
await this.#sync.preloadSyncStates(cachedIds);
@@ -44,11 +44,70 @@ export class DesignLocalFirstClient {
getDoc(): DesignDoc | undefined { return this.#documents.get(designDocId(this.#space) as DocumentId); }
onChange(cb: (doc: DesignDoc) => void): () => void { return this.#sync.onChange(designDocId(this.#space) as DocumentId, cb as (doc: any) => void); }
- linkProject(project: LinkedProject): void {
- this.#sync.change(designDocId(this.#space) as DocumentId, `Link ${project.name}`, (d) => { d.linkedProjects[project.id] = project; });
+ // ── Frame CRUD ──
+
+ addFrame(frame: DesignFrame): void {
+ this.#sync.change(designDocId(this.#space) as DocumentId, `Add frame ${frame.id}`, (d) => {
+ d.document.frames[frame.id] = { ...frame, createdAt: frame.createdAt || Date.now(), updatedAt: Date.now() };
+ });
}
- unlinkProject(id: string): void {
- this.#sync.change(designDocId(this.#space) as DocumentId, `Unlink project`, (d) => { delete d.linkedProjects[id]; });
+
+ updateFrame(frameId: string, updates: Partial): void {
+ this.#sync.change(designDocId(this.#space) as DocumentId, `Update frame ${frameId}`, (d) => {
+ const existing = d.document.frames[frameId];
+ if (existing) {
+ Object.assign(existing, updates, { updatedAt: Date.now() });
+ }
+ });
+ }
+
+ deleteFrame(frameId: string): void {
+ this.#sync.change(designDocId(this.#space) as DocumentId, `Delete frame ${frameId}`, (d) => {
+ delete d.document.frames[frameId];
+ });
+ }
+
+ // ── Page CRUD ──
+
+ addPage(page: DesignPage): void {
+ this.#sync.change(designDocId(this.#space) as DocumentId, `Add page ${page.number}`, (d) => {
+ d.document.pages[page.id] = page;
+ });
+ }
+
+ updatePage(pageId: string, updates: Partial): void {
+ this.#sync.change(designDocId(this.#space) as DocumentId, `Update page ${pageId}`, (d) => {
+ const existing = d.document.pages[pageId];
+ if (existing) Object.assign(existing, updates);
+ });
+ }
+
+ // ── Document metadata ──
+
+ setTitle(title: string): void {
+ this.#sync.change(designDocId(this.#space) as DocumentId, `Set title`, (d) => {
+ d.document.title = title;
+ });
+ }
+
+ // ── Bulk update from bridge state ──
+
+ applyBridgeState(pages: DesignPage[], frames: DesignFrame[]): void {
+ this.#sync.change(designDocId(this.#space) as DocumentId, `Sync from Scribus`, (d) => {
+ // Update pages
+ for (const page of pages) {
+ d.document.pages[page.id] = page;
+ }
+ // Update frames — merge, don't replace, to preserve CRDT metadata
+ for (const frame of frames) {
+ const existing = d.document.frames[frame.id];
+ if (existing) {
+ Object.assign(existing, frame, { updatedAt: Date.now() });
+ } else {
+ d.document.frames[frame.id] = { ...frame, createdAt: Date.now(), updatedAt: Date.now() };
+ }
+ }
+ });
}
async disconnect(): Promise { await this.#sync.flush(); this.#sync.disconnect(); }
diff --git a/modules/rdesign/mod.ts b/modules/rdesign/mod.ts
index 39b8c32..e16b20d 100644
--- a/modules/rdesign/mod.ts
+++ b/modules/rdesign/mod.ts
@@ -1,25 +1,52 @@
/**
- * Design module — collaborative design workspace via Affine.
+ * rDesign module — collaborative DTP workspace via Scribus + noVNC.
*
- * Wraps the Affine instance as an external app embedded in the rSpace shell.
+ * Embeds Scribus running in a Docker container with noVNC for browser access.
+ * Includes a generative design agent powered by Gemini tool-calling.
*/
import { Hono } from "hono";
-import { renderShell, renderExternalAppShell } from "../../server/shell";
+import { renderShell } from "../../server/shell";
import { getModuleInfoList } from "../../shared/module";
import type { RSpaceModule } from "../../shared/module";
+import { designAgentRoutes } from "./design-agent-route";
const routes = new Hono();
-const AFFINE_URL = "https://affine.cosmolocal.world";
+const SCRIBUS_NOVNC_URL = process.env.SCRIBUS_NOVNC_URL || "https://design.rspace.online";
+const SCRIBUS_BRIDGE_URL = process.env.SCRIBUS_BRIDGE_URL || "http://scribus-novnc:8765";
+
+// Mount design agent API routes
+routes.route("/", designAgentRoutes);
routes.get("/api/health", (c) => {
return c.json({ ok: true, module: "rdesign" });
});
+// Proxy bridge API calls from rspace to the Scribus container
+routes.all("/api/bridge/*", async (c) => {
+ const path = c.req.path.replace(/^.*\/api\/bridge/, "/api/scribus");
+ const bridgeSecret = process.env.SCRIBUS_BRIDGE_SECRET || "";
+ const headers: Record = { "Content-Type": "application/json" };
+ if (bridgeSecret) headers["X-Bridge-Secret"] = bridgeSecret;
+
+ try {
+ const url = `${SCRIBUS_BRIDGE_URL}${path}`;
+ const res = await fetch(url, {
+ method: c.req.method,
+ headers,
+ body: c.req.method !== "GET" ? await c.req.text() : undefined,
+ signal: AbortSignal.timeout(30_000),
+ });
+ const data = await res.json();
+ return c.json(data, res.status as any);
+ } catch (e: any) {
+ return c.json({ error: `Bridge proxy failed: ${e.message}` }, 502);
+ }
+});
+
routes.get("/", (c) => {
const space = c.req.param("space") || "demo";
- const dataSpace = c.get("effectiveSpace") || space;
const view = c.req.query("view");
if (view === "demo") {
@@ -29,50 +56,260 @@ routes.get("/", (c) => {
spaceSlug: space,
modules: getModuleInfoList(),
theme: "dark",
- body: `
-
🎯
-
rDesign
-
Collaborative design workspace powered by Affine. Whiteboard, docs, and kanban — all in one tool for your community.
-
Open Affine
-
`,
+ body: renderDesignLanding(),
}));
}
- // Default: show the external app directly
- return c.html(renderExternalAppShell({
- title: `${space} — Affine | rSpace`,
+ // Default: show the design agent UI (text-driven, no iframe)
+ return c.html(renderShell({
+ title: `${space} — rDesign | rSpace`,
moduleId: "rdesign",
spaceSlug: space,
modules: getModuleInfoList(),
- appUrl: AFFINE_URL,
- appName: "Affine",
theme: "dark",
+ body: renderDesignApp(space, SCRIBUS_NOVNC_URL),
+ styles: ``,
+ scripts: ``,
}));
});
+const RDESIGN_CSS = `
+#rdesign-app { max-width:900px; margin:0 auto; padding:0.5rem 0; }
+.rd-panel { background:var(--rs-bg-surface,#1e1e2e); border:1px solid var(--rs-border,#334155); border-radius:12px; overflow:hidden; }
+.rd-prompt { padding:16px; border-bottom:1px solid var(--rs-border,#334155); }
+.rd-prompt textarea { width:100%; padding:12px; border:2px solid var(--rs-border,#334155); border-radius:8px; font-size:14px; resize:none; outline:none; font-family:inherit; background:var(--rs-bg-elevated,#0f172a); color:var(--rs-text-primary,#e2e8f0); box-sizing:border-box; }
+.rd-prompt textarea:focus { border-color:#7c3aed; }
+.rd-prompt textarea::placeholder { color:#64748b; }
+.rd-btn-row { display:flex; gap:8px; margin-top:10px; align-items:center; }
+.rd-btn { padding:8px 18px; border-radius:8px; border:none; font-size:13px; font-weight:600; cursor:pointer; transition:all 0.15s; }
+.rd-btn-primary { background:linear-gradient(135deg,#7c3aed,#a78bfa); color:white; }
+.rd-btn-primary:hover { background:linear-gradient(135deg,#6d28d9,#8b5cf6); transform:translateY(-1px); }
+.rd-btn-primary:disabled { opacity:0.5; cursor:default; transform:none; }
+.rd-btn-secondary { background:var(--rs-bg-elevated,#1e293b); color:var(--rs-text-secondary,#94a3b8); border:1px solid var(--rs-border,#334155); }
+.rd-btn-secondary:hover { background:var(--rs-bg-hover,#334155); color:var(--rs-text-primary,#e2e8f0); }
+.rd-badge { font-size:10px; padding:3px 8px; border-radius:10px; text-transform:uppercase; letter-spacing:0.5px; background:rgba(124,58,237,0.2); color:#a78bfa; margin-left:auto; }
+.rd-body { display:flex; min-height:400px; }
+.rd-steps { flex:1; overflow-y:auto; padding:12px; font-size:12px; border-right:1px solid var(--rs-border,#334155); max-height:500px; }
+.rd-preview { flex:1; display:flex; flex-direction:column; align-items:center; justify-content:center; padding:16px; min-width:300px; }
+.rd-preview img { max-width:100%; max-height:400px; border-radius:8px; border:1px solid var(--rs-border,#334155); }
+.rd-empty { color:#64748b; text-align:center; font-size:13px; padding:3rem 1rem; }
+.rd-step { padding:6px 0; border-bottom:1px solid var(--rs-border,#1e293b); display:flex; align-items:flex-start; gap:8px; }
+.rd-step-icon { flex-shrink:0; width:18px; height:18px; border-radius:50%; display:flex; align-items:center; justify-content:center; font-size:10px; margin-top:1px; }
+.rd-step-icon.thinking { background:#1e3a5f; color:#60a5fa; }
+.rd-step-icon.executing { background:#422006; color:#fbbf24; }
+.rd-step-icon.done { background:#064e3b; color:#34d399; }
+.rd-step-icon.error { background:#450a0a; color:#f87171; }
+.rd-step-content { flex:1; line-height:1.4; color:var(--rs-text-secondary,#94a3b8); }
+.rd-step-tool { font-family:monospace; background:var(--rs-bg-elevated,#0f172a); padding:1px 4px; border-radius:3px; font-size:11px; color:#a78bfa; }
+.rd-export { display:flex; gap:8px; padding:12px 16px; border-top:1px solid var(--rs-border,#334155); justify-content:center; }
+.rd-spinner { display:inline-block; width:12px; height:12px; border:2px solid rgba(255,255,255,0.3); border-top-color:#fff; border-radius:50%; animation:rd-spin 0.8s linear infinite; vertical-align:middle; margin-right:4px; }
+@keyframes rd-spin { to { transform:rotate(360deg); } }
+@media (max-width:700px) {
+ .rd-body { flex-direction:column; }
+ .rd-steps { border-right:none; border-bottom:1px solid var(--rs-border,#334155); max-height:250px; }
+}
+`;
+
+const RDESIGN_JS = `
+(function() {
+ var brief = document.getElementById('rdesign-brief');
+ var generateBtn = document.getElementById('rdesign-generate');
+ var stopBtn = document.getElementById('rdesign-stop');
+ var badge = document.getElementById('rdesign-badge');
+ var stepsEl = document.getElementById('rdesign-steps');
+ var previewEl = document.getElementById('rdesign-preview');
+ var exportRow = document.getElementById('rdesign-export');
+ var refineBtn = document.getElementById('rdesign-refine');
+ var abortController = null;
+ var state = 'idle';
+ if (!brief) return;
+
+ function setState(s) {
+ state = s;
+ badge.textContent = s.charAt(0).toUpperCase() + s.slice(1);
+ var working = s !== 'idle' && s !== 'done' && s !== 'error';
+ generateBtn.disabled = working;
+ generateBtn.innerHTML = working ? ' Working...' : 'Generate Design';
+ stopBtn.style.display = working ? '' : 'none';
+ exportRow.style.display = s === 'done' ? '' : 'none';
+ brief.disabled = working;
+ }
+
+ function addStep(icon, cls, text) {
+ var ph = stepsEl.querySelector('.rd-empty');
+ if (ph) ph.remove();
+ var div = document.createElement('div');
+ div.className = 'rd-step';
+ div.innerHTML = '' + icon + '
' + text + '
';
+ stepsEl.appendChild(div);
+ stepsEl.scrollTop = stepsEl.scrollHeight;
+ }
+
+ function processEvent(data) {
+ switch (data.action) {
+ case 'starting_scribus': addStep('~', 'thinking', data.status || 'Starting Scribus...'); break;
+ case 'scribus_ready': addStep('\\u2713', 'done', 'Scribus ready'); break;
+ case 'thinking': setState('planning'); addStep('~', 'thinking', data.status || 'Thinking...'); break;
+ case 'executing':
+ setState('executing');
+ addStep('\\u25B6', 'executing', (data.status || 'Executing') + ': ' + data.tool + '');
+ break;
+ case 'tool_result':
+ if (data.result && data.result.error) {
+ addStep('!', 'error', data.tool + ' failed: ' + data.result.error);
+ } else {
+ addStep('\\u2713', 'done', data.tool + ' completed');
+ }
+ break;
+ case 'verifying': setState('verifying'); addStep('~', 'thinking', data.status || 'Verifying...'); break;
+ case 'complete': addStep('\\u2713', 'done', data.message || 'Design complete'); break;
+ case 'done':
+ addStep('\\u2713', 'done', data.status || 'Done!');
+ if (data.state && data.state.frames) {
+ addStep('\\u2713', 'done', data.state.frames.length + ' frame(s) in document');
+ }
+ if (data.state && data.state.frames && data.state.frames.length > 0) {
+ renderLayoutPreview(data.state);
+ }
+ break;
+ case 'error':
+ addStep('!', 'error', data.error || 'Unknown error');
+ setState('error');
+ break;
+ }
+ }
+
+ function renderLayoutPreview(docState) {
+ var pages = docState.pages || [];
+ var frames = docState.frames || [];
+ var page = pages[0] || { width: 210, height: 297 };
+ var maxW = 350, maxH = 400;
+ var scale = Math.min(maxW / page.width, maxH / page.height);
+ var pw = Math.round(page.width * scale);
+ var pht = Math.round(page.height * scale);
+ var svg = '';
+ previewEl.innerHTML = svg + '' + frames.length + ' frames on ' + page.width + '\\u00d7' + page.height + 'mm page
';
+ }
+
+ function generate() {
+ var text = brief.value.trim();
+ if (!text || (state !== 'idle' && state !== 'done' && state !== 'error')) return;
+ stepsEl.innerHTML = '';
+ previewEl.innerHTML = '';
+ setState('planning');
+ abortController = new AbortController();
+ var space = document.getElementById('rdesign-app').dataset.space || 'demo';
+ fetch('/' + space + '/rdesign/api/design-agent', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ brief: text, space: space }),
+ signal: abortController.signal,
+ }).then(function(res) {
+ if (!res.ok || !res.body) { addStep('!', 'error', 'Request failed: ' + res.status); setState('error'); return; }
+ var reader = res.body.getReader();
+ var decoder = new TextDecoder();
+ var buffer = '';
+ function read() {
+ reader.read().then(function(result) {
+ if (result.done) { if (state !== 'error') setState('done'); abortController = null; return; }
+ buffer += decoder.decode(result.value, { stream: true });
+ var lines = buffer.split('\\n');
+ buffer = lines.pop() || '';
+ for (var j = 0; j < lines.length; j++) {
+ if (lines[j].indexOf('data:') === 0) {
+ try { processEvent(JSON.parse(lines[j].substring(5).trim())); } catch(e) {}
+ }
+ }
+ read();
+ });
+ }
+ read();
+ }).catch(function(e) {
+ if (e.name !== 'AbortError') { addStep('!', 'error', 'Error: ' + e.message); setState('error'); }
+ abortController = null;
+ });
+ }
+
+ generateBtn.addEventListener('click', generate);
+ stopBtn.addEventListener('click', function() { if (abortController) abortController.abort(); setState('idle'); addStep('!', 'error', 'Stopped by user'); });
+ brief.addEventListener('keydown', function(e) { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); generate(); } });
+ refineBtn.addEventListener('click', function() { brief.focus(); brief.select(); });
+})();
+`;
+
+function renderDesignApp(space: string, novncUrl: string): string {
+ return `
+
+
+
+
+
+
+ Idle
+
+
+
+
+
+ Enter a design brief above and click Generate Design.
+ The agent will create frames, text, and shapes in Scribus step by step.
+
+
+
+
+
📐
+ Preview will appear here
after generation completes
+
+
+
+
+
+
`;
+}
+
function renderDesignLanding(): string {
return `
-
🎯
-
rDesign
-
Collaborative design workspace powered by Affine. Whiteboard, docs, and kanban — all in one tool for your community.
+
🎯
+
rDesign
+
AI-powered DTP workspace. Describe what you want and the design agent builds it in Scribus — posters, flyers, brochures, and print-ready documents.
+
Text in, design out. No mouse interaction needed.
+
Open rDesign
`;
}
export const designModule: RSpaceModule = {
id: "rdesign",
name: "rDesign",
- icon: "🎯",
- description: "Collaborative design workspace with whiteboard and docs",
+ icon: "\u{1f3af}",
+ description: "AI-powered DTP workspace — text in, design out",
scoping: { defaultScope: 'global', userConfigurable: false },
routes,
landingPage: renderDesignLanding,
- externalApp: { url: AFFINE_URL, name: "Affine" },
feeds: [
- { id: "design-assets", name: "Design Assets", kind: "resource", description: "Design files, mockups, and whiteboard exports" },
+ { id: "design-assets", name: "Design Assets", kind: "resource", description: "Design files, layouts, and print-ready exports" },
],
acceptsFeeds: ["data", "resource"],
outputPaths: [
- { path: "designs", name: "Designs", icon: "🎯", description: "Design files and mockups" },
- { path: "templates", name: "Templates", icon: "📐", description: "Reusable design templates" },
+ { path: "designs", name: "Designs", icon: "\u{1f3af}", description: "Design files and layouts" },
+ { path: "templates", name: "Templates", icon: "\u{1f4d0}", description: "Reusable design templates" },
],
};
diff --git a/modules/rdesign/schemas.ts b/modules/rdesign/schemas.ts
index 269ce69..b60df0f 100644
--- a/modules/rdesign/schemas.ts
+++ b/modules/rdesign/schemas.ts
@@ -1,36 +1,87 @@
/**
* rDesign Automerge document schemas.
*
- * Syncs linked Affine design projects per space.
- * Actual design data lives in the Affine instance.
+ * Syncs full design state (pages, frames) per space via CRDT.
+ * Source of truth for collaborative editing — Scribus state is
+ * periodically reconciled via sla-bridge.ts.
*
- * DocId format: {space}:design:projects
+ * DocId format: {space}:design:doc
*/
import type { DocSchema } from '../../shared/local-first/document';
-export interface LinkedProject {
+export interface DesignFrame {
id: string;
- url: string;
- name: string;
- addedBy: string | null;
- addedAt: number;
+ type: 'text' | 'image' | 'rect' | 'ellipse';
+ page: number;
+ x: number;
+ y: number;
+ width: number;
+ height: number;
+ text?: string;
+ fontSize?: number;
+ fontName?: string;
+ imageUrl?: string;
+ imagePath?: string;
+ shapeType?: string;
+ fill?: string;
+ stroke?: string;
+ strokeWidth?: number;
+ createdBy?: string;
+ createdAt?: number;
+ updatedAt?: number;
+}
+
+export interface DesignPage {
+ id: string;
+ number: number;
+ width: number;
+ height: number;
+ margins: number;
+ background?: string;
}
export interface DesignDoc {
- meta: { module: string; collection: string; version: number; spaceSlug: string; createdAt: number };
- linkedProjects: Record;
+ meta: {
+ module: string;
+ collection: string;
+ version: number;
+ spaceSlug: string;
+ createdAt: number;
+ };
+ document: {
+ title: string;
+ unit: string;
+ pages: Record;
+ frames: Record;
+ };
}
export const designSchema: DocSchema = {
module: 'design',
- collection: 'projects',
- version: 1,
+ collection: 'doc',
+ version: 2,
init: (): DesignDoc => ({
- meta: { module: 'design', collection: 'projects', version: 1, spaceSlug: '', createdAt: Date.now() },
- linkedProjects: {},
+ meta: { module: 'design', collection: 'doc', version: 2, spaceSlug: '', createdAt: Date.now() },
+ document: {
+ title: 'Untitled Design',
+ unit: 'mm',
+ pages: {},
+ frames: {},
+ },
}),
- migrate: (doc: any) => { if (!doc.linkedProjects) doc.linkedProjects = {}; doc.meta.version = 1; return doc; },
+ migrate: (doc: any) => {
+ // v1 → v2: migrate from LinkedProject schema to full design state
+ if (doc.linkedProjects && !doc.document) {
+ doc.document = { title: 'Untitled Design', unit: 'mm', pages: {}, frames: {} };
+ delete doc.linkedProjects;
+ }
+ if (!doc.document) doc.document = { title: 'Untitled Design', unit: 'mm', pages: {}, frames: {} };
+ if (!doc.document.pages) doc.document.pages = {};
+ if (!doc.document.frames) doc.document.frames = {};
+ doc.meta.version = 2;
+ return doc;
+ },
};
-export function designDocId(space: string) { return `${space}:design:projects` as const; }
+export function designDocId(space: string) { return `${space}:design:doc` as const; }
diff --git a/modules/rdesign/sla-bridge.ts b/modules/rdesign/sla-bridge.ts
new file mode 100644
index 0000000..c0f68f7
--- /dev/null
+++ b/modules/rdesign/sla-bridge.ts
@@ -0,0 +1,277 @@
+/**
+ * SLA Bridge — bidirectional conversion between Automerge CRDT state and Scribus bridge commands.
+ *
+ * designDocToScribusCommands(doc) → array of bridge commands (for replaying state into Scribus)
+ * scribusStateToDesignDocPatch(state, doc) → diff patches (for updating CRDT from Scribus changes)
+ */
+
+import type { DesignDoc, DesignFrame, DesignPage } from './schemas';
+
+// ── Types from the bridge ──
+
+interface BridgeCommand {
+ action: string;
+ args: Record;
+}
+
+interface ScribusFrameState {
+ name: string;
+ type: string; // TextFrame, ImageFrame, Rectangle, Ellipse, etc.
+ x: number;
+ y: number;
+ width: number;
+ height: number;
+ text?: string;
+ fontSize?: number;
+ fontName?: string;
+}
+
+interface ScribusPageState {
+ number: number;
+ width: number;
+ height: number;
+}
+
+interface ScribusState {
+ pages: ScribusPageState[];
+ frames: ScribusFrameState[];
+}
+
+// ── CRDT → Scribus commands ──
+
+/**
+ * Convert a full DesignDoc into an ordered list of bridge commands
+ * to recreate the design in Scribus from scratch.
+ */
+export function designDocToScribusCommands(doc: DesignDoc): BridgeCommand[] {
+ const commands: BridgeCommand[] = [];
+ const pages = Object.values(doc.document.pages).sort((a, b) => a.number - b.number);
+
+ // Create document with first page dimensions (or A4 default)
+ const firstPage = pages[0];
+ commands.push({
+ action: 'new_document',
+ args: {
+ width: firstPage?.width || 210,
+ height: firstPage?.height || 297,
+ margins: firstPage?.margins || 10,
+ pages: pages.length || 1,
+ },
+ });
+
+ // Add frames sorted by page, then by creation time
+ const frames = Object.values(doc.document.frames).sort((a, b) => {
+ if (a.page !== b.page) return a.page - b.page;
+ return (a.createdAt || 0) - (b.createdAt || 0);
+ });
+
+ for (const frame of frames) {
+ switch (frame.type) {
+ case 'text':
+ commands.push({
+ action: 'add_text_frame',
+ args: {
+ x: frame.x,
+ y: frame.y,
+ width: frame.width,
+ height: frame.height,
+ text: frame.text || '',
+ fontSize: frame.fontSize || 12,
+ fontName: frame.fontName || 'Liberation Sans',
+ name: frame.id,
+ },
+ });
+ break;
+
+ case 'image':
+ commands.push({
+ action: 'add_image_frame',
+ args: {
+ x: frame.x,
+ y: frame.y,
+ width: frame.width,
+ height: frame.height,
+ imagePath: frame.imagePath || '',
+ name: frame.id,
+ },
+ });
+ break;
+
+ case 'rect':
+ case 'ellipse':
+ commands.push({
+ action: 'add_shape',
+ args: {
+ shapeType: frame.type === 'ellipse' ? 'ellipse' : 'rect',
+ x: frame.x,
+ y: frame.y,
+ width: frame.width,
+ height: frame.height,
+ fill: frame.fill,
+ name: frame.id,
+ },
+ });
+ break;
+ }
+ }
+
+ return commands;
+}
+
+/**
+ * Convert a subset of changed commands (for incremental updates).
+ * Only generates commands for frames that differ from existing state.
+ */
+export function designDocToIncrementalCommands(
+ doc: DesignDoc,
+ changedFrameIds: string[],
+): BridgeCommand[] {
+ const commands: BridgeCommand[] = [];
+
+ for (const frameId of changedFrameIds) {
+ const frame = doc.document.frames[frameId];
+ if (!frame) {
+ // Frame was deleted
+ commands.push({ action: 'delete_frame', args: { name: frameId } });
+ continue;
+ }
+
+ // For simplicity, delete and recreate the frame
+ commands.push({ action: 'delete_frame', args: { name: frameId } });
+
+ switch (frame.type) {
+ case 'text':
+ commands.push({
+ action: 'add_text_frame',
+ args: {
+ x: frame.x, y: frame.y, width: frame.width, height: frame.height,
+ text: frame.text || '', fontSize: frame.fontSize || 12,
+ fontName: frame.fontName || 'Liberation Sans', name: frame.id,
+ },
+ });
+ break;
+ case 'image':
+ commands.push({
+ action: 'add_image_frame',
+ args: {
+ x: frame.x, y: frame.y, width: frame.width, height: frame.height,
+ imagePath: frame.imagePath || '', name: frame.id,
+ },
+ });
+ break;
+ case 'rect':
+ case 'ellipse':
+ commands.push({
+ action: 'add_shape',
+ args: {
+ shapeType: frame.type === 'ellipse' ? 'ellipse' : 'rect',
+ x: frame.x, y: frame.y, width: frame.width, height: frame.height,
+ fill: frame.fill, name: frame.id,
+ },
+ });
+ break;
+ }
+ }
+
+ return commands;
+}
+
+// ── Scribus → CRDT patches ──
+
+/** Map Scribus object type strings to our frame types. */
+function mapScribusType(scribusType: string): DesignFrame['type'] {
+ switch (scribusType) {
+ case 'TextFrame': return 'text';
+ case 'ImageFrame': return 'image';
+ case 'Rectangle': return 'rect';
+ case 'Ellipse': return 'ellipse';
+ default: return 'rect'; // fallback
+ }
+}
+
+/**
+ * Diff Scribus state against CRDT doc and produce patches.
+ * Returns arrays of frames to add, update, and delete in the CRDT.
+ */
+export function scribusStateToDesignDocPatch(
+ scribusState: ScribusState,
+ existingDoc: DesignDoc,
+): {
+ pagesToUpdate: DesignPage[];
+ framesToAdd: DesignFrame[];
+ framesToUpdate: Array<{ id: string; updates: Partial }>;
+ framesToDelete: string[];
+} {
+ const result = {
+ pagesToUpdate: [] as DesignPage[],
+ framesToAdd: [] as DesignFrame[],
+ framesToUpdate: [] as Array<{ id: string; updates: Partial }>,
+ framesToDelete: [] as string[],
+ };
+
+ // Pages
+ for (const sp of scribusState.pages) {
+ const pageId = `page_${sp.number}`;
+ result.pagesToUpdate.push({
+ id: pageId,
+ number: sp.number,
+ width: sp.width,
+ height: sp.height,
+ margins: 10, // Scribus doesn't report margins in getAllObjects; keep default
+ });
+ }
+
+ // Frames — build a set of Scribus frame names
+ const scribusFrameMap = new Map();
+ for (const sf of scribusState.frames) {
+ scribusFrameMap.set(sf.name, sf);
+ }
+
+ const existingFrameIds = new Set(Object.keys(existingDoc.document.frames));
+
+ // Check for new or updated frames from Scribus
+ for (const [name, sf] of scribusFrameMap) {
+ const existing = existingDoc.document.frames[name];
+ const frameType = mapScribusType(sf.type);
+
+ if (!existing) {
+ // New frame in Scribus not in CRDT
+ result.framesToAdd.push({
+ id: name,
+ type: frameType,
+ page: 1, // Scribus getAllObjects doesn't report per-frame page easily
+ x: sf.x,
+ y: sf.y,
+ width: sf.width,
+ height: sf.height,
+ text: sf.text,
+ fontSize: sf.fontSize,
+ fontName: sf.fontName,
+ });
+ } else {
+ // Check for position/size/text changes
+ const updates: Partial = {};
+ const tolerance = 0.5; // mm tolerance for position changes
+
+ if (Math.abs(existing.x - sf.x) > tolerance) updates.x = sf.x;
+ if (Math.abs(existing.y - sf.y) > tolerance) updates.y = sf.y;
+ if (Math.abs(existing.width - sf.width) > tolerance) updates.width = sf.width;
+ if (Math.abs(existing.height - sf.height) > tolerance) updates.height = sf.height;
+ if (sf.text !== undefined && sf.text !== existing.text) updates.text = sf.text;
+ if (sf.fontSize !== undefined && sf.fontSize !== existing.fontSize) updates.fontSize = sf.fontSize;
+
+ if (Object.keys(updates).length > 0) {
+ result.framesToUpdate.push({ id: name, updates });
+ }
+ }
+ }
+
+ // Check for frames deleted in Scribus
+ for (const frameId of existingFrameIds) {
+ if (!scribusFrameMap.has(frameId)) {
+ result.framesToDelete.push(frameId);
+ }
+ }
+
+ return result;
+}
diff --git a/server/index.ts b/server/index.ts
index 189e320..8b9306e 100644
--- a/server/index.ts
+++ b/server/index.ts
@@ -76,7 +76,7 @@ import { socialsModule } from "../modules/rsocials/mod";
import { meetsModule } from "../modules/rmeets/mod";
import { chatsModule } from "../modules/rchats/mod";
// import { docsModule } from "../modules/rdocs/mod";
-// import { designModule } from "../modules/rdesign/mod";
+import { designModule } from "../modules/rdesign/mod";
import { scheduleModule } from "../modules/rschedule/mod";
import { bnbModule } from "../modules/rbnb/mod";
import { vnbModule } from "../modules/rvnb/mod";
@@ -124,13 +124,13 @@ registerModule(chatsModule);
registerModule(bnbModule);
registerModule(vnbModule);
registerModule(crowdsurfModule);
+registerModule(designModule); // Scribus DTP + AI design agent
// De-emphasized modules (bottom of menu)
registerModule(forumModule);
registerModule(tubeModule);
registerModule(tripsModule);
registerModule(booksModule);
// registerModule(docsModule); // placeholder — not yet an rApp
-// registerModule(designModule); // placeholder — not yet an rApp
// ── Config ──
const PORT = Number(process.env.PORT) || 3000;