import { nanoid } from 'nanoid'; import { callLLM } from '@/lib/ai/llm'; import { createStageAPI } from '@/lib/api/stage-api'; import type { StageStore } from '@/lib/api/stage-api-types'; import { applyOutlineFallbacks, generateSceneOutlinesFromRequirements, } from '@/lib/generation/outline-generator'; import { createSceneWithActions, generateSceneActions, generateSceneContent, } from '@/lib/generation/scene-generator'; import type { AICallFn } from '@/lib/generation/pipeline-types'; import type { AgentInfo } from '@/lib/generation/pipeline-types'; import { formatTeacherPersonaForPrompt } from '@/lib/generation/prompt-formatters'; import { getDefaultAgents } from '@/lib/orchestration/registry/store'; import { createLogger } from '@/lib/logger'; import { parseModelString } from '@/lib/ai/providers'; import { resolveApiKey, resolveWebSearchApiKey } from '@/lib/server/provider-config'; import { resolveModel } from '@/lib/server/resolve-model'; import { searchWithTavily, formatSearchResultsAsContext } from '@/lib/web-search/tavily'; import { persistClassroom } from '@/lib/server/classroom-storage'; import { generateMediaForClassroom, replaceMediaPlaceholders, generateTTSForClassroom, } from '@/lib/server/classroom-media-generation'; import type { UserRequirements } from '@/lib/types/generation'; import type { Scene, Stage } from '@/lib/types/stage'; const log = createLogger('Classroom'); export interface GenerateClassroomInput { requirement: string; pdfContent?: { text: string; images: string[] }; language?: string; enableWebSearch?: boolean; enableImageGeneration?: boolean; enableVideoGeneration?: boolean; enableTTS?: boolean; agentMode?: 'default' | 'generate'; } export type ClassroomGenerationStep = | 'initializing' | 'researching' | 'generating_outlines' | 'generating_scenes' | 'generating_media' | 'generating_tts' | 'persisting' | 'completed'; export interface ClassroomGenerationProgress { step: ClassroomGenerationStep; progress: number; message: string; scenesGenerated: number; totalScenes?: number; } export interface GenerateClassroomResult { id: string; url: string; stage: Stage; scenes: Scene[]; scenesCount: number; createdAt: string; } function createInMemoryStore(stage: Stage): StageStore { let state = { stage: stage as Stage | null, scenes: [] as Scene[], currentSceneId: null as string | null, mode: 'playback' as const, }; const listeners: Array<(s: typeof state, prev: typeof state) => void> = []; return { getState: () => state, setState: (partial: Partial) => { const prev = state; state = { ...state, ...partial }; listeners.forEach((fn) => fn(state, prev)); }, subscribe: (listener: (s: typeof state, prev: typeof state) => void) => { listeners.push(listener); return () => { const idx = listeners.indexOf(listener); if (idx >= 0) listeners.splice(idx, 1); }; }, }; } function normalizeLanguage(language?: string): 'zh-CN' | 'en-US' { return language === 'en-US' ? 'en-US' : 'zh-CN'; } function stripCodeFences(text: string): string { let cleaned = text.trim(); if (cleaned.startsWith('```')) { cleaned = cleaned.replace(/^```(?:json)?\s*\n?/, '').replace(/\n?```\s*$/, ''); } return cleaned.trim(); } async function generateAgentProfiles( requirement: string, language: string, aiCall: AICallFn, ): Promise { const systemPrompt = 'You are an expert instructional designer. Generate agent profiles for a multi-agent classroom simulation. Return ONLY valid JSON, no markdown or explanation.'; const userPrompt = `Generate agent profiles for a course with this requirement: ${requirement} Requirements: - Decide the appropriate number of agents based on the course content (typically 3-5) - Exactly 1 agent must have role "teacher", the rest can be "assistant" or "student" - Each agent needs: name, role, persona (2-3 sentences describing personality and teaching/learning style) - Names and personas must be in language: ${language} Return a JSON object with this exact structure: { "agents": [ { "name": "string", "role": "teacher" | "assistant" | "student", "persona": "string (2-3 sentences)" } ] }`; const response = await aiCall(systemPrompt, userPrompt); const rawText = stripCodeFences(response); const parsed = JSON.parse(rawText) as { agents: Array<{ name: string; role: string; persona: string }>; }; if (!parsed.agents || !Array.isArray(parsed.agents) || parsed.agents.length < 2) { throw new Error(`Expected at least 2 agents, got ${parsed.agents?.length ?? 0}`); } const teacherCount = parsed.agents.filter((a) => a.role === 'teacher').length; if (teacherCount !== 1) { throw new Error(`Expected exactly 1 teacher, got ${teacherCount}`); } return parsed.agents.map((a, i) => ({ id: `gen-server-${i}`, name: a.name, role: a.role, persona: a.persona, })); } export async function generateClassroom( input: GenerateClassroomInput, options: { baseUrl: string; onProgress?: (progress: ClassroomGenerationProgress) => Promise | void; }, ): Promise { const { requirement, pdfContent } = input; await options.onProgress?.({ step: 'initializing', progress: 5, message: 'Initializing classroom generation', scenesGenerated: 0, }); const { model: languageModel, modelInfo, modelString } = resolveModel({}); log.info(`Using server-configured model: ${modelString}`); // Fail fast if the resolved provider has no API key configured const { providerId } = parseModelString(modelString); const apiKey = resolveApiKey(providerId); if (!apiKey) { throw new Error( `No API key configured for provider "${providerId}". ` + `Set the appropriate key in .env.local or server-providers.yml (e.g. ${providerId.toUpperCase()}_API_KEY).`, ); } const aiCall: AICallFn = async (systemPrompt, userPrompt, _images) => { const result = await callLLM( { model: languageModel, messages: [ { role: 'system', content: systemPrompt }, { role: 'user', content: userPrompt }, ], maxOutputTokens: modelInfo?.outputWindow, }, 'generate-classroom', ); return result.text; }; const lang = normalizeLanguage(input.language); const requirements: UserRequirements = { requirement, language: lang, }; const pdfText = pdfContent?.text || undefined; // Resolve agents based on agentMode let agents: AgentInfo[]; const agentMode = input.agentMode || 'default'; if (agentMode === 'generate') { log.info('Generating custom agent profiles via LLM...'); try { agents = await generateAgentProfiles(requirement, lang, aiCall); log.info(`Generated ${agents.length} agent profiles`); } catch (e) { log.warn('Agent profile generation failed, falling back to defaults:', e); agents = getDefaultAgents(); } } else { agents = getDefaultAgents(); } const teacherContext = formatTeacherPersonaForPrompt(agents); await options.onProgress?.({ step: 'researching', progress: 10, message: 'Researching topic', scenesGenerated: 0, }); // Web search (optional, graceful degradation) let researchContext: string | undefined; if (input.enableWebSearch) { const tavilyKey = resolveWebSearchApiKey(); if (tavilyKey) { try { log.info('Running web search for requirement context...'); const searchResult = await searchWithTavily({ query: requirement, apiKey: tavilyKey }); researchContext = formatSearchResultsAsContext(searchResult); if (researchContext) { log.info(`Web search returned ${searchResult.sources.length} sources`); } } catch (e) { log.warn('Web search failed, continuing without search context:', e); } } else { log.warn('enableWebSearch is true but no Tavily API key configured, skipping web search'); } } await options.onProgress?.({ step: 'generating_outlines', progress: 15, message: 'Generating scene outlines', scenesGenerated: 0, }); const outlinesResult = await generateSceneOutlinesFromRequirements( requirements, pdfText, undefined, aiCall, undefined, { imageGenerationEnabled: input.enableImageGeneration, videoGenerationEnabled: input.enableVideoGeneration, researchContext, teacherContext, }, ); if (!outlinesResult.success || !outlinesResult.data) { log.error('Failed to generate outlines:', outlinesResult.error); throw new Error(outlinesResult.error || 'Failed to generate scene outlines'); } const outlines = outlinesResult.data; log.info(`Generated ${outlines.length} scene outlines`); await options.onProgress?.({ step: 'generating_outlines', progress: 30, message: `Generated ${outlines.length} scene outlines`, scenesGenerated: 0, totalScenes: outlines.length, }); const stageId = nanoid(10); const stage: Stage = { id: stageId, name: outlines[0]?.title || requirement.slice(0, 50), description: undefined, language: lang, style: 'interactive', createdAt: Date.now(), updatedAt: Date.now(), }; const store = createInMemoryStore(stage); const api = createStageAPI(store); log.info('Stage 2: Generating scene content and actions...'); let generatedScenes = 0; for (const [index, outline] of outlines.entries()) { const safeOutline = applyOutlineFallbacks(outline, true); const progressStart = 30 + Math.floor((index / Math.max(outlines.length, 1)) * 60); await options.onProgress?.({ step: 'generating_scenes', progress: Math.max(progressStart, 31), message: `Generating scene ${index + 1}/${outlines.length}: ${safeOutline.title}`, scenesGenerated: generatedScenes, totalScenes: outlines.length, }); const content = await generateSceneContent( safeOutline, aiCall, undefined, undefined, undefined, undefined, undefined, agents, ); if (!content) { log.warn(`Skipping scene "${safeOutline.title}" — content generation failed`); continue; } const actions = await generateSceneActions(safeOutline, content, aiCall, undefined, agents); log.info(`Scene "${safeOutline.title}": ${actions.length} actions`); const sceneId = createSceneWithActions(safeOutline, content, actions, api); if (!sceneId) { log.warn(`Skipping scene "${safeOutline.title}" — scene creation failed`); continue; } generatedScenes += 1; const progressEnd = 30 + Math.floor(((index + 1) / Math.max(outlines.length, 1)) * 60); await options.onProgress?.({ step: 'generating_scenes', progress: Math.min(progressEnd, 90), message: `Generated ${generatedScenes}/${outlines.length} scenes`, scenesGenerated: generatedScenes, totalScenes: outlines.length, }); } const scenes = store.getState().scenes; log.info(`Pipeline complete: ${scenes.length} scenes generated`); if (scenes.length === 0) { throw new Error('No scenes were generated'); } // Phase: Media generation (after all scenes generated) if (input.enableImageGeneration || input.enableVideoGeneration) { await options.onProgress?.({ step: 'generating_media', progress: 90, message: 'Generating media files', scenesGenerated: scenes.length, totalScenes: outlines.length, }); try { const mediaMap = await generateMediaForClassroom(outlines, stageId, options.baseUrl); replaceMediaPlaceholders(scenes, mediaMap); log.info(`Media generation complete: ${Object.keys(mediaMap).length} files`); } catch (err) { log.warn('Media generation phase failed, continuing:', err); } } // Phase: TTS generation if (input.enableTTS) { await options.onProgress?.({ step: 'generating_tts', progress: 94, message: 'Generating TTS audio', scenesGenerated: scenes.length, totalScenes: outlines.length, }); try { await generateTTSForClassroom(scenes, stageId, options.baseUrl); log.info('TTS generation complete'); } catch (err) { log.warn('TTS generation phase failed, continuing:', err); } } await options.onProgress?.({ step: 'persisting', progress: 98, message: 'Persisting classroom data', scenesGenerated: scenes.length, totalScenes: outlines.length, }); const persisted = await persistClassroom( { id: stageId, stage, scenes, }, options.baseUrl, ); log.info(`Classroom persisted: ${persisted.id}, URL: ${persisted.url}`); await options.onProgress?.({ step: 'completed', progress: 100, message: 'Classroom generation completed', scenesGenerated: scenes.length, totalScenes: outlines.length, }); return { id: persisted.id, url: persisted.url, stage, scenes, scenesCount: scenes.length, createdAt: persisted.createdAt, }; }