返回 presentation-ai
PresentationGenerationManager.tsx
根目录 / src / components / notebook / presentation / components / PresentationGenerationManager.tsx
1 "use client";
2
3 import { generateImageAction } from "@/app/_actions/apps/image-studio/generate";
4 import { getImageFromPixabay } from "@/app/_actions/apps/image-studio/pixabay";
5 import { getImageFromUnsplash } from "@/app/_actions/apps/image-studio/unsplash";
6 import { updatePresentation } from "@/app/_actions/notebook/presentation/presentationActions";
7 import { generateSlideImageAction } from "@/app/_actions/presentation/generate-slide-image";
8 import {
9 getMessageText,
10 getToolInputArgs,
11 getToolName,
12 getToolOutput,
13 getToolState,
14 isToolPart,
15 } from "@/lib/ai/uiMessageParts";
16 import { collectNotebookAgentToolCalls } from "@/lib/notebook/agent-activity";
17 import { isWebSearchToolName } from "@/lib/ai/tool-names";
18 import { createLogger } from "@/lib/observability/logger";
19 import { useDebouncedSave } from "@/hooks/presentation/useDebouncedSave";
20 import { buildPresentationCustomization } from "@/lib/presentation/customization";
21 import { extractGeneratedPresentationTheme } from "@/lib/presentation/generated-theme";
22 import {
23 getPersistablePresentationTheme,
24 PRESENTATION_AUTO_THEME_ID,
25 } from "@/lib/presentation/theme-resolution";
26 import { type ThemeProperties } from "@/lib/presentation/themes";
27 import { usePresentationState } from "@/states/presentation-state";
28 import { useChat, useCompletion } from "@ai-sdk/react";
29 import { DefaultChatTransport, type UIMessage } from "ai";
30 import { usePresentationTheme } from "@/components/presentation/providers/PresentationThemeProvider";
31 import { useEffect, useRef } from "react";
32 import { toast } from "sonner";
33 import { SlideParser } from "../utils/parser";
34 import {
35 serializeTemplateHintsForPrompt,
36 serializeTemplatesForPrompt,
37 } from "../utils/template-serializer";
38
39 interface PresentationOutlineMessageMetadata {
40 numberOfCards: number;
41 language: string;
42 modelId: string;
43 modelProvider: "openai" | "ollama" | "lmstudio";
44 webSearch: boolean;
45 autoTheme: boolean;
46 presentationId: string | null;
47 textContent: "minimal" | "concise" | "detailed" | "extensive";
48 tone:
49 | "auto"
50 | "general"
51 | "persuasive"
52 | "inspiring"
53 | "instructive"
54 | "engaging";
55 audience:
56 | "auto"
57 | "general"
58 | "business"
59 | "investor"
60 | "teacher"
61 | "student";
62 scenario:
63 | "auto"
64 | "general"
65 | "analysis-report"
66 | "teaching-training"
67 | "promotional-materials"
68 | "public-speeches";
69 }
70
71 const generationLogger = createLogger("client:presentation-generation");
72
73 function stripXmlCodeBlock(input: string): string {
74 let result = input.trim();
75 if (result.startsWith("```xml")) {
76 result = result.slice(6).trimStart();
77 }
78 if (result.endsWith("```")) {
79 result = result.slice(0, -3).trimEnd();
80 }
81 return result;
82 }
83
84 function hasGeneratedOutline(outline: string[]): boolean {
85 return outline.some((item) => item.trim().length > 0);
86 }
87
88 function parseOutlineItems(content: string): string[] {
89 if (!/^#\s+/m.test(content)) {
90 return [];
91 }
92
93 const sections = content.split(/^# /gm).filter(Boolean);
94 return sections.length > 0
95 ? sections.map((section) => `# ${section}`.trim())
96 : [];
97 }
98
99 function usesStockSearchForPresentation(
100 imageSource: "automatic" | "ai" | "stock" | "gif",
101 ): boolean {
102 return imageSource === "automatic" || imageSource === "stock";
103 }
104
105 export function PresentationGenerationManager() {
106 const { resolvedTheme } = usePresentationTheme();
107 const {
108 numSlides,
109 language,
110 modelId,
111 modelProvider,
112 presentationInput,
113 shouldStartOutlineGeneration,
114 shouldStartPresentationGeneration,
115 shouldStartImageSlideGeneration,
116 webSearchEnabled,
117 autoThemeEnabled,
118 setIsGeneratingOutline,
119 setShouldStartOutlineGeneration,
120 setShouldStartPresentationGeneration,
121 setShouldStartImageSlideGeneration,
122 resetGeneration,
123 setOutline,
124 setOutlineToolCalls,
125 setSearchResults,
126 setSlides,
127 setIsGeneratingPresentation,
128 setCurrentPresentation,
129 currentPresentationId,
130 imageModel,
131 imageSource,
132 rootImageGeneration,
133 startRootImageGeneration,
134 completeRootImageGeneration,
135 failRootImageGeneration,
136 isGeneratingPresentation,
137 isGeneratingOutline,
138 slides,
139 textContent,
140 tone,
141 audience,
142 scenario,
143 } = usePresentationState();
144
145 // Persist slide updates during generation using debounced saves to limit frequency
146 const { save } = useDebouncedSave();
147
148 // Create a ref for the streaming parser to persist between renders
149 const streamingParserRef = useRef<SlideParser>(new SlideParser());
150 // Add refs to track the animation frame IDs
151 const slidesRafIdRef = useRef<number | null>(null);
152 const outlineRafIdRef = useRef<number | null>(null);
153 const outlineTransportRef = useRef<DefaultChatTransport<UIMessage> | null>(
154 null,
155 );
156 const outlineBufferRef = useRef<string[] | null>(null);
157 const searchResultsBufferRef = useRef<Array<{
158 query: string;
159 results: unknown[];
160 }> | null>(null);
161 // Track the last processed messages length to avoid unnecessary updates
162 const lastProcessedMessagesLength = useRef<number>(0);
163 // Track if title has already been extracted to avoid unnecessary processing
164 const titleExtractedRef = useRef<boolean>(false);
165 const latestGeneratedThemeDataRef = useRef<ThemeProperties | null>(null);
166
167 // Function to update slides using requestAnimationFrame
168 const updateSlidesWithRAF = (): void => {
169 const processedPresentationCompletion = stripXmlCodeBlock(
170 presentationCompletion,
171 );
172 streamingParserRef.current.reset();
173 streamingParserRef.current.parseChunk(processedPresentationCompletion);
174 streamingParserRef.current.finalize();
175 const allSlides = streamingParserRef.current.getAllSlides();
176 // Merge any completed root image URLs from state into streamed slides
177 const mergedSlides = allSlides.map((slide) => {
178 const gen = rootImageGeneration[slide.id];
179 if (gen?.status === "success" && slide.rootImage?.query) {
180 return {
181 ...slide,
182 rootImage: {
183 ...slide.rootImage,
184 url: gen.url,
185 imageSource: (imageSource === "stock" ? "search" : "generate") as
186 | "search"
187 | "generate",
188 },
189 };
190 }
191 return slide;
192 });
193 // For any slide that has a rootImage query but no url, ensure generation is tracked/started
194 for (const slide of allSlides) {
195 const slideId = slide.id;
196 const rootImage = slide.rootImage;
197 if (rootImage?.query && !rootImage.url) {
198 const already = rootImageGeneration[slideId];
199 if (!already || already.status === "error") {
200 startRootImageGeneration(slideId, rootImage.query);
201 }
202 }
203 }
204 setSlides(mergedSlides);
205 // Debounced save during generation to avoid excessive writes
206 save();
207 slidesRafIdRef.current = null;
208 };
209
210 // Function to extract title from content
211 const extractTitle = (
212 content: string,
213 ): { title: string | null; cleanContent: string } => {
214 const titleMatch = content.match(/<TITLE>(.*?)<\/TITLE>/i);
215 if (titleMatch?.[1]) {
216 const title = titleMatch[1].trim();
217 const cleanContent = content.replace(/<TITLE>.*?<\/TITLE>/i, "").trim();
218 return { title, cleanContent };
219 }
220 return { title: null, cleanContent: content };
221 };
222
223 const processMessages = (messages: typeof outlineMessages): void => {
224 if (messages.length <= 1) return;
225 const searchResults: Array<{ query: string; results: unknown[] }> = [];
226 let latestTitle: string | null = null;
227 let latestOutlineItems: string[] = [];
228
229 for (const message of messages) {
230 for (const part of message.parts) {
231 if (!isToolPart(part)) {
232 continue;
233 }
234
235 const invocation = {
236 toolName: getToolName(part),
237 state: getToolState(part),
238 args: getToolInputArgs(part),
239 result: getToolOutput(part),
240 };
241
242 if (
243 isWebSearchToolName(invocation.toolName) &&
244 invocation.state === "result" &&
245 invocation.result
246 ) {
247 const argsRecord =
248 typeof invocation.args === "object" && invocation.args !== null
249 ? (invocation.args as Record<string, unknown>)
250 : {};
251 const query =
252 typeof argsRecord.query === "string"
253 ? argsRecord.query
254 : "Unknown query";
255
256 let parsedResult: unknown;
257 try {
258 parsedResult =
259 typeof invocation.result === "string"
260 ? JSON.parse(invocation.result)
261 : invocation.result;
262 } catch {
263 parsedResult = invocation.result;
264 }
265
266 searchResults.push({
267 query,
268 results:
269 parsedResult &&
270 typeof parsedResult === "object" &&
271 "results" in parsedResult &&
272 Array.isArray(parsedResult.results)
273 ? parsedResult.results
274 : [],
275 });
276 }
277 }
278
279 if (message.role !== "assistant") {
280 continue;
281 }
282
283 const assistantText = getMessageText(message);
284 if (!assistantText) {
285 continue;
286 }
287
288 const { title, cleanContent } = extractTitle(assistantText);
289 if (title) {
290 latestTitle = title;
291 }
292
293 const generatedTheme = extractGeneratedPresentationTheme(cleanContent);
294 const outlineItems = parseOutlineItems(generatedTheme.cleanContent);
295 if (outlineItems.length > 0) {
296 latestOutlineItems = outlineItems;
297 }
298
299 if (generatedTheme.themeData) {
300 latestGeneratedThemeDataRef.current = generatedTheme.themeData;
301 }
302 }
303
304 if (!titleExtractedRef.current && latestTitle) {
305 setCurrentPresentation(currentPresentationId, latestTitle);
306 titleExtractedRef.current = true;
307 }
308
309 if (searchResults.length > 0) {
310 searchResultsBufferRef.current = searchResults;
311 }
312
313 if (latestOutlineItems.length > 0) {
314 outlineBufferRef.current = latestOutlineItems;
315 }
316
317 if (latestGeneratedThemeDataRef.current) {
318 const state = usePresentationState.getState();
319 state.setGeneratedThemeData(latestGeneratedThemeDataRef.current);
320 state.setTheme(PRESENTATION_AUTO_THEME_ID);
321 }
322 };
323
324 // Function to update outline and search results using requestAnimationFrame
325 const updateOutlineWithRAF = (): void => {
326 // Batch all updates in a single RAF callback for better performance
327
328 // Update search results if available
329 if (searchResultsBufferRef.current !== null) {
330 setSearchResults(searchResultsBufferRef.current);
331 searchResultsBufferRef.current = null;
332 }
333
334 // Update outline if available
335 if (outlineBufferRef.current !== null) {
336 setOutline(outlineBufferRef.current);
337 outlineBufferRef.current = null;
338 }
339
340 // Clear the current frame ID
341 outlineRafIdRef.current = null;
342 };
343
344 // Outline generation with or without web search
345 if (outlineTransportRef.current === null) {
346 outlineTransportRef.current = new DefaultChatTransport({
347 api: "/api/presentation/outline",
348 });
349 }
350
351 const {
352 messages: outlineMessages,
353 sendMessage: appendOutlineMessage,
354 setMessages: setOutlineMessages,
355 } = useChat({
356 transport: outlineTransportRef.current,
357
358 onFinish: () => {
359 const {
360 currentPresentationId,
361 outline,
362 searchResults,
363 currentPresentationTitle,
364 imageSource,
365 } = usePresentationState.getState();
366 const state = usePresentationState.getState();
367 const generatedThemeData = latestGeneratedThemeDataRef.current;
368
369 setIsGeneratingOutline(false);
370 setShouldStartOutlineGeneration(false);
371 setShouldStartPresentationGeneration(false);
372
373 if (!hasGeneratedOutline(outline)) {
374 generationLogger.warn(
375 "Presentation outline completed without any outline items",
376 {
377 presentationId: currentPresentationId,
378 searchResultsCount: searchResults.length,
379 },
380 );
381 toast.error(
382 "Outline generation finished without producing an outline. Please try again.",
383 );
384 return;
385 }
386
387 generationLogger.info("Presentation outline completed", {
388 presentationId: currentPresentationId,
389 outlineItems: outline.length,
390 searchResultsCount: searchResults.length,
391 title: currentPresentationTitle,
392 imageSource,
393 });
394
395 if (currentPresentationId) {
396 const outlineToolCalls = collectNotebookAgentToolCalls(outlineMessages);
397 setOutlineToolCalls(outlineToolCalls);
398
399 void updatePresentation({
400 id: currentPresentationId,
401 outline,
402 searchResults,
403 toolCalls: outlineToolCalls,
404 selectedChunks: state.selectedChunks.map(
405 ({ chunkId, slideNumber, content, ragId }) => ({
406 chunkId,
407 slideNumber,
408 content,
409 ragId,
410 }),
411 ),
412 prompt: presentationInput,
413 title: currentPresentationTitle ?? "",
414 imageSource,
415 theme: getPersistablePresentationTheme({
416 fallbackTheme: resolvedTheme === "dark" ? "ebony" : "mystique",
417 theme: generatedThemeData ? PRESENTATION_AUTO_THEME_ID : state.theme,
418 }),
419 customization: buildPresentationCustomization({
420 customThemeData: generatedThemeData ?? state.customThemeData,
421 themeDataByTheme: state.themeDataByTheme,
422 generatedThemeData: generatedThemeData ?? state.generatedThemeData,
423 theme: generatedThemeData ? PRESENTATION_AUTO_THEME_ID : state.theme,
424 pageStyle: state.pageStyle,
425 presentationStyle: state.presentationStyle,
426 generationAspectRatio: state.generationAspectRatio,
427 textContent: state.textContent,
428 tone: state.tone,
429 audience: state.audience,
430 scenario: state.scenario,
431 pageBackground: state.pageBackground,
432 selectedSlideTemplates: state.selectedSlideTemplates,
433 outlineItemIds: state.outlineItemIds,
434 outlineTemplateOverrides: state.outlineTemplateOverrides,
435 }),
436 });
437 }
438
439 // Cancel any pending outline animation frame
440 if (outlineRafIdRef.current !== null) {
441 cancelAnimationFrame(outlineRafIdRef.current);
442 outlineRafIdRef.current = null;
443 }
444 },
445 onError: (error) => {
446 generationLogger.error("Presentation outline generation failed", error, {
447 presentationId: usePresentationState.getState().currentPresentationId,
448 });
449 setIsGeneratingOutline(false);
450 setShouldStartOutlineGeneration(false);
451 setShouldStartPresentationGeneration(false);
452 toast.error("Failed to generate outline: " + error.message);
453 resetGeneration();
454 setOutlineToolCalls([]);
455
456 if (outlineRafIdRef.current !== null) {
457 cancelAnimationFrame(outlineRafIdRef.current);
458 outlineRafIdRef.current = null;
459 }
460 },
461 });
462
463 // Lightweight useEffect that only schedules RAF updates
464 useEffect(() => {
465 setOutlineToolCalls(collectNotebookAgentToolCalls(outlineMessages));
466
467 if (outlineMessages.length > 1) {
468 lastProcessedMessagesLength.current = outlineMessages.length;
469 processMessages(outlineMessages);
470 if (outlineRafIdRef.current === null) {
471 outlineRafIdRef.current = requestAnimationFrame(updateOutlineWithRAF);
472 }
473 }
474 }, [outlineMessages, webSearchEnabled, setOutlineToolCalls]);
475
476 // Watch for outline generation start
477 useEffect(() => {
478 const startOutlineGeneration = async (): Promise<void> => {
479 if (shouldStartOutlineGeneration) {
480 try {
481 titleExtractedRef.current = false;
482 setOutlineMessages([]);
483 outlineBufferRef.current = null;
484 searchResultsBufferRef.current = null;
485 latestGeneratedThemeDataRef.current = null;
486 lastProcessedMessagesLength.current = 0;
487
488 const { presentationInput } = usePresentationState.getState();
489 if (outlineRafIdRef.current === null) {
490 outlineRafIdRef.current =
491 requestAnimationFrame(updateOutlineWithRAF);
492 }
493
494 generationLogger.info("Presentation outline generation started", {
495 presentationId: currentPresentationId,
496 modelProvider,
497 modelId: modelId || "gpt-4o-mini",
498 numSlides,
499 language,
500 webSearchEnabled,
501 textContent,
502 tone,
503 audience,
504 scenario,
505 });
506
507 await appendOutlineMessage({
508 role: "user",
509 metadata: {
510 numberOfCards: numSlides,
511 language,
512 modelId,
513 modelProvider,
514 webSearch: webSearchEnabled,
515 autoTheme: autoThemeEnabled,
516 presentationId: currentPresentationId,
517 textContent,
518 tone,
519 audience,
520 scenario,
521 } satisfies PresentationOutlineMessageMetadata,
522 parts: [{ type: "text", text: presentationInput }],
523 });
524 } catch (error) {
525 generationLogger.error(
526 "Failed to start presentation outline generation",
527 error,
528 {
529 presentationId: currentPresentationId,
530 },
531 );
532 }
533 }
534 };
535
536 void startOutlineGeneration();
537 }, [shouldStartOutlineGeneration]);
538
539 const { completion: presentationCompletion, complete: generatePresentation } =
540 useCompletion({
541 api: "/api/presentation/generate",
542 onFinish: (_prompt, _completion) => {
543 generationLogger.info("Presentation generation completed", {
544 presentationId: currentPresentationId,
545 generatedSlides: usePresentationState.getState().slides.length,
546 });
547 setIsGeneratingPresentation(false);
548 setShouldStartPresentationGeneration(false);
549 const state = usePresentationState.getState();
550 if (currentPresentationId) {
551 updatePresentation({
552 id: currentPresentationId,
553 theme: getPersistablePresentationTheme({
554 fallbackTheme: resolvedTheme === "dark" ? "ebony" : "mystique",
555 theme: state.theme,
556 }),
557 customization: buildPresentationCustomization({
558 customThemeData: state.customThemeData,
559 themeDataByTheme: state.themeDataByTheme,
560 generatedThemeData: state.generatedThemeData,
561 theme: state.theme,
562 pageStyle: state.pageStyle,
563 presentationStyle: state.presentationStyle,
564 generationAspectRatio: state.generationAspectRatio,
565 textContent: state.textContent,
566 tone: state.tone,
567 audience: state.audience,
568 scenario: state.scenario,
569 pageBackground: state.pageBackground,
570 selectedSlideTemplates: state.selectedSlideTemplates,
571 outlineItemIds: state.outlineItemIds,
572 outlineTemplateOverrides: state.outlineTemplateOverrides,
573 }),
574 });
575 }
576 },
577 onError: (error) => {
578 generationLogger.error("Presentation generation failed", error, {
579 presentationId: usePresentationState.getState().currentPresentationId,
580 });
581 toast.error("Failed to generate presentation: " + error.message);
582 resetGeneration();
583 streamingParserRef.current.reset();
584
585 // Cancel any pending animation frame
586 if (slidesRafIdRef.current !== null) {
587 cancelAnimationFrame(slidesRafIdRef.current);
588 slidesRafIdRef.current = null;
589 }
590 },
591 });
592
593 // Image slides generation
594 const { completion: imageSlidesCompletion, complete: generateImageSlides } =
595 useCompletion({
596 api: "/api/presentation/generate-image-slides",
597 onFinish: (_prompt, _completion) => {
598 generationLogger.info("Image slide generation completed", {
599 presentationId: currentPresentationId,
600 generatedSlides: usePresentationState.getState().slides.length,
601 });
602 setIsGeneratingPresentation(false);
603 setShouldStartImageSlideGeneration(false);
604 const state = usePresentationState.getState();
605 if (currentPresentationId) {
606 updatePresentation({
607 id: currentPresentationId,
608 theme: getPersistablePresentationTheme({
609 fallbackTheme: resolvedTheme === "dark" ? "ebony" : "mystique",
610 theme: state.theme,
611 }),
612 customization: buildPresentationCustomization({
613 customThemeData: state.customThemeData,
614 themeDataByTheme: state.themeDataByTheme,
615 generatedThemeData: state.generatedThemeData,
616 theme: state.theme,
617 pageStyle: state.pageStyle,
618 presentationStyle: state.presentationStyle,
619 generationAspectRatio: state.generationAspectRatio,
620 textContent: state.textContent,
621 tone: state.tone,
622 audience: state.audience,
623 scenario: state.scenario,
624 pageBackground: state.pageBackground,
625 selectedSlideTemplates: state.selectedSlideTemplates,
626 outlineItemIds: state.outlineItemIds,
627 outlineTemplateOverrides: state.outlineTemplateOverrides,
628 }),
629 });
630 }
631 },
632 onError: (error) => {
633 generationLogger.error("Image slide generation failed", error, {
634 presentationId: usePresentationState.getState().currentPresentationId,
635 });
636 toast.error("Failed to generate image slides: " + error.message);
637 resetGeneration();
638 streamingParserRef.current.reset();
639
640 // Cancel any pending animation frame
641 if (slidesRafIdRef.current !== null) {
642 cancelAnimationFrame(slidesRafIdRef.current);
643 slidesRafIdRef.current = null;
644 }
645 },
646 });
647
648 useEffect(() => {
649 if (presentationCompletion) {
650 try {
651 // Only schedule a new frame if one isn't already pending
652 if (slidesRafIdRef.current === null) {
653 slidesRafIdRef.current = requestAnimationFrame(updateSlidesWithRAF);
654 }
655 } catch (error) {
656 generationLogger.error("Failed to process presentation XML stream", error, {
657 presentationId: usePresentationState.getState().currentPresentationId,
658 });
659 toast.error("Error processing presentation content");
660 }
661 }
662 }, [presentationCompletion]);
663
664 // Handle image slides completion streaming
665 useEffect(() => {
666 if (imageSlidesCompletion) {
667 try {
668 const processedCompletion = stripXmlCodeBlock(imageSlidesCompletion);
669 streamingParserRef.current.reset();
670 streamingParserRef.current.parseChunk(processedCompletion);
671 streamingParserRef.current.finalize();
672 const allSlides = streamingParserRef.current.getAllSlides();
673
674 // Mark all slides as image slides and start image generation
675 const imageSlidesData = allSlides.map((slide) => {
676 const gen = rootImageGeneration[slide.id];
677 if (gen?.status === "success" && slide.rootImage?.query) {
678 return {
679 ...slide,
680 isImageSlide: true,
681 rootImage: {
682 ...slide.rootImage,
683 url: gen.url,
684 imageSource: "generate" as const,
685 },
686 };
687 }
688 return { ...slide, isImageSlide: true };
689 });
690
691 // Start image generation for slides that need it
692 for (const slide of allSlides) {
693 const slideId = slide.id;
694 const rootImage = slide.rootImage;
695 if (rootImage?.query && !rootImage.url) {
696 const already = rootImageGeneration[slideId];
697 if (!already || already.status === "error") {
698 startRootImageGeneration(slideId, rootImage.query);
699 }
700 }
701 }
702
703 setSlides(imageSlidesData);
704 save();
705 } catch (error) {
706 generationLogger.error("Failed to process image slides XML stream", error, {
707 presentationId: usePresentationState.getState().currentPresentationId,
708 });
709 toast.error("Error processing image slides content");
710 }
711 }
712 }, [imageSlidesCompletion]);
713
714 useEffect(() => {
715 if (shouldStartPresentationGeneration) {
716 const {
717 outline,
718 presentationInput,
719 language,
720 modelId,
721 modelProvider,
722 tone,
723 currentPresentationTitle,
724 searchResults: stateSearchResults,
725 setThumbnailUrl,
726 textContent,
727 audience,
728 scenario,
729 imageSource,
730 selectedSlideTemplates,
731 outlineTemplateOverrides,
732 } = usePresentationState.getState();
733
734 if (!hasGeneratedOutline(outline)) {
735 setShouldStartPresentationGeneration(false);
736 setIsGeneratingPresentation(false);
737 toast.error("Generate an outline before generating the presentation.");
738 return;
739 }
740
741 // Serialize templates for AI if any are selected
742 const templateContext =
743 selectedSlideTemplates.length > 0
744 ? serializeTemplatesForPrompt(selectedSlideTemplates)
745 : undefined;
746 const outlineTemplateHints =
747 selectedSlideTemplates.length > 0 &&
748 Object.keys(outlineTemplateOverrides).length > 0
749 ? serializeTemplateHintsForPrompt(
750 outlineTemplateOverrides,
751 selectedSlideTemplates,
752 )
753 : undefined;
754
755 // Reset the parser before starting a new generation
756 streamingParserRef.current.reset();
757 setIsGeneratingPresentation(true);
758 setThumbnailUrl(undefined);
759 generationLogger.info("Presentation generation started", {
760 presentationId: currentPresentationId,
761 title: currentPresentationTitle ?? presentationInput ?? "",
762 outlineItems: outline.length,
763 modelProvider,
764 modelId: modelId || "gpt-4o-mini",
765 imageSource,
766 templateCount: selectedSlideTemplates.length,
767 });
768 void generatePresentation(presentationInput ?? "", {
769 body: {
770 title: currentPresentationTitle ?? presentationInput ?? "",
771 prompt: presentationInput ?? "",
772 outline,
773 searchResults: stateSearchResults,
774 language,
775 tone: tone,
776 modelId,
777 modelProvider,
778 textContent,
779 audience,
780 scenario,
781 imageSource,
782 templateContext,
783 outlineTemplateHints,
784 selectedTemplateCount: selectedSlideTemplates.length,
785 },
786 });
787 }
788 }, [shouldStartPresentationGeneration]);
789
790 // Watch for image slide generation start
791 useEffect(() => {
792 if (shouldStartImageSlideGeneration) {
793 const {
794 outline,
795 presentationInput,
796 language,
797 modelId,
798 modelProvider,
799 currentPresentationTitle,
800 setThumbnailUrl,
801 } = usePresentationState.getState();
802
803 if (!hasGeneratedOutline(outline)) {
804 setShouldStartImageSlideGeneration(false);
805 setIsGeneratingPresentation(false);
806 toast.error("Generate an outline before generating image slides.");
807 return;
808 }
809
810 // Reset the parser before starting a new generation
811 streamingParserRef.current.reset();
812 setIsGeneratingPresentation(true);
813 setThumbnailUrl(undefined);
814 generationLogger.info("Image slide generation started", {
815 presentationId: currentPresentationId,
816 title: currentPresentationTitle ?? presentationInput ?? "",
817 outlineItems: outline.length,
818 modelProvider,
819 modelId: modelId || "gpt-4o-mini",
820 });
821
822 void generateImageSlides(presentationInput ?? "", {
823 body: {
824 title: currentPresentationTitle ?? presentationInput ?? "",
825 prompt: presentationInput ?? "",
826 outline,
827 language,
828 modelId,
829 modelProvider,
830 },
831 });
832 }
833 }, [shouldStartImageSlideGeneration]);
834
835 // Listen for manual root image generation changes (when user manually triggers image generation)
836 useEffect(() => {
837 for (const [slideId, gen] of Object.entries(rootImageGeneration)) {
838 if (gen.status === "queued") {
839 // Next, set status to "pending"
840 usePresentationState.getState().rootImageGeneration &&
841 usePresentationState.setState((state) => ({
842 rootImageGeneration: {
843 ...state.rootImageGeneration,
844 [slideId]: {
845 ...gen,
846 status: "generating",
847 },
848 },
849 }));
850
851 const slide = slides.find((s) => s.id === slideId);
852 if (slide?.rootImage?.query) {
853 const usesStockSearch =
854 usesStockSearchForPresentation(imageSource) && !slide.isImageSlide;
855 generationLogger.info("Root image generation started", {
856 presentationId: currentPresentationId,
857 slideId,
858 isImageSlide: Boolean(slide.isImageSlide),
859 imageSource,
860 imageModel,
861 query: slide.rootImage.query,
862 });
863 void (async () => {
864 try {
865 let result;
866
867 if (usesStockSearch) {
868 const { stockImageProvider } = usePresentationState.getState();
869 if (
870 imageSource === "stock" &&
871 stockImageProvider === "pixabay"
872 ) {
873 const pixabayResult = await getImageFromPixabay(
874 slide.rootImage!.query,
875 slide.rootImage!.layoutType,
876 );
877 if (pixabayResult.success && pixabayResult.imageUrl) {
878 result = {
879 success: true,
880 image: { url: pixabayResult.imageUrl },
881 };
882 }
883 } else {
884 const unsplashResult = await getImageFromUnsplash(
885 slide.rootImage!.query,
886 slide.rootImage!.layoutType,
887 );
888 if (unsplashResult.success && unsplashResult.imageUrl) {
889 result = {
890 success: true,
891 image: { url: unsplashResult.imageUrl },
892 };
893 }
894 }
895 } else {
896 if (slide?.isImageSlide) {
897 result = await generateSlideImageAction(
898 slide.rootImage!.query,
899 imageModel,
900 );
901 } else {
902 result = await generateImageAction(
903 slide.rootImage!.query,
904 imageModel,
905 );
906 }
907 }
908
909 if (result?.success && result.image?.url) {
910 generationLogger.info("Root image generation completed", {
911 presentationId: currentPresentationId,
912 slideId,
913 imageUrl: result.image.url,
914 mode: usesStockSearch ? "stock-search" : "ai-generate",
915 });
916 completeRootImageGeneration(slideId, result.image.url);
917 usePresentationState.getState().setSlides(
918 usePresentationState.getState().slides.map((s) =>
919 s.id === slideId
920 ? {
921 ...s,
922 rootImage: {
923 ...s.rootImage!,
924 url: result.image.url,
925 imageSource: usesStockSearch
926 ? "search"
927 : "generate",
928 },
929 }
930 : s,
931 ),
932 );
933 save();
934 } else {
935 generationLogger.error(
936 "Root image generation failed without an image URL",
937 undefined,
938 {
939 presentationId: currentPresentationId,
940 slideId,
941 mode: usesStockSearch ? "stock-search" : "ai-generate",
942 error: result?.error ?? "No image url returned",
943 },
944 );
945 failRootImageGeneration(
946 slideId,
947 result?.error ?? "No image url returned",
948 );
949 }
950 } catch (err) {
951 const message =
952 err instanceof Error ? err.message : "Image generation failed";
953 generationLogger.error("Root image generation threw an error", err, {
954 presentationId: currentPresentationId,
955 slideId,
956 mode: usesStockSearch ? "stock-search" : "ai-generate",
957 });
958 failRootImageGeneration(slideId, message);
959 }
960 })();
961 }
962 }
963 }
964 }, [
965 rootImageGeneration,
966 isGeneratingPresentation,
967 isGeneratingOutline,
968 slides,
969 imageSource,
970 imageModel,
971 completeRootImageGeneration,
972 failRootImageGeneration,
973 setSlides,
974 ]);
975
976 // Clean up RAF on unmount
977 useEffect(() => {
978 return () => {
979 if (slidesRafIdRef.current !== null) {
980 cancelAnimationFrame(slidesRafIdRef.current);
981 slidesRafIdRef.current = null;
982 }
983
984 if (outlineRafIdRef.current !== null) {
985 cancelAnimationFrame(outlineRafIdRef.current);
986 outlineRafIdRef.current = null;
987 }
988 };
989 }, []);
990
991 return null;
992 }
993
993 lines Plain Text