Spaces:
No application file
No application file
| '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 { 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 || '', | |
| 'x-requires-api-key': modelConfig.requiresApiKey ? 'true' : 'false', | |
| // 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').replace('{n}', String(MAX_PDF_CONTENT_CHARS)), | |
| ); | |
| } | |
| if (images.length > MAX_VISION_IMAGES) { | |
| warnings.push( | |
| t('generation.imageTruncated') | |
| .replace('{total}', String(images.length)) | |
| .replace('{max}', String(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: { 'Content-Type': 'application/json' }, | |
| body: JSON.stringify({ | |
| query: currentSession.requirements.requirement, | |
| 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 = [ | |
| '/avatars/assist.png', | |
| '/avatars/assist-2.png', | |
| '/avatars/clown.png', | |
| '/avatars/clown-2.png', | |
| '/avatars/curious.png', | |
| '/avatars/curious-2.png', | |
| '/avatars/note-taker.png', | |
| '/avatars/note-taker-2.png', | |
| '/avatars/teacher.png', | |
| '/avatars/teacher-2.png', | |
| '/avatars/thinker.png', | |
| '/avatars/thinker-2.png', | |
| ]; | |
| // 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, | |
| }), | |
| 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); | |
| // 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(); | |
| agents = settings.selectedAgentIds | |
| .map((id) => registry.getAgent(id)) | |
| .filter(Boolean) | |
| .map((a) => ({ | |
| id: a!.id, | |
| name: a!.name, | |
| role: a!.role, | |
| persona: a!.persona, | |
| })); | |
| } | |
| } else { | |
| // Preset mode — use selected agents (include persona) | |
| const registry = useAgentRegistry.getState(); | |
| agents = settings.selectedAgentIds | |
| .map((id) => registry.getAgent(id)) | |
| .filter(Boolean) | |
| .map((a) => ({ | |
| id: a!.id, | |
| name: a!.name, | |
| role: a!.role, | |
| persona: a!.persona, | |
| })); | |
| } | |
| // ── 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, | |
| 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; | |
| } | |
| 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> | |
| ); | |
| } | |