返回 presentation-ai
route.ts
根目录 / src / app / api / presentation / outline / route.ts
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
366 lines TYPESCRIPT