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": "设置",