项目文件夹

文件
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 行
62 KiB
TypeScript

此文件含有模棱两可的 Unicode 字符
此文件含有可能会与其他字符混淆的 Unicode 字符。 如果您是想特意这样的,可以安全地忽略该警告。 使用 Escape 按钮显示他们。
/**
* 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;
}