| 1 | import { search_tool } from "@/ai/tools/search"; |
| 2 | import { |
| 3 | getLatestUserMessage, |
| 4 | getMessageText, |
| 5 | } from "@/lib/ai/uiMessageParts"; |
| 6 | import { |
| 7 | assertModelIsConfigured, |
| 8 | ensureModelIsReady, |
| 9 | modelPicker, |
| 10 | } from "@/lib/modelPicker"; |
| 11 | import { createLogger } from "@/lib/observability/logger"; |
| 12 | import { logger } from "@/lib/observability/server/logger"; |
| 13 | import { auth } from "@/server/auth"; |
| 14 | import { toBaseMessages, toUIMessageStream } from "@ai-sdk/langchain"; |
| 15 | import { |
| 16 | createUIMessageStreamResponse, |
| 17 | type UIMessage, |
| 18 | } from "ai"; |
| 19 | import { createAgent } from "langchain"; |
| 20 | import { NextResponse } from "next/server"; |
| 21 | |
| 22 | interface OutlineRequest { |
| 23 | messages?: UIMessage[]; |
| 24 | } |
| 25 | |
| 26 | interface OutlineMessageMetadata { |
| 27 | numberOfCards?: number; |
| 28 | language?: string; |
| 29 | modelId?: string; |
| 30 | modelProvider?: "openai" | "ollama" | "lmstudio"; |
| 31 | webSearch?: boolean; |
| 32 | autoTheme?: boolean; |
| 33 | textContent?: "minimal" | "concise" | "detailed" | "extensive"; |
| 34 | tone?: string; |
| 35 | audience?: string; |
| 36 | scenario?: string; |
| 37 | presentationId?: string; |
| 38 | } |
| 39 | |
| 40 | const outlineSystemPrompt = `You are an expert presentation outline generator. Your task is to create a comprehensive and engaging presentation outline based on the user's topic. |
| 41 | |
| 42 | Current Date: {currentDate} |
| 43 | |
| 44 | ## Presentation Customization: |
| 45 | - Text Content Level: {textContent} |
| 46 | - Tone: {tone} |
| 47 | - Target Audience: {audience} |
| 48 | - Scenario: {scenario} |
| 49 | |
| 50 | ## Your Process: |
| 51 | 1. Analyze the topic |
| 52 | 2. {researchStep} |
| 53 | 3. Generate the outline |
| 54 | |
| 55 | ## Web Search Guidelines: |
| 56 | {webSearchGuidelines} |
| 57 | |
| 58 | ## Outline Requirements: |
| 59 | - First generate an appropriate title for the presentation |
| 60 | - Generate exactly {numberOfCards} main topics |
| 61 | - Each topic should be a clear, engaging heading |
| 62 | - Include 2-3 bullet points per topic |
| 63 | - Use {language} language |
| 64 | - Adapt content depth based on the text content level |
| 65 | - Tailor language for the requested tone, audience, and scenario |
| 66 | - ALWAYS use bullet points formatted as "- point text" |
| 67 | - Do not use bold, italic, or underline |
| 68 | |
| 69 | ## Output Format: |
| 70 | Start with the title in XML tags, then generate markdown with each topic as a heading followed by bullet points. |
| 71 | |
| 72 | Example: |
| 73 | <TITLE>Your Generated Presentation Title Here</TITLE> |
| 74 | |
| 75 | # First Main Topic |
| 76 | - Key point |
| 77 | - Another point |
| 78 | |
| 79 | # Second Main Topic |
| 80 | - Key point |
| 81 | - Another point |
| 82 | |
| 83 | {themeInstructions} |
| 84 | |
| 85 | Remember: {finalInstruction}`; |
| 86 | |
| 87 | const autoThemeInstructions = `## Custom Theme Output: |
| 88 | After the full outline is complete, you MUST emit one final THEME XML block. The THEME block must come after all outline sections, never before them. |
| 89 | |
| 90 | The THEME block is mandatory for this request. Create a custom visual direction that fits the user's topic, audience, tone, scenario, and any named brand or organization. |
| 91 | |
| 92 | Example theme block: |
| 93 | <THEME> |
| 94 | <name>Short theme name</name> |
| 95 | <description>Short visual direction</description> |
| 96 | <mode>light</mode> |
| 97 | <primary>#2563EB</primary> |
| 98 | <accent>#F97316</accent> |
| 99 | <background>#F8FAFC</background> |
| 100 | <text>#1F2937</text> |
| 101 | <heading>#111827</heading> |
| 102 | <smartLayout>#2563EB</smartLayout> |
| 103 | <cardBackground>#FFFFFF</cardBackground> |
| 104 | <headingFont>Inter</headingFont> |
| 105 | <bodyFont>Inter</bodyFont> |
| 106 | </THEME> |
| 107 | |
| 108 | Theme requirements: |
| 109 | - Prefer known brand colors when the prompt clearly names a brand and the palette is already known to you. |
| 110 | - If the brand palette is not known with confidence, create a topic-appropriate palette instead of inventing brand colors. |
| 111 | - Generate colors that match the topic, audience, tone, and scenario. |
| 112 | - Use only valid 6-digit hex colors. |
| 113 | - Ensure text and heading colors have strong contrast against background and cardBackground. |
| 114 | - Color field meanings: |
| 115 | - primary is the main brand/action color used for emphasis and prominent accents. |
| 116 | - smartLayout is the fill color for SVG-based visual structures such as pyramids, pie charts, staircase blocks, cycles, timelines, and diagrams. It usually belongs near primary or a deliberate variant of it, not a disconnected neutral color. |
| 117 | - cardBackground is the readable surface behind text in cards and containers. Do not use cardBackground as a substitute for smartLayout. |
| 118 | - Include headingFont and bodyFont when you include a THEME block. Use real, well-known font family names that fit the brand and requirement. Do not invent font names. Good choices include Inter, Manrope, Poppins, IBM Plex Sans, Space Grotesk, Sora, Playfair Display, Merriweather, Lato, Open Sans, Work Sans, DM Sans, and Source Sans Pro. |
| 119 | - Do not include prose before or after the THEME block.`; |
| 120 | |
| 121 | function buildOutlineSystemPrompt({ |
| 122 | actualLanguage, |
| 123 | numberOfCards, |
| 124 | currentDate, |
| 125 | textContent, |
| 126 | tone, |
| 127 | audience, |
| 128 | scenario, |
| 129 | webSearch, |
| 130 | autoTheme, |
| 131 | }: { |
| 132 | actualLanguage: string; |
| 133 | numberOfCards: number; |
| 134 | currentDate: string; |
| 135 | textContent: NonNullable<OutlineMessageMetadata["textContent"]>; |
| 136 | tone: string; |
| 137 | audience: string; |
| 138 | scenario: string; |
| 139 | webSearch: boolean; |
| 140 | autoTheme: boolean; |
| 141 | }) { |
| 142 | return outlineSystemPrompt |
| 143 | .replace("{currentDate}", currentDate) |
| 144 | .replace("{numberOfCards}", numberOfCards.toString()) |
| 145 | .replace("{language}", actualLanguage) |
| 146 | .replaceAll("{textContent}", textContent) |
| 147 | .replaceAll("{tone}", tone) |
| 148 | .replaceAll("{audience}", audience) |
| 149 | .replaceAll("{scenario}", scenario) |
| 150 | .replace( |
| 151 | "{researchStep}", |
| 152 | webSearch |
| 153 | ? "Research first using web search before writing the outline" |
| 154 | : "Use existing knowledge only and skip tool usage", |
| 155 | ) |
| 156 | .replace( |
| 157 | "{webSearchGuidelines}", |
| 158 | webSearch |
| 159 | ? [ |
| 160 | "- Use web search for current facts, recent developments, and useful statistics", |
| 161 | "- Limit yourself to a few focused searches", |
| 162 | "- Only search when it materially improves the outline", |
| 163 | ].join("\n") |
| 164 | : "- Web search is disabled for this request.", |
| 165 | ) |
| 166 | .replace("{themeInstructions}", autoTheme ? autoThemeInstructions : "") |
| 167 | .replace( |
| 168 | "{finalInstruction}", |
| 169 | webSearch |
| 170 | ? "Perform at least one web search before generating the outline." |
| 171 | : "Generate the outline directly without web search.", |
| 172 | ); |
| 173 | } |
| 174 | |
| 175 | export async function POST(req: Request) { |
| 176 | const actionName = "presentation.outline.post"; |
| 177 | const requestId = crypto.randomUUID(); |
| 178 | const routeLogger = createLogger("api:presentation-outline"); |
| 179 | const span = logger.startSpan(`allweone.api.${actionName}`, { |
| 180 | attributes: { |
| 181 | "allweone.scope": "api", |
| 182 | "allweone.action.type": "api_route", |
| 183 | "allweone.action.name": actionName, |
| 184 | "http.method": "POST", |
| 185 | "http.route": "/api/presentation/outline", |
| 186 | "allweone.request.id": requestId, |
| 187 | }, |
| 188 | }); |
| 189 | |
| 190 | try { |
| 191 | routeLogger.info("Outline request received", { requestId }); |
| 192 | const session = await auth(); |
| 193 | if (!session) { |
| 194 | routeLogger.warn("Outline request rejected: unauthorized", { requestId }); |
| 195 | span.event("allweone.api.request_rejected", { |
| 196 | "allweone.validation.error": "unauthorized", |
| 197 | }); |
| 198 | return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); |
| 199 | } |
| 200 | |
| 201 | const request = (await req.json()) as OutlineRequest; |
| 202 | const { messages = [] } = request; |
| 203 | const latestUserMessage = getLatestUserMessage(messages); |
| 204 | const prompt = latestUserMessage ? getMessageText(latestUserMessage).trim() : ""; |
| 205 | const metadata = |
| 206 | (latestUserMessage?.metadata as OutlineMessageMetadata | undefined) ?? {}; |
| 207 | const numberOfCards = metadata.numberOfCards ?? 0; |
| 208 | const language = metadata.language ?? ""; |
| 209 | const modelProvider = metadata.modelProvider ?? "openai"; |
| 210 | const modelId = metadata.modelId; |
| 211 | const webSearch = Boolean(metadata.webSearch); |
| 212 | const autoTheme = metadata.autoTheme ?? false; |
| 213 | |
| 214 | span.annotate({ |
| 215 | "allweone.presentation.cards.count": numberOfCards, |
| 216 | "allweone.presentation.prompt.length": prompt.length, |
| 217 | "allweone.presentation.language": language, |
| 218 | "allweone.presentation.web_search": webSearch, |
| 219 | "allweone.presentation.auto_theme": autoTheme, |
| 220 | }); |
| 221 | routeLogger.info("Validated outline request payload", { |
| 222 | requestId, |
| 223 | numberOfCards, |
| 224 | promptLength: prompt.length, |
| 225 | language, |
| 226 | modelProvider, |
| 227 | modelId: modelId || "gpt-4o-mini", |
| 228 | webSearch, |
| 229 | }); |
| 230 | |
| 231 | if (!prompt || !numberOfCards || !language || messages.length === 0) { |
| 232 | routeLogger.warn("Outline request rejected: missing required fields", { |
| 233 | requestId, |
| 234 | hasPrompt: Boolean(prompt), |
| 235 | numberOfCards, |
| 236 | language, |
| 237 | messageCount: messages.length, |
| 238 | }); |
| 239 | span.event("allweone.api.request_rejected", { |
| 240 | "allweone.validation.error": "missing_required_fields", |
| 241 | }); |
| 242 | return NextResponse.json( |
| 243 | { error: "Missing required fields" }, |
| 244 | { status: 400 }, |
| 245 | ); |
| 246 | } |
| 247 | |
| 248 | const languageMap: Record<string, string> = { |
| 249 | "en-US": "English (US)", |
| 250 | pt: "Portuguese", |
| 251 | es: "Spanish", |
| 252 | fr: "French", |
| 253 | de: "German", |
| 254 | it: "Italian", |
| 255 | ja: "Japanese", |
| 256 | ko: "Korean", |
| 257 | zh: "Chinese", |
| 258 | ru: "Russian", |
| 259 | hi: "Hindi", |
| 260 | ar: "Arabic", |
| 261 | }; |
| 262 | |
| 263 | const actualLanguage = languageMap[language] ?? language; |
| 264 | const currentDate = new Date().toLocaleDateString("en-US", { |
| 265 | weekday: "long", |
| 266 | year: "numeric", |
| 267 | month: "long", |
| 268 | day: "numeric", |
| 269 | }); |
| 270 | try { |
| 271 | assertModelIsConfigured(modelProvider, modelId); |
| 272 | } catch (error) { |
| 273 | routeLogger.error("Outline request rejected: invalid model configuration", error, { |
| 274 | requestId, |
| 275 | modelProvider, |
| 276 | modelId: modelId || "gpt-4o-mini", |
| 277 | }); |
| 278 | return NextResponse.json( |
| 279 | { |
| 280 | error: |
| 281 | error instanceof Error |
| 282 | ? error.message |
| 283 | : "Invalid model configuration", |
| 284 | }, |
| 285 | { status: 400 }, |
| 286 | ); |
| 287 | } |
| 288 | try { |
| 289 | await ensureModelIsReady(modelProvider, modelId); |
| 290 | } catch (error) { |
| 291 | routeLogger.error( |
| 292 | "Outline request rejected: selected model could not be prepared", |
| 293 | error, |
| 294 | { |
| 295 | requestId, |
| 296 | modelProvider, |
| 297 | modelId: modelId || "gpt-4o-mini", |
| 298 | }, |
| 299 | ); |
| 300 | return NextResponse.json( |
| 301 | { |
| 302 | error: |
| 303 | error instanceof Error |
| 304 | ? error.message |
| 305 | : "Failed to prepare selected model", |
| 306 | }, |
| 307 | { status: 503 }, |
| 308 | ); |
| 309 | } |
| 310 | |
| 311 | const agent = createAgent({ |
| 312 | model: modelPicker(modelProvider, modelId), |
| 313 | tools: webSearch ? [search_tool] : [], |
| 314 | systemPrompt: |
| 315 | buildOutlineSystemPrompt({ |
| 316 | actualLanguage, |
| 317 | numberOfCards, |
| 318 | currentDate, |
| 319 | textContent: metadata.textContent ?? "concise", |
| 320 | tone: metadata.tone ?? "auto", |
| 321 | audience: metadata.audience ?? "auto", |
| 322 | scenario: metadata.scenario ?? "auto", |
| 323 | webSearch, |
| 324 | autoTheme, |
| 325 | }), |
| 326 | }); |
| 327 | |
| 328 | routeLogger.info("Presentation outline generation started", { |
| 329 | requestId, |
| 330 | modelProvider, |
| 331 | modelId: modelId || "gpt-4o-mini", |
| 332 | numberOfCards, |
| 333 | webSearch, |
| 334 | }); |
| 335 | const stream = await agent.stream( |
| 336 | { |
| 337 | messages: await toBaseMessages(messages), |
| 338 | }, |
| 339 | { |
| 340 | streamMode: ["values", "messages"], |
| 341 | }, |
| 342 | ); |
| 343 | |
| 344 | routeLogger.info("Presentation outline stream created", { |
| 345 | requestId, |
| 346 | modelProvider, |
| 347 | modelId: modelId || "gpt-4o-mini", |
| 348 | }); |
| 349 | span.event("allweone.api.response_stream_created"); |
| 350 | return createUIMessageStreamResponse({ |
| 351 | stream: toUIMessageStream(stream), |
| 352 | }); |
| 353 | } catch (error) { |
| 354 | routeLogger.error("Presentation outline generation failed", error, { |
| 355 | requestId, |
| 356 | }); |
| 357 | span.error(error); |
| 358 | return NextResponse.json( |
| 359 | { error: "Failed to generate outline" }, |
| 360 | { status: 500 }, |
| 361 | ); |
| 362 | } finally { |
| 363 | span.end(); |
| 364 | } |
| 365 | } |
| 366 |