/** * 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(), theme: "dark", 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(), theme: "dark", body: renderDesignApp(space, SCRIBUS_NOVNC_URL), styles: ``, scripts: ``, })); }); const RDESIGN_CSS = ` #rdesign-app { max-width:900px; margin:0 auto; padding:0.5rem 0; } .rd-panel { background:var(--rs-bg-surface,#1e1e2e); border:1px solid var(--rs-border,#334155); border-radius:12px; overflow:hidden; } .rd-prompt { padding:16px; border-bottom:1px solid var(--rs-border,#334155); } .rd-prompt textarea { width:100%; padding:12px; border:2px solid var(--rs-border,#334155); border-radius:8px; font-size:14px; resize:none; outline:none; font-family:inherit; background:var(--rs-bg-elevated,#0f172a); color:var(--rs-text-primary,#e2e8f0); box-sizing:border-box; } .rd-prompt textarea:focus { border-color:#7c3aed; } .rd-prompt textarea::placeholder { color:#64748b; } .rd-btn-row { display:flex; gap:8px; margin-top:10px; align-items:center; } .rd-btn { padding:8px 18px; border-radius:8px; border:none; font-size:13px; font-weight:600; cursor:pointer; transition:all 0.15s; } .rd-btn-primary { background:linear-gradient(135deg,#7c3aed,#a78bfa); color:white; } .rd-btn-primary:hover { background:linear-gradient(135deg,#6d28d9,#8b5cf6); transform:translateY(-1px); } .rd-btn-primary:disabled { opacity:0.5; cursor:default; transform:none; } .rd-btn-secondary { background:var(--rs-bg-elevated,#1e293b); color:var(--rs-text-secondary,#94a3b8); border:1px solid var(--rs-border,#334155); } .rd-btn-secondary:hover { background:var(--rs-bg-hover,#334155); color:var(--rs-text-primary,#e2e8f0); } .rd-badge { font-size:10px; padding:3px 8px; border-radius:10px; text-transform:uppercase; letter-spacing:0.5px; background:rgba(124,58,237,0.2); color:#a78bfa; margin-left:auto; } .rd-body { display:flex; min-height:400px; } .rd-steps { flex:1; overflow-y:auto; padding:12px; font-size:12px; border-right:1px solid var(--rs-border,#334155); max-height:500px; } .rd-preview { flex:1; display:flex; flex-direction:column; align-items:center; justify-content:center; padding:16px; min-width:300px; } .rd-preview img { max-width:100%; max-height:400px; border-radius:8px; border:1px solid var(--rs-border,#334155); } .rd-empty { color:#64748b; text-align:center; font-size:13px; padding:3rem 1rem; } .rd-step { padding:6px 0; border-bottom:1px solid var(--rs-border,#1e293b); display:flex; align-items:flex-start; gap:8px; } .rd-step-icon { flex-shrink:0; width:18px; height:18px; border-radius:50%; display:flex; align-items:center; justify-content:center; font-size:10px; margin-top:1px; } .rd-step-icon.thinking { background:#1e3a5f; color:#60a5fa; } .rd-step-icon.executing { background:#422006; color:#fbbf24; } .rd-step-icon.done { background:#064e3b; color:#34d399; } .rd-step-icon.error { background:#450a0a; color:#f87171; } .rd-step-content { flex:1; line-height:1.4; color:var(--rs-text-secondary,#94a3b8); } .rd-step-tool { font-family:monospace; background:var(--rs-bg-elevated,#0f172a); padding:1px 4px; border-radius:3px; font-size:11px; color:#a78bfa; } .rd-export { display:flex; gap:8px; padding:12px 16px; border-top:1px solid var(--rs-border,#334155); justify-content:center; } .rd-spinner { display:inline-block; width:12px; height:12px; border:2px solid rgba(255,255,255,0.3); border-top-color:#fff; border-radius:50%; animation:rd-spin 0.8s linear infinite; vertical-align:middle; margin-right:4px; } @keyframes rd-spin { to { transform:rotate(360deg); } } @media (max-width:700px) { .rd-body { flex-direction:column; } .rd-steps { border-right:none; border-bottom:1px solid var(--rs-border,#334155); max-height:250px; } } `; const RDESIGN_JS = ` (function() { var brief = document.getElementById('rdesign-brief'); var generateBtn = document.getElementById('rdesign-generate'); var stopBtn = document.getElementById('rdesign-stop'); var badge = document.getElementById('rdesign-badge'); var stepsEl = document.getElementById('rdesign-steps'); var previewEl = document.getElementById('rdesign-preview'); var exportRow = document.getElementById('rdesign-export'); var refineBtn = document.getElementById('rdesign-refine'); var abortController = null; var state = 'idle'; if (!brief) return; function setState(s) { state = s; badge.textContent = s.charAt(0).toUpperCase() + s.slice(1); var working = s !== 'idle' && s !== 'done' && s !== 'error'; generateBtn.disabled = working; generateBtn.innerHTML = working ? ' Working...' : 'Generate Design'; stopBtn.style.display = working ? '' : 'none'; exportRow.style.display = s === 'done' ? '' : 'none'; brief.disabled = working; } function addStep(icon, cls, text) { var ph = stepsEl.querySelector('.rd-empty'); if (ph) ph.remove(); var div = document.createElement('div'); div.className = 'rd-step'; div.innerHTML = '
' + icon + '
' + text + '
'; stepsEl.appendChild(div); stepsEl.scrollTop = stepsEl.scrollHeight; } function processEvent(data) { switch (data.action) { case 'starting_scribus': addStep('~', 'thinking', data.status || 'Starting Scribus...'); break; case 'scribus_ready': addStep('\\u2713', 'done', 'Scribus ready'); break; case 'thinking': setState('planning'); addStep('~', 'thinking', data.status || 'Thinking...'); break; case 'executing': setState('executing'); addStep('\\u25B6', 'executing', (data.status || 'Executing') + ': ' + data.tool + ''); break; case 'tool_result': if (data.result && data.result.error) { addStep('!', 'error', data.tool + ' failed: ' + data.result.error); } else { addStep('\\u2713', 'done', data.tool + ' completed'); } break; case 'verifying': setState('verifying'); addStep('~', 'thinking', data.status || 'Verifying...'); break; case 'complete': addStep('\\u2713', 'done', data.message || 'Design complete'); break; case 'done': addStep('\\u2713', 'done', data.status || 'Done!'); if (data.state && data.state.frames) { addStep('\\u2713', 'done', data.state.frames.length + ' frame(s) in document'); } if (data.state && data.state.frames && data.state.frames.length > 0) { renderLayoutPreview(data.state); } break; case 'error': addStep('!', 'error', data.error || 'Unknown error'); setState('error'); break; } } function renderLayoutPreview(docState) { var pages = docState.pages || []; var frames = docState.frames || []; var page = pages[0] || { width: 210, height: 297 }; var maxW = 350, maxH = 400; var scale = Math.min(maxW / page.width, maxH / page.height); var pw = Math.round(page.width * scale); var pht = Math.round(page.height * scale); var svg = ''; 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 += ''; var fs = Math.min((f.fontSize || 12) * 0.35, fh * 0.7); var txt = (f.text || '').substring(0, 40).replace(/'+txt+''; } else if (f.type === 'ImageFrame' || f.type === 'image') { svg += ''; svg += 'IMAGE'; } else { svg += ''; } } svg += ''; previewEl.innerHTML = svg + '
' + frames.length + ' frames on ' + page.width + '\\u00d7' + page.height + 'mm page
'; } function generate() { var text = brief.value.trim(); if (!text || (state !== 'idle' && state !== 'done' && state !== 'error')) return; stepsEl.innerHTML = ''; previewEl.innerHTML = '
\\u{1f4d0}
Generating...
'; setState('planning'); abortController = new AbortController(); var space = document.getElementById('rdesign-app').dataset.space || 'demo'; fetch('/' + space + '/rdesign/api/design-agent', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ brief: text, space: space }), signal: abortController.signal, }).then(function(res) { if (!res.ok || !res.body) { addStep('!', 'error', 'Request failed: ' + res.status); setState('error'); return; } var reader = res.body.getReader(); var decoder = new TextDecoder(); var buffer = ''; function read() { reader.read().then(function(result) { if (result.done) { if (state !== 'error') setState('done'); abortController = null; return; } buffer += decoder.decode(result.value, { stream: true }); var lines = buffer.split('\\n'); buffer = lines.pop() || ''; for (var j = 0; j < lines.length; j++) { if (lines[j].indexOf('data:') === 0) { try { processEvent(JSON.parse(lines[j].substring(5).trim())); } catch(e) {} } } read(); }); } read(); }).catch(function(e) { if (e.name !== 'AbortError') { addStep('!', 'error', 'Error: ' + e.message); setState('error'); } abortController = null; }); } generateBtn.addEventListener('click', generate); stopBtn.addEventListener('click', function() { if (abortController) abortController.abort(); setState('idle'); addStep('!', 'error', 'Stopped by user'); }); brief.addEventListener('keydown', function(e) { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); generate(); } }); refineBtn.addEventListener('click', function() { brief.focus(); brief.select(); }); })(); `; function renderDesignApp(space: string, novncUrl: string): string { return `
Idle
Enter a design brief above and click Generate Design.
The agent will create frames, text, and shapes in Scribus step by step.
📐
Preview will appear here
after generation completes
`; } function renderDesignLanding(): string { return `
🎯

rDesign

AI-powered DTP workspace. Describe what you want and the design agent builds it in Scribus — posters, flyers, brochures, and print-ready documents.

Text in, design out. No mouse interaction needed.

Open rDesign
`; } export const designModule: RSpaceModule = { id: "rdesign", name: "rDesign", icon: "\u{1f3af}", description: "AI-powered DTP workspace — text in, design out", scoping: { defaultScope: 'global', userConfigurable: false }, 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: "\u{1f3af}", description: "Design files and layouts" }, { path: "templates", name: "Templates", icon: "\u{1f4d0}", description: "Reusable design templates" }, ], };