| |
| |
| |
| |
|
|
| import { nanoid } from 'nanoid'; |
| import { MAX_PDF_CONTENT_CHARS, MAX_VISION_IMAGES } from '@/lib/constants/generation'; |
| import type { |
| UserRequirements, |
| SceneOutline, |
| PdfImage, |
| ImageMapping, |
| } from '@/lib/types/generation'; |
| import { buildPrompt, PROMPT_IDS } from '@/lib/prompts'; |
| import { formatImageDescription, formatImagePlaceholder } from './prompt-formatters'; |
| import { parseJsonResponse } from './json-repair'; |
| import { uniquifyMediaElementIds } from './scene-builder'; |
| import type { AICallFn, GenerationResult, GenerationCallbacks } from './pipeline-types'; |
| import { createLogger } from '@/lib/logger'; |
| const log = createLogger('Generation'); |
|
|
| |
| |
| |
| |
| |
| |
| export const DEFAULT_LANGUAGE_DIRECTIVE = |
| 'Teach in the language that matches the user requirement.'; |
|
|
| |
| |
| |
| |
| export async function generateSceneOutlinesFromRequirements( |
| requirements: UserRequirements, |
| pdfText: string | undefined, |
| pdfImages: PdfImage[] | undefined, |
| aiCall: AICallFn, |
| callbacks?: GenerationCallbacks, |
| options?: { |
| visionEnabled?: boolean; |
| imageMapping?: ImageMapping; |
| imageGenerationEnabled?: boolean; |
| videoGenerationEnabled?: boolean; |
| researchContext?: string; |
| teacherContext?: string; |
| }, |
| ): Promise<GenerationResult<{ languageDirective: string; outlines: SceneOutline[] }>> { |
| |
| let availableImagesText = 'No images available'; |
| let visionImages: Array<{ id: string; src: string }> | undefined; |
|
|
| if (pdfImages && pdfImages.length > 0) { |
| if (options?.visionEnabled && options?.imageMapping) { |
| |
| const allWithSrc = pdfImages.filter((img) => options.imageMapping![img.id]); |
| const visionSlice = allWithSrc.slice(0, MAX_VISION_IMAGES); |
| const textOnlySlice = allWithSrc.slice(MAX_VISION_IMAGES); |
| const noSrcImages = pdfImages.filter((img) => !options.imageMapping![img.id]); |
|
|
| const visionDescriptions = visionSlice.map((img) => formatImagePlaceholder(img)); |
| const textDescriptions = [...textOnlySlice, ...noSrcImages].map((img) => |
| formatImageDescription(img), |
| ); |
| availableImagesText = [...visionDescriptions, ...textDescriptions].join('\n'); |
|
|
| visionImages = visionSlice.map((img) => ({ |
| id: img.id, |
| src: options.imageMapping![img.id], |
| width: img.width, |
| height: img.height, |
| })); |
| } else { |
| |
| availableImagesText = pdfImages.map((img) => formatImageDescription(img)).join('\n'); |
| } |
| } |
|
|
| |
| const userProfileText = |
| requirements.userNickname || requirements.userBio |
| ? `## Student Profile\n\nStudent: ${requirements.userNickname || 'Unknown'}${requirements.userBio ? ` — ${requirements.userBio}` : ''}\n\nConsider this student's background when designing the course. Adapt difficulty, examples, and teaching approach accordingly.\n\n---` |
| : ''; |
|
|
| |
| const imageEnabled = options?.imageGenerationEnabled ?? false; |
| const videoEnabled = options?.videoGenerationEnabled ?? false; |
| const mediaEnabled = imageEnabled || videoEnabled; |
| const hasSourceImages = (pdfImages?.length ?? 0) > 0; |
|
|
| |
| const prompts = buildPrompt(PROMPT_IDS.REQUIREMENTS_TO_OUTLINES, { |
| |
| requirement: requirements.requirement, |
| pdfContent: pdfText ? pdfText.substring(0, MAX_PDF_CONTENT_CHARS) : 'None', |
| availableImages: availableImagesText, |
| userProfile: userProfileText, |
| hasSourceImages, |
| imageEnabled, |
| videoEnabled, |
| mediaEnabled, |
| researchContext: options?.researchContext || 'None', |
| |
| teacherContext: options?.teacherContext || '', |
| }); |
|
|
| if (!prompts) { |
| return { success: false, error: 'Prompt template not found' }; |
| } |
|
|
| try { |
| callbacks?.onProgress?.({ |
| currentStage: 1, |
| overallProgress: 20, |
| stageProgress: 50, |
| statusMessage: '正在分析需求,生成场景大纲...', |
| scenesGenerated: 0, |
| totalScenes: 0, |
| }); |
|
|
| const response = await aiCall(prompts.system, prompts.user, visionImages); |
| const parsed = parseJsonResponse< |
| { languageDirective: string; outlines: SceneOutline[] } | SceneOutline[] |
| >(response); |
|
|
| let languageDirective: string; |
| let rawOutlines: SceneOutline[]; |
|
|
| if (Array.isArray(parsed)) { |
| |
| languageDirective = DEFAULT_LANGUAGE_DIRECTIVE; |
| rawOutlines = parsed; |
| } else if (parsed && parsed.outlines) { |
| languageDirective = parsed.languageDirective || DEFAULT_LANGUAGE_DIRECTIVE; |
| rawOutlines = parsed.outlines; |
| } else { |
| return { success: false, error: 'Failed to parse scene outlines response' }; |
| } |
|
|
| if (!Array.isArray(rawOutlines)) { |
| return { success: false, error: 'Failed to parse scene outlines response' }; |
| } |
|
|
| |
| const enriched = rawOutlines.map((outline, index) => ({ |
| ...outline, |
| id: outline.id || nanoid(), |
| order: index + 1, |
| })); |
|
|
| |
| const result = uniquifyMediaElementIds(enriched); |
|
|
| callbacks?.onProgress?.({ |
| currentStage: 1, |
| overallProgress: 50, |
| stageProgress: 100, |
| statusMessage: `已生成 ${result.length} 个场景大纲`, |
| scenesGenerated: 0, |
| totalScenes: result.length, |
| }); |
|
|
| return { success: true, data: { languageDirective, outlines: result } }; |
| } catch (error) { |
| return { success: false, error: String(error) }; |
| } |
| } |
|
|
| |
| |
| |
| |
| |
| export function applyOutlineFallbacks( |
| outline: SceneOutline, |
| hasLanguageModel: boolean, |
| ): SceneOutline { |
| |
| const hasWidgetConfig = outline.widgetType && outline.widgetOutline; |
|
|
| if (outline.type === 'interactive' && !outline.interactiveConfig && !hasWidgetConfig) { |
| log.warn( |
| `Interactive outline "${outline.title}" missing interactiveConfig and widget config, falling back to slide`, |
| ); |
| return { ...outline, type: 'slide' }; |
| } |
| if (outline.type === 'pbl' && (!outline.pblConfig || !hasLanguageModel)) { |
| log.warn( |
| `PBL outline "${outline.title}" missing pblConfig or languageModel, falling back to slide`, |
| ); |
| return { ...outline, type: 'slide' }; |
| } |
| return outline; |
| } |
|
|