Merge branch 'dev'
CI/CD / deploy (push) Successful in 2m40s Details

This commit is contained in:
Jeff Emmett 2026-04-09 13:57:25 -04:00
commit 5916e7bba2
2 changed files with 300 additions and 1 deletions

View File

@ -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 FROM oven/bun:1-slim AS production
WORKDIR /app 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) # Install Typst binary (for rPubs PDF generation)
COPY --from=typst /usr/local/bin/typst /usr/local/bin/typst COPY --from=typst /usr/local/bin/typst /usr/local/bin/typst

View File

@ -360,7 +360,8 @@ app.get("/api/link-preview", async (c) => {
} }
return c.json(result, 200, { "Cache-Control": "public, max-age=3600" }); 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); 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<string, any> = {}): Promise<any> {
const headers: Record<string, string> = { "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) ── // ── ASCII Art Generation (proxies to ascii-art service on rspace-internal) ──
const ASCII_ART_URL = process.env.ASCII_ART_URL || "http://ascii-art:8000"; const ASCII_ART_URL = process.env.ASCII_ART_URL || "http://ascii-art:8000";