diff --git a/app/api/generate/media-prompt/route.ts b/app/api/generate/media-prompt/route.ts new file mode 100644 index 000000000..bc8b03e5a --- /dev/null +++ b/app/api/generate/media-prompt/route.ts @@ -0,0 +1,59 @@ +/** + * Auto-generates a media generation prompt from a slide indication text. + * Used when the user picks a media type not present in the original slide. + */ +import { NextRequest } from 'next/server'; +import { createLogger } from '@/lib/logger'; +import { apiError, apiSuccess } from '@/lib/server/api-response'; +import { callLLM } from '@/lib/ai/llm'; +import { resolveModelFromHeaders } from '@/lib/server/resolve-model'; + +const log = createLogger('MediaPrompt API'); + +export const maxDuration = 30; + +export async function POST(req: NextRequest) { + try { + const body = await req.json(); + const { indicationText, mediaType, language } = body as { + indicationText: string; + mediaType: 'image' | 'video'; + language?: string; + }; + + if (!indicationText || !indicationText.trim()) { + return apiError('MISSING_REQUIRED_FIELD', 400, 'indicationText is required'); + } + if (!mediaType) { + return apiError('MISSING_REQUIRED_FIELD', 400, 'mediaType is required'); + } + if (mediaType !== 'image' && mediaType !== 'video') { + return apiError('INVALID_REQUEST', 400, 'mediaType must be "image" or "video"'); + } + + const { model: languageModel } = await resolveModelFromHeaders(req); + + const mediaLabel = mediaType === 'image' ? 'image' : 'short video loop'; + const langHint = language ? ` The course language is ${language}.` : ''; + + const result = await callLLM( + { + model: languageModel, + system: `You are a visual media prompt writer. Given a slide description, write a concise prompt (1–2 sentences, max 30 words) for generating a ${mediaLabel} that visually represents the slide content. Respond with ONLY the prompt text — no quotes, no explanation.${langHint}`, + prompt: indicationText, + maxOutputTokens: 150, + }, + 'media-prompt', + ); + + const prompt = result.text.trim(); + log.info( + `Generated media prompt for ${mediaType}: "${prompt.length > 60 ? prompt.slice(0, 60) + '...' : prompt}"`, + ); + + return apiSuccess({ data: { prompt } }); + } catch (error) { + log.error('media-prompt generation failed:', error); + return apiError('INTERNAL_ERROR', 500, error instanceof Error ? error.message : String(error)); + } +} diff --git a/app/api/generate/narration-text/route.ts b/app/api/generate/narration-text/route.ts new file mode 100644 index 000000000..b43d53394 --- /dev/null +++ b/app/api/generate/narration-text/route.ts @@ -0,0 +1,54 @@ +/** + * Generates narration text for a slide from its indication (description + key points). + * Used by the "Regenerate narration" AI button in the slide regeneration dialog. + */ +import { NextRequest } from 'next/server'; +import { createLogger } from '@/lib/logger'; +import { apiError, apiSuccess, requireAuth } from '@/lib/server/api-response'; +import { callLLM } from '@/lib/ai/llm'; +import { resolveModelFromHeaders } from '@/lib/server/resolve-model'; + +const log = createLogger('NarrationText API'); + +export const maxDuration = 30; + +export async function POST(req: NextRequest) { + const user = await requireAuth(req); + if ('status' in user && user instanceof Response) return user; + + try { + const body = await req.json(); + const { indicationText, language } = body as { + indicationText: string; + language?: string; + }; + + if (!indicationText || !indicationText.trim()) { + return apiError('MISSING_REQUIRED_FIELD', 400, 'indicationText is required'); + } + + const { model: languageModel } = await resolveModelFromHeaders(req); + + const langHint = language + ? ` The narration MUST be written in ${language} (match the language of the key points).` + : ''; + + const result = await callLLM( + { + model: languageModel, + system: `You are an expert educational narrator. Given a slide's description and key points, write a natural spoken narration for a teacher to deliver while presenting the slide. The narration should:\n- Be conversational and engaging, as if speaking directly to students\n- Cover the key points clearly without reading them verbatim\n- Be 2-4 sentences long (suitable for a 15-30 second voiceover)\n- NOT include stage directions, quotes, or explanations — only the spoken text itself${langHint}`, + prompt: indicationText, + maxOutputTokens: 300, + }, + 'narration-text', + ); + + const text = result.text.trim(); + log.info(`Generated narration text (${text.length} chars)`); + + return apiSuccess({ data: { text } }); + } catch (error) { + log.error('narration-text generation failed:', error); + return apiError('INTERNAL_ERROR', 500, error instanceof Error ? error.message : String(error)); + } +} diff --git a/app/api/generate/scene-content-only/route.ts b/app/api/generate/scene-content-only/route.ts new file mode 100644 index 000000000..34d9049ce --- /dev/null +++ b/app/api/generate/scene-content-only/route.ts @@ -0,0 +1,83 @@ +/** + * Synchronous slide content generation — returns raw PPTElements without persisting a scene. + * Used by the per-slide regeneration flow. + */ +import { NextRequest } from 'next/server'; +import { createLogger } from '@/lib/logger'; +import { apiError, apiSuccess } from '@/lib/server/api-response'; +import { generateSceneContentFromInput } from '@/lib/server/scene-content-generation'; +import { getStorageBackend } from '@/lib/server/storage'; +import { resolveModelFromHeaders } from '@/lib/server/resolve-model'; +import type { SceneOutline, GeneratedSlideContent } from '@/lib/types/generation'; +import type { AgentInfo } from '@/lib/generation/generation-pipeline'; + +const log = createLogger('SceneContentOnly API'); + +export const maxDuration = 60; + +export async function POST(req: NextRequest) { + try { + const body = await req.json(); + const { outline, stageId, agents, themeId } = body as { + outline: SceneOutline; + stageId: string; + agents?: AgentInfo[]; + themeId?: string; + }; + + if (!outline) { + return apiError('MISSING_REQUIRED_FIELD', 400, 'outline is required'); + } + if (!stageId) { + return apiError('MISSING_REQUIRED_FIELD', 400, 'stageId is required'); + } + + if (outline.type !== 'slide') { + return apiError('INVALID_REQUEST', 400, 'Only slide-type outlines are supported'); + } + + // Load stage metadata and outlines from server storage + const backend = getStorageBackend(); + const [stageData, savedOutlines] = await Promise.all([ + backend.loadStage(stageId), + backend.loadOutlines(stageId), + ]); + + if (!stageData) { + return apiError('NOT_FOUND', 404, 'Stage not found'); + } + + const allOutlines = savedOutlines ?? [outline]; + const stageInfo = { + name: stageData.stage.name ?? '', + description: stageData.stage.description, + language: stageData.stage.language, + style: stageData.stage.style, + themeId, + }; + + // ── Model resolution from request headers ── + const { modelString } = await resolveModelFromHeaders(req); + + const result = await generateSceneContentFromInput({ + outline, + allOutlines, + stageId, + stageInfo, + agents, + modelConfig: { modelString }, + }); + + // Return only the slide content fields (elements + background) + const slideContent = result.content as GeneratedSlideContent; + return apiSuccess({ + data: { + elements: slideContent.elements ?? [], + background: slideContent.background, + }, + }); + } catch (error) { + log.error('scene-content-only failed:', error); + return apiError('INTERNAL_ERROR', 500, error instanceof Error ? error.message : String(error)); + } +} diff --git a/components/classroom/regenerate-slide-dialog.tsx b/components/classroom/regenerate-slide-dialog.tsx new file mode 100644 index 000000000..886a82724 --- /dev/null +++ b/components/classroom/regenerate-slide-dialog.tsx @@ -0,0 +1,535 @@ +'use client'; + +import { useState, useEffect, useCallback, useRef } from 'react'; +import { Sparkles } from 'lucide-react'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, + DialogFooter, +} from '@/components/ui/dialog'; +import { Button } from '@/components/ui/button'; +import { Textarea } from '@/components/ui/textarea'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Switch } from '@/components/ui/switch'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import { useSettingsStore } from '@/lib/store/settings'; +import type { ThemeListItem } from '@/lib/types/theme'; +import { useI18n } from '@/lib/hooks/use-i18n'; +import { outlineToIndication, indicationToOutline } from '@/lib/hooks/use-scene-regenerator'; +import { getCurrentModelConfig } from '@/lib/utils/model-config'; +import { CompactModelSelector } from '@/components/generation/model-selector-popover'; +import { MediaPopover } from '@/components/generation/media-popover'; +import type { Scene } from '@/lib/types/stage'; +import type { SceneOutline } from '@/lib/types/generation'; +import type { SpeechAction } from '@/lib/types/action'; +import type { RegenerateParams } from '@/lib/hooks/use-scene-regenerator'; + +export interface RegenerateFormValues { + title: string; + indication: string; + regenerateSlide: boolean; + audioText: string; + modifyAudio: boolean; + mediaType: 'none' | 'image' | 'video' | 'keep'; + mediaPrompt: string; + themeId: string; +} + +interface RegenerateSlideDialogProps { + open: boolean; + scene: Scene; + outline: SceneOutline; + initialValues?: RegenerateFormValues; + /** Error message from the last regeneration attempt, if any */ + errorMessage?: string; + onRegenerate: (params: RegenerateParams) => void; + onClose: () => void; +} + +function sceneToAudioText(scene: Scene): string { + return (scene.actions ?? []) + .filter((a): a is SpeechAction => a.type === 'speech') + .map((a) => a.text) + .join('\n\n'); +} + +function outlineToMediaType(outline: SceneOutline): 'none' | 'image' | 'video' | 'keep' { + const generations = outline.mediaGenerations ?? []; + // If the outline had media, default to 'keep' so it's preserved without re-generation + if (generations.some((g) => g.type === 'video')) return 'keep'; + if (generations.some((g) => g.type === 'image')) return 'keep'; + return 'none'; +} + +function outlineToMediaPrompt( + outline: SceneOutline, + mediaType: 'none' | 'image' | 'video', +): string { + if (mediaType === 'none') return ''; + const entry = (outline.mediaGenerations ?? []).find((g) => g.type === mediaType); + return entry?.prompt ?? ''; +} + +export function RegenerateSlideDialog({ + open, + scene, + outline, + initialValues, + errorMessage, + onRegenerate, + onClose, +}: RegenerateSlideDialogProps) { + const { t } = useI18n(); + const defaultThemeId = useSettingsStore((s) => s.themeId); + + const [title, setTitle] = useState(''); + const [regenerateSlide, setRegenerateSlide] = useState(true); + const [indication, setIndication] = useState(''); + const [audioText, setAudioText] = useState(''); + const [modifyAudio, setModifyAudio] = useState(false); + const [mediaType, setMediaType] = useState<'none' | 'image' | 'video' | 'keep'>('none'); + const [mediaPrompt, setMediaPrompt] = useState(''); + const [themeId, setThemeId] = useState(defaultThemeId); + const [themes, setThemes] = useState([]); + const [isGeneratingPrompt, setIsGeneratingPrompt] = useState(false); + const [isGeneratingNarration, setIsGeneratingNarration] = useState(false); + const [narrationError, setNarrationError] = useState(null); + const [promptError, setPromptError] = useState(null); + const promptAbortRef = useRef(null); + const narrationAbortRef = useRef(null); + + // Initialise form values on open + fetch theme list + useEffect(() => { + if (!open) return; + if (initialValues) { + setTitle(initialValues.title); + setRegenerateSlide(initialValues.regenerateSlide); + setIndication(initialValues.indication); + setAudioText(initialValues.audioText); + setModifyAudio(initialValues.modifyAudio); + setMediaType(initialValues.mediaType); + setMediaPrompt(initialValues.mediaPrompt); + setThemeId(initialValues.themeId || defaultThemeId); + } else { + setTitle(outline.title); + setRegenerateSlide(true); + setIndication(outlineToIndication(outline.description, outline.keyPoints)); + setAudioText(sceneToAudioText(scene)); + setModifyAudio(false); + const mt = outlineToMediaType(outline); + setMediaType(mt); + setMediaPrompt(mt === 'keep' ? '' : outlineToMediaPrompt(outline, mt)); + setThemeId(defaultThemeId); + } + // Fetch available themes + fetch('/api/themes') + .then((r) => r.json()) + .then((data: ThemeListItem[]) => setThemes(data)) + .catch(() => { + /* theme list stays empty, selector hidden */ + }); + }, [open, outline, scene, initialValues, defaultThemeId]); + + // Abort in-flight fetches when dialog closes + useEffect(() => { + if (!open) { + promptAbortRef.current?.abort(); + narrationAbortRef.current?.abort(); + } + }, [open]); + + // Abort on unmount + useEffect(() => { + return () => { + promptAbortRef.current?.abort(); + narrationAbortRef.current?.abort(); + }; + }, []); + + const generateNarration = useCallback( + async (currentIndication: string) => { + narrationAbortRef.current?.abort(); + const ctrl = new AbortController(); + narrationAbortRef.current = ctrl; + + setIsGeneratingNarration(true); + setNarrationError(null); + try { + const config = getCurrentModelConfig(); + const res = await fetch('/api/generate/narration-text', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'x-model': config.modelString || '', + 'x-provider-type': config.providerType || '', + }, + body: JSON.stringify({ + indicationText: currentIndication, + language: outline.language, + }), + signal: ctrl.signal, + }); + if (ctrl.signal.aborted) return; + const json = await res.json(); + if (json.success && json.data?.text) { + setAudioText(json.data.text); + } else { + setNarrationError(json.error || t('stage.regen.aiGenerationError')); + } + } catch (err) { + if (err instanceof DOMException && err.name === 'AbortError') return; + setNarrationError(t('stage.regen.aiGenerationError')); + } finally { + if (!ctrl.signal.aborted) setIsGeneratingNarration(false); + } + }, + [outline.language, t], + ); + + const generatePromptForType = useCallback( + async (type: 'image' | 'video', currentIndication: string) => { + // Cancel any in-flight request + promptAbortRef.current?.abort(); + const ctrl = new AbortController(); + promptAbortRef.current = ctrl; + + setIsGeneratingPrompt(true); + setMediaPrompt(''); + setPromptError(null); + try { + const config = getCurrentModelConfig(); + const res = await fetch('/api/generate/media-prompt', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'x-model': config.modelString || '', + 'x-provider-type': config.providerType || '', + }, + body: JSON.stringify({ + indicationText: currentIndication, + mediaType: type, + language: outline.language, + }), + signal: ctrl.signal, + }); + if (ctrl.signal.aborted) return; + const json = await res.json(); + if (json.success && json.data?.prompt) { + setMediaPrompt(json.data.prompt); + } else { + setPromptError(json.error || t('stage.regen.aiGenerationError')); + } + } catch (err) { + if (err instanceof DOMException && err.name === 'AbortError') return; + setPromptError(t('stage.regen.aiGenerationError')); + } finally { + if (!ctrl.signal.aborted) setIsGeneratingPrompt(false); + } + }, + [outline.language, t], + ); + + const handleMediaTypeChange = useCallback( + (value: 'none' | 'image' | 'video' | 'keep') => { + setMediaType(value); + if (value === 'none' || value === 'keep') { + setMediaPrompt(''); + promptAbortRef.current?.abort(); + return; + } + // Check if original outline already has a prompt for this type + const existingPrompt = outlineToMediaPrompt(outline, value); + if (existingPrompt) { + setMediaPrompt(existingPrompt); + } else { + generatePromptForType(value, indication); + } + }, + [outline, generatePromptForType, indication], + ); + + // Conflict: new media requested without slide toggle, but slide has no existing media slot + const hasExistingMedia = outlineToMediaType(outline) !== 'none'; + const needsNewMedia = mediaType === 'image' || mediaType === 'video'; + const showSlideWarning = !regenerateSlide && needsNewMedia && !hasExistingMedia; + + const handleSubmit = () => { + const forceSlideRegen = !regenerateSlide && needsNewMedia && !hasExistingMedia; + const updatedOutline: SceneOutline = + regenerateSlide || forceSlideRegen + ? { ...outline, title, ...indicationToOutline(indication) } + : { ...outline }; + // Do NOT call onClose() here — Stage closes the dialog by transitioning + // regenState from 'dialog_open' to 'regenerating'. Calling onClose() would + // race with setRegenState('regenerating') and the batch winner is 'idle', + // leaving the dialog open throughout the entire regeneration. + onRegenerate({ + outline: updatedOutline, + audioTextOverride: modifyAudio ? audioText : '', + mediaType, + mediaPrompt: mediaType !== 'none' && mediaType !== 'keep' ? mediaPrompt : undefined, + skipAudio: !modifyAudio, + skipSlide: !regenerateSlide && !forceSlideRegen, + themeId: themeId || undefined, + }); + }; + + const isSubmitDisabled = + isGeneratingPrompt || (mediaType !== 'none' && mediaType !== 'keep' && !mediaPrompt.trim()); + + return ( + !o && onClose()}> + + + + {t('stage.regen.dialogTitle')} — {title} + + + {t('stage.regen.dialogTitle')} — {title} + + + + {errorMessage && ( +
+ Error: + {errorMessage} +
+ )} + +
+ {/* Slide block */} +
+
+ +
+ + {t('stage.regen.modifySlide')} + + +
+
+ {regenerateSlide ? ( + <> +
+ + setTitle(e.target.value)} + className="h-8 text-sm" + /> +
+