fix(link-preview): add ca-certificates to Docker + implement design-agent route
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 <noreply@anthropic.com>
This commit is contained in:
parent
7836b1d956
commit
83267a2209
|
|
@ -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
|
||||
|
||||
|
|
|
|||
298
server/index.ts
298
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<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) ──
|
||||
const ASCII_ART_URL = process.env.ASCII_ART_URL || "http://ascii-art:8000";
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue