feat(pitch-deck): add AI Presenter mode with LiteLLM migration and FAQ system
All checks were successful
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / test-go-consent (push) Successful in 27s
CI / test-python-voice (push) Successful in 25s
CI / test-bqas (push) Successful in 25s
CI / Deploy (push) Successful in 4s
All checks were successful
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / test-go-consent (push) Successful in 27s
CI / test-python-voice (push) Successful in 25s
CI / test-bqas (push) Successful in 25s
CI / Deploy (push) Successful in 4s
- Migrate chat API from Ollama to LiteLLM (OpenAI-compatible SSE) - Add 15-min presenter storyline with bilingual scripts for all 20 slides - Add FAQ system (30 entries) with keyword matching for instant answers - Add IntroPresenterSlide with avatar placeholder and start button - Add PresenterOverlay (progress bar, subtitle text, play/pause/stop) - Add AvatarPlaceholder with pulse animation during speaking - Add usePresenterMode hook (state machine: idle→presenting→paused→answering→resuming) - Add 'P' keyboard shortcut to toggle presenter mode - Support [GOTO:slide-id] markers in chat responses - Dynamic slide count (was hardcoded 13, now from SLIDE_ORDER) - TTS stub prepared for future Piper integration Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -11,6 +11,7 @@ interface UseKeyboardProps {
|
||||
onFullscreen: () => void
|
||||
onLanguageToggle: () => void
|
||||
onMenuToggle: () => void
|
||||
onPresenterToggle?: () => void
|
||||
onGoToSlide: (index: number) => void
|
||||
enabled?: boolean
|
||||
}
|
||||
@@ -24,6 +25,7 @@ export function useKeyboard({
|
||||
onFullscreen,
|
||||
onLanguageToggle,
|
||||
onMenuToggle,
|
||||
onPresenterToggle,
|
||||
onGoToSlide,
|
||||
enabled = true,
|
||||
}: UseKeyboardProps) {
|
||||
@@ -74,6 +76,11 @@ export function useKeyboard({
|
||||
e.preventDefault()
|
||||
onMenuToggle()
|
||||
break
|
||||
case 'p':
|
||||
case 'P':
|
||||
e.preventDefault()
|
||||
onPresenterToggle?.()
|
||||
break
|
||||
case '1':
|
||||
case '2':
|
||||
case '3':
|
||||
@@ -88,7 +95,7 @@ export function useKeyboard({
|
||||
break
|
||||
}
|
||||
},
|
||||
[enabled, onNext, onPrev, onFirst, onLast, onOverview, onFullscreen, onLanguageToggle, onMenuToggle, onGoToSlide]
|
||||
[enabled, onNext, onPrev, onFirst, onLast, onOverview, onFullscreen, onLanguageToggle, onMenuToggle, onPresenterToggle, onGoToSlide]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
254
pitch-deck/lib/hooks/usePresenterMode.ts
Normal file
254
pitch-deck/lib/hooks/usePresenterMode.ts
Normal file
@@ -0,0 +1,254 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useCallback, useRef, useEffect } from 'react'
|
||||
import { Language, SlideId } from '../types'
|
||||
import { PresenterState } from '../presenter/types'
|
||||
import { PRESENTER_SCRIPT } from '../presenter/presenter-script'
|
||||
import { SLIDE_ORDER } from './useSlideNavigation'
|
||||
|
||||
interface UsePresenterModeConfig {
|
||||
goToSlide: (index: number) => void
|
||||
currentSlide: number
|
||||
totalSlides: number
|
||||
language: Language
|
||||
}
|
||||
|
||||
interface UsePresenterModeReturn {
|
||||
state: PresenterState
|
||||
currentParagraph: number
|
||||
start: () => void
|
||||
stop: () => void
|
||||
pause: () => void
|
||||
resume: () => void
|
||||
skipSlide: () => void
|
||||
toggle: () => void
|
||||
displayText: string
|
||||
progress: number
|
||||
}
|
||||
|
||||
export function usePresenterMode({
|
||||
goToSlide,
|
||||
currentSlide,
|
||||
totalSlides,
|
||||
language,
|
||||
}: UsePresenterModeConfig): UsePresenterModeReturn {
|
||||
const [state, setState] = useState<PresenterState>('idle')
|
||||
const [currentParagraph, setCurrentParagraph] = useState(0)
|
||||
const [displayText, setDisplayText] = useState('')
|
||||
const timerRef = useRef<NodeJS.Timeout | null>(null)
|
||||
const slideIndexRef = useRef(currentSlide)
|
||||
const paragraphIndexRef = useRef(0)
|
||||
const stateRef = useRef<PresenterState>('idle')
|
||||
|
||||
// Keep refs in sync
|
||||
useEffect(() => {
|
||||
slideIndexRef.current = currentSlide
|
||||
}, [currentSlide])
|
||||
|
||||
useEffect(() => {
|
||||
stateRef.current = state
|
||||
}, [state])
|
||||
|
||||
const clearTimer = useCallback(() => {
|
||||
if (timerRef.current) {
|
||||
clearTimeout(timerRef.current)
|
||||
timerRef.current = null
|
||||
}
|
||||
}, [])
|
||||
|
||||
const getScriptForIndex = useCallback((index: number) => {
|
||||
const slideId = SLIDE_ORDER[index]
|
||||
return PRESENTER_SCRIPT.find(s => s.slideId === slideId)
|
||||
}, [])
|
||||
|
||||
const showParagraph = useCallback((slideIdx: number, paraIdx: number) => {
|
||||
const script = getScriptForIndex(slideIdx)
|
||||
if (!script || paraIdx >= script.paragraphs.length) return null
|
||||
|
||||
const para = script.paragraphs[paraIdx]
|
||||
const text = language === 'de' ? para.text_de : para.text_en
|
||||
setDisplayText(text)
|
||||
setCurrentParagraph(paraIdx)
|
||||
paragraphIndexRef.current = paraIdx
|
||||
return para
|
||||
}, [language, getScriptForIndex])
|
||||
|
||||
const advancePresentation = useCallback(() => {
|
||||
if (stateRef.current !== 'presenting') return
|
||||
|
||||
const slideIdx = slideIndexRef.current
|
||||
const script = getScriptForIndex(slideIdx)
|
||||
|
||||
if (!script) {
|
||||
// No script for this slide, advance to next
|
||||
if (slideIdx < totalSlides - 1) {
|
||||
goToSlide(slideIdx + 1)
|
||||
paragraphIndexRef.current = 0
|
||||
// Schedule next after slide transition
|
||||
timerRef.current = setTimeout(() => advancePresentation(), 2000)
|
||||
} else {
|
||||
setState('idle')
|
||||
setDisplayText('')
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const nextPara = paragraphIndexRef.current + 1
|
||||
|
||||
if (nextPara < script.paragraphs.length) {
|
||||
// Show next paragraph
|
||||
const para = showParagraph(slideIdx, nextPara)
|
||||
if (para) {
|
||||
// Calculate display time: ~150ms per word + pause
|
||||
const wordCount = (language === 'de' ? para.text_de : para.text_en).split(/\s+/).length
|
||||
const readingTime = Math.max(wordCount * 150, 2000)
|
||||
timerRef.current = setTimeout(() => advancePresentation(), readingTime + para.pause_after)
|
||||
}
|
||||
} else {
|
||||
// All paragraphs done for this slide
|
||||
// Show transition hint briefly
|
||||
if (script.transition_hint_de || script.transition_hint_en) {
|
||||
const hint = language === 'de'
|
||||
? (script.transition_hint_de || '')
|
||||
: (script.transition_hint_en || '')
|
||||
setDisplayText(hint)
|
||||
}
|
||||
|
||||
// Move to next slide
|
||||
if (slideIdx < totalSlides - 1) {
|
||||
timerRef.current = setTimeout(() => {
|
||||
if (stateRef.current !== 'presenting') return
|
||||
goToSlide(slideIdx + 1)
|
||||
paragraphIndexRef.current = -1 // Will be incremented to 0
|
||||
|
||||
timerRef.current = setTimeout(() => {
|
||||
if (stateRef.current !== 'presenting') return
|
||||
const nextScript = getScriptForIndex(slideIdx + 1)
|
||||
if (nextScript && nextScript.paragraphs.length > 0) {
|
||||
const para = showParagraph(slideIdx + 1, 0)
|
||||
if (para) {
|
||||
const wordCount = (language === 'de' ? para.text_de : para.text_en).split(/\s+/).length
|
||||
const readingTime = Math.max(wordCount * 150, 2000)
|
||||
timerRef.current = setTimeout(() => advancePresentation(), readingTime + para.pause_after)
|
||||
}
|
||||
} else {
|
||||
advancePresentation()
|
||||
}
|
||||
}, 1500)
|
||||
}, 2000)
|
||||
} else {
|
||||
// Last slide — done
|
||||
timerRef.current = setTimeout(() => {
|
||||
setState('idle')
|
||||
setDisplayText('')
|
||||
}, 3000)
|
||||
}
|
||||
}
|
||||
}, [language, totalSlides, goToSlide, getScriptForIndex, showParagraph])
|
||||
|
||||
const start = useCallback(() => {
|
||||
clearTimer()
|
||||
setState('presenting')
|
||||
|
||||
const slideIdx = slideIndexRef.current
|
||||
const script = getScriptForIndex(slideIdx)
|
||||
|
||||
if (script && script.paragraphs.length > 0) {
|
||||
const para = showParagraph(slideIdx, 0)
|
||||
if (para) {
|
||||
const wordCount = (language === 'de' ? para.text_de : para.text_en).split(/\s+/).length
|
||||
const readingTime = Math.max(wordCount * 150, 2000)
|
||||
timerRef.current = setTimeout(() => advancePresentation(), readingTime + para.pause_after)
|
||||
}
|
||||
} else {
|
||||
// No script, advance immediately
|
||||
timerRef.current = setTimeout(() => advancePresentation(), 1000)
|
||||
}
|
||||
}, [clearTimer, language, getScriptForIndex, showParagraph, advancePresentation])
|
||||
|
||||
const stop = useCallback(() => {
|
||||
clearTimer()
|
||||
setState('idle')
|
||||
setDisplayText('')
|
||||
setCurrentParagraph(0)
|
||||
paragraphIndexRef.current = 0
|
||||
}, [clearTimer])
|
||||
|
||||
const pause = useCallback(() => {
|
||||
clearTimer()
|
||||
setState('paused')
|
||||
}, [clearTimer])
|
||||
|
||||
const resume = useCallback(() => {
|
||||
setState('resuming')
|
||||
// Brief pause before continuing
|
||||
timerRef.current = setTimeout(() => {
|
||||
setState('presenting')
|
||||
// Continue from where we left off
|
||||
advancePresentation()
|
||||
}, 2000)
|
||||
}, [advancePresentation])
|
||||
|
||||
const skipSlide = useCallback(() => {
|
||||
clearTimer()
|
||||
const nextIdx = slideIndexRef.current + 1
|
||||
if (nextIdx < totalSlides) {
|
||||
goToSlide(nextIdx)
|
||||
paragraphIndexRef.current = -1
|
||||
|
||||
if (stateRef.current === 'presenting') {
|
||||
timerRef.current = setTimeout(() => {
|
||||
const script = getScriptForIndex(nextIdx)
|
||||
if (script && script.paragraphs.length > 0) {
|
||||
const para = showParagraph(nextIdx, 0)
|
||||
if (para) {
|
||||
const wordCount = (language === 'de' ? para.text_de : para.text_en).split(/\s+/).length
|
||||
const readingTime = Math.max(wordCount * 150, 2000)
|
||||
timerRef.current = setTimeout(() => advancePresentation(), readingTime + para.pause_after)
|
||||
}
|
||||
}
|
||||
}, 1500)
|
||||
}
|
||||
}
|
||||
}, [clearTimer, totalSlides, goToSlide, language, getScriptForIndex, showParagraph, advancePresentation])
|
||||
|
||||
const toggle = useCallback(() => {
|
||||
if (stateRef.current === 'idle') {
|
||||
start()
|
||||
} else {
|
||||
stop()
|
||||
}
|
||||
}, [start, stop])
|
||||
|
||||
// Calculate overall progress
|
||||
const progress = (() => {
|
||||
if (state === 'idle') return 0
|
||||
const totalScripts = PRESENTER_SCRIPT.length
|
||||
const currentScriptIdx = PRESENTER_SCRIPT.findIndex(s => s.slideId === SLIDE_ORDER[currentSlide])
|
||||
if (currentScriptIdx < 0) return (currentSlide / totalSlides) * 100
|
||||
|
||||
const script = PRESENTER_SCRIPT[currentScriptIdx]
|
||||
const slideProgress = script.paragraphs.length > 0
|
||||
? currentParagraph / script.paragraphs.length
|
||||
: 0
|
||||
return ((currentScriptIdx + slideProgress) / totalScripts) * 100
|
||||
})()
|
||||
|
||||
// Cleanup on unmount
|
||||
useEffect(() => {
|
||||
return () => clearTimer()
|
||||
}, [clearTimer])
|
||||
|
||||
return {
|
||||
state,
|
||||
currentParagraph,
|
||||
start,
|
||||
stop,
|
||||
pause,
|
||||
resume,
|
||||
skipSlide,
|
||||
toggle,
|
||||
displayText,
|
||||
progress,
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,8 @@
|
||||
import { useState, useCallback } from 'react'
|
||||
import { SlideId } from '../types'
|
||||
|
||||
const SLIDE_ORDER: SlideId[] = [
|
||||
export const SLIDE_ORDER: SlideId[] = [
|
||||
'intro-presenter',
|
||||
'cover',
|
||||
'problem',
|
||||
'solution',
|
||||
|
||||
Reference in New Issue
Block a user