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:
parent
d74512ddcd
commit
77b7aba893
|
|
@ -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:
|
||||
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
flask==3.1.0
|
||||
flask-socketio==5.4.1
|
||||
watchdog==6.0.0
|
||||
|
|
@ -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")
|
||||
|
|
@ -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)))
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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.`;
|
||||
|
|
@ -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>🎯</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">×</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">📐</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("✓", "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"}: <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("✓", "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);
|
||||
}
|
||||
|
|
@ -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 });
|
||||
});
|
||||
|
|
@ -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(); }
|
||||
|
|
|
|||
|
|
@ -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, '<');
|
||||
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">📐</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">🎯</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" },
|
||||
],
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in New Issue