| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488 |
- 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 { isProviderKeyRequired } from '@/lib/ai/providers';
- import { resolveWebSearchApiKey } from '@/lib/server/provider-config';
- import { resolveModel } from '@/lib/server/resolve-model';
- import { buildSearchQuery } from '@/lib/server/search-query-builder';
- 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';
- import { AGENT_COLOR_PALETTE, AGENT_DEFAULT_AVATARS } from '@/lib/constants/agent-defaults';
- 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,
- providerId,
- apiKey,
- } = await resolveModel({});
- log.info(`Using server-configured model: ${modelString}`);
- // Fail fast if the resolved provider has no API key configured
- if (isProviderKeyRequired(providerId) && !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 searchQueryAiCall: AICallFn = async (systemPrompt, userPrompt, _images) => {
- const result = await callLLM(
- {
- model: languageModel,
- messages: [
- { role: 'system', content: systemPrompt },
- { role: 'user', content: userPrompt },
- ],
- maxOutputTokens: 256,
- },
- 'web-search-query-rewrite',
- );
- 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[];
- let 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();
- agentMode = 'default';
- }
- } 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 {
- const searchQuery = await buildSearchQuery(requirement, pdfText, searchQueryAiCall);
- log.info('Running web search for classroom generation', {
- hasPdfContext: searchQuery.hasPdfContext,
- rawRequirementLength: searchQuery.rawRequirementLength,
- rewriteAttempted: searchQuery.rewriteAttempted,
- finalQueryLength: searchQuery.finalQueryLength,
- });
- const searchResult = await searchWithTavily({
- query: searchQuery.query,
- 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(),
- // For LLM-generated agents, embed full configs so the client can
- // hydrate the agent registry without prior IndexedDB data.
- // For default agents, just record IDs — the client already has them.
- ...(agentMode === 'generate'
- ? {
- generatedAgentConfigs: agents.map((a, i) => ({
- id: a.id,
- name: a.name,
- role: a.role,
- persona: a.persona || '',
- avatar: AGENT_DEFAULT_AVATARS[i % AGENT_DEFAULT_AVATARS.length],
- color: AGENT_COLOR_PALETTE[i % AGENT_COLOR_PALETTE.length],
- priority: a.role === 'teacher' ? 10 : a.role === 'assistant' ? 7 : 5,
- })),
- }
- : {
- agentIds: agents.map((a) => a.id),
- }),
- };
- 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,
- };
- }
|