项目文件夹

文件
wehub-resource-sync c48612c494
CI / E2E Tests (push) Has been cancelled
CI / Lint, Typecheck & Unit Tests (push) Has been cancelled
Docs Build / Build docs site (push) Has been cancelled
Publish @openmaic packages / Build, validate & publish (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:03:23 +08:00

203 行
6.2 KiB
TypeScript

/**
* Scene Content Generation API
*
* Generates scene content (slides/quiz/interactive/pbl) from an outline.
* This is the first half of the two-step scene generation pipeline.
* Does NOT generate actions — use /api/generate/scene-actions for that.
*/
import { NextRequest } from 'next/server';
import { callLLM } from '@/lib/ai/llm';
import {
applyOutlineFallbacks,
generateSceneContent,
buildVisionUserContent,
} from '@/lib/generation/generation-pipeline';
import type { AgentInfo } from '@/lib/generation/generation-pipeline';
import type {
SceneOutline,
PdfImage,
ImageMapping,
UserRequirements,
} from '@/lib/types/generation';
import { createLogger } from '@/lib/logger';
import { apiError, apiSuccess } from '@/lib/server/api-response';
import { llmApiError } from '@/lib/server/llm-error-response';
import { resolveModelFromRequest } from '@/lib/server/resolve-model';
import { resolveVocationalActive } from '@/lib/config/feature-flags';
const log = createLogger('Scene Content API');
export const maxDuration = 300;
export async function POST(req: NextRequest) {
let outlineTitle: string | undefined;
let resolvedModelString: string | undefined;
try {
const body = await req.json();
const {
outline: rawOutline,
allOutlines,
pdfImages,
imageMapping,
stageInfo: _stageInfo,
stageId,
agents,
languageDirective,
requirements,
} = body as {
outline: SceneOutline;
allOutlines: SceneOutline[];
pdfImages?: PdfImage[];
imageMapping?: ImageMapping;
stageInfo: {
name: string;
description?: string;
style?: string;
};
stageId: string;
agents?: AgentInfo[];
languageDirective?: string;
requirements?: UserRequirements;
};
// Validate required fields
if (!rawOutline) {
return apiError('MISSING_REQUIRED_FIELD', 400, 'outline is required');
}
if (!allOutlines || allOutlines.length === 0) {
return apiError(
'MISSING_REQUIRED_FIELD',
400,
'allOutlines is required and must not be empty',
);
}
if (!stageId) {
return apiError('MISSING_REQUIRED_FIELD', 400, 'stageId is required');
}
const outline: SceneOutline = { ...rawOutline };
// ── Model resolution from request headers/body ──
// Route per scene-content type (e.g. `scene-content:quiz`); getStageModel
// falls back to the base `scene-content` route when the type is unrouted.
const stage = outline.type ? (`scene-content:${outline.type}` as const) : 'scene-content';
const {
model: languageModel,
modelInfo,
modelString,
thinkingConfig,
} = await resolveModelFromRequest(req, body, stage);
outlineTitle = rawOutline?.title;
resolvedModelString = modelString;
// Detect vision capability
const hasVision = !!modelInfo?.capabilities?.vision;
// Vision-aware AI call function
const aiCall = async (
systemPrompt: string,
userPrompt: string,
images?: Array<{ id: string; src: string }>,
): Promise<string> => {
if (images?.length && hasVision) {
const result = await callLLM(
{
model: languageModel,
system: systemPrompt,
messages: [
{
role: 'user' as const,
content: buildVisionUserContent(userPrompt, images),
},
],
maxOutputTokens: modelInfo?.outputWindow,
maxRetries: 0,
},
'scene-content',
undefined,
thinkingConfig,
);
return result.text;
}
const result = await callLLM(
{
model: languageModel,
system: systemPrompt,
prompt: userPrompt,
maxOutputTokens: modelInfo?.outputWindow,
maxRetries: 0,
},
'scene-content',
undefined,
thinkingConfig,
);
return result.text;
};
// ── Apply fallbacks ──
const vocationalActive = resolveVocationalActive(requirements);
const effectiveOutline = applyOutlineFallbacks(outline, !!languageModel, {
allowProceduralSkill: vocationalActive,
});
// ── Filter images assigned to this outline ──
let assignedImages: PdfImage[] | undefined;
if (
pdfImages &&
pdfImages.length > 0 &&
effectiveOutline.suggestedImageIds &&
effectiveOutline.suggestedImageIds.length > 0
) {
const suggestedIds = new Set(effectiveOutline.suggestedImageIds);
assignedImages = pdfImages.filter((img) => suggestedIds.has(img.id));
}
// ── Media generation is handled client-side in parallel (media-orchestrator.ts) ──
// The content generator receives placeholder IDs (gen_img_1, gen_vid_1) as-is.
// resolveImageIds() in generation-pipeline.ts will keep these placeholders in elements.
const generatedMediaMapping: ImageMapping = {};
// ── Generate content ──
log.info(
`Generating content: "${effectiveOutline.title}" (${effectiveOutline.type}) [model=${modelString}]`,
);
const userLocale = req.headers?.get('x-user-locale') ?? '';
const content = await generateSceneContent(effectiveOutline, aiCall, {
assignedImages,
imageMapping,
languageModel: effectiveOutline.type === 'pbl' ? languageModel : undefined,
visionEnabled: hasVision,
generatedMediaMapping,
agents,
languageDirective,
thinkingConfig,
targetLanguage: userLocale || undefined,
userRequirements: requirements,
allowProceduralSkill: vocationalActive,
});
if (!content) {
log.error(`Failed to generate content for: "${effectiveOutline.title}"`);
return apiError(
'GENERATION_FAILED',
500,
`Failed to generate content: ${effectiveOutline.title}`,
);
}
log.info(`Content generated successfully: "${effectiveOutline.title}"`);
return apiSuccess({ content, effectiveOutline });
} catch (error) {
log.error(
`Scene content generation failed [scene="${outlineTitle ?? 'unknown'}", model=${resolvedModelString ?? 'unknown'}]:`,
error,
);
return llmApiError(error);
}
}