page.tsx 42 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092
  1. 'use client';
  2. import { useEffect, useState, Suspense, useRef } from 'react';
  3. import { useRouter } from 'next/navigation';
  4. import { motion, AnimatePresence } from 'motion/react';
  5. import { CheckCircle2, Sparkles, AlertCircle, AlertTriangle, ArrowLeft, Bot } from 'lucide-react';
  6. import { Button } from '@/components/ui/button';
  7. import { Card } from '@/components/ui/card';
  8. import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
  9. import { cn } from '@/lib/utils';
  10. import { useStageStore } from '@/lib/store/stage';
  11. import { useSettingsStore } from '@/lib/store/settings';
  12. import { useAgentRegistry } from '@/lib/orchestration/registry/store';
  13. import { getAvailableProvidersWithVoices } from '@/lib/audio/voice-resolver';
  14. import { useI18n } from '@/lib/hooks/use-i18n';
  15. import {
  16. loadImageMapping,
  17. loadPdfBlob,
  18. cleanupOldImages,
  19. storeImages,
  20. } from '@/lib/utils/image-storage';
  21. import { getCurrentModelConfig } from '@/lib/utils/model-config';
  22. import { db } from '@/lib/utils/database';
  23. import { MAX_PDF_CONTENT_CHARS, MAX_VISION_IMAGES } from '@/lib/constants/generation';
  24. import { nanoid } from 'nanoid';
  25. import type { Stage } from '@/lib/types/stage';
  26. import type { SceneOutline, PdfImage, ImageMapping } from '@/lib/types/generation';
  27. import { AgentRevealModal } from '@/components/agent/agent-reveal-modal';
  28. import { createLogger } from '@/lib/logger';
  29. import { type GenerationSessionState, ALL_STEPS, getActiveSteps } from './types';
  30. import { StepVisualizer } from './components/visualizers';
  31. const log = createLogger('GenerationPreview');
  32. function GenerationPreviewContent() {
  33. const router = useRouter();
  34. const { t } = useI18n();
  35. const hasStartedRef = useRef(false);
  36. const abortControllerRef = useRef<AbortController | null>(null);
  37. const [session, setSession] = useState<GenerationSessionState | null>(null);
  38. const [sessionLoaded, setSessionLoaded] = useState(false);
  39. const [error, setError] = useState<string | null>(null);
  40. const [currentStepIndex, setCurrentStepIndex] = useState(0);
  41. const [isComplete] = useState(false);
  42. const [statusMessage, setStatusMessage] = useState('');
  43. const [streamingOutlines, setStreamingOutlines] = useState<SceneOutline[] | null>(null);
  44. const [truncationWarnings, setTruncationWarnings] = useState<string[]>([]);
  45. const [webSearchSources, setWebSearchSources] = useState<Array<{ title: string; url: string }>>(
  46. [],
  47. );
  48. const [showAgentReveal, setShowAgentReveal] = useState(false);
  49. const [generatedAgents, setGeneratedAgents] = useState<
  50. Array<{
  51. id: string;
  52. name: string;
  53. role: string;
  54. persona: string;
  55. avatar: string;
  56. color: string;
  57. priority: number;
  58. }>
  59. >([]);
  60. const agentRevealResolveRef = useRef<(() => void) | null>(null);
  61. // Compute active steps based on session state
  62. const activeSteps = getActiveSteps(session);
  63. // Load session from sessionStorage
  64. useEffect(() => {
  65. cleanupOldImages(24).catch((e) => log.error(e));
  66. const saved = sessionStorage.getItem('generationSession');
  67. if (saved) {
  68. try {
  69. const parsed = JSON.parse(saved) as GenerationSessionState;
  70. setSession(parsed);
  71. } catch (e) {
  72. log.error('Failed to parse generation session:', e);
  73. }
  74. }
  75. setSessionLoaded(true);
  76. }, []);
  77. // Abort all in-flight requests on unmount
  78. useEffect(() => {
  79. return () => {
  80. abortControllerRef.current?.abort();
  81. };
  82. }, []);
  83. // Get API credentials from localStorage
  84. const getApiHeaders = () => {
  85. const modelConfig = getCurrentModelConfig();
  86. const settings = useSettingsStore.getState();
  87. const imageProviderConfig = settings.imageProvidersConfig?.[settings.imageProviderId];
  88. const videoProviderConfig = settings.videoProvidersConfig?.[settings.videoProviderId];
  89. return {
  90. 'Content-Type': 'application/json',
  91. 'x-model': modelConfig.modelString,
  92. 'x-api-key': modelConfig.apiKey,
  93. 'x-base-url': modelConfig.baseUrl,
  94. 'x-provider-type': modelConfig.providerType || '',
  95. // Image generation provider
  96. 'x-image-provider': settings.imageProviderId || '',
  97. 'x-image-model': settings.imageModelId || '',
  98. 'x-image-api-key': imageProviderConfig?.apiKey || '',
  99. 'x-image-base-url': imageProviderConfig?.baseUrl || '',
  100. // Video generation provider
  101. 'x-video-provider': settings.videoProviderId || '',
  102. 'x-video-model': settings.videoModelId || '',
  103. 'x-video-api-key': videoProviderConfig?.apiKey || '',
  104. 'x-video-base-url': videoProviderConfig?.baseUrl || '',
  105. // Media generation toggles
  106. 'x-image-generation-enabled': String(settings.imageGenerationEnabled ?? false),
  107. 'x-video-generation-enabled': String(settings.videoGenerationEnabled ?? false),
  108. };
  109. };
  110. // Auto-start generation when session is loaded
  111. useEffect(() => {
  112. if (session && !hasStartedRef.current) {
  113. hasStartedRef.current = true;
  114. startGeneration();
  115. }
  116. // eslint-disable-next-line react-hooks/exhaustive-deps
  117. }, [session]);
  118. // Main generation flow
  119. const startGeneration = async () => {
  120. if (!session) return;
  121. // Create AbortController for this generation run
  122. abortControllerRef.current?.abort();
  123. const controller = new AbortController();
  124. abortControllerRef.current = controller;
  125. const signal = controller.signal;
  126. // Use a local mutable copy so we can update it after PDF parsing
  127. let currentSession = session;
  128. setError(null);
  129. setCurrentStepIndex(0);
  130. try {
  131. // Compute active steps for this session (recomputed after session mutations)
  132. let activeSteps = getActiveSteps(currentSession);
  133. // Determine if we need the PDF analysis step
  134. const hasPdfToAnalyze = !!currentSession.pdfStorageKey && !currentSession.pdfText;
  135. // If no PDF to analyze, skip to the next available step
  136. if (!hasPdfToAnalyze) {
  137. const firstNonPdfIdx = activeSteps.findIndex((s) => s.id !== 'pdf-analysis');
  138. setCurrentStepIndex(Math.max(0, firstNonPdfIdx));
  139. }
  140. // Step 0: Parse PDF if needed
  141. if (hasPdfToAnalyze) {
  142. log.debug('=== Generation Preview: Parsing PDF ===');
  143. const pdfBlob = await loadPdfBlob(currentSession.pdfStorageKey!);
  144. if (!pdfBlob) {
  145. throw new Error(t('generation.pdfLoadFailed'));
  146. }
  147. // Ensure pdfBlob is a valid Blob with content
  148. if (!(pdfBlob instanceof Blob) || pdfBlob.size === 0) {
  149. log.error('Invalid PDF blob:', {
  150. type: typeof pdfBlob,
  151. size: pdfBlob instanceof Blob ? pdfBlob.size : 'N/A',
  152. });
  153. throw new Error(t('generation.pdfLoadFailed'));
  154. }
  155. // Wrap as a File to guarantee multipart/form-data with correct content-type
  156. const pdfFile = new File([pdfBlob], currentSession.pdfFileName || 'document.pdf', {
  157. type: 'application/pdf',
  158. });
  159. const parseFormData = new FormData();
  160. parseFormData.append('pdf', pdfFile);
  161. if (currentSession.pdfProviderId) {
  162. parseFormData.append('providerId', currentSession.pdfProviderId);
  163. }
  164. if (currentSession.pdfProviderConfig?.apiKey?.trim()) {
  165. parseFormData.append('apiKey', currentSession.pdfProviderConfig.apiKey);
  166. }
  167. if (currentSession.pdfProviderConfig?.baseUrl?.trim()) {
  168. parseFormData.append('baseUrl', currentSession.pdfProviderConfig.baseUrl);
  169. }
  170. const parseResponse = await fetch('/api/parse-pdf', {
  171. method: 'POST',
  172. body: parseFormData,
  173. signal,
  174. });
  175. if (!parseResponse.ok) {
  176. const errorData = await parseResponse.json();
  177. throw new Error(errorData.error || t('generation.pdfParseFailed'));
  178. }
  179. const parseResult = await parseResponse.json();
  180. if (!parseResult.success || !parseResult.data) {
  181. throw new Error(t('generation.pdfParseFailed'));
  182. }
  183. let pdfText = parseResult.data.text as string;
  184. // Truncate if needed
  185. if (pdfText.length > MAX_PDF_CONTENT_CHARS) {
  186. pdfText = pdfText.substring(0, MAX_PDF_CONTENT_CHARS);
  187. }
  188. // Create image metadata and store images
  189. // Prefer metadata.pdfImages (both parsers now return this)
  190. const rawPdfImages = parseResult.data.metadata?.pdfImages;
  191. const images = rawPdfImages
  192. ? rawPdfImages.map(
  193. (img: {
  194. id: string;
  195. src?: string;
  196. pageNumber?: number;
  197. description?: string;
  198. width?: number;
  199. height?: number;
  200. }) => ({
  201. id: img.id,
  202. src: img.src || '',
  203. pageNumber: img.pageNumber || 1,
  204. description: img.description,
  205. width: img.width,
  206. height: img.height,
  207. }),
  208. )
  209. : (parseResult.data.images as string[]).map((src: string, i: number) => ({
  210. id: `img_${i + 1}`,
  211. src,
  212. pageNumber: 1,
  213. }));
  214. const imageStorageIds = await storeImages(images);
  215. const pdfImages: PdfImage[] = images.map(
  216. (
  217. img: {
  218. id: string;
  219. src: string;
  220. pageNumber: number;
  221. description?: string;
  222. width?: number;
  223. height?: number;
  224. },
  225. i: number,
  226. ) => ({
  227. id: img.id,
  228. src: '',
  229. pageNumber: img.pageNumber,
  230. description: img.description,
  231. width: img.width,
  232. height: img.height,
  233. storageId: imageStorageIds[i],
  234. }),
  235. );
  236. // Update session with parsed PDF data
  237. const updatedSession = {
  238. ...currentSession,
  239. pdfText,
  240. pdfImages,
  241. imageStorageIds,
  242. pdfStorageKey: undefined, // Clear so we don't re-parse
  243. };
  244. setSession(updatedSession);
  245. sessionStorage.setItem('generationSession', JSON.stringify(updatedSession));
  246. // Truncation warnings
  247. const warnings: string[] = [];
  248. if ((parseResult.data.text as string).length > MAX_PDF_CONTENT_CHARS) {
  249. warnings.push(t('generation.textTruncated', { n: MAX_PDF_CONTENT_CHARS }));
  250. }
  251. if (images.length > MAX_VISION_IMAGES) {
  252. warnings.push(
  253. t('generation.imageTruncated', { total: images.length, max: MAX_VISION_IMAGES }),
  254. );
  255. }
  256. if (warnings.length > 0) {
  257. setTruncationWarnings(warnings);
  258. }
  259. // Reassign local reference for subsequent steps
  260. currentSession = updatedSession;
  261. activeSteps = getActiveSteps(currentSession);
  262. }
  263. // Step: Web Search (if enabled)
  264. const webSearchStepIdx = activeSteps.findIndex((s) => s.id === 'web-search');
  265. if (currentSession.requirements.webSearch && webSearchStepIdx >= 0) {
  266. setCurrentStepIndex(webSearchStepIdx);
  267. setWebSearchSources([]);
  268. const wsSettings = useSettingsStore.getState();
  269. const wsApiKey =
  270. wsSettings.webSearchProvidersConfig?.[wsSettings.webSearchProviderId]?.apiKey;
  271. const res = await fetch('/api/web-search', {
  272. method: 'POST',
  273. headers: getApiHeaders(),
  274. body: JSON.stringify({
  275. query: currentSession.requirements.requirement,
  276. pdfText: currentSession.pdfText || undefined,
  277. apiKey: wsApiKey || undefined,
  278. }),
  279. signal,
  280. });
  281. if (!res.ok) {
  282. const data = await res.json().catch(() => ({ error: 'Web search failed' }));
  283. throw new Error(data.error || t('generation.webSearchFailed'));
  284. }
  285. const searchData = await res.json();
  286. const sources = (searchData.sources || []).map((s: { title: string; url: string }) => ({
  287. title: s.title,
  288. url: s.url,
  289. }));
  290. setWebSearchSources(sources);
  291. const updatedSessionWithSearch = {
  292. ...currentSession,
  293. researchContext: searchData.context || '',
  294. researchSources: sources,
  295. };
  296. setSession(updatedSessionWithSearch);
  297. sessionStorage.setItem('generationSession', JSON.stringify(updatedSessionWithSearch));
  298. currentSession = updatedSessionWithSearch;
  299. activeSteps = getActiveSteps(currentSession);
  300. }
  301. // Load imageMapping early (needed for both outline and scene generation)
  302. let imageMapping: ImageMapping = {};
  303. if (currentSession.imageStorageIds && currentSession.imageStorageIds.length > 0) {
  304. log.debug('Loading images from IndexedDB');
  305. imageMapping = await loadImageMapping(currentSession.imageStorageIds);
  306. } else if (
  307. currentSession.imageMapping &&
  308. Object.keys(currentSession.imageMapping).length > 0
  309. ) {
  310. log.debug('Using imageMapping from session (old format)');
  311. imageMapping = currentSession.imageMapping;
  312. }
  313. // ── Agent generation (before outlines so persona can influence structure) ──
  314. const settings = useSettingsStore.getState();
  315. let agents: Array<{
  316. id: string;
  317. name: string;
  318. role: string;
  319. persona?: string;
  320. }> = [];
  321. // Create stage client-side (needed for agent generation stageId)
  322. const stageId = nanoid(10);
  323. const stage: Stage = {
  324. id: stageId,
  325. name: extractTopicFromRequirement(currentSession.requirements.requirement),
  326. description: '',
  327. language: currentSession.requirements.language || 'zh-CN',
  328. style: 'professional',
  329. createdAt: Date.now(),
  330. updatedAt: Date.now(),
  331. };
  332. if (settings.agentMode === 'auto') {
  333. const agentStepIdx = activeSteps.findIndex((s) => s.id === 'agent-generation');
  334. if (agentStepIdx >= 0) setCurrentStepIndex(agentStepIdx);
  335. try {
  336. const allAvatars = [
  337. {
  338. path: '/avatars/teacher.png',
  339. desc: 'Male teacher with glasses, holding a book, green background',
  340. },
  341. {
  342. path: '/avatars/teacher-2.png',
  343. desc: 'Female teacher with long dark hair, blue traditional outfit, gentle expression',
  344. },
  345. {
  346. path: '/avatars/assist.png',
  347. desc: 'Young female assistant with glasses, pink background, friendly smile',
  348. },
  349. {
  350. path: '/avatars/assist-2.png',
  351. desc: 'Young female in orange top and purple overalls, cheerful and approachable',
  352. },
  353. {
  354. path: '/avatars/clown.png',
  355. desc: 'Energetic girl with glasses pointing up, green shirt, lively and fun',
  356. },
  357. {
  358. path: '/avatars/clown-2.png',
  359. desc: 'Playful girl with curly hair doing rock gesture, blue shirt, humorous vibe',
  360. },
  361. {
  362. path: '/avatars/curious.png',
  363. desc: 'Surprised boy with glasses, hand on cheek, curious expression',
  364. },
  365. {
  366. path: '/avatars/curious-2.png',
  367. desc: 'Boy with backpack holding a book and question mark bubble, inquisitive',
  368. },
  369. {
  370. path: '/avatars/note-taker.png',
  371. desc: 'Studious boy with glasses, blue shirt, calm and organized',
  372. },
  373. {
  374. path: '/avatars/note-taker-2.png',
  375. desc: 'Active boy with yellow backpack waving, blue outfit, enthusiastic learner',
  376. },
  377. {
  378. path: '/avatars/thinker.png',
  379. desc: 'Thoughtful girl with hand on chin, purple background, contemplative',
  380. },
  381. {
  382. path: '/avatars/thinker-2.png',
  383. desc: 'Girl reading a book intently, long dark hair, intellectual and focused',
  384. },
  385. ];
  386. const getAvailableVoicesForGeneration = () => {
  387. const providers = getAvailableProvidersWithVoices(settings.ttsProvidersConfig);
  388. return providers.flatMap((p) =>
  389. p.voices.map((v) => ({
  390. providerId: p.providerId,
  391. voiceId: v.id,
  392. voiceName: v.name,
  393. })),
  394. );
  395. };
  396. // No outlines yet — agent generation uses only stage name + description
  397. const agentResp = await fetch('/api/generate/agent-profiles', {
  398. method: 'POST',
  399. headers: getApiHeaders(),
  400. body: JSON.stringify({
  401. stageInfo: { name: stage.name, description: stage.description },
  402. language: currentSession.requirements.language || 'zh-CN',
  403. availableAvatars: allAvatars.map((a) => a.path),
  404. avatarDescriptions: allAvatars.map((a) => ({ path: a.path, desc: a.desc })),
  405. availableVoices: getAvailableVoicesForGeneration(),
  406. }),
  407. signal,
  408. });
  409. if (!agentResp.ok) throw new Error('Agent generation failed');
  410. const agentData = await agentResp.json();
  411. if (!agentData.success) throw new Error(agentData.error || 'Agent generation failed');
  412. // Save to IndexedDB and registry
  413. const { saveGeneratedAgents } = await import('@/lib/orchestration/registry/store');
  414. const savedIds = await saveGeneratedAgents(stage.id, agentData.agents);
  415. settings.setSelectedAgentIds(savedIds);
  416. stage.agentIds = savedIds;
  417. // Show card-reveal modal, continue generation once all cards are revealed
  418. setGeneratedAgents(agentData.agents);
  419. setShowAgentReveal(true);
  420. await new Promise<void>((resolve) => {
  421. agentRevealResolveRef.current = resolve;
  422. });
  423. agents = savedIds
  424. .map((id) => useAgentRegistry.getState().getAgent(id))
  425. .filter(Boolean)
  426. .map((a) => ({
  427. id: a!.id,
  428. name: a!.name,
  429. role: a!.role,
  430. persona: a!.persona,
  431. }));
  432. } catch (err: unknown) {
  433. log.warn('[Generation] Agent generation failed, falling back to presets:', err);
  434. const registry = useAgentRegistry.getState();
  435. const fallbackIds = settings.selectedAgentIds.filter((id) => {
  436. const a = registry.getAgent(id);
  437. return a && !a.isGenerated;
  438. });
  439. agents = fallbackIds
  440. .map((id) => registry.getAgent(id))
  441. .filter(Boolean)
  442. .map((a) => ({
  443. id: a!.id,
  444. name: a!.name,
  445. role: a!.role,
  446. persona: a!.persona,
  447. }));
  448. stage.agentIds = fallbackIds;
  449. }
  450. } else {
  451. // Preset mode — use selected agents (include persona)
  452. // Filter out stale generated agent IDs that may linger in settings
  453. const registry = useAgentRegistry.getState();
  454. const presetAgentIds = settings.selectedAgentIds.filter((id) => {
  455. const a = registry.getAgent(id);
  456. return a && !a.isGenerated;
  457. });
  458. agents = presetAgentIds
  459. .map((id) => registry.getAgent(id))
  460. .filter(Boolean)
  461. .map((a) => ({
  462. id: a!.id,
  463. name: a!.name,
  464. role: a!.role,
  465. persona: a!.persona,
  466. }));
  467. stage.agentIds = presetAgentIds;
  468. }
  469. // ── Generate outlines (with agent personas for teacher context) ──
  470. let outlines = currentSession.sceneOutlines;
  471. const outlineStepIdx = activeSteps.findIndex((s) => s.id === 'outline');
  472. setCurrentStepIndex(outlineStepIdx >= 0 ? outlineStepIdx : 0);
  473. if (!outlines || outlines.length === 0) {
  474. log.debug('=== Generating outlines (SSE) ===');
  475. setStreamingOutlines([]);
  476. outlines = await new Promise<SceneOutline[]>((resolve, reject) => {
  477. const collected: SceneOutline[] = [];
  478. fetch('/api/generate/scene-outlines-stream', {
  479. method: 'POST',
  480. headers: getApiHeaders(),
  481. body: JSON.stringify({
  482. requirements: currentSession.requirements,
  483. pdfText: currentSession.pdfText,
  484. pdfImages: currentSession.pdfImages,
  485. imageMapping,
  486. researchContext: currentSession.researchContext,
  487. agents,
  488. }),
  489. signal,
  490. })
  491. .then((res) => {
  492. if (!res.ok) {
  493. return res.json().then((d) => {
  494. reject(new Error(d.error || t('generation.outlineGenerateFailed')));
  495. });
  496. }
  497. const reader = res.body?.getReader();
  498. if (!reader) {
  499. reject(new Error(t('generation.streamNotReadable')));
  500. return;
  501. }
  502. const decoder = new TextDecoder();
  503. let sseBuffer = '';
  504. const pump = (): Promise<void> =>
  505. reader.read().then(({ done, value }) => {
  506. if (value) {
  507. sseBuffer += decoder.decode(value, { stream: !done });
  508. const lines = sseBuffer.split('\n');
  509. sseBuffer = lines.pop() || '';
  510. for (const line of lines) {
  511. if (!line.startsWith('data: ')) continue;
  512. try {
  513. const evt = JSON.parse(line.slice(6));
  514. if (evt.type === 'outline') {
  515. collected.push(evt.data);
  516. setStreamingOutlines([...collected]);
  517. } else if (evt.type === 'retry') {
  518. collected.length = 0;
  519. setStreamingOutlines([]);
  520. setStatusMessage(t('generation.outlineRetrying'));
  521. } else if (evt.type === 'done') {
  522. resolve(evt.outlines || collected);
  523. return;
  524. } else if (evt.type === 'error') {
  525. reject(new Error(evt.error));
  526. return;
  527. }
  528. } catch (e) {
  529. log.error('Failed to parse outline SSE:', line, e);
  530. }
  531. }
  532. }
  533. if (done) {
  534. if (collected.length > 0) {
  535. resolve(collected);
  536. } else {
  537. reject(new Error(t('generation.outlineEmptyResponse')));
  538. }
  539. return;
  540. }
  541. return pump();
  542. });
  543. pump().catch(reject);
  544. })
  545. .catch(reject);
  546. });
  547. const updatedSession = { ...currentSession, sceneOutlines: outlines };
  548. setSession(updatedSession);
  549. sessionStorage.setItem('generationSession', JSON.stringify(updatedSession));
  550. // Outline generation succeeded — clear homepage draft cache
  551. try {
  552. localStorage.removeItem('requirementDraft');
  553. } catch {
  554. /* ignore */
  555. }
  556. // Brief pause to let user see the final outline state
  557. await new Promise((resolve) => setTimeout(resolve, 800));
  558. }
  559. // Move to scene generation step
  560. setStatusMessage('');
  561. if (!outlines || outlines.length === 0) {
  562. throw new Error(t('generation.outlineEmptyResponse'));
  563. }
  564. // Store stage and outlines
  565. const store = useStageStore.getState();
  566. store.setStage(stage);
  567. store.setOutlines(outlines);
  568. // Advance to slide-content step
  569. const contentStepIdx = activeSteps.findIndex((s) => s.id === 'slide-content');
  570. if (contentStepIdx >= 0) setCurrentStepIndex(contentStepIdx);
  571. // Build stageInfo and userProfile for API call
  572. const stageInfo = {
  573. name: stage.name,
  574. description: stage.description,
  575. language: stage.language,
  576. style: stage.style,
  577. };
  578. const userProfile =
  579. currentSession.requirements.userNickname || currentSession.requirements.userBio
  580. ? `Student: ${currentSession.requirements.userNickname || 'Unknown'}${currentSession.requirements.userBio ? ` — ${currentSession.requirements.userBio}` : ''}`
  581. : undefined;
  582. // Generate ONLY the first scene
  583. store.setGeneratingOutlines(outlines);
  584. const firstOutline = outlines[0];
  585. // Step 2: Generate content (currentStepIndex is already 2)
  586. const contentResp = await fetch('/api/generate/scene-content', {
  587. method: 'POST',
  588. headers: getApiHeaders(),
  589. body: JSON.stringify({
  590. outline: firstOutline,
  591. allOutlines: outlines,
  592. pdfImages: currentSession.pdfImages,
  593. imageMapping,
  594. stageInfo,
  595. stageId: stage.id,
  596. agents,
  597. }),
  598. signal,
  599. });
  600. if (!contentResp.ok) {
  601. const errorData = await contentResp.json().catch(() => ({ error: 'Request failed' }));
  602. throw new Error(errorData.error || t('generation.sceneGenerateFailed'));
  603. }
  604. const contentData = await contentResp.json();
  605. if (!contentData.success || !contentData.content) {
  606. throw new Error(contentData.error || t('generation.sceneGenerateFailed'));
  607. }
  608. // Generate actions (activate actions step indicator)
  609. const actionsStepIdx = activeSteps.findIndex((s) => s.id === 'actions');
  610. setCurrentStepIndex(actionsStepIdx >= 0 ? actionsStepIdx : currentStepIndex + 1);
  611. const actionsResp = await fetch('/api/generate/scene-actions', {
  612. method: 'POST',
  613. headers: getApiHeaders(),
  614. body: JSON.stringify({
  615. outline: contentData.effectiveOutline || firstOutline,
  616. allOutlines: outlines,
  617. content: contentData.content,
  618. stageId: stage.id,
  619. agents,
  620. previousSpeeches: [],
  621. userProfile,
  622. }),
  623. signal,
  624. });
  625. if (!actionsResp.ok) {
  626. const errorData = await actionsResp.json().catch(() => ({ error: 'Request failed' }));
  627. throw new Error(errorData.error || t('generation.sceneGenerateFailed'));
  628. }
  629. const data = await actionsResp.json();
  630. if (!data.success || !data.scene) {
  631. throw new Error(data.error || t('generation.sceneGenerateFailed'));
  632. }
  633. // Generate TTS for first scene (part of actions step — blocking)
  634. if (settings.ttsEnabled && settings.ttsProviderId !== 'browser-native-tts') {
  635. const ttsProviderConfig = settings.ttsProvidersConfig?.[settings.ttsProviderId];
  636. const speechActions = (data.scene.actions || []).filter(
  637. (a: { type: string; text?: string }) => a.type === 'speech' && a.text,
  638. );
  639. let ttsFailCount = 0;
  640. for (const action of speechActions) {
  641. const audioId = `tts_${action.id}`;
  642. action.audioId = audioId;
  643. try {
  644. const resp = await fetch('/api/generate/tts', {
  645. method: 'POST',
  646. headers: { 'Content-Type': 'application/json' },
  647. body: JSON.stringify({
  648. text: action.text,
  649. audioId,
  650. ttsProviderId: settings.ttsProviderId,
  651. ttsModelId: ttsProviderConfig?.modelId,
  652. ttsVoice: settings.ttsVoice,
  653. ttsSpeed: settings.ttsSpeed,
  654. ttsApiKey: ttsProviderConfig?.apiKey || undefined,
  655. ttsBaseUrl: ttsProviderConfig?.baseUrl || undefined,
  656. }),
  657. signal,
  658. });
  659. if (!resp.ok) {
  660. ttsFailCount++;
  661. continue;
  662. }
  663. const ttsData = await resp.json();
  664. if (!ttsData.success) {
  665. ttsFailCount++;
  666. continue;
  667. }
  668. const binary = atob(ttsData.base64);
  669. const bytes = new Uint8Array(binary.length);
  670. for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
  671. const blob = new Blob([bytes], { type: `audio/${ttsData.format}` });
  672. await db.audioFiles.put({
  673. id: audioId,
  674. blob,
  675. format: ttsData.format,
  676. createdAt: Date.now(),
  677. });
  678. } catch (err) {
  679. log.warn(`[TTS] Failed for ${audioId}:`, err);
  680. ttsFailCount++;
  681. }
  682. }
  683. if (ttsFailCount > 0 && speechActions.length > 0) {
  684. throw new Error(t('generation.speechFailed'));
  685. }
  686. }
  687. // Add scene to store and navigate
  688. store.addScene(data.scene);
  689. store.setCurrentSceneId(data.scene.id);
  690. // Set remaining outlines as skeleton placeholders
  691. const remaining = outlines.filter((o) => o.order !== data.scene.order);
  692. store.setGeneratingOutlines(remaining);
  693. // Store generation params for classroom to continue generation
  694. sessionStorage.setItem(
  695. 'generationParams',
  696. JSON.stringify({
  697. pdfImages: currentSession.pdfImages,
  698. agents,
  699. userProfile,
  700. }),
  701. );
  702. sessionStorage.removeItem('generationSession');
  703. await store.saveToStorage();
  704. router.push(`/classroom/${stage.id}`);
  705. } catch (err) {
  706. // AbortError is expected when navigating away — don't show as error
  707. if (err instanceof DOMException && err.name === 'AbortError') {
  708. log.info('[GenerationPreview] Generation aborted');
  709. return;
  710. }
  711. sessionStorage.removeItem('generationSession');
  712. setError(err instanceof Error ? err.message : String(err));
  713. }
  714. };
  715. const extractTopicFromRequirement = (requirement: string): string => {
  716. const trimmed = requirement.trim();
  717. if (trimmed.length <= 500) {
  718. return trimmed;
  719. }
  720. return trimmed.substring(0, 500).trim() + '...';
  721. };
  722. const goBackToHome = () => {
  723. abortControllerRef.current?.abort();
  724. sessionStorage.removeItem('generationSession');
  725. router.push('/');
  726. };
  727. // Still loading session from sessionStorage
  728. if (!sessionLoaded) {
  729. return (
  730. <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">
  731. <div className="text-center text-muted-foreground">
  732. <div className="size-8 border-2 border-current border-t-transparent rounded-full animate-spin mx-auto" />
  733. </div>
  734. </div>
  735. );
  736. }
  737. // No session found
  738. if (!session) {
  739. return (
  740. <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">
  741. <Card className="p-8 max-w-md w-full">
  742. <div className="text-center space-y-4">
  743. <AlertCircle className="size-12 text-muted-foreground mx-auto" />
  744. <h2 className="text-xl font-semibold">{t('generation.sessionNotFound')}</h2>
  745. <p className="text-sm text-muted-foreground">{t('generation.sessionNotFoundDesc')}</p>
  746. <Button onClick={() => router.push('/')} className="w-full">
  747. <ArrowLeft className="size-4 mr-2" />
  748. {t('generation.backToHome')}
  749. </Button>
  750. </div>
  751. </Card>
  752. </div>
  753. );
  754. }
  755. const activeStep =
  756. activeSteps.length > 0
  757. ? activeSteps[Math.min(currentStepIndex, activeSteps.length - 1)]
  758. : ALL_STEPS[0];
  759. return (
  760. <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">
  761. {/* Background Decor */}
  762. <div className="fixed inset-0 overflow-hidden pointer-events-none z-0">
  763. <div
  764. className="absolute top-0 left-1/4 w-96 h-96 bg-blue-500/10 rounded-full blur-3xl animate-pulse"
  765. style={{ animationDuration: '4s' }}
  766. />
  767. <div
  768. className="absolute bottom-0 right-1/4 w-96 h-96 bg-purple-500/10 rounded-full blur-3xl animate-pulse"
  769. style={{ animationDuration: '6s' }}
  770. />
  771. </div>
  772. {/* Back button */}
  773. <motion.div
  774. initial={{ opacity: 0, y: -20 }}
  775. animate={{ opacity: 1, y: 0 }}
  776. className="absolute top-4 left-4 z-20"
  777. >
  778. <Button variant="ghost" size="sm" onClick={goBackToHome}>
  779. <ArrowLeft className="size-4 mr-2" />
  780. {t('generation.backToHome')}
  781. </Button>
  782. </motion.div>
  783. <div className="z-10 w-full max-w-lg space-y-8 flex flex-col items-center">
  784. <motion.div
  785. initial={{ opacity: 0, y: 20 }}
  786. animate={{ opacity: 1, y: 0 }}
  787. transition={{ duration: 0.5 }}
  788. className="w-full"
  789. >
  790. <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">
  791. {/* Progress Dots */}
  792. <div className="absolute top-6 left-0 right-0 flex justify-center gap-2">
  793. {activeSteps.map((step, idx) => (
  794. <div
  795. key={step.id}
  796. className={cn(
  797. 'h-1.5 rounded-full transition-all duration-500',
  798. idx < currentStepIndex
  799. ? 'w-1.5 bg-blue-500/30'
  800. : idx === currentStepIndex
  801. ? 'w-8 bg-blue-500'
  802. : 'w-1.5 bg-muted/50',
  803. )}
  804. />
  805. ))}
  806. </div>
  807. {/* Central Content */}
  808. <div className="flex-1 flex flex-col items-center justify-center w-full space-y-8 mt-4">
  809. {/* Icon / Visualizer Container */}
  810. <div className="relative size-48 flex items-center justify-center">
  811. <AnimatePresence mode="popLayout">
  812. {error ? (
  813. <motion.div
  814. key="error"
  815. initial={{ scale: 0.5, opacity: 0 }}
  816. animate={{ scale: 1, opacity: 1 }}
  817. className="size-32 rounded-full bg-red-500/10 flex items-center justify-center border-2 border-red-500/20"
  818. >
  819. <AlertCircle className="size-16 text-red-500" />
  820. </motion.div>
  821. ) : isComplete ? (
  822. <motion.div
  823. key="complete"
  824. initial={{ scale: 0.5, opacity: 0 }}
  825. animate={{ scale: 1, opacity: 1 }}
  826. className="size-32 rounded-full bg-green-500/10 flex items-center justify-center border-2 border-green-500/20"
  827. >
  828. <CheckCircle2 className="size-16 text-green-500" />
  829. </motion.div>
  830. ) : (
  831. <motion.div
  832. key={activeStep.id}
  833. initial={{ scale: 0.8, opacity: 0, filter: 'blur(10px)' }}
  834. animate={{ scale: 1, opacity: 1, filter: 'blur(0px)' }}
  835. exit={{ scale: 1.2, opacity: 0, filter: 'blur(10px)' }}
  836. transition={{ duration: 0.4 }}
  837. className="absolute inset-0 flex items-center justify-center"
  838. >
  839. <StepVisualizer
  840. stepId={activeStep.id}
  841. outlines={streamingOutlines}
  842. webSearchSources={webSearchSources}
  843. />
  844. </motion.div>
  845. )}
  846. </AnimatePresence>
  847. </div>
  848. {/* Text Content */}
  849. <div className="space-y-3 max-w-sm mx-auto">
  850. <AnimatePresence mode="wait">
  851. <motion.div
  852. key={error ? 'error' : isComplete ? 'done' : activeStep.id}
  853. initial={{ opacity: 0, y: 10 }}
  854. animate={{ opacity: 1, y: 0 }}
  855. exit={{ opacity: 0, y: -10 }}
  856. className="space-y-2"
  857. >
  858. <h2 className="text-2xl font-bold tracking-tight">
  859. {error
  860. ? t('generation.generationFailed')
  861. : isComplete
  862. ? t('generation.generationComplete')
  863. : t(activeStep.title)}
  864. </h2>
  865. <p className="text-muted-foreground text-base">
  866. {error
  867. ? error
  868. : isComplete
  869. ? t('generation.classroomReady')
  870. : statusMessage || t(activeStep.description)}
  871. </p>
  872. </motion.div>
  873. </AnimatePresence>
  874. {/* Truncation warning indicator */}
  875. <AnimatePresence>
  876. {truncationWarnings.length > 0 && !error && !isComplete && (
  877. <motion.div
  878. initial={{ opacity: 0, scale: 0 }}
  879. animate={{ opacity: 1, scale: 1 }}
  880. exit={{ opacity: 0, scale: 0 }}
  881. transition={{
  882. type: 'spring',
  883. stiffness: 500,
  884. damping: 30,
  885. }}
  886. className="flex justify-center"
  887. >
  888. <Tooltip>
  889. <TooltipTrigger asChild>
  890. <motion.button
  891. type="button"
  892. animate={{
  893. boxShadow: [
  894. '0 0 0 0 rgba(251, 191, 36, 0), 0 0 0 0 rgba(251, 191, 36, 0)',
  895. '0 0 16px 4px rgba(251, 191, 36, 0.12), 0 0 4px 1px rgba(251, 191, 36, 0.08)',
  896. '0 0 0 0 rgba(251, 191, 36, 0), 0 0 0 0 rgba(251, 191, 36, 0)',
  897. ],
  898. }}
  899. transition={{
  900. duration: 3,
  901. repeat: Infinity,
  902. ease: 'easeInOut',
  903. }}
  904. className="relative size-7 rounded-full flex items-center justify-center cursor-default
  905. bg-gradient-to-br from-amber-400/15 to-orange-400/10
  906. border border-amber-400/25 hover:border-amber-400/40
  907. hover:from-amber-400/20 hover:to-orange-400/15
  908. transition-colors duration-300
  909. focus:outline-none focus-visible:ring-2 focus-visible:ring-amber-500/30"
  910. >
  911. <AlertTriangle
  912. className="size-3.5 text-amber-500 dark:text-amber-400"
  913. strokeWidth={2.5}
  914. />
  915. </motion.button>
  916. </TooltipTrigger>
  917. <TooltipContent side="bottom" sideOffset={6}>
  918. <div className="space-y-1 py-0.5">
  919. {truncationWarnings.map((w, i) => (
  920. <p key={i} className="text-xs leading-relaxed">
  921. {w}
  922. </p>
  923. ))}
  924. </div>
  925. </TooltipContent>
  926. </Tooltip>
  927. </motion.div>
  928. )}
  929. </AnimatePresence>
  930. </div>
  931. </div>
  932. </Card>
  933. </motion.div>
  934. {/* Footer Action */}
  935. <div className="h-16 flex items-center justify-center w-full">
  936. <AnimatePresence>
  937. {error ? (
  938. <motion.div
  939. initial={{ opacity: 0, y: 10 }}
  940. animate={{ opacity: 1, y: 0 }}
  941. className="w-full max-w-xs"
  942. >
  943. <Button size="lg" variant="outline" className="w-full h-12" onClick={goBackToHome}>
  944. {t('generation.goBackAndRetry')}
  945. </Button>
  946. </motion.div>
  947. ) : !isComplete ? (
  948. <motion.div
  949. initial={{ opacity: 0 }}
  950. animate={{ opacity: 1 }}
  951. className="flex items-center gap-3 text-sm text-muted-foreground/50 font-medium uppercase tracking-widest"
  952. >
  953. <Sparkles className="size-3 animate-pulse" />
  954. {t('generation.aiWorking')}
  955. {generatedAgents.length > 0 && !showAgentReveal && (
  956. <button
  957. onClick={() => setShowAgentReveal(true)}
  958. 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"
  959. >
  960. <Bot className="size-3" />
  961. {t('generation.viewAgents')}
  962. </button>
  963. )}
  964. </motion.div>
  965. ) : null}
  966. </AnimatePresence>
  967. </div>
  968. </div>
  969. {/* Agent Reveal Modal */}
  970. <AgentRevealModal
  971. agents={generatedAgents}
  972. open={showAgentReveal}
  973. onClose={() => setShowAgentReveal(false)}
  974. onAllRevealed={() => {
  975. agentRevealResolveRef.current?.();
  976. agentRevealResolveRef.current = null;
  977. }}
  978. />
  979. </div>
  980. );
  981. }
  982. export default function GenerationPreviewPage() {
  983. return (
  984. <Suspense
  985. fallback={
  986. <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">
  987. <div className="animate-pulse space-y-4 text-center">
  988. <div className="h-8 w-48 bg-muted rounded mx-auto" />
  989. <div className="h-4 w-64 bg-muted rounded mx-auto" />
  990. </div>
  991. </div>
  992. }
  993. >
  994. <GenerationPreviewContent />
  995. </Suspense>
  996. );
  997. }