route.ts 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368
  1. /**
  2. * Scene Outlines Streaming API (SSE)
  3. *
  4. * Streams outline generation via Server-Sent Events.
  5. * Emits individual outline objects as they're parsed from the LLM response,
  6. * so the frontend can display them incrementally.
  7. *
  8. * SSE events:
  9. * { type: 'outline', data: SceneOutline, index: number }
  10. * { type: 'done', outlines: SceneOutline[] }
  11. * { type: 'error', error: string }
  12. */
  13. import { NextRequest } from 'next/server';
  14. import { streamLLM } from '@/lib/ai/llm';
  15. import { buildPrompt, PROMPT_IDS } from '@/lib/generation/prompts';
  16. import {
  17. formatImageDescription,
  18. formatImagePlaceholder,
  19. buildVisionUserContent,
  20. uniquifyMediaElementIds,
  21. formatTeacherPersonaForPrompt,
  22. } from '@/lib/generation/generation-pipeline';
  23. import type { AgentInfo } from '@/lib/generation/generation-pipeline';
  24. import { MAX_PDF_CONTENT_CHARS, MAX_VISION_IMAGES } from '@/lib/constants/generation';
  25. import { nanoid } from 'nanoid';
  26. import type {
  27. UserRequirements,
  28. PdfImage,
  29. SceneOutline,
  30. ImageMapping,
  31. } from '@/lib/types/generation';
  32. import { apiError } from '@/lib/server/api-response';
  33. import { createLogger } from '@/lib/logger';
  34. import { resolveModelFromHeaders } from '@/lib/server/resolve-model';
  35. const log = createLogger('Outlines Stream');
  36. export const maxDuration = 300;
  37. /**
  38. * Incremental JSON array parser.
  39. * Extracts complete top-level objects from a partially-streamed JSON array.
  40. * Returns newly found objects (skipping `alreadyParsed` count).
  41. */
  42. function extractNewOutlines(buffer: string, alreadyParsed: number): SceneOutline[] {
  43. const results: SceneOutline[] = [];
  44. // Find the start of the JSON array (skip any markdown fencing)
  45. const stripped = buffer.replace(/^[\s\S]*?(?=\[)/, '');
  46. const arrayStart = stripped.indexOf('[');
  47. if (arrayStart === -1) return results;
  48. let depth = 0;
  49. let objectStart = -1;
  50. let inString = false;
  51. let escaped = false;
  52. let objectCount = 0;
  53. for (let i = arrayStart + 1; i < stripped.length; i++) {
  54. const char = stripped[i];
  55. if (escaped) {
  56. escaped = false;
  57. continue;
  58. }
  59. if (char === '\\' && inString) {
  60. escaped = true;
  61. continue;
  62. }
  63. if (char === '"') {
  64. inString = !inString;
  65. continue;
  66. }
  67. if (inString) continue;
  68. if (char === '{') {
  69. if (depth === 0) objectStart = i;
  70. depth++;
  71. } else if (char === '}') {
  72. depth--;
  73. if (depth === 0 && objectStart >= 0) {
  74. objectCount++;
  75. if (objectCount > alreadyParsed) {
  76. try {
  77. const obj = JSON.parse(stripped.substring(objectStart, i + 1));
  78. results.push(obj);
  79. } catch {
  80. // Incomplete or invalid JSON — skip
  81. }
  82. }
  83. objectStart = -1;
  84. }
  85. }
  86. }
  87. return results;
  88. }
  89. export async function POST(req: NextRequest) {
  90. let requirementSnippet: string | undefined;
  91. let resolvedModelString: string | undefined;
  92. try {
  93. const body = await req.json();
  94. // Get API configuration from request headers
  95. const { model: languageModel, modelInfo, modelString } = await resolveModelFromHeaders(req);
  96. resolvedModelString = modelString;
  97. if (!body.requirements) {
  98. return apiError('MISSING_REQUIRED_FIELD', 400, 'Requirements are required');
  99. }
  100. const { requirements, pdfText, pdfImages, imageMapping, researchContext, agents } = body as {
  101. requirements: UserRequirements;
  102. pdfText?: string;
  103. pdfImages?: PdfImage[];
  104. imageMapping?: ImageMapping;
  105. researchContext?: string;
  106. agents?: AgentInfo[];
  107. };
  108. requirementSnippet = requirements?.requirement?.substring(0, 60);
  109. // Detect vision capability
  110. const hasVision = !!modelInfo?.capabilities?.vision;
  111. // Build prompt (same logic as generateSceneOutlinesFromRequirements)
  112. let availableImagesText =
  113. requirements.language === 'zh-CN' ? '无可用图片' : 'No images available';
  114. let visionImages: Array<{ id: string; src: string }> | undefined;
  115. if (pdfImages && pdfImages.length > 0) {
  116. if (hasVision && imageMapping) {
  117. // Vision mode: split into vision images (first N) and text-only (rest)
  118. const allWithSrc = pdfImages.filter((img) => imageMapping[img.id]);
  119. const visionSlice = allWithSrc.slice(0, MAX_VISION_IMAGES);
  120. const textOnlySlice = allWithSrc.slice(MAX_VISION_IMAGES);
  121. const noSrcImages = pdfImages.filter((img) => !imageMapping[img.id]);
  122. const visionDescriptions = visionSlice.map((img) =>
  123. formatImagePlaceholder(img, requirements.language),
  124. );
  125. const textDescriptions = [...textOnlySlice, ...noSrcImages].map((img) =>
  126. formatImageDescription(img, requirements.language),
  127. );
  128. availableImagesText = [...visionDescriptions, ...textDescriptions].join('\n');
  129. visionImages = visionSlice.map((img) => ({
  130. id: img.id,
  131. src: imageMapping[img.id],
  132. width: img.width,
  133. height: img.height,
  134. }));
  135. } else {
  136. // Text-only mode: full descriptions
  137. availableImagesText = pdfImages
  138. .map((img) => formatImageDescription(img, requirements.language))
  139. .join('\n');
  140. }
  141. }
  142. // Build media generation policy based on enabled flags
  143. const imageGenerationEnabled = req.headers.get('x-image-generation-enabled') === 'true';
  144. const videoGenerationEnabled = req.headers.get('x-video-generation-enabled') === 'true';
  145. let mediaGenerationPolicy = '';
  146. if (!imageGenerationEnabled && !videoGenerationEnabled) {
  147. mediaGenerationPolicy =
  148. '**IMPORTANT: Do NOT include any mediaGenerations in the outlines. Both image and video generation are disabled.**';
  149. } else if (!imageGenerationEnabled) {
  150. mediaGenerationPolicy =
  151. '**IMPORTANT: Do NOT include any image mediaGenerations (type: "image") in the outlines. Image generation is disabled. Video generation is allowed.**';
  152. } else if (!videoGenerationEnabled) {
  153. mediaGenerationPolicy =
  154. '**IMPORTANT: Do NOT include any video mediaGenerations (type: "video") in the outlines. Video generation is disabled. Image generation is allowed.**';
  155. }
  156. // Build teacher context from agents (if available)
  157. const teacherContext = formatTeacherPersonaForPrompt(agents);
  158. const prompts = buildPrompt(PROMPT_IDS.REQUIREMENTS_TO_OUTLINES, {
  159. requirement: requirements.requirement,
  160. language: requirements.language,
  161. pdfContent: pdfText
  162. ? pdfText.substring(0, MAX_PDF_CONTENT_CHARS)
  163. : requirements.language === 'zh-CN'
  164. ? '无'
  165. : 'None',
  166. availableImages: availableImagesText,
  167. researchContext: researchContext || (requirements.language === 'zh-CN' ? '无' : 'None'),
  168. mediaGenerationPolicy,
  169. teacherContext,
  170. });
  171. if (!prompts) {
  172. return apiError('INTERNAL_ERROR', 500, 'Prompt template not found');
  173. }
  174. log.info(
  175. `Generating outlines: "${requirements.requirement.substring(0, 50)}" [model=${modelString}]`,
  176. );
  177. // Create SSE stream with heartbeat to prevent connection timeout
  178. const encoder = new TextEncoder();
  179. const HEARTBEAT_INTERVAL_MS = 15_000;
  180. const stream = new ReadableStream({
  181. async start(controller) {
  182. // Heartbeat: periodically send SSE comments to keep the connection alive.
  183. let heartbeatTimer: ReturnType<typeof setInterval> | null = null;
  184. const startHeartbeat = () => {
  185. stopHeartbeat();
  186. heartbeatTimer = setInterval(() => {
  187. try {
  188. controller.enqueue(encoder.encode(`:heartbeat\n\n`));
  189. } catch {
  190. stopHeartbeat();
  191. }
  192. }, HEARTBEAT_INTERVAL_MS);
  193. };
  194. const stopHeartbeat = () => {
  195. if (heartbeatTimer) {
  196. clearInterval(heartbeatTimer);
  197. heartbeatTimer = null;
  198. }
  199. };
  200. const MAX_STREAM_RETRIES = 2;
  201. try {
  202. startHeartbeat();
  203. const streamParams = visionImages?.length
  204. ? {
  205. model: languageModel,
  206. system: prompts.system,
  207. messages: [
  208. {
  209. role: 'user' as const,
  210. content: buildVisionUserContent(prompts.user, visionImages),
  211. },
  212. ],
  213. maxOutputTokens: modelInfo?.outputWindow,
  214. }
  215. : {
  216. model: languageModel,
  217. system: prompts.system,
  218. prompt: prompts.user,
  219. maxOutputTokens: modelInfo?.outputWindow,
  220. };
  221. let parsedOutlines: SceneOutline[] = [];
  222. let lastError: string | undefined;
  223. for (let attempt = 1; attempt <= MAX_STREAM_RETRIES + 1; attempt++) {
  224. try {
  225. const result = streamLLM(streamParams, 'scene-outlines-stream');
  226. let fullText = '';
  227. parsedOutlines = [];
  228. for await (const chunk of result.textStream) {
  229. fullText += chunk;
  230. // Try to extract new outlines from the accumulated text
  231. const newOutlines = extractNewOutlines(fullText, parsedOutlines.length);
  232. for (const outline of newOutlines) {
  233. // Ensure ID and order
  234. const enriched = {
  235. ...outline,
  236. id: outline.id || nanoid(),
  237. order: parsedOutlines.length + 1,
  238. };
  239. parsedOutlines.push(enriched);
  240. const event = JSON.stringify({
  241. type: 'outline',
  242. data: enriched,
  243. index: parsedOutlines.length - 1,
  244. });
  245. controller.enqueue(encoder.encode(`data: ${event}\n\n`));
  246. }
  247. }
  248. // Validate: got outlines?
  249. if (parsedOutlines.length > 0) break;
  250. // Empty result — retry if we have attempts left
  251. lastError = fullText.trim()
  252. ? 'LLM response could not be parsed into outlines'
  253. : 'LLM returned empty response';
  254. if (attempt <= MAX_STREAM_RETRIES) {
  255. log.warn(
  256. `Empty outlines (attempt ${attempt}/${MAX_STREAM_RETRIES + 1}), retrying...`,
  257. );
  258. // Notify client a retry is happening
  259. const retryEvent = JSON.stringify({
  260. type: 'retry',
  261. attempt,
  262. maxAttempts: MAX_STREAM_RETRIES + 1,
  263. });
  264. controller.enqueue(encoder.encode(`data: ${retryEvent}\n\n`));
  265. }
  266. } catch (error) {
  267. lastError = error instanceof Error ? error.message : String(error);
  268. if (attempt <= MAX_STREAM_RETRIES) {
  269. log.warn(
  270. `Stream error (attempt ${attempt}/${MAX_STREAM_RETRIES + 1}), retrying...`,
  271. error,
  272. );
  273. const retryEvent = JSON.stringify({
  274. type: 'retry',
  275. attempt,
  276. maxAttempts: MAX_STREAM_RETRIES + 1,
  277. });
  278. controller.enqueue(encoder.encode(`data: ${retryEvent}\n\n`));
  279. continue;
  280. }
  281. }
  282. }
  283. if (parsedOutlines.length > 0) {
  284. // Replace sequential gen_img_N/gen_vid_N with globally unique IDs
  285. const uniquifiedOutlines = uniquifyMediaElementIds(parsedOutlines);
  286. // Send done event with all outlines
  287. const doneEvent = JSON.stringify({
  288. type: 'done',
  289. outlines: uniquifiedOutlines,
  290. });
  291. controller.enqueue(encoder.encode(`data: ${doneEvent}\n\n`));
  292. } else {
  293. // All retries exhausted, no outlines produced
  294. log.error(
  295. `Outline generation failed after ${MAX_STREAM_RETRIES + 1} attempts: ${lastError}`,
  296. );
  297. const errorEvent = JSON.stringify({
  298. type: 'error',
  299. error: lastError || 'Failed to generate outlines',
  300. });
  301. controller.enqueue(encoder.encode(`data: ${errorEvent}\n\n`));
  302. }
  303. } catch (error) {
  304. const errorEvent = JSON.stringify({
  305. type: 'error',
  306. error: error instanceof Error ? error.message : String(error),
  307. });
  308. controller.enqueue(encoder.encode(`data: ${errorEvent}\n\n`));
  309. } finally {
  310. stopHeartbeat();
  311. controller.close();
  312. }
  313. },
  314. });
  315. return new Response(stream, {
  316. headers: {
  317. 'Content-Type': 'text/event-stream',
  318. 'Cache-Control': 'no-cache',
  319. Connection: 'keep-alive',
  320. },
  321. });
  322. } catch (error) {
  323. log.error(
  324. `Outline streaming failed [requirement="${requirementSnippet ?? 'unknown'}...", model=${resolvedModelString ?? 'unknown'}]:`,
  325. error,
  326. );
  327. return apiError('INTERNAL_ERROR', 500, error instanceof Error ? error.message : String(error));
  328. }
  329. }