| 1 | import { createUIMessageStreamResponse } from "ai"; |
| 2 | import { assertModelIsConfigured, modelPicker } from "@/lib/modelPicker"; |
| 3 | import { createLogger } from "@/lib/observability/logger"; |
| 4 | import { toUIMessageStream } from "@ai-sdk/langchain"; |
| 5 | import { auth } from "@/server/auth"; |
| 6 | import { PromptTemplate } from "@langchain/core/prompts"; |
| 7 | import { RunnableSequence } from "@langchain/core/runnables"; |
| 8 | import { NextResponse } from "next/server"; |
| 9 | import { LAYOUT_REFERENCE } from "@/lib/presentation/layout-catalog"; |
| 10 | |
| 11 | interface GenerateSlideRequest { |
| 12 | prompt: string; |
| 13 | currentSlide?: string; // Serialized XML of current slide (optional context) |
| 14 | theme?: string; |
| 15 | language?: string; |
| 16 | slideType?: "standard" | "image"; |
| 17 | imageStyle?: "3D" | "Sketch" | "Flat"; |
| 18 | textDensity?: "Minimal" | "Balanced" | "Detailed"; |
| 19 | } |
| 20 | |
| 21 | const singleSlideTemplate = `You are an expert presentation designer. Create an engaging presentation in XML format. |
| 22 | Your output is consumed directly by a slide rendering engine that parses a custom XML schema -- it is NOT rendered as HTML or displayed as raw text. Every tag name, attribute, and structural rule described below is part of a strict schema. |
| 23 | |
| 24 | Your task: Create a SINGLE engaging slide in XML format based on the user's request. |
| 25 | |
| 26 | --- |
| 27 | |
| 28 | # TASK CONTEXT |
| 29 | |
| 30 | ## User Request |
| 31 | {PROMPT} |
| 32 | |
| 33 | ## Current Slide Context (if provided) |
| 34 | {CURRENT_SLIDE} |
| 35 | |
| 36 | ## Language |
| 37 | {LANGUAGE} |
| 38 | |
| 39 | --- |
| 40 | |
| 41 | # XML OUTPUT SCHEMA |
| 42 | |
| 43 | Return ONLY the XML for a single slide. No explanation, no wrapper tags. |
| 44 | |
| 45 | \`\`\`xml |
| 46 | <SECTION layout="left|right|vertical|background"> |
| 47 | <!-- Choose ONE layout component --> |
| 48 | <!-- Optional: include an IMG tag with query after the content if relevant. Use layout="background" as a normal full-slide image layout when the image leaves enough contrast for readable foreground content. --> |
| 49 | </SECTION> |
| 50 | \`\`\` |
| 51 | |
| 52 | --- |
| 53 | |
| 54 | ${LAYOUT_REFERENCE} |
| 55 | |
| 56 | --- |
| 57 | |
| 58 | # IMAGE HANDLING |
| 59 | |
| 60 | Include an image query in most slides: |
| 61 | \`\`\`xml |
| 62 | |
| 63 | <IMG query="abstract background, minimalist design" /> |
| 64 | \`\`\` |
| 65 | |
| 66 | If you include an \`<IMG query="...">\` tag, the query text MUST always be in English for stock or web image-provider compatibility, even if {LANGUAGE} is not English. Keep all other slide copy in {LANGUAGE}; only the image search query should be in English. |
| 67 | For standard slides, generate the slide content before the root image: put headings/body/layout components first and place any direct child root \`<IMG ... />\` as the final child of \`<SECTION>\`. |
| 68 | |
| 69 | --- |
| 70 | |
| 71 | # INFOGRAPHIC BLOCKS |
| 72 | |
| 73 | Use an infographic block when the slide needs a custom visual explanation: process map, hierarchy, lifecycle, relationship diagram, matrix, framework, or cause-and-effect flow. |
| 74 | |
| 75 | <SECTION layout="vertical"> |
| 76 | <H2>Operating Model</H2> |
| 77 | <INFOGRAPHIC>Operating model with five connected parts: Inputs = customer data and market signals; Workflows = intake, prioritization, delivery; Governance = decision rights and risk checks; Metrics = cycle time, quality, adoption; Outcomes = faster launches and higher retention.</INFOGRAPHIC> |
| 78 | </SECTION> |
| 79 | |
| 80 | Place the \`<INFOGRAPHIC>\` element as slide content inside \`<SECTION>\`. Its text must be fully self-contained: include the exact labels, entities, values, steps, sequence, relationships, and takeaway the infographic should show. |
| 81 | For item-level content inside the infographic prompt, use labels of 20 characters or fewer and descriptions of 60 characters or fewer. |
| 82 | For layout-based infographic prompts such as pyramids, quadrants, lists, hierarchies, sequences, matrices, and relationship diagrams, include only the strongest 4 to 5 visible items. Synthesize extra detail into those items. Word clouds and chart-style visuals may include more items when useful. |
| 83 | When an \`<INFOGRAPHIC>\` is the main/root slide component, do not generate any other layout component on that slide. Only simple \`<H1>\`, \`<H2>\`, \`<H3>\`, or \`<P>\` text may accompany it. |
| 84 | The infographic prompt must state the required visual orientation: \`layout="vertical"\` or \`layout="background"\` requires a horizontal/landscape infographic because the content area is wide; \`layout="left"\` or \`layout="right"\` requires a vertical/stacked infographic because the content area is a narrow side column. |
| 85 | |
| 86 | **CRITICAL INFOGRAPHIC RULE**: If the user's request explicitly mentions an "infographic", "diagram", "process map", or similar visual component, you MUST include an \`<INFOGRAPHIC>...</INFOGRAPHIC>\` element. |
| 87 | |
| 88 | --- |
| 89 | |
| 90 | # HARD CONSTRAINTS |
| 91 | |
| 92 | These rules are non-negotiable. Violating any **MUST** rule will break the parser. |
| 93 | |
| 94 | ### MUST (parsing will break) |
| 95 | 1. Generate exactly ONE \`<SECTION>\` with ONE main layout component. |
| 96 | 2. Use ONLY layout tags from the AVAILABLE LAYOUTS section -- unlisted tags cause parsing errors. |
| 97 | 3. Do NOT use CYCLE with a vertical root image layout. Give CYCLE enough horizontal room or omit the root image. |
| 98 | |
| 99 | ### SHOULD (quality) |
| 100 | 4. Use supported attributes instead of plain defaults when they improve the slide: alignment, bulletType, orientation, sidedness, svgType, boxType, statstype, numbered, showLine, and isFunnel. |
| 101 | 5. Match nested layout orientation to the root image layout: vertical/background root image pairs with horizontal timelines; left/right root image pairs with vertical timelines. |
| 102 | 6. Do not force all visuals into the root image. Add nested \`<IMG query="..." />\` inside layout items when item-level imagery improves comprehension. |
| 103 | 7. For direct child root images, place \`<IMG ... />\` after the slide's content/layout component so the content is produced before the root image. |
| 104 | 8. Use an \`<INFOGRAPHIC>\` element when it communicates the idea better than another list, chart, or image. |
| 105 | 9. If the root slide component is \`<INFOGRAPHIC>\`, do not add COLUMNS, BULLETS, ICONS, CYCLE, ARROWS, TIMELINE, PYRAMID, BOXES, STEPS, COMPARE, TABLE, CHART, or any other layout component to the same slide. |
| 106 | |
| 107 | Now generate a single slide based on the user's request. |
| 108 | `; |
| 109 | |
| 110 | const singleImageSlideTemplate = `# ROLE |
| 111 | |
| 112 | You are a visual presentation-to-XML compiler. Your output is consumed directly by a slide rendering engine -- it is NOT rendered as HTML. You produce a single full-bleed image slide where ALL text is rendered inside the generated image itself (no separate text overlays). |
| 113 | |
| 114 | --- |
| 115 | |
| 116 | # TASK CONTEXT |
| 117 | |
| 118 | ## User Request |
| 119 | {PROMPT} |
| 120 | |
| 121 | ## Current Slide Context (if provided) |
| 122 | {CURRENT_SLIDE} |
| 123 | |
| 124 | ## Language |
| 125 | {LANGUAGE} |
| 126 | |
| 127 | --- |
| 128 | |
| 129 | # XML OUTPUT SCHEMA |
| 130 | |
| 131 | Return ONLY the XML for a single image slide. No explanation, no wrapper tags. |
| 132 | |
| 133 | \`\`\`xml |
| 134 | <SECTION isImageSlide="true"> |
| 135 | <IMG query="..." /> |
| 136 | </SECTION> |
| 137 | \`\`\` |
| 138 | |
| 139 | Requirements: |
| 140 | - The SECTION MUST have \`isImageSlide="true"\` |
| 141 | - Output ONLY an \`<IMG query="...">\` tag inside the SECTION -- no H1/H2/H3/P or other elements |
| 142 | - The IMG query must be 60-120 words, highly descriptive, and include the exact on-image text in quotes |
| 143 | |
| 144 | --- |
| 145 | |
| 146 | # IMAGE GENERATION GUIDELINES |
| 147 | |
| 148 | ## Visual Style |
| 149 | - **Style**: {IMAGE_STYLE} |
| 150 | - {IMAGE_STYLE_GUIDANCE} |
| 151 | |
| 152 | ## Text Density |
| 153 | - **Density**: {TEXT_DENSITY} |
| 154 | - {TEXT_DENSITY_GUIDANCE} |
| 155 | |
| 156 | ## Prompt Construction |
| 157 | |
| 158 | Create a detailed, artistic prompt that: |
| 159 | - Describes the visual scene, composition, mood, color palette, and lighting |
| 160 | - Includes typography guidance (font style, size, placement, contrast) |
| 161 | - Expands the prompt into the final copy for the slide (titles, subtitles, bullets, labels) |
| 162 | - Does NOT use placeholders, brackets, or vague references |
| 163 | |
| 164 | --- |
| 165 | |
| 166 | # HARD CONSTRAINTS |
| 167 | |
| 168 | 1. Generate exactly ONE \`<SECTION isImageSlide="true">\` containing exactly ONE \`<IMG query="...">\` |
| 169 | 2. Do NOT include any other tags -- no H1, H2, H3, P, COLUMNS, or any layout component |
| 170 | 3. The IMG query MUST be 60-120 words |
| 171 | 4. The IMG query MUST include the exact on-image text in quotes |
| 172 | |
| 173 | Now generate the single image slide. |
| 174 | `; |
| 175 | |
| 176 | const model = modelPicker("gpt-4o-mini"); |
| 177 | |
| 178 | function getImageStyleGuidance(style?: string): string { |
| 179 | switch (style) { |
| 180 | case "3D": |
| 181 | return "Use a cinematic 3D render with depth, realistic shadows, and depth of field."; |
| 182 | case "Sketch": |
| 183 | return "Use hand-drawn sketch textures, ink lines, and paper grain."; |
| 184 | case "Flat": |
| 185 | return "Use flat design with minimal shading and strong color blocks."; |
| 186 | default: |
| 187 | return "Use a cinematic 3D render with depth, realistic shadows, and depth of field."; |
| 188 | } |
| 189 | } |
| 190 | |
| 191 | function getTextDensityGuidance(density?: string): string { |
| 192 | switch (density) { |
| 193 | case "Minimal": |
| 194 | return "Text should be minimal: a short title and one short supporting line."; |
| 195 | case "Detailed": |
| 196 | return "Text should be detailed: title, subtitle, and 4-6 concise bullet lines or labels."; |
| 197 | default: |
| 198 | return "Text should be balanced: title, subtitle, and 2-3 concise supporting lines."; |
| 199 | } |
| 200 | } |
| 201 | |
| 202 | export async function POST(req: Request) { |
| 203 | const requestId = crypto.randomUUID(); |
| 204 | const routeLogger = createLogger("api:presentation-generate-slide"); |
| 205 | |
| 206 | try { |
| 207 | routeLogger.info("Single slide generation request received", { requestId }); |
| 208 | const session = await auth(); |
| 209 | if (!session) { |
| 210 | routeLogger.warn("Single slide generation request rejected: unauthorized", { |
| 211 | requestId, |
| 212 | }); |
| 213 | return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); |
| 214 | } |
| 215 | |
| 216 | const { |
| 217 | prompt, |
| 218 | currentSlide, |
| 219 | language, |
| 220 | slideType, |
| 221 | imageStyle, |
| 222 | textDensity, |
| 223 | } = (await req.json()) as GenerateSlideRequest; |
| 224 | |
| 225 | if (!prompt) { |
| 226 | routeLogger.warn("Single slide generation request rejected: missing prompt", { |
| 227 | requestId, |
| 228 | }); |
| 229 | return NextResponse.json( |
| 230 | { error: "Missing required prompt field" }, |
| 231 | { status: 400 }, |
| 232 | ); |
| 233 | } |
| 234 | routeLogger.info("Validated single slide generation request", { |
| 235 | requestId, |
| 236 | slideType: slideType || "standard", |
| 237 | language: language || "en-US", |
| 238 | imageStyle: imageStyle || "3D", |
| 239 | textDensity: textDensity || "Balanced", |
| 240 | promptLength: prompt.length, |
| 241 | modelProvider: "openai", |
| 242 | modelId: "gpt-4o-mini", |
| 243 | }); |
| 244 | try { |
| 245 | assertModelIsConfigured("gpt-4o-mini"); |
| 246 | } catch (error) { |
| 247 | routeLogger.error( |
| 248 | "Single slide generation request rejected: invalid model configuration", |
| 249 | error, |
| 250 | { |
| 251 | requestId, |
| 252 | modelProvider: "openai", |
| 253 | modelId: "gpt-4o-mini", |
| 254 | }, |
| 255 | ); |
| 256 | return NextResponse.json( |
| 257 | { |
| 258 | error: |
| 259 | error instanceof Error |
| 260 | ? error.message |
| 261 | : "Invalid model configuration", |
| 262 | }, |
| 263 | { status: 400 }, |
| 264 | ); |
| 265 | } |
| 266 | |
| 267 | const isImageSlide = slideType === "image"; |
| 268 | const promptTemplate = PromptTemplate.fromTemplate( |
| 269 | isImageSlide ? singleImageSlideTemplate : singleSlideTemplate, |
| 270 | ); |
| 271 | const chain = RunnableSequence.from([promptTemplate, model]); |
| 272 | |
| 273 | const input = isImageSlide |
| 274 | ? { |
| 275 | PROMPT: prompt, |
| 276 | CURRENT_SLIDE: currentSlide || "No current slide context provided.", |
| 277 | LANGUAGE: language || "en-US", |
| 278 | IMAGE_STYLE: imageStyle || "3D", |
| 279 | IMAGE_STYLE_GUIDANCE: getImageStyleGuidance(imageStyle), |
| 280 | TEXT_DENSITY: textDensity || "Balanced", |
| 281 | TEXT_DENSITY_GUIDANCE: getTextDensityGuidance(textDensity), |
| 282 | } |
| 283 | : { |
| 284 | PROMPT: prompt, |
| 285 | CURRENT_SLIDE: currentSlide || "No current slide context provided.", |
| 286 | LANGUAGE: language || "en-US", |
| 287 | }; |
| 288 | routeLogger.info("Single slide generation started", { |
| 289 | requestId, |
| 290 | slideType: isImageSlide ? "image" : "standard", |
| 291 | }); |
| 292 | // @ts-expect-error types are incorrectly inferred |
| 293 | const stream = await chain.stream(input); |
| 294 | |
| 295 | routeLogger.info("Single slide generation stream created", { |
| 296 | requestId, |
| 297 | slideType: isImageSlide ? "image" : "standard", |
| 298 | }); |
| 299 | return createUIMessageStreamResponse({ |
| 300 | stream: toUIMessageStream(stream), |
| 301 | }); |
| 302 | } catch (error) { |
| 303 | routeLogger.error("Single slide generation failed", error, { requestId }); |
| 304 | return NextResponse.json( |
| 305 | { error: "Failed to generate slide" }, |
| 306 | { status: 500 }, |
| 307 | ); |
| 308 | } |
| 309 | } |
| 310 |