|
|
|
|
| import { useState, useEffect, useRef, useMemo, useCallback } from 'react'; |
| import { useStageStore } from '@/lib/store'; |
| 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 { SceneSidebar } from './stage/scene-sidebar'; |
| import { Header } from './header'; |
| import { CanvasArea } from '@/components/canvas/canvas-area'; |
| import { Roundtable } from '@/components/roundtable'; |
| import { PlaybackEngine, computePlaybackView } from '@/lib/playback'; |
| import type { EngineMode, TriggerEvent, Effect } from '@/lib/playback'; |
| import { ActionEngine } from '@/lib/action/engine'; |
| import { createAudioPlayer } from '@/lib/utils/audio-player'; |
| import { useDiscussionTTS } from '@/lib/hooks/use-discussion-tts'; |
| import { useWidgetIframeStore } from '@/lib/store/widget-iframe'; |
| import type { AudioIndicatorState } from '@/components/roundtable/audio-indicator'; |
| import type { Action, DiscussionAction, SpeechAction } from '@/lib/types/action'; |
| import { cn } from '@/lib/utils'; |
| |
| import { ChatArea, type ChatAreaRef } from '@/components/chat/chat-area'; |
| import { agentsToParticipants, useAgentRegistry } from '@/lib/orchestration/registry/store'; |
| import type { AgentConfig } from '@/lib/orchestration/registry/types'; |
| import { |
| AlertDialog, |
| AlertDialogContent, |
| AlertDialogTitle, |
| AlertDialogFooter, |
| AlertDialogAction, |
| AlertDialogCancel, |
| } from '@/components/ui/alert-dialog'; |
| import { AlertTriangle } from 'lucide-react'; |
| import { VisuallyHidden } from 'radix-ui'; |
|
|
| |
| |
| |
| |
| |
| |
| |
| export function Stage({ |
| onRetryOutline, |
| }: { |
| onRetryOutline?: (outlineId: string) => Promise<void>; |
| }) { |
| const { t } = useI18n(); |
| const { |
| mode, |
| getCurrentScene, |
| scenes, |
| currentSceneId, |
| setCurrentSceneId, |
| generatingOutlines, |
| outlines, |
| } = useStageStore(); |
| const failedOutlines = useStageStore.use.failedOutlines(); |
|
|
| const currentScene = getCurrentScene(); |
|
|
| |
| const sidebarCollapsed = useSettingsStore((s) => s.sidebarCollapsed); |
| const setSidebarCollapsed = useSettingsStore((s) => s.setSidebarCollapsed); |
| const chatAreaWidth = useSettingsStore((s) => s.chatAreaWidth); |
| const setChatAreaWidth = useSettingsStore((s) => s.setChatAreaWidth); |
| const chatAreaCollapsed = useSettingsStore((s) => s.chatAreaCollapsed); |
| const setChatAreaCollapsed = useSettingsStore((s) => s.setChatAreaCollapsed); |
| const setTTSMuted = useSettingsStore((s) => s.setTTSMuted); |
| const setTTSVolume = useSettingsStore((s) => s.setTTSVolume); |
|
|
| |
| const [engineMode, setEngineMode] = useState<EngineMode>('idle'); |
| const [playbackCompleted, setPlaybackCompleted] = useState(false); |
| const [lectureSpeech, setLectureSpeech] = useState<string | null>(null); |
| const [liveSpeech, setLiveSpeech] = useState<string | null>(null); |
| const [speechProgress, setSpeechProgress] = useState<number | null>(null); |
| const [discussionTrigger, setDiscussionTrigger] = useState<TriggerEvent | null>(null); |
|
|
| |
| const [speakingAgentId, setSpeakingAgentId] = useState<string | null>(null); |
|
|
| |
| const [thinkingState, setThinkingState] = useState<{ |
| stage: string; |
| agentId?: string; |
| } | null>(null); |
|
|
| |
| const [isCueUser, setIsCueUser] = useState(false); |
|
|
| |
| const [showEndFlash, setShowEndFlash] = useState(false); |
| const [endFlashSessionType, setEndFlashSessionType] = useState<'qa' | 'discussion'>('discussion'); |
|
|
| |
| const [chatIsStreaming, setChatIsStreaming] = useState(false); |
| const [chatSessionType, setChatSessionType] = useState<string | null>(null); |
|
|
| |
| const [isTopicPending, setIsTopicPending] = useState(false); |
|
|
| |
| const [activeBubbleId, setActiveBubbleId] = useState<string | null>(null); |
|
|
| |
| const [pendingSceneId, setPendingSceneId] = useState<string | null>(null); |
| const [isPresenting, setIsPresenting] = useState(false); |
| const [controlsVisible, setControlsVisible] = useState(true); |
| const [isPresentationInteractionActive, setIsPresentationInteractionActive] = useState(false); |
|
|
| |
| const whiteboardOpen = useCanvasStore.use.whiteboardOpen(); |
| const setWhiteboardOpen = useCanvasStore.use.setWhiteboardOpen(); |
|
|
| |
| const selectedAgentIds = useSettingsStore((s) => s.selectedAgentIds); |
| const ttsMuted = useSettingsStore((s) => s.ttsMuted); |
| const ttsEnabled = useSettingsStore((s) => s.ttsEnabled); |
|
|
| |
| const participants = useMemo( |
| () => agentsToParticipants(selectedAgentIds, t), |
| [selectedAgentIds, t], |
| ); |
|
|
| |
| |
| const agentsRecord = useAgentRegistry((s) => s.agents); |
| const selectedAgents = useMemo( |
| () => selectedAgentIds.map((id) => agentsRecord[id]).filter((a): a is AgentConfig => a != null), |
| [agentsRecord, selectedAgentIds], |
| ); |
|
|
| |
| const [audioIndicatorState, setAudioIndicatorState] = useState<AudioIndicatorState>('idle'); |
| const [audioAgentId, setAudioAgentId] = useState<string | null>(null); |
|
|
| const discussionTTS = useDiscussionTTS({ |
| enabled: ttsEnabled && !ttsMuted, |
| agents: selectedAgents, |
| onAudioStateChange: (agentId, state) => { |
| setAudioAgentId(agentId); |
| setAudioIndicatorState(state); |
| }, |
| }); |
|
|
| |
| const pickStudentAgent = useCallback((): string => { |
| const registry = useAgentRegistry.getState(); |
| const agents = selectedAgentIds |
| .map((id) => registry.getAgent(id)) |
| .filter((a): a is AgentConfig => a != null); |
| const students = agents.filter((a) => a.role === 'student'); |
| if (students.length > 0) { |
| return students[Math.floor(Math.random() * students.length)].id; |
| } |
| const nonTeachers = agents.filter((a) => a.role !== 'teacher'); |
| if (nonTeachers.length > 0) { |
| return nonTeachers[Math.floor(Math.random() * nonTeachers.length)].id; |
| } |
| return agents[0]?.id || 'default-1'; |
| }, [selectedAgentIds]); |
|
|
| const engineRef = useRef<PlaybackEngine | null>(null); |
| const audioPlayerRef = useRef(createAudioPlayer()); |
| const chatAreaRef = useRef<ChatAreaRef>(null); |
| const lectureSessionIdRef = useRef<string | null>(null); |
| const lectureActionCounterRef = useRef(0); |
| const discussionAbortRef = useRef<AbortController | null>(null); |
| const presentationIdleTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null); |
| const stageRef = useRef<HTMLDivElement>(null); |
| |
| const manualStopRef = useRef(false); |
| |
| const sceneEpochRef = useRef(0); |
| |
| const autoStartRef = useRef(false); |
| |
| const [isDiscussionPaused, setIsDiscussionPaused] = useState(false); |
|
|
| |
| |
| |
| |
| const doResumeTopic = useCallback(async () => { |
| |
| setIsTopicPending(false); |
| setLiveSpeech(null); |
| setSpeakingAgentId(null); |
| setThinkingState({ stage: 'director' }); |
| setChatIsStreaming(true); |
| |
| |
| engineRef.current?.resume(); |
| |
| await chatAreaRef.current?.resumeActiveSession(); |
| }, []); |
|
|
| |
| const resetLiveState = useCallback(() => { |
| setLiveSpeech(null); |
| setSpeakingAgentId(null); |
| setSpeechProgress(null); |
| setThinkingState(null); |
| setIsCueUser(false); |
| setIsTopicPending(false); |
| setChatIsStreaming(false); |
| setChatSessionType(null); |
| setIsDiscussionPaused(false); |
| }, []); |
|
|
| |
| const resetSceneState = useCallback(() => { |
| resetLiveState(); |
| setPlaybackCompleted(false); |
| setLectureSpeech(null); |
| setSpeechProgress(null); |
| setShowEndFlash(false); |
| setActiveBubbleId(null); |
| setDiscussionTrigger(null); |
| }, [resetLiveState]); |
|
|
| |
| const handleLiveSessionError = useCallback(() => { |
| engineRef.current?.handleDiscussionError(); |
| resetLiveState(); |
| setActiveBubbleId(null); |
| }, [resetLiveState]); |
|
|
| |
| |
| |
| |
| const doSessionCleanup = useCallback(() => { |
| const activeType = chatSessionType; |
|
|
| |
| manualStopRef.current = true; |
| engineRef.current?.handleEndDiscussion(); |
| manualStopRef.current = false; |
|
|
| |
| if (activeType === 'qa' || activeType === 'discussion') { |
| setEndFlashSessionType(activeType); |
| setShowEndFlash(true); |
| setTimeout(() => setShowEndFlash(false), 1800); |
| } |
|
|
| |
| discussionTTS.cleanup(); |
|
|
| resetLiveState(); |
| }, [chatSessionType, resetLiveState, discussionTTS]); |
|
|
| |
| const handleStopDiscussion = useCallback(async () => { |
| await chatAreaRef.current?.endActiveSession(); |
| doSessionCleanup(); |
| }, [doSessionCleanup]); |
|
|
| const clearPresentationIdleTimer = useCallback(() => { |
| if (presentationIdleTimerRef.current) { |
| clearTimeout(presentationIdleTimerRef.current); |
| presentationIdleTimerRef.current = null; |
| } |
| }, []); |
|
|
| const resetPresentationIdleTimer = useCallback(() => { |
| setControlsVisible(true); |
| clearPresentationIdleTimer(); |
| if (isPresenting && !isPresentationInteractionActive) { |
| presentationIdleTimerRef.current = setTimeout(() => { |
| setControlsVisible(false); |
| }, 3000); |
| } |
| }, [clearPresentationIdleTimer, isPresenting, isPresentationInteractionActive]); |
|
|
| const togglePresentation = useCallback(async () => { |
| const stageElement = stageRef.current; |
| if (!stageElement) return; |
|
|
| try { |
| if (document.fullscreenElement === stageElement) { |
| |
| |
| (navigator as any).keyboard?.unlock?.(); |
| await document.exitFullscreen(); |
| return; |
| } |
|
|
| setControlsVisible(true); |
| await stageElement.requestFullscreen(); |
| |
| |
| |
| await (navigator as any).keyboard?.lock?.(['Escape']).catch(() => {}); |
| setSidebarCollapsed(true); |
| setChatAreaCollapsed(true); |
| } catch { |
| |
| console.warn('[Presentation] Fullscreen request denied — browser policy'); |
| } |
| }, [setChatAreaCollapsed, setSidebarCollapsed]); |
|
|
| useEffect(() => { |
| const onFullscreenChange = () => { |
| const active = document.fullscreenElement === stageRef.current; |
| setIsPresenting(active); |
|
|
| if (!active) { |
| |
| |
| (navigator as any).keyboard?.unlock?.(); |
| setControlsVisible(true); |
| clearPresentationIdleTimer(); |
| } |
| }; |
|
|
| document.addEventListener('fullscreenchange', onFullscreenChange); |
| return () => document.removeEventListener('fullscreenchange', onFullscreenChange); |
| }, [clearPresentationIdleTimer]); |
|
|
| useEffect(() => { |
| if (!isPresenting) { |
| setControlsVisible(true); |
| clearPresentationIdleTimer(); |
| return; |
| } |
|
|
| const handleActivity = () => { |
| resetPresentationIdleTimer(); |
| }; |
|
|
| window.addEventListener('mousemove', handleActivity); |
| window.addEventListener('mousedown', handleActivity); |
| window.addEventListener('touchstart', handleActivity); |
| if (isPresentationInteractionActive) { |
| setControlsVisible(true); |
| clearPresentationIdleTimer(); |
| } else { |
| resetPresentationIdleTimer(); |
| } |
|
|
| return () => { |
| window.removeEventListener('mousemove', handleActivity); |
| window.removeEventListener('mousedown', handleActivity); |
| window.removeEventListener('touchstart', handleActivity); |
| clearPresentationIdleTimer(); |
| }; |
| }, [ |
| clearPresentationIdleTimer, |
| isPresenting, |
| isPresentationInteractionActive, |
| resetPresentationIdleTimer, |
| ]); |
|
|
| |
| useEffect(() => { |
| |
| sceneEpochRef.current++; |
|
|
| |
| |
| |
| chatAreaRef.current?.endActiveSession(); |
|
|
| |
| if (discussionAbortRef.current) { |
| discussionAbortRef.current.abort(); |
| discussionAbortRef.current = null; |
| } |
|
|
| |
| discussionTTS.cleanup(); |
|
|
| |
| resetSceneState(); |
|
|
| if (!currentScene || !currentScene.actions || currentScene.actions.length === 0) { |
| engineRef.current = null; |
| setEngineMode('idle'); |
|
|
| return; |
| } |
|
|
| |
| if (engineRef.current) { |
| engineRef.current.stop(); |
| } |
|
|
| |
| const widgetSendMessage = useWidgetIframeStore.getState().getSendMessage(currentScene.id); |
|
|
| |
| const actionEngine = new ActionEngine(useStageStore, audioPlayerRef.current, widgetSendMessage); |
|
|
| |
| const engine = new PlaybackEngine([currentScene], actionEngine, audioPlayerRef.current, { |
| onModeChange: (mode) => { |
| setEngineMode(mode); |
| }, |
| onSceneChange: (_sceneId) => { |
| |
| }, |
| onSpeechStart: (text) => { |
| setLectureSpeech(text); |
| |
| |
| if (lectureSessionIdRef.current) { |
| const idx = lectureActionCounterRef.current++; |
| const speechId = `speech-${Date.now()}`; |
| chatAreaRef.current?.addLectureMessage( |
| lectureSessionIdRef.current, |
| { id: speechId, type: 'speech', text } as Action, |
| idx, |
| ); |
| |
| const msgId = chatAreaRef.current?.getLectureMessageId(lectureSessionIdRef.current!); |
| if (msgId) setActiveBubbleId(msgId); |
| } |
| }, |
| onSpeechEnd: () => { |
| |
| |
| |
| setActiveBubbleId(null); |
| }, |
| onEffectFire: (effect: Effect) => { |
| |
| if ( |
| lectureSessionIdRef.current && |
| (effect.kind === 'spotlight' || effect.kind === 'laser') |
| ) { |
| const idx = lectureActionCounterRef.current++; |
| chatAreaRef.current?.addLectureMessage( |
| lectureSessionIdRef.current, |
| { |
| id: `${effect.kind}-${Date.now()}`, |
| type: effect.kind, |
| elementId: effect.targetId, |
| } as Action, |
| idx, |
| ); |
| } |
| }, |
| onProactiveShow: (trigger) => { |
| if (!trigger.agentId) { |
| |
| |
| trigger.agentId = pickStudentAgent(); |
| } |
| setDiscussionTrigger(trigger); |
| }, |
| onProactiveHide: () => { |
| setDiscussionTrigger(null); |
| }, |
| onDiscussionConfirmed: (topic, prompt, agentId) => { |
| |
| handleDiscussionSSE(topic, prompt, agentId); |
| }, |
| onDiscussionEnd: () => { |
| |
| if (discussionAbortRef.current) { |
| discussionAbortRef.current.abort(); |
| discussionAbortRef.current = null; |
| } |
| setDiscussionTrigger(null); |
| |
| discussionTTS.cleanup(); |
| |
| resetLiveState(); |
| |
| if (!manualStopRef.current) { |
| setEndFlashSessionType('discussion'); |
| setShowEndFlash(true); |
| setTimeout(() => setShowEndFlash(false), 1800); |
| } |
| |
| |
| if (engineRef.current?.isExhausted()) { |
| setPlaybackCompleted(true); |
| } |
| }, |
| onUserInterrupt: (text) => { |
| |
| chatAreaRef.current?.sendMessage(text); |
| }, |
| isAgentSelected: (agentId) => { |
| const ids = useSettingsStore.getState().selectedAgentIds; |
| return ids.includes(agentId); |
| }, |
| getPlaybackSpeed: () => useSettingsStore.getState().playbackSpeed || 1, |
| onComplete: () => { |
| |
| |
| |
| setPlaybackCompleted(true); |
|
|
| |
| if (lectureSessionIdRef.current) { |
| chatAreaRef.current?.endSession(lectureSessionIdRef.current); |
| lectureSessionIdRef.current = null; |
| } |
| |
| const { autoPlayLecture } = useSettingsStore.getState(); |
| if (autoPlayLecture) { |
| setTimeout(() => { |
| const stageState = useStageStore.getState(); |
| if (!useSettingsStore.getState().autoPlayLecture) return; |
| const allScenes = stageState.scenes; |
| const curId = stageState.currentSceneId; |
| const idx = allScenes.findIndex((s) => s.id === curId); |
| if (idx >= 0 && idx < allScenes.length - 1) { |
| const currentScene = allScenes[idx]; |
| if ( |
| currentScene.type === 'quiz' || |
| currentScene.type === 'interactive' || |
| currentScene.type === 'pbl' |
| ) { |
| return; |
| } |
| autoStartRef.current = true; |
| stageState.setCurrentSceneId(allScenes[idx + 1].id); |
| } else if (idx === allScenes.length - 1 && stageState.generatingOutlines.length > 0) { |
| |
| const currentScene = allScenes[idx]; |
| if ( |
| currentScene.type === 'quiz' || |
| currentScene.type === 'interactive' || |
| currentScene.type === 'pbl' |
| ) { |
| return; |
| } |
| autoStartRef.current = true; |
| stageState.setCurrentSceneId(PENDING_SCENE_ID); |
| } |
| }, 1500); |
| } |
| }, |
| }); |
|
|
| engineRef.current = engine; |
|
|
| |
| if (autoStartRef.current) { |
| autoStartRef.current = false; |
| (async () => { |
| if (currentScene && chatAreaRef.current) { |
| const sessionId = await chatAreaRef.current.startLecture(currentScene.id); |
| lectureSessionIdRef.current = sessionId; |
| lectureActionCounterRef.current = 0; |
| } |
| engine.start(); |
| })(); |
| } else { |
| |
| } |
| |
| }, [currentScene]); |
|
|
| |
| useEffect(() => { |
| const audioPlayer = audioPlayerRef.current; |
| const chatArea = chatAreaRef.current; |
| return () => { |
| if (engineRef.current) { |
| engineRef.current.stop(); |
| } |
| audioPlayer.destroy(); |
| if (discussionAbortRef.current) { |
| discussionAbortRef.current.abort(); |
| } |
| discussionTTS.cleanup(); |
| chatArea?.endActiveSession(); |
| clearPresentationIdleTimer(); |
| }; |
| |
| }, []); |
|
|
| |
| useEffect(() => { |
| audioPlayerRef.current.setMuted(ttsMuted); |
| }, [ttsMuted]); |
|
|
| |
| const ttsVolume = useSettingsStore((s) => s.ttsVolume); |
| useEffect(() => { |
| if (!ttsMuted) { |
| audioPlayerRef.current.setVolume(ttsVolume); |
| } |
| }, [ttsVolume, ttsMuted]); |
|
|
| |
| const playbackSpeed = useSettingsStore((s) => s.playbackSpeed); |
| useEffect(() => { |
| audioPlayerRef.current.setPlaybackRate(playbackSpeed); |
| }, [playbackSpeed]); |
|
|
| |
| |
| |
| const handleDiscussionSSE = useCallback( |
| async (topic: string, prompt?: string, agentId?: string) => { |
| |
| chatAreaRef.current?.startDiscussion({ |
| topic, |
| prompt, |
| agentId: agentId || 'default-1', |
| }); |
| |
| chatAreaRef.current?.switchToTab('chat'); |
| |
| setChatIsStreaming(true); |
| setChatSessionType('discussion'); |
| |
| setThinkingState({ stage: 'director' }); |
| }, |
| [], |
| ); |
|
|
| |
| const firstSpeechText = useMemo( |
| () => currentScene?.actions?.find((a): a is SpeechAction => a.type === 'speech')?.text ?? null, |
| [currentScene], |
| ); |
|
|
| |
| const speakingStudentFlag = useMemo(() => { |
| if (!speakingAgentId) return false; |
| const agent = useAgentRegistry.getState().getAgent(speakingAgentId); |
| return agent?.role !== 'teacher'; |
| }, [speakingAgentId]); |
|
|
| |
| const playbackView = useMemo( |
| () => |
| computePlaybackView({ |
| engineMode, |
| lectureSpeech, |
| liveSpeech, |
| speakingAgentId, |
| thinkingState, |
| isCueUser, |
| isTopicPending, |
| chatIsStreaming, |
| discussionTrigger, |
| playbackCompleted, |
| idleText: firstSpeechText, |
| speakingStudent: speakingStudentFlag, |
| sessionType: chatSessionType, |
| }), |
| [ |
| engineMode, |
| lectureSpeech, |
| liveSpeech, |
| speakingAgentId, |
| thinkingState, |
| isCueUser, |
| isTopicPending, |
| chatIsStreaming, |
| discussionTrigger, |
| playbackCompleted, |
| firstSpeechText, |
| speakingStudentFlag, |
| chatSessionType, |
| ], |
| ); |
|
|
| const isTopicActive = playbackView.isTopicActive; |
|
|
| |
| |
| |
| |
| const gatedSceneSwitch = useCallback( |
| (targetSceneId: string): boolean => { |
| if (targetSceneId === currentSceneId) return false; |
| if (isTopicActive) { |
| setPendingSceneId(targetSceneId); |
| return false; |
| } |
| setCurrentSceneId(targetSceneId); |
| return true; |
| }, |
| [currentSceneId, isTopicActive, setCurrentSceneId], |
| ); |
|
|
| |
| const confirmSceneSwitch = useCallback(() => { |
| if (!pendingSceneId) return; |
| chatAreaRef.current?.endActiveSession(); |
| doSessionCleanup(); |
| setCurrentSceneId(pendingSceneId); |
| setPendingSceneId(null); |
| }, [pendingSceneId, setCurrentSceneId, doSessionCleanup]); |
|
|
| |
| const cancelSceneSwitch = useCallback(() => { |
| setPendingSceneId(null); |
| }, []); |
|
|
| |
| const handlePlayPause = useCallback(async () => { |
| const engine = engineRef.current; |
| if (!engine) return; |
|
|
| const mode = engine.getMode(); |
| if (mode === 'playing' || mode === 'live') { |
| engine.pause(); |
| |
| if (lectureSessionIdRef.current) { |
| chatAreaRef.current?.pauseBuffer(lectureSessionIdRef.current); |
| } |
| } else if (mode === 'paused') { |
| engine.resume(); |
| |
| if (lectureSessionIdRef.current) { |
| chatAreaRef.current?.resumeBuffer(lectureSessionIdRef.current); |
| } |
| } else { |
| const wasCompleted = playbackCompleted; |
| setPlaybackCompleted(false); |
| |
| if (currentScene && chatAreaRef.current) { |
| const sessionId = await chatAreaRef.current.startLecture(currentScene.id); |
| lectureSessionIdRef.current = sessionId; |
| } |
| if (wasCompleted) { |
| |
| lectureActionCounterRef.current = 0; |
| engine.start(); |
| } else { |
| |
| engine.continuePlayback(); |
| } |
| } |
| }, [playbackCompleted, currentScene]); |
|
|
| |
| const isPendingScene = currentSceneId === PENDING_SCENE_ID; |
| const hasNextPending = generatingOutlines.length > 0; |
| |
| |
| |
| |
| |
| const isCourseComplete = |
| outlines.length > 0 && scenes.length === outlines.length && generatingOutlines.length === 0; |
| const canAdvanceToPendingSlot = hasNextPending || isCourseComplete; |
|
|
| |
| const handlePreviousScene = useCallback(() => { |
| if (isPendingScene) { |
| |
| if (scenes.length > 0) { |
| gatedSceneSwitch(scenes[scenes.length - 1].id); |
| } |
| return; |
| } |
| const currentIndex = scenes.findIndex((s) => s.id === currentSceneId); |
| if (currentIndex > 0) { |
| gatedSceneSwitch(scenes[currentIndex - 1].id); |
| } |
| }, [currentSceneId, gatedSceneSwitch, isPendingScene, scenes]); |
|
|
| |
| const handleNextScene = useCallback(() => { |
| if (isPendingScene) return; |
| const currentIndex = scenes.findIndex((s) => s.id === currentSceneId); |
| if (currentIndex < scenes.length - 1) { |
| gatedSceneSwitch(scenes[currentIndex + 1].id); |
| } else if (canAdvanceToPendingSlot) { |
| |
| setCurrentSceneId(PENDING_SCENE_ID); |
| } |
| }, [ |
| currentSceneId, |
| gatedSceneSwitch, |
| canAdvanceToPendingSlot, |
| isPendingScene, |
| scenes, |
| setCurrentSceneId, |
| ]); |
|
|
| const currentSceneIndex = isPendingScene |
| ? scenes.length |
| : scenes.findIndex((s) => s.id === currentSceneId); |
| const totalScenesCount = scenes.length + (canAdvanceToPendingSlot ? 1 : 0); |
|
|
| |
| const totalActions = currentScene?.actions?.length || 0; |
|
|
| |
| const handleWhiteboardToggle = () => { |
| setWhiteboardOpen(!whiteboardOpen); |
| }; |
|
|
| const isPresentationShortcutTarget = useCallback((target: EventTarget | null) => { |
| if (!(target instanceof HTMLElement)) return false; |
|
|
| if (target.isContentEditable || target.closest('[contenteditable="true"]')) { |
| return true; |
| } |
|
|
| return ( |
| target.closest( |
| ['input', 'textarea', 'select', '[role="slider"]', 'input[type="range"]'].join(', '), |
| ) !== null |
| ); |
| }, []); |
|
|
| useEffect(() => { |
| const onKeyDown = (event: KeyboardEvent) => { |
| if (event.defaultPrevented) return; |
| |
| if (event.ctrlKey || event.metaKey || event.altKey) return; |
| if ( |
| isPresentationShortcutTarget(event.target) || |
| isPresentationShortcutTarget(document.activeElement) |
| ) { |
| return; |
| } |
|
|
| switch (event.key) { |
| case 'ArrowLeft': |
| if (!isPresenting) return; |
| event.preventDefault(); |
| handlePreviousScene(); |
| resetPresentationIdleTimer(); |
| break; |
| case 'ArrowRight': |
| if (!isPresenting) return; |
| event.preventDefault(); |
| handleNextScene(); |
| resetPresentationIdleTimer(); |
| break; |
| case ' ': |
| case 'Spacebar': |
| |
| |
| if (chatSessionType === 'qa' || chatSessionType === 'discussion') break; |
| event.preventDefault(); |
| handlePlayPause(); |
| break; |
| case 'Escape': |
| |
| |
| |
| if (isPresenting && !isPresentationInteractionActive) { |
| event.preventDefault(); |
| togglePresentation(); |
| } |
| break; |
| case 'ArrowUp': |
| event.preventDefault(); |
| setTTSVolume(ttsVolume + 0.1); |
| break; |
| case 'ArrowDown': |
| event.preventDefault(); |
| setTTSVolume(ttsVolume - 0.1); |
| break; |
| case 'm': |
| case 'M': |
| event.preventDefault(); |
| setTTSMuted(!ttsMuted); |
| break; |
| case 's': |
| case 'S': |
| event.preventDefault(); |
| setSidebarCollapsed(!sidebarCollapsed); |
| break; |
| case 'c': |
| case 'C': |
| event.preventDefault(); |
| setChatAreaCollapsed(!chatAreaCollapsed); |
| break; |
| default: |
| break; |
| } |
| }; |
|
|
| window.addEventListener('keydown', onKeyDown); |
| return () => window.removeEventListener('keydown', onKeyDown); |
| }, [ |
| chatSessionType, |
| chatAreaCollapsed, |
| handleNextScene, |
| handlePlayPause, |
| handlePreviousScene, |
| isPresenting, |
| isPresentationInteractionActive, |
| isPresentationShortcutTarget, |
| resetPresentationIdleTimer, |
| setChatAreaCollapsed, |
| setSidebarCollapsed, |
| setTTSMuted, |
| setTTSVolume, |
| sidebarCollapsed, |
| togglePresentation, |
| ttsMuted, |
| ttsVolume, |
| ]); |
|
|
| |
| |
| useEffect(() => { |
| const onF11 = (event: KeyboardEvent) => { |
| if (event.key === 'F11') { |
| event.preventDefault(); |
| togglePresentation(); |
| } |
| }; |
|
|
| window.addEventListener('keydown', onF11); |
| return () => window.removeEventListener('keydown', onF11); |
| }, [togglePresentation]); |
|
|
| |
| const canvasEngineState = (() => { |
| switch (engineMode) { |
| case 'playing': |
| case 'live': |
| return 'playing'; |
| case 'paused': |
| return 'paused'; |
| default: |
| return 'idle'; |
| } |
| })(); |
|
|
| |
| const discussionRequest: DiscussionAction | null = discussionTrigger |
| ? { |
| type: 'discussion', |
| id: discussionTrigger.id, |
| topic: discussionTrigger.question, |
| prompt: discussionTrigger.prompt, |
| agentId: discussionTrigger.agentId || 'default-1', |
| } |
| : null; |
|
|
| |
| const sceneViewerHeight = (() => { |
| const headerHeight = isPresenting ? 0 : 80; |
| const roundtableHeight = mode === 'playback' && !isPresenting ? 192 : 0; |
| return `calc(100% - ${headerHeight + roundtableHeight}px)`; |
| })(); |
|
|
| return ( |
| <div |
| ref={stageRef} |
| className={cn( |
| 'flex-1 flex overflow-hidden bg-gray-50 dark:bg-gray-900', |
| isPresenting && !controlsVisible && 'cursor-none', |
| )} |
| > |
| {/* Scene Sidebar */} |
| <SceneSidebar |
| collapsed={sidebarCollapsed} |
| onCollapseChange={setSidebarCollapsed} |
| onSceneSelect={gatedSceneSwitch} |
| onRetryOutline={onRetryOutline} |
| isCourseComplete={isCourseComplete} |
| /> |
| |
| {/* Main Content Area */} |
| <div className="flex-1 flex flex-col overflow-hidden min-w-0 relative"> |
| {/* Header */} |
| {!isPresenting && ( |
| <Header |
| currentSceneTitle={ |
| currentScene?.title || |
| (isCourseComplete && isPendingScene ? t('stage.courseComplete') : '') |
| } |
| /> |
| )} |
| |
| {/* Canvas Area */} |
| <div |
| className="overflow-hidden relative flex-1 min-h-0 isolate" |
| style={{ |
| height: sceneViewerHeight, |
| }} |
| suppressHydrationWarning |
| > |
| <CanvasArea |
| currentScene={currentScene} |
| currentSceneIndex={currentSceneIndex} |
| scenesCount={totalScenesCount} |
| mode={mode} |
| engineState={canvasEngineState} |
| isLiveSession={ |
| chatIsStreaming || isTopicPending || engineMode === 'live' || !!chatSessionType |
| } |
| whiteboardOpen={whiteboardOpen} |
| sidebarCollapsed={sidebarCollapsed} |
| chatCollapsed={chatAreaCollapsed} |
| onToggleSidebar={() => setSidebarCollapsed(!sidebarCollapsed)} |
| onToggleChat={() => setChatAreaCollapsed(!chatAreaCollapsed)} |
| onPrevSlide={handlePreviousScene} |
| onNextSlide={handleNextScene} |
| onPlayPause={handlePlayPause} |
| onWhiteboardClose={handleWhiteboardToggle} |
| isPresenting={isPresenting} |
| onTogglePresentation={togglePresentation} |
| showStopDiscussion={ |
| engineMode === 'live' || |
| (chatIsStreaming && (chatSessionType === 'qa' || chatSessionType === 'discussion')) |
| } |
| onStopDiscussion={handleStopDiscussion} |
| hideToolbar={mode === 'playback' || (isPresenting && !controlsVisible)} |
| isPendingScene={isPendingScene} |
| isCourseComplete={isCourseComplete} |
| isGenerationFailed={ |
| isPendingScene && failedOutlines.some((f) => f.id === generatingOutlines[0]?.id) |
| } |
| onRetryGeneration={ |
| onRetryOutline && generatingOutlines[0] |
| ? () => onRetryOutline(generatingOutlines[0].id) |
| : undefined |
| } |
| /> |
| </div> |
| |
| {/* Roundtable Area */} |
| {mode === 'playback' && ( |
| <div |
| className={cn( |
| 'transition-opacity duration-300', |
| !isPresenting && 'shrink-0', |
| isPresenting && 'absolute inset-x-0 bottom-0 z-20', |
| )} |
| > |
| <Roundtable |
| mode={mode} |
| initialParticipants={participants} |
| playbackView={playbackView} |
| currentSpeech={liveSpeech} |
| lectureSpeech={lectureSpeech} |
| idleText={firstSpeechText} |
| playbackCompleted={playbackCompleted} |
| discussionRequest={discussionRequest} |
| engineMode={engineMode} |
| isStreaming={chatIsStreaming} |
| audioIndicatorState={audioIndicatorState} |
| audioAgentId={audioAgentId} |
| sessionType={ |
| chatSessionType === 'qa' |
| ? 'qa' |
| : chatSessionType === 'discussion' |
| ? 'discussion' |
| : undefined |
| } |
| speakingAgentId={speakingAgentId} |
| speechProgress={speechProgress} |
| showEndFlash={showEndFlash} |
| endFlashSessionType={endFlashSessionType} |
| thinkingState={thinkingState} |
| isCueUser={isCueUser} |
| isTopicPending={isTopicPending} |
| onMessageSend={async (msg) => { |
| // Always clear Level-1 pause state — the closure may hold a stale |
| // isDiscussionPaused value (e.g. voice input's onTranscription callback |
| // captures onMessageSend before React re-renders with the updated state). |
| setIsDiscussionPaused(false); |
| // Clear the sticky livePausedRef so the next agent-loop buffer |
| // starts unpaused. (pauseActiveLiveBuffer sets a ref that new |
| // buffers inherit — must be cleared before sendMessage creates one.) |
| chatAreaRef.current?.resumeActiveLiveBuffer(); |
| // Flush any buffered / in-flight TTS audio from the previous |
| // agent turn so it doesn't leak into the next round. |
| discussionTTS.cleanup(); |
| // Clear soft-paused state — user is continuing the topic |
| if (isTopicPending) { |
| setIsTopicPending(false); |
| setLiveSpeech(null); |
| setSpeakingAgentId(null); |
| } |
| // User interrupts during playback — handleUserInterrupt triggers |
| // onUserInterrupt callback which already calls sendMessage, so skip |
| // the direct sendMessage below to avoid sending twice. |
| // Include 'paused' because onInputActivate pauses the engine before |
| // the user finishes typing — without this the interrupt position |
| // would never be saved and resuming after QA skips to the next sentence. |
| if ( |
| engineRef.current && |
| (engineMode === 'playing' || engineMode === 'live' || engineMode === 'paused') |
| ) { |
| engineRef.current.handleUserInterrupt(msg); |
| } else { |
| chatAreaRef.current?.sendMessage(msg); |
| } |
| // Auto-switch to chat tab when user sends a message |
| chatAreaRef.current?.switchToTab('chat'); |
| setIsCueUser(false); |
| // Immediately mark streaming for synchronized stop button |
| setChatIsStreaming(true); |
| setChatSessionType(chatSessionType || 'qa'); |
| // Optimistic thinking: show thinking dots immediately so there's |
| // no blank gap between userMessage expiry and the SSE thinking event. |
| // The real SSE event will overwrite this with the same or updated value. |
| setThinkingState({ stage: 'director' }); |
| }} |
| onDiscussionStart={() => { |
| // User clicks "Join" on ProactiveCard |
| engineRef.current?.confirmDiscussion(); |
| }} |
| onDiscussionSkip={() => { |
| // User clicks "Skip" on ProactiveCard |
| engineRef.current?.skipDiscussion(); |
| }} |
| onStopDiscussion={handleStopDiscussion} |
| onInputActivate={() => { |
| // Level-1 pause: freeze buffer tick + TTS audio while SSE keeps buffering. |
| // User resumes manually via Space / pause button after closing the input. |
| // No isDiscussionPaused guard — always attempt to pause the buffer. |
| // The return value ensures UI state stays in sync with buffer state. |
| if (chatSessionType === 'qa' || chatSessionType === 'discussion') { |
| const paused = chatAreaRef.current?.pauseActiveLiveBuffer(); |
| if (paused) { |
| discussionTTS.pause(); |
| setIsDiscussionPaused(true); |
| } |
| } |
| // Also pause playback engine |
| if (engineRef.current && (engineMode === 'playing' || engineMode === 'live')) { |
| engineRef.current.pause(); |
| } |
| }} |
| onResumeTopic={doResumeTopic} |
| onPlayPause={handlePlayPause} |
| isDiscussionPaused={isDiscussionPaused} |
| onDiscussionPause={() => { |
| const paused = chatAreaRef.current?.pauseActiveLiveBuffer(); |
| if (paused) { |
| discussionTTS.pause(); |
| setIsDiscussionPaused(true); |
| } |
| }} |
| onDiscussionResume={() => { |
| chatAreaRef.current?.resumeActiveLiveBuffer(); |
| discussionTTS.resume(); |
| setIsDiscussionPaused(false); |
| }} |
| totalActions={totalActions} |
| currentActionIndex={0} |
| currentSceneIndex={currentSceneIndex} |
| scenesCount={totalScenesCount} |
| whiteboardOpen={whiteboardOpen} |
| sidebarCollapsed={sidebarCollapsed} |
| chatCollapsed={chatAreaCollapsed} |
| onToggleSidebar={() => setSidebarCollapsed(!sidebarCollapsed)} |
| onToggleChat={() => setChatAreaCollapsed(!chatAreaCollapsed)} |
| onPrevSlide={handlePreviousScene} |
| onNextSlide={handleNextScene} |
| onWhiteboardClose={handleWhiteboardToggle} |
| isPresenting={isPresenting} |
| controlsVisible={controlsVisible} |
| onTogglePresentation={togglePresentation} |
| onPresentationInteractionChange={setIsPresentationInteractionActive} |
| fullscreenContainerRef={stageRef} |
| /> |
| </div> |
| )} |
| </div> |
| |
| {/* Chat Area */} |
| <ChatArea |
| ref={chatAreaRef} |
| width={chatAreaWidth} |
| onWidthChange={setChatAreaWidth} |
| collapsed={chatAreaCollapsed} |
| onCollapseChange={setChatAreaCollapsed} |
| activeBubbleId={activeBubbleId} |
| onActiveBubble={(id) => setActiveBubbleId(id)} |
| currentSceneId={currentSceneId} |
| onLiveSpeech={(text, agentId) => { |
| // Capture epoch at call time — discard if scene has changed since |
| const epoch = sceneEpochRef.current; |
| // Use queueMicrotask to let any pending scene-switch reset settle first |
| queueMicrotask(() => { |
| if (sceneEpochRef.current !== epoch) return; // stale — scene changed |
| setLiveSpeech(text); |
| if (agentId !== undefined) { |
| setSpeakingAgentId(agentId); |
| } |
| if (text !== null || agentId) { |
| setChatIsStreaming(true); |
| setChatSessionType(chatAreaRef.current?.getActiveSessionType?.() ?? null); |
| setIsTopicPending(false); |
| } else if (text === null && agentId === null) { |
| setChatIsStreaming(false); |
| // Don't clear chatSessionType here — it's needed by the stop |
| // button when director cues user (cue_user → done → liveSpeech null). |
| // It gets properly cleared in doSessionCleanup and scene change. |
| } |
| }); |
| }} |
| onSpeechProgress={(ratio) => { |
| const epoch = sceneEpochRef.current; |
| queueMicrotask(() => { |
| if (sceneEpochRef.current !== epoch) return; |
| setSpeechProgress(ratio); |
| }); |
| }} |
| onThinking={(state) => { |
| const epoch = sceneEpochRef.current; |
| queueMicrotask(() => { |
| if (sceneEpochRef.current !== epoch) return; |
| setThinkingState(state); |
| }); |
| }} |
| onCueUser={(_fromAgentId, _prompt) => { |
| setIsCueUser(true); |
| }} |
| onLiveSessionError={handleLiveSessionError} |
| onStopSession={doSessionCleanup} |
| onSegmentSealed={discussionTTS.handleSegmentSealed} |
| shouldHoldAfterReveal={discussionTTS.shouldHold} |
| /> |
| |
| {/* Scene switch confirmation dialog */} |
| <AlertDialog |
| open={!!pendingSceneId} |
| onOpenChange={(open) => { |
| if (!open) cancelSceneSwitch(); |
| }} |
| > |
| <AlertDialogContent |
| container={isPresenting ? stageRef.current : undefined} |
| className="max-w-sm rounded-2xl p-0 overflow-hidden border-0 shadow-[0_25px_60px_-12px_rgba(0,0,0,0.15)] dark:shadow-[0_25px_60px_-12px_rgba(0,0,0,0.5)]" |
| > |
| <VisuallyHidden.Root> |
| <AlertDialogTitle>{t('stage.confirmSwitchTitle')}</AlertDialogTitle> |
| </VisuallyHidden.Root> |
| {/* Top accent bar */} |
| <div className="h-1 bg-gradient-to-r from-amber-400 via-orange-400 to-red-400" /> |
| |
| <div className="px-6 pt-5 pb-2 flex flex-col items-center text-center"> |
| {/* Icon */} |
| <div className="w-12 h-12 rounded-full bg-amber-50 dark:bg-amber-900/20 flex items-center justify-center mb-4 ring-1 ring-amber-200/50 dark:ring-amber-700/30"> |
| <AlertTriangle className="w-6 h-6 text-amber-500 dark:text-amber-400" /> |
| </div> |
| {/* Title */} |
| <h3 className="text-base font-bold text-gray-900 dark:text-gray-100 mb-1.5"> |
| {t('stage.confirmSwitchTitle')} |
| </h3> |
| {/* Description */} |
| <p className="text-sm text-gray-500 dark:text-gray-400 leading-relaxed"> |
| {t('stage.confirmSwitchMessage')} |
| </p> |
| </div> |
| |
| <AlertDialogFooter className="px-6 pb-5 pt-3 flex-row gap-3"> |
| <AlertDialogCancel onClick={cancelSceneSwitch} className="flex-1 rounded-xl"> |
| {t('common.cancel')} |
| </AlertDialogCancel> |
| <AlertDialogAction |
| onClick={confirmSceneSwitch} |
| className="flex-1 rounded-xl bg-gradient-to-r from-amber-500 to-orange-500 hover:from-amber-600 hover:to-orange-600 text-white border-0 shadow-md shadow-amber-200/50 dark:shadow-amber-900/30" |
| > |
| {t('common.confirm')} |
| </AlertDialogAction> |
| </AlertDialogFooter> |
| </AlertDialogContent> |
| </AlertDialog> |
| </div> |
| ); |
| } |
|
|