/** * Scene Outlines Streaming API (SSE) * * Streams outline generation via Server-Sent Events. * Emits individual outline objects as they're parsed from the LLM response, * so the frontend can display them incrementally. * * SSE events: * { type: 'languageDirective', data: string } * { type: 'courseTitle', data: string } * { type: 'outline', data: SceneOutline, index: number } * { type: 'done', outlines: SceneOutline[], languageDirective: string, courseTitle?: string } * { type: 'error', error: string } */ import { NextRequest } from 'next/server'; import { streamLLM } from '@/lib/ai/llm'; import { buildPrompt, PROMPT_IDS } from '@/lib/prompts'; import { formatImageDescription, formatImagePlaceholder, buildVisionUserContent, buildOutlinePrompt, uniquifyMediaElementIds, formatTeacherPersonaForPrompt, } from '@openmaic/generation'; import type { AgentInfo } from '@openmaic/generation'; import { DEFAULT_LANGUAGE_DIRECTIVE } from '@openmaic/generation'; import { MAX_PDF_CONTENT_CHARS, MAX_VISION_IMAGES } from '@/lib/constants/generation'; import { nanoid } from 'nanoid'; import type { UserRequirements, PdfImage, SceneOutline, ImageMapping, } from '@/lib/types/generation'; import { apiError } from '@/lib/server/api-response'; import { createLogger } from '@/lib/logger'; import { resolveModelFromRequest } from '@/lib/server/resolve-model'; import { sortDocumentImagesForVision } from '@/lib/document/bundle'; import { resolveVisionImagesForPrompt } from '@/lib/persistence/resolve-vision-images'; import { resolveVocationalActive } from '@/lib/config/feature-flags'; const log = createLogger('Outlines Stream'); export const maxDuration = 300; /** * Extract the languageDirective from the streamed wrapper JSON. * Matches `"languageDirective":""` in partial JSON like: * {"languageDirective":"用中文授课...","outlines":[... */ function extractLanguageDirective(buffer: string): string | null { // The directive is the first key of the wrapper object, so it can only ever // appear in the head of the buffer. Bound the scan to keep this O(1) per // streamed chunk — it is called on the full, growing buffer on every chunk, // which is otherwise O(n²) over the stream. const head = buffer.length > 8192 ? buffer.slice(0, 8192) : buffer; const match = head.match(/"languageDirective"\s*:\s*"((?:[^"\\]|\\.)*)"/); if (!match) return null; try { return JSON.parse(`"${match[1]}"`); } catch { return match[1]; } } /** * Extract the courseTitle from the streamed wrapper JSON. * Same head-bound scan as `extractLanguageDirective` — the title is a * top-level key near the start of the wrapper object, so it only appears in * the buffer head. Returns the decoded title, or null if not yet streamed. */ const COURSE_TITLE_RE = /"courseTitle"\s*:\s*"((?:[^"\\]|\\.)*)"/; // Normalize a captured title identically to the non-streaming parser // (@openmaic/generation outline parser): ignore whitespace-only titles and cap // length defensively so a hallucinating model cannot push a blank or unbounded // value into the stage name. Returning null lets callers fall back / keep scanning. function normalizeStreamedTitle(raw: string): string | null { let title: string; try { title = JSON.parse(`"${raw}"`); } catch { title = raw; } const normalized = title.trim(); return normalized ? normalized.slice(0, 120) : null; } function extractCourseTitle(buffer: string): string | null { const head = buffer.length > 8192 ? buffer.slice(0, 8192) : buffer; const match = head.match(COURSE_TITLE_RE); return match ? normalizeStreamedTitle(match[1]) : null; } /** * Full-buffer fallback, run once after the stream completes: recovers a title * the model emitted after the `outlines` array or beyond the 8KB head window — * cases the head-bound `extractCourseTitle` scan would miss. Only invoked when * the streaming scan produced nothing, so the extra full-buffer regex is paid once. */ function extractCourseTitleFromComplete(buffer: string): string | null { const match = buffer.match(COURSE_TITLE_RE); return match ? normalizeStreamedTitle(match[1]) : null; } /** * Incremental JSON array parser. * Extracts complete top-level objects from a partially-streamed JSON array, * resuming from `scanFrom` (an index into `buffer`) so the growing buffer is * scanned only ONCE across the whole stream — O(n) total instead of O(n²). * Supports both a flat array `[{...},{...}]` and a wrapper object * `{"languageDirective":"...","outlines":[{...},{...}]}`, with or without a * markdown ```json fence (the array is located by content, not by stripping). * Returns newly found objects plus the index to resume scanning from next time. */ function extractNewOutlines( buffer: string, scanFrom: number, ): { outlines: SceneOutline[]; scanFrom: number } { const results: SceneOutline[] = []; let i: number; if (scanFrom > 0) { // Resume just past the last fully-parsed object (between array elements, // so not inside a string and at brace depth 0). i = scanFrom; } else { // Locate the outlines array opening once. const outlinesKeyIdx = buffer.indexOf('"outlines"'); const arrayStart = outlinesKeyIdx >= 0 ? buffer.indexOf('[', outlinesKeyIdx) : buffer.indexOf('['); if (arrayStart === -1) return { outlines: results, scanFrom: 0 }; i = arrayStart + 1; } let depth = 0; let objectStart = -1; let inString = false; let escaped = false; let consumed = i; // index just past the last fully-parsed object for (; i < buffer.length; i++) { const char = buffer[i]; if (escaped) { escaped = false; continue; } if (char === '\\' && inString) { escaped = true; continue; } if (char === '"') { inString = !inString; continue; } if (inString) continue; if (char === '{') { if (depth === 0) objectStart = i; depth++; } else if (char === '}') { depth--; if (depth === 0 && objectStart >= 0) { try { results.push(JSON.parse(buffer.substring(objectStart, i + 1))); } catch { // Incomplete or invalid JSON — skip } objectStart = -1; consumed = i + 1; } } } return { outlines: results, scanFrom: consumed }; } function normalizeTaskEngineProceduralOutline( outline: SceneOutline, requirement: string, ): SceneOutline { const widgetOutline = outline.widgetOutline ?? {}; return { ...outline, type: 'interactive', widgetType: 'procedural-skill', widgetOutline: { ...widgetOutline, procedureType: widgetOutline.procedureType ?? 'inspection', task: widgetOutline.task || requirement, tools: widgetOutline.tools && widgetOutline.tools.length > 0 ? widgetOutline.tools : ['required PPE', 'task checklist'], steps: widgetOutline.steps && widgetOutline.steps.length > 0 ? widgetOutline.steps : ['Confirm task conditions', 'Select required tools', 'Complete safety check'], successCriteria: widgetOutline.successCriteria && widgetOutline.successCriteria.length > 0 ? widgetOutline.successCriteria : ['Required checks completed', 'Unsafe conditions are not ignored'], errorConsequences: widgetOutline.errorConsequences && widgetOutline.errorConsequences.length > 0 ? widgetOutline.errorConsequences : ['Unsafe or incorrect actions require stopping and rechecking'], }, }; } function normalizeTaskEngineSlideOutline(outline: SceneOutline): SceneOutline { const normalized: SceneOutline = { ...outline, type: 'slide', }; delete normalized.widgetType; delete normalized.widgetOutline; delete normalized.interactiveConfig; return normalized; } const ORDINARY_WIDGET_TYPES = new Set(['simulation', 'diagram', 'code', 'game', 'visualization3d']); function normalizeTaskEngineOutline(outline: SceneOutline, requirement: string): SceneOutline { if (outline.type === 'slide') { return normalizeTaskEngineSlideOutline(outline); } if (outline.type === 'interactive' && outline.widgetType === 'procedural-skill') { return normalizeTaskEngineProceduralOutline(outline, requirement); } if ( outline.type === 'interactive' && outline.widgetType && ORDINARY_WIDGET_TYPES.has(outline.widgetType) ) { return outline; } return normalizeTaskEngineSlideOutline(outline); } function sanitizeNonTaskEngineOutline(outline: SceneOutline): SceneOutline { if (outline.widgetType !== 'procedural-skill') { return outline; } const widgetOutline = { ...(outline.widgetOutline ?? {}) }; delete widgetOutline.procedureType; delete widgetOutline.task; delete widgetOutline.tools; delete widgetOutline.steps; delete widgetOutline.successCriteria; delete widgetOutline.errorConsequences; // procedural-skill is gated behind taskEngineMode to protect ordinary MAIC generation. return { ...outline, type: 'interactive', widgetType: 'diagram', description: outline.description ? `${outline.description} Present this as a process or structure diagram.` : 'Present this topic as a process or structure diagram.', widgetOutline, }; } function ensureUniqueOutlineId(outline: SceneOutline, usedIds: Set): SceneOutline { const candidate = typeof outline.id === 'string' && outline.id.trim() ? outline.id : undefined; if (candidate && !usedIds.has(candidate)) { usedIds.add(candidate); return outline; } let id = nanoid(); while (usedIds.has(id)) { id = nanoid(); } usedIds.add(id); return { ...outline, id }; } export async function POST(req: NextRequest) { let requirementSnippet: string | undefined; let resolvedModelString: string | undefined; try { const body = await req.json(); // Get API configuration from request headers/body const { model: languageModel, modelInfo, modelString, thinkingConfig, } = await resolveModelFromRequest(req, body, 'scene-outlines-stream'); resolvedModelString = modelString; if (!body.requirements) { return apiError('MISSING_REQUIRED_FIELD', 400, 'Requirements are required'); } const { requirements, pdfText, pdfImages, imageMapping, researchContext, agents } = body as { requirements: UserRequirements; pdfText?: string; pdfImages?: PdfImage[]; imageMapping?: ImageMapping; researchContext?: string; agents?: AgentInfo[]; }; requirementSnippet = requirements?.requirement?.substring(0, 60); // Build user profile string for language inference context const userProfileText = requirements.userNickname || requirements.userBio ? `## Student Profile\n\nStudent: ${requirements.userNickname || 'Unknown'}${requirements.userBio ? ` — ${requirements.userBio}` : ''}\n\nConsider this student's background when designing the course. Adapt difficulty, examples, and teaching approach accordingly.\n\n---` : ''; // Detect vision capability const hasVision = !!modelInfo?.capabilities?.vision; // Build prompt (same logic as generateSceneOutlinesFromRequirements) let availableImagesText = 'No images available'; let visionImages: Array<{ id: string; src: string }> | undefined; // N3: the RESOLVED slice, threaded into the standard buildOutlinePrompt // branch below. The vision resolution drops images the server cannot // resolve; the standard branch must rebuild its placeholder text from the // same resolved set so a dropped image drops its text mention AND its // attachment there too. let resolvedPdfImages: PdfImage[] | undefined; let resolvedImageMapping: ImageMapping | undefined; if (pdfImages && pdfImages.length > 0) { if (hasVision && imageMapping) { // Vision mode: split into vision images (first N) and text-only (rest) const sortedImages = sortDocumentImagesForVision(pdfImages); const allWithSrc = sortedImages.filter((img) => imageMapping[img.id]); const visionSlice = allWithSrc.slice(0, MAX_VISION_IMAGES); const textOnlySlice = allWithSrc.slice(MAX_VISION_IMAGES); const noSrcImages = sortedImages.filter((img) => !imageMapping[img.id]); // Server-backed transport: `imageMapping` values are allocated asset // ids, so the vision srcs reach here as ids. Resolve them to the same // bytes the base64 path would send BEFORE prompt assembly, keeping the // vision prompt byte-identical in both modes (RFC #1153 part 2 B). An // id the server cannot resolve is dropped here, so the placeholder // text below never promises an image that will not be attached. const resolvedVisionImages = await resolveVisionImagesForPrompt( visionSlice.map((img) => ({ id: img.id, src: imageMapping[img.id], ...(img.width !== undefined ? { width: img.width } : {}), ...(img.height !== undefined ? { height: img.height } : {}), })), req.headers, ); const resolvedIds = new Set(resolvedVisionImages.map((img) => img.id)); const visionImageById = new Map(sortedImages.map((img) => [img.id, img] as const)); const visionDescriptions = resolvedVisionImages.map((img) => formatImagePlaceholder(visionImageById.get(img.id) ?? { ...img, pageNumber: 1, src: '' }), ); const textDescriptions = [...textOnlySlice, ...noSrcImages].map((img) => formatImageDescription(img), ); availableImagesText = [...visionDescriptions, ...textDescriptions].join('\n'); visionImages = resolvedVisionImages.map((img) => ({ id: img.id, src: img.src, ...(img.width !== undefined ? { width: img.width } : {}), ...(img.height !== undefined ? { height: img.height } : {}), })); // N3: the standard branch (buildOutlinePrompt) rebuilds its own // placeholder text from pdfImages × imageMapping — feed it the RESOLVED // set: unresolvable vision images removed from the slice and a mapping // naming only the resolved ids, so its `[see attached]` promises match // the attachments this route attaches exactly. const visionSliceIds = new Set(visionSlice.map((img) => img.id)); resolvedPdfImages = pdfImages.filter( (img) => !visionSliceIds.has(img.id) || resolvedIds.has(img.id), ); resolvedImageMapping = Object.fromEntries( Object.entries(imageMapping).filter(([id]) => resolvedIds.has(id)), ); // Shift-in is IMPOSSIBLE here by construction (unlike the scene-content // route's re-slice): `visionImages` — the ONLY attachments this route // sends — IS the resolved slice, resolved once from the original // `visionSlice` and never re-sliced, so a dropped image admits NO new // image into the attachments; and `resolvedImageMapping` names only // the resolved slice's ids, so the standard branch's `[see attached]` // text matches the attachments exactly (images beyond the slice and // no-src images keep plain descriptions because their mapping entries // are stripped). } else { // Text-only mode: full descriptions availableImagesText = pdfImages.map((img) => formatImageDescription(img)).join('\n'); } } // Build media snippet conditions based on enabled flags. const imageGenerationEnabled = req.headers.get('x-image-generation-enabled') === 'true'; const videoGenerationEnabled = req.headers.get('x-video-generation-enabled') === 'true'; const mediaGenerationEnabled = imageGenerationEnabled || videoGenerationEnabled; const hasSourceImages = (pdfImages?.length ?? 0) > 0; // Build teacher context from agents (if available) const teacherContext = formatTeacherPersonaForPrompt(agents); // Check if Interactive Mode or server-enabled Task Engine mode is enabled. const interactiveMode = requirements.interactiveMode ?? false; const taskEngineMode = resolveVocationalActive(requirements); // Standard outline generation is byte-identical to the package path. The // two app-only modes retain their own templates but share the same inputs. // The standard branch receives the N3 RESOLVED slice (unresolvable vision // images removed, mapping naming only resolved ids) so its placeholder // text never promises an image this route will not attach. let prompts: { system: string; user: string } | null = buildOutlinePrompt(requirements, { pdfText, pdfImages: resolvedPdfImages ?? pdfImages, visionEnabled: hasVision, imageMapping: resolvedImageMapping ?? imageMapping, imageGenerationEnabled, videoGenerationEnabled, researchContext, teacherContext, }); if (taskEngineMode || interactiveMode) { const promptId = taskEngineMode ? PROMPT_IDS.TASK_ENGINE_OUTLINES : PROMPT_IDS.INTERACTIVE_OUTLINES; prompts = buildPrompt(promptId, { requirement: requirements.requirement, pdfContent: pdfText ? pdfText.substring(0, MAX_PDF_CONTENT_CHARS) : 'None', availableImages: availableImagesText, researchContext: researchContext || 'None', hasSourceImages, imageEnabled: imageGenerationEnabled, videoEnabled: videoGenerationEnabled, mediaEnabled: mediaGenerationEnabled, teacherContext, userProfile: userProfileText, }); } if (!prompts) { return apiError('INTERNAL_ERROR', 500, 'Prompt template not found'); } log.info( `Generating outlines: "${requirements.requirement.substring(0, 50)}" [model=${modelString}]`, ); // Create SSE stream with heartbeat to prevent connection timeout const encoder = new TextEncoder(); const HEARTBEAT_INTERVAL_MS = 15_000; const stream = new ReadableStream({ async start(controller) { // Heartbeat: periodically send SSE comments to keep the connection alive. let heartbeatTimer: ReturnType | null = null; const startHeartbeat = () => { stopHeartbeat(); heartbeatTimer = setInterval(() => { try { controller.enqueue(encoder.encode(`:heartbeat\n\n`)); } catch { stopHeartbeat(); } }, HEARTBEAT_INTERVAL_MS); }; const stopHeartbeat = () => { if (heartbeatTimer) { clearInterval(heartbeatTimer); heartbeatTimer = null; } }; const MAX_STREAM_RETRIES = 2; // Hard ceiling on the accumulated stream buffer. Legitimate outline // JSON is small (tens of KB); anything past this is a runaway/degenerate // generation and must not be allowed to grow the heap unbounded. const MAX_OUTLINE_STREAM_BYTES = 512 * 1024; try { startHeartbeat(); const streamParams = visionImages?.length ? { model: languageModel, system: prompts.system, messages: [ { role: 'user' as const, content: buildVisionUserContent(prompts.user, visionImages), }, ], maxOutputTokens: modelInfo?.outputWindow, // Tear down the upstream LLM request when the client disconnects, // instead of letting it run to completion for a dead connection. abortSignal: req.signal, } : { model: languageModel, system: prompts.system, prompt: prompts.user, maxOutputTokens: modelInfo?.outputWindow, abortSignal: req.signal, }; let parsedOutlines: SceneOutline[] = []; let languageDirective: string | null = null; let courseTitle: string | null = null; let lastError: string | undefined; for (let attempt = 1; attempt <= MAX_STREAM_RETRIES + 1; attempt++) { try { let fullText = ''; let scanFrom = 0; parsedOutlines = []; languageDirective = null; courseTitle = null; const usedOutlineIds = new Set(); const textStream = streamLLM( streamParams, 'scene-outlines-stream', thinkingConfig, ).textStream; for await (const chunk of textStream) { // Stop doing work the moment the client goes away — otherwise // generation keeps running and buffering for a dead connection. if (req.signal?.aborted) { stopHeartbeat(); return; } fullText += chunk; if (fullText.length > MAX_OUTLINE_STREAM_BYTES) { log.warn( `Outline stream exceeded ${MAX_OUTLINE_STREAM_BYTES} bytes (len=${fullText.length}); stopping read and finalizing with ${parsedOutlines.length} outline(s)`, ); break; } // Try to extract language directive early if (!languageDirective) { languageDirective = extractLanguageDirective(fullText); if (languageDirective) { const ldEvent = JSON.stringify({ type: 'languageDirective', data: languageDirective, }); controller.enqueue(encoder.encode(`data: ${ldEvent}\n\n`)); } } // Try to extract course title early (same pattern as languageDirective) if (!courseTitle) { courseTitle = extractCourseTitle(fullText); if (courseTitle) { const ctEvent = JSON.stringify({ type: 'courseTitle', data: courseTitle, }); controller.enqueue(encoder.encode(`data: ${ctEvent}\n\n`)); } } // Try to extract new outlines from the accumulated text, // resuming the scan from where the previous chunk left off. const { outlines: newOutlines, scanFrom: nextScanFrom } = extractNewOutlines( fullText, scanFrom, ); scanFrom = nextScanFrom; for (const outline of newOutlines) { // Ensure ID and order const enrichedBase = { ...outline, order: parsedOutlines.length + 1, }; const normalized = taskEngineMode ? normalizeTaskEngineOutline(enrichedBase, requirements.requirement) : sanitizeNonTaskEngineOutline(enrichedBase); const enriched = ensureUniqueOutlineId(normalized, usedOutlineIds); parsedOutlines.push(enriched); const event = JSON.stringify({ type: 'outline', data: enriched, index: parsedOutlines.length - 1, }); controller.enqueue(encoder.encode(`data: ${event}\n\n`)); } } // Validate: got outlines? if (parsedOutlines.length > 0) { if (!courseTitle) { // The head-bound streaming scan can miss a title the model // placed after the outlines array or past the 8KB head window; // recover it from the now-complete response before finalizing. courseTitle = extractCourseTitleFromComplete(fullText); } break; } // Empty result — retry if we have attempts left lastError = fullText.trim() ? 'LLM response could not be parsed into outlines' : 'LLM returned empty response'; log.warn( `Outlines attempt ${attempt} diagnostics: textLen=${fullText.length}, outlines=${parsedOutlines.length}, languageDirective=${languageDirective ? 'yes' : 'no'}, preview=${JSON.stringify(fullText.slice(0, 240))}`, ); if (attempt <= MAX_STREAM_RETRIES) { log.warn( `Empty outlines (attempt ${attempt}/${MAX_STREAM_RETRIES + 1}), retrying...`, ); // Notify client a retry is happening const retryEvent = JSON.stringify({ type: 'retry', attempt, maxAttempts: MAX_STREAM_RETRIES + 1, }); controller.enqueue(encoder.encode(`data: ${retryEvent}\n\n`)); } } catch (error) { // Client disconnected (AbortError from the now-propagated signal): // stop immediately, don't burn retries re-running generation. if (req.signal?.aborted) { stopHeartbeat(); return; } lastError = error instanceof Error ? error.message : String(error); log.warn( `Outlines stream error detail (attempt ${attempt}/${MAX_STREAM_RETRIES + 1}): ${lastError}`, ); if (attempt <= MAX_STREAM_RETRIES) { log.warn( `Stream error (attempt ${attempt}/${MAX_STREAM_RETRIES + 1}), retrying...`, error, ); const retryEvent = JSON.stringify({ type: 'retry', attempt, maxAttempts: MAX_STREAM_RETRIES + 1, }); controller.enqueue(encoder.encode(`data: ${retryEvent}\n\n`)); continue; } } } if (parsedOutlines.length > 0) { // Replace sequential gen_img_N/gen_vid_N with globally unique IDs const uniquifiedOutlines = uniquifyMediaElementIds(parsedOutlines); // Send done event with all outlines const doneEvent = JSON.stringify({ type: 'done', outlines: uniquifiedOutlines, languageDirective: languageDirective || DEFAULT_LANGUAGE_DIRECTIVE, courseTitle: courseTitle || undefined, taskEngineMode, }); controller.enqueue(encoder.encode(`data: ${doneEvent}\n\n`)); } else { // All retries exhausted, no outlines produced log.error( `Outline generation failed after ${MAX_STREAM_RETRIES + 1} attempts: ${lastError}`, ); const errorEvent = JSON.stringify({ type: 'error', error: lastError || 'Failed to generate outlines', }); controller.enqueue(encoder.encode(`data: ${errorEvent}\n\n`)); } } catch (error) { const errorEvent = JSON.stringify({ type: 'error', error: error instanceof Error ? error.message : String(error), }); controller.enqueue(encoder.encode(`data: ${errorEvent}\n\n`)); } finally { stopHeartbeat(); // The controller may already be closed if the client disconnected. try { controller.close(); } catch { // already closed — ignore } } }, }); return new Response(stream, { headers: { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', Connection: 'keep-alive', }, }); } catch (error) { log.error( `Outline streaming failed [requirement="${requirementSnippet ?? 'unknown'}...", model=${resolvedModelString ?? 'unknown'}]:`, error, ); return apiError('INTERNAL_ERROR', 500, error instanceof Error ? error.message : String(error)); } }