From 75c9fea89cb2129f816f04167b3b4bb8be53df8d Mon Sep 17 00:00:00 2001 From: "bingxiang.cheng" Date: Wed, 1 Apr 2026 19:45:45 +0800 Subject: [PATCH 1/3] =?UTF-8?q?feat(scene):=20=E6=B7=BB=E5=8A=A0=E5=9C=BA?= =?UTF-8?q?=E6=99=AF=E9=87=8D=E6=96=B0=E7=94=9F=E6=88=90=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 在useSceneGenerator中引入 regenerateScene 函数,实现对已完成场景的内容重新生成 - 优化generateRemaining函数,加入生成锁,避免并发冲突,确保生成过程有序执行 - 在ClassroomDetailPage逻辑中引用并传递 regenerateScene 以支持界面调用 - 在Stage组件及SceneSidebar中添加重新生成按钮和相关回调支持 - 实现重新生成流程的状态管理,显示加载动画,保证用户体验 - 增加本地状态和全局状态联合判断场景是否处于重新生成中,控制按钮禁用和动画 - 补充多语言文案,支持重新生成场景按钮的描述文本展示 --- app/classroom/[id]/page.tsx | 62 ++-- components/stage.tsx | 7 + components/stage/scene-sidebar.tsx | 59 ++++ lib/hooks/use-scene-generator.ts | 490 ++++++++++++++++++++--------- lib/i18n/generation.ts | 2 + 5 files changed, 437 insertions(+), 183 deletions(-) diff --git a/app/classroom/[id]/page.tsx b/app/classroom/[id]/page.tsx index b3f2c33d1..c940f3038 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'); }, @@ -131,37 +131,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); }); @@ -195,7 +199,7 @@ export default function ClassroomDetailPage() { ) : ( - + )} diff --git a/components/stage.tsx b/components/stage.tsx index 856d20005..3a4b0a3ee 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(); @@ -934,6 +940,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..b65dbcd29 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,40 @@ 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 +228,27 @@ 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..a31ed1b19 100644 --- a/lib/hooks/use-scene-generator.ts +++ b/lib/hooks/use-scene-generator.ts @@ -240,186 +240,230 @@ export function useSceneGenerator(options: UseSceneGeneratorOptions = {}) { const store = useStageStore; - 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; - } - - store.getState().setGenerationStatus('generating'); + // ==================== Generation Lock (prevents concurrent scene mutations) ==================== + type QueuedOp = { + fn: () => Promise; + resolve: () => void; + reject: (e: unknown) => void; + }; - // 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; + 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(); + }); } - - 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); + return new Promise((resolve, reject) => { + lock.queue.push({ fn, resolve, reject }); }); + }, + [processQueue], + ); - // 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); - } + const generateRemaining = useCallback( + async (params: GenerationParams) => { + lastParamsRef.current = params; + 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; + } - // 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; - } + store.getState().setGenerationStatus('generating'); - store.getState().setCurrentGeneratingOrder(outline.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); - // 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 (pending.length === 0) { + store.getState().setGenerationStatus('completed'); + store.getState().setGeneratingOutlines([]); + options.onComplete?.(); + generatingRef.current = false; + return; + } - if (!contentResult.success || !contentResult.content) { + 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); + } + + // 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; } - 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, - ); + 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 (actionsResult.success && actionsResult.scene) { - const scene = actionsResult.scene; - const settings = useSettingsStore.getState(); + if (abortRef.current || store.getState().generationEpoch !== startEpoch) { + store.getState().setGenerationStatus('paused'); + pausedByFailureOrAbort = true; + break; + } - // 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) { + // 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; } - store.getState().addFailedOutline(outline); - options.onSceneFailed?.(outline, ttsResult.error || 'TTS generation failed'); - store.getState().setGenerationStatus('paused'); + } + + // Epoch changed — stage switched, discard this scene + if (store.getState().generationEpoch !== startEpoch) { pausedByFailureOrAbort = true; break; } - } - // Epoch changed — stage switched, discard this scene - if (store.getState().generationEpoch !== startEpoch) { + 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; } + } - 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'); + 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; } - - 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], + [options, store, withGenerationLock], ); // Keep ref in sync so retrySingleOutline can call it @@ -430,14 +474,20 @@ export function useSceneGenerator(options: UseSceneGeneratorOptions = {}) { store.getState().bumpGenerationEpoch(); fetchAbortRef.current?.abort(); mediaAbortRef.current?.abort(); + // Drain queued operations — these are stale user intents + generationLockRef.current.queue = []; }, [store]); - const isGenerating = useCallback(() => generatingRef.current, []); + const isGenerating = useCallback( + () => generationLockRef.current.locked || generatingRef.current, + [], + ); /** Retry a single failed outline from scratch (content → actions → TTS). */ const retrySingleOutline = useCallback( async (outlineId: string) => { - const state = store.getState(); + 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; @@ -476,6 +526,7 @@ export function useSceneGenerator(options: UseSceneGeneratorOptions = {}) { if (!contentResult.success || !contentResult.content) { store.getState().addFailedOutline(outline); + removeGeneratingOutline(); return; } @@ -503,6 +554,7 @@ export function useSceneGenerator(options: UseSceneGeneratorOptions = {}) { if (!actionsResult.success || !actionsResult.scene) { store.getState().addFailedOutline(outline); + removeGeneratingOutline(); return; } @@ -512,6 +564,7 @@ export function useSceneGenerator(options: UseSceneGeneratorOptions = {}) { const ttsResult = await generateTTSForScene(actionsResult.scene, signal); if (!ttsResult.success) { store.getState().addFailedOutline(outline); + removeGeneratingOutline(); return; } } @@ -527,10 +580,139 @@ export function useSceneGenerator(options: UseSceneGeneratorOptions = {}) { if (!(err instanceof DOMException && err.name === 'AbortError')) { store.getState().addFailedOutline(outline); } + removeGeneratingOutline(); } + }); + }, + [store, withGenerationLock], + ); + + /** 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 abortController = new AbortController(); + const signal = abortController.signal; + + const removeFromGenerating = () => { + const current = store.getState().generatingOutlines; + store.getState().setGeneratingOutlines(current.filter((o) => o.id !== outline.id)); + }; + + 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); + 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); + removeFromGenerating(); + return; + } + + // 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; + } + } + + 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 index e5707445f..8d07d6a94 100644 --- a/lib/i18n/generation.ts +++ b/lib/i18n/generation.ts @@ -43,6 +43,7 @@ export const generationZhCN = { speechFailed: '语音合成失败', retryScene: '重试生成', retryingScene: '正在重新生成...', + regenerateScene: '重新生成场景', backToHome: '返回首页', sessionNotFound: '未找到生成会话', sessionNotFoundDesc: '请先填写课程需求开始生成流程。', @@ -113,6 +114,7 @@ export const generationEnUS = { speechFailed: 'Speech generation failed', retryScene: 'Retry', retryingScene: 'Regenerating...', + regenerateScene: 'Regenerate Scene', backToHome: 'Back to Home', sessionNotFound: 'Session Not Found', sessionNotFoundDesc: 'Please fill in course requirements to start the generation process.', From dea4c6750152d9da4e40d70c9a989c46463abc70 Mon Sep 17 00:00:00 2001 From: "bingxiang.cheng" Date: Wed, 1 Apr 2026 20:16:55 +0800 Subject: [PATCH 2/3] =?UTF-8?q?fix(scene-generator):=20=E4=BB=A3=E7=A0=81?= =?UTF-8?q?=E9=A3=8E=E6=A0=BC=E4=B8=8E=E7=BC=A9=E8=BF=9B=E8=B0=83=E6=95=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- components/stage/scene-sidebar.tsx | 26 +++-- lib/hooks/use-scene-generator.ts | 169 +++++++++++++++-------------- 2 files changed, 101 insertions(+), 94 deletions(-) diff --git a/components/stage/scene-sidebar.tsx b/components/stage/scene-sidebar.tsx index b65dbcd29..405bf42b2 100644 --- a/components/stage/scene-sidebar.tsx +++ b/components/stage/scene-sidebar.tsx @@ -62,15 +62,18 @@ 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]); + 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 @@ -245,7 +248,10 @@ export function SceneSidebar({ title={t('generation.regenerateScene')} > )} diff --git a/lib/hooks/use-scene-generator.ts b/lib/hooks/use-scene-generator.ts index a31ed1b19..debab27f8 100644 --- a/lib/hooks/use-scene-generator.ts +++ b/lib/hooks/use-scene-generator.ts @@ -256,7 +256,8 @@ export function useSceneGenerator(options: UseSceneGeneratorOptions = {}) { const lock = generationLockRef.current; const next = lock.queue.shift(); if (!next) return; - next.fn() + next + .fn() .then(next.resolve) .catch(next.reject) .finally(() => { @@ -488,100 +489,100 @@ export function useSceneGenerator(options: UseSceneGeneratorOptions = {}) { 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]); - } + const outline = state.failedOutlines.find((o) => o.id === outlineId); + const params = lastParamsRef.current; + if (!outline || !state.stage || !params) return; - 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); - removeGeneratingOutline(); - 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)); + }; - // 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); - removeGeneratingOutline(); - return; + // 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 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 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); removeGeneratingOutline(); return; } - } - removeGeneratingOutline(); - store.getState().addScene(actionsResult.scene); + // 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) + : []; - // 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); + 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); + removeGeneratingOutline(); + return; + } + + // 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; + } + } + + removeGeneratingOutline(); + store.getState().addScene(actionsResult.scene); + + // 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); + } + removeGeneratingOutline(); } - removeGeneratingOutline(); - } }); }, [store, withGenerationLock], From a4e0b8280aa44c091f4543a69d2f70cbbe5f3fd9 Mon Sep 17 00:00:00 2001 From: "bingxiang.cheng" Date: Sun, 5 Apr 2026 10:38:10 +0800 Subject: [PATCH 3/3] =?UTF-8?q?feat(i18n):=20=E6=B7=BB=E5=8A=A0=E9=87=8D?= =?UTF-8?q?=E6=96=B0=E7=94=9F=E6=88=90=E5=9C=BA=E6=99=AF=E7=9A=84=E5=A4=9A?= =?UTF-8?q?=E8=AF=AD=E8=A8=80=E6=96=87=E6=A1=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/i18n/locales/en-US.json | 3 ++- lib/i18n/locales/zh-CN.json | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) 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": "设置",