diff --git a/app/classroom/[id]/page.tsx b/app/classroom/[id]/page.tsx index be311c493..94833c16a 100644 --- a/app/classroom/[id]/page.tsx +++ b/app/classroom/[id]/page.tsx @@ -26,7 +26,7 @@ export default function ClassroomDetailPage() { const generationStartedRef = useRef(false); - const { generateRemaining, retrySingleOutline, stop } = useSceneGenerator({ + const { generateRemaining, retrySingleOutline, stop, regenerateScene } = useSceneGenerator({ onComplete: () => { log.info('[Classroom] All scenes generated'); }, @@ -140,37 +140,41 @@ export default function ClassroomDetailPage() { const completedOrders = new Set(scenes.map((s) => s.order)); const hasPending = outlines.some((o) => !completedOrders.has(o.order)); - if (hasPending && stage) { - generationStartedRef.current = true; - - // Load generation params from sessionStorage (stored by generation-preview before navigating) - const genParamsStr = sessionStorage.getItem('generationParams'); - const params = genParamsStr ? JSON.parse(genParamsStr) : {}; - - // Reconstruct imageMapping from IndexedDB using pdfImages storageIds - const storageIds = (params.pdfImages || []) - .map((img: { storageId?: string }) => img.storageId) - .filter(Boolean); - - loadImageMapping(storageIds).then((imageMapping) => { - generateRemaining({ - pdfImages: params.pdfImages, - imageMapping, - stageInfo: { - name: stage.name || '', - description: stage.description, - language: stage.language, - style: stage.style, - }, - agents: params.agents, - userProfile: params.userProfile, - }); + if (!stage) return; + + generationStartedRef.current = true; + + // Load generation params from sessionStorage (stored by generation-preview before navigating) + const genParamsStr = sessionStorage.getItem('generationParams'); + const params = genParamsStr ? JSON.parse(genParamsStr) : {}; + + // Reconstruct imageMapping from IndexedDB using pdfImages storageIds + const storageIds = (params.pdfImages || []) + .map((img: { storageId?: string }) => img.storageId) + .filter(Boolean); + + // Always call generateRemaining to set lastParamsRef (needed for regenerateScene) + // It will early-return if there's nothing to generate + loadImageMapping(storageIds).then((imageMapping) => { + generateRemaining({ + pdfImages: params.pdfImages, + imageMapping, + stageInfo: { + name: stage.name || '', + description: stage.description, + language: stage.language, + style: stage.style, + }, + agents: params.agents, + userProfile: params.userProfile, }); - } else if (outlines.length > 0 && stage) { + }); + + // If no pending outlines, also resume media generation in background + if (!hasPending && outlines.length > 0) { // All scenes are generated, but some media may not have finished. // Resume media generation for any tasks not yet in IndexedDB. // generateMediaForOutlines skips already-completed tasks automatically. - generationStartedRef.current = true; generateMediaForOutlines(outlines, stage.id).catch((err) => { log.warn('[Classroom] Media generation resume error:', err); }); @@ -204,7 +208,7 @@ export default function ClassroomDetailPage() { ) : ( - + )} diff --git a/components/stage.tsx b/components/stage.tsx index 04b826fc6..3561171a4 100644 --- a/components/stage.tsx +++ b/components/stage.tsx @@ -6,6 +6,7 @@ import { PENDING_SCENE_ID } from '@/lib/store/stage'; import { useCanvasStore } from '@/lib/store/canvas'; import { useSettingsStore } from '@/lib/store/settings'; import { useI18n } from '@/lib/hooks/use-i18n'; +import { useSceneGenerator } from '@/lib/hooks/use-scene-generator'; import { SceneSidebar } from './stage/scene-sidebar'; import { Header } from './header'; import { CanvasArea } from '@/components/canvas/canvas-area'; @@ -42,8 +43,10 @@ import { VisuallyHidden } from 'radix-ui'; */ export function Stage({ onRetryOutline, + onRegenerateScene, }: { onRetryOutline?: (outlineId: string) => Promise; + onRegenerateScene?: (sceneId: string) => Promise; }) { const { t } = useI18n(); const { mode, getCurrentScene, scenes, currentSceneId, setCurrentSceneId, generatingOutlines } = @@ -138,6 +141,9 @@ export function Stage({ }, }); + // Scene regeneration hook + const { regenerateScene } = useSceneGenerator(); + // Pick a student agent for discussion trigger (prioritize student > non-teacher > fallback) const pickStudentAgent = useCallback((): string => { const registry = useAgentRegistry.getState(); @@ -936,6 +942,7 @@ export function Stage({ onCollapseChange={setSidebarCollapsed} onSceneSelect={gatedSceneSwitch} onRetryOutline={onRetryOutline} + onRegenerateScene={onRegenerateScene ?? regenerateScene} /> {/* Main Content Area */} diff --git a/components/stage/scene-sidebar.tsx b/components/stage/scene-sidebar.tsx index 4b75471a2..405bf42b2 100644 --- a/components/stage/scene-sidebar.tsx +++ b/components/stage/scene-sidebar.tsx @@ -24,6 +24,8 @@ interface SceneSidebarProps { readonly onCollapseChange: (collapsed: boolean) => void; readonly onSceneSelect?: (sceneId: string) => void; readonly onRetryOutline?: (outlineId: string) => Promise; + readonly onRegenerateScene?: (sceneId: string) => Promise; + readonly regeneratingSceneId?: string | null; } const DEFAULT_WIDTH = 220; @@ -35,6 +37,8 @@ export function SceneSidebar({ onCollapseChange, onSceneSelect, onRetryOutline, + onRegenerateScene, + regeneratingSceneId, }: SceneSidebarProps) { const { t } = useI18n(); const router = useRouter(); @@ -56,6 +60,43 @@ export function SceneSidebar({ } }; + const [regeneratingSceneIdState, setRegeneratingSceneIdState] = useState(null); + + const handleRegenerateScene = useCallback( + async (sceneId: string) => { + if (!onRegenerateScene) return; + setRegeneratingSceneIdState(sceneId); + try { + await onRegenerateScene(sceneId); + } finally { + setRegeneratingSceneIdState(null); + } + }, + [onRegenerateScene], + ); + + // Determines whether a scene is actively regenerating. + // Checks the local button-click state AND whether the scene's outline + // is currently in generatingOutlines (meaning the operation is running). + // This ensures the animation stays visible when the operation is queued + // (local state is cleared in finally but generatingOutlines is updated + // when the operation actually starts executing). + const isSceneRegenerating = useCallback( + (sceneId: string) => { + // Respect external prop if provided + if (regeneratingSceneId !== undefined && regeneratingSceneId !== null) { + return regeneratingSceneId === sceneId; + } + if (regeneratingSceneIdState === sceneId) return true; + const scene = scenes.find((s) => s.id === sceneId); + if (!scene) return false; + // Cross-reference: if the outline with matching order is in generatingOutlines, + // this scene is being regenerated (even if local state was cleared after queueing). + return generatingOutlines.some((o) => o.order === scene.order); + }, + [regeneratingSceneId, regeneratingSceneIdState, scenes, generatingOutlines], + ); + const [sidebarWidth, setSidebarWidth] = useState(DEFAULT_WIDTH); const isDraggingRef = useRef(false); @@ -190,6 +231,30 @@ export function SceneSidebar({ {scene.title} + {/* Regenerate Button */} + {onRegenerateScene && ( + + )} {/* Thumbnail */} diff --git a/lib/hooks/use-scene-generator.ts b/lib/hooks/use-scene-generator.ts index c48012085..debab27f8 100644 --- a/lib/hooks/use-scene-generator.ts +++ b/lib/hooks/use-scene-generator.ts @@ -240,83 +240,283 @@ export function useSceneGenerator(options: UseSceneGeneratorOptions = {}) { const store = useStageStore; + // ==================== Generation Lock (prevents concurrent scene mutations) ==================== + type QueuedOp = { + fn: () => Promise; + resolve: () => void; + reject: (e: unknown) => void; + }; + + const generationLockRef = useRef<{ + locked: boolean; + queue: QueuedOp[]; + }>({ locked: false, queue: [] }); + + const processQueue = useCallback(() => { + const lock = generationLockRef.current; + const next = lock.queue.shift(); + if (!next) return; + next + .fn() + .then(next.resolve) + .catch(next.reject) + .finally(() => { + lock.locked = false; + processQueue(); + }); + }, []); + + const withGenerationLock = useCallback( + (fn: () => Promise): Promise => { + const lock = generationLockRef.current; + if (!lock.locked) { + lock.locked = true; + return fn().finally(() => { + lock.locked = false; + processQueue(); + }); + } + return new Promise((resolve, reject) => { + lock.queue.push({ fn, resolve, reject }); + }); + }, + [processQueue], + ); + const generateRemaining = useCallback( async (params: GenerationParams) => { lastParamsRef.current = params; - if (generatingRef.current) return; - generatingRef.current = true; - abortRef.current = false; - const removeGeneratingOutline = (outlineId: string) => { - const current = store.getState().generatingOutlines; - if (!current.some((o) => o.id === outlineId)) return; - store.getState().setGeneratingOutlines(current.filter((o) => o.id !== outlineId)); - }; - - // Create a new AbortController for this generation run - fetchAbortRef.current = new AbortController(); - const signal = fetchAbortRef.current.signal; - - const state = store.getState(); - const { outlines, scenes, stage } = state; - const startEpoch = state.generationEpoch; - if (!stage || outlines.length === 0) { - generatingRef.current = false; - return; - } + return withGenerationLock(async () => { + if (generatingRef.current) return; + generatingRef.current = true; + abortRef.current = false; + const removeGeneratingOutline = (outlineId: string) => { + const current = store.getState().generatingOutlines; + if (!current.some((o) => o.id === outlineId)) return; + store.getState().setGeneratingOutlines(current.filter((o) => o.id !== outlineId)); + }; + + // Create a new AbortController for this generation run + fetchAbortRef.current = new AbortController(); + const signal = fetchAbortRef.current.signal; + + const state = store.getState(); + const { outlines, scenes, stage } = state; + const startEpoch = state.generationEpoch; + if (!stage || outlines.length === 0) { + generatingRef.current = false; + return; + } - store.getState().setGenerationStatus('generating'); + store.getState().setGenerationStatus('generating'); - // Determine pending outlines - const completedOrders = new Set(scenes.map((s) => s.order)); - const pending = outlines - .filter((o) => !completedOrders.has(o.order)) - .sort((a, b) => a.order - b.order); + // Determine pending outlines + const completedOrders = new Set(scenes.map((s) => s.order)); + const pending = outlines + .filter((o) => !completedOrders.has(o.order)) + .sort((a, b) => a.order - b.order); - if (pending.length === 0) { - store.getState().setGenerationStatus('completed'); - store.getState().setGeneratingOutlines([]); - options.onComplete?.(); - generatingRef.current = false; - return; - } + if (pending.length === 0) { + store.getState().setGenerationStatus('completed'); + store.getState().setGeneratingOutlines([]); + options.onComplete?.(); + generatingRef.current = false; + return; + } - store.getState().setGeneratingOutlines(pending); + store.getState().setGeneratingOutlines(pending); + + // Launch media generation in parallel — does not block content/action generation + mediaAbortRef.current = new AbortController(); + generateMediaForOutlines(outlines, stage.id, mediaAbortRef.current.signal).catch((err) => { + log.warn('Media generation error:', err); + }); + + // Get previousSpeeches from last completed scene + let previousSpeeches: string[] = []; + const sortedScenes = [...scenes].sort((a, b) => a.order - b.order); + if (sortedScenes.length > 0) { + const lastScene = sortedScenes[sortedScenes.length - 1]; + previousSpeeches = (lastScene.actions || []) + .filter((a): a is SpeechAction => a.type === 'speech') + .map((a) => a.text); + } - // Launch media generation in parallel — does not block content/action generation - mediaAbortRef.current = new AbortController(); - generateMediaForOutlines(outlines, stage.id, mediaAbortRef.current.signal).catch((err) => { - log.warn('Media generation error:', err); - }); + // Serial generation loop — two-step per outline + try { + let pausedByFailureOrAbort = false; + for (const outline of pending) { + if (abortRef.current || store.getState().generationEpoch !== startEpoch) { + store.getState().setGenerationStatus('paused'); + pausedByFailureOrAbort = true; + break; + } - // Get previousSpeeches from last completed scene - let previousSpeeches: string[] = []; - const sortedScenes = [...scenes].sort((a, b) => a.order - b.order); - if (sortedScenes.length > 0) { - const lastScene = sortedScenes[sortedScenes.length - 1]; - previousSpeeches = (lastScene.actions || []) - .filter((a): a is SpeechAction => a.type === 'speech') - .map((a) => a.text); - } + store.getState().setCurrentGeneratingOrder(outline.order); + + // Step 1: Generate content + options.onPhaseChange?.('content', outline); + const contentResult = await fetchSceneContent( + { + outline, + allOutlines: outlines, + stageId: stage.id, + pdfImages: params.pdfImages, + imageMapping: params.imageMapping, + stageInfo: params.stageInfo, + agents: params.agents, + }, + signal, + ); + + if (!contentResult.success || !contentResult.content) { + if (abortRef.current || store.getState().generationEpoch !== startEpoch) { + pausedByFailureOrAbort = true; + break; + } + store.getState().addFailedOutline(outline); + options.onSceneFailed?.(outline, contentResult.error || 'Content generation failed'); + store.getState().setGenerationStatus('paused'); + pausedByFailureOrAbort = true; + break; + } + + if (abortRef.current || store.getState().generationEpoch !== startEpoch) { + store.getState().setGenerationStatus('paused'); + pausedByFailureOrAbort = true; + break; + } + + // Step 2: Generate actions + assemble scene + options.onPhaseChange?.('actions', outline); + const actionsResult = await fetchSceneActions( + { + outline: contentResult.effectiveOutline || outline, + allOutlines: outlines, + content: contentResult.content, + stageId: stage.id, + agents: params.agents, + previousSpeeches, + userProfile: params.userProfile, + }, + signal, + ); + + if (actionsResult.success && actionsResult.scene) { + const scene = actionsResult.scene; + const settings = useSettingsStore.getState(); + + // TTS generation — failure means the whole scene fails + if (settings.ttsEnabled && settings.ttsProviderId !== 'browser-native-tts') { + const ttsResult = await generateTTSForScene(scene, signal); + if (!ttsResult.success) { + if (abortRef.current || store.getState().generationEpoch !== startEpoch) { + pausedByFailureOrAbort = true; + break; + } + store.getState().addFailedOutline(outline); + options.onSceneFailed?.(outline, ttsResult.error || 'TTS generation failed'); + store.getState().setGenerationStatus('paused'); + pausedByFailureOrAbort = true; + break; + } + } - // Serial generation loop — two-step per outline - try { - let pausedByFailureOrAbort = false; - for (const outline of pending) { - if (abortRef.current || store.getState().generationEpoch !== startEpoch) { + // Epoch changed — stage switched, discard this scene + if (store.getState().generationEpoch !== startEpoch) { + pausedByFailureOrAbort = true; + break; + } + + removeGeneratingOutline(outline.id); + store.getState().addScene(scene); + options.onSceneGenerated?.(scene, outline.order); + previousSpeeches = actionsResult.previousSpeeches || []; + } else { + if (abortRef.current || store.getState().generationEpoch !== startEpoch) { + pausedByFailureOrAbort = true; + break; + } + store.getState().addFailedOutline(outline); + options.onSceneFailed?.(outline, actionsResult.error || 'Actions generation failed'); + store.getState().setGenerationStatus('paused'); + pausedByFailureOrAbort = true; + break; + } + } + + if (!abortRef.current && !pausedByFailureOrAbort) { + store.getState().setGenerationStatus('completed'); + store.getState().setGeneratingOutlines([]); + options.onComplete?.(); + } + } catch (err: unknown) { + // AbortError is expected when stop() is called — don't treat as failure + if (err instanceof DOMException && err.name === 'AbortError') { + log.info('Generation aborted'); store.getState().setGenerationStatus('paused'); - pausedByFailureOrAbort = true; - break; + } else { + throw err; } + } finally { + generatingRef.current = false; + fetchAbortRef.current = null; + } + }); + }, + [options, store, withGenerationLock], + ); + + // Keep ref in sync so retrySingleOutline can call it + generateRemainingRef.current = generateRemaining; - store.getState().setCurrentGeneratingOrder(outline.order); + const stop = useCallback(() => { + abortRef.current = true; + store.getState().bumpGenerationEpoch(); + fetchAbortRef.current?.abort(); + mediaAbortRef.current?.abort(); + // Drain queued operations — these are stale user intents + generationLockRef.current.queue = []; + }, [store]); + + const isGenerating = useCallback( + () => generationLockRef.current.locked || generatingRef.current, + [], + ); + + /** Retry a single failed outline from scratch (content → actions → TTS). */ + const retrySingleOutline = useCallback( + async (outlineId: string) => { + return withGenerationLock(async () => { + const state = store.getState(); + const outline = state.failedOutlines.find((o) => o.id === outlineId); + const params = lastParamsRef.current; + if (!outline || !state.stage || !params) return; + + const removeGeneratingOutline = () => { + const current = store.getState().generatingOutlines; + if (!current.some((o) => o.id === outlineId)) return; + store.getState().setGeneratingOutlines(current.filter((o) => o.id !== outlineId)); + }; + + // Remove from failed list and mark as generating + store.getState().retryFailedOutline(outlineId); + store.getState().setGenerationStatus('generating'); + const currentGenerating = store.getState().generatingOutlines; + if (!currentGenerating.some((o) => o.id === outline.id)) { + store.getState().setGeneratingOutlines([...currentGenerating, outline]); + } - // Step 1: Generate content - options.onPhaseChange?.('content', outline); + const abortController = new AbortController(); + const signal = abortController.signal; + + try { + // Step 1: Content const contentResult = await fetchSceneContent( { outline, - allOutlines: outlines, - stageId: stage.id, + allOutlines: state.outlines, + stageId: state.stage.id, pdfImages: params.pdfImages, imageMapping: params.imageMapping, stageInfo: params.stageInfo, @@ -326,31 +526,26 @@ export function useSceneGenerator(options: UseSceneGeneratorOptions = {}) { ); if (!contentResult.success || !contentResult.content) { - if (abortRef.current || store.getState().generationEpoch !== startEpoch) { - pausedByFailureOrAbort = true; - break; - } store.getState().addFailedOutline(outline); - options.onSceneFailed?.(outline, contentResult.error || 'Content generation failed'); - store.getState().setGenerationStatus('paused'); - pausedByFailureOrAbort = true; - break; + removeGeneratingOutline(); + return; } - if (abortRef.current || store.getState().generationEpoch !== startEpoch) { - store.getState().setGenerationStatus('paused'); - pausedByFailureOrAbort = true; - break; - } + // Step 2: Actions + const sortedScenes = [...store.getState().scenes].sort((a, b) => a.order - b.order); + const lastScene = sortedScenes[sortedScenes.length - 1]; + const previousSpeeches = lastScene + ? (lastScene.actions || []) + .filter((a): a is SpeechAction => a.type === 'speech') + .map((a) => a.text) + : []; - // Step 2: Generate actions + assemble scene - options.onPhaseChange?.('actions', outline); const actionsResult = await fetchSceneActions( { outline: contentResult.effectiveOutline || outline, - allOutlines: outlines, + allOutlines: state.outlines, content: contentResult.content, - stageId: stage.id, + stageId: state.stage.id, agents: params.agents, previousSpeeches, userProfile: params.userProfile, @@ -358,179 +553,167 @@ export function useSceneGenerator(options: UseSceneGeneratorOptions = {}) { signal, ); - if (actionsResult.success && actionsResult.scene) { - const scene = actionsResult.scene; - const settings = useSettingsStore.getState(); + if (!actionsResult.success || !actionsResult.scene) { + store.getState().addFailedOutline(outline); + removeGeneratingOutline(); + return; + } - // TTS generation — failure means the whole scene fails - if (settings.ttsEnabled && settings.ttsProviderId !== 'browser-native-tts') { - const ttsResult = await generateTTSForScene(scene, signal); - if (!ttsResult.success) { - if (abortRef.current || store.getState().generationEpoch !== startEpoch) { - pausedByFailureOrAbort = true; - break; - } - store.getState().addFailedOutline(outline); - options.onSceneFailed?.(outline, ttsResult.error || 'TTS generation failed'); - store.getState().setGenerationStatus('paused'); - pausedByFailureOrAbort = true; - break; - } + // Step 3: TTS + const settings = useSettingsStore.getState(); + if (settings.ttsEnabled && settings.ttsProviderId !== 'browser-native-tts') { + const ttsResult = await generateTTSForScene(actionsResult.scene, signal); + if (!ttsResult.success) { + store.getState().addFailedOutline(outline); + removeGeneratingOutline(); + return; } + } - // Epoch changed — stage switched, discard this scene - if (store.getState().generationEpoch !== startEpoch) { - pausedByFailureOrAbort = true; - break; - } + removeGeneratingOutline(); + store.getState().addScene(actionsResult.scene); - removeGeneratingOutline(outline.id); - store.getState().addScene(scene); - options.onSceneGenerated?.(scene, outline.order); - previousSpeeches = actionsResult.previousSpeeches || []; - } else { - if (abortRef.current || store.getState().generationEpoch !== startEpoch) { - pausedByFailureOrAbort = true; - break; - } + // Resume remaining generation if there are pending outlines + if (store.getState().generatingOutlines.length > 0 && lastParamsRef.current) { + generateRemainingRef.current?.(lastParamsRef.current); + } + } catch (err) { + if (!(err instanceof DOMException && err.name === 'AbortError')) { store.getState().addFailedOutline(outline); - options.onSceneFailed?.(outline, actionsResult.error || 'Actions generation failed'); - store.getState().setGenerationStatus('paused'); - pausedByFailureOrAbort = true; - break; } + removeGeneratingOutline(); } - - if (!abortRef.current && !pausedByFailureOrAbort) { - store.getState().setGenerationStatus('completed'); - store.getState().setGeneratingOutlines([]); - options.onComplete?.(); - } - } catch (err: unknown) { - // AbortError is expected when stop() is called — don't treat as failure - if (err instanceof DOMException && err.name === 'AbortError') { - log.info('Generation aborted'); - store.getState().setGenerationStatus('paused'); - } else { - throw err; - } - } finally { - generatingRef.current = false; - fetchAbortRef.current = null; - } + }); }, - [options, store], + [store, withGenerationLock], ); - // Keep ref in sync so retrySingleOutline can call it - generateRemainingRef.current = generateRemaining; + /** Regenerate a completed scene from its outline (re-generates the scene content). */ + const regenerateScene = useCallback( + async (sceneId: string) => { + return withGenerationLock(async () => { + const state = store.getState(); + const scene = state.scenes.find((s) => s.id === sceneId); + if (!scene || !state.stage) return; + + // Find the outline that corresponds to this scene by order + const outline = state.outlines.find((o) => o.order === scene.order); + if (!outline) return; + + const params = lastParamsRef.current; + if (!params) return; + + // Remember the original scene index so we can restore it at the same position + const originalIndex = state.scenes.findIndex((s) => s.id === sceneId); + + // Note: Do NOT delete the old scene here. Keeping it in the store prevents + // a blank/empty state during regeneration. The old scene stays visible + // until the new one is ready, then we atomically replace it at the same index. + + // Mark the outline as generating + store.getState().setGenerationStatus('generating'); + const currentGenerating = store.getState().generatingOutlines; + if (!currentGenerating.some((o) => o.id === outline.id)) { + store.getState().setGeneratingOutlines([...currentGenerating, outline]); + } - const stop = useCallback(() => { - abortRef.current = true; - store.getState().bumpGenerationEpoch(); - fetchAbortRef.current?.abort(); - mediaAbortRef.current?.abort(); - }, [store]); + const abortController = new AbortController(); + const signal = abortController.signal; - const isGenerating = useCallback(() => generatingRef.current, []); + const removeFromGenerating = () => { + const current = store.getState().generatingOutlines; + store.getState().setGeneratingOutlines(current.filter((o) => o.id !== outline.id)); + }; - /** Retry a single failed outline from scratch (content → actions → TTS). */ - const retrySingleOutline = useCallback( - async (outlineId: string) => { - const state = store.getState(); - const outline = state.failedOutlines.find((o) => o.id === outlineId); - const params = lastParamsRef.current; - if (!outline || !state.stage || !params) return; - - const removeGeneratingOutline = () => { - const current = store.getState().generatingOutlines; - if (!current.some((o) => o.id === outlineId)) return; - store.getState().setGeneratingOutlines(current.filter((o) => o.id !== outlineId)); - }; - - // Remove from failed list and mark as generating - store.getState().retryFailedOutline(outlineId); - store.getState().setGenerationStatus('generating'); - const currentGenerating = store.getState().generatingOutlines; - if (!currentGenerating.some((o) => o.id === outline.id)) { - store.getState().setGeneratingOutlines([...currentGenerating, outline]); - } + try { + // Step 1: Content + const contentResult = await fetchSceneContent( + { + outline, + allOutlines: state.outlines, + stageId: state.stage.id, + pdfImages: params.pdfImages, + imageMapping: params.imageMapping, + stageInfo: params.stageInfo, + agents: params.agents, + }, + signal, + ); - const abortController = new AbortController(); - const signal = abortController.signal; - - try { - // Step 1: Content - const contentResult = await fetchSceneContent( - { - outline, - allOutlines: state.outlines, - stageId: state.stage.id, - pdfImages: params.pdfImages, - imageMapping: params.imageMapping, - stageInfo: params.stageInfo, - agents: params.agents, - }, - signal, - ); - - if (!contentResult.success || !contentResult.content) { - store.getState().addFailedOutline(outline); - return; - } + if (!contentResult.success || !contentResult.content) { + store.getState().addFailedOutline(outline); + removeFromGenerating(); + return; + } - // Step 2: Actions - const sortedScenes = [...store.getState().scenes].sort((a, b) => a.order - b.order); - const lastScene = sortedScenes[sortedScenes.length - 1]; - const previousSpeeches = lastScene - ? (lastScene.actions || []) - .filter((a): a is SpeechAction => a.type === 'speech') - .map((a) => a.text) - : []; - - const actionsResult = await fetchSceneActions( - { - outline: contentResult.effectiveOutline || outline, - allOutlines: state.outlines, - content: contentResult.content, - stageId: state.stage.id, - agents: params.agents, - previousSpeeches, - userProfile: params.userProfile, - }, - signal, - ); - - if (!actionsResult.success || !actionsResult.scene) { - store.getState().addFailedOutline(outline); - return; - } + // Step 2: Actions + const sortedScenes = [...store.getState().scenes].sort((a, b) => a.order - b.order); + const lastScene = sortedScenes[sortedScenes.length - 1]; + const previousSpeeches = lastScene + ? (lastScene.actions || []) + .filter((a): a is SpeechAction => a.type === 'speech') + .map((a) => a.text) + : []; - // Step 3: TTS - const settings = useSettingsStore.getState(); - if (settings.ttsEnabled && settings.ttsProviderId !== 'browser-native-tts') { - const ttsResult = await generateTTSForScene(actionsResult.scene, signal); - if (!ttsResult.success) { + const actionsResult = await fetchSceneActions( + { + outline: contentResult.effectiveOutline || outline, + allOutlines: state.outlines, + content: contentResult.content, + stageId: state.stage.id, + agents: params.agents, + previousSpeeches, + userProfile: params.userProfile, + }, + signal, + ); + + if (!actionsResult.success || !actionsResult.scene) { store.getState().addFailedOutline(outline); + removeFromGenerating(); return; } - } - removeGeneratingOutline(); - store.getState().addScene(actionsResult.scene); + // Step 3: TTS + const settings = useSettingsStore.getState(); + if (settings.ttsEnabled && settings.ttsProviderId !== 'browser-native-tts') { + const ttsResult = await generateTTSForScene(actionsResult.scene, signal); + if (!ttsResult.success) { + store.getState().addFailedOutline(outline); + removeFromGenerating(); + return; + } + } - // Resume remaining generation if there are pending outlines - if (store.getState().generatingOutlines.length > 0 && lastParamsRef.current) { - generateRemainingRef.current?.(lastParamsRef.current); - } - } catch (err) { - if (!(err instanceof DOMException && err.name === 'AbortError')) { - store.getState().addFailedOutline(outline); + removeFromGenerating(); + + // Insert the new scene back at its original position (not at the end). + // We filter out the old scene by id AND insert at the same index in one atomic setScenes call. + // This prevents the old scene from disappearing during regeneration. + const currentScenes = store.getState().scenes; + const scenesWithoutOld = currentScenes.filter((s) => s.id !== sceneId); + const insertIndex = Math.min(originalIndex, scenesWithoutOld.length); + const newScenes = [ + ...scenesWithoutOld.slice(0, insertIndex), + actionsResult.scene, + ...scenesWithoutOld.slice(insertIndex), + ]; + store.getState().setScenes(newScenes); + + // Resume remaining generation if there are pending outlines + if (store.getState().generatingOutlines.length > 0 && lastParamsRef.current) { + generateRemainingRef.current?.(lastParamsRef.current); + } + } catch (err) { + if (!(err instanceof DOMException && err.name === 'AbortError')) { + store.getState().addFailedOutline(outline); + } + removeFromGenerating(); } - } + }); }, - [store], + [store, withGenerationLock], ); - return { generateRemaining, retrySingleOutline, stop, isGenerating }; + return { generateRemaining, retrySingleOutline, regenerateScene, stop, isGenerating }; } diff --git a/lib/i18n/generation.ts b/lib/i18n/generation.ts new file mode 100644 index 000000000..e69de29bb diff --git a/lib/i18n/locales/en-US.json b/lib/i18n/locales/en-US.json index b154b4144..3a8b77a83 100644 --- a/lib/i18n/locales/en-US.json +++ b/lib/i18n/locales/en-US.json @@ -313,7 +313,8 @@ "outlineGenerateFailed": "Outline generation failed, please try again later", "webSearching": "Web Search", "webSearchingDesc": "Searching the web for up-to-date information", - "webSearchFailed": "Web search failed" + "webSearchFailed": "Web search failed", + "regenerateScene": "Regenerate Scene" }, "settings": { "title": "Settings", diff --git a/lib/i18n/locales/zh-CN.json b/lib/i18n/locales/zh-CN.json index 82b07bb38..12592cd04 100644 --- a/lib/i18n/locales/zh-CN.json +++ b/lib/i18n/locales/zh-CN.json @@ -313,7 +313,8 @@ "outlineGenerateFailed": "大纲生成失败,请稍后重试", "webSearching": "网络搜索", "webSearchingDesc": "正在搜索网络获取最新资料", - "webSearchFailed": "网络搜索失败" + "webSearchFailed": "网络搜索失败", + "regenerateScene": "重新生成场景" }, "settings": { "title": "设置",