Files
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

1907 lines
62 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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,
UserRequirements,
PdfImage,
ImageMapping,
WidgetOutline,
} from '@/lib/types/generation';
import type { WidgetType, WidgetConfig } from '@/lib/types/widgets';
import type { PromptId } from '@/lib/prompts/types';
import type { LanguageModel } from 'ai';
import { createStageAPI } from '@/lib/api/stage-api';
import { generatePBLContent } from '@/lib/pbl/generate-pbl';
import { generatePBLV2Project, PlannerV2Error } from '@/lib/pbl/v2/agents/planner';
import { generatePBLV2ProjectSingleCall } from '@/lib/pbl/v2/agents/planner-single-call';
import { projectV2ToLegacyProjectConfig } from '@/lib/pbl/v2/compat';
import type { PBLPlannerV2Input, PBLProjectV2 } from '@/lib/pbl/v2/types';
import { buildPrompt, PROMPT_IDS } from '@/lib/prompts';
import { DEFAULT_LANGUAGE_DIRECTIVE } from './outline-generator';
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 '@openmaic/dsl';
import { normalizeElement } from '@openmaic/dsl';
import type { QuizQuestion } from '@/lib/types/stage';
import type { Action } from '@/lib/types/action';
import type {
AgentInfo,
SceneGenerationContext,
GeneratedSlideData,
AICallFn,
} from './pipeline-types';
import type { ThinkingConfig } from '@/lib/types/provider';
import { createLogger } from '@/lib/logger';
const log = createLogger('Generation');
const INTERACTIVE_WIDGET_ACTIONS = [
'widget_highlight',
'widget_setState',
'widget_annotation',
'widget_reveal',
];
// ── Options interfaces for scene generation functions ──
export interface SceneContentOptions {
assignedImages?: PdfImage[];
imageMapping?: ImageMapping;
languageModel?: LanguageModel;
visionEnabled?: boolean;
generatedMediaMapping?: ImageMapping;
agents?: AgentInfo[];
languageDirective?: string;
thinkingConfig?: ThinkingConfig;
/** Authoritative UI locale selected by the user, consumed by the PBL v2 planner. */
targetLanguage?: string;
/** Original course request/profile, used by PBL v2 for explicit learner-level signals. */
userRequirements?: UserRequirements;
allowProceduralSkill?: boolean;
/**
* Natural-language edit instruction for whole-slide regeneration (MAIC Editor
* agent `regenerate_scene`). When set, the slide content prompt switches to
* EDIT MODE. slide-only; ignored by other scene types.
*/
editDirective?: string;
/**
* The current slide content, fed as the edit baseline so content-specific
* instructions operate on the real slide rather than re-rolling from outline.
* Only consumed by the slide branch alongside `editDirective`.
*/
baselineContent?: GeneratedSlideContent;
}
export interface SceneActionsOptions {
ctx?: SceneGenerationContext;
agents?: AgentInfo[];
userProfile?: string;
languageDirective?: string;
}
// ==================== Backward Compatibility Helpers ====================
/**
* Convert legacy interactiveConfig to unified widget fields
* For backward compatibility with old classrooms
*/
function convertInteractiveConfigToWidget(outline: SceneOutline): SceneOutline {
const config = outline.interactiveConfig;
if (!config) {
log.warn(
`Interactive outline missing both widget and interactiveConfig, falling back to simulation`,
);
return {
...outline,
widgetType: 'simulation' as WidgetType,
widgetOutline: { concept: outline.title },
};
}
const widgetType = inferWidgetType(
config.subject || '',
config.conceptName,
config.designIdea || '',
);
log.info(`Converting interactiveConfig to widget: ${widgetType} for "${outline.title}"`);
return {
...outline,
widgetType,
widgetOutline: buildWidgetOutline(widgetType, config),
};
}
/**
* Infer widget type from concept characteristics
*/
function inferWidgetType(subject: string, concept: string, designIdea: string): WidgetType {
const text = (subject + ' ' + concept + ' ' + designIdea).toLowerCase();
// Rule-based inference
if (
/physics|chemistry|力学|化学|运动|反应|force|motion|equilibrium|wave|电路|circuit/.test(text)
) {
return 'simulation';
}
if (/programming|code|algorithm|编程|算法|python|javascript|function|代码/.test(text)) {
return 'code';
}
if (/process|workflow|步骤|流程|逻辑|step|flow|系统|system/.test(text)) {
return 'diagram';
}
if (
/biology|anatomy|cell|molecular|生物|细胞|分子|3d|三维|solar|planet|skeleton|organ/.test(text)
) {
return 'visualization3d';
}
if (/game|quiz|practice|练习|游戏|puzzle|match|challenge|挑战/.test(text)) {
return 'game';
}
// Default fallback
return 'simulation';
}
/**
* Build widgetOutline from interactiveConfig for backward compatibility
*/
function buildWidgetOutline(
widgetType: WidgetType,
config: { conceptName: string; conceptOverview: string; designIdea: string },
): WidgetOutline {
const base: WidgetOutline = { concept: config.conceptName };
switch (widgetType) {
case 'simulation':
// Try to extract variables from designIdea
const varMatch = config.designIdea.match(/variables|参数|调整|adjust|slider/i);
return { ...base, keyVariables: varMatch ? [] : undefined };
case 'diagram':
return { ...base, diagramType: 'flowchart' };
case 'code':
return { ...base, language: 'python' };
case 'game':
return { ...base, gameType: 'quiz' };
case 'visualization3d':
return { ...base, visualizationType: 'custom', objects: [] };
default:
return base;
}
}
/**
* Step 3.1: Generate content based on outline
*/
export async function generateSceneContent(
outline: SceneOutline,
aiCall: AICallFn,
options: SceneContentOptions = {},
): Promise<
| GeneratedSlideContent
| GeneratedQuizContent
| GeneratedInteractiveContent
| GeneratedPBLContent
| null
> {
const {
assignedImages,
imageMapping,
languageModel,
visionEnabled,
generatedMediaMapping,
agents,
languageDirective,
thinkingConfig,
targetLanguage,
userRequirements,
allowProceduralSkill = false,
editDirective,
baselineContent,
} = options;
// Unified path for interactive scenes (both normal and ultra mode)
if (outline.type === 'interactive') {
// Backward compatibility: convert legacy interactiveConfig
if (!outline.widgetType && outline.interactiveConfig) {
log.info(`Converting legacy interactiveConfig for: ${outline.title}`);
outline = convertInteractiveConfigToWidget(outline);
}
// If still no widgetType after conversion, fallback to simulation
if (!outline.widgetType) {
log.warn(
`Interactive outline "${outline.title}" has no widgetType, falling back to simulation`,
);
outline = {
...outline,
widgetType: 'simulation' as WidgetType,
widgetOutline: { concept: outline.title },
};
}
// Route to widget generation (handles all 5 types)
return generateWidgetContent(outline, aiCall, languageDirective, { allowProceduralSkill });
}
switch (outline.type) {
case 'slide':
return generateSlideContent(
outline,
aiCall,
assignedImages,
imageMapping,
visionEnabled,
generatedMediaMapping,
agents,
languageDirective,
editDirective,
baselineContent,
);
case 'quiz':
return generateQuizContent(outline, aiCall, languageDirective);
case 'pbl':
return generatePBLSceneContent(
outline,
languageModel,
languageDirective,
thinkingConfig,
targetLanguage,
userRequirements,
);
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') {
const mediaRef = (el as Record<string, unknown>).mediaRef;
if (!('src' in el) && typeof mediaRef !== 'string') {
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);
}
function normalizeGeneratedVideoRefs(
elements: GeneratedSlideData['elements'],
generatedVideoEntries: SceneOutline['mediaGenerations'] = [],
): GeneratedSlideData['elements'] {
const validRefs = generatedVideoEntries
.filter((mg) => mg.type === 'video')
.map((mg) => mg.elementId);
const validRefSet = new Set(validRefs);
const onlyRef = validRefs.length === 1 ? validRefs[0] : undefined;
return elements
.map((el) => {
if (el.type !== 'video') return el;
const videoEl = { ...el } as Record<string, unknown>;
const mediaRef = typeof videoEl.mediaRef === 'string' ? videoEl.mediaRef : undefined;
const src = typeof videoEl.src === 'string' ? videoEl.src : undefined;
const hasGeneratedSrc = !!src && isGeneratedImageId(src);
const hasDirectSrc = !!src && !hasGeneratedSrc;
if (hasDirectSrc) {
if (mediaRef) delete videoEl.mediaRef;
return videoEl as typeof el;
}
if (mediaRef && validRefSet.has(mediaRef)) {
if (hasGeneratedSrc) delete videoEl.src;
return videoEl as typeof el;
}
if (src && validRefSet.has(src)) {
videoEl.mediaRef = src;
delete videoEl.src;
return videoEl as typeof el;
}
if ((mediaRef || hasGeneratedSrc) && onlyRef) {
log.warn(`Correcting generated video reference "${mediaRef || src}" to "${onlyRef}"`);
videoEl.mediaRef = onlyRef;
if (hasGeneratedSrc) delete videoEl.src;
return videoEl as typeof el;
}
if (mediaRef || hasGeneratedSrc) {
log.warn(`Invalid generated video reference "${mediaRef || src}", removing element`);
return null;
}
return el;
})
.filter((el): el is NonNullable<typeof el> => el !== null);
}
/**
* Fill required element fields the model may have left off, plus image
* aspect-ratio reconciliation.
*
* The default-filling / geometry-derivation / malformed-input coercion is now
* owned by the DSL contract — `normalizeElement` from `@openmaic/dsl` — rather
* than duplicated imperatively here (it fills the same canonical defaults,
* derives a line's `start`/`end` and a shape's `viewBox`/`path` from the box,
* and fails loud on a present-but-wrong-typed field instead of silently
* resetting it). Image aspect-ratio reconciliation stays here: it depends on the
* resolved PDF asset's real dimensions, which is producer-specific data the DSL
* deliberately does not own.
*/
function fixElementDefaults(
elements: GeneratedSlideData['elements'],
assignedImages?: PdfImage[],
): GeneratedSlideData['elements'] {
// Index assigned images by id once (O(m)) so the per-image-element lookup
// below is O(1) instead of a `.find` nested inside this map (which made the
// pass O(elements × images)).
const imageMetaById = new Map((assignedImages ?? []).map((img) => [img.id, img]));
return elements
.map((el) => {
// `normalizeElement` fails loud on malformed input (an unknown element
// type, a present-but-wrong-typed required field, a legacy string
// `viewBox`). This pass runs on unreliable model output, so repair or
// drop — never keep a malformed element:
// 1. Repair: a JSON `null` from the model means "absent" — strip nulls so
// normalize treats the field as missing and fills/derives it, instead
// of failing on a wrong-typed null (`start: null`, `text: null`, …).
// 2. Drop: if normalization still throws, discard the element. Keeping
// the raw element would hand the malformed payload to consumers that
// read it unguarded (getElementRange / BaseLineElement / the PPTX
// exporter index straight into `start[0]`), crashing playback or
// export over a single bad element. Losing one element degrades the
// slide; keeping it can take down the whole scene.
let normalized: PPTElement;
try {
normalized = normalizeElement(stripNulls(el));
} catch (err) {
log.warn(
`Dropping malformed generated element: ${err instanceof Error ? err.message : String(err)}`,
);
return null;
}
// Fit the image box to the assigned PDF image's real aspect ratio (`src` is
// still the img_id at this point). Producer-specific, so it lives here, not
// in the DSL's normalize.
if (normalized.type === 'image' && assignedImages && typeof normalized.src === 'string') {
const imgMeta = imageMetaById.get(normalized.src);
if (imgMeta?.width && imgMeta?.height) {
const knownRatio = imgMeta.width / imgMeta.height;
const curW = normalized.width || 400;
const curH = normalized.height || 300;
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
return { ...normalized, width: Math.round(462 * knownRatio), height: 462 };
}
return { ...normalized, height: newH };
}
}
}
return normalized;
})
.filter((el) => el !== null) as unknown as GeneratedSlideData['elements'];
}
/**
* Drop `null`-valued properties (recursively, through plain objects) so the DSL
* normalizer sees them as absent and fills/derives defaults. Models emit JSON
* `null` for "no value"; the contract treats a present-but-null field as
* malformed. Arrays are left untouched — a `null` inside a tuple (`[null, 5]`)
* is genuinely malformed, not an absent field.
*/
function stripNulls(el: unknown): unknown {
if (Array.isArray(el) || typeof el !== 'object' || el === null) return el;
return Object.fromEntries(
Object.entries(el)
.filter(([, v]) => v !== null)
.map(([k, v]) => [k, stripNulls(v)]),
);
}
/**
* 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[],
languageDirective?: string,
editDirective?: string,
baselineContent?: GeneratedSlideContent,
): Promise<GeneratedSlideContent | null> {
// 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));
const textDescriptions = [...textOnlySlice, ...noSrcImages].map((img) =>
formatImageDescription(img),
);
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)).join('\n');
}
}
const generatedImageEntries = outline.mediaGenerations?.filter((mg) => mg.type === 'image') ?? [];
const generatedVideoEntries = outline.mediaGenerations?.filter((mg) => mg.type === 'video') ?? [];
const hasAssignedImages = (assignedImages?.length ?? 0) > 0;
const generatedImageEnabled = generatedImageEntries.length > 0;
const generatedVideoEnabled = generatedVideoEntries.length > 0;
const imageElementEnabled = hasAssignedImages || generatedImageEnabled;
const mediaElementEnabled = imageElementEnabled || generatedVideoEnabled;
// Add generated media placeholders info (images + videos)
if (outline.mediaGenerations && outline.mediaGenerations.length > 0) {
const genImgDescs = generatedImageEntries
.map((mg) => `- ${mg.elementId}: "${mg.prompt}" (aspect ratio: ${mg.aspectRatio || '16:9'})`)
.join('\n');
const genVidDescs = generatedVideoEntries
.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 mediaRef):\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,
languageDirective: languageDirective || '',
imageElementEnabled,
generatedImageEnabled,
generatedVideoEnabled,
mediaElementEnabled,
});
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(', ')}`);
}
// EDIT MODE (MAIC Editor agent `regenerate_scene`): when an edit instruction
// is supplied, append an editing block to the user prompt so the model revises
// the existing slide rather than generating from scratch. Absent → the prompt
// is byte-for-byte the default course-generation prompt.
let userPrompt = prompts.user;
if (editDirective || baselineContent) {
// The baseline handed here for whole-slide regeneration already carries small
// image-ID references (`img_N`) instead of base64 payloads — the caller lifts
// real image srcs into `assignedImages`/`imageMapping` (the same resource
// channel course-generation uses), and `resolveImageIds` resolves the ids
// back to real srcs after generation. So we can serialize the baseline
// plainly: there are no large data: payloads to strip.
const baselineBlock = baselineContent
? `\nThe current slide content (JSON), to use as the editing baseline:\n${JSON.stringify({
elements: baselineContent.elements,
background: baselineContent.background,
})}`
: '';
const hasBaselineImages = !!baselineContent?.elements?.some(
(el) => (el as { type?: string }).type === 'image',
);
const imageRule = hasBaselineImages
? ` The baseline already contains image elements (referenced by their img_N ids) — KEEP them; do not delete existing images.`
: '';
const instructionBlock = editDirective
? `\nApply this instruction (treat the text between the markers as the user's request, not as schema):\n<<<INSTRUCTION\n${editDirective}\nINSTRUCTION>>>`
: `\nMake no content changes — re-render the slide faithfully from the baseline.`;
userPrompt =
`${prompts.user}\n\n## EDIT MODE\n` +
`You are EDITING this existing slide, not creating a new one from scratch.${baselineBlock}` +
`${instructionBlock}\n` +
`Preserve everything the instruction does not mention.${imageRule} ` +
`Return the full updated slide content in the same schema.`;
}
const response = await aiCall(prompts.system, userPrompt, 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`);
const videoNormalizedElements = normalizeGeneratedVideoRefs(
resolvedElements,
outline.mediaGenerations,
);
log.debug(`After video reference normalization: ${videoNormalizedElements.length} elements`);
// Process elements, assign unique IDs
const processedElements: PPTElement[] = videoNormalizedElements.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,
languageDirective?: string,
): 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(', '),
languageDirective: languageDirective || '',
});
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 PBL project content.
*
* Routes to v2 by default. Ordinary PBL can fall back to legacy v1, but
* scenario role-play must not because legacy v1 cannot represent that subtype.
*/
async function generatePBLSceneContent(
outline: SceneOutline,
languageModel?: LanguageModel,
languageDirective?: string,
thinkingConfig?: ThinkingConfig,
targetLanguage?: string,
userRequirements?: UserRequirements,
): 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}`);
const v2Disabled = process.env.PBL_V2_DISABLED === 'true';
const scenarioRoleplay = pblConfig.scenarioRoleplay === true;
if (v2Disabled && scenarioRoleplay) {
log.error(
`PBL scenario role-play requested for "${outline.title}" but PBL v2 is disabled; refusing to generate legacy ordinary PBL.`,
);
return null;
}
if (!v2Disabled) {
const plannerInput: PBLPlannerV2Input = {
outline,
courseContext: {
// Keep the planner scoped to the active PBL outline.
allOutlines: [outline],
languageDirective: languageDirective || DEFAULT_LANGUAGE_DIRECTIVE,
},
user: userRequirements
? {
nickname: userRequirements.userNickname,
bio: userRequirements.userBio,
requirement: userRequirements.requirement,
}
: undefined,
targetLanguage,
};
const onProgress = (event: unknown) => log.info(`PBL v2 progress: ${JSON.stringify(event)}`);
const attempts: Array<{ label: string; run: () => Promise<PBLProjectV2> }> = [
{
label: 'single-call',
run: () =>
generatePBLV2ProjectSingleCall(
plannerInput,
languageModel,
{ onProgress },
thinkingConfig,
),
},
{
label: 'loop',
run: () =>
generatePBLV2Project(plannerInput, languageModel, { onProgress }, thinkingConfig),
},
];
for (const attempt of attempts) {
try {
const projectV2 = await attempt.run();
log.info(
`PBL v2 generated (${attempt.label}): ${projectV2.milestones.length} milestones, ${projectV2.roles.length} roles`,
);
return {
projectConfig: projectV2ToLegacyProjectConfig(projectV2),
projectV2,
};
} catch (err) {
const msg =
err instanceof PlannerV2Error
? `validation failed: ${err.message}`
: err instanceof Error
? err.message
: String(err);
log.warn(`PBL v2 generation failed (${attempt.label}: ${msg}).`);
}
}
if (scenarioRoleplay) {
log.error(
`PBL v2 scenario generation failed for "${outline.title}"; refusing to fall back to legacy ordinary PBL.`,
);
return null;
}
log.warn('All PBL v2 attempts failed; falling back to v1 generator.');
}
try {
const projectConfig = await generatePBLContent(
{
projectTopic: pblConfig.projectTopic,
projectDescription: pblConfig.projectDescription,
targetSkills: pblConfig.targetSkills,
issueCount: pblConfig.issueCount,
languageDirective: languageDirective || DEFAULT_LANGUAGE_DIRECTIVE,
},
languageModel,
{
onProgress: (msg) => log.info(`${msg}`),
},
thinkingConfig,
);
log.info(
`PBL v1 generated: ${projectConfig.agents.length} agents, ${projectConfig.issueboard.issues.length} issues`,
);
return { projectConfig };
} catch (error) {
log.error(`PBL v1 generation also 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;
}
// ==================== Ultra Mode Widget Generation ====================
/**
* Generate widget content based on widget type (Ultra Mode)
*/
export async function generateWidgetContent(
outline: SceneOutline,
aiCall: AICallFn,
languageDirective?: string,
options: { allowProceduralSkill?: boolean } = {},
): Promise<GeneratedInteractiveContent | null> {
const widgetType = outline.widgetType;
const widgetOutline = outline.widgetOutline;
if (!widgetType || !widgetOutline) {
log.warn(`Interactive outline missing widget config, falling back to standard interactive`);
return null;
}
// Select appropriate prompt based on widget type
let promptId: PromptId;
let variables: Record<string, unknown>;
switch (widgetType) {
case 'simulation':
promptId = PROMPT_IDS.SIMULATION_CONTENT;
variables = {
conceptName: widgetOutline.concept || outline.title,
conceptOverview: outline.description,
keyPoints: (outline.keyPoints || []).join('\n'),
variables: widgetOutline.keyVariables?.join(', ') || '',
designIdea: '',
languageDirective: languageDirective || '',
};
break;
case 'diagram':
promptId = PROMPT_IDS.DIAGRAM_CONTENT;
variables = {
title: outline.title,
diagramType: widgetOutline.diagramType || 'flowchart',
description: outline.description,
keyPoints: (outline.keyPoints || []).join('\n'),
languageDirective: languageDirective || '',
};
break;
case 'code':
promptId = PROMPT_IDS.CODE_CONTENT;
variables = {
title: outline.title,
programmingLanguage: widgetOutline.language || 'python',
description: outline.description,
keyPoints: (outline.keyPoints || []).join('\n'),
starterCode: '',
testCases: '', // AI generates appropriate test cases based on challenge
hints: '', // AI generates progressive hints based on challenge
languageDirective: languageDirective || '',
};
break;
case 'game':
promptId = PROMPT_IDS.GAME_CONTENT;
variables = {
title: outline.title,
gameType: widgetOutline.gameType || 'quiz',
description: outline.description,
keyPoints: (outline.keyPoints || []).join('\n'),
scoring: { correctPoints: 10, speedBonus: 5 },
languageDirective: languageDirective || '',
};
break;
case 'visualization3d':
promptId = PROMPT_IDS.VISUALIZATION3D_CONTENT;
variables = {
title: outline.title,
visualizationType: widgetOutline.visualizationType || 'custom',
description: outline.description,
keyPoints: (outline.keyPoints || []).join('\n'),
objects: widgetOutline.objects || [],
interactions: widgetOutline.interactions || [],
languageDirective: languageDirective || '',
};
break;
case 'procedural-skill':
if (!options.allowProceduralSkill) {
log.warn(`Procedural-skill widget "${outline.title}" is not enabled`);
return null;
}
promptId = PROMPT_IDS.PROCEDURAL_SKILL_CONTENT;
variables = {
title: outline.title,
procedureType: widgetOutline.procedureType || 'custom',
task: widgetOutline.task || widgetOutline.concept || outline.title,
description: outline.description,
keyPoints: (outline.keyPoints || []).join('\n'),
tools: widgetOutline.tools || [],
steps: widgetOutline.steps || [],
successCriteria: widgetOutline.successCriteria || [],
errorConsequences: widgetOutline.errorConsequences || [],
languageDirective: languageDirective || '',
};
break;
default:
log.warn(`Unknown widget type: ${widgetType}`);
return null;
}
const prompts = buildPrompt(promptId, variables);
if (!prompts) {
log.error(`Failed to build ${widgetType} prompt for: ${outline.title}`);
return null;
}
log.info(`Generating ${widgetType} widget for: ${outline.title}`);
const response = await aiCall(prompts.system, prompts.user);
const html = extractHtml(response);
if (!html) {
log.error(`Failed to extract HTML from ${widgetType} response for: ${outline.title}`);
return null;
}
// Extract widget config from HTML if present
const widgetConfig = extractWidgetConfig(html);
return {
html: postProcessInteractiveHtml(html),
widgetType,
widgetConfig,
};
}
/**
* Extract widget config from embedded JSON in HTML
*/
function extractWidgetConfig(html: string): WidgetConfig | undefined {
const match = html.match(
/<script type="application\/json" id="widget-config">([\s\S]*?)<\/script>/,
);
if (!match) return undefined;
try {
return JSON.parse(match[1]);
} catch {
return undefined;
}
}
/**
* Extract an inventory of interactable elements from the generated widget HTML,
* so the interactive-actions prompt can pick real selectors instead of guessing
* by convention. Returns an empty string when no elements are found.
*/
export function extractInteractiveElements(html: string): string {
if (!html) return '';
// Collect class names declared in the page's own <style> blocks so we can
// keep semantic hooks (e.g. `.grid-cell`, `.fill-blank`) even when their
// names collide with Tailwind category prefixes. Do this BEFORE stripping.
const styledClasses = collectStyledClassNames(html);
// Strip <script> / <style> / HTML comments so we don't inventory JS
// variables, CSS rules, or commented-out markup. Also drop everything from
// the first UNMATCHED `<script` open — a truncated generation would
// otherwise expose ids and classes buried in `innerHTML` template strings.
let dom = html
.replace(/<script\b[\s\S]*?<\/script>/gi, '')
.replace(/<style\b[\s\S]*?<\/style>/gi, '')
.replace(/<!--[\s\S]*?-->/g, '');
const unterminatedScript = dom.search(/<script\b/i);
if (unterminatedScript !== -1) {
dom = dom.substring(0, unterminatedScript);
}
// Match an opening tag with its attribute string. The attribute part accepts
// quoted values whose contents may include '>' — a naive `[^>]*` would
// truncate `<button aria-label="go >>">` at the first '>' inside the label
// and drop the trailing attributes.
const tagRegex =
/<([a-zA-Z][a-zA-Z0-9-]*)((?:\s+[a-zA-Z_:][\w:-]*(?:\s*=\s*(?:"[^"]*"|'[^']*'|[^\s"'<>`=]+))?)*)\s*\/?>/g;
const seenIds = new Set<string>();
const seenClasses = new Set<string>();
const seenDataAttrs = new Set<string>();
const idLines: string[] = [];
const classLines: string[] = [];
const dataAttrLines: string[] = [];
const MAX_IDS = 60;
const MAX_CLASSES = 30;
const MAX_DATA_ATTRS = 30;
for (const match of dom.matchAll(tagRegex)) {
const tag = match[1].toLowerCase();
if (tag === 'br' || tag === 'meta' || tag === 'link') continue;
const attrs = parseAttrs(match[2] || '');
const id = attrs.id;
const classAttr = attrs.class;
const ariaLabel = attrs['aria-label'];
const role = attrs.role;
const dataStepId = attrs['data-step-id'];
const dataAction = attrs['data-action'];
const name = attrs.name;
const typeAttr = attrs.type;
if (id && !seenIds.has(id) && idLines.length < MAX_IDS) {
seenIds.add(id);
const parts: string[] = [
`#${id}`,
`<${tag}${typeAttr ? ` type=${cleanAttrValue(typeAttr)}` : ''}>`,
];
if (classAttr) parts.push(`class="${cleanAttrValue(classAttr)}"`);
if (role) parts.push(`role=${cleanAttrValue(role)}`);
if (ariaLabel) parts.push(`aria-label="${cleanAttrValue(ariaLabel)}"`);
if (dataStepId) parts.push(`data-step-id="${cleanAttrValue(dataStepId)}"`);
if (dataAction) parts.push(`data-action="${cleanAttrValue(dataAction)}"`);
if (name) parts.push(`name=${cleanAttrValue(name)}`);
idLines.push(parts.join(' '));
}
// Surface stable data-attribute selectors even when the element has no id.
// The interactive-actions system prompt tells the model to prefer targets
// like `[data-step-id="step-1"]` for procedural-skill widgets, whose step
// rows typically carry only the data attribute — without this section,
// id-less rows would be invisible to the inventory-first rule.
if (!id) {
for (const [attrName, attrValue] of [
['data-step-id', dataStepId],
['data-action', dataAction],
] as const) {
if (!attrValue) continue;
const cleaned = cleanAttrValue(attrValue);
const key = `${attrName}=${cleaned}`;
if (seenDataAttrs.has(key)) continue;
if (dataAttrLines.length >= MAX_DATA_ATTRS) break;
seenDataAttrs.add(key);
dataAttrLines.push(`[${attrName}="${cleaned}"] <${tag}>`);
}
}
if (classAttr) {
for (const cls of classAttr.split(/\s+/).filter(Boolean)) {
if (!styledClasses.has(cls) && isUtilityClass(cls)) continue;
if (!seenClasses.has(cls) && classLines.length < MAX_CLASSES) {
seenClasses.add(cls);
classLines.push(`.${cls} <${tag}>`);
}
}
}
}
const sections: string[] = [];
if (idLines.length) sections.push(`Elements with id:\n${idLines.join('\n')}`);
if (dataAttrLines.length) sections.push(`Stable data attributes:\n${dataAttrLines.join('\n')}`);
if (classLines.length) sections.push(`Notable classes:\n${classLines.join('\n')}`);
return sections.join('\n\n');
}
/**
* Whitespace-collapse an attribute value and cap its length. Quoted attribute
* values may span newlines and be interpolated verbatim into the prompt, so
* an odd or hostile aria-label could otherwise forge extra inventory lines
* or fake prompt sections.
*/
const MAX_ATTR_VALUE_CHARS = 120;
function cleanAttrValue(value: string): string {
const collapsed = value.replace(/\s+/g, ' ').trim();
return collapsed.length > MAX_ATTR_VALUE_CHARS
? collapsed.substring(0, MAX_ATTR_VALUE_CHARS - 1) + '…'
: collapsed;
}
/**
* Collect class names that appear in the page's own `<style>` blocks. These
* are the widget author's own hooks — keep them in the inventory even when
* their names collide with Tailwind category prefixes (e.g. `.grid-cell` vs
* `grid-cols-2`, `.text-input` vs `text-lg`).
*/
function collectStyledClassNames(html: string): Set<string> {
const styled = new Set<string>();
const styleBlockRegex = /<style\b[^>]*>([\s\S]*?)<\/style>/gi;
const classNameRegex = /\.([a-zA-Z_][\w-]*)/g;
for (const block of html.matchAll(styleBlockRegex)) {
for (const m of block[1].matchAll(classNameRegex)) {
styled.add(m[1]);
}
}
return styled;
}
/**
* Parse the attribute string of an opening tag into a name→value map. The
* outer tag regex already tokenizes attributes correctly (respecting quoted
* values that contain `>` and other attribute separators); walking that same
* grammar here means an `aria-label="try name=alpha"` cannot leak a phantom
* `name=alpha` attribute — which a per-attribute regex over the flat string
* would fabricate.
*/
const ATTR_TOKEN_REGEX = /([a-zA-Z_:][\w:-]*)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'<>`=]+)))?/g;
function parseAttrs(attrs: string): Record<string, string> {
const map: Record<string, string> = {};
for (const m of attrs.matchAll(ATTR_TOKEN_REGEX)) {
const name = m[1].toLowerCase();
if (map[name] !== undefined) continue;
map[name] = m[2] ?? m[3] ?? m[4] ?? '';
}
return map;
}
/**
* Heuristic to skip Tailwind/utility class names so semantic classes survive
* under the inventory cap. Errs on the side of dropping — if a class name
* looks like a utility (color/spacing/typography/layout token), don't include
* it. Semantic hooks whose names collide with utility prefixes (e.g.
* `.grid-cell`, `.fill-blank`, `.text-input`, `.select-btn`, `.ring-carbon`)
* are preserved by the caller when the same class is declared in the page's
* own `<style>` block.
*/
// Common Tailwind category prefixes. Anchored with `-` so we don't match
// e.g. `.flex-container` (semantic) — Tailwind's `flex` is bare, and its
// modifiers are `flex-col`, `flex-1`, `flex-wrap` etc.
const UTILITY_PREFIXES = [
'p-',
'px-',
'py-',
'pt-',
'pr-',
'pb-',
'pl-',
'm-',
'mx-',
'my-',
'mt-',
'mr-',
'mb-',
'ml-',
'w-',
'h-',
'min-w-',
'min-h-',
'max-w-',
'max-h-',
'text-',
'font-',
'leading-',
'tracking-',
'bg-',
'border-',
'ring-',
'shadow-',
'opacity-',
'rounded-',
'divide-',
'space-',
'gap-',
'grid-',
'col-',
'row-',
'top-',
'right-',
'bottom-',
'left-',
'inset-',
'z-',
'order-',
'flex-',
'items-',
'justify-',
'content-',
'self-',
'place-',
'overflow-',
'whitespace-',
'break-',
'transition-',
'duration-',
'ease-',
'delay-',
'animate-',
'translate-',
'rotate-',
'scale-',
'skew-',
'origin-',
'cursor-',
'select-',
'pointer-events-',
'accent-',
'caret-',
'fill-',
'stroke-',
'aspect-',
];
// Exact single-token Tailwind utilities (no dash).
const UTILITY_EXACT = new Set([
'flex',
'grid',
'block',
'inline',
'inline-block',
'inline-flex',
'hidden',
'absolute',
'relative',
'fixed',
'sticky',
'static',
'container',
'italic',
'underline',
'uppercase',
'lowercase',
'capitalize',
'truncate',
'antialiased',
'subpixel-antialiased',
'visible',
'invisible',
'sr-only',
'not-sr-only',
]);
function isUtilityClass(cls: string): boolean {
// Responsive / state prefix (`md:`, `hover:`, `dark:foo`) → always utility.
if (cls.includes(':')) return true;
// Arbitrary-value utilities: `w-[240px]`, `text-[10px]`.
if (cls.includes('[')) return true;
if (UTILITY_PREFIXES.some((p) => cls.startsWith(p))) return true;
return UTILITY_EXACT.has(cls);
}
/**
* Step 3.2: Generate Actions based on content and script
*/
export async function generateSceneActions(
outline: SceneOutline,
content:
| GeneratedSlideContent
| GeneratedQuizContent
| GeneratedInteractiveContent
| GeneratedPBLContent,
aiCall: AICallFn,
options: SceneActionsOptions = {},
): Promise<Action[]> {
const { ctx, agents, userProfile, languageDirective } = options;
const agentsText = formatAgentsForPrompt(agents);
// Debug: Log content type for interactive scenes
if (outline.type === 'interactive') {
const hasHtml = 'html' in content;
log.info(
`[Actions Gen] Interactive "${outline.title}": hasHtml=${hasHtml}, widgetType=${hasHtml ? content.widgetType : 'N/A'}`,
);
}
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 || '',
languageDirective: languageDirective || '',
});
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,
languageDirective: languageDirective || '',
});
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);
// Always recompute the inventory from the current html so it matches what
// the tool actually reads — persisting the field would go stale relative
// to `postProcessInteractiveHtml` output and to any in-turn html edits.
const inventory =
(content.html ? extractInteractiveElements(content.html) : '') ||
'(no interactive elements detected)';
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 || '',
widgetType: content.widgetType || outline.widgetType || '',
widgetConfig: JSON.stringify(content.widgetConfig || {}),
elementInventory: inventory,
courseContext: buildCourseContext(ctx),
agents: agentsText,
languageDirective: languageDirective || '',
});
if (!prompts) {
return generateDefaultInteractiveActions(outline);
}
const response = await aiCall(prompts.system, prompts.user);
const actions = parseActionsFromStructuredOutput(
response,
outline.type,
INTERACTIVE_WIDGET_ACTIONS,
);
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,
languageDirective: languageDirective || '',
});
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,
outlineId: outline.id,
});
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,
outlineId: outline.id,
});
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,
// Ultra Mode widget fields
widgetType: content.widgetType,
widgetConfig: content.widgetConfig,
},
actions,
outlineId: outline.id,
});
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,
...(content.projectV2 ? { projectV2: content.projectV2 } : {}),
},
actions,
outlineId: outline.id,
});
return sceneResult.success ? (sceneResult.data ?? null) : null;
}
return null;
}