Files
openmaic/OpenMAIC/app/api/generate/scene-outlines-stream/route.ts
2026-08-16 14:58:47 +08:00

674 lines
25 KiB
TypeScript

/**
* 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 { resolveVocationalActive } from '@/lib/config/feature-flags';
import { buildInteractiveClassroomOutlinePrompt } from '@/lib/server/classroom-outline-mode';
const log = createLogger('Outlines Stream');
export const maxDuration = 300;
/**
* Extract the languageDirective from the streamed wrapper JSON.
* Matches `"languageDirective":"<value>"` 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<string>): 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;
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]);
const visionDescriptions = visionSlice.map((img) => formatImagePlaceholder(img));
const textDescriptions = [...textOnlySlice, ...noSrcImages].map((img) =>
formatImageDescription(img),
);
availableImagesText = [...visionDescriptions, ...textDescriptions].join('\n');
visionImages = visionSlice.map((img) => ({
id: img.id,
src: imageMapping[img.id],
width: img.width,
height: img.height,
}));
} 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.
let prompts: { system: string; user: string } | null = buildOutlinePrompt(requirements, {
pdfText,
pdfImages,
visionEnabled: hasVision,
imageMapping,
imageGenerationEnabled,
videoGenerationEnabled,
researchContext,
teacherContext,
});
if (taskEngineMode) {
prompts = buildPrompt(PROMPT_IDS.TASK_ENGINE_OUTLINES, {
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,
});
} else if (interactiveMode) {
prompts = buildInteractiveClassroomOutlinePrompt(requirements, {
pdfText,
availableImages: availableImagesText,
researchContext,
hasSourceImages,
imageGenerationEnabled,
videoGenerationEnabled,
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<typeof setInterval> | 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<string>();
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));
}
}