| 1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294 |
- /**
- * Stage 2: Scene content and action generation.
- *
- * Generates full scenes (slide/quiz/interactive/pbl with actions)
- * from scene outlines.
- */
- import { nanoid } from 'nanoid';
- import katex from 'katex';
- import { MAX_VISION_IMAGES } from '@/lib/constants/generation';
- import type {
- SceneOutline,
- GeneratedSlideContent,
- GeneratedQuizContent,
- GeneratedInteractiveContent,
- GeneratedPBLContent,
- ScientificModel,
- PdfImage,
- ImageMapping,
- } from '@/lib/types/generation';
- import type { LanguageModel } from 'ai';
- import type { StageStore } from '@/lib/api/stage-api';
- import { createStageAPI } from '@/lib/api/stage-api';
- import { generatePBLContent } from '@/lib/pbl/generate-pbl';
- import { buildPrompt, PROMPT_IDS } from './prompts';
- import { postProcessInteractiveHtml } from './interactive-post-processor';
- import { parseActionsFromStructuredOutput } from './action-parser';
- import { parseJsonResponse } from './json-repair';
- import {
- buildCourseContext,
- formatAgentsForPrompt,
- formatTeacherPersonaForPrompt,
- formatImageDescription,
- formatImagePlaceholder,
- } from './prompt-formatters';
- import type { PPTElement, Slide, SlideBackground, SlideTheme } from '@/lib/types/slides';
- import type { QuizQuestion } from '@/lib/types/stage';
- import type { Action } from '@/lib/types/action';
- import type {
- AgentInfo,
- SceneGenerationContext,
- GeneratedSlideData,
- AICallFn,
- GenerationResult,
- GenerationCallbacks,
- } from './pipeline-types';
- import { createLogger } from '@/lib/logger';
- const log = createLogger('Generation');
- // ==================== Stage 2: Full Scenes (Two-Step) ====================
- /**
- * Stage 3: Generate full scenes (parallel version)
- *
- * Two steps:
- * - Step 3.1: Outline -> Page content (slide/quiz)
- * - Step 3.2: Content + script -> Action list
- *
- * All scenes generated in parallel using Promise.all
- */
- export async function generateFullScenes(
- sceneOutlines: SceneOutline[],
- store: StageStore,
- aiCall: AICallFn,
- callbacks?: GenerationCallbacks,
- ): Promise<GenerationResult<string[]>> {
- const api = createStageAPI(store);
- const totalScenes = sceneOutlines.length;
- let completedCount = 0;
- callbacks?.onProgress?.({
- currentStage: 3,
- overallProgress: 66,
- stageProgress: 0,
- statusMessage: `正在并行生成 ${totalScenes} 个场景...`,
- scenesGenerated: 0,
- totalScenes,
- });
- // Generate all scenes in parallel
- const results = await Promise.all(
- sceneOutlines.map(async (outline, index) => {
- try {
- const sceneId = await generateSingleScene(outline, api, aiCall);
- // Update progress (not atomic, but sufficient for UI display)
- completedCount++;
- callbacks?.onProgress?.({
- currentStage: 3,
- overallProgress: 66 + Math.floor((completedCount / totalScenes) * 34),
- stageProgress: Math.floor((completedCount / totalScenes) * 100),
- statusMessage: `已完成 ${completedCount}/${totalScenes} 个场景`,
- scenesGenerated: completedCount,
- totalScenes,
- });
- return { success: true, sceneId, index };
- } catch (error) {
- completedCount++;
- callbacks?.onError?.(`Failed to generate scene ${outline.title}: ${error}`);
- return { success: false, sceneId: null, index };
- }
- }),
- );
- // Collect successful sceneIds in original order
- const sceneIds = results
- .filter(
- (r): r is { success: true; sceneId: string; index: number } =>
- r.success && r.sceneId !== null,
- )
- .sort((a, b) => a.index - b.index)
- .map((r) => r.sceneId);
- return { success: true, data: sceneIds };
- }
- /**
- * Generate a single scene (two-step process)
- *
- * Step 3.1: Generate content
- * Step 3.2: Generate Actions
- */
- async function generateSingleScene(
- outline: SceneOutline,
- api: ReturnType<typeof createStageAPI>,
- aiCall: AICallFn,
- ): Promise<string | null> {
- // Step 3.1: Generate content
- log.info(`Step 3.1: Generating content for: ${outline.title}`);
- const content = await generateSceneContent(outline, aiCall);
- if (!content) {
- log.error(`Failed to generate content for: ${outline.title}`);
- return null;
- }
- // Step 3.2: Generate Actions
- log.info(`Step 3.2: Generating actions for: ${outline.title}`);
- const actions = await generateSceneActions(outline, content, aiCall);
- log.info(`Generated ${actions.length} actions for: ${outline.title}`);
- // Create complete Scene
- return createSceneWithActions(outline, content, actions, api);
- }
- /**
- * Step 3.1: Generate content based on outline
- */
- export async function generateSceneContent(
- outline: SceneOutline,
- aiCall: AICallFn,
- assignedImages?: PdfImage[],
- imageMapping?: ImageMapping,
- languageModel?: LanguageModel,
- visionEnabled?: boolean,
- generatedMediaMapping?: ImageMapping,
- agents?: AgentInfo[],
- ): Promise<
- | GeneratedSlideContent
- | GeneratedQuizContent
- | GeneratedInteractiveContent
- | GeneratedPBLContent
- | null
- > {
- // If outline is interactive but missing interactiveConfig, fall back to slide
- if (outline.type === 'interactive' && !outline.interactiveConfig) {
- log.warn(
- `Interactive outline "${outline.title}" missing interactiveConfig, falling back to slide`,
- );
- const fallbackOutline = { ...outline, type: 'slide' as const };
- return generateSlideContent(
- fallbackOutline,
- aiCall,
- assignedImages,
- imageMapping,
- visionEnabled,
- generatedMediaMapping,
- agents,
- );
- }
- switch (outline.type) {
- case 'slide':
- return generateSlideContent(
- outline,
- aiCall,
- assignedImages,
- imageMapping,
- visionEnabled,
- generatedMediaMapping,
- agents,
- );
- case 'quiz':
- return generateQuizContent(outline, aiCall);
- case 'interactive':
- return generateInteractiveContent(outline, aiCall, outline.language);
- case 'pbl':
- return generatePBLSceneContent(outline, languageModel);
- default:
- return null;
- }
- }
- /**
- * Check if a string looks like an image ID (e.g., "img_1", "img_2")
- * rather than a base64 data URL or actual URL
- *
- * This function distinguishes between:
- * - Image IDs: "img_1", "img_2", etc. → returns true
- * - Base64 data URLs: "data:image/..." → returns false
- * - HTTP URLs: "http://...", "https://..." → returns false
- * - Relative paths: "/images/..." → returns false
- */
- function isImageIdReference(value: string): boolean {
- if (!value) return false;
- // Exclude real URLs and paths
- if (value.startsWith('data:')) return false;
- if (value.startsWith('http://') || value.startsWith('https://')) return false;
- if (value.startsWith('/')) return false; // Relative paths
- // Match image ID format: img_1, img_2, etc.
- return /^img_\d+$/i.test(value);
- }
- /**
- * Check if a string looks like a generated image/video ID (e.g., "gen_img_1", "gen_img_xK8f2mQ")
- * These are placeholders for AI-generated media, not PDF-extracted images.
- */
- function isGeneratedImageId(value: string): boolean {
- if (!value) return false;
- return /^gen_(img|vid)_[\w-]+$/i.test(value);
- }
- /**
- * Resolve image ID references in src field to actual base64 URLs
- *
- * AI generates: { type: "image", src: "img_1", ... }
- * This function replaces: { type: "image", src: "data:image/png;base64,...", ... }
- *
- * Design rationale (Plan B):
- * - Simpler: AI only needs to know one field (src)
- * - Consistent: Generated JSON structure matches final PPTImageElement
- * - Intuitive: src is the image source, first as ID then as actual URL
- * - Less prompt complexity: No need to explain imageId vs src distinction
- */
- function resolveImageIds(
- elements: GeneratedSlideData['elements'],
- imageMapping?: ImageMapping,
- generatedMediaMapping?: ImageMapping,
- ): GeneratedSlideData['elements'] {
- return elements
- .map((el) => {
- if (el.type === 'image') {
- if (!('src' in el)) {
- log.warn(`Image element missing src, removing element`);
- return null; // Remove invalid image elements
- }
- const src = el.src as string;
- // If src is an image ID reference, replace with actual URL
- if (isImageIdReference(src)) {
- if (!imageMapping || !imageMapping[src]) {
- log.warn(`No mapping for image ID: ${src}, removing element`);
- return null; // Remove invalid image elements
- }
- log.debug(`Resolved image ID "${src}" to base64 URL`);
- return { ...el, src: imageMapping[src] };
- }
- // Generated image reference — keep as placeholder for async backfill
- if (isGeneratedImageId(src)) {
- if (generatedMediaMapping && generatedMediaMapping[src]) {
- log.debug(`Resolved generated image ID "${src}" to URL`);
- return { ...el, src: generatedMediaMapping[src] };
- }
- // Keep element with placeholder ID — frontend renders skeleton
- log.debug(`Keeping generated image placeholder: ${src}`);
- return el;
- }
- }
- if (el.type === 'video') {
- if (!('src' in el)) {
- log.warn(`Video element missing src, removing element`);
- return null;
- }
- const src = el.src as string;
- if (isGeneratedImageId(src)) {
- if (generatedMediaMapping && generatedMediaMapping[src]) {
- log.debug(`Resolved generated video ID "${src}" to URL`);
- return { ...el, src: generatedMediaMapping[src] };
- }
- // Keep element with placeholder ID — frontend renders skeleton
- log.debug(`Keeping generated video placeholder: ${src}`);
- return el;
- }
- }
- return el;
- })
- .filter((el): el is NonNullable<typeof el> => el !== null);
- }
- /**
- * Fix elements with missing required fields
- * Adds default values for fields that AI might not have generated correctly
- */
- function fixElementDefaults(
- elements: GeneratedSlideData['elements'],
- assignedImages?: PdfImage[],
- ): GeneratedSlideData['elements'] {
- return elements.map((el) => {
- // Fix line elements
- if (el.type === 'line') {
- const lineEl = el as Record<string, unknown>;
- // Ensure points field exists with default values
- if (!lineEl.points || !Array.isArray(lineEl.points) || lineEl.points.length !== 2) {
- log.warn(`Line element missing points, adding defaults`);
- lineEl.points = ['', ''] as [string, string]; // Default: no markers on either end
- }
- // Ensure start/end exist
- if (!lineEl.start || !Array.isArray(lineEl.start)) {
- lineEl.start = [el.left ?? 0, el.top ?? 0];
- }
- if (!lineEl.end || !Array.isArray(lineEl.end)) {
- lineEl.end = [(el.left ?? 0) + (el.width ?? 100), (el.top ?? 0) + (el.height ?? 0)];
- }
- // Ensure style exists
- if (!lineEl.style) {
- lineEl.style = 'solid';
- }
- // Ensure color exists
- if (!lineEl.color) {
- lineEl.color = '#333333';
- }
- return lineEl as typeof el;
- }
- // Fix text elements
- if (el.type === 'text') {
- const textEl = el as Record<string, unknown>;
- if (!textEl.defaultFontName) {
- textEl.defaultFontName = 'Microsoft YaHei';
- }
- if (!textEl.defaultColor) {
- textEl.defaultColor = '#333333';
- }
- if (!textEl.content) {
- textEl.content = '';
- }
- return textEl as typeof el;
- }
- // Fix image elements
- if (el.type === 'image') {
- const imageEl = el as Record<string, unknown>;
- if (imageEl.fixedRatio === undefined) {
- imageEl.fixedRatio = true;
- }
- // Correct dimensions using known aspect ratio (src is still img_id at this point)
- if (assignedImages && typeof imageEl.src === 'string') {
- const imgMeta = assignedImages.find((img) => img.id === imageEl.src);
- if (imgMeta?.width && imgMeta?.height) {
- const knownRatio = imgMeta.width / imgMeta.height;
- const curW = (el.width || 400) as number;
- const curH = (el.height || 300) as number;
- if (Math.abs(curW / curH - knownRatio) / knownRatio > 0.1) {
- // Keep width, correct height
- const newH = Math.round(curW / knownRatio);
- if (newH > 462) {
- // canvas 562.5 - margins 50×2
- const newW = Math.round(462 * knownRatio);
- imageEl.width = newW;
- imageEl.height = 462;
- } else {
- imageEl.height = newH;
- }
- }
- }
- }
- return imageEl as typeof el;
- }
- // Fix shape elements
- if (el.type === 'shape') {
- const shapeEl = el as Record<string, unknown>;
- if (!shapeEl.viewBox) {
- shapeEl.viewBox = `0 0 ${el.width ?? 100} ${el.height ?? 100}`;
- }
- if (!shapeEl.path) {
- // Default to rectangle
- const w = el.width ?? 100;
- const h = el.height ?? 100;
- shapeEl.path = `M0 0 L${w} 0 L${w} ${h} L0 ${h} Z`;
- }
- if (!shapeEl.fill) {
- shapeEl.fill = '#5b9bd5';
- }
- if (shapeEl.fixedRatio === undefined) {
- shapeEl.fixedRatio = false;
- }
- return shapeEl as typeof el;
- }
- return el;
- });
- }
- /**
- * Process LaTeX elements: render latex string to HTML using KaTeX.
- * Fills in html and fixedRatio fields.
- * Elements that fail conversion are removed.
- */
- function processLatexElements(
- elements: GeneratedSlideData['elements'],
- ): GeneratedSlideData['elements'] {
- return elements
- .map((el) => {
- if (el.type !== 'latex') return el;
- const latexStr = el.latex as string | undefined;
- if (!latexStr) {
- log.warn('Latex element missing latex string, removing');
- return null;
- }
- try {
- const html = katex.renderToString(latexStr, {
- throwOnError: false,
- displayMode: true,
- output: 'html',
- });
- return {
- ...el,
- html,
- fixedRatio: true,
- };
- } catch (err) {
- log.warn(`Failed to render latex "${latexStr}":`, err);
- return null;
- }
- })
- .filter((el): el is NonNullable<typeof el> => el !== null);
- }
- /**
- * Generate slide content
- */
- async function generateSlideContent(
- outline: SceneOutline,
- aiCall: AICallFn,
- assignedImages?: PdfImage[],
- imageMapping?: ImageMapping,
- visionEnabled?: boolean,
- generatedMediaMapping?: ImageMapping,
- agents?: AgentInfo[],
- ): Promise<GeneratedSlideContent | null> {
- const lang = outline.language || 'zh-CN';
- // Build assigned images description for the prompt
- let assignedImagesText = '无可用图片,禁止插入任何 image 元素';
- let visionImages: Array<{ id: string; src: string }> | undefined;
- if (assignedImages && assignedImages.length > 0) {
- if (visionEnabled && imageMapping) {
- // Vision mode: split into vision images and text-only
- const withSrc = assignedImages.filter((img) => imageMapping[img.id]);
- const visionSlice = withSrc.slice(0, MAX_VISION_IMAGES);
- const textOnlySlice = withSrc.slice(MAX_VISION_IMAGES);
- const noSrcImages = assignedImages.filter((img) => !imageMapping[img.id]);
- const visionDescriptions = visionSlice.map((img) => formatImagePlaceholder(img, lang));
- const textDescriptions = [...textOnlySlice, ...noSrcImages].map((img) =>
- formatImageDescription(img, lang),
- );
- assignedImagesText = [...visionDescriptions, ...textDescriptions].join('\n');
- visionImages = visionSlice.map((img) => ({
- id: img.id,
- src: imageMapping[img.id],
- width: img.width,
- height: img.height,
- }));
- } else {
- assignedImagesText = assignedImages
- .map((img) => formatImageDescription(img, lang))
- .join('\n');
- }
- }
- // Add generated media placeholders info (images + videos)
- if (outline.mediaGenerations && outline.mediaGenerations.length > 0) {
- const genImgDescs = outline.mediaGenerations
- .filter((mg) => mg.type === 'image')
- .map((mg) => `- ${mg.elementId}: "${mg.prompt}" (aspect ratio: ${mg.aspectRatio || '16:9'})`)
- .join('\n');
- const genVidDescs = outline.mediaGenerations
- .filter((mg) => mg.type === 'video')
- .map((mg) => `- ${mg.elementId}: "${mg.prompt}" (aspect ratio: ${mg.aspectRatio || '16:9'})`)
- .join('\n');
- const mediaParts: string[] = [];
- if (genImgDescs) {
- mediaParts.push(`AI-Generated Images (use these IDs as image element src):\n${genImgDescs}`);
- }
- if (genVidDescs) {
- mediaParts.push(`AI-Generated Videos (use these IDs as video element src):\n${genVidDescs}`);
- }
- if (mediaParts.length > 0) {
- const mediaText = mediaParts.join('\n\n');
- if (assignedImagesText.includes('禁止插入') || assignedImagesText.includes('No images')) {
- assignedImagesText = mediaText;
- } else {
- assignedImagesText += `\n\n${mediaText}`;
- }
- }
- }
- // Canvas dimensions (matching viewportSize and viewportRatio)
- const canvasWidth = 1000;
- const canvasHeight = 562.5;
- const teacherContext = formatTeacherPersonaForPrompt(agents);
- const prompts = buildPrompt(PROMPT_IDS.SLIDE_CONTENT, {
- title: outline.title,
- description: outline.description,
- keyPoints: (outline.keyPoints || []).map((p, i) => `${i + 1}. ${p}`).join('\n'),
- elements: '(根据要点自动生成)',
- assignedImages: assignedImagesText,
- canvas_width: canvasWidth,
- canvas_height: canvasHeight,
- teacherContext,
- });
- if (!prompts) {
- return null;
- }
- log.debug(`Generating slide content for: ${outline.title}`);
- if (assignedImages && assignedImages.length > 0) {
- log.debug(`Assigned images: ${assignedImages.map((img) => img.id).join(', ')}`);
- }
- if (visionImages && visionImages.length > 0) {
- log.debug(`Vision images: ${visionImages.map((img) => img.id).join(', ')}`);
- }
- const response = await aiCall(prompts.system, prompts.user, visionImages);
- const generatedData = parseJsonResponse<GeneratedSlideData>(response);
- if (!generatedData || !generatedData.elements || !Array.isArray(generatedData.elements)) {
- log.error(`Failed to parse AI response for: ${outline.title}`);
- return null;
- }
- log.debug(`Got ${generatedData.elements.length} elements for: ${outline.title}`);
- // Debug: Log image elements before resolution
- const imageElements = generatedData.elements.filter((el) => el.type === 'image');
- if (imageElements.length > 0) {
- log.debug(
- `Image elements before resolution:`,
- imageElements.map((el) => ({
- type: el.type,
- src:
- (el as Record<string, unknown>).src &&
- String((el as Record<string, unknown>).src).substring(0, 50),
- })),
- );
- log.debug(`imageMapping keys:`, imageMapping ? Object.keys(imageMapping).length : '0 keys');
- }
- // Fix elements with missing required fields + aspect ratio correction (while src is still img_id)
- const fixedElements = fixElementDefaults(generatedData.elements, assignedImages);
- log.debug(`After element fixing: ${fixedElements.length} elements`);
- // Process LaTeX elements: render latex string → HTML via KaTeX
- const latexProcessedElements = processLatexElements(fixedElements);
- log.debug(`After LaTeX processing: ${latexProcessedElements.length} elements`);
- // Resolve image_id references to actual URLs
- const resolvedElements = resolveImageIds(
- latexProcessedElements,
- imageMapping,
- generatedMediaMapping,
- );
- log.debug(`After image resolution: ${resolvedElements.length} elements`);
- // Process elements, assign unique IDs
- const processedElements: PPTElement[] = resolvedElements.map((el) => ({
- ...el,
- id: `${el.type}_${nanoid(8)}`,
- rotate: 0,
- })) as PPTElement[];
- // Process background
- let background: SlideBackground | undefined;
- if (generatedData.background) {
- if (generatedData.background.type === 'solid' && generatedData.background.color) {
- background = { type: 'solid', color: generatedData.background.color };
- } else if (generatedData.background.type === 'gradient' && generatedData.background.gradient) {
- background = {
- type: 'gradient',
- gradient: generatedData.background.gradient,
- };
- }
- }
- return {
- elements: processedElements,
- background,
- remark: generatedData.remark || outline.description,
- };
- }
- /**
- * Generate quiz content
- */
- async function generateQuizContent(
- outline: SceneOutline,
- aiCall: AICallFn,
- ): Promise<GeneratedQuizContent | null> {
- const quizConfig = outline.quizConfig || {
- questionCount: 3,
- difficulty: 'medium',
- questionTypes: ['single'],
- };
- const prompts = buildPrompt(PROMPT_IDS.QUIZ_CONTENT, {
- title: outline.title,
- description: outline.description,
- keyPoints: (outline.keyPoints || []).map((p, i) => `${i + 1}. ${p}`).join('\n'),
- questionCount: quizConfig.questionCount,
- difficulty: quizConfig.difficulty,
- questionTypes: quizConfig.questionTypes.join(', '),
- });
- if (!prompts) {
- return null;
- }
- log.debug(`Generating quiz content for: ${outline.title}`);
- const response = await aiCall(prompts.system, prompts.user);
- const generatedQuestions = parseJsonResponse<QuizQuestion[]>(response);
- if (!generatedQuestions || !Array.isArray(generatedQuestions)) {
- log.error(`Failed to parse AI response for: ${outline.title}`);
- return null;
- }
- log.debug(`Got ${generatedQuestions.length} questions for: ${outline.title}`);
- // Ensure each question has an ID and normalize options format
- const questions: QuizQuestion[] = generatedQuestions.map((q) => {
- const isText = q.type === 'short_answer';
- return {
- ...q,
- id: q.id || `q_${nanoid(8)}`,
- options: isText ? undefined : normalizeQuizOptions(q.options),
- answer: isText ? undefined : normalizeQuizAnswer(q as unknown as Record<string, unknown>),
- hasAnswer: isText ? false : true,
- };
- });
- return { questions };
- }
- /**
- * Normalize quiz options from AI response.
- * AI may generate plain strings ["OptionA", "OptionB"] or QuizOption objects.
- * This normalizes to QuizOption[] format: { value: "A", label: "OptionA" }
- */
- function normalizeQuizOptions(
- options: unknown[] | undefined,
- ): { value: string; label: string }[] | undefined {
- if (!options || !Array.isArray(options)) return undefined;
- return options.map((opt, index) => {
- const letter = String.fromCharCode(65 + index); // A, B, C, D...
- if (typeof opt === 'string') {
- return { value: letter, label: opt };
- }
- if (typeof opt === 'object' && opt !== null) {
- const obj = opt as Record<string, unknown>;
- return {
- value: typeof obj.value === 'string' ? obj.value : letter,
- label: typeof obj.label === 'string' ? obj.label : String(obj.value || obj.text || letter),
- };
- }
- return { value: letter, label: String(opt) };
- });
- }
- /**
- * Normalize quiz answer from AI response.
- * AI may generate correctAnswer as string or string[], under various field names.
- * This normalizes to string[] format matching option values.
- */
- function normalizeQuizAnswer(question: Record<string, unknown>): string[] | undefined {
- // AI might use "correctAnswer", "answer", or "correct_answer"
- const raw =
- question.answer ??
- question.correctAnswer ??
- (question as Record<string, unknown>).correct_answer;
- if (!raw) return undefined;
- if (Array.isArray(raw)) {
- return raw.map(String);
- }
- return [String(raw)];
- }
- /**
- * Generate interactive page content
- * Two AI calls + post-processing:
- * 1. Scientific modeling -> ScientificModel (with fallback)
- * 2. HTML generation with constraints -> post-processed HTML
- */
- async function generateInteractiveContent(
- outline: SceneOutline,
- aiCall: AICallFn,
- language: 'zh-CN' | 'en-US' = 'zh-CN',
- ): Promise<GeneratedInteractiveContent | null> {
- const config = outline.interactiveConfig!;
- // Step 1: Scientific modeling (with fallback on failure)
- let scientificModel: ScientificModel | undefined;
- try {
- const modelPrompts = buildPrompt(PROMPT_IDS.INTERACTIVE_SCIENTIFIC_MODEL, {
- subject: config.subject || '',
- conceptName: config.conceptName,
- conceptOverview: config.conceptOverview,
- keyPoints: (outline.keyPoints || []).map((p, i) => `${i + 1}. ${p}`).join('\n'),
- designIdea: config.designIdea,
- });
- if (modelPrompts) {
- log.info(`Step 1: Scientific modeling for: ${outline.title}`);
- const modelResponse = await aiCall(modelPrompts.system, modelPrompts.user);
- const parsed = parseJsonResponse<ScientificModel>(modelResponse);
- if (parsed && parsed.core_formulas) {
- scientificModel = parsed;
- log.info(
- `Scientific model: ${parsed.core_formulas.length} formulas, ${parsed.constraints?.length || 0} constraints`,
- );
- }
- }
- } catch (error) {
- log.warn(`Scientific modeling failed, continuing without: ${error}`);
- }
- // Format scientific constraints for HTML generation prompt
- let scientificConstraints = 'No specific scientific constraints available.';
- if (scientificModel) {
- const lines: string[] = [];
- if (scientificModel.core_formulas?.length) {
- lines.push(`Core Formulas: ${scientificModel.core_formulas.join('; ')}`);
- }
- if (scientificModel.mechanism?.length) {
- lines.push(`Mechanisms: ${scientificModel.mechanism.join('; ')}`);
- }
- if (scientificModel.constraints?.length) {
- lines.push(`Must Obey: ${scientificModel.constraints.join('; ')}`);
- }
- if (scientificModel.forbidden_errors?.length) {
- lines.push(`Forbidden Errors: ${scientificModel.forbidden_errors.join('; ')}`);
- }
- scientificConstraints = lines.join('\n');
- }
- // Step 2: HTML generation
- const htmlPrompts = buildPrompt(PROMPT_IDS.INTERACTIVE_HTML, {
- conceptName: config.conceptName,
- subject: config.subject || '',
- conceptOverview: config.conceptOverview,
- keyPoints: (outline.keyPoints || []).map((p, i) => `${i + 1}. ${p}`).join('\n'),
- scientificConstraints,
- designIdea: config.designIdea,
- language,
- });
- if (!htmlPrompts) {
- log.error(`Failed to build HTML prompt for: ${outline.title}`);
- return null;
- }
- log.info(`Step 2: Generating HTML for: ${outline.title}`);
- const htmlResponse = await aiCall(htmlPrompts.system, htmlPrompts.user);
- // Extract HTML from response
- const rawHtml = extractHtml(htmlResponse);
- if (!rawHtml) {
- log.error(`Failed to extract HTML from response for: ${outline.title}`);
- return null;
- }
- // Step 3: Post-process HTML (LaTeX delimiter conversion + KaTeX injection)
- const processedHtml = postProcessInteractiveHtml(rawHtml);
- log.info(`Post-processed HTML (${processedHtml.length} chars) for: ${outline.title}`);
- return {
- html: processedHtml,
- scientificModel,
- };
- }
- /**
- * Generate PBL project content
- * Uses the agentic loop from lib/pbl/generate-pbl.ts
- */
- async function generatePBLSceneContent(
- outline: SceneOutline,
- languageModel?: LanguageModel,
- ): Promise<GeneratedPBLContent | null> {
- if (!languageModel) {
- log.error('LanguageModel required for PBL generation');
- return null;
- }
- const pblConfig = outline.pblConfig;
- if (!pblConfig) {
- log.error(`PBL outline "${outline.title}" missing pblConfig`);
- return null;
- }
- log.info(`Generating PBL content for: ${outline.title}`);
- try {
- const projectConfig = await generatePBLContent(
- {
- projectTopic: pblConfig.projectTopic,
- projectDescription: pblConfig.projectDescription,
- targetSkills: pblConfig.targetSkills,
- issueCount: pblConfig.issueCount,
- language: pblConfig.language,
- },
- languageModel,
- {
- onProgress: (msg) => log.info(`${msg}`),
- },
- );
- log.info(
- `PBL generated: ${projectConfig.agents.length} agents, ${projectConfig.issueboard.issues.length} issues`,
- );
- return { projectConfig };
- } catch (error) {
- log.error(`Failed:`, error);
- return null;
- }
- }
- /**
- * Extract HTML document from AI response.
- * Tries to find <!DOCTYPE html>...</html> first, then falls back to code block extraction.
- */
- function extractHtml(response: string): string | null {
- // Strategy 1: Find complete HTML document
- const doctypeStart = response.indexOf('<!DOCTYPE html>');
- const htmlTagStart = response.indexOf('<html');
- const start = doctypeStart !== -1 ? doctypeStart : htmlTagStart;
- if (start !== -1) {
- const htmlEnd = response.lastIndexOf('</html>');
- if (htmlEnd !== -1) {
- return response.substring(start, htmlEnd + 7);
- }
- }
- // Strategy 2: Extract from code block
- const codeBlockMatch = response.match(/```(?:html)?\s*([\s\S]*?)```/);
- if (codeBlockMatch) {
- const content = codeBlockMatch[1].trim();
- if (content.includes('<html') || content.includes('<!DOCTYPE')) {
- return content;
- }
- }
- // Strategy 3: If response itself looks like HTML
- const trimmed = response.trim();
- if (trimmed.startsWith('<!DOCTYPE') || trimmed.startsWith('<html')) {
- return trimmed;
- }
- log.error('Could not extract HTML from response');
- log.error('Response preview:', response.substring(0, 200));
- return null;
- }
- /**
- * Step 3.2: Generate Actions based on content and script
- */
- export async function generateSceneActions(
- outline: SceneOutline,
- content:
- | GeneratedSlideContent
- | GeneratedQuizContent
- | GeneratedInteractiveContent
- | GeneratedPBLContent,
- aiCall: AICallFn,
- ctx?: SceneGenerationContext,
- agents?: AgentInfo[],
- userProfile?: string,
- ): Promise<Action[]> {
- const agentsText = formatAgentsForPrompt(agents);
- if (outline.type === 'slide' && 'elements' in content) {
- // Format element list for AI to select from
- const elementsText = formatElementsForPrompt(content.elements);
- const prompts = buildPrompt(PROMPT_IDS.SLIDE_ACTIONS, {
- title: outline.title,
- keyPoints: (outline.keyPoints || []).map((p, i) => `${i + 1}. ${p}`).join('\n'),
- description: outline.description,
- elements: elementsText,
- courseContext: buildCourseContext(ctx),
- agents: agentsText,
- userProfile: userProfile || '',
- });
- if (!prompts) {
- return generateDefaultSlideActions(outline, content.elements);
- }
- const response = await aiCall(prompts.system, prompts.user);
- const actions = parseActionsFromStructuredOutput(response, outline.type);
- if (actions.length > 0) {
- // Validate and fill in Action IDs
- return processActions(actions, content.elements, agents);
- }
- return generateDefaultSlideActions(outline, content.elements);
- }
- if (outline.type === 'quiz' && 'questions' in content) {
- // Format question list for AI reference
- const questionsText = formatQuestionsForPrompt(content.questions);
- const prompts = buildPrompt(PROMPT_IDS.QUIZ_ACTIONS, {
- title: outline.title,
- keyPoints: (outline.keyPoints || []).map((p, i) => `${i + 1}. ${p}`).join('\n'),
- description: outline.description,
- questions: questionsText,
- courseContext: buildCourseContext(ctx),
- agents: agentsText,
- });
- if (!prompts) {
- return generateDefaultQuizActions(outline);
- }
- const response = await aiCall(prompts.system, prompts.user);
- const actions = parseActionsFromStructuredOutput(response, outline.type);
- if (actions.length > 0) {
- return processActions(actions, [], agents);
- }
- return generateDefaultQuizActions(outline);
- }
- if (outline.type === 'interactive' && 'html' in content) {
- const config = outline.interactiveConfig;
- const agentsText = formatAgentsForPrompt(agents);
- const prompts = buildPrompt(PROMPT_IDS.INTERACTIVE_ACTIONS, {
- title: outline.title,
- keyPoints: (outline.keyPoints || []).map((p, i) => `${i + 1}. ${p}`).join('\n'),
- description: outline.description,
- conceptName: config?.conceptName || outline.title,
- designIdea: config?.designIdea || '',
- courseContext: buildCourseContext(ctx),
- agents: agentsText,
- });
- if (!prompts) {
- return generateDefaultInteractiveActions(outline);
- }
- const response = await aiCall(prompts.system, prompts.user);
- const actions = parseActionsFromStructuredOutput(response, outline.type);
- if (actions.length > 0) {
- return processActions(actions, [], agents);
- }
- return generateDefaultInteractiveActions(outline);
- }
- if (outline.type === 'pbl' && 'projectConfig' in content) {
- const pblConfig = outline.pblConfig;
- const agentsText = formatAgentsForPrompt(agents);
- const prompts = buildPrompt(PROMPT_IDS.PBL_ACTIONS, {
- title: outline.title,
- keyPoints: (outline.keyPoints || []).map((p, i) => `${i + 1}. ${p}`).join('\n'),
- description: outline.description,
- projectTopic: pblConfig?.projectTopic || outline.title,
- projectDescription: pblConfig?.projectDescription || outline.description,
- courseContext: buildCourseContext(ctx),
- agents: agentsText,
- });
- if (!prompts) {
- return generateDefaultPBLActions(outline);
- }
- const response = await aiCall(prompts.system, prompts.user);
- const actions = parseActionsFromStructuredOutput(response, outline.type);
- if (actions.length > 0) {
- return processActions(actions, [], agents);
- }
- return generateDefaultPBLActions(outline);
- }
- return [];
- }
- /**
- * Generate default PBL Actions (fallback)
- */
- function generateDefaultPBLActions(_outline: SceneOutline): Action[] {
- return [
- {
- id: `action_${nanoid(8)}`,
- type: 'speech',
- title: 'PBL 项目介绍',
- text: '现在让我们开始一个项目式学习活动。请选择你的角色,查看任务看板,开始协作完成项目。',
- },
- ];
- }
- /**
- * Format element list for AI to select elementId
- */
- function formatElementsForPrompt(elements: PPTElement[]): string {
- return elements
- .map((el) => {
- let summary = '';
- if (el.type === 'text' && 'content' in el) {
- // Extract text content summary (strip HTML tags)
- const textContent = ((el.content as string) || '').replace(/<[^>]*>/g, '').substring(0, 50);
- summary = `Content summary: "${textContent}${textContent.length >= 50 ? '...' : ''}"`;
- } else if (el.type === 'chart' && 'chartType' in el) {
- summary = `Chart type: ${el.chartType}`;
- } else if (el.type === 'image') {
- summary = 'Image element';
- } else if (el.type === 'shape' && 'shapeName' in el) {
- summary = `Shape: ${el.shapeName || 'unknown'}`;
- } else if (el.type === 'latex' && 'latex' in el) {
- summary = `Formula: ${((el.latex as string) || '').substring(0, 30)}`;
- } else {
- summary = `${el.type} element`;
- }
- return `- id: "${el.id}", type: "${el.type}", ${summary}`;
- })
- .join('\n');
- }
- /**
- * Format question list for AI reference
- */
- function formatQuestionsForPrompt(questions: QuizQuestion[]): string {
- return questions
- .map((q, i) => {
- const optionsText = q.options
- ? `Options: ${q.options.map((o) => `${o.value}. ${o.label}`).join(', ')}`
- : '';
- return `Q${i + 1} (${q.type}): ${q.question}\n${optionsText}`;
- })
- .join('\n\n');
- }
- /**
- * Process and validate Actions
- */
- function processActions(actions: Action[], elements: PPTElement[], agents?: AgentInfo[]): Action[] {
- const elementIds = new Set(elements.map((el) => el.id));
- const agentIds = new Set(agents?.map((a) => a.id) || []);
- const studentAgents = agents?.filter((a) => a.role === 'student') || [];
- const nonTeacherAgents = agents?.filter((a) => a.role !== 'teacher') || [];
- return actions.map((action) => {
- // Ensure each action has an ID
- const processedAction: Action = {
- ...action,
- id: action.id || `action_${nanoid(8)}`,
- };
- // Validate spotlight elementId
- if (processedAction.type === 'spotlight') {
- const spotlightAction = processedAction;
- if (!spotlightAction.elementId || !elementIds.has(spotlightAction.elementId)) {
- // If elementId is invalid, try selecting the first element
- if (elements.length > 0) {
- spotlightAction.elementId = elements[0].id;
- log.warn(
- `Invalid elementId, falling back to first element: ${spotlightAction.elementId}`,
- );
- }
- }
- }
- // Validate/fill discussion agentId
- if (processedAction.type === 'discussion' && agents && agents.length > 0) {
- if (processedAction.agentId && agentIds.has(processedAction.agentId)) {
- // agentId valid — keep it
- } else {
- // agentId missing or invalid — pick a random student, or non-teacher, or skip
- const pool = studentAgents.length > 0 ? studentAgents : nonTeacherAgents;
- if (pool.length > 0) {
- const picked = pool[Math.floor(Math.random() * pool.length)];
- log.warn(
- `Discussion agentId "${processedAction.agentId || '(none)'}" invalid, assigned: ${picked.id} (${picked.name})`,
- );
- processedAction.agentId = picked.id;
- }
- }
- }
- return processedAction;
- });
- }
- /**
- * Generate default slide Actions (fallback)
- */
- function generateDefaultSlideActions(outline: SceneOutline, elements: PPTElement[]): Action[] {
- const actions: Action[] = [];
- // Add spotlight for text elements
- const textElements = elements.filter((el) => el.type === 'text');
- if (textElements.length > 0) {
- actions.push({
- id: `action_${nanoid(8)}`,
- type: 'spotlight',
- title: '聚焦重点',
- elementId: textElements[0].id,
- });
- }
- // Add opening speech based on key points
- const speechText = outline.keyPoints?.length
- ? outline.keyPoints.join('。') + '。'
- : outline.description || outline.title;
- actions.push({
- id: `action_${nanoid(8)}`,
- type: 'speech',
- title: '场景讲解',
- text: speechText,
- });
- return actions;
- }
- /**
- * Generate default quiz Actions (fallback)
- */
- function generateDefaultQuizActions(_outline: SceneOutline): Action[] {
- return [
- {
- id: `action_${nanoid(8)}`,
- type: 'speech',
- title: '测验引导',
- text: '现在让我们来做一个小测验,检验一下学习成果。',
- },
- ];
- }
- /**
- * Generate default interactive Actions (fallback)
- */
- function generateDefaultInteractiveActions(_outline: SceneOutline): Action[] {
- return [
- {
- id: `action_${nanoid(8)}`,
- type: 'speech',
- title: '交互引导',
- text: '现在让我们通过交互式可视化来探索这个概念。请尝试操作页面中的元素,观察变化。',
- },
- ];
- }
- /**
- * Create a complete scene with Actions
- */
- export function createSceneWithActions(
- outline: SceneOutline,
- content:
- | GeneratedSlideContent
- | GeneratedQuizContent
- | GeneratedInteractiveContent
- | GeneratedPBLContent,
- actions: Action[],
- api: ReturnType<typeof createStageAPI>,
- ): string | null {
- if (outline.type === 'slide' && 'elements' in content) {
- // Build complete Slide object
- const defaultTheme: SlideTheme = {
- backgroundColor: '#ffffff',
- themeColors: ['#5b9bd5', '#ed7d31', '#a5a5a5', '#ffc000', '#4472c4'],
- fontColor: '#333333',
- fontName: 'Microsoft YaHei',
- outline: { color: '#d14424', width: 2, style: 'solid' },
- shadow: { h: 0, v: 0, blur: 10, color: '#000000' },
- };
- const slide: Slide = {
- id: nanoid(),
- viewportSize: 1000,
- viewportRatio: 0.5625,
- theme: defaultTheme,
- elements: content.elements,
- background: content.background,
- };
- const sceneResult = api.scene.create({
- type: 'slide',
- title: outline.title,
- order: outline.order,
- content: {
- type: 'slide',
- canvas: slide,
- },
- actions,
- });
- return sceneResult.success ? (sceneResult.data ?? null) : null;
- }
- if (outline.type === 'quiz' && 'questions' in content) {
- const sceneResult = api.scene.create({
- type: 'quiz',
- title: outline.title,
- order: outline.order,
- content: {
- type: 'quiz',
- questions: content.questions,
- },
- actions,
- });
- return sceneResult.success ? (sceneResult.data ?? null) : null;
- }
- if (outline.type === 'interactive' && 'html' in content) {
- const sceneResult = api.scene.create({
- type: 'interactive',
- title: outline.title,
- order: outline.order,
- content: {
- type: 'interactive',
- url: '',
- html: content.html,
- },
- actions,
- });
- return sceneResult.success ? (sceneResult.data ?? null) : null;
- }
- if (outline.type === 'pbl' && 'projectConfig' in content) {
- const sceneResult = api.scene.create({
- type: 'pbl',
- title: outline.title,
- order: outline.order,
- content: {
- type: 'pbl',
- projectConfig: content.projectConfig,
- },
- actions,
- });
- return sceneResult.success ? (sceneResult.data ?? null) : null;
- }
- return null;
- }
|