/** * rDesign module — collaborative DTP workspace via Scribus + noVNC. * * 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 } 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 SCRIBUS_NOVNC_URL = process.env.SCRIBUS_NOVNC_URL || "https://design.rspace.online"; const SCRIBUS_BRIDGE_URL = process.env.SCRIBUS_BRIDGE_URL || "http://scribus-novnc:8765"; // Mount design agent API routes routes.route("/", designAgentRoutes); routes.get("/api/health", (c) => { return c.json({ ok: true, module: "rdesign" }); }); // Proxy bridge API calls from rspace to the Scribus container routes.all("/api/bridge/*", async (c) => { const path = c.req.path.replace(/^.*\/api\/bridge/, "/api/scribus"); const bridgeSecret = process.env.SCRIBUS_BRIDGE_SECRET || ""; const headers: Record = { "Content-Type": "application/json" }; if (bridgeSecret) headers["X-Bridge-Secret"] = bridgeSecret; try { const url = `${SCRIBUS_BRIDGE_URL}${path}`; const res = await fetch(url, { method: c.req.method, headers, body: c.req.method !== "GET" ? await c.req.text() : undefined, signal: AbortSignal.timeout(30_000), }); const data = await res.json(); return c.json(data, res.status as any); } catch (e: any) { return c.json({ error: `Bridge proxy failed: ${e.message}` }, 502); } }); routes.get("/", (c) => { const space = c.req.param("space") || "demo"; const view = c.req.query("view"); if (view === "demo") { return c.html(renderShell({ title: `${space} — Design | rSpace`, moduleId: "rdesign", spaceSlug: space, modules: getModuleInfoList(), body: renderDesignLanding(), })); } // Default: show the design agent UI (text-driven, no iframe) return c.html(renderShell({ title: `${space} — rDesign | rSpace`, moduleId: "rdesign", spaceSlug: space, modules: getModuleInfoList(), body: renderDesignApp(space, SCRIBUS_NOVNC_URL), styles: ``, scripts: ``, })); }); const RDESIGN_CSS = ` /* Layout — prevent page scroll */ html:has(.rd-app), html:has(.rd-app) body { overflow:hidden; height:100vh; } .rd-app { display:flex; flex-direction:column; height:calc(100vh - 56px); overflow:hidden; } .rd-toolbar { display:flex; align-items:center; gap:8px; padding:6px 12px; border-bottom:1px solid var(--rs-border); background:var(--rs-bg-surface); flex-shrink:0; } .rd-split { display:flex; flex:1; overflow:hidden; } .rd-chat { flex:1; display:flex; flex-direction:column; min-width:300px; border-right:1px solid var(--rs-border); background:var(--rs-bg-page); } .rd-chat-messages { flex:1; overflow-y:auto; padding:12px; display:flex; flex-direction:column; gap:8px; } .rd-chat-input { display:flex; gap:8px; padding:8px 12px; border-top:1px solid var(--rs-border); background:var(--rs-bg-surface); align-items:flex-end; flex-shrink:0; } .rd-chat-input textarea { flex:1; padding:8px 12px; border:2px solid var(--rs-border); border-radius:8px; font-size:13px; resize:none; outline:none; font-family:inherit; background:var(--rs-bg-page); color:var(--rs-text-primary); box-sizing:border-box; max-height:120px; } .rd-chat-input textarea:focus { border-color:#7c3aed; } .rd-chat-input textarea::placeholder { color:var(--rs-text-secondary); } .rd-editor { flex:1; display:flex; flex-direction:column; min-width:300px; background:var(--rs-bg-page); } .rd-canvas { flex:1; overflow:auto; display:flex; align-items:center; justify-content:center; background:var(--rs-bg-surface-sunken); } .rd-statusbar { padding:4px 12px; font-size:11px; color:var(--rs-text-secondary); border-top:1px solid var(--rs-border); background:var(--rs-bg-surface); text-align:center; flex-shrink:0; } /* Buttons & badges */ .rd-btn { padding:6px 14px; border-radius:8px; border:none; font-size:13px; font-weight:600; cursor:pointer; transition:all 0.15s; text-decoration:none; display:inline-flex; align-items:center; } .rd-btn-primary { background:linear-gradient(135deg,#7c3aed,#a78bfa); color:white; } .rd-btn-primary:hover { background:linear-gradient(135deg,#6d28d9,#8b5cf6); } .rd-btn-primary:disabled { opacity:0.5; cursor:default; } .rd-btn-sm { padding:4px 10px; font-size:11px; } .rd-btn-secondary { background:var(--rs-bg-surface-raised); color:var(--rs-text-secondary); border:1px solid var(--rs-border); } .rd-btn-secondary:hover { background:var(--rs-bg-hover); color:var(--rs-text-primary); } .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; } .rd-badge.rd-badge-error { background:rgba(248,113,113,0.2); color:#f87171; } .rd-badge.rd-badge-done { background:rgba(52,211,153,0.2); color:#34d399; } /* Message bubbles */ .rd-msg-user { align-self:flex-end; background:linear-gradient(135deg,#7c3aed,#a78bfa); color:white; border-radius:12px 12px 4px 12px; max-width:80%; padding:8px 12px; font-size:13px; line-height:1.4; white-space:pre-wrap; word-break:break-word; } .rd-msg-agent { align-self:flex-start; background:var(--rs-bg-surface-raised); color:var(--rs-text-primary); border-radius:12px 12px 12px 4px; max-width:85%; padding:8px 12px; font-size:13px; line-height:1.4; white-space:pre-wrap; word-break:break-word; } .rd-msg-system { align-self:center; color:var(--rs-text-secondary); font-size:11px; padding:2px 8px; } .rd-msg-system.rd-msg-error { color:#f87171; } .rd-msg-tool { margin-top:6px; font-size:12px; } .rd-msg-tool summary { cursor:pointer; color:var(--rs-text-secondary); padding:2px 0; } .rd-msg-tool summary:hover { color:var(--rs-text-primary); } .rd-msg-tool .rd-tool-ok { color:#34d399; } .rd-msg-tool .rd-tool-fail { color:#f87171; } .rd-msg-tool pre { margin:4px 0 0; padding:6px 8px; background:var(--rs-bg-surface-sunken); border-radius:6px; font-size:11px; overflow-x:auto; white-space:pre-wrap; word-break:break-all; color:var(--rs-text-secondary); } /* Empty editor placeholder */ .rd-empty-editor { color:var(--rs-text-secondary); text-align:center; font-size:13px; padding:3rem 1rem; } /* SVG editor */ .rd-svg-wrap { position:relative; } .rd-svg-wrap svg { display:block; } /* Spinner */ .rd-spinner { display:inline-block; width:12px; height:12px; border:2px solid rgba(124,58,237,0.3); border-top-color:#a78bfa; border-radius:50%; animation:rd-spin 0.8s linear infinite; vertical-align:middle; margin-right:4px; } @keyframes rd-spin { to { transform:rotate(360deg); } } /* Mobile */ @media (max-width:800px) { .rd-split { flex-direction:column; } .rd-chat { min-height:50vh; max-height:50vh; border-right:none; border-bottom:1px solid var(--rs-border); } .rd-editor { min-height:50vh; } } `; const RDESIGN_JS = ` (function() { var app = document.getElementById('rdesign-app'); if (!app) return; var space = app.dataset.space || 'demo'; var messagesEl = document.getElementById('rd-messages'); var inputEl = document.getElementById('rd-input'); var sendBtn = document.getElementById('rd-send'); var badge = document.getElementById('rd-badge'); var canvasEl = document.getElementById('rd-canvas'); var statusEl = document.getElementById('rd-status'); // ── State ── var messages = []; var msgId = 0; var uiState = 'idle'; // idle | generating | done | error var abortController = null; var docState = { pages: [], frames: [] }; var selectedId = null; var dragState = null; // { frameIdx, startX, startY, origX, origY, mode:'move'|'resize', corner? } // ── UI State ── function setState(s) { uiState = s; var working = s === 'generating'; inputEl.disabled = working; sendBtn.disabled = working; sendBtn.innerHTML = working ? '' : 'Send'; badge.textContent = { idle:'Idle', generating:'Working', done:'Done', error:'Error' }[s] || s; badge.className = 'rd-badge' + (s === 'error' ? ' rd-badge-error' : s === 'done' ? ' rd-badge-done' : ''); } // ── Chat Messages ── function esc(s) { var d = document.createElement('div'); d.textContent = s; return d.innerHTML; } function addMessage(role, html) { var id = ++msgId; messages.push({ role: role, html: html, id: id }); renderMessages(); return id; } function appendToLastAgent(html) { for (var i = messages.length - 1; i >= 0; i--) { if (messages[i].role === 'agent') { messages[i].html += html; renderMessages(); return; } } addMessage('agent', html); } function renderMessages() { var h = ''; for (var i = 0; i < messages.length; i++) { var m = messages[i]; var cls = m.role === 'user' ? 'rd-msg-user' : m.role === 'agent' ? 'rd-msg-agent' : 'rd-msg-system'; if (m.role === 'system' && m.html.indexOf('error') > -1) cls += ' rd-msg-error'; h += '
' + m.html + '
'; } messagesEl.innerHTML = h; messagesEl.scrollTop = messagesEl.scrollHeight; } // ── SSE Event Processing ── function processEvent(data) { switch (data.action) { case 'starting_scribus': addMessage('system', 'Starting Scribus...'); break; case 'scribus_ready': addMessage('system', 'Scribus ready'); break; case 'thinking': var text = data.text || data.status || 'Planning...'; addMessage('agent', esc(text)); setState('generating'); break; case 'executing': var desc = data.description || data.status || data.tool; var argsJson = data.args ? JSON.stringify(data.args, null, 2) : ''; var toolHtml = '
\\u{1f527} ' + esc(desc) + ''; if (argsJson) toolHtml += '
' + esc(argsJson) + '
'; toolHtml += '
'; appendToLastAgent(toolHtml); break; case 'tool_result': // Update the last
summary with result indicator for (var i = messages.length - 1; i >= 0; i--) { if (messages[i].role === 'agent') { var ok = !(data.result && data.result.error); var indicator = ok ? ' \\u2713' : ' \\u2717 ' + esc(data.result.error || '') + ''; // Append indicator before last var h = messages[i].html; var lastSummary = h.lastIndexOf(''); if (lastSummary > -1) { messages[i].html = h.slice(0, lastSummary) + indicator + h.slice(lastSummary); } renderMessages(); break; } } // Update doc state from tool_result if it contains frame/page data if (data.result && !data.result.error) { if (data.result.frames) docState.frames = data.result.frames; if (data.result.pages) docState.pages = data.result.pages; } break; case 'verifying': addMessage('system', 'Verifying final state...'); break; case 'complete': addMessage('agent', esc(data.message || 'Design complete.')); break; case 'done': addMessage('system', 'Design complete'); if (data.state) { docState = { pages: data.state.pages || [], frames: data.state.frames || [] }; renderEditor(); } setState('done'); break; case 'error': addMessage('system', 'Error: ' + esc(data.error || 'Unknown error') + ''); setState('error'); break; } } // ── Send ── function send() { var text = inputEl.value.trim(); if (!text || uiState === 'generating') return; addMessage('user', esc(text)); inputEl.value = ''; setState('generating'); abortController = new AbortController(); var fetchHeaders = { 'Content-Type': 'application/json' }; try { var sess = JSON.parse(localStorage.getItem('encryptid_session') || '{}'); if (sess.accessToken) fetchHeaders['Authorization'] = 'Bearer ' + sess.accessToken; } catch(e) {} var basePath = window.location.pathname.replace(/\\/$/, ''); fetch(basePath + '/api/design-agent', { method: 'POST', headers: fetchHeaders, body: JSON.stringify({ brief: text, space: space }), signal: abortController.signal, }).then(function(res) { if (!res.ok || !res.body) { addMessage('system', 'Request failed: ' + res.status + ''); setState('error'); return; } var reader = res.body.getReader(); var decoder = new TextDecoder(); var buf = ''; function read() { reader.read().then(function(result) { if (result.done) { if (uiState !== 'error' && uiState !== 'done') setState('done'); abortController = null; return; } buf += decoder.decode(result.value, { stream: true }); var lines = buf.split('\\n'); buf = lines.pop() || ''; for (var j = 0; j < lines.length; j++) { var line = lines[j].trim(); if (line.indexOf('data:') === 0) { var payload = line.substring(5).trim(); if (!payload) continue; // keepalive try { processEvent(JSON.parse(payload)); } catch(e) {} } } read(); }).catch(function(e) { // Stream closed (Cloudflare QUIC reset or normal close) if (uiState !== 'error' && uiState !== 'done') setState('done'); abortController = null; }); } read(); }).catch(function(e) { if (e.name !== 'AbortError') { addMessage('system', 'Error: ' + esc(e.message) + ''); setState('error'); } abortController = null; }); } sendBtn.addEventListener('click', send); inputEl.addEventListener('keydown', function(e) { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); send(); } }); // ── SVG Editor ── function renderEditor() { var pages = docState.pages || []; var frames = docState.frames || []; if (frames.length === 0 && pages.length === 0) { canvasEl.innerHTML = '
Preview appears here after generation
'; statusEl.textContent = 'No document'; return; } var page = pages[0] || { width: 210, height: 297 }; var pw = page.width, ph = page.height; // Scale SVG to fit canvas (leave 24px padding) var cw = canvasEl.clientWidth - 48 || 400; var ch = canvasEl.clientHeight - 48 || 500; var scale = Math.min(cw / pw, ch / ph); var svgW = Math.round(pw * scale); var svgH = Math.round(ph * scale); var svg = ''; // Page background svg += ''; 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; var isSel = (selectedId === i); var fType = (f.type || '').toLowerCase(); svg += ''; if (fType === 'textframe' || fType === 'text') { svg += ''; var fs = Math.min((f.fontSize || 12) * 0.35, fh * 0.7); if (fs < 2) fs = 2; var txt = (f.text || '').substring(0, 60).replace(/'+txt+''; } else if (fType === 'imageframe' || fType === 'image') { svg += ''; svg += ''; svg += ''; svg += 'IMAGE'; } else { svg += ''; } // Selection handles if (isSel) { svg += ''; var hs = 2.5; // handle size in doc coords var corners = [ [fx - hs/2, fy - hs/2, 'nw'], [fx + fw - hs/2, fy - hs/2, 'ne'], [fx - hs/2, fy + fh - hs/2, 'sw'], [fx + fw - hs/2, fy + fh - hs/2, 'se'], ]; for (var ci = 0; ci < corners.length; ci++) { svg += ''; } } svg += ''; } svg += ''; canvasEl.innerHTML = '
' + svg + '
'; statusEl.textContent = frames.length + ' frame' + (frames.length !== 1 ? 's' : '') + ' \\u00b7 ' + pw + '\\u00d7' + ph + 'mm'; // Attach pointer events attachEditorEvents(scale, pw, ph); } function attachEditorEvents(scale, pw, ph) { var svgEl = canvasEl.querySelector('svg'); if (!svgEl) return; svgEl.addEventListener('pointerdown', function(e) { var target = e.target; // Handle resize if (target.classList.contains('rd-handle')) { e.preventDefault(); e.stopPropagation(); var corner = target.getAttribute('data-corner'); var f = docState.frames[selectedId]; if (!f) return; dragState = { frameIdx: selectedId, startX: e.clientX, startY: e.clientY, origX: f.x, origY: f.y, origW: f.width, origH: f.height, mode: 'resize', corner: corner, scale: scale }; return; } // Find clicked frame group var g = target.closest('g[data-idx]'); if (g) { e.preventDefault(); var idx = parseInt(g.getAttribute('data-idx')); selectedId = idx; var f = docState.frames[idx]; if (f) { dragState = { frameIdx: idx, startX: e.clientX, startY: e.clientY, origX: f.x || 0, origY: f.y || 0, origW: f.width, origH: f.height, mode: 'move', scale: scale }; } renderEditor(); } else { // Deselect if (selectedId !== null) { selectedId = null; renderEditor(); } } }); document.addEventListener('pointermove', onPointerMove); document.addEventListener('pointerup', onPointerUp); } function onPointerMove(e) { if (!dragState) return; var dx = (e.clientX - dragState.startX) / dragState.scale; var dy = (e.clientY - dragState.startY) / dragState.scale; var f = docState.frames[dragState.frameIdx]; if (!f) return; if (dragState.mode === 'move') { f.x = Math.round((dragState.origX + dx) * 10) / 10; f.y = Math.round((dragState.origY + dy) * 10) / 10; } else if (dragState.mode === 'resize') { var c = dragState.corner; if (c === 'se') { f.width = Math.max(5, Math.round((dragState.origW + dx) * 10) / 10); f.height = Math.max(5, Math.round((dragState.origH + dy) * 10) / 10); } else if (c === 'ne') { f.width = Math.max(5, Math.round((dragState.origW + dx) * 10) / 10); var newH = Math.max(5, Math.round((dragState.origH - dy) * 10) / 10); f.y = Math.round((dragState.origY + dragState.origH - newH) * 10) / 10; f.height = newH; } else if (c === 'sw') { var newW = Math.max(5, Math.round((dragState.origW - dx) * 10) / 10); f.x = Math.round((dragState.origX + dragState.origW - newW) * 10) / 10; f.width = newW; f.height = Math.max(5, Math.round((dragState.origH + dy) * 10) / 10); } else if (c === 'nw') { var newW = Math.max(5, Math.round((dragState.origW - dx) * 10) / 10); var newH = Math.max(5, Math.round((dragState.origH - dy) * 10) / 10); f.x = Math.round((dragState.origX + dragState.origW - newW) * 10) / 10; f.y = Math.round((dragState.origY + dragState.origH - newH) * 10) / 10; f.width = newW; f.height = newH; } } renderEditor(); } function onPointerUp() { dragState = null; } // Initial render renderEditor(); })(); `; function renderDesignApp(space: string, novncUrl: string): string { return `
Enter a design brief to get started.
Preview appears here after generation
No document
`; } function renderDesignLanding(): string { return `
rDesign

(You)rDesign, text in — design out.

AI-Powered Desktop Publishing with Scribus

Describe what you want and the design agent builds it — posters, flyers, brochures, zines, and print-ready documents. No mouse interaction needed. Powered by Gemini tool-calling and Scribus under the hood.

What rDesign Handles

📄

Posters & Flyers

Describe your event, campaign, or announcement and get a print-ready poster with proper typography, layout, and image placement.

📚

Brochures & Zines

Multi-page layouts with text flow, columns, and image frames. Perfect for newsletters, pamphlets, and small publications.

🎨

Brand Assets

Business cards, letterheads, social media graphics. Consistent typography and color from a single design brief.

How It Works

1

Describe

Write a natural-language brief — “A3 poster for a jazz night, deep blue background, gold accents, vintage feel.”

2

Agent Plans

The AI agent decomposes your brief into Scribus operations: document size, text frames, image placement, colors, and fonts.

3

Live Preview

Watch the design take shape in real time. Each tool call updates the SVG preview so you see progress as it happens.

4

Export

Open the finished document in Scribus for fine-tuning, or export directly as PDF for print.

Design Agent Capabilities

🗎

Text Frames

Add and position text with control over font, size, color, alignment, and line spacing. The agent handles typography best practices.

🖼

Image Frames

Place images from URLs or generate them inline with AI. Automatic scaling and positioning within the layout.

Shapes & Backgrounds

Rectangles, ellipses, decorative elements, and full-page backgrounds. Set colors, borders, and opacity per element.

A4/A3/Letter/Custom PDF Export AI Image Generation Multi-page Print-ready

Built on Open Source

The tools that power rDesign.

Scribus

Professional desktop publishing — the open-source alternative to InDesign. Runs headless in Docker via Xvfb + noVNC.

Gemini

Google’s multimodal AI with native tool-calling. Powers the design agent’s planning and execution loop.

noVNC

Browser-based VNC client for direct Scribus access. Open the full desktop when you need pixel-perfect manual edits.

Hono

Ultra-fast API framework powering the bridge between the design agent and Scribus.

Your Designs, Protected

All processing happens on your server — designs never leave your infrastructure.

🏠

Self-Hosted

Scribus runs in a Docker container on your own server. No cloud DTP service, no vendor lock-in.

💾

Persistent Storage

Design files are stored in a Docker volume and persist across container restarts and updates.

🔧

Full Source Access

Open the .sla file in Scribus anytime. Native format, no proprietary lock-in. Export to PDF, SVG, or PNG.

(You)rDesign, text in — design out.

Describe it and the agent builds it. Try the demo or create a space to get started.

`; } export const designModule: RSpaceModule = { id: "rdesign", name: "rDesign", icon: "🎨", description: "AI-powered DTP workspace — text in, design out", scoping: { defaultScope: 'global', userConfigurable: false }, publicWrite: true, routes, landingPage: renderDesignLanding, feeds: [ { 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 layouts" }, { path: "templates", name: "Templates", icon: "📐", description: "Reusable design templates" }, ], };