1420 lines
55 KiB
TypeScript
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>
|
|
);
|
|
}
|