'use client'; import { useEffect, useState, Suspense, useRef } from 'react'; import { useRouter } from 'next/navigation'; import { motion, AnimatePresence } from 'motion/react'; import { CheckCircle2, Sparkles, AlertCircle, AlertTriangle, ArrowLeft, Bot } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Card } from '@/components/ui/card'; import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; import { OutlinesEditor } from '@/components/generation/outlines-editor'; import { cn } from '@/lib/utils'; import { useStageStore } from '@/lib/store/stage'; import { useSettingsStore } from '@/lib/store/settings'; import { useAgentRegistry } from '@/lib/orchestration/registry/store'; import { getEnabledProvidersWithVoices, resolveNarratorVoiceForGeneration, } from '@/lib/audio/voice-resolver'; import { isQwenCloneVoice, resolveTTSModelForVoice } from '@/lib/audio/constants'; import { isTTSProviderEnabled } from '@/lib/audio/provider-enablement'; import { useAllVoiceProfiles } from '@/lib/audio/voxcpm-voices'; import { useI18n } from '@/lib/hooks/use-i18n'; import { fetchSceneActions, fetchSceneContent, generateTTSForScene, } from '@/lib/hooks/use-scene-generator'; import { isAbortError } from '@openmaic/generation'; import { FOREGROUND_SCENE_RETRY_OPTIONS } from './foreground-retry'; import { loadImageMapping, loadDocumentBlob, cleanupOldImages, storeImages, } from '@/lib/utils/image-storage'; import { getCurrentModelConfig } from '@/lib/utils/model-config'; import { resolveSessionDocumentSources } from '@/lib/document/session-sources'; import { MAX_VISION_IMAGES } from '@/lib/constants/generation'; import { MAX_DOCUMENT_BUNDLE_FILES, MAX_DOCUMENT_BUNDLE_TOTAL_SIZE_BYTES, buildDocumentBundle, type ParsedDocumentPart, } from '@/lib/document/bundle'; import { buildVideoManifestFromOutlines } from '@/lib/media/video-manifest'; import { nanoid } from 'nanoid'; import type { GeneratedAgentConfig, Stage } from '@/lib/types/stage'; import type { SceneOutline, PdfImage, ImageMapping, SessionDocumentSource, } from '@/lib/types/generation'; import { AgentRevealModal } from '@/components/agent/agent-reveal-modal'; import { createLogger } from '@/lib/logger'; import { type GenerationSessionState, ALL_STEPS, getActiveSteps, getGenerationStepText, } from './types'; import { StepVisualizer } from './components/visualizers'; import { resolveTaskEngineModeFromOutlineDoneEvent } from './vocational-mode'; const log = createLogger('GenerationPreview'); const OUTLINE_REVIEW_AUTO_CONTINUE_MS = 2500; type ParsedDocumentResponseImage = { id: string; src?: string; pageNumber?: number; description?: string; width?: number; height?: number; }; function validateDocumentSources( sources: SessionDocumentSource[], t: (key: string, values?: Record) => string, ) { if (sources.length > MAX_DOCUMENT_BUNDLE_FILES) { throw new Error(t('upload.courseMaterialCountLimit', { n: MAX_DOCUMENT_BUNDLE_FILES })); } const totalSize = sources.reduce((sum, source) => sum + source.size, 0); if (totalSize > MAX_DOCUMENT_BUNDLE_TOTAL_SIZE_BYTES) { throw new Error( t('upload.courseMaterialTotalSizeLimit', { n: Math.floor(MAX_DOCUMENT_BUNDLE_TOTAL_SIZE_BYTES / 1024 / 1024), }), ); } } type SceneGenerationFailure = { error?: string; errorCode?: string; statusCode?: number; }; function GenerationPreviewContent() { const router = useRouter(); const { t } = useI18n(); const hasStartedRef = useRef(false); const abortControllerRef = useRef(null); const outlineReviewTimerRef = useRef | null>(null); const outlineReviewResolveRef = useRef<((outlines: SceneOutline[]) => void) | null>(null); // Sticky flag: true once the user signals review intent (either by clicking the // streaming card mid-stream, or by restoring a session that was already in review). // Combined with `reviewOutlineEnabled` to decide whether the post-stream timer fires. const outlineReviewIntentRef = useRef(false); const { profiles: voiceProfiles } = useAllVoiceProfiles(); const [session, setSession] = useState(null); const [sessionLoaded, setSessionLoaded] = useState(false); const [error, setError] = useState(null); const [currentStepIndex, setCurrentStepIndex] = useState(0); const [isComplete] = useState(false); const [statusMessage, setStatusMessage] = useState(''); const [streamingOutlines, setStreamingOutlines] = useState(null); const [isOutlineStreaming, setIsOutlineStreaming] = useState(false); const [truncationWarnings, setTruncationWarnings] = useState([]); const [webSearchSources, setWebSearchSources] = useState>( [], ); const [showAgentReveal, setShowAgentReveal] = useState(false); const [isConfirmingOutlines, setIsConfirmingOutlines] = useState(false); const [generatedAgents, setGeneratedAgents] = useState< Array<{ id: string; name: string; role: string; persona: string; avatar: string; color: string; priority: number; }> >([]); const agentRevealResolveRef = useRef<(() => void) | null>(null); const reviewOutlineEnabled = useSettingsStore((s) => s.reviewOutlineEnabled); const setReviewOutlineEnabled = useSettingsStore((s) => s.setReviewOutlineEnabled); // Compute active steps based on session state const activeSteps = getActiveSteps(session); const isOutlineReady = session?.previewPhase === 'outline-ready'; const isReviewingOutlines = session?.previewPhase === 'review'; const sceneGenerationErrorMessage = (failure: SceneGenerationFailure): string => { if ( failure.errorCode === 'MISSING_API_KEY' || failure.statusCode === 401 || failure.statusCode === 403 ) { return t('generation.sceneGenerateAuthFailed'); } if (failure.errorCode === 'RATE_LIMITED' || failure.statusCode === 429) { return t('generation.sceneGenerateRateLimited'); } if (failure.errorCode === 'UPSTREAM_ERROR' && failure.statusCode && failure.statusCode >= 500) { return t('generation.sceneGenerateProviderUnavailable'); } if (failure.errorCode === 'INTERNAL_ERROR') { return t('generation.sceneGenerateFailed'); } if (failure.errorCode === 'GENERATION_FAILED') { return t('generation.sceneGenerateInvalidResponse'); } return failure.error || t('generation.sceneGenerateFailed'); }; const persistSession = (nextSession: GenerationSessionState) => { setSession(nextSession); sessionStorage.setItem('generationSession', JSON.stringify(nextSession)); }; const clearOutlineReviewTimer = () => { if (outlineReviewTimerRef.current) { clearTimeout(outlineReviewTimerRef.current); outlineReviewTimerRef.current = null; } }; const waitForOutlineReviewChoice = ( outlines: SceneOutline[], shouldReview: boolean, signal: AbortSignal, ): Promise => new Promise((resolve, reject) => { if (signal.aborted) { reject(new DOMException('Aborted', 'AbortError')); return; } outlineReviewResolveRef.current = resolve; // Reject on abort so navigating away (`goBackToHome`) or unmounting // settles this promise instead of leaking the awaiting startGeneration // closure. The catch at the bottom of startGeneration already swallows // AbortError silently. const onAbort = () => { clearOutlineReviewTimer(); outlineReviewResolveRef.current = null; reject(new DOMException('Aborted', 'AbortError')); }; signal.addEventListener('abort', onAbort, { once: true }); if (!shouldReview) { outlineReviewTimerRef.current = setTimeout(() => { outlineReviewTimerRef.current = null; outlineReviewResolveRef.current = null; signal.removeEventListener('abort', onAbort); resolve(outlines); }, OUTLINE_REVIEW_AUTO_CONTINUE_MS); } }); // Load session from sessionStorage useEffect(() => { cleanupOldImages(24).catch((e) => log.error(e)); const saved = sessionStorage.getItem('generationSession'); if (saved) { try { const parsed = JSON.parse(saved) as GenerationSessionState; if (!parsed.previewPhase) { parsed.previewPhase = parsed.sceneOutlines?.length ? 'outline-ready' : 'preparing'; } // Restore review intent: a saved 'review' phase without outlines means the user // had opened the editor mid-stream before the refresh — preserve that intent so // the post-stream auto-continue timer doesn't fire after SSE restart. if (parsed.previewPhase === 'review' && !parsed.sceneOutlines?.length) { outlineReviewIntentRef.current = true; } parsed.taskEngineMode = parsed.taskEngineMode === true; setSession(parsed); } catch (e) { log.error('Failed to parse generation session:', e); } } setSessionLoaded(true); }, []); // Abort all in-flight requests on unmount useEffect(() => { return () => { abortControllerRef.current?.abort(); clearOutlineReviewTimer(); }; }, []); // Get API credentials from localStorage const getApiHeaders = () => { const modelConfig = getCurrentModelConfig(); const settings = useSettingsStore.getState(); const imageProviderConfig = settings.imageProvidersConfig?.[settings.imageProviderId]; const videoProviderConfig = settings.videoProvidersConfig?.[settings.videoProviderId]; return { 'Content-Type': 'application/json', 'x-model': modelConfig.modelString, 'x-api-key': modelConfig.apiKey, 'x-base-url': modelConfig.baseUrl, 'x-provider-type': modelConfig.providerType || '', // Image generation provider 'x-image-provider': settings.imageProviderId || '', 'x-image-model': settings.imageModelId || '', 'x-image-api-key': imageProviderConfig?.apiKey || '', 'x-image-base-url': imageProviderConfig?.baseUrl || '', // Video generation provider 'x-video-provider': settings.videoProviderId || '', 'x-video-model': settings.videoModelId || '', 'x-video-api-key': videoProviderConfig?.apiKey || '', 'x-video-base-url': videoProviderConfig?.baseUrl || '', // Media generation toggles 'x-image-generation-enabled': String(settings.imageGenerationEnabled ?? false), 'x-video-generation-enabled': String(settings.videoGenerationEnabled ?? false), }; }; const withThinkingConfig = >(body: T) => { const { thinkingConfig } = getCurrentModelConfig(); return thinkingConfig ? { ...body, thinkingConfig } : body; }; // Auto-start generation when session is loaded useEffect(() => { if (!session || hasStartedRef.current) return; const needsOutlines = !session.sceneOutlines || session.sceneOutlines.length === 0; const phase = session.previewPhase; const shouldAutoStart = !phase || phase === 'preparing' || phase === 'generating-content' || // Refresh during early-review: editor is shown but outlines weren't persisted, // so kick off SSE again — the editor will receive streaming outlines. (phase === 'review' && needsOutlines); if (shouldAutoStart) { hasStartedRef.current = true; startGeneration(); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [session]); // Main generation flow const startGeneration = async (sessionOverride?: GenerationSessionState) => { const generationSession = sessionOverride ?? session; if (!generationSession) return; // Create AbortController for this generation run abortControllerRef.current?.abort(); const controller = new AbortController(); abortControllerRef.current = controller; const signal = controller.signal; // Use a local mutable copy so we can update it after document extraction let currentSession = generationSession; setError(null); setCurrentStepIndex(0); try { // Compute active steps for this session (recomputed after session mutations) let activeSteps = getActiveSteps(currentSession); // Determine if we need the document analysis step const documentSources = resolveSessionDocumentSources(currentSession); const hasPdfToAnalyze = documentSources.length > 0 && !currentSession.pdfText; // If no document to analyze, skip to the next available step if (!hasPdfToAnalyze) { const firstNonPdfIdx = activeSteps.findIndex((s) => s.id !== 'pdf-analysis'); setCurrentStepIndex(Math.max(0, firstNonPdfIdx)); } // Step 0: Extract uploaded course material if needed if (hasPdfToAnalyze) { log.debug('=== Generation Preview: Extracting course material bundle ==='); validateDocumentSources(documentSources, t); const sortedDocumentSources = [...documentSources].sort((a, b) => a.order - b.order); const parsedParts = await Promise.all( sortedDocumentSources.map(async (source): Promise => { const providerId = source.providerId || currentSession.pdfProviderId; const legacySourceConfig = ( source as SessionDocumentSource & { providerConfig?: { apiKey?: string; baseUrl?: string; accessKeyId?: string; accessKeySecret?: string; }; } ).providerConfig; const providerConfig = currentSession.pdfProviderConfig || legacySourceConfig; const documentBlob = await loadDocumentBlob(source.storageKey); if (!(documentBlob instanceof Blob) || documentBlob.size === 0) { throw new Error(t('generation.courseMaterialLoadFailed')); } const documentFile = new File([documentBlob], source.name || 'document.pdf', { type: source.mimeType || documentBlob.type || 'application/pdf', }); const parseFormData = new FormData(); parseFormData.append('file', documentFile); if (providerId) parseFormData.append('providerId', providerId); if (providerConfig?.apiKey?.trim()) parseFormData.append('apiKey', providerConfig.apiKey); if (providerConfig?.baseUrl?.trim()) parseFormData.append('baseUrl', providerConfig.baseUrl); if (providerConfig?.accessKeyId?.trim()) { parseFormData.append('accessKeyId', providerConfig.accessKeyId); } if (providerConfig?.accessKeySecret?.trim()) { parseFormData.append('accessKeySecret', providerConfig.accessKeySecret); } const parseResponse = await fetch('/api/extract-document', { method: 'POST', body: parseFormData, signal, }); if (!parseResponse.ok) throw new Error(t('generation.courseMaterialParseFailed')); const parseResult = await parseResponse.json(); if (!parseResult.success || !parseResult.data) { throw new Error(t('generation.courseMaterialParseFailed')); } const parseData = parseResult.data; const rawImages = parseData.metadata?.pdfImages; const images = rawImages ? rawImages.map((img: ParsedDocumentResponseImage) => ({ id: img.id, src: img.src || '', pageNumber: img.pageNumber ?? 1, description: img.description, width: img.width, height: img.height, })) : ((parseData.images as string[] | undefined) ?? []).map((src, i) => ({ id: `img_${i + 1}`, src, pageNumber: 1, })); return { source: { id: source.id, name: source.name, size: source.size, lastModified: source.lastModified, mimeType: source.mimeType, order: source.order, providerId, }, text: parseData.text as string, rawTextLength: (parseData.text as string).length, pageCount: parseData.metadata?.pageCount, images, }; }), ); const bundle = buildDocumentBundle(parsedParts); const imageStorageIds = await storeImages(bundle.images); const pdfImages: PdfImage[] = bundle.images.map((img, i) => ({ id: img.id, src: '', pageNumber: img.pageNumber, description: img.description, width: img.width, height: img.height, originalId: img.originalId, sourceDocumentId: img.sourceDocumentId, sourceDocumentName: img.sourceDocumentName, sourceDocumentOrder: img.sourceDocumentOrder, visionPriority: img.visionPriority, storageId: imageStorageIds[i], })); // Update session with extracted document data const updatedSession = { ...currentSession, documentSources, pdfText: bundle.text, pdfImages, imageStorageIds, pdfStorageKey: undefined, // Clear so we don't re-parse }; setSession(updatedSession); sessionStorage.setItem('generationSession', JSON.stringify(updatedSession)); // Truncation warnings const warnings: string[] = []; if (bundle.totalRawTextLength > bundle.textContentBudget) { warnings.push(t('generation.textTruncated', { n: bundle.textContentBudget })); } if (bundle.totalImageCount > MAX_VISION_IMAGES) { warnings.push( t('generation.imageTruncated', { total: bundle.totalImageCount, max: MAX_VISION_IMAGES, }), ); } if (warnings.length > 0) { setTruncationWarnings(warnings); } // Reassign local reference for subsequent steps currentSession = updatedSession; activeSteps = getActiveSteps(currentSession); } // Step: Web Search (if enabled) const webSearchStepIdx = activeSteps.findIndex((s) => s.id === 'web-search'); if (currentSession.requirements.webSearch && webSearchStepIdx >= 0) { setCurrentStepIndex(webSearchStepIdx); setWebSearchSources([]); const wsSettings = useSettingsStore.getState(); const wsProviderId = wsSettings.webSearchProviderId; const wsConfig = wsSettings.webSearchProvidersConfig?.[wsProviderId]; const res = await fetch('/api/web-search', { method: 'POST', headers: getApiHeaders(), body: JSON.stringify( withThinkingConfig({ query: currentSession.requirements.requirement, pdfText: currentSession.pdfText || undefined, providerId: wsProviderId, apiKey: wsConfig?.apiKey || undefined, baseUrl: wsProviderId === 'searxng' ? undefined : wsConfig?.baseUrl || undefined, baiduSubSources: wsProviderId === 'baidu' ? wsSettings.baiduSubSources : undefined, claudeModelId: wsProviderId === 'claude' ? wsConfig?.modelId || undefined : undefined, }), ), signal, }); if (!res.ok) { const data = await res.json().catch(() => ({ error: 'Web search failed' })); throw new Error(data.error || t('generation.webSearchFailed')); } const searchData = await res.json(); const sources = (searchData.sources || []).map((s: { title: string; url: string }) => ({ title: s.title, url: s.url, })); setWebSearchSources(sources); const updatedSessionWithSearch = { ...currentSession, researchContext: searchData.context || '', researchSources: sources, }; setSession(updatedSessionWithSearch); sessionStorage.setItem('generationSession', JSON.stringify(updatedSessionWithSearch)); currentSession = updatedSessionWithSearch; activeSteps = getActiveSteps(currentSession); } // Load imageMapping early (needed for both outline and scene generation). let imageMapping: ImageMapping = {}; if (currentSession.imageStorageIds && currentSession.imageStorageIds.length > 0) { log.debug('Loading images from IndexedDB'); imageMapping = await loadImageMapping(currentSession.imageStorageIds); } else if ( currentSession.imageMapping && Object.keys(currentSession.imageMapping).length > 0 ) { log.debug('Using imageMapping from session (old format)'); imageMapping = currentSession.imageMapping; } // Create stage client-side const stageId = nanoid(10); const stage: Stage = { id: stageId, name: extractTopicFromRequirement(currentSession.requirements.requirement), description: '', style: 'professional', createdAt: Date.now(), updatedAt: Date.now(), interactiveMode: !!currentSession.requirements.interactiveMode, taskEngineMode: currentSession.taskEngineMode === true, }; // ── Generate outlines first (infers languageDirective) ── let outlines = currentSession.sceneOutlines; let languageDirective = currentSession.languageDirective; let courseTitle = currentSession.courseTitle; const outlineStepIdx = activeSteps.findIndex((s) => s.id === 'outline'); setCurrentStepIndex(outlineStepIdx >= 0 ? outlineStepIdx : 0); if (!outlines || outlines.length === 0) { log.debug('=== Generating outlines (SSE) ==='); setStreamingOutlines([]); setIsOutlineStreaming(true); const outlineResult = await new Promise<{ outlines: SceneOutline[]; languageDirective: string; courseTitle?: string; taskEngineMode: boolean; }>((resolve, reject) => { const collected: SceneOutline[] = []; let directive: string | undefined; let title: string | undefined; fetch('/api/generate/scene-outlines-stream', { method: 'POST', headers: getApiHeaders(), body: JSON.stringify( withThinkingConfig({ requirements: currentSession.requirements, pdfText: currentSession.pdfText, pdfImages: currentSession.pdfImages, imageMapping, researchContext: currentSession.researchContext, }), ), signal, }) .then((res) => { if (!res.ok) { return res.json().then((d) => { reject(new Error(d.error || t('generation.outlineGenerateFailed'))); }); } const reader = res.body?.getReader(); if (!reader) { reject(new Error(t('generation.streamNotReadable'))); return; } const decoder = new TextDecoder(); let sseBuffer = ''; const pump = (): Promise => reader.read().then(({ done, value }) => { if (value) { sseBuffer += decoder.decode(value, { stream: !done }); const lines = sseBuffer.split('\n'); sseBuffer = lines.pop() || ''; for (const line of lines) { if (!line.startsWith('data: ')) continue; try { const evt = JSON.parse(line.slice(6)); if (evt.type === 'languageDirective') { directive = evt.data; } else if (evt.type === 'courseTitle') { title = evt.data; } else if (evt.type === 'outline') { collected.push(evt.data); setStreamingOutlines([...collected]); } else if (evt.type === 'retry') { collected.length = 0; // Drop any directive/title latched from the failed // attempt — the server resets these per attempt, so a // succeeding attempt that omits them must fall back, not // inherit the previous attempt's stale values. directive = undefined; title = undefined; setStreamingOutlines([]); setStatusMessage(t('generation.outlineRetrying')); } else if (evt.type === 'done') { directive = evt.languageDirective || directive; resolve({ outlines: evt.outlines || collected, languageDirective: directive || 'Teach in the language that matches the user requirement.', courseTitle: evt.courseTitle || title, taskEngineMode: resolveTaskEngineModeFromOutlineDoneEvent(evt), }); return; } else if (evt.type === 'error') { reject(new Error(evt.error)); return; } } catch (e) { log.error('Failed to parse outline SSE:', line, e); } } } if (done) { if (collected.length > 0) { resolve({ outlines: collected, languageDirective: directive || 'Teach in the language that matches the user requirement.', // Carry any title latched from a streaming `courseTitle` // event here too — symmetric with languageDirective — so // a stream that ends without an explicit `done` event // does not silently drop a valid inferred title. courseTitle: title, taskEngineMode: false, }); } else { reject(new Error(t('generation.outlineEmptyResponse'))); } return; } return pump(); }); pump().catch(reject); }) .catch(reject); }); outlines = outlineResult.outlines; languageDirective = outlineResult.languageDirective; courseTitle = outlineResult.courseTitle; const effectiveTaskEngineMode = outlineResult.taskEngineMode; setIsOutlineStreaming(false); // Mid-stream review intent (sticky ref) overrides the auto-continue timer. const userOpenedReviewEarly = outlineReviewIntentRef.current; const shouldReviewOutlines = useSettingsStore.getState().reviewOutlineEnabled || userOpenedReviewEarly; const updatedSession: GenerationSessionState = { ...currentSession, sceneOutlines: outlines, languageDirective, courseTitle, taskEngineMode: effectiveTaskEngineMode, previewPhase: shouldReviewOutlines ? 'review' : 'outline-ready', }; persistSession(updatedSession); currentSession = updatedSession; setStreamingOutlines(outlines); setStatusMessage(shouldReviewOutlines ? '' : t('generation.reviewOutlineAutoContinue')); setIsConfirmingOutlines(false); outlines = await waitForOutlineReviewChoice(outlines, shouldReviewOutlines, signal); clearOutlineReviewTimer(); currentSession = { ...currentSession, sceneOutlines: outlines, taskEngineMode: effectiveTaskEngineMode, previewPhase: 'generating-content', }; persistSession(currentSession); // User has committed to course generation (either by confirming the // outline review or by letting the auto-continue timer fire). Now it's // safe to wipe the homepage draft cache; before this point, "back to // requirements" must restore the user's original input. try { localStorage.removeItem('requirementDraft'); } catch { /* ignore */ } } // Move to next step setStatusMessage(''); if (!outlines || outlines.length === 0) { throw new Error(t('generation.outlineEmptyResponse')); } stage.taskEngineMode = currentSession.taskEngineMode === true; // Store languageDirective on the stage if (languageDirective) { stage.languageDirective = languageDirective; } // Adopt the LLM-inferred course title as the stage name when available, // replacing the raw-requirement placeholder set at stage creation time. if (courseTitle) { stage.name = courseTitle; } // ── Agent generation (after outlines — uses languageDirective + outlines) ── const settings = useSettingsStore.getState(); let agents: Array<{ id: string; name: string; role: string; persona?: string; }> = []; if (settings.agentMode === 'auto') { const agentStepIdx = activeSteps.findIndex((s) => s.id === 'agent-generation'); if (agentStepIdx >= 0) setCurrentStepIndex(agentStepIdx); try { const allAvatars = [ { path: '/avatars/teacher.png', desc: 'Male teacher with glasses, holding a book, green background', }, { path: '/avatars/teacher-2.png', desc: 'Female teacher with long dark hair, blue traditional outfit, gentle expression', }, { path: '/avatars/assist.png', desc: 'Young female assistant with glasses, pink background, friendly smile', }, { path: '/avatars/assist-2.png', desc: 'Young female in orange top and purple overalls, cheerful and approachable', }, { path: '/avatars/clown.png', desc: 'Energetic girl with glasses pointing up, green shirt, lively and fun', }, { path: '/avatars/clown-2.png', desc: 'Playful girl with curly hair doing rock gesture, blue shirt, humorous vibe', }, { path: '/avatars/curious.png', desc: 'Surprised boy with glasses, hand on cheek, curious expression', }, { path: '/avatars/curious-2.png', desc: 'Boy with backpack holding a book and question mark bubble, inquisitive', }, { path: '/avatars/note-taker.png', desc: 'Studious boy with glasses, blue shirt, calm and organized', }, { path: '/avatars/note-taker-2.png', desc: 'Active boy with yellow backpack waving, blue outfit, enthusiastic learner', }, { path: '/avatars/thinker.png', desc: 'Thoughtful girl with hand on chin, purple background, contemplative', }, { path: '/avatars/thinker-2.png', desc: 'Girl reading a book intently, long dark hair, intellectual and focused', }, ]; const getAvailableVoicesForGeneration = () => { const providers = getEnabledProvidersWithVoices( settings.ttsProvidersConfig, voiceProfiles, ); return providers.flatMap((p) => p.voices.map((v) => { const cloneModelGroup = p.providerId === 'qwen-tts' && isQwenCloneVoice(v.id) ? p.modelGroups.find((group) => group.voices.some((groupVoice) => groupVoice.id === v.id), ) : undefined; const modelId = cloneModelGroup ? resolveTTSModelForVoice(p.providerId, v.id, cloneModelGroup.modelId) : undefined; return { providerId: p.providerId, ...(modelId ? { modelId } : {}), voiceId: v.id, voiceName: v.name, voiceLanguage: v.language, }; }), ); }; // The user's global TTS voice is the narrator voice. Pass it along so // the server pins the teacher agent to it instead of letting the LLM // pick a different voice. Reuse the same resolution helpers as the // advertised list: the model follows the voice, and only clones carry // a model on the wire. An unusable global voice (disabled/unconfigured // provider) is NOT pinned — the LLM then picks a working advertised // voice and the narration fallback machinery stays alive. const getNarratorVoiceForGeneration = () => resolveNarratorVoiceForGeneration( settings.ttsProviderId, settings.ttsVoice, settings.ttsProvidersConfig[settings.ttsProviderId], ); const agentResp = await fetch('/api/generate/agent-profiles', { method: 'POST', headers: getApiHeaders(), body: JSON.stringify( withThinkingConfig({ stageInfo: { name: stage.name, description: stage.description }, sceneOutlines: outlines.map((o) => ({ title: o.title, description: o.description, })), languageDirective, availableAvatars: allAvatars.map((a) => a.path), avatarDescriptions: allAvatars.map((a) => ({ path: a.path, desc: a.desc })), availableVoices: getAvailableVoicesForGeneration(), narratorVoice: getNarratorVoiceForGeneration(), }), ), signal, }); if (!agentResp.ok) throw new Error('Agent generation failed'); const agentData = await agentResp.json(); if (!agentData.success) throw new Error(agentData.error || 'Agent generation failed'); // Embed the roster (including its voice binding) on the stage — it // persists with the stage document via saveToStorage below — and // mirror it into the in-memory registry. The agent-profile LLM has // already bound each agent's voice (from availableVoices); the // fallback for an invalid/unavailable voice is applied later at the // live TTS call. const generatedConfigs = agentData.agents as GeneratedAgentConfig[]; stage.generatedAgentConfigs = generatedConfigs; const { applyGeneratedAgentsToRegistry } = await import('@/lib/orchestration/registry/store'); const savedIds = applyGeneratedAgentsToRegistry(stage.id, generatedConfigs); settings.setSelectedAgentIds(savedIds); // Stage-derived, not a user choice — must not carry across classrooms. settings.setAgentSelectionIsUserSet(false); stage.agentIds = savedIds; // Show card-reveal modal, continue generation once all cards are revealed setGeneratedAgents(agentData.agents); setShowAgentReveal(true); await new Promise((resolve) => { agentRevealResolveRef.current = resolve; }); agents = savedIds .map((id) => useAgentRegistry.getState().getAgent(id)) .filter(Boolean) .map((a) => ({ id: a!.id, name: a!.name, role: a!.role, persona: a!.persona, })); } catch (err: unknown) { log.warn('[Generation] Agent generation failed, falling back to presets:', err); const registry = useAgentRegistry.getState(); const fallbackIds = settings.selectedAgentIds.filter((id) => { const a = registry.getAgent(id); return a && !a.isGenerated; }); agents = fallbackIds .map((id) => registry.getAgent(id)) .filter(Boolean) .map((a) => ({ id: a!.id, name: a!.name, role: a!.role, persona: a!.persona, })); stage.agentIds = fallbackIds; } } else { // Preset mode — use selected agents (include persona) // Filter out stale generated agent IDs that may linger in settings const registry = useAgentRegistry.getState(); const presetAgentIds = settings.selectedAgentIds.filter((id) => { const a = registry.getAgent(id); return a && !a.isGenerated; }); agents = presetAgentIds .map((id) => registry.getAgent(id)) .filter(Boolean) .map((a) => ({ id: a!.id, name: a!.name, role: a!.role, persona: a!.persona, })); stage.agentIds = presetAgentIds; } // Move to scene generation step setStatusMessage(''); if (!outlines || outlines.length === 0) { throw new Error(t('generation.outlineEmptyResponse')); } // Store stage and outlines const store = useStageStore.getState(); stage.videoManifest = buildVideoManifestFromOutlines(outlines); store.setStage(stage); store.setOutlines(outlines); // Advance to slide-content step const contentStepIdx = activeSteps.findIndex((s) => s.id === 'slide-content'); if (contentStepIdx >= 0) setCurrentStepIndex(contentStepIdx); // Build stageInfo and userProfile for API call const stageInfo = { name: stage.name, description: stage.description, style: stage.style, }; const userProfile = currentSession.requirements.userNickname || currentSession.requirements.userBio ? `Student: ${currentSession.requirements.userNickname || 'Unknown'}${currentSession.requirements.userBio ? ` — ${currentSession.requirements.userBio}` : ''}` : undefined; // Generate ONLY the first scene store.setGeneratingOutlines(outlines); const firstOutline = outlines[0]; // Step 2: Generate content (currentStepIndex is already 2) const contentData = await fetchSceneContent( { outline: firstOutline, allOutlines: outlines, pdfImages: currentSession.pdfImages, imageMapping, stageInfo, stageId: stage.id, agents, languageDirective, requirements: currentSession.requirements, }, signal, FOREGROUND_SCENE_RETRY_OPTIONS, ); if (!contentData.success || !contentData.content) { throw new Error(sceneGenerationErrorMessage(contentData)); } // Generate actions (activate actions step indicator) const actionsStepIdx = activeSteps.findIndex((s) => s.id === 'actions'); setCurrentStepIndex(actionsStepIdx >= 0 ? actionsStepIdx : currentStepIndex + 1); const data = await fetchSceneActions( { outline: contentData.effectiveOutline || firstOutline, allOutlines: outlines, content: contentData.content, stageId: stage.id, agents, previousSpeeches: [], userProfile, languageDirective, }, signal, FOREGROUND_SCENE_RETRY_OPTIONS, ); if (!data.success || !data.scene) { throw new Error(sceneGenerationErrorMessage(data)); } const firstScene = data.scene; // Generate TTS for first scene (part of actions step — blocking) if ( settings.ttsEnabled && settings.ttsProviderId !== 'browser-native-tts' && isTTSProviderEnabled( settings.ttsProviderId, settings.ttsProvidersConfig?.[settings.ttsProviderId], ) ) { const ttsResult = await generateTTSForScene( firstScene, languageDirective, signal, FOREGROUND_SCENE_RETRY_OPTIONS, ); if (!ttsResult.success) throw new Error(t('generation.speechFailed')); } // Add scene to store and navigate store.addScene(firstScene); store.setCurrentSceneId(firstScene.id); // Set remaining outlines as skeleton placeholders const remaining = outlines.filter((o) => o.order !== firstScene.order); store.setGeneratingOutlines(remaining); // Store generation params for classroom to continue generation sessionStorage.setItem( 'generationParams', JSON.stringify({ pdfImages: currentSession.pdfImages, agents, userProfile, languageDirective, }), ); sessionStorage.removeItem('generationSession'); await store.saveToStorage(); router.push(`/classroom/${stage.id}`); } catch (err) { setIsOutlineStreaming(false); // AbortError is expected when navigating away — don't show as error if (isAbortError(err)) { log.info('[GenerationPreview] Generation aborted'); return; } sessionStorage.removeItem('generationSession'); setError(err instanceof Error ? err.message : String(err)); } }; const extractTopicFromRequirement = (requirement: string): string => { const trimmed = requirement.trim(); if (trimmed.length <= 500) { return trimmed; } return trimmed.substring(0, 500).trim() + '...'; }; const goBackToHome = () => { abortControllerRef.current?.abort(); clearOutlineReviewTimer(); outlineReviewIntentRef.current = false; sessionStorage.removeItem('generationSession'); router.push('/'); }; // Triggered when the user clicks the streaming outline card mid-stream. // SSE keeps running; only the surface morph + intent flag change. const handleExpandStreamingOutline = () => { if (!session) return; clearOutlineReviewTimer(); setStatusMessage(''); outlineReviewIntentRef.current = true; persistSession({ ...session, previewPhase: 'review', }); }; // Inverse of expand. Mid-stream: shrink back to the streaming preview card so // the user can keep watching while SSE fills in the rest. Post-stream: shrink // back to the small card too, then re-arm the 2.5s auto-continue timer — same // pacing as the no-review path so the user has a beat to see the card before // the page advances. Jumping straight to content gen feels too abrupt. const handleCollapseEditor = () => { if (!session) return; if (isOutlineStreaming) { // Intentionally drop the review-intent flag: collapsing mid-stream is the // user saying "actually, never mind". When SSE finishes, the no-early-open // path runs and the standard `reviewOutlineEnabled` / auto-continue rules // decide what happens next. There is no parked promise to settle yet — // the promise is created only after SSE completes (see line 583). outlineReviewIntentRef.current = false; persistSession({ ...session, previewPhase: 'preparing' }); setStatusMessage(''); return; } const collapsedOutlines = session.sceneOutlines ?? streamingOutlines; if (!collapsedOutlines || collapsedOutlines.length === 0) return; outlineReviewIntentRef.current = false; persistSession({ ...session, sceneOutlines: collapsedOutlines, previewPhase: 'outline-ready', }); setStatusMessage(t('generation.reviewOutlineAutoContinue')); // Re-arm the auto-continue timer. The SSE-completion flow is parked inside // `waitForOutlineReviewChoice` (because `shouldReview` was true when the // user opened the editor) — fire its resolve via a fresh timeout to match // the no-review path's pacing. clearOutlineReviewTimer(); outlineReviewTimerRef.current = setTimeout(() => { outlineReviewTimerRef.current = null; const resolve = outlineReviewResolveRef.current; outlineReviewResolveRef.current = null; if (resolve) { resolve(collapsedOutlines); return; } // No parked promise (e.g. session was restored from a refresh into // 'review' state). Drive the transition ourselves. const confirmedSession: GenerationSessionState = { ...session, sceneOutlines: collapsedOutlines, previewPhase: 'generating-content', }; persistSession(confirmedSession); hasStartedRef.current = true; void startGeneration(confirmedSession); }, OUTLINE_REVIEW_AUTO_CONTINUE_MS); }; const handleOutlinesChange = (outlines: SceneOutline[]) => { if (!session) return; // Streaming SSE owns `streamingOutlines` while it's running; ignore editor // changes until the stream completes (the editor is read-only in that state // anyway, but guard defensively against any racy event). if (isOutlineStreaming) return; persistSession({ ...session, sceneOutlines: outlines, previewPhase: 'review', }); }; const handleConfirmOutlines = () => { const finalOutlines = session?.sceneOutlines ?? streamingOutlines; if (!finalOutlines || finalOutlines.length === 0) return; setIsConfirmingOutlines(true); clearOutlineReviewTimer(); outlineReviewIntentRef.current = false; if (outlineReviewResolveRef.current) { const resolve = outlineReviewResolveRef.current; outlineReviewResolveRef.current = null; resolve(finalOutlines); return; } // Fallback: no parked promise (session restored mid-review). The button's // loading state was set above to give the click immediate feedback, but the // editor is about to unmount anyway as we drive the next phase ourselves. // Reset the flag so the state doesn't linger if `startGeneration` later // re-renders the editor for any reason. setIsConfirmingOutlines(false); const confirmedSession: GenerationSessionState = { ...(session as GenerationSessionState), sceneOutlines: finalOutlines, previewPhase: 'generating-content', }; persistSession(confirmedSession); hasStartedRef.current = true; void startGeneration(confirmedSession); }; // Still loading session from sessionStorage if (!sessionLoaded) { return (
); } // No session found if (!session) { return (

{t('generation.sessionNotFound')}

{t('generation.sessionNotFoundDesc')}

); } const activeStep = activeSteps.length > 0 ? activeSteps[Math.min(currentStepIndex, activeSteps.length - 1)] : ALL_STEPS[0]; const activeStepText = getGenerationStepText(activeStep, session); if (isReviewingOutlines) { const outlineStepIndex = Math.max( 0, activeSteps.findIndex((step) => step.id === 'outline'), ); // Editor source-of-truth: prefer the persisted final list; fall back to the // live streaming buffer so the editor can render mid-stream after expansion. const editorOutlines = session.sceneOutlines ?? streamingOutlines ?? []; return (
{activeSteps.map((step, idx) => (
))}

{t('generation.reviewOutlineTitle')}

{isOutlineStreaming ? t('generation.reviewOutlineStreamingDesc') : t('generation.reviewOutlineDesc')}

{error && (
{error}
)}
); } return (
{/* Background Decor */}
{/* Back button */}
{/* Progress Dots */}
{activeSteps.map((step, idx) => (
))}
{/* Central Content */}
{/* Icon / Visualizer Container */}
{error ? ( ) : isComplete ? ( ) : ( )}
{/* Text Content */}

{error ? t('generation.generationFailed') : isComplete ? t('generation.generationComplete') : t(activeStepText.title, activeStepText.titleValues)}

{error ? error : isComplete ? t('generation.classroomReady') : statusMessage || t(activeStepText.description)}

{/* Truncation warning indicator */} {truncationWarnings.length > 0 && !error && !isComplete && (
{truncationWarnings.map((w, i) => (

{w}

))}
)}
{/* Footer Action */}
{error ? ( ) : isOutlineReady ? null : !isComplete ? ( {t('generation.aiWorking')} {generatedAgents.length > 0 && !showAgentReveal && ( )} ) : null}
{/* Agent Reveal Modal */} setShowAgentReveal(false)} onAllRevealed={() => { agentRevealResolveRef.current?.(); agentRevealResolveRef.current = null; }} />
); } export default function GenerationPreviewPage() { return (
} > ); }