| 1 | import { ChatPromptTemplate } from "@langchain/core/prompts"; |
| 2 | |
| 3 | import { type PresentationImageSearchResult } from "@/lib/presentation/image-search"; |
| 4 | import { LAYOUT_REFERENCE } from "@/lib/presentation/layout-catalog"; |
| 5 | |
| 6 | export type PresentationGenerationPromptInput = { |
| 7 | audience?: string; |
| 8 | currentDate: string; |
| 9 | imageSearchResults?: PresentationImageSearchResult[]; |
| 10 | imageSource?: "automatic" | "ai" | "stock" | "gif"; |
| 11 | language: string; |
| 12 | outline: string[]; |
| 13 | outlineTemplateHints?: Record<number, string>; |
| 14 | presentationTemplateContext?: string; |
| 15 | prompt: string; |
| 16 | scenario?: string; |
| 17 | searchResults?: Array<{ query: string; results: unknown[] }>; |
| 18 | selectedChunks?: Array<{ |
| 19 | chunkId: string; |
| 20 | slideNumber?: number | null; |
| 21 | content?: string; |
| 22 | }>; |
| 23 | selectedTemplateCount?: number; |
| 24 | templateContext?: string; |
| 25 | textContent?: "minimal" | "concise" | "detailed" | "extensive"; |
| 26 | title: string; |
| 27 | tone: string; |
| 28 | }; |
| 29 | |
| 30 | type TemplateMode = |
| 31 | | "none" |
| 32 | | "presentation" |
| 33 | | "assigned-full" |
| 34 | | "assigned-partial" |
| 35 | | "selected-full" |
| 36 | | "selected-partial"; |
| 37 | |
| 38 | type TemplatePromptContext = { |
| 39 | assignedSlideCount: number; |
| 40 | mode: TemplateMode; |
| 41 | selectedTemplateCount: number; |
| 42 | totalSlides: number; |
| 43 | }; |
| 44 | |
| 45 | const COMPONENT_INSTRUCTIONS = `Component instructions: |
| 46 | - Match component geometry to SECTION layout: vertical root images need horizontal/wide components, and left/right root images need vertical or compact components. |
| 47 | - Do not pair CYCLE with layout="vertical". |
| 48 | - Use compact text in dense visual components. SNAKE, CIRCULAR-GRID, CONNECTED-CIRCLES, and SLOPE items need very short labels. |
| 49 | - SLOPE items must use <H4> only and must not include <P>. |
| 50 | - Use <TITLE> only for the first slide, a newly created title slide, or an introduction slide. |
| 51 | - Use <CONTRIBUTOR /> only as an empty standalone metadata block. Do not add attributes or body text to it. |
| 52 | - Treat <LABEL>, <QUOTE>, <CALLOUT>, and <CODE> as normal content blocks that can be used anywhere headings and paragraphs can be used, including inside COLUMNS. |
| 53 | - Use COLUMNS only for balanced lanes. Every column item must have parallel content, similar text length, and the same heading level; do not mix an H1-style item with H3/H4-style items in sibling columns. |
| 54 | - Keep columns visually balanced even when they include images, charts, infographics, or nested supported content.`; |
| 55 | |
| 56 | export const SYSTEM_PROMPT_TEMPLATE = ` |
| 57 | You are a presentation XML expert. Generate a complete presentation from the user's request, outline, and supporting context. Your output goes directly into a strict XML parser, so produce only valid presentation XML. |
| 58 | |
| 59 | Your task is to create exactly {TOTAL_SLIDES} slides. Use the outline for coverage and sequence, then write stronger slide copy when the outline wording is too raw. Match the requested language, tone, audience, scenario, and text density. |
| 60 | |
| 61 | # XML SYNTAX GUIDANCE |
| 62 | |
| 63 | {XML_SYNTAX_GUIDANCE} |
| 64 | |
| 65 | # RULES |
| 66 | |
| 67 | {CONTEXT_RULES} |
| 68 | |
| 69 | # FORMAT GUIDANCE |
| 70 | |
| 71 | {FORMAT_GUIDANCE} |
| 72 | |
| 73 | # VISUAL GUIDANCE |
| 74 | |
| 75 | {VISUAL_GUIDANCE} |
| 76 | |
| 77 | # Image query guidance: |
| 78 | {IMAGE_QUERY_STYLE} |
| 79 | |
| 80 | # STYLE GUIDANCE |
| 81 | |
| 82 | {STYLE_GUIDANCE} |
| 83 | |
| 84 | |
| 85 | # XML output contract: |
| 86 | {CRITICAL_RULES} |
| 87 | |
| 88 | Generate the complete XML presentation now.`; |
| 89 | |
| 90 | const USER_PROMPT_TEMPLATE = `{USER_CONTEXT}`; |
| 91 | |
| 92 | export const presentationGenerationPromptTemplate = |
| 93 | ChatPromptTemplate.fromMessages([ |
| 94 | ["system", SYSTEM_PROMPT_TEMPLATE], |
| 95 | ["human", USER_PROMPT_TEMPLATE], |
| 96 | ]); |
| 97 | |
| 98 | export type PresentationPromptMessages = { |
| 99 | human: string; |
| 100 | system: string; |
| 101 | }; |
| 102 | |
| 103 | export function buildPresentationPromptValues( |
| 104 | input: PresentationGenerationPromptInput, |
| 105 | ): Record<string, string | number> { |
| 106 | const totalSlides = input.outline.length; |
| 107 | const templatePromptContext = getTemplatePromptContext({ |
| 108 | outlineTemplateHints: input.outlineTemplateHints, |
| 109 | presentationTemplateContext: input.presentationTemplateContext, |
| 110 | selectedTemplateCount: input.selectedTemplateCount ?? 0, |
| 111 | templateContext: input.templateContext, |
| 112 | totalSlides, |
| 113 | }); |
| 114 | const userContext = buildUserContext(input); |
| 115 | |
| 116 | return { |
| 117 | CRITICAL_RULES: buildCriticalRules(templatePromptContext), |
| 118 | CONTEXT_RULES: buildContextRules(input, templatePromptContext), |
| 119 | FORMAT_GUIDANCE: buildFormatGuidance(templatePromptContext), |
| 120 | IMAGE_QUERY_STYLE: getImageQueryStyle( |
| 121 | input.imageSource, |
| 122 | shouldUseImageLibrary(input), |
| 123 | ), |
| 124 | STYLE_GUIDANCE: buildStyleGuidance(input), |
| 125 | TOTAL_SLIDES: totalSlides, |
| 126 | USER_CONTEXT: userContext, |
| 127 | VISUAL_GUIDANCE: buildVisualGuidance(templatePromptContext), |
| 128 | XML_SYNTAX_GUIDANCE: buildXmlSyntaxGuidance(templatePromptContext), |
| 129 | }; |
| 130 | } |
| 131 | |
| 132 | export async function buildPresentationPromptMessages( |
| 133 | input: PresentationGenerationPromptInput, |
| 134 | ): Promise<PresentationPromptMessages> { |
| 135 | const promptValues = buildPresentationPromptValues(input); |
| 136 | |
| 137 | return { |
| 138 | human: interpolatePromptTemplate(USER_PROMPT_TEMPLATE, promptValues), |
| 139 | system: interpolatePromptTemplate(SYSTEM_PROMPT_TEMPLATE, promptValues), |
| 140 | }; |
| 141 | } |
| 142 | |
| 143 | function interpolatePromptTemplate( |
| 144 | template: string, |
| 145 | values: Record<string, string | number>, |
| 146 | ): string { |
| 147 | return Object.entries(values).reduce( |
| 148 | (renderedTemplate, [key, value]) => |
| 149 | renderedTemplate.replaceAll(`{${key}}`, String(value)), |
| 150 | template, |
| 151 | ); |
| 152 | } |
| 153 | |
| 154 | function hasItems(value: unknown[] | undefined): boolean { |
| 155 | return Array.isArray(value) && value.length > 0; |
| 156 | } |
| 157 | |
| 158 | function usesSearchableImageSource(imageSource?: string): boolean { |
| 159 | return imageSource === "stock" || imageSource === "automatic" || !imageSource; |
| 160 | } |
| 161 | |
| 162 | function shouldUseImageLibrary( |
| 163 | input: PresentationGenerationPromptInput, |
| 164 | ): boolean { |
| 165 | return ( |
| 166 | usesSearchableImageSource(input.imageSource) && |
| 167 | hasItems(input.imageSearchResults) |
| 168 | ); |
| 169 | } |
| 170 | |
| 171 | function isXmlSectionFormat(value: string): boolean { |
| 172 | const normalizedValue = value.toLowerCase(); |
| 173 | |
| 174 | return ( |
| 175 | normalizedValue.includes("<section") && |
| 176 | normalizedValue.includes("</section>") |
| 177 | ); |
| 178 | } |
| 179 | |
| 180 | function getXmlSectionFormatHints( |
| 181 | outlineTemplateHints?: Record<number, string>, |
| 182 | ): Record<number, string> { |
| 183 | if (!outlineTemplateHints) { |
| 184 | return {}; |
| 185 | } |
| 186 | |
| 187 | return Object.fromEntries( |
| 188 | Object.entries(outlineTemplateHints).filter( |
| 189 | (entry): entry is [string, string] => isXmlSectionFormat(entry[1]), |
| 190 | ), |
| 191 | ); |
| 192 | } |
| 193 | |
| 194 | function formatResearchContext( |
| 195 | searchResults?: Array<{ query: string; results: unknown[] }>, |
| 196 | ): string { |
| 197 | if (!searchResults || searchResults.length === 0) { |
| 198 | return ""; |
| 199 | } |
| 200 | |
| 201 | const searchData = searchResults |
| 202 | .map((searchItem, index: number) => { |
| 203 | const query = searchItem.query || `Search ${index + 1}`; |
| 204 | const results = Array.isArray(searchItem.results) |
| 205 | ? searchItem.results |
| 206 | : []; |
| 207 | |
| 208 | if (results.length === 0) return ""; |
| 209 | |
| 210 | const formattedResults = results |
| 211 | .map((result: unknown) => { |
| 212 | const resultObj = result as Record<string, unknown>; |
| 213 | return `- ${resultObj.title || "No title"}\n ${ |
| 214 | resultObj.content || "No content" |
| 215 | }\n ${resultObj.url || "No URL"}`; |
| 216 | }) |
| 217 | .join("\n"); |
| 218 | |
| 219 | return `**Query ${index + 1}:** ${query}\n${formattedResults}`; |
| 220 | }) |
| 221 | .filter(Boolean) |
| 222 | .join("\n\n"); |
| 223 | |
| 224 | if (!searchData) { |
| 225 | return ""; |
| 226 | } |
| 227 | |
| 228 | return `## Research Data\n\n\`\`\`md\n${searchData}\n\`\`\`\n`; |
| 229 | } |
| 230 | |
| 231 | function formatImageLibrary( |
| 232 | imageSearchResults?: PresentationImageSearchResult[], |
| 233 | ): string { |
| 234 | if (!imageSearchResults || imageSearchResults.length === 0) { |
| 235 | return ""; |
| 236 | } |
| 237 | |
| 238 | const formattedSearches = imageSearchResults |
| 239 | .map((searchItem, index) => { |
| 240 | const results = Array.isArray(searchItem.results) |
| 241 | ? searchItem.results |
| 242 | : []; |
| 243 | |
| 244 | if (results.length === 0) { |
| 245 | return ""; |
| 246 | } |
| 247 | |
| 248 | return [ |
| 249 | `Query ${index + 1}: ${searchItem.query}`, |
| 250 | ...results.map((result, resultIndex) => { |
| 251 | const sourceLabel = |
| 252 | result.sourceTitle ?? result.sourceUrl ?? "Direct image result"; |
| 253 | |
| 254 | return [ |
| 255 | `- Image ${resultIndex + 1}: ${result.description}`, |
| 256 | ` URL: ${result.url}`, |
| 257 | ` Source: ${sourceLabel}`, |
| 258 | ].join("\n"); |
| 259 | }), |
| 260 | ].join("\n"); |
| 261 | }) |
| 262 | .filter(Boolean) |
| 263 | .join("\n\n"); |
| 264 | |
| 265 | if (!formattedSearches) { |
| 266 | return ""; |
| 267 | } |
| 268 | |
| 269 | return `## Preloaded Image Library\n\n\`\`\`md\n${formattedSearches}\n\`\`\`\n`; |
| 270 | } |
| 271 | |
| 272 | function getImageQueryStyle( |
| 273 | imageSource?: string, |
| 274 | canUseImageLibrary = false, |
| 275 | ): string { |
| 276 | if (imageSource === "gif") { |
| 277 | return `Image instruction: |
| 278 | - Use \`<IMG query="..." />\` with a short English keyword query to find an animated GIF from Giphy. |
| 279 | - Every \`<IMG query="...">\` value must be written in English for Giphy compatibility, even if the presentation language is not English. |
| 280 | - Keep all slide text/content in the requested presentation language; only the GIF query must stay in English. |
| 281 | |
| 282 | Write only the searchable subject, reaction, action, or context: |
| 283 | - Use 1-5 words whenever possible. |
| 284 | - Prefer concrete motion/action terms: celebration, teamwork, launch, typing, applause, confused reaction. |
| 285 | - Do NOT write sentences, camera directions, lighting, art style, colors, or presentation intent. |
| 286 | - Do NOT include commas, quotes, or filler words. |
| 287 | |
| 288 | \`\`\`xml |
| 289 | <IMG query="team celebration" /> |
| 290 | <IMG query="product launch" /> |
| 291 | <IMG query="data loading" /> |
| 292 | <IMG query="applause reaction" /> |
| 293 | \`\`\``; |
| 294 | } |
| 295 | |
| 296 | if (usesSearchableImageSource(imageSource)) { |
| 297 | const imageLibraryRule = canUseImageLibrary |
| 298 | ? '- When the Preloaded Image Library contains a clearly relevant image, use `<IMG url="..." />` with its exact URL.' |
| 299 | : ""; |
| 300 | |
| 301 | return `Image instruction: |
| 302 | ${imageLibraryRule ? `${imageLibraryRule}\n` : ""}- Use \`<IMG query="..." />\` with a short English keyword query to find a stock or web image. |
| 303 | - Every \`<IMG query="...">\` value must be written in English for image-provider compatibility, even if the presentation language is not English. |
| 304 | - Keep all slide text/content in the requested presentation language; only the image query must stay in English. |
| 305 | |
| 306 | Write only the searchable subject and context: |
| 307 | - Use 2-5 words whenever possible. |
| 308 | - Prefer concrete nouns and noun phrases: people, place, object, industry, activity. |
| 309 | - Do NOT write sentences, camera directions, lighting, mood, art style, colors, typography, or presentation intent. |
| 310 | - Do NOT include commas, quotes, adjectives like "cinematic", or filler like "high contrast". |
| 311 | - If the slide needs a specific real-world thing, name that thing directly. |
| 312 | |
| 313 | \`\`\`xml |
| 314 | <IMG query="smart city skyline" /> |
| 315 | <IMG query="team collaboration" /> |
| 316 | <IMG query="solar panels roof" /> |
| 317 | <IMG query="hospital patient care" /> |
| 318 | \`\`\``; |
| 319 | } |
| 320 | |
| 321 | return `Image instruction: |
| 322 | - Use \`<IMG query="...">\` with a detailed descriptive prompt to generate an image. |
| 323 | - Keep slide text/content in the requested presentation language. |
| 324 | - Write image prompts in English unless the user explicitly requested another language for generated visuals. |
| 325 | |
| 326 | Create prompts that: |
| 327 | - Describe the visual scene, composition, and mood |
| 328 | - Include style references (photorealistic, illustration, cinematic, etc.) |
| 329 | - Mention lighting, colors, and atmosphere |
| 330 | - Are relevant to the slide topic |
| 331 | - Do NOT include on-image text unless explicitly required by the slide content |
| 332 | - Do NOT use placeholders, brackets, or vague references |
| 333 | - Do NOT mention AI tools, models, or generation technology |
| 334 | |
| 335 | \`\`\`xml |
| 336 | <IMG query="cinematic wide-angle view of a futuristic smart city powered by renewable energy, gleaming solar arrays and vertical gardens, morning haze, warm sunlight cutting through glass towers, clean aerial composition with leading lines, crisp details, high contrast, optimistic mood" /> |
| 337 | <IMG query="photorealistic scene of a diverse product team collaborating in a modern glass office, warm ambient lighting, soft shadows, laptops and whiteboards with sketched diagrams, shallow depth of field, candid expressions, balanced composition, professional yet inviting atmosphere" /> |
| 338 | \`\`\``; |
| 339 | } |
| 340 | |
| 341 | function buildStyleGuidance(input: PresentationGenerationPromptInput): string { |
| 342 | const textDensity = input.textContent ?? "concise"; |
| 343 | const textDensityGuidance = getTextDensityGuidance(textDensity); |
| 344 | const styleRules = [ |
| 345 | `Match the requested tone: ${input.tone}.`, |
| 346 | input.audience ? `Write for this audience: ${input.audience}.` : "", |
| 347 | input.scenario ? `Fit this presentation scenario: ${input.scenario}.` : "", |
| 348 | textDensityGuidance, |
| 349 | ].filter((rule) => rule.length > 0); |
| 350 | |
| 351 | return `Style guidance: |
| 352 | ${styleRules.map((rule) => `- ${rule}`).join("\n")}`; |
| 353 | } |
| 354 | |
| 355 | function getTextDensityGuidance( |
| 356 | textDensity: NonNullable<PresentationGenerationPromptInput["textContent"]>, |
| 357 | ): string { |
| 358 | switch (textDensity) { |
| 359 | case "minimal": |
| 360 | return "Text density is minimal: use short labels."; |
| 361 | case "detailed": |
| 362 | return "Text density is detailed: add a specific support detail."; |
| 363 | case "extensive": |
| 364 | return "Text density is extensive: add context and implication while keeping each point presentation-friendly."; |
| 365 | case "concise": |
| 366 | return "Text density is concise: use one direct sentence per point."; |
| 367 | } |
| 368 | } |
| 369 | |
| 370 | function buildContextRules( |
| 371 | input: PresentationGenerationPromptInput, |
| 372 | context: TemplatePromptContext, |
| 373 | ): string { |
| 374 | const rules = [ |
| 375 | buildTemplateRules(input, context), |
| 376 | buildResearchRules(input), |
| 377 | buildSelectedDocumentRules(input.selectedChunks), |
| 378 | ].filter((section) => section.length > 0); |
| 379 | |
| 380 | return rules.length > 0 ? `${rules.join("\n\n")}\n` : ""; |
| 381 | } |
| 382 | |
| 383 | function getTemplatePromptContext({ |
| 384 | outlineTemplateHints, |
| 385 | presentationTemplateContext, |
| 386 | selectedTemplateCount, |
| 387 | templateContext, |
| 388 | totalSlides, |
| 389 | }: { |
| 390 | outlineTemplateHints?: Record<number, string>; |
| 391 | presentationTemplateContext?: string; |
| 392 | selectedTemplateCount: number; |
| 393 | templateContext?: string; |
| 394 | totalSlides: number; |
| 395 | }): TemplatePromptContext { |
| 396 | const assignedSlideCount = Object.keys( |
| 397 | getXmlSectionFormatHints(outlineTemplateHints), |
| 398 | ).length; |
| 399 | const hasPerSlideAssignments = assignedSlideCount > 0; |
| 400 | let mode: TemplateMode = "none"; |
| 401 | |
| 402 | if (presentationTemplateContext) { |
| 403 | mode = "presentation"; |
| 404 | } else if (hasPerSlideAssignments) { |
| 405 | mode = |
| 406 | assignedSlideCount >= totalSlides ? "assigned-full" : "assigned-partial"; |
| 407 | } else if (templateContext) { |
| 408 | mode = |
| 409 | selectedTemplateCount >= totalSlides |
| 410 | ? "selected-full" |
| 411 | : "selected-partial"; |
| 412 | } |
| 413 | |
| 414 | return { |
| 415 | assignedSlideCount, |
| 416 | mode, |
| 417 | selectedTemplateCount, |
| 418 | totalSlides, |
| 419 | }; |
| 420 | } |
| 421 | |
| 422 | function buildTemplateRules( |
| 423 | input: PresentationGenerationPromptInput, |
| 424 | context: TemplatePromptContext, |
| 425 | ): string { |
| 426 | if (context.mode === "presentation" && input.presentationTemplateContext) { |
| 427 | return `Use this XML structure for the presentation. Fill it with the user's content: headings, body text, list items, table cells, chart values, image queries or urls, icon keywords, and infographic prompt text. If the structure has a different slide count than ${context.totalSlides}, repeat or trim sections in order until the output has exactly ${context.totalSlides} slides. |
| 428 | |
| 429 | \`\`\`xml |
| 430 | ${input.presentationTemplateContext} |
| 431 | \`\`\``; |
| 432 | } |
| 433 | |
| 434 | if (context.mode === "none") { |
| 435 | return `Use the available formats below to generate the deck. Choose the format that best fits each slide's content, and vary formats across the deck so the presentation does not feel repetitive. |
| 436 | |
| 437 | ${LAYOUT_REFERENCE} |
| 438 | |
| 439 | ${COMPONENT_INSTRUCTIONS}`; |
| 440 | } |
| 441 | |
| 442 | const selectedLayoutContext = input.templateContext |
| 443 | ? `\n\n${input.templateContext}` |
| 444 | : ""; |
| 445 | const hasSelectedLayoutContext = selectedLayoutContext.length > 0; |
| 446 | const perSlideLayoutAssignments = buildPerSlideAssignmentsContext( |
| 447 | getXmlSectionFormatHints(input.outlineTemplateHints), |
| 448 | ); |
| 449 | const selectedLayoutRule = |
| 450 | context.mode === "assigned-full" |
| 451 | ? "Use the listed XML structures for their assigned slides exactly. Fill each structure with the slide content." |
| 452 | : context.mode === "assigned-partial" |
| 453 | ? hasSelectedLayoutContext |
| 454 | ? "Use the listed XML structures for their assigned slides exactly. Fill each structure with the slide content. Use the selected XML layouts on the best matching unassigned slides, then build any other remaining slides from the available XML syntax below." |
| 455 | : "Use the listed XML structures for their assigned slides exactly. Fill each structure with the slide content. Build the remaining slides from the available XML syntax below." |
| 456 | : context.mode === "selected-full" |
| 457 | ? "Use every selected XML layout exactly once across the deck. Assign each selected layout to the slide where it best fits the outline, unless the user has assigned a layout to a specific slide." |
| 458 | : "Use every selected XML layout exactly once on the best matching slides. Build the remaining slides from the available XML syntax below."; |
| 459 | const remainingSlideCount = Math.max( |
| 460 | context.totalSlides - |
| 461 | (context.mode === "assigned-full" || context.mode === "assigned-partial" |
| 462 | ? context.assignedSlideCount |
| 463 | : context.selectedTemplateCount), |
| 464 | 0, |
| 465 | ); |
| 466 | const catalogRule = |
| 467 | remainingSlideCount > 0 && |
| 468 | (context.mode === "assigned-partial" || context.mode === "selected-partial") |
| 469 | ? `Available XML syntax for the ${remainingSlideCount} remaining slide(s):\n\n${LAYOUT_REFERENCE}\n\n${COMPONENT_INSTRUCTIONS}` |
| 470 | : ""; |
| 471 | |
| 472 | return [ |
| 473 | selectedLayoutRule, |
| 474 | selectedLayoutContext.trim(), |
| 475 | perSlideLayoutAssignments, |
| 476 | catalogRule, |
| 477 | ] |
| 478 | .filter((section) => section.length > 0) |
| 479 | .join("\n\n"); |
| 480 | } |
| 481 | |
| 482 | function buildXmlSyntaxGuidance(context: TemplatePromptContext): string { |
| 483 | if (context.mode === "presentation") { |
| 484 | return "Use the XML format provided below as the output format."; |
| 485 | } |
| 486 | |
| 487 | if (context.mode === "assigned-full" || context.mode === "selected-full") { |
| 488 | return "Use the XML formats provided below as the output formats."; |
| 489 | } |
| 490 | |
| 491 | return 'Available XML syntax: wrap the deck in one <PRESENTATION> root. Put each slide in <SECTION layout="left|right|vertical">. Put one main component in each SECTION, except simple text slides and infographic-as-main slides may use only headings, paragraphs, title, label, quote, callout, code, or contributor blocks beside the infographic. Put a direct child root <IMG ... /> last when the slide needs a root image.'; |
| 492 | } |
| 493 | |
| 494 | function buildFormatGuidance(context: TemplatePromptContext): string { |
| 495 | if (context.mode === "presentation") { |
| 496 | return ""; |
| 497 | } |
| 498 | |
| 499 | if (context.mode === "assigned-full" || context.mode === "selected-full") { |
| 500 | return ""; |
| 501 | } |
| 502 | |
| 503 | return `Use the available formats intentionally according to each slide's content and purpose: |
| 504 | - Pick list-style components for grouped points. |
| 505 | - Pick sequence components for processes or maturity paths. |
| 506 | - Pick comparison components for trade-offs or before/after states. |
| 507 | - Pick relationship components for connected concepts. |
| 508 | - Pick data components for evidence. |
| 509 | - Pick infographics for custom visual explanations that a standard component cannot express clearly. |
| 510 | - Use columns as a special mixed-content container when a slide needs balanced lanes, item images, charts, infographics, or nested supported content. |
| 511 | |
| 512 | **Make sure there is high degree of visual and structural variety across the deck. Avoid using the same component more than once or twice in a row, to reduce visual monotony and maintain audience engagement.** |
| 513 | `; |
| 514 | } |
| 515 | |
| 516 | function buildVisualGuidance(context: TemplatePromptContext): string { |
| 517 | const fixedStructureGuidance = |
| 518 | context.mode === "presentation" || |
| 519 | context.mode === "assigned-full" || |
| 520 | context.mode === "selected-full"; |
| 521 | |
| 522 | if (fixedStructureGuidance) { |
| 523 | return 'Fill visual fields in the provided XML structure. Icon attributes take one lowercase English keyword. Image queries follow the image instruction below. Chart data must be a markdown table inside <CHART>; the header row defines each field once. Infographic text is a complete visual brief with labels, entities, values, sequence, relationships, orientation, and takeaway. SECTION layout sets infographic orientation: layout="vertical" means horizontal/landscape; layout="left" or layout="right" means vertical/stacked. If an existing template uses layout="background", keep foreground copy compact and readable.'; |
| 524 | } |
| 525 | |
| 526 | return `Use images deliberately: |
| 527 | - Add a root image when it complements the component layout. |
| 528 | - Use item-level images inside COLUMNS when each lane needs a visual. |
| 529 | - Pair image placement and component geometry: vertical root images create a wide lower content area, so favor horizontal components; left/right root images create a narrower side content area, so favor vertical or compact components. |
| 530 | - Omit the root image when the component or infographic already carries the visual story. |
| 531 | |
| 532 | Use icons only as search hints: |
| 533 | - When a supported item needs an icon, set icon to one lowercase English keyword such as security, analytics, team, growth, upload, idea, automation, calendar, money, network, settings, document, or message. |
| 534 | - For icon-list visuals, use <ICONS variant="icon"> with DIV icon attributes for symbolic lists, or <ICONS variant="image"> with DIV prompt attributes for generated item images. Use orientation="side" when the visual should sit beside the text and orientation="top" when it should sit above the text. |
| 535 | |
| 536 | Use charts only for real numeric comparisons, trends, shares, distributions, or correlations: |
| 537 | - Use STATS for headline metrics. |
| 538 | - Use TABLE for exact row/column comparison. |
| 539 | - Use CHART for visual data. |
| 540 | - Put chart data directly inside <CHART> as a markdown table. The markdown header row defines field names once. |
| 541 | - For most charts: <CHART charttype="bar"> |
| 542 | | label | value | |
| 543 | | --- | --- | |
| 544 | | Q1 | 24 | |
| 545 | | Q2 | 31 | |
| 546 | </CHART>. |
| 547 | - For multi-series charts, add more columns: label, revenue, profit. |
| 548 | - For scatter or bubble charts, use x, y, and optional z columns. |
| 549 | - For specialized charts, use renderer field names as table headers: range charts need category/low/high; waterfall needs category/amount; OHLC and candlestick need date/open/high/low/close; box plots need category/min/q1/median/q3/max; heatmaps need x/y/value; sankey/chord need from/to/size. |
| 550 | |
| 551 | Use infographics when the slide asks for an infographic, diagram, process map, framework, hierarchy, lifecycle, matrix, relationship map, funnel, or cause-and-effect flow: |
| 552 | - Write the infographic prompt as a complete visual brief with exact labels, entities, values, sequence, relationships, orientation, and takeaway. |
| 553 | - Include the orientation based on SECTION layout: vertical or background means horizontal/landscape infographic; left or right means vertical/stacked infographic. |
| 554 | - Keep layout-based infographic prompts to the strongest 4 or 5 visible items unless it is a word cloud or chart-like visual.`; |
| 555 | } |
| 556 | |
| 557 | function buildResearchRules(input: PresentationGenerationPromptInput): string { |
| 558 | const canUseImageLibrary = shouldUseImageLibrary(input); |
| 559 | |
| 560 | if (!hasItems(input.searchResults) && !canUseImageLibrary) { |
| 561 | return ""; |
| 562 | } |
| 563 | |
| 564 | if (hasItems(input.searchResults) && canUseImageLibrary) { |
| 565 | return "Use provided research to enrich slide content with accurate facts, statistics, and context. Use the Preloaded Image Library only when a listed image clearly fits a slide topic."; |
| 566 | } |
| 567 | |
| 568 | if (canUseImageLibrary) { |
| 569 | return "Use the Preloaded Image Library only when a listed image clearly fits a slide topic."; |
| 570 | } |
| 571 | |
| 572 | return "Use provided research to enrich slide content with accurate facts, statistics, and context."; |
| 573 | } |
| 574 | |
| 575 | function buildSelectedDocumentRules( |
| 576 | selectedChunks: PresentationGenerationPromptInput["selectedChunks"], |
| 577 | ): string { |
| 578 | if (!hasItems(selectedChunks)) { |
| 579 | return ""; |
| 580 | } |
| 581 | |
| 582 | return "Incorporate selected document content into the presentation. If a chunk is assigned to a slide, include it on that slide. Convert markdown syntax into clean XML content instead of copying markdown markers into text nodes: write `<H2>Heading 2</H2>`, not `<H2>## Heading 2</H2>`."; |
| 583 | } |
| 584 | |
| 585 | function buildUserContext(input: PresentationGenerationPromptInput): string { |
| 586 | const sections = [ |
| 587 | formatRequestContext(input), |
| 588 | formatOutlineContext(input.outline), |
| 589 | formatResearchContext(input.searchResults), |
| 590 | shouldUseImageLibrary(input) |
| 591 | ? formatImageLibrary(input.imageSearchResults) |
| 592 | : "", |
| 593 | formatSelectedChunks(input.selectedChunks), |
| 594 | ].filter((section) => section.length > 0); |
| 595 | |
| 596 | return sections.join("\n\n"); |
| 597 | } |
| 598 | |
| 599 | function formatRequestContext( |
| 600 | input: PresentationGenerationPromptInput, |
| 601 | ): string { |
| 602 | const rows = [ |
| 603 | ["Title", input.title], |
| 604 | ["User Request", input.prompt || "No specific prompt provided"], |
| 605 | ["Date", input.currentDate], |
| 606 | ["Language", input.language], |
| 607 | ["Tone", input.tone], |
| 608 | ["Total Slides", input.outline.length.toString()], |
| 609 | ["Text Density", input.textContent || "concise"], |
| 610 | ...(input.audience ? [["Target Audience", input.audience]] : []), |
| 611 | ...(input.scenario ? [["Scenario", input.scenario]] : []), |
| 612 | ]; |
| 613 | |
| 614 | return `# Presentation Context |
| 615 | |
| 616 | | Field | Value | |
| 617 | |---|---| |
| 618 | ${rows.map(([label, value]) => `| ${label} | ${value} |`).join("\n")}`; |
| 619 | } |
| 620 | |
| 621 | function formatOutlineContext(outline: string[]): string { |
| 622 | return `## Outline |
| 623 | |
| 624 | \`\`\`md |
| 625 | ${formatOutlineForPrompt(outline)} |
| 626 | \`\`\``; |
| 627 | } |
| 628 | |
| 629 | function buildPerSlideAssignmentsContext( |
| 630 | outlineTemplateHints?: Record<number, string>, |
| 631 | ): string { |
| 632 | if (!outlineTemplateHints || Object.keys(outlineTemplateHints).length === 0) { |
| 633 | return ""; |
| 634 | } |
| 635 | |
| 636 | const hints = Object.entries(outlineTemplateHints) |
| 637 | .map( |
| 638 | ([index, layoutDetail]) => |
| 639 | `Slide ${parseInt(index, 10) + 1}:\n${layoutDetail}`, |
| 640 | ) |
| 641 | .join("\n\n"); |
| 642 | |
| 643 | return `Use these XML formats for the listed slides: |
| 644 | |
| 645 | ${hints} |
| 646 | `; |
| 647 | } |
| 648 | |
| 649 | function buildCriticalRules(context: TemplatePromptContext): string { |
| 650 | const baseRules = `Presentation rules: |
| 651 | - Output exactly ${context.totalSlides} slides. |
| 652 | - Return one <PRESENTATION> root and valid XML only. |
| 653 | - Use supported tags and attributes only. |
| 654 | - Do not generate <BUTTON> elements. |
| 655 | - When you generate root level image, i.e <IMG /> elements, put it at last in each SECTION. |
| 656 | - Make sure you follow all the component level requirements and guidelines. |
| 657 | - Use the outline to cover the intended ideas, but shape the final slide copy like a strong presentation rather than copying the outline literally.`; |
| 658 | |
| 659 | if (context.mode === "none") { |
| 660 | return `${baseRules} |
| 661 | - Include images where they strengthen the slide visually.`; |
| 662 | } |
| 663 | |
| 664 | if (context.mode === "presentation") { |
| 665 | return `Presentation rules: |
| 666 | - Return exactly ${context.totalSlides} slides inside one <PRESENTATION> root. |
| 667 | - Use valid XML. |
| 668 | - Do not generate <BUTTON> elements. |
| 669 | - Use the provided XML structure as the slide structure.`; |
| 670 | } |
| 671 | |
| 672 | if (context.mode === "selected-full") { |
| 673 | return `Presentation rules: |
| 674 | - Return exactly ${context.totalSlides} slides inside one <PRESENTATION> root. |
| 675 | - Use valid XML. |
| 676 | - Do not generate <BUTTON> elements. |
| 677 | - Use every selected XML layout exactly once. |
| 678 | - Preserve each selected layout's SECTION layout, main component tag, component attributes, item count, and nesting pattern.`; |
| 679 | } |
| 680 | |
| 681 | if (context.mode === "selected-partial") { |
| 682 | return `${baseRules} |
| 683 | - Use every selected XML layout exactly once, then use the available XML syntax for remaining slides. |
| 684 | - Preserve each selected layout's SECTION layout, main component tag, component attributes, item count, and nesting pattern.`; |
| 685 | } |
| 686 | |
| 687 | const assignmentCoverage = |
| 688 | context.assignedSlideCount < context.totalSlides |
| 689 | ? "For slides without a provided XML format, choose from the available XML syntax." |
| 690 | : "Every slide has a provided XML format."; |
| 691 | |
| 692 | if (context.mode === "assigned-full") { |
| 693 | return `Presentation rules: |
| 694 | - Return exactly ${context.totalSlides} slides inside one <PRESENTATION> root. |
| 695 | - Use valid XML. |
| 696 | - Do not generate <BUTTON> elements. |
| 697 | - Use the exact provided XML layout for each listed slide. |
| 698 | - Preserve each assigned layout's SECTION layout, main component tag, component attributes, item count, and nesting pattern.`; |
| 699 | } |
| 700 | |
| 701 | return `${baseRules} |
| 702 | - Use the exact provided XML format for each listed slide. |
| 703 | - Preserve each assigned layout's SECTION layout, main component tag, component attributes, item count, and nesting pattern. |
| 704 | - ${assignmentCoverage}`; |
| 705 | } |
| 706 | |
| 707 | function formatSelectedChunks( |
| 708 | selectedChunks?: Array<{ |
| 709 | chunkId: string; |
| 710 | slideNumber?: number | null; |
| 711 | content?: string; |
| 712 | }>, |
| 713 | ): string { |
| 714 | if (!selectedChunks || selectedChunks.length === 0) { |
| 715 | return ""; |
| 716 | } |
| 717 | |
| 718 | const generalChunks = selectedChunks.filter((c) => !c.slideNumber); |
| 719 | const assignedChunks = selectedChunks.filter((c) => c.slideNumber); |
| 720 | |
| 721 | let output = `## Selected Document Content\n\n`; |
| 722 | |
| 723 | if (generalChunks.length > 0) { |
| 724 | output += `### GENERAL CONTENT (Incorporate where relevant) |
| 725 | ${generalChunks |
| 726 | .map((c, i) => { |
| 727 | const isImage = c.content?.trim().match(/^!\[.*\]\(.*\)$/); |
| 728 | |
| 729 | if (isImage) { |
| 730 | const urlMatch = c.content?.match(/\((.*?)\)/); |
| 731 | const url = urlMatch ? urlMatch[1] : ""; |
| 732 | return `Chunk ${i + 1} (Image URL): ${url}`; |
| 733 | } |
| 734 | |
| 735 | return `Chunk ${i + 1}: ${c.content}`; |
| 736 | }) |
| 737 | .join("\n\n")} |
| 738 | |
| 739 | `; |
| 740 | } |
| 741 | |
| 742 | if (assignedChunks.length > 0) { |
| 743 | output += `### Slide-Specific Content |
| 744 | |
| 745 | ${assignedChunks |
| 746 | .map((c) => { |
| 747 | const isImage = c.content?.trim().match(/^!\[.*\]\(.*\)$/); |
| 748 | |
| 749 | if (isImage) { |
| 750 | const urlMatch = c.content?.match(/\((.*?)\)/); |
| 751 | const url = urlMatch ? urlMatch[1] : ""; |
| 752 | return `- Slide ${c.slideNumber} image URL: ${url}`; |
| 753 | } |
| 754 | |
| 755 | return `- Slide ${c.slideNumber}: ${c.content}`; |
| 756 | }) |
| 757 | .join("\n")} |
| 758 | `; |
| 759 | } |
| 760 | |
| 761 | return output + "---\n"; |
| 762 | } |
| 763 | |
| 764 | function formatOutlineForPrompt(outline: string[]): string { |
| 765 | return outline |
| 766 | .map((item, index) => `Slide ${index + 1}:\n${item.trim()}`) |
| 767 | .join("\n\n---\n\n"); |
| 768 | } |
| 769 |