From 83267a2209e8a4c4bcef993d8626d6275fb098ba Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Thu, 9 Apr 2026 13:57:16 -0400 Subject: [PATCH] fix(link-preview): add ca-certificates to Docker + implement design-agent route MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The oven/bun:1-slim image lacks system CA certs, causing TLS verification failures on outbound HTTPS for link-preview. Also implements the /api/design-agent SSE endpoint — Gemini Flash tool loop driving the Scribus bridge for DTP layout generation. Co-Authored-By: Claude Opus 4.6 --- Dockerfile | 3 + server/index.ts | 298 +++++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 300 insertions(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index f37b4eec..85d92a71 100644 --- a/Dockerfile +++ b/Dockerfile @@ -30,6 +30,9 @@ RUN apt-get update && apt-get install -y --no-install-recommends curl xz-utils c FROM oven/bun:1-slim AS production WORKDIR /app +# Install CA certificates for outbound HTTPS (link-preview, etc.) +RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates && rm -rf /var/lib/apt/lists/* + # Install Typst binary (for rPubs PDF generation) COPY --from=typst /usr/local/bin/typst /usr/local/bin/typst diff --git a/server/index.ts b/server/index.ts index a9f77163..c57f031b 100644 --- a/server/index.ts +++ b/server/index.ts @@ -360,7 +360,8 @@ app.get("/api/link-preview", async (c) => { } return c.json(result, 200, { "Cache-Control": "public, max-age=3600" }); - } catch { + } catch (e) { + console.error("[link-preview] fetch error:", url, e instanceof Error ? e.message : e); return c.json({ error: "Failed to fetch URL" }, 502); } }); @@ -1897,6 +1898,301 @@ Output ONLY the Python code, no explanations or comments outside the code.`); } }); +// ── Design Agent (Scribus DTP via Gemini tool loop) ── +const SCRIBUS_BRIDGE_URL = process.env.SCRIBUS_BRIDGE_URL || "http://scribus-novnc:8765"; +const SCRIBUS_BRIDGE_SECRET = process.env.SCRIBUS_BRIDGE_SECRET || ""; + +const SCRIBUS_TOOLS = [ + { + name: "new_document", + description: "Create a new Scribus document with given page size and margins", + parameters: { + type: "object", + properties: { + width: { type: "number", description: "Page width in points (default 595 = A4)" }, + height: { type: "number", description: "Page height in points (default 842 = A4)" }, + margins: { type: "number", description: "Margin in points (default 40)" }, + pages: { type: "number", description: "Number of pages (default 1)" }, + unit: { type: "string", description: "Unit: pt, mm, in (default pt)" }, + }, + }, + }, + { + name: "add_text_frame", + description: "Add a text frame to the document", + parameters: { + type: "object", + properties: { + x: { type: "number", description: "X position in points" }, + y: { type: "number", description: "Y position in points" }, + width: { type: "number", description: "Frame width in points" }, + height: { type: "number", description: "Frame height in points" }, + text: { type: "string", description: "Text content" }, + fontSize: { type: "number", description: "Font size in points (default 12)" }, + fontName: { type: "string", description: "Font name (default: Arial Regular)" }, + name: { type: "string", description: "Unique frame name" }, + }, + required: ["x", "y", "width", "height", "text", "name"], + }, + }, + { + name: "add_image_frame", + description: "Add an image frame to the document", + parameters: { + type: "object", + properties: { + x: { type: "number", description: "X position" }, + y: { type: "number", description: "Y position" }, + width: { type: "number", description: "Frame width" }, + height: { type: "number", description: "Frame height" }, + imagePath: { type: "string", description: "Path to image file inside container" }, + name: { type: "string", description: "Unique frame name" }, + }, + required: ["x", "y", "width", "height", "name"], + }, + }, + { + name: "add_shape", + description: "Add a rectangle or ellipse shape to the document", + parameters: { + type: "object", + properties: { + x: { type: "number", description: "X position" }, + y: { type: "number", description: "Y position" }, + width: { type: "number", description: "Shape width" }, + height: { type: "number", description: "Shape height" }, + shapeType: { type: "string", description: "Shape type: rectangle or ellipse" }, + fillColor: { type: "string", description: "Fill color as hex (e.g. #ff0000)" }, + name: { type: "string", description: "Unique frame name" }, + }, + required: ["x", "y", "width", "height", "name"], + }, + }, + { + name: "set_background_color", + description: "Set the page background color", + parameters: { + type: "object", + properties: { + color: { type: "string", description: "Hex color (e.g. #ffffff)" }, + }, + required: ["color"], + }, + }, + { + name: "move_frame", + description: "Move a frame to a new position", + parameters: { + type: "object", + properties: { + name: { type: "string", description: "Frame name" }, + x: { type: "number", description: "New X position" }, + y: { type: "number", description: "New Y position" }, + relative: { type: "boolean", description: "If true, move relative to current position" }, + }, + required: ["name", "x", "y"], + }, + }, + { + name: "delete_frame", + description: "Delete a frame by name", + parameters: { + type: "object", + properties: { + name: { type: "string", description: "Frame name to delete" }, + }, + required: ["name"], + }, + }, + { + name: "save_as_sla", + description: "Save the document as a Scribus .sla file", + parameters: { + type: "object", + properties: { + space: { type: "string", description: "Space/project name for organizing files" }, + filename: { type: "string", description: "Filename without extension" }, + }, + required: ["space", "filename"], + }, + }, + { + name: "get_doc_state", + description: "Get current document state including all frames and their properties", + parameters: { type: "object", properties: {} }, + }, +]; + +const SCRIBUS_SYSTEM_PROMPT = `You are a Scribus desktop publishing design assistant. You create professional print-ready layouts by calling Scribus tools step by step. + +Available tools control a live Scribus document. Follow this workflow: +1. new_document — Create the page (default A4: 595×842 pt, or specify custom size) +2. set_background_color — Set page background if needed +3. add_text_frame — Add titles, headings, body text +4. add_shape — Add decorative shapes, dividers, color blocks +5. add_image_frame — Add image placeholders +6. get_doc_state — Verify the layout +7. save_as_sla — Save the final document + +Design principles: +- Use clear visual hierarchy: large bold titles, medium subheads, smaller body text +- Leave generous margins (40+ pt) and whitespace +- Use shapes as backgrounds or accent elements behind text +- For flyers: big headline at top, key info in middle, call-to-action at bottom +- For posters: dramatic title, supporting image area, details below +- For cards: centered content, decorative border shapes +- Name every frame descriptively (e.g. "title", "subtitle", "hero-bg", "body-text") +- Standard A4 is 595×842 pt, US Letter is 612×792 pt +- Font sizes: titles 36-60pt, subtitles 18-24pt, body 10-14pt`; + +async function callScribusBridge(action: string, args: Record = {}): Promise { + const headers: Record = { "Content-Type": "application/json" }; + if (SCRIBUS_BRIDGE_SECRET) headers["X-Bridge-Secret"] = SCRIBUS_BRIDGE_SECRET; + + const res = await fetch(`${SCRIBUS_BRIDGE_URL}/api/scribus/command`, { + method: "POST", + headers, + body: JSON.stringify({ action, args }), + signal: AbortSignal.timeout(30_000), + }); + return res.json(); +} + +app.post("/api/design-agent", async (c) => { + const { brief, space } = await c.req.json(); + if (!brief) return c.json({ error: "brief is required" }, 400); + if (!GEMINI_API_KEY) return c.json({ error: "GEMINI_API_KEY not configured" }, 503); + + const encoder = new TextEncoder(); + const stream = new ReadableStream({ + async start(controller) { + const send = (event: any) => { + controller.enqueue(encoder.encode(`data: ${JSON.stringify(event)}\n\n`)); + }; + + try { + // 1. Start Scribus sidecar + send({ action: "starting_scribus", status: "Starting Scribus container..." }); + await ensureSidecar("scribus-novnc"); + + // 2. Health check + try { + const healthRes = await fetch(`${SCRIBUS_BRIDGE_URL}/health`, { signal: AbortSignal.timeout(10_000) }); + const health = await healthRes.json() as any; + if (!health.ok) { + send({ action: "error", error: "Scribus bridge unhealthy" }); + controller.close(); + return; + } + } catch (e) { + send({ action: "error", error: `Scribus bridge unreachable: ${e instanceof Error ? e.message : e}` }); + controller.close(); + return; + } + send({ action: "scribus_ready", status: "Scribus ready" }); + + // 3. Initialize Gemini Flash with Scribus tools + const { GoogleGenerativeAI } = await import("@google/generative-ai"); + const genAI = new GoogleGenerativeAI(GEMINI_API_KEY); + const model = genAI.getGenerativeModel({ + model: "gemini-2.5-flash", + systemInstruction: SCRIBUS_SYSTEM_PROMPT, + generationConfig: { temperature: 0.3 }, + tools: [{ functionDeclarations: SCRIBUS_TOOLS as any }], + }); + + // 4. Agent loop + const maxTurns = 15; + const deadline = Date.now() + 90_000; + let contents: any[] = [ + { role: "user", parts: [{ text: `Design brief: ${brief}\n\nSpace/project: ${space || "demo"}\nCreate this design now using the available Scribus tools.` }] }, + ]; + + for (let turn = 0; turn < maxTurns; turn++) { + if (Date.now() > deadline) { + send({ action: "thinking", status: "Deadline reached, finishing up..." }); + break; + } + + send({ action: "thinking", status: `Planning step ${turn + 1}...` }); + + const result = await model.generateContent({ contents }); + const candidate = result.response.candidates?.[0]; + if (!candidate) break; + + const parts = candidate.content?.parts || []; + const fnCalls = parts.filter((p: any) => p.functionCall); + + if (fnCalls.length === 0) { + // Model is done — extract final text + const finalText = parts.filter((p: any) => p.text).map((p: any) => p.text).join("\n"); + if (finalText) send({ action: "thinking", status: finalText }); + break; + } + + // Execute each function call + const fnResponseParts: any[] = []; + for (const part of fnCalls) { + const fc = part.functionCall!; + const toolName = fc.name; + const toolArgs = fc.args || {}; + + send({ action: "executing", tool: toolName, status: `Executing ${toolName}` }); + console.log(`[design-agent] Turn ${turn + 1}: ${toolName}(${JSON.stringify(toolArgs).slice(0, 200)})`); + + let toolResult: any; + try { + toolResult = await callScribusBridge(toolName, toolArgs); + } catch (err) { + toolResult = { error: err instanceof Error ? err.message : String(err) }; + } + + send({ action: "tool_result", tool: toolName, result: toolResult }); + + fnResponseParts.push({ + functionResponse: { + name: toolName, + response: { result: JSON.stringify(toolResult) }, + }, + }); + } + + contents.push({ role: "model", parts }); + contents.push({ role: "user", parts: fnResponseParts }); + } + + // 5. Get final document state + let docState: any = null; + try { + const stateRes = await fetch(`${SCRIBUS_BRIDGE_URL}/api/scribus/state`, { + headers: SCRIBUS_BRIDGE_SECRET ? { "X-Bridge-Secret": SCRIBUS_BRIDGE_SECRET } : {}, + signal: AbortSignal.timeout(10_000), + }); + docState = await stateRes.json(); + } catch (e) { + console.warn("[design-agent] Failed to get final state:", e); + } + + send({ action: "done", status: "Design complete", state: docState }); + markSidecarUsed("scribus-novnc"); + } catch (e) { + console.error("[design-agent] error:", e); + send({ action: "error", error: e instanceof Error ? e.message : String(e) }); + } + + controller.close(); + }, + }); + + return new Response(stream, { + headers: { + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache", + Connection: "keep-alive", + }, + }); +}); + // ── ASCII Art Generation (proxies to ascii-art service on rspace-internal) ── const ASCII_ART_URL = process.env.ASCII_ART_URL || "http://ascii-art:8000";