feat(rdesign): Scribus noVNC + AI design agent + CRDT sync

Replace Affine wrapper with full Scribus DTP stack:
- Docker container: Scribus 1.5 + Xvfb + x11vnc + noVNC + Python bridge
- Bridge API: Flask server (port 8765) proxying to Scribus Python API via Unix socket
- Design agent: Gemini tool-calling loop drives Scribus headlessly from text briefs
- CRDT sync: Automerge schema v2 with pages/frames, bidirectional SLA bridge
- Canvas tool: folk-design-agent shape + create_design_agent in canvas-tools registry
- Module UI: inline text prompt + step log + SVG layout preview (no iframe)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-03-24 10:06:04 -07:00
parent d74512ddcd
commit 77b7aba893
16 changed files with 2169 additions and 50 deletions

View File

@ -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:

View File

@ -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"]

View File

@ -0,0 +1,3 @@
flask==3.1.0
flask-socketio==5.4.1
watchdog==6.0.0

View File

@ -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")

View File

@ -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)))

View File

@ -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

View File

@ -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

View File

@ -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);

View File

@ -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.`;

457
lib/folk-design-agent.ts Normal file
View File

@ -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`
<div class="header">
<span class="header-title">
<span>&#x1f3af;</span>
<span>rDesign Agent</span>
</span>
<div class="header-actions">
<span class="state-badge" data-ref="state-badge">Idle</span>
<button class="close-btn" title="Close">&times;</button>
</div>
</div>
<div class="content">
<div class="prompt-area">
<textarea class="prompt-input" placeholder="Describe the design you want to create..." rows="3"></textarea>
<div class="btn-row">
<button class="btn btn-primary" data-ref="generate-btn">Generate Design</button>
<button class="btn btn-secondary" data-ref="stop-btn" style="display:none">Stop</button>
</div>
</div>
<div class="status-area" data-ref="status-area">
<div class="placeholder">
<span class="placeholder-icon">&#x1f4d0;</span>
<span>Enter a design brief to get started.<br>The agent will create a Scribus document step by step.</span>
</div>
</div>
<div class="export-row" data-ref="export-row" style="display:none">
<a class="btn btn-secondary" href="https://design.rspace.online" target="_blank" rel="noopener">Open in Scribus</a>
</div>
</div>
`;
// 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
? '<span class="spinner"></span> 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 = `<div class="step-icon ${cls}">${icon}</div><div class="step-content">${text}</div>`;
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("&#x2713;", "done", "Scribus ready");
break;
case "thinking":
this.#setState("planning");
this.#addStep("~", "thinking", data.status || "Thinking...");
break;
case "executing":
this.#setState("executing");
this.#addStep("&#x25B6;", "executing",
`${data.status || "Executing"}: <span class="step-tool">${data.tool}</span>`);
break;
case "tool_result":
if (data.result?.error) {
this.#addStep("!", "error", `${data.tool} failed: ${data.result.error}`);
} else {
this.#addStep("&#x2713;", "done", `${data.tool} completed`);
}
break;
case "verifying":
this.#setState("verifying");
this.#addStep("~", "thinking", data.status || "Verifying...");
break;
case "complete":
this.#addStep("&#x2713;", "done", data.message || "Design complete");
break;
case "done":
this.#addStep("&#x2713;", "done", data.status || "Done!");
if (data.state?.frames) {
this.#addStep("&#x2713;", "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);
}

View File

@ -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<string, any> = {}): Promise<any> {
const headers: Record<string, string> = { "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<any> {
const headers: Record<string, string> = {};
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<any> {
const headers: Record<string, string> = { "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<string, any>, space: string): Promise<any> {
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<string, any>): Promise<any> {
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<any> {
// 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 });
});

View File

@ -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<void> {
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<DesignDoc>(docId, designSchema, binary);
await this.#sync.preloadSyncStates(cachedIds);
@ -44,11 +44,70 @@ export class DesignLocalFirstClient {
getDoc(): DesignDoc | undefined { return this.#documents.get<DesignDoc>(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<DesignDoc>(designDocId(this.#space) as DocumentId, `Link ${project.name}`, (d) => { d.linkedProjects[project.id] = project; });
// ── Frame CRUD ──
addFrame(frame: DesignFrame): void {
this.#sync.change<DesignDoc>(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<DesignDoc>(designDocId(this.#space) as DocumentId, `Unlink project`, (d) => { delete d.linkedProjects[id]; });
updateFrame(frameId: string, updates: Partial<DesignFrame>): void {
this.#sync.change<DesignDoc>(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<DesignDoc>(designDocId(this.#space) as DocumentId, `Delete frame ${frameId}`, (d) => {
delete d.document.frames[frameId];
});
}
// ── Page CRUD ──
addPage(page: DesignPage): void {
this.#sync.change<DesignDoc>(designDocId(this.#space) as DocumentId, `Add page ${page.number}`, (d) => {
d.document.pages[page.id] = page;
});
}
updatePage(pageId: string, updates: Partial<DesignPage>): void {
this.#sync.change<DesignDoc>(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<DesignDoc>(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<DesignDoc>(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<void> { await this.#sync.flush(); this.#sync.disconnect(); }

View File

@ -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<string, string> = { "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: `<div style="max-width:640px;margin:0 auto;padding:3rem 1rem;text-align:center">
<div style="font-size:3rem;margin-bottom:1rem">🎯</div>
<h2 style="font-size:1.5rem;margin-bottom:0.75rem;background:linear-gradient(135deg,#14b8a6,#22d3ee);-webkit-background-clip:text;-webkit-text-fill-color:transparent">rDesign</h2>
<p style="color:#94a3b8;margin-bottom:2rem;line-height:1.6">Collaborative design workspace powered by Affine. Whiteboard, docs, and kanban all in one tool for your community.</p>
<a href="?" class="rapp-nav__btn--app-toggle" style="display:inline-block;padding:10px 24px;font-size:0.9rem">Open Affine</a>
</div>`,
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: `<style>${RDESIGN_CSS}</style>`,
scripts: `<script>${RDESIGN_JS}</script>`,
}));
});
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 ? '<span class="rd-spinner"></span> 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 = '<div class="rd-step-icon ' + cls + '">' + icon + '</div><div class="rd-step-content">' + text + '</div>';
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') + ': <span class="rd-step-tool">' + data.tool + '</span>');
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 = '<svg width="' + pw + '" height="' + pht + '" viewBox="0 0 ' + page.width + ' ' + page.height + '" style="background:white;border:1px solid #334155;border-radius:6px;box-shadow:0 4px 12px rgba(0,0,0,0.3)">';
for (var i = 0; i < frames.length; i++) {
var f = frames[i];
var fx = f.x || 0, fy = f.y || 0, fw = f.width || 50, fh = f.height || 20;
if (f.type === 'TextFrame' || f.type === 'text') {
svg += '<rect x="'+fx+'" y="'+fy+'" width="'+fw+'" height="'+fh+'" fill="none" stroke="#7c3aed" stroke-width="0.5" stroke-dasharray="2,1" rx="1"/>';
var fs = Math.min((f.fontSize || 12) * 0.35, fh * 0.7);
var txt = (f.text || '').substring(0, 40).replace(/</g, '&lt;');
svg += '<text x="'+(fx+2)+'" y="'+(fy+fs+1)+'" font-size="'+fs+'" fill="#334155" font-family="sans-serif">'+txt+'</text>';
} else if (f.type === 'ImageFrame' || f.type === 'image') {
svg += '<rect x="'+fx+'" y="'+fy+'" width="'+fw+'" height="'+fh+'" fill="#f1f5f9" stroke="#94a3b8" stroke-width="0.5" rx="1"/>';
svg += '<text x="'+(fx+fw/2)+'" y="'+(fy+fh/2+2)+'" font-size="4" fill="#94a3b8" text-anchor="middle" font-family="sans-serif">IMAGE</text>';
} else {
svg += '<rect x="'+fx+'" y="'+fy+'" width="'+fw+'" height="'+fh+'" fill="'+(f.fill||'#e2e8f0')+'" stroke="#94a3b8" stroke-width="0.3" rx="1"/>';
}
}
svg += '</svg>';
previewEl.innerHTML = svg + '<div style="color:#64748b;font-size:11px;margin-top:8px">' + frames.length + ' frames on ' + page.width + '\\u00d7' + page.height + 'mm page</div>';
}
function generate() {
var text = brief.value.trim();
if (!text || (state !== 'idle' && state !== 'done' && state !== 'error')) return;
stepsEl.innerHTML = '';
previewEl.innerHTML = '<div class="rd-empty"><div style="font-size:2rem;margin-bottom:0.5rem">\\u{1f4d0}</div>Generating...</div>';
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 `<div id="rdesign-app" data-space="${space}">
<div class="rd-panel">
<div class="rd-prompt">
<textarea id="rdesign-brief" rows="3" placeholder="e.g. Create an A4 event poster for 'Mushroom Festival 2026' with a bold title, date (June 14-15), location, and a large image area for a forest photo"></textarea>
<div class="rd-btn-row">
<button id="rdesign-generate" class="rd-btn rd-btn-primary">Generate Design</button>
<button id="rdesign-stop" class="rd-btn rd-btn-secondary" style="display:none">Stop</button>
<span id="rdesign-badge" class="rd-badge">Idle</span>
</div>
</div>
<div class="rd-body">
<div id="rdesign-steps" class="rd-steps">
<div class="rd-empty">
Enter a design brief above and click <strong>Generate Design</strong>.<br>
The agent will create frames, text, and shapes in Scribus step by step.
</div>
</div>
<div id="rdesign-preview" class="rd-preview">
<div class="rd-empty">
<div style="font-size:2rem;margin-bottom:0.5rem">&#x1f4d0;</div>
Preview will appear here<br>after generation completes
</div>
</div>
</div>
<div id="rdesign-export" class="rd-export" style="display:none">
<a class="rd-btn rd-btn-secondary" href="${novncUrl}" target="_blank" rel="noopener">Open Scribus (noVNC)</a>
<button id="rdesign-refine" class="rd-btn rd-btn-secondary">Refine</button>
</div>
</div>
</div>`;
}
function renderDesignLanding(): string {
return `<div style="max-width:640px;margin:0 auto;padding:3rem 1rem;text-align:center">
<div style="font-size:3rem;margin-bottom:1rem">🎯</div>
<h2 style="font-size:1.5rem;margin-bottom:0.75rem;background:linear-gradient(135deg,#14b8a6,#22d3ee);-webkit-background-clip:text;-webkit-text-fill-color:transparent">rDesign</h2>
<p style="color:#94a3b8;margin-bottom:2rem;line-height:1.6">Collaborative design workspace powered by Affine. Whiteboard, docs, and kanban all in one tool for your community.</p>
<div style="font-size:3rem;margin-bottom:1rem">&#x1f3af;</div>
<h2 style="font-size:1.5rem;margin-bottom:0.75rem;background:linear-gradient(135deg,#7c3aed,#a78bfa);-webkit-background-clip:text;-webkit-text-fill-color:transparent">rDesign</h2>
<p style="color:#94a3b8;margin-bottom:1rem;line-height:1.6">AI-powered DTP workspace. Describe what you want and the design agent builds it in Scribus posters, flyers, brochures, and print-ready documents.</p>
<p style="color:#64748b;font-size:0.85rem;margin-bottom:2rem">Text in, design out. No mouse interaction needed.</p>
<a href="?" class="rapp-nav__btn--app-toggle" style="display:inline-block;padding:10px 24px;font-size:0.9rem">Open rDesign</a>
</div>`;
}
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" },
],
};

View File

@ -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<string, LinkedProject>;
meta: {
module: string;
collection: string;
version: number;
spaceSlug: string;
createdAt: number;
};
document: {
title: string;
unit: string;
pages: Record<string, DesignPage>;
frames: Record<string, DesignFrame>;
};
}
export const designSchema: DocSchema<DesignDoc> = {
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; }

View File

@ -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<string, any>;
}
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<DesignFrame> }>;
framesToDelete: string[];
} {
const result = {
pagesToUpdate: [] as DesignPage[],
framesToAdd: [] as DesignFrame[],
framesToUpdate: [] as Array<{ id: string; updates: Partial<DesignFrame> }>,
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<string, ScribusFrameState>();
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<DesignFrame> = {};
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;
}

View File

@ -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;