返回 presentation-ai
generation-prompt.ts
根目录 / src / lib / presentation / generation-prompt.ts
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
769 lines TYPESCRIPT