| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092 |
- 'use client';
- import { useEffect, useState, Suspense, useRef } from 'react';
- import { useRouter } from 'next/navigation';
- import { motion, AnimatePresence } from 'motion/react';
- import { CheckCircle2, Sparkles, AlertCircle, AlertTriangle, ArrowLeft, Bot } from 'lucide-react';
- import { Button } from '@/components/ui/button';
- import { Card } from '@/components/ui/card';
- import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
- import { cn } from '@/lib/utils';
- import { useStageStore } from '@/lib/store/stage';
- import { useSettingsStore } from '@/lib/store/settings';
- import { useAgentRegistry } from '@/lib/orchestration/registry/store';
- import { getAvailableProvidersWithVoices } from '@/lib/audio/voice-resolver';
- import { useI18n } from '@/lib/hooks/use-i18n';
- import {
- loadImageMapping,
- loadPdfBlob,
- cleanupOldImages,
- storeImages,
- } from '@/lib/utils/image-storage';
- import { getCurrentModelConfig } from '@/lib/utils/model-config';
- import { db } from '@/lib/utils/database';
- import { MAX_PDF_CONTENT_CHARS, MAX_VISION_IMAGES } from '@/lib/constants/generation';
- import { nanoid } from 'nanoid';
- import type { Stage } from '@/lib/types/stage';
- import type { SceneOutline, PdfImage, ImageMapping } from '@/lib/types/generation';
- import { AgentRevealModal } from '@/components/agent/agent-reveal-modal';
- import { createLogger } from '@/lib/logger';
- import { type GenerationSessionState, ALL_STEPS, getActiveSteps } from './types';
- import { StepVisualizer } from './components/visualizers';
- const log = createLogger('GenerationPreview');
- function GenerationPreviewContent() {
- const router = useRouter();
- const { t } = useI18n();
- const hasStartedRef = useRef(false);
- const abortControllerRef = useRef<AbortController | null>(null);
- 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 [truncationWarnings, setTruncationWarnings] = useState<string[]>([]);
- const [webSearchSources, setWebSearchSources] = useState<Array<{ title: string; url: string }>>(
- [],
- );
- const [showAgentReveal, setShowAgentReveal] = useState(false);
- const [generatedAgents, setGeneratedAgents] = useState<
- Array<{
- id: string;
- name: string;
- role: string;
- persona: string;
- avatar: string;
- color: string;
- priority: number;
- }>
- >([]);
- const agentRevealResolveRef = useRef<(() => void) | null>(null);
- // Compute active steps based on session state
- const activeSteps = getActiveSteps(session);
- // 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;
- 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();
- };
- }, []);
- // 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),
- };
- };
- // Auto-start generation when session is loaded
- useEffect(() => {
- if (session && !hasStartedRef.current) {
- hasStartedRef.current = true;
- startGeneration();
- }
- // eslint-disable-next-line react-hooks/exhaustive-deps
- }, [session]);
- // Main generation flow
- const startGeneration = async () => {
- if (!session) 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 PDF parsing
- let currentSession = session;
- setError(null);
- setCurrentStepIndex(0);
- try {
- // Compute active steps for this session (recomputed after session mutations)
- let activeSteps = getActiveSteps(currentSession);
- // Determine if we need the PDF analysis step
- const hasPdfToAnalyze = !!currentSession.pdfStorageKey && !currentSession.pdfText;
- // If no PDF 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: Parse PDF if needed
- if (hasPdfToAnalyze) {
- log.debug('=== Generation Preview: Parsing PDF ===');
- const pdfBlob = await loadPdfBlob(currentSession.pdfStorageKey!);
- if (!pdfBlob) {
- throw new Error(t('generation.pdfLoadFailed'));
- }
- // Ensure pdfBlob is a valid Blob with content
- if (!(pdfBlob instanceof Blob) || pdfBlob.size === 0) {
- log.error('Invalid PDF blob:', {
- type: typeof pdfBlob,
- size: pdfBlob instanceof Blob ? pdfBlob.size : 'N/A',
- });
- throw new Error(t('generation.pdfLoadFailed'));
- }
- // Wrap as a File to guarantee multipart/form-data with correct content-type
- const pdfFile = new File([pdfBlob], currentSession.pdfFileName || 'document.pdf', {
- type: 'application/pdf',
- });
- const parseFormData = new FormData();
- parseFormData.append('pdf', pdfFile);
- if (currentSession.pdfProviderId) {
- parseFormData.append('providerId', currentSession.pdfProviderId);
- }
- if (currentSession.pdfProviderConfig?.apiKey?.trim()) {
- parseFormData.append('apiKey', currentSession.pdfProviderConfig.apiKey);
- }
- if (currentSession.pdfProviderConfig?.baseUrl?.trim()) {
- parseFormData.append('baseUrl', currentSession.pdfProviderConfig.baseUrl);
- }
- const parseResponse = await fetch('/api/parse-pdf', {
- method: 'POST',
- body: parseFormData,
- signal,
- });
- if (!parseResponse.ok) {
- const errorData = await parseResponse.json();
- throw new Error(errorData.error || t('generation.pdfParseFailed'));
- }
- const parseResult = await parseResponse.json();
- if (!parseResult.success || !parseResult.data) {
- throw new Error(t('generation.pdfParseFailed'));
- }
- let pdfText = parseResult.data.text as string;
- // Truncate if needed
- if (pdfText.length > MAX_PDF_CONTENT_CHARS) {
- pdfText = pdfText.substring(0, MAX_PDF_CONTENT_CHARS);
- }
- // Create image metadata and store images
- // Prefer metadata.pdfImages (both parsers now return this)
- const rawPdfImages = parseResult.data.metadata?.pdfImages;
- const images = rawPdfImages
- ? rawPdfImages.map(
- (img: {
- id: string;
- src?: string;
- pageNumber?: number;
- description?: string;
- width?: number;
- height?: number;
- }) => ({
- id: img.id,
- src: img.src || '',
- pageNumber: img.pageNumber || 1,
- description: img.description,
- width: img.width,
- height: img.height,
- }),
- )
- : (parseResult.data.images as string[]).map((src: string, i: number) => ({
- id: `img_${i + 1}`,
- src,
- pageNumber: 1,
- }));
- const imageStorageIds = await storeImages(images);
- const pdfImages: PdfImage[] = images.map(
- (
- img: {
- id: string;
- src: string;
- pageNumber: number;
- description?: string;
- width?: number;
- height?: number;
- },
- i: number,
- ) => ({
- id: img.id,
- src: '',
- pageNumber: img.pageNumber,
- description: img.description,
- width: img.width,
- height: img.height,
- storageId: imageStorageIds[i],
- }),
- );
- // Update session with parsed PDF data
- const updatedSession = {
- ...currentSession,
- pdfText,
- 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 ((parseResult.data.text as string).length > MAX_PDF_CONTENT_CHARS) {
- warnings.push(t('generation.textTruncated', { n: MAX_PDF_CONTENT_CHARS }));
- }
- if (images.length > MAX_VISION_IMAGES) {
- warnings.push(
- t('generation.imageTruncated', { total: images.length, 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 wsApiKey =
- wsSettings.webSearchProvidersConfig?.[wsSettings.webSearchProviderId]?.apiKey;
- const res = await fetch('/api/web-search', {
- method: 'POST',
- headers: getApiHeaders(),
- body: JSON.stringify({
- query: currentSession.requirements.requirement,
- pdfText: currentSession.pdfText || undefined,
- apiKey: wsApiKey || 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;
- }
- // ── Agent generation (before outlines so persona can influence structure) ──
- const settings = useSettingsStore.getState();
- let agents: Array<{
- id: string;
- name: string;
- role: string;
- persona?: string;
- }> = [];
- // Create stage client-side (needed for agent generation stageId)
- const stageId = nanoid(10);
- const stage: Stage = {
- id: stageId,
- name: extractTopicFromRequirement(currentSession.requirements.requirement),
- description: '',
- language: currentSession.requirements.language || 'zh-CN',
- style: 'professional',
- createdAt: Date.now(),
- updatedAt: Date.now(),
- };
- if (settings.agentMode === 'auto') {
- const agentStepIdx = activeSteps.findIndex((s) => s.id === 'agent-generation');
- if (agentStepIdx >= 0) setCurrentStepIndex(agentStepIdx);
- try {
- const allAvatars = [
- {
- path: '/avatars/teacher.png',
- desc: 'Male teacher with glasses, holding a book, green background',
- },
- {
- path: '/avatars/teacher-2.png',
- desc: 'Female teacher with long dark hair, blue traditional outfit, gentle expression',
- },
- {
- path: '/avatars/assist.png',
- desc: 'Young female assistant with glasses, pink background, friendly smile',
- },
- {
- path: '/avatars/assist-2.png',
- desc: 'Young female in orange top and purple overalls, cheerful and approachable',
- },
- {
- path: '/avatars/clown.png',
- desc: 'Energetic girl with glasses pointing up, green shirt, lively and fun',
- },
- {
- path: '/avatars/clown-2.png',
- desc: 'Playful girl with curly hair doing rock gesture, blue shirt, humorous vibe',
- },
- {
- path: '/avatars/curious.png',
- desc: 'Surprised boy with glasses, hand on cheek, curious expression',
- },
- {
- path: '/avatars/curious-2.png',
- desc: 'Boy with backpack holding a book and question mark bubble, inquisitive',
- },
- {
- path: '/avatars/note-taker.png',
- desc: 'Studious boy with glasses, blue shirt, calm and organized',
- },
- {
- path: '/avatars/note-taker-2.png',
- desc: 'Active boy with yellow backpack waving, blue outfit, enthusiastic learner',
- },
- {
- path: '/avatars/thinker.png',
- desc: 'Thoughtful girl with hand on chin, purple background, contemplative',
- },
- {
- path: '/avatars/thinker-2.png',
- desc: 'Girl reading a book intently, long dark hair, intellectual and focused',
- },
- ];
- const getAvailableVoicesForGeneration = () => {
- const providers = getAvailableProvidersWithVoices(settings.ttsProvidersConfig);
- return providers.flatMap((p) =>
- p.voices.map((v) => ({
- providerId: p.providerId,
- voiceId: v.id,
- voiceName: v.name,
- })),
- );
- };
- // No outlines yet — agent generation uses only stage name + description
- const agentResp = await fetch('/api/generate/agent-profiles', {
- method: 'POST',
- headers: getApiHeaders(),
- body: JSON.stringify({
- stageInfo: { name: stage.name, description: stage.description },
- language: currentSession.requirements.language || 'zh-CN',
- availableAvatars: allAvatars.map((a) => a.path),
- avatarDescriptions: allAvatars.map((a) => ({ path: a.path, desc: a.desc })),
- availableVoices: getAvailableVoicesForGeneration(),
- }),
- signal,
- });
- if (!agentResp.ok) throw new Error('Agent generation failed');
- const agentData = await agentResp.json();
- if (!agentData.success) throw new Error(agentData.error || 'Agent generation failed');
- // Save to IndexedDB and registry
- const { saveGeneratedAgents } = await import('@/lib/orchestration/registry/store');
- const savedIds = await saveGeneratedAgents(stage.id, agentData.agents);
- settings.setSelectedAgentIds(savedIds);
- stage.agentIds = savedIds;
- // Show card-reveal modal, continue generation once all cards are revealed
- setGeneratedAgents(agentData.agents);
- setShowAgentReveal(true);
- await new Promise<void>((resolve) => {
- agentRevealResolveRef.current = resolve;
- });
- agents = savedIds
- .map((id) => useAgentRegistry.getState().getAgent(id))
- .filter(Boolean)
- .map((a) => ({
- id: a!.id,
- name: a!.name,
- role: a!.role,
- persona: a!.persona,
- }));
- } catch (err: unknown) {
- log.warn('[Generation] Agent generation failed, falling back to presets:', err);
- const registry = useAgentRegistry.getState();
- const fallbackIds = settings.selectedAgentIds.filter((id) => {
- const a = registry.getAgent(id);
- return a && !a.isGenerated;
- });
- agents = fallbackIds
- .map((id) => registry.getAgent(id))
- .filter(Boolean)
- .map((a) => ({
- id: a!.id,
- name: a!.name,
- role: a!.role,
- persona: a!.persona,
- }));
- stage.agentIds = fallbackIds;
- }
- } else {
- // Preset mode — use selected agents (include persona)
- // Filter out stale generated agent IDs that may linger in settings
- const registry = useAgentRegistry.getState();
- const presetAgentIds = settings.selectedAgentIds.filter((id) => {
- const a = registry.getAgent(id);
- return a && !a.isGenerated;
- });
- agents = presetAgentIds
- .map((id) => registry.getAgent(id))
- .filter(Boolean)
- .map((a) => ({
- id: a!.id,
- name: a!.name,
- role: a!.role,
- persona: a!.persona,
- }));
- stage.agentIds = presetAgentIds;
- }
- // ── Generate outlines (with agent personas for teacher context) ──
- let outlines = currentSession.sceneOutlines;
- const outlineStepIdx = activeSteps.findIndex((s) => s.id === 'outline');
- setCurrentStepIndex(outlineStepIdx >= 0 ? outlineStepIdx : 0);
- if (!outlines || outlines.length === 0) {
- log.debug('=== Generating outlines (SSE) ===');
- setStreamingOutlines([]);
- outlines = await new Promise<SceneOutline[]>((resolve, reject) => {
- const collected: SceneOutline[] = [];
- fetch('/api/generate/scene-outlines-stream', {
- method: 'POST',
- headers: getApiHeaders(),
- body: JSON.stringify({
- requirements: currentSession.requirements,
- pdfText: currentSession.pdfText,
- pdfImages: currentSession.pdfImages,
- imageMapping,
- researchContext: currentSession.researchContext,
- agents,
- }),
- 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 === 'outline') {
- collected.push(evt.data);
- setStreamingOutlines([...collected]);
- } else if (evt.type === 'retry') {
- collected.length = 0;
- setStreamingOutlines([]);
- setStatusMessage(t('generation.outlineRetrying'));
- } else if (evt.type === 'done') {
- resolve(evt.outlines || collected);
- 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(collected);
- } else {
- reject(new Error(t('generation.outlineEmptyResponse')));
- }
- return;
- }
- return pump();
- });
- pump().catch(reject);
- })
- .catch(reject);
- });
- const updatedSession = { ...currentSession, sceneOutlines: outlines };
- setSession(updatedSession);
- sessionStorage.setItem('generationSession', JSON.stringify(updatedSession));
- // Outline generation succeeded — clear homepage draft cache
- try {
- localStorage.removeItem('requirementDraft');
- } catch {
- /* ignore */
- }
- // Brief pause to let user see the final outline state
- await new Promise((resolve) => setTimeout(resolve, 800));
- }
- // 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();
- store.setStage(stage);
- store.setOutlines(outlines);
- // Advance to slide-content step
- const contentStepIdx = activeSteps.findIndex((s) => s.id === 'slide-content');
- if (contentStepIdx >= 0) setCurrentStepIndex(contentStepIdx);
- // Build stageInfo and userProfile for API call
- const stageInfo = {
- name: stage.name,
- description: stage.description,
- language: stage.language,
- 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 contentResp = await fetch('/api/generate/scene-content', {
- method: 'POST',
- headers: getApiHeaders(),
- body: JSON.stringify({
- outline: firstOutline,
- allOutlines: outlines,
- pdfImages: currentSession.pdfImages,
- imageMapping,
- stageInfo,
- stageId: stage.id,
- agents,
- }),
- signal,
- });
- if (!contentResp.ok) {
- const errorData = await contentResp.json().catch(() => ({ error: 'Request failed' }));
- throw new Error(errorData.error || t('generation.sceneGenerateFailed'));
- }
- const contentData = await contentResp.json();
- if (!contentData.success || !contentData.content) {
- throw new Error(contentData.error || t('generation.sceneGenerateFailed'));
- }
- // Generate actions (activate actions step indicator)
- const actionsStepIdx = activeSteps.findIndex((s) => s.id === 'actions');
- setCurrentStepIndex(actionsStepIdx >= 0 ? actionsStepIdx : currentStepIndex + 1);
- const actionsResp = await fetch('/api/generate/scene-actions', {
- method: 'POST',
- headers: getApiHeaders(),
- body: JSON.stringify({
- outline: contentData.effectiveOutline || firstOutline,
- allOutlines: outlines,
- content: contentData.content,
- stageId: stage.id,
- agents,
- previousSpeeches: [],
- userProfile,
- }),
- signal,
- });
- if (!actionsResp.ok) {
- const errorData = await actionsResp.json().catch(() => ({ error: 'Request failed' }));
- throw new Error(errorData.error || t('generation.sceneGenerateFailed'));
- }
- const data = await actionsResp.json();
- if (!data.success || !data.scene) {
- throw new Error(data.error || t('generation.sceneGenerateFailed'));
- }
- // Generate TTS for first scene (part of actions step — blocking)
- if (settings.ttsEnabled && settings.ttsProviderId !== 'browser-native-tts') {
- const ttsProviderConfig = settings.ttsProvidersConfig?.[settings.ttsProviderId];
- const speechActions = (data.scene.actions || []).filter(
- (a: { type: string; text?: string }) => a.type === 'speech' && a.text,
- );
- let ttsFailCount = 0;
- for (const action of speechActions) {
- const audioId = `tts_${action.id}`;
- action.audioId = audioId;
- try {
- const resp = await fetch('/api/generate/tts', {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({
- text: action.text,
- audioId,
- ttsProviderId: settings.ttsProviderId,
- ttsModelId: ttsProviderConfig?.modelId,
- ttsVoice: settings.ttsVoice,
- ttsSpeed: settings.ttsSpeed,
- ttsApiKey: ttsProviderConfig?.apiKey || undefined,
- ttsBaseUrl: ttsProviderConfig?.baseUrl || undefined,
- }),
- signal,
- });
- if (!resp.ok) {
- ttsFailCount++;
- continue;
- }
- const ttsData = await resp.json();
- if (!ttsData.success) {
- ttsFailCount++;
- continue;
- }
- const binary = atob(ttsData.base64);
- const bytes = new Uint8Array(binary.length);
- for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
- const blob = new Blob([bytes], { type: `audio/${ttsData.format}` });
- await db.audioFiles.put({
- id: audioId,
- blob,
- format: ttsData.format,
- createdAt: Date.now(),
- });
- } catch (err) {
- log.warn(`[TTS] Failed for ${audioId}:`, err);
- ttsFailCount++;
- }
- }
- if (ttsFailCount > 0 && speechActions.length > 0) {
- throw new Error(t('generation.speechFailed'));
- }
- }
- // Add scene to store and navigate
- store.addScene(data.scene);
- store.setCurrentSceneId(data.scene.id);
- // Set remaining outlines as skeleton placeholders
- const remaining = outlines.filter((o) => o.order !== data.scene.order);
- store.setGeneratingOutlines(remaining);
- // Store generation params for classroom to continue generation
- sessionStorage.setItem(
- 'generationParams',
- JSON.stringify({
- pdfImages: currentSession.pdfImages,
- agents,
- userProfile,
- }),
- );
- sessionStorage.removeItem('generationSession');
- await store.saveToStorage();
- router.push(`/classroom/${stage.id}`);
- } catch (err) {
- // AbortError is expected when navigating away — don't show as error
- if (err instanceof DOMException && err.name === 'AbortError') {
- 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();
- sessionStorage.removeItem('generationSession');
- router.push('/');
- };
- // 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];
- 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={streamingOutlines}
- webSearchSources={webSearchSources}
- />
- </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(activeStep.title)}
- </h2>
- <p className="text-muted-foreground text-base">
- {error
- ? error
- : isComplete
- ? t('generation.classroomReady')
- : statusMessage || t(activeStep.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>
- ) : !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')}
- {generatedAgents.length > 0 && !showAgentReveal && (
- <button
- onClick={() => setShowAgentReveal(true)}
- className="ml-2 flex items-center gap-1.5 rounded-full border border-purple-300/30 bg-purple-500/10 px-3 py-1 text-xs font-medium normal-case tracking-normal text-purple-400 transition-colors hover:bg-purple-500/20 hover:text-purple-300"
- >
- <Bot className="size-3" />
- {t('generation.viewAgents')}
- </button>
- )}
- </motion.div>
- ) : null}
- </AnimatePresence>
- </div>
- </div>
- {/* Agent Reveal Modal */}
- <AgentRevealModal
- agents={generatedAgents}
- open={showAgentReveal}
- onClose={() => setShowAgentReveal(false)}
- onAllRevealed={() => {
- agentRevealResolveRef.current?.();
- agentRevealResolveRef.current = null;
- }}
- />
- </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>
- );
- }
|