From d8f8b319daa1bcc8cad381d130e578c245cd63f6 Mon Sep 17 00:00:00 2001 From: nkmohit Date: Sat, 21 Mar 2026 16:55:27 +0530 Subject: [PATCH 1/2] feat: add pre-generation outline review Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- app/generation-preview/page.tsx | 274 +++++++++++++++++++--- app/generation-preview/types.ts | 1 + components/generation/media-popover.tsx | 95 +++++--- components/generation/outlines-editor.tsx | 56 +++-- lib/i18n/generation.ts | 56 +++++ lib/i18n/settings.ts | 5 + lib/store/settings.ts | 7 + 7 files changed, 405 insertions(+), 89 deletions(-) diff --git a/app/generation-preview/page.tsx b/app/generation-preview/page.tsx index 213a51409..f0909ec51 100644 --- a/app/generation-preview/page.tsx +++ b/app/generation-preview/page.tsx @@ -7,6 +7,7 @@ import { CheckCircle2, Sparkles, AlertCircle, AlertTriangle, ArrowLeft, Bot } fr import { Button } from '@/components/ui/button'; import { Card } from '@/components/ui/card'; import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; +import { OutlinesEditor } from '@/components/generation/outlines-editor'; import { cn } from '@/lib/utils'; import { useStageStore } from '@/lib/store/stage'; import { useSettingsStore } from '@/lib/store/settings'; @@ -49,6 +50,7 @@ function GenerationPreviewContent() { [], ); const [showAgentReveal, setShowAgentReveal] = useState(false); + const [isConfirmingOutlines, setIsConfirmingOutlines] = useState(false); const [generatedAgents, setGeneratedAgents] = useState< Array<{ id: string; @@ -60,10 +62,20 @@ function GenerationPreviewContent() { priority: number; }> >([]); + const generatedStageRef = useRef(null); const agentRevealResolveRef = useRef<(() => void) | null>(null); + const getOutlinePreviewPhase = (): GenerationSessionState['previewPhase'] => + useSettingsStore.getState().reviewOutlineEnabled ? 'review' : 'generating-content'; + // Compute active steps based on session state const activeSteps = getActiveSteps(session); + const isReviewingOutlines = session?.previewPhase === 'review'; + + const persistSession = (nextSession: GenerationSessionState) => { + setSession(nextSession); + sessionStorage.setItem('generationSession', JSON.stringify(nextSession)); + }; // Load session from sessionStorage useEffect(() => { @@ -73,6 +85,11 @@ function GenerationPreviewContent() { if (saved) { try { const parsed = JSON.parse(saved) as GenerationSessionState; + if (!parsed.previewPhase) { + parsed.previewPhase = parsed.sceneOutlines?.length + ? getOutlinePreviewPhase() + : 'preparing'; + } setSession(parsed); } catch (e) { log.error('Failed to parse generation session:', e); @@ -119,7 +136,11 @@ function GenerationPreviewContent() { // Auto-start generation when session is loaded useEffect(() => { - if (session && !hasStartedRef.current) { + if ( + session && + !hasStartedRef.current && + (session.previewPhase === 'preparing' || !session.previewPhase) + ) { hasStartedRef.current = true; startGeneration(); } @@ -271,8 +292,7 @@ function GenerationPreviewContent() { imageStorageIds, pdfStorageKey: undefined, // Clear so we don't re-parse }; - setSession(updatedSession); - sessionStorage.setItem('generationSession', JSON.stringify(updatedSession)); + persistSession(updatedSession); // Truncation warnings const warnings: string[] = []; @@ -333,8 +353,7 @@ function GenerationPreviewContent() { researchContext: searchData.context || '', researchSources: sources, }; - setSession(updatedSessionWithSearch); - sessionStorage.setItem('generationSession', JSON.stringify(updatedSessionWithSearch)); + persistSession(updatedSessionWithSearch); currentSession = updatedSessionWithSearch; activeSteps = getActiveSteps(currentSession); } @@ -372,6 +391,7 @@ function GenerationPreviewContent() { createdAt: Date.now(), updatedAt: Date.now(), }; + generatedStageRef.current = stage; if (settings.agentMode === 'auto') { const agentStepIdx = activeSteps.findIndex((s) => s.id === 'agent-generation'); @@ -544,9 +564,13 @@ function GenerationPreviewContent() { .catch(reject); }); - const updatedSession = { ...currentSession, sceneOutlines: outlines }; - setSession(updatedSession); - sessionStorage.setItem('generationSession', JSON.stringify(updatedSession)); + const updatedSession = { + ...currentSession, + sceneOutlines: outlines, + previewPhase: getOutlinePreviewPhase(), + }; + persistSession(updatedSession); + currentSession = updatedSession; // Outline generation succeeded — clear homepage draft cache try { @@ -554,27 +578,112 @@ function GenerationPreviewContent() { } catch { /* ignore */ } - - // Brief pause to let user see the final outline state - await new Promise((resolve) => setTimeout(resolve, 800)); } - // Move to scene generation step setStatusMessage(''); - if (!outlines || outlines.length === 0) { + if (!currentSession.sceneOutlines || currentSession.sceneOutlines.length === 0) { throw new Error(t('generation.outlineEmptyResponse')); } + setStreamingOutlines(currentSession.sceneOutlines); + + if (currentSession.previewPhase === 'review') { + return; + } + + await continueGeneration(currentSession.sceneOutlines); + return; + } catch (err) { + // AbortError is expected when navigating away — don't show as error + if (err instanceof DOMException && err.name === 'AbortError') { + log.info('[GenerationPreview] Generation aborted'); + return; + } + setError(err instanceof Error ? err.message : String(err)); + } + }; + + const continueGeneration = async (confirmedOutlines: SceneOutline[]) => { + if (!session) return; + + abortControllerRef.current?.abort(); + const controller = new AbortController(); + abortControllerRef.current = controller; + const signal = controller.signal; + + setError(null); + setIsConfirmingOutlines(true); + + try { + const contentSession: GenerationSessionState = { + ...session, + sceneOutlines: confirmedOutlines, + previewPhase: 'generating-content', + }; + persistSession(contentSession); + + const activeSteps = getActiveSteps(contentSession); + + let imageMapping: ImageMapping = {}; + if (contentSession.imageStorageIds && contentSession.imageStorageIds.length > 0) { + log.debug('Loading images from IndexedDB'); + imageMapping = await loadImageMapping(contentSession.imageStorageIds); + } else if ( + contentSession.imageMapping && + Object.keys(contentSession.imageMapping).length > 0 + ) { + log.debug('Using imageMapping from session (old format)'); + imageMapping = contentSession.imageMapping; + } + + const settings = useSettingsStore.getState(); + const stage = generatedStageRef.current ?? { + id: nanoid(10), + name: extractTopicFromRequirement(contentSession.requirements.requirement), + description: '', + language: contentSession.requirements.language || 'zh-CN', + style: 'professional', + createdAt: Date.now(), + updatedAt: Date.now(), + }; + + let agents: Array<{ + id: string; + name: string; + role: string; + persona?: string; + }> = []; + + if (settings.agentMode === 'auto') { + const registry = useAgentRegistry.getState(); + agents = settings.selectedAgentIds + .map((id) => registry.getAgent(id)) + .filter(Boolean) + .map((a) => ({ + id: a!.id, + name: a!.name, + role: a!.role, + persona: a!.persona, + })); + } else { + const registry = useAgentRegistry.getState(); + agents = settings.selectedAgentIds + .map((id) => registry.getAgent(id)) + .filter(Boolean) + .map((a) => ({ + id: a!.id, + name: a!.name, + role: a!.role, + persona: a!.persona, + })); + } - // Store stage and outlines const store = useStageStore.getState(); store.setStage(stage); - store.setOutlines(outlines); + store.setOutlines(confirmedOutlines); - // Advance to slide-content step const contentStepIdx = activeSteps.findIndex((s) => s.id === 'slide-content'); if (contentStepIdx >= 0) setCurrentStepIndex(contentStepIdx); - // Build stageInfo and userProfile for API call const stageInfo = { name: stage.name, description: stage.description, @@ -583,23 +692,21 @@ function GenerationPreviewContent() { }; const userProfile = - currentSession.requirements.userNickname || currentSession.requirements.userBio - ? `Student: ${currentSession.requirements.userNickname || 'Unknown'}${currentSession.requirements.userBio ? ` — ${currentSession.requirements.userBio}` : ''}` + contentSession.requirements.userNickname || contentSession.requirements.userBio + ? `Student: ${contentSession.requirements.userNickname || 'Unknown'}${contentSession.requirements.userBio ? ` — ${contentSession.requirements.userBio}` : ''}` : undefined; - // Generate ONLY the first scene - store.setGeneratingOutlines(outlines); + store.setGeneratingOutlines(confirmedOutlines); - const firstOutline = outlines[0]; + const firstOutline = confirmedOutlines[0]; - // Step 2: Generate content (currentStepIndex is already 2) const contentResp = await fetch('/api/generate/scene-content', { method: 'POST', headers: getApiHeaders(), body: JSON.stringify({ outline: firstOutline, - allOutlines: outlines, - pdfImages: currentSession.pdfImages, + allOutlines: confirmedOutlines, + pdfImages: contentSession.pdfImages, imageMapping, stageInfo, stageId: stage.id, @@ -618,7 +725,6 @@ function GenerationPreviewContent() { throw new Error(contentData.error || t('generation.sceneGenerateFailed')); } - // Generate actions (activate actions step indicator) const actionsStepIdx = activeSteps.findIndex((s) => s.id === 'actions'); setCurrentStepIndex(actionsStepIdx >= 0 ? actionsStepIdx : currentStepIndex + 1); @@ -627,7 +733,7 @@ function GenerationPreviewContent() { headers: getApiHeaders(), body: JSON.stringify({ outline: contentData.effectiveOutline || firstOutline, - allOutlines: outlines, + allOutlines: confirmedOutlines, content: contentData.content, stageId: stage.id, agents, @@ -647,7 +753,6 @@ function GenerationPreviewContent() { throw new Error(data.error || t('generation.sceneGenerateFailed')); } - // Generate TTS for first scene (part of actions step — blocking) if (settings.ttsEnabled && settings.ttsProviderId !== 'browser-native-tts') { const ttsProviderConfig = settings.ttsProvidersConfig?.[settings.ttsProviderId]; const speechActions = (data.scene.actions || []).filter( @@ -703,19 +808,16 @@ function GenerationPreviewContent() { } } - // Add scene to store and navigate store.addScene(data.scene); store.setCurrentSceneId(data.scene.id); - // Set remaining outlines as skeleton placeholders - const remaining = outlines.filter((o) => o.order !== data.scene.order); + const remaining = confirmedOutlines.filter((o) => o.order !== data.scene.order); store.setGeneratingOutlines(remaining); - // Store generation params for classroom to continue generation sessionStorage.setItem( 'generationParams', JSON.stringify({ - pdfImages: currentSession.pdfImages, + pdfImages: contentSession.pdfImages, agents, userProfile, }), @@ -725,15 +827,39 @@ function GenerationPreviewContent() { await store.saveToStorage(); router.push(`/classroom/${stage.id}`); } catch (err) { - // AbortError is expected when navigating away — don't show as error if (err instanceof DOMException && err.name === 'AbortError') { log.info('[GenerationPreview] Generation aborted'); return; } setError(err instanceof Error ? err.message : String(err)); + } finally { + setIsConfirmingOutlines(false); } }; + const handleOutlinesChange = (outlines: SceneOutline[]) => { + if (!session) return; + + setError(null); + setStreamingOutlines(outlines); + persistSession({ + ...session, + sceneOutlines: outlines, + previewPhase: 'review', + }); + }; + + const handleConfirmOutlines = async () => { + if (!session?.sceneOutlines) return; + await continueGeneration(session.sceneOutlines); + }; + + const handleBackToHomeFromReview = () => { + abortControllerRef.current?.abort(); + sessionStorage.removeItem('generationSession'); + router.push('/'); + }; + const extractTopicFromRequirement = (requirement: string): string => { const trimmed = requirement.trim(); if (trimmed.length <= 500) { @@ -783,6 +909,86 @@ function GenerationPreviewContent() { ? activeSteps[Math.min(currentStepIndex, activeSteps.length - 1)] : ALL_STEPS[0]; + if (isReviewingOutlines && session.sceneOutlines) { + const outlineStepIndex = Math.max( + 0, + activeSteps.findIndex((step) => step.id === 'outline'), + ); + + return ( +
+
+
+
+
+ + + + + +
+ + +
+ {activeSteps.map((step, idx) => ( +
+ ))} +
+ +
+
+

+ {t('generation.reviewOutlineTitle')} +

+

+ {t('generation.reviewOutlineDesc')} +

+
+ + {error && ( +
+ {error} +
+ )} + + +
+ + +
+
+ ); + } + return (
{/* Background Decor */} diff --git a/app/generation-preview/types.ts b/app/generation-preview/types.ts index 408ae81fd..77076aae6 100644 --- a/app/generation-preview/types.ts +++ b/app/generation-preview/types.ts @@ -17,6 +17,7 @@ export interface GenerationSessionState { imageMapping?: ImageMapping; sceneOutlines?: SceneOutline[] | null; currentStep: 'generating' | 'complete'; + previewPhase?: 'preparing' | 'review' | 'generating-content'; // PDF deferred parsing fields pdfStorageKey?: string; pdfFileName?: string; diff --git a/components/generation/media-popover.tsx b/components/generation/media-popover.tsx index c26d52a14..a3da14398 100644 --- a/components/generation/media-popover.tsx +++ b/components/generation/media-popover.tsx @@ -7,6 +7,7 @@ import { Video, Volume2, Mic, + ListTree, SlidersHorizontal, ChevronRight, Play, @@ -55,7 +56,7 @@ const VIDEO_PROVIDER_ICONS: Record = { sora: '/logos/openai.svg', }; -type TabId = 'image' | 'video' | 'tts' | 'asr'; +type TabId = 'image' | 'video' | 'tts' | 'asr' | 'outline'; const LANG_LABELS: Record = { zh: '中文', @@ -77,6 +78,7 @@ const TABS: Array<{ id: TabId; icon: LucideIcon; label: string }> = [ { id: 'video', icon: Video, label: 'Video' }, { id: 'tts', icon: Volume2, label: 'TTS' }, { id: 'asr', icon: Mic, label: 'ASR' }, + { id: 'outline', icon: ListTree, label: 'Outline' }, ]; /** Localized TTS provider name (mirrors audio-settings.tsx) */ @@ -111,10 +113,12 @@ export function MediaPopover({ onSettingsOpen }: MediaPopoverProps) { const videoGenerationEnabled = useSettingsStore((s) => s.videoGenerationEnabled); const ttsEnabled = useSettingsStore((s) => s.ttsEnabled); const asrEnabled = useSettingsStore((s) => s.asrEnabled); + const reviewOutlineEnabled = useSettingsStore((s) => s.reviewOutlineEnabled); const setImageGenerationEnabled = useSettingsStore((s) => s.setImageGenerationEnabled); const setVideoGenerationEnabled = useSettingsStore((s) => s.setVideoGenerationEnabled); const setTTSEnabled = useSettingsStore((s) => s.setTTSEnabled); const setASREnabled = useSettingsStore((s) => s.setASREnabled); + const setReviewOutlineEnabled = useSettingsStore((s) => s.setReviewOutlineEnabled); const imageProviderId = useSettingsStore((s) => s.imageProviderId); const imageModelId = useSettingsStore((s) => s.imageModelId); @@ -147,6 +151,7 @@ export function MediaPopover({ onSettingsOpen }: MediaPopoverProps) { video: videoGenerationEnabled, tts: ttsEnabled, asr: asrEnabled, + outline: reviewOutlineEnabled, }; const enabledCount = [ @@ -154,6 +159,7 @@ export function MediaPopover({ onSettingsOpen }: MediaPopoverProps) { videoGenerationEnabled, ttsEnabled, asrEnabled, + reviewOutlineEnabled, ].filter(Boolean).length; const cfgOk = ( @@ -234,6 +240,7 @@ export function MediaPopover({ onSettingsOpen }: MediaPopoverProps) { groupName: `${providerName} · ${langLabel}`, groupIcon: p.icon, available: true, + compositePrefix: `${p.id}::${langKey}`, items: voices.map((v) => ({ id: v.voiceURI, name: v.name })), }); } @@ -312,7 +319,7 @@ export function MediaPopover({ onSettingsOpen }: MediaPopoverProps) { } setOpen(isOpen); if (isOpen) { - const first = (['image', 'video', 'tts', 'asr'] as TabId[]).find((id) => enabledMap[id]); + const first = (['image', 'video', 'tts', 'asr', 'outline'] as TabId[]).find((id) => enabledMap[id]); setActiveTab(first || 'image'); } }; @@ -333,13 +340,14 @@ export function MediaPopover({ onSettingsOpen }: MediaPopoverProps) { {videoGenerationEnabled &&