rspace-online/modules/rdesign/mod.ts

316 lines
15 KiB
TypeScript

/**
* 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<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 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: `<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); border:1px solid var(--rs-border); border-radius:12px; overflow:hidden; }
.rd-prompt { padding:16px; border-bottom:1px solid var(--rs-border); }
.rd-prompt textarea { width:100%; padding:12px; border:2px solid var(--rs-border); border-radius:8px; font-size:14px; resize:none; outline:none; font-family:inherit; background:var(--rs-bg-page); color:var(--rs-text-primary); box-sizing:border-box; }
.rd-prompt textarea:focus { border-color:#7c3aed; }
.rd-prompt textarea::placeholder { color:var(--rs-text-secondary); }
.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-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; 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); 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); }
.rd-empty { color:var(--rs-text-secondary); text-align:center; font-size:13px; padding:3rem 1rem; }
.rd-step { padding:6px 0; border-bottom:1px solid var(--rs-border-subtle); 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:rgba(96,165,250,0.15); color:#60a5fa; }
.rd-step-icon.executing { background:rgba(251,191,36,0.15); color:#fbbf24; }
.rd-step-icon.done { background:rgba(52,211,153,0.15); color:#34d399; }
.rd-step-icon.error { background:rgba(248,113,113,0.15); color:#f87171; }
.rd-step-content { flex:1; line-height:1.4; color:var(--rs-text-secondary); }
.rd-step-tool { font-family:monospace; background:var(--rs-bg-surface-sunken); 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); justify-content:center; }
.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); } }
@media (max-width:700px) {
.rd-body { flex-direction:column; }
.rd-steps { border-right:none; border-bottom:1px solid var(--rs-border); max-height:250px; }
}
`;
const RDESIGN_JS = `
(function() {
var brief = document.getElementById('rdesign-brief');
var generateBtn = document.getElementById('rdesign-generate');
var stopBtn = document.getElementById('rdesign-stop');
var badge = document.getElementById('rdesign-badge');
var stepsEl = document.getElementById('rdesign-steps');
var previewEl = document.getElementById('rdesign-preview');
var exportRow = document.getElementById('rdesign-export');
var refineBtn = document.getElementById('rdesign-refine');
var abortController = null;
var state = 'idle';
if (!brief) return;
function setState(s) {
state = s;
badge.textContent = s.charAt(0).toUpperCase() + s.slice(1);
var working = s !== 'idle' && s !== 'done' && s !== 'error';
generateBtn.disabled = working;
generateBtn.innerHTML = working ? '<span class="rd-spinner"></span> Working...' : 'Generate Design';
stopBtn.style.display = working ? '' : 'none';
exportRow.style.display = s === 'done' ? '' : 'none';
brief.disabled = working;
}
function addStep(icon, cls, text) {
var ph = stepsEl.querySelector('.rd-empty');
if (ph) ph.remove();
var div = document.createElement('div');
div.className = 'rd-step';
div.innerHTML = '<div class="rd-step-icon ' + cls + '">' + icon + '</div><div class="rd-step-content">' + text + '</div>';
stepsEl.appendChild(div);
stepsEl.scrollTop = stepsEl.scrollHeight;
}
function processEvent(data) {
switch (data.action) {
case 'starting_scribus': addStep('~', 'thinking', data.status || 'Starting Scribus...'); break;
case 'scribus_ready': addStep('\\u2713', 'done', 'Scribus ready'); break;
case 'thinking': setState('planning'); addStep('~', 'thinking', data.status || 'Thinking...'); break;
case 'executing':
setState('executing');
addStep('\\u25B6', 'executing', (data.status || 'Executing') + ': <span class="rd-step-tool">' + data.tool + '</span>');
break;
case 'tool_result':
if (data.result && data.result.error) {
addStep('!', 'error', data.tool + ' failed: ' + data.result.error);
} else {
addStep('\\u2713', 'done', data.tool + ' completed');
}
break;
case 'verifying': setState('verifying'); addStep('~', 'thinking', data.status || 'Verifying...'); break;
case 'complete': addStep('\\u2713', 'done', data.message || 'Design complete'); break;
case 'done':
addStep('\\u2713', 'done', data.status || 'Done!');
if (data.state && data.state.frames) {
addStep('\\u2713', 'done', data.state.frames.length + ' frame(s) in document');
}
if (data.state && data.state.frames && data.state.frames.length > 0) {
renderLayoutPreview(data.state);
}
break;
case 'error':
addStep('!', 'error', data.error || 'Unknown error');
setState('error');
break;
}
}
function renderLayoutPreview(docState) {
var pages = docState.pages || [];
var frames = docState.frames || [];
var page = pages[0] || { width: 210, height: 297 };
var maxW = 350, maxH = 400;
var scale = Math.min(maxW / page.width, maxH / page.height);
var pw = Math.round(page.width * scale);
var pht = Math.round(page.height * scale);
var svg = '<svg width="' + pw + '" height="' + pht + '" viewBox="0 0 ' + page.width + ' ' + page.height + '" style="background:white;border:1px solid #334155;border-radius:6px;box-shadow:0 4px 12px rgba(0,0,0,0.3)">';
for (var i = 0; i < frames.length; i++) {
var f = frames[i];
var fx = f.x || 0, fy = f.y || 0, fw = f.width || 50, fh = f.height || 20;
if (f.type === 'TextFrame' || f.type === 'text') {
svg += '<rect x="'+fx+'" y="'+fy+'" width="'+fw+'" height="'+fh+'" fill="none" stroke="#7c3aed" stroke-width="0.5" stroke-dasharray="2,1" rx="1"/>';
var fs = Math.min((f.fontSize || 12) * 0.35, fh * 0.7);
var txt = (f.text || '').substring(0, 40).replace(/</g, '&lt;');
svg += '<text x="'+(fx+2)+'" y="'+(fy+fs+1)+'" font-size="'+fs+'" fill="#334155" font-family="sans-serif">'+txt+'</text>';
} else if (f.type === 'ImageFrame' || f.type === 'image') {
svg += '<rect x="'+fx+'" y="'+fy+'" width="'+fw+'" height="'+fh+'" fill="#f1f5f9" stroke="#94a3b8" stroke-width="0.5" rx="1"/>';
svg += '<text x="'+(fx+fw/2)+'" y="'+(fy+fh/2+2)+'" font-size="4" fill="#94a3b8" text-anchor="middle" font-family="sans-serif">IMAGE</text>';
} else {
svg += '<rect x="'+fx+'" y="'+fy+'" width="'+fw+'" height="'+fh+'" fill="'+(f.fill||'#e2e8f0')+'" stroke="#94a3b8" stroke-width="0.3" rx="1"/>';
}
}
svg += '</svg>';
previewEl.innerHTML = svg + '<div style="color:#64748b;font-size:11px;margin-top:8px">' + frames.length + ' frames on ' + page.width + '\\u00d7' + page.height + 'mm page</div>';
}
function generate() {
var text = brief.value.trim();
if (!text || (state !== 'idle' && state !== 'done' && state !== 'error')) return;
stepsEl.innerHTML = '';
previewEl.innerHTML = '<div class="rd-empty"><div style="font-size:2rem;margin-bottom:0.5rem">\\u{1f4d0}</div>Generating...</div>';
setState('planning');
abortController = new AbortController();
var space = document.getElementById('rdesign-app').dataset.space || 'demo';
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) {}
fetch('/' + space + '/rdesign/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) { addStep('!', 'error', 'Request failed: ' + res.status); setState('error'); return; }
var reader = res.body.getReader();
var decoder = new TextDecoder();
var buffer = '';
function read() {
reader.read().then(function(result) {
if (result.done) { if (state !== 'error') setState('done'); abortController = null; return; }
buffer += decoder.decode(result.value, { stream: true });
var lines = buffer.split('\\n');
buffer = lines.pop() || '';
for (var j = 0; j < lines.length; j++) {
if (lines[j].indexOf('data:') === 0) {
try { processEvent(JSON.parse(lines[j].substring(5).trim())); } catch(e) {}
}
}
read();
});
}
read();
}).catch(function(e) {
if (e.name !== 'AbortError') { addStep('!', 'error', 'Error: ' + e.message); setState('error'); }
abortController = null;
});
}
generateBtn.addEventListener('click', generate);
stopBtn.addEventListener('click', function() { if (abortController) abortController.abort(); setState('idle'); addStep('!', 'error', 'Stopped by user'); });
brief.addEventListener('keydown', function(e) { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); generate(); } });
refineBtn.addEventListener('click', function() { brief.focus(); brief.select(); });
})();
`;
function renderDesignApp(space: string, novncUrl: string): string {
return `<div id="rdesign-app" data-space="${space}">
<div class="rd-panel">
<div class="rd-prompt">
<textarea id="rdesign-brief" rows="3" placeholder="e.g. Create an A4 event poster for 'Mushroom Festival 2026' with a bold title, date (June 14-15), location, and a large image area for a forest photo"></textarea>
<div class="rd-btn-row">
<button id="rdesign-generate" class="rd-btn rd-btn-primary">Generate Design</button>
<button id="rdesign-stop" class="rd-btn rd-btn-secondary" style="display:none">Stop</button>
<span id="rdesign-badge" class="rd-badge">Idle</span>
</div>
</div>
<div class="rd-body">
<div id="rdesign-steps" class="rd-steps">
<div class="rd-empty">
Enter a design brief above and click <strong>Generate Design</strong>.<br>
The agent will create frames, text, and shapes in Scribus step by step.
</div>
</div>
<div id="rdesign-preview" class="rd-preview">
<div class="rd-empty">
<div style="font-size:2rem;margin-bottom:0.5rem">&#x1f4d0;</div>
Preview will appear here<br>after generation completes
</div>
</div>
</div>
<div id="rdesign-export" class="rd-export" style="display:none">
<a class="rd-btn rd-btn-secondary" href="${novncUrl}" target="_blank" rel="noopener">Open Scribus (noVNC)</a>
<button id="rdesign-refine" class="rd-btn rd-btn-secondary">Refine</button>
</div>
</div>
</div>`;
}
function renderDesignLanding(): string {
return `<div style="max-width:640px;margin:0 auto;padding:3rem 1rem;text-align:center">
<div style="font-size:3rem;margin-bottom:1rem">&#x1f3af;</div>
<h2 style="font-size:1.5rem;margin-bottom:0.75rem;background:linear-gradient(135deg,#7c3aed,#a78bfa);-webkit-background-clip:text;-webkit-text-fill-color:transparent">rDesign</h2>
<p style="color:#94a3b8;margin-bottom:1rem;line-height:1.6">AI-powered DTP workspace. Describe what you want and the design agent builds it in Scribus — posters, flyers, brochures, and print-ready documents.</p>
<p style="color:#64748b;font-size:0.85rem;margin-bottom:2rem">Text in, design out. No mouse interaction needed.</p>
<a href="?" class="rapp-nav__btn--app-toggle" style="display:inline-block;padding:10px 24px;font-size:0.9rem">Open rDesign</a>
</div>`;
}
export const designModule: RSpaceModule = {
id: "rdesign",
name: "rDesign",
icon: "\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" },
],
};