| 1 | import { toUIMessageStream } from "@ai-sdk/langchain"; |
| 2 | import { RunnableSequence } from "@langchain/core/runnables"; |
| 3 | import { createUIMessageStreamResponse } from "ai"; |
| 4 | import { NextResponse } from "next/server"; |
| 5 | |
| 6 | import { |
| 7 | assertModelIsConfigured, |
| 8 | ensureModelIsReady, |
| 9 | modelPicker, |
| 10 | } from "@/lib/modelPicker"; |
| 11 | import { createLogger } from "@/lib/observability/logger"; |
| 12 | import { |
| 13 | buildPresentationPromptValues, |
| 14 | presentationGenerationPromptTemplate, |
| 15 | type PresentationGenerationPromptInput, |
| 16 | } from "@/lib/presentation/generation-prompt"; |
| 17 | import { auth } from "@/server/auth"; |
| 18 | |
| 19 | type SlidesRequest = Omit<PresentationGenerationPromptInput, "currentDate"> & { |
| 20 | modelId?: string; |
| 21 | modelProvider?: "openai" | "ollama" | "lmstudio"; |
| 22 | presentationId?: string; |
| 23 | }; |
| 24 | |
| 25 | export async function POST(req: Request) { |
| 26 | const requestId = crypto.randomUUID(); |
| 27 | const routeLogger = createLogger("api:presentation-generate"); |
| 28 | |
| 29 | try { |
| 30 | routeLogger.info("Presentation generation request received", { requestId }); |
| 31 | const session = await auth(); |
| 32 | if (!session) { |
| 33 | routeLogger.warn("Presentation generation request rejected: unauthorized", { |
| 34 | requestId, |
| 35 | }); |
| 36 | return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); |
| 37 | } |
| 38 | |
| 39 | const request = (await req.json()) as SlidesRequest; |
| 40 | const { modelId, modelProvider = "openai" } = request; |
| 41 | |
| 42 | if ( |
| 43 | !request.title || |
| 44 | !request.outline || |
| 45 | !Array.isArray(request.outline) || |
| 46 | !request.language |
| 47 | ) { |
| 48 | routeLogger.warn( |
| 49 | "Presentation generation request rejected: missing required fields", |
| 50 | { |
| 51 | requestId, |
| 52 | hasTitle: Boolean(request.title), |
| 53 | hasOutline: Array.isArray(request.outline), |
| 54 | language: request.language, |
| 55 | }, |
| 56 | ); |
| 57 | return NextResponse.json( |
| 58 | { error: "Missing required fields" }, |
| 59 | { status: 400 }, |
| 60 | ); |
| 61 | } |
| 62 | |
| 63 | const totalSlides = request.outline.length; |
| 64 | const templateCount = request.selectedTemplateCount ?? 0; |
| 65 | |
| 66 | const currentDate = new Date().toLocaleDateString("en-US", { |
| 67 | weekday: "long", |
| 68 | year: "numeric", |
| 69 | month: "long", |
| 70 | day: "numeric", |
| 71 | }); |
| 72 | |
| 73 | routeLogger.info("Validated presentation generation request", { |
| 74 | requestId, |
| 75 | title: request.title, |
| 76 | totalSlides, |
| 77 | language: request.language, |
| 78 | tone: request.tone, |
| 79 | modelProvider, |
| 80 | modelId: modelId || "gpt-4o-mini", |
| 81 | imageSource: request.imageSource || "automatic", |
| 82 | templateCount, |
| 83 | searchResultGroupCount: request.searchResults?.length ?? 0, |
| 84 | imageSearchResultGroupCount: request.imageSearchResults?.length ?? 0, |
| 85 | selectedChunkCount: request.selectedChunks?.length ?? 0, |
| 86 | presentationId: request.presentationId, |
| 87 | }); |
| 88 | |
| 89 | try { |
| 90 | assertModelIsConfigured(modelProvider, modelId); |
| 91 | } catch (error) { |
| 92 | routeLogger.error( |
| 93 | "Presentation generation request rejected: invalid model configuration", |
| 94 | error, |
| 95 | { |
| 96 | requestId, |
| 97 | modelProvider, |
| 98 | modelId: modelId || "gpt-4o-mini", |
| 99 | }, |
| 100 | ); |
| 101 | return NextResponse.json( |
| 102 | { |
| 103 | error: |
| 104 | error instanceof Error |
| 105 | ? error.message |
| 106 | : "Invalid model configuration", |
| 107 | }, |
| 108 | { status: 400 }, |
| 109 | ); |
| 110 | } |
| 111 | |
| 112 | try { |
| 113 | await ensureModelIsReady(modelProvider, modelId); |
| 114 | } catch (error) { |
| 115 | routeLogger.error( |
| 116 | "Presentation generation request rejected: selected model could not be prepared", |
| 117 | error, |
| 118 | { |
| 119 | requestId, |
| 120 | modelProvider, |
| 121 | modelId: modelId || "gpt-4o-mini", |
| 122 | }, |
| 123 | ); |
| 124 | return NextResponse.json( |
| 125 | { |
| 126 | error: |
| 127 | error instanceof Error |
| 128 | ? error.message |
| 129 | : "Failed to prepare selected model", |
| 130 | }, |
| 131 | { status: 503 }, |
| 132 | ); |
| 133 | } |
| 134 | |
| 135 | const model = modelPicker(modelProvider, modelId); |
| 136 | const chain = RunnableSequence.from([ |
| 137 | presentationGenerationPromptTemplate, |
| 138 | model, |
| 139 | ]); |
| 140 | |
| 141 | routeLogger.info("Presentation generation started", { |
| 142 | requestId, |
| 143 | title: request.title, |
| 144 | totalSlides, |
| 145 | modelProvider, |
| 146 | modelId: modelId || "gpt-4o-mini", |
| 147 | }); |
| 148 | |
| 149 | const stream = await chain.stream( |
| 150 | buildPresentationPromptValues({ |
| 151 | ...request, |
| 152 | currentDate, |
| 153 | }), |
| 154 | ); |
| 155 | |
| 156 | routeLogger.info("Presentation generation stream created", { |
| 157 | requestId, |
| 158 | title: request.title, |
| 159 | totalSlides, |
| 160 | }); |
| 161 | |
| 162 | return createUIMessageStreamResponse({ stream: toUIMessageStream(stream) }); |
| 163 | } catch (error) { |
| 164 | routeLogger.error("Presentation generation failed", error, { requestId }); |
| 165 | return NextResponse.json( |
| 166 | { error: "Failed to generate presentation slides" }, |
| 167 | { status: 500 }, |
| 168 | ); |
| 169 | } |
| 170 | } |
| 171 |