route.ts 5.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174
  1. /**
  2. * Scene Content Generation API
  3. *
  4. * Generates scene content (slides/quiz/interactive/pbl) from an outline.
  5. * This is the first half of the two-step scene generation pipeline.
  6. * Does NOT generate actions — use /api/generate/scene-actions for that.
  7. */
  8. import { NextRequest } from 'next/server';
  9. import { callLLM } from '@/lib/ai/llm';
  10. import {
  11. applyOutlineFallbacks,
  12. generateSceneContent,
  13. buildVisionUserContent,
  14. } from '@/lib/generation/generation-pipeline';
  15. import type { AgentInfo } from '@/lib/generation/generation-pipeline';
  16. import type { SceneOutline, PdfImage, ImageMapping } from '@/lib/types/generation';
  17. import { createLogger } from '@/lib/logger';
  18. import { apiError, apiSuccess } from '@/lib/server/api-response';
  19. import { resolveModelFromHeaders } from '@/lib/server/resolve-model';
  20. const log = createLogger('Scene Content API');
  21. export const maxDuration = 300;
  22. export async function POST(req: NextRequest) {
  23. let outlineTitle: string | undefined;
  24. let resolvedModelString: string | undefined;
  25. try {
  26. const body = await req.json();
  27. const {
  28. outline: rawOutline,
  29. allOutlines,
  30. pdfImages,
  31. imageMapping,
  32. stageInfo,
  33. stageId,
  34. agents,
  35. } = body as {
  36. outline: SceneOutline;
  37. allOutlines: SceneOutline[];
  38. pdfImages?: PdfImage[];
  39. imageMapping?: ImageMapping;
  40. stageInfo: {
  41. name: string;
  42. description?: string;
  43. language?: string;
  44. style?: string;
  45. };
  46. stageId: string;
  47. agents?: AgentInfo[];
  48. };
  49. // Validate required fields
  50. if (!rawOutline) {
  51. return apiError('MISSING_REQUIRED_FIELD', 400, 'outline is required');
  52. }
  53. if (!allOutlines || allOutlines.length === 0) {
  54. return apiError(
  55. 'MISSING_REQUIRED_FIELD',
  56. 400,
  57. 'allOutlines is required and must not be empty',
  58. );
  59. }
  60. if (!stageId) {
  61. return apiError('MISSING_REQUIRED_FIELD', 400, 'stageId is required');
  62. }
  63. // Ensure outline has language from stageInfo (fallback for older outlines)
  64. const outline: SceneOutline = {
  65. ...rawOutline,
  66. language: rawOutline.language || (stageInfo?.language as 'zh-CN' | 'en-US') || 'zh-CN',
  67. };
  68. // ── Model resolution from request headers ──
  69. const { model: languageModel, modelInfo, modelString } = await resolveModelFromHeaders(req);
  70. outlineTitle = rawOutline?.title;
  71. resolvedModelString = modelString;
  72. // Detect vision capability
  73. const hasVision = !!modelInfo?.capabilities?.vision;
  74. // Vision-aware AI call function
  75. const aiCall = async (
  76. systemPrompt: string,
  77. userPrompt: string,
  78. images?: Array<{ id: string; src: string }>,
  79. ): Promise<string> => {
  80. if (images?.length && hasVision) {
  81. const result = await callLLM(
  82. {
  83. model: languageModel,
  84. system: systemPrompt,
  85. messages: [
  86. {
  87. role: 'user' as const,
  88. content: buildVisionUserContent(userPrompt, images),
  89. },
  90. ],
  91. maxOutputTokens: modelInfo?.outputWindow,
  92. },
  93. 'scene-content',
  94. );
  95. return result.text;
  96. }
  97. const result = await callLLM(
  98. {
  99. model: languageModel,
  100. system: systemPrompt,
  101. prompt: userPrompt,
  102. maxOutputTokens: modelInfo?.outputWindow,
  103. },
  104. 'scene-content',
  105. );
  106. return result.text;
  107. };
  108. // ── Apply fallbacks ──
  109. const effectiveOutline = applyOutlineFallbacks(outline, !!languageModel);
  110. // ── Filter images assigned to this outline ──
  111. let assignedImages: PdfImage[] | undefined;
  112. if (
  113. pdfImages &&
  114. pdfImages.length > 0 &&
  115. effectiveOutline.suggestedImageIds &&
  116. effectiveOutline.suggestedImageIds.length > 0
  117. ) {
  118. const suggestedIds = new Set(effectiveOutline.suggestedImageIds);
  119. assignedImages = pdfImages.filter((img) => suggestedIds.has(img.id));
  120. }
  121. // ── Media generation is handled client-side in parallel (media-orchestrator.ts) ──
  122. // The content generator receives placeholder IDs (gen_img_1, gen_vid_1) as-is.
  123. // resolveImageIds() in generation-pipeline.ts will keep these placeholders in elements.
  124. const generatedMediaMapping: ImageMapping = {};
  125. // ── Generate content ──
  126. log.info(
  127. `Generating content: "${effectiveOutline.title}" (${effectiveOutline.type}) [model=${modelString}]`,
  128. );
  129. const content = await generateSceneContent(
  130. effectiveOutline,
  131. aiCall,
  132. assignedImages,
  133. imageMapping,
  134. effectiveOutline.type === 'pbl' ? languageModel : undefined,
  135. hasVision,
  136. generatedMediaMapping,
  137. agents,
  138. );
  139. if (!content) {
  140. log.error(`Failed to generate content for: "${effectiveOutline.title}"`);
  141. return apiError(
  142. 'GENERATION_FAILED',
  143. 500,
  144. `Failed to generate content: ${effectiveOutline.title}`,
  145. );
  146. }
  147. log.info(`Content generated successfully: "${effectiveOutline.title}"`);
  148. return apiSuccess({ content, effectiveOutline });
  149. } catch (error) {
  150. log.error(
  151. `Scene content generation failed [scene="${outlineTitle ?? 'unknown'}", model=${resolvedModelString ?? 'unknown'}]:`,
  152. error,
  153. );
  154. return apiError('INTERNAL_ERROR', 500, error instanceof Error ? error.message : String(error));
  155. }
  156. }