feat(rdesign): split-pane chat + visual editor UI, SSE keepalive

Rewrite rDesign UI from cramped textarea+step-log to proper split-screen:
left = chat conversation with bubbles, right = interactive SVG editor
with click-to-select, drag-to-move, and corner-handle resize.

SSE keepalive pings every 15s prevent Cloudflare QUIC stream drops.
Tool calls now show human-readable descriptions in collapsible details.
Gemini reasoning text included in thinking events.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-03-24 11:28:28 -07:00
parent 3ead9b4ca0
commit 3cb2298569
2 changed files with 385 additions and 148 deletions

View File

@ -15,6 +15,21 @@ const MAX_TURNS = 10;
export const designAgentRoutes = new Hono();
/** Human-readable description of a tool call. */
function describeToolCall(name: string, args: Record<string, any>): string {
switch (name) {
case "new_document": return `Creating ${args.width || 210}\u00d7${args.height || 297}mm document (${args.pages || 1} page${(args.pages || 1) > 1 ? "s" : ""})`;
case "add_text_frame": return `Adding text frame: "${(args.text || "").slice(0, 40)}${(args.text || "").length > 40 ? "\u2026" : ""}" at ${args.x},${args.y}mm (${args.width}\u00d7${args.height})`;
case "add_image_frame": return `Adding image frame at ${args.x},${args.y}mm (${args.width}\u00d7${args.height})`;
case "add_shape": return `Adding ${args.shapeType || "rect"} shape at ${args.x},${args.y}mm (${args.width}\u00d7${args.height}${args.fill ? ", fill:" + args.fill : ""})`;
case "set_background_color": return `Setting background color to ${args.color}`;
case "get_state": return "Checking current document state";
case "save_document": return `Saving document as ${args.filename || "design.sla"}`;
case "generate_image": return `Generating AI image: "${(args.prompt || "").slice(0, 50)}${(args.prompt || "").length > 50 ? "\u2026" : ""}"`;
default: return `Executing ${name}`;
}
}
/** 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" };
@ -169,6 +184,11 @@ designAgentRoutes.post("/api/design-agent", async (c) => {
await stream.writeSSE({ data: JSON.stringify(data), event: "step", id: String(++eventId) });
};
// Keepalive to prevent Cloudflare QUIC/HTTP2 timeout (drops idle streams ~100s)
const keepalive = setInterval(() => {
stream.writeSSE({ data: "", event: "keepalive", id: String(++eventId) }).catch(() => {});
}, 15_000);
try {
// Step 1: Ensure Scribus is running
await sendEvent({ step: 1, action: "starting_scribus", status: "Starting Scribus..." });
@ -185,8 +205,6 @@ designAgentRoutes.post("/api/design-agent", async (c) => {
];
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) {
@ -198,12 +216,21 @@ designAgentRoutes.post("/api/design-agent", async (c) => {
const textParts = parts.filter((p: any) => p.text);
const toolCalls = parts.filter((p: any) => p.functionCall);
// Send thinking event with Gemini's reasoning text (if any)
const thinkingText = textParts.map((p: any) => p.text).join("\n").trim();
await sendEvent({
step: turn + 2,
action: "thinking",
status: thinkingText || `Planning turn ${turn + 1}...`,
text: thinkingText || null,
});
// 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"),
message: thinkingText,
});
break;
}
@ -217,7 +244,8 @@ designAgentRoutes.post("/api/design-agent", async (c) => {
action: "executing",
tool: name,
args,
status: `Executing: ${name}`,
description: describeToolCall(name, args || {}),
status: describeToolCall(name, args || {}),
});
const result = await executeToolCall(name, args || {}, space);
@ -252,6 +280,8 @@ designAgentRoutes.post("/api/design-agent", async (c) => {
});
} catch (e: any) {
await sendEvent({ step: 0, action: "error", error: e.message });
} finally {
clearInterval(keepalive);
}
});
});

View File

@ -72,147 +72,201 @@ routes.get("/", (c) => {
});
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; }
/* Layout */
.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; }
.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); transform:translateY(-1px); }
.rd-btn-primary:disabled { opacity:0.5; cursor:default; transform:none; }
.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; 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-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); } }
@media (max-width:700px) {
.rd-body { flex-direction:column; }
.rd-steps { border-right:none; border-bottom:1px solid var(--rs-border); max-height:250px; }
/* 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 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 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 state = 'idle';
if (!brief) return;
var docState = { pages: [], frames: [] };
var selectedId = null;
var dragState = null; // { frameIdx, startX, startY, origX, origY, mode:'move'|'resize', corner? }
// ── UI State ──
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;
uiState = s;
var working = s === 'generating';
inputEl.disabled = working;
sendBtn.disabled = working;
sendBtn.innerHTML = working ? '<span class="rd-spinner"></span>' : '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' : '');
}
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;
// ── 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 += '<div class="' + cls + '">' + m.html + '</div>';
}
messagesEl.innerHTML = h;
messagesEl.scrollTop = messagesEl.scrollHeight;
}
// ── SSE Event Processing ──
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 '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':
setState('executing');
addStep('\\u25B6', 'executing', (data.status || 'Executing') + ': <span class="rd-step-tool">' + data.tool + '</span>');
var desc = data.description || data.status || data.tool;
var argsJson = data.args ? JSON.stringify(data.args, null, 2) : '';
var toolHtml = '<div class="rd-msg-tool"><details><summary>\\u{1f527} ' + esc(desc) + '</summary>';
if (argsJson) toolHtml += '<pre>' + esc(argsJson) + '</pre>';
toolHtml += '</details></div>';
appendToLastAgent(toolHtml);
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');
// Update the last <details> 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 ? ' <span class="rd-tool-ok">\\u2713</span>' : ' <span class="rd-tool-fail">\\u2717 ' + esc(data.result.error || '') + '</span>';
// Append indicator before last </summary>
var h = messages[i].html;
var lastSummary = h.lastIndexOf('</summary>');
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': setState('verifying'); addStep('~', 'thinking', data.status || 'Verifying...'); break;
case 'complete': addStep('\\u2713', 'done', data.message || 'Design complete'); break;
case 'verifying':
addMessage('system', 'Verifying final state...');
break;
case 'complete':
addMessage('agent', esc(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);
addMessage('system', 'Design complete');
if (data.state) {
docState = { pages: data.state.pages || [], frames: data.state.frames || [] };
renderEditor();
}
setState('done');
break;
case 'error':
addStep('!', 'error', data.error || 'Unknown error');
addMessage('system', '<span style="color:#f87171">Error: ' + esc(data.error || 'Unknown error') + '</span>');
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');
// ── 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 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) {}
var basePath = window.location.pathname.replace(/\\/$/, '');
@ -222,19 +276,22 @@ const RDESIGN_JS = `
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; }
if (!res.ok || !res.body) { addMessage('system', '<span style="color:#f87171">Request failed: ' + res.status + '</span>'); setState('error'); return; }
var reader = res.body.getReader();
var decoder = new TextDecoder();
var buffer = '';
var buf = '';
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() || '';
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++) {
if (lines[j].indexOf('data:') === 0) {
try { processEvent(JSON.parse(lines[j].substring(5).trim())); } catch(e) {}
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();
@ -242,46 +299,196 @@ const RDESIGN_JS = `
}
read();
}).catch(function(e) {
if (e.name !== 'AbortError') { addStep('!', 'error', 'Error: ' + e.message); setState('error'); }
if (e.name !== 'AbortError') { addMessage('system', '<span style="color:#f87171">Error: ' + esc(e.message) + '</span>'); 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(); });
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 = '<div class="rd-empty-editor">Preview appears here after generation</div>';
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 = '<svg xmlns="http://www.w3.org/2000/svg" width="' + svgW + '" height="' + svgH + '" viewBox="0 0 ' + pw + ' ' + ph + '" style="filter:drop-shadow(0 2px 8px rgba(0,0,0,0.25));cursor:default">';
// Page background
svg += '<rect width="' + pw + '" height="' + ph + '" fill="white" rx="1"/>';
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 += '<g data-idx="' + i + '" style="cursor:move">';
if (fType === 'textframe' || fType === 'text') {
svg += '<rect x="'+fx+'" y="'+fy+'" width="'+fw+'" height="'+fh+'" fill="rgba(124,58,237,0.04)" stroke="#7c3aed" stroke-width="' + (isSel ? 1 : 0.5) + '" stroke-dasharray="' + (isSel ? '' : '2,1') + '" rx="0.5"/>';
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(/</g, '&lt;');
svg += '<text x="'+(fx+2)+'" y="'+(fy+fs+1)+'" font-size="'+fs+'" fill="#334155" font-family="sans-serif">'+txt+'</text>';
} else if (fType === 'imageframe' || fType === 'image') {
svg += '<rect x="'+fx+'" y="'+fy+'" width="'+fw+'" height="'+fh+'" fill="#f1f5f9" stroke="' + (isSel ? '#3b82f6' : '#94a3b8') + '" stroke-width="' + (isSel ? 1 : 0.5) + '" rx="0.5"/>';
svg += '<line x1="'+fx+'" y1="'+fy+'" x2="'+(fx+fw)+'" y2="'+(fy+fh)+'" stroke="#cbd5e1" stroke-width="0.3"/>';
svg += '<line x1="'+(fx+fw)+'" y1="'+fy+'" x2="'+fx+'" y2="'+(fy+fh)+'" stroke="#cbd5e1" stroke-width="0.3"/>';
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="' + (isSel ? '#3b82f6' : '#94a3b8') + '" stroke-width="' + (isSel ? 1 : 0.3) + '" rx="0.5"/>';
}
// Selection handles
if (isSel) {
svg += '<rect x="'+fx+'" y="'+fy+'" width="'+fw+'" height="'+fh+'" fill="none" stroke="#3b82f6" stroke-width="0.8"/>';
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 += '<rect class="rd-handle" data-corner="' + corners[ci][2] + '" x="'+corners[ci][0]+'" y="'+corners[ci][1]+'" width="'+hs+'" height="'+hs+'" fill="white" stroke="#3b82f6" stroke-width="0.5" style="cursor:nwse-resize"/>';
}
}
svg += '</g>';
}
svg += '</svg>';
canvasEl.innerHTML = '<div class="rd-svg-wrap">' + svg + '</div>';
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 `<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>
return `<div id="rdesign-app" data-space="${space}" class="rd-app">
<div class="rd-toolbar">
<span class="rd-badge" id="rd-badge">Idle</span>
<div style="flex:1"></div>
<a href="${novncUrl}" target="_blank" rel="noopener" class="rd-btn rd-btn-secondary rd-btn-sm">Open Scribus</a>
</div>
<div class="rd-split">
<div class="rd-chat">
<div class="rd-chat-messages" id="rd-messages">
<div class="rd-msg-system">Enter a design brief to get started.</div>
</div>
<div class="rd-chat-input">
<textarea id="rd-input" rows="2" placeholder="Describe your design..."></textarea>
<button id="rd-send" class="rd-btn rd-btn-primary">Send</button>
</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 class="rd-editor">
<div class="rd-canvas" id="rd-canvas">
<div class="rd-empty-editor">Preview appears here after generation</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 class="rd-statusbar" id="rd-status">No document</div>
</div>
</div>
</div>`;