Files
openmaic/OpenMAIC/app/generation-preview/page.tsx

1420 lines
55 KiB
TypeScript

'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 } 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 { isTTSProviderEnabled } from '@/lib/audio/provider-enablement';
import { useI18n } from '@/lib/hooks/use-i18n';
import {
fetchSceneActions,
fetchSceneContent,
generateTTSForScene,
useSceneGenerator,
} from '@/lib/hooks/use-scene-generator';
import { isAbortError } from '@openmaic/generation/generation-retry';
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 { 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 { Stage } from '@/lib/types/stage';
import type {
SceneOutline,
PdfImage,
ImageMapping,
SessionDocumentSource,
} from '@/lib/types/generation';
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 legacySourceFromSession(session: GenerationSessionState): SessionDocumentSource[] {
if (session.documentSources?.length) return session.documentSources;
if (!session.pdfStorageKey) return [];
return [
{
id: 'source_1',
name: session.pdfFileName || 'document.pdf',
size: 0,
mimeType: session.documentMimeType || 'application/pdf',
order: 1,
storageKey: session.pdfStorageKey,
providerId: session.pdfProviderId,
},
];
}
function validateDocumentSources(
sources: SessionDocumentSource[],
t: (key: string, values?: Record<string, unknown>) => 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<AbortController | null>(null);
const outlineReviewTimerRef = useRef<ReturnType<typeof setTimeout> | 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 [session, setSession] = useState<GenerationSessionState | null>(null);
const [sessionLoaded, setSessionLoaded] = useState(false);
const [error, setError] = useState<string | null>(null);
const [currentStepIndex, setCurrentStepIndex] = useState(0);
const [isComplete] = useState(false);
const [statusMessage, setStatusMessage] = useState('');
const [streamingOutlines, setStreamingOutlines] = useState<SceneOutline[] | null>(null);
const [isOutlineStreaming, setIsOutlineStreaming] = useState(false);
const [truncationWarnings, setTruncationWarnings] = useState<string[]>([]);
const [webSearchSources, setWebSearchSources] = useState<Array<{ title: string; url: string }>>(
[],
);
const [isConfirmingOutlines, setIsConfirmingOutlines] = useState(false);
const reviewOutlineEnabled = useSettingsStore((s) => s.reviewOutlineEnabled);
const setReviewOutlineEnabled = useSettingsStore((s) => s.setReviewOutlineEnabled);
// Drives generation of ALL remaining scenes before the classroom is entered,
// so the deck is fully materialized when the user browses it.
const sceneCountRef = useRef(0);
const { generateRemaining, stop: stopRemainingGeneration } = useSceneGenerator({
onPhaseChange: (phase, outline) => {
const outlineIndex = outline.order ?? 0;
const total = Math.max(sceneCountRef.current, outlineIndex + 1);
setStatusMessage(
phase === 'content'
? `${t('generation.generatingSlideContent')} (${outlineIndex + 1}/${total})`
: `${t('generation.generatingActions')} (${outlineIndex + 1}/${total})`,
);
},
});
// 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<SceneOutline[]> =>
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 = <T extends Record<string, unknown>>(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 = legacySourceFromSession(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<ParsedDocumentPart> => {
const documentBlob = await loadDocumentBlob(source.storageKey);
if (!documentBlob) {
throw new Error(t('generation.courseMaterialLoadFailed'));
}
if (!(documentBlob instanceof Blob) || documentBlob.size === 0) {
log.error('Invalid course material blob:', {
source: source.name,
type: typeof documentBlob,
size: documentBlob instanceof Blob ? documentBlob.size : 'N/A',
});
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);
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;
if (providerId) parseFormData.append('providerId', providerId);
if (providerConfig?.apiKey?.trim()) {
parseFormData.append('apiKey', providerConfig.apiKey);
}
if (providerConfig?.baseUrl?.trim()) {
parseFormData.append('baseUrl', providerConfig.baseUrl);
}
// AliDocMind uses AK/SK instead of a single apiKey.
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) {
const errorData = await parseResponse.json();
throw new Error(errorData.error || t('generation.courseMaterialParseFailed'));
}
const parseResult = await parseResponse.json();
if (!parseResult.success || !parseResult.data) {
throw new Error(t('generation.courseMaterialParseFailed'));
}
const rawImages = parseResult.data.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,
}))
: ((parseResult.data.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: parseResult.data.text as string,
rawTextLength: (parseResult.data.text as string).length,
pageCount: parseResult.data.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<void> =>
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;
}
// ── Agents: always the preset roster — LLM agent-role generation removed ──
const settings = useSettingsStore.getState();
// 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;
});
const agents: Array<{
id: string;
name: string;
role: string;
persona?: string;
}> = 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);
sceneCountRef.current = outlines.length;
// 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
store.addScene(firstScene);
store.setCurrentSceneId(firstScene.id);
// Store generation params for classroom to continue generation
sessionStorage.setItem(
'generationParams',
JSON.stringify({
pdfImages: currentSession.pdfImages,
agents,
userProfile,
languageDirective,
}),
);
// Generate ALL remaining scenes now, before entering the classroom, so
// the user browses a fully materialized deck (no per-page "generating"
// placeholders). Uses the same pipeline as the classroom auto-resume.
const remaining = outlines.filter((o) => o.order !== firstScene.order);
store.setGeneratingOutlines(remaining);
await generateRemaining({
pdfImages: currentSession.pdfImages,
imageMapping,
stageInfo: {
name: stage.name || '',
description: stage.description,
style: stage.style,
language: languageDirective,
},
agents,
userProfile,
languageDirective,
});
// If any scene failed, the pipeline paused with failedOutlines — surface
// the failure here instead of entering a half-generated classroom.
const failed = useStageStore.getState().failedOutlines;
if (failed.length > 0) {
throw new Error(t('generation.sceneGenerateFailed'));
}
// All scenes generated — mark complete and navigate
store.setGeneratingOutlines([]);
store.markGenerationCompleteIfDone();
store.setGenerationStatus('completed');
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();
stopRemainingGeneration();
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 (
<div className="min-h-[100dvh] w-full bg-gradient-to-b from-slate-50 to-slate-100 dark:from-slate-950 dark:to-slate-900 flex items-center justify-center p-4">
<div className="text-center text-muted-foreground">
<div className="size-8 border-2 border-current border-t-transparent rounded-full animate-spin mx-auto" />
</div>
</div>
);
}
// No session found
if (!session) {
return (
<div className="min-h-[100dvh] w-full bg-gradient-to-b from-slate-50 to-slate-100 dark:from-slate-950 dark:to-slate-900 flex items-center justify-center p-4">
<Card className="p-8 max-w-md w-full">
<div className="text-center space-y-4">
<AlertCircle className="size-12 text-muted-foreground mx-auto" />
<h2 className="text-xl font-semibold">{t('generation.sessionNotFound')}</h2>
<p className="text-sm text-muted-foreground">{t('generation.sessionNotFoundDesc')}</p>
<Button onClick={() => router.push('/')} className="w-full">
<ArrowLeft className="size-4 mr-2" />
{t('generation.backToHome')}
</Button>
</div>
</Card>
</div>
);
}
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 (
<div className="min-h-[100dvh] w-full bg-gradient-to-b from-slate-50 to-slate-100 dark:from-slate-950 dark:to-slate-900 flex flex-col items-center p-4 relative overflow-hidden">
<motion.div
initial={{ opacity: 0, y: -20 }}
animate={{ opacity: 1, y: 0 }}
className="absolute top-4 left-4 z-20"
>
<Button variant="ghost" size="sm" onClick={goBackToHome} disabled={isConfirmingOutlines}>
<ArrowLeft className="size-4 mr-2" />
{t('generation.backToHome')}
</Button>
</motion.div>
<div className="z-10 w-full max-w-3xl pt-16 pb-8">
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
className="space-y-6"
>
<div className="flex justify-center gap-2">
{activeSteps.map((step, idx) => (
<div
key={step.id}
className={cn(
'h-1.5 rounded-full transition-all duration-500',
idx < outlineStepIndex
? 'w-1.5 bg-blue-500/30'
: idx === outlineStepIndex
? 'w-8 bg-blue-500'
: 'w-1.5 bg-muted/50',
)}
/>
))}
</div>
<div className="max-w-2xl space-y-2 text-center mx-auto">
<h2 className="text-2xl font-bold tracking-tight">
{t('generation.reviewOutlineTitle')}
</h2>
<p className="text-muted-foreground text-sm md:text-base">
{isOutlineStreaming
? t('generation.reviewOutlineStreamingDesc')
: t('generation.reviewOutlineDesc')}
</p>
</div>
{error && (
<div className="mx-auto max-w-2xl rounded-md border border-red-500/20 bg-red-500/10 px-4 py-3 text-sm text-red-600 dark:text-red-300">
{error}
</div>
)}
<OutlinesEditor
outlines={editorOutlines}
onChange={handleOutlinesChange}
onConfirm={handleConfirmOutlines}
onBack={goBackToHome}
alwaysReview={reviewOutlineEnabled}
onAlwaysReviewChange={setReviewOutlineEnabled}
isLoading={isConfirmingOutlines}
isStreaming={isOutlineStreaming}
onCollapse={handleCollapseEditor}
/>
</motion.div>
</div>
</div>
);
}
return (
<div className="min-h-[100dvh] w-full bg-gradient-to-b from-slate-50 to-slate-100 dark:from-slate-950 dark:to-slate-900 flex flex-col items-center justify-center p-4 relative overflow-hidden text-center">
{/* Background Decor */}
<div className="fixed inset-0 overflow-hidden pointer-events-none z-0">
<div
className="absolute top-0 left-1/4 w-96 h-96 bg-blue-500/10 rounded-full blur-3xl animate-pulse"
style={{ animationDuration: '4s' }}
/>
<div
className="absolute bottom-0 right-1/4 w-96 h-96 bg-purple-500/10 rounded-full blur-3xl animate-pulse"
style={{ animationDuration: '6s' }}
/>
</div>
{/* Back button */}
<motion.div
initial={{ opacity: 0, y: -20 }}
animate={{ opacity: 1, y: 0 }}
className="absolute top-4 left-4 z-20"
>
<Button variant="ghost" size="sm" onClick={goBackToHome}>
<ArrowLeft className="size-4 mr-2" />
{t('generation.backToHome')}
</Button>
</motion.div>
<div className="z-10 w-full max-w-lg space-y-8 flex flex-col items-center">
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5 }}
className="w-full"
>
<Card className="relative overflow-hidden border-muted/40 shadow-2xl bg-white/80 dark:bg-slate-900/80 backdrop-blur-xl min-h-[400px] flex flex-col items-center justify-center p-8 md:p-12">
{/* Progress Dots */}
<div className="absolute top-6 left-0 right-0 flex justify-center gap-2">
{activeSteps.map((step, idx) => (
<div
key={step.id}
className={cn(
'h-1.5 rounded-full transition-all duration-500',
idx < currentStepIndex
? 'w-1.5 bg-blue-500/30'
: idx === currentStepIndex
? 'w-8 bg-blue-500'
: 'w-1.5 bg-muted/50',
)}
/>
))}
</div>
{/* Central Content */}
<div className="flex-1 flex flex-col items-center justify-center w-full space-y-8 mt-4">
{/* Icon / Visualizer Container */}
<div className="relative size-48 flex items-center justify-center">
<AnimatePresence mode="popLayout">
{error ? (
<motion.div
key="error"
initial={{ scale: 0.5, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
className="size-32 rounded-full bg-red-500/10 flex items-center justify-center border-2 border-red-500/20"
>
<AlertCircle className="size-16 text-red-500" />
</motion.div>
) : isComplete ? (
<motion.div
key="complete"
initial={{ scale: 0.5, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
className="size-32 rounded-full bg-green-500/10 flex items-center justify-center border-2 border-green-500/20"
>
<CheckCircle2 className="size-16 text-green-500" />
</motion.div>
) : (
<motion.div
key={activeStep.id}
initial={{ scale: 0.8, opacity: 0, filter: 'blur(10px)' }}
animate={{ scale: 1, opacity: 1, filter: 'blur(0px)' }}
exit={{ scale: 1.2, opacity: 0, filter: 'blur(10px)' }}
transition={{ duration: 0.4 }}
className="absolute inset-0 flex items-center justify-center"
>
<StepVisualizer
stepId={activeStep.id}
outlines={session.sceneOutlines ?? streamingOutlines}
webSearchSources={webSearchSources}
onExpandOutline={
activeStep.id === 'outline' ? handleExpandStreamingOutline : undefined
}
/>
</motion.div>
)}
</AnimatePresence>
</div>
{/* Text Content */}
<div className="space-y-3 max-w-sm mx-auto">
<AnimatePresence mode="wait">
<motion.div
key={error ? 'error' : isComplete ? 'done' : activeStep.id}
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -10 }}
className="space-y-2"
>
<h2 className="text-2xl font-bold tracking-tight">
{error
? t('generation.generationFailed')
: isComplete
? t('generation.generationComplete')
: t(activeStepText.title, activeStepText.titleValues)}
</h2>
<p className="text-muted-foreground text-base">
{error
? error
: isComplete
? t('generation.classroomReady')
: statusMessage || t(activeStepText.description)}
</p>
</motion.div>
</AnimatePresence>
{/* Truncation warning indicator */}
<AnimatePresence>
{truncationWarnings.length > 0 && !error && !isComplete && (
<motion.div
initial={{ opacity: 0, scale: 0 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0 }}
transition={{
type: 'spring',
stiffness: 500,
damping: 30,
}}
className="flex justify-center"
>
<Tooltip>
<TooltipTrigger asChild>
<motion.button
type="button"
animate={{
boxShadow: [
'0 0 0 0 rgba(251, 191, 36, 0), 0 0 0 0 rgba(251, 191, 36, 0)',
'0 0 16px 4px rgba(251, 191, 36, 0.12), 0 0 4px 1px rgba(251, 191, 36, 0.08)',
'0 0 0 0 rgba(251, 191, 36, 0), 0 0 0 0 rgba(251, 191, 36, 0)',
],
}}
transition={{
duration: 3,
repeat: Infinity,
ease: 'easeInOut',
}}
className="relative size-7 rounded-full flex items-center justify-center cursor-default
bg-gradient-to-br from-amber-400/15 to-orange-400/10
border border-amber-400/25 hover:border-amber-400/40
hover:from-amber-400/20 hover:to-orange-400/15
transition-colors duration-300
focus:outline-none focus-visible:ring-2 focus-visible:ring-amber-500/30"
>
<AlertTriangle
className="size-3.5 text-amber-500 dark:text-amber-400"
strokeWidth={2.5}
/>
</motion.button>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={6}>
<div className="space-y-1 py-0.5">
{truncationWarnings.map((w, i) => (
<p key={i} className="text-xs leading-relaxed">
{w}
</p>
))}
</div>
</TooltipContent>
</Tooltip>
</motion.div>
)}
</AnimatePresence>
</div>
</div>
</Card>
</motion.div>
{/* Footer Action */}
<div className="h-16 flex items-center justify-center w-full">
<AnimatePresence>
{error ? (
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
className="w-full max-w-xs"
>
<Button size="lg" variant="outline" className="w-full h-12" onClick={goBackToHome}>
{t('generation.goBackAndRetry')}
</Button>
</motion.div>
) : isOutlineReady ? null : !isComplete ? (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
className="flex items-center gap-3 text-sm text-muted-foreground/50 font-medium uppercase tracking-widest"
>
<Sparkles className="size-3 animate-pulse" />
{t('generation.aiWorking')}
</motion.div>
) : null}
</AnimatePresence>
</div>
</div>
</div>
);
}
export default function GenerationPreviewPage() {
return (
<Suspense
fallback={
<div className="min-h-[100dvh] w-full bg-gradient-to-b from-slate-50 to-slate-100 dark:from-slate-950 dark:to-slate-900 flex items-center justify-center">
<div className="animate-pulse space-y-4 text-center">
<div className="h-8 w-48 bg-muted rounded mx-auto" />
<div className="h-4 w-64 bg-muted rounded mx-auto" />
</div>
</div>
}
>
<GenerationPreviewContent />
</Suspense>
);
}