Spaces:
No application file
No application file
| 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<typeof state>) => { | |
| 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<AgentInfo[]> { | |
| 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> | void; | |
| }, | |
| ): Promise<GenerateClassroomResult> { | |
| 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, | |
| }; | |
| } | |