返回 presentation-ai
page.tsx
根目录 / src / app / presentation / generate / [id] / page.tsx
1 "use client";
2
3 import {
4 getPresentation,
5 updatePresentation,
6 } from "@/app/_actions/notebook/presentation/presentationActions";
7 import { getCustomThemeById } from "@/app/_actions/presentation/theme-actions";
8 import { Header } from "@/components/notebook/presentation/components/outline/Header";
9 import { OutlineList } from "@/components/notebook/presentation/components/outline/OutlineList";
10 import { ToolCallDisplay } from "@/components/notebook/presentation/components/outline/ToolCallDisplay";
11 import { PresentationCustomizer } from "@/components/notebook/presentation/components/theme/PresentationCustomizer";
12 import { ThemeBackground } from "@/components/notebook/presentation/components/theme/ThemeBackground";
13 import { ThemeSettings } from "@/components/notebook/presentation/components/theme/ThemeSettings";
14 import { usePresentationTheme } from "@/components/presentation/providers/PresentationThemeProvider";
15 import { HelpMenu } from "@/components/sidebar/help-menu";
16 import { Button } from "@/components/ui/button";
17 import { Spinner } from "@/components/ui/spinner";
18 import {
19 applyPageBackgroundToConfig,
20 buildPresentationCustomization,
21 getPresentationCustomization,
22 } from "@/lib/presentation/customization";
23 import { type NotebookAgentToolCall } from "@/lib/notebook/agent-activity";
24 import { type NotebookSelectedChunk } from "@/lib/notebook/attachments";
25 import { getPersistablePresentationTheme } from "@/lib/presentation/theme-resolution";
26 import {
27 themes,
28 type ThemeProperties,
29 type Themes,
30 } from "@/lib/presentation/themes";
31 import { usePresentationState } from "@/states/presentation-state";
32 import { useQuery } from "@tanstack/react-query";
33 import { Wand2 } from "lucide-react";
34 import { useParams, useRouter } from "next/navigation";
35 import { useLayoutEffect, useRef, useState } from "react";
36 import { toast } from "sonner";
37
38 function parsePersistedArray<T>(value: unknown): T[] {
39 if (Array.isArray(value)) {
40 return value as T[];
41 }
42
43 if (typeof value === "string") {
44 try {
45 const parsed = JSON.parse(value);
46 return Array.isArray(parsed) ? (parsed as T[]) : [];
47 } catch {
48 return [];
49 }
50 }
51
52 return [];
53 }
54
55 export default function PresentationGenerateWithIdPage() {
56 const router = useRouter();
57 const params = useParams();
58 const id = params.id as string;
59 const { resolvedTheme } = usePresentationTheme();
60 const {
61 setCurrentPresentation,
62 setPresentationInput,
63 startOutlineGeneration,
64 startPresentationGeneration,
65 isGeneratingPresentation,
66 isGeneratingOutline,
67 setOutline,
68 setSearchResults,
69 setOutlineToolCalls,
70 setTheme,
71 setImageSource,
72 setPresentationStyle,
73 setPageStyle,
74 setLanguage,
75 setWebSearchEnabled,
76 setTextContent,
77 setTone,
78 setAudience,
79 setScenario,
80 setSelectedChunks,
81 setPendingCreateRequest,
82 outline,
83 currentPresentationId,
84 } = usePresentationState();
85 const outlineSectionRef = useRef<HTMLDivElement>(null);
86 const [isSavingBeforeGenerate, setIsSavingBeforeGenerate] = useState(false);
87 const hasOutline = outline.some((item) => item.trim().length > 0);
88 const isGeneratePresentationDisabled =
89 isGeneratingPresentation || isGeneratingOutline;
90 const generatePresentationButtonLabel = isGeneratingOutline
91 ? "Generating Outline..."
92 : isGeneratingPresentation
93 ? "Generating Presentation..."
94 : isSavingBeforeGenerate
95 ? "Saving Outline..."
96 : hasOutline
97 ? "Generate Presentation"
98 : "Generate Outline";
99
100 // Use React Query to fetch presentation data
101 const { data: presentationData, isLoading: isLoadingPresentation } = useQuery(
102 {
103 queryKey: ["presentation", id],
104 queryFn: async () => {
105 const result = await getPresentation(id);
106 if (!result.success) {
107 throw new Error(result.message ?? "Failed to load presentation");
108 }
109 return result.presentation;
110 },
111 enabled:
112 currentPresentationId !== id ||
113 (!isGeneratingOutline && outline.length === 0),
114 },
115 );
116
117 useLayoutEffect(() => {
118 if (id) {
119 setPendingCreateRequest(null);
120 }
121 }, [id, setPendingCreateRequest]);
122
123 // Update presentation state when data is fetched
124 useLayoutEffect(() => {
125 if (presentationData && !isLoadingPresentation && !isGeneratingOutline) {
126 setCurrentPresentation(presentationData.id, presentationData.title);
127 setPresentationInput(
128 presentationData.presentation?.prompt ?? presentationData.title,
129 );
130
131 const customization = getPresentationCustomization(
132 presentationData.presentation?.customization,
133 );
134 const customizationThemeId = presentationData.presentation?.theme ?? null;
135 const presentationContent = presentationData.presentation
136 ?.content as unknown as {
137 config?: Record<string, unknown>;
138 };
139
140 if (presentationData.presentation?.outline) {
141 setOutline(presentationData.presentation.outline);
142 }
143
144 setOutlineToolCalls(
145 parsePersistedArray<NotebookAgentToolCall>(
146 presentationData.presentation?.toolCalls,
147 ),
148 );
149 setSelectedChunks(
150 parsePersistedArray<NotebookSelectedChunk>(
151 presentationData.presentation?.selectedChunks,
152 ),
153 );
154
155 // Load search results if available
156 if (presentationData.presentation?.searchResults) {
157 try {
158 const searchResults = Array.isArray(
159 presentationData.presentation.searchResults,
160 )
161 ? presentationData.presentation.searchResults
162 : JSON.parse(presentationData.presentation.searchResults as string);
163 setWebSearchEnabled(true);
164 setSearchResults(searchResults);
165 } catch (error) {
166 console.error("Failed to parse search results:", error);
167 setSearchResults([]);
168 }
169 }
170
171 // Set theme if available
172 if (customizationThemeId) {
173 const themeId = customizationThemeId;
174 const customThemeData = customization?.themeData as
175 | ThemeProperties
176 | undefined;
177 if (customThemeData) {
178 setTheme(themeId, customThemeData);
179 } else if (themeId in themes) {
180 setTheme(themeId as Themes);
181 } else {
182 // If not in predefined themes, treat as custom theme
183 void getCustomThemeById(themeId)
184 .then((result) => {
185 if (result.success && result.theme) {
186 // Set the theme with the custom theme data
187 const themeData = result.theme
188 .themeData as unknown as ThemeProperties;
189 setTheme(themeId, themeData);
190 } else {
191 // Fallback to default theme if custom theme not found
192 console.warn("Custom theme not found:", themeId);
193 setTheme(resolvedTheme === "dark" ? "ebony" : "mystique");
194 }
195 })
196 .catch((error) => {
197 console.error("Failed to load custom theme:", error);
198 // Fallback to default theme on error
199 setTheme(resolvedTheme === "dark" ? "ebony" : "mystique");
200 });
201 }
202 }
203
204 // Set presentationStyle if available
205 if (customization?.presentationStyle) {
206 setPresentationStyle(customization.presentationStyle);
207 } else if (presentationData?.presentation?.presentationStyle) {
208 setPresentationStyle(presentationData.presentation.presentationStyle);
209 }
210
211 if (customization?.pageStyle) {
212 setPageStyle(customization.pageStyle);
213 }
214
215 if (customization?.textContent) {
216 setTextContent(customization.textContent);
217 }
218 if (customization?.tone) {
219 setTone(customization.tone);
220 }
221 if (customization?.audience) {
222 setAudience(customization.audience);
223 }
224 if (customization?.scenario) {
225 setScenario(customization.scenario);
226 }
227
228 if (presentationData?.presentation?.imageSource) {
229 setImageSource(
230 presentationData.presentation.imageSource as
231 | "automatic"
232 | "ai"
233 | "stock"
234 | "gif",
235 );
236 }
237
238 // Set language if available
239 if (presentationData.presentation?.language) {
240 setLanguage(presentationData.presentation.language);
241 }
242
243 if (customization?.pageBackground) {
244 const { setPageBackground } = usePresentationState.getState();
245 const next = applyPageBackgroundToConfig(
246 customization.pageBackground,
247 presentationContent?.config ?? {},
248 );
249 setPageBackground(next);
250 }
251
252 }
253 }, [
254 presentationData,
255 isLoadingPresentation,
256 setCurrentPresentation,
257 setPresentationInput,
258 setOutline,
259 setTheme,
260 setImageSource,
261 setOutlineToolCalls,
262 setPresentationStyle,
263 setPageStyle,
264 setLanguage,
265 setTextContent,
266 setTone,
267 setAudience,
268 setScenario,
269 setSelectedChunks,
270 resolvedTheme,
271 ]);
272
273 async function persistCurrentGenerationSettings() {
274 const state = usePresentationState.getState();
275 const customization = buildPresentationCustomization({
276 customThemeData: state.customThemeData,
277 themeDataByTheme: state.themeDataByTheme,
278 generatedThemeData: state.generatedThemeData,
279 theme: state.theme,
280 pageStyle: state.pageStyle,
281 presentationStyle: state.presentationStyle,
282 generationAspectRatio: state.generationAspectRatio,
283 textContent: state.textContent,
284 tone: state.tone,
285 audience: state.audience,
286 scenario: state.scenario,
287 pageBackground: state.pageBackground,
288 selectedSlideTemplates: state.selectedSlideTemplates,
289 outlineItemIds: state.outlineItemIds,
290 outlineTemplateOverrides: state.outlineTemplateOverrides,
291 });
292
293 const result = await updatePresentation({
294 id,
295 title: state.currentPresentationTitle ?? "",
296 prompt: state.presentationInput,
297 outline: state.outline,
298 searchResults: state.searchResults,
299 toolCalls: state.outlineToolCalls,
300 imageSource: state.imageSource,
301 presentationStyle: state.presentationStyle,
302 language: state.language,
303 selectedChunks: state.selectedChunks,
304 theme: getPersistablePresentationTheme({
305 fallbackTheme: resolvedTheme === "dark" ? "ebony" : "mystique",
306 theme: state.theme,
307 }),
308 customization,
309 });
310
311 if (!result.success) {
312 throw new Error(result.message ?? "Failed to save presentation");
313 }
314 }
315
316 const handleGenerate = async () => {
317 if (isGeneratingOutline || isGeneratingPresentation || isSavingBeforeGenerate) {
318 return;
319 }
320
321 const latestOutline = usePresentationState.getState().outline;
322 const hasLatestOutline = latestOutline.some(
323 (item) => item.trim().length > 0,
324 );
325
326 if (!hasLatestOutline) {
327 outlineSectionRef.current?.scrollIntoView({
328 behavior: "smooth",
329 block: "start",
330 });
331 startOutlineGeneration();
332 return;
333 }
334
335 setIsSavingBeforeGenerate(true);
336 try {
337 await persistCurrentGenerationSettings();
338 } catch (error) {
339 const message =
340 error instanceof Error
341 ? error.message
342 : "Failed to save the latest outline.";
343 toast.error(message);
344 setIsSavingBeforeGenerate(false);
345 return;
346 }
347
348 router.push(`/presentation/${id}`);
349 startPresentationGeneration();
350 };
351
352 const handleRegenerateOutline = () => {
353 if (isGeneratingOutline || isGeneratingPresentation) {
354 return;
355 }
356
357 startOutlineGeneration();
358 };
359
360 if (isLoadingPresentation) {
361 return (
362 <ThemeBackground
363 themeOverride={resolvedTheme === "dark" ? "ebony" : "mystique"}
364 >
365 <div className="flex h-[calc(100vh-8rem)] flex-col items-center justify-center px-4 text-center">
366 <div className="relative">
367 <Spinner className="h-10 w-10 text-primary" />
368 </div>
369 <div className="space-y-2 text-center">
370 <h2 className="text-2xl font-bold">Loading Presentation Outline</h2>
371 <p className="text-muted-foreground">Please wait a moment...</p>
372 </div>
373 </div>
374 </ThemeBackground>
375 );
376 }
377
378 return (
379 <ThemeBackground>
380 <div className="flex justify-center pb-28">
381 <div className="w-full max-w-4xl space-y-6 px-4 pt-14 pb-6 sm:p-8 sm:pt-6">
382 <div className="space-y-6">
383 <Header
384 onRegenerate={handleRegenerateOutline}
385 isGeneratingOutlineOverride={isGeneratingOutline}
386 />
387 <ToolCallDisplay
388 isGeneratingOutlineOverride={isGeneratingOutline}
389 />
390 <div ref={outlineSectionRef}>
391 <OutlineList />
392 </div>
393 <PresentationCustomizer />
394 <ThemeSettings />
395 </div>
396 </div>
397 </div>
398
399 <div className="fixed right-0 bottom-0 left-0 border-t bg-background/80 p-4 backdrop-blur-xs">
400 <div className="mx-auto flex w-full max-w-4xl flex-col justify-center gap-3 sm:w-fit sm:max-w-none sm:flex-row sm:gap-4">
401 <div className="w-full sm:w-fit sm:flex-none">
402 <Button
403 size="lg"
404 className="w-full gap-2 px-8 sm:h-10 sm:w-auto sm:min-w-0 sm:px-5 sm:text-sm"
405 onClick={handleGenerate}
406 disabled={
407 isGeneratePresentationDisabled || isSavingBeforeGenerate
408 }
409 >
410 <Wand2 className="h-5 w-5 sm:h-4 sm:w-4" />
411 {generatePresentationButtonLabel}
412 </Button>
413 </div>
414 </div>
415 </div>
416
417 <div className="fixed right-4 bottom-24 z-99999 sm:right-6 sm:bottom-2">
418 <HelpMenu hideKeyboardShortcutsOnMobile />
419 </div>
420 </ThemeBackground>
421 );
422 }
423
423 lines Plain Text