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:
76
pitch-deck/components/presenter/AvatarPlaceholder.tsx
Normal file
76
pitch-deck/components/presenter/AvatarPlaceholder.tsx
Normal file
@@ -0,0 +1,76 @@
|
||||
'use client'
|
||||
|
||||
import { motion, AnimatePresence } from 'framer-motion'
|
||||
import { PresenterState } from '@/lib/presenter/types'
|
||||
|
||||
interface AvatarPlaceholderProps {
|
||||
state: PresenterState
|
||||
}
|
||||
|
||||
export default function AvatarPlaceholder({ state }: AvatarPlaceholderProps) {
|
||||
const isSpeaking = state === 'presenting' || state === 'answering'
|
||||
const isIdle = state === 'idle'
|
||||
|
||||
if (isIdle) return null
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
<motion.div
|
||||
initial={{ scale: 0, opacity: 0 }}
|
||||
animate={{ scale: 1, opacity: 1 }}
|
||||
exit={{ scale: 0, opacity: 0 }}
|
||||
className="fixed bottom-24 right-6 z-40"
|
||||
>
|
||||
<div className="relative w-16 h-16">
|
||||
{/* Pulse rings when speaking */}
|
||||
{isSpeaking && (
|
||||
<>
|
||||
<motion.div
|
||||
className="absolute inset-0 rounded-full border border-indigo-400/30"
|
||||
animate={{ scale: [1, 1.4, 1], opacity: [0.3, 0, 0.3] }}
|
||||
transition={{ duration: 1.5, repeat: Infinity, ease: 'easeInOut' }}
|
||||
/>
|
||||
<motion.div
|
||||
className="absolute inset-0 rounded-full border border-purple-400/20"
|
||||
animate={{ scale: [1, 1.6, 1], opacity: [0.2, 0, 0.2] }}
|
||||
transition={{ duration: 2, repeat: Infinity, ease: 'easeInOut', delay: 0.2 }}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Avatar circle */}
|
||||
<motion.div
|
||||
className={`w-16 h-16 rounded-full flex items-center justify-center shadow-lg
|
||||
${isSpeaking
|
||||
? 'bg-gradient-to-br from-indigo-500/40 to-purple-500/40 border-2 border-indigo-400/50'
|
||||
: 'bg-gradient-to-br from-indigo-500/20 to-purple-500/20 border border-indigo-400/30'
|
||||
}`}
|
||||
animate={isSpeaking ? { scale: [1, 1.05, 1] } : {}}
|
||||
transition={{ duration: 0.8, repeat: Infinity, ease: 'easeInOut' }}
|
||||
>
|
||||
{/* Bot icon */}
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
||||
strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"
|
||||
className={isSpeaking ? 'text-indigo-300' : 'text-indigo-400/60'}
|
||||
>
|
||||
<rect x="3" y="11" width="18" height="10" rx="2" />
|
||||
<circle cx="12" cy="5" r="2" />
|
||||
<path d="M12 7v4" />
|
||||
<circle cx="8" cy="16" r="1" fill="currentColor" />
|
||||
<circle cx="16" cy="16" r="1" fill="currentColor" />
|
||||
</svg>
|
||||
</motion.div>
|
||||
|
||||
{/* State dot */}
|
||||
<div className={`absolute -top-0.5 -right-0.5 w-3 h-3 rounded-full border-2 border-black/80
|
||||
${state === 'presenting' ? 'bg-green-400' :
|
||||
state === 'paused' ? 'bg-yellow-400' :
|
||||
state === 'answering' ? 'bg-blue-400' :
|
||||
'bg-indigo-400'
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
)
|
||||
}
|
||||
161
pitch-deck/components/presenter/PresenterOverlay.tsx
Normal file
161
pitch-deck/components/presenter/PresenterOverlay.tsx
Normal file
@@ -0,0 +1,161 @@
|
||||
'use client'
|
||||
|
||||
import { motion, AnimatePresence } from 'framer-motion'
|
||||
import { Play, Pause, Square, SkipForward } from 'lucide-react'
|
||||
import { Language } from '@/lib/types'
|
||||
import { PresenterState } from '@/lib/presenter/types'
|
||||
import { SLIDE_ORDER } from '@/lib/hooks/useSlideNavigation'
|
||||
import { t } from '@/lib/i18n'
|
||||
|
||||
interface PresenterOverlayProps {
|
||||
state: PresenterState
|
||||
currentIndex: number
|
||||
totalSlides: number
|
||||
progress: number
|
||||
displayText: string
|
||||
lang: Language
|
||||
onPause: () => void
|
||||
onResume: () => void
|
||||
onStop: () => void
|
||||
onSkip: () => void
|
||||
}
|
||||
|
||||
export default function PresenterOverlay({
|
||||
state,
|
||||
currentIndex,
|
||||
totalSlides,
|
||||
progress,
|
||||
displayText,
|
||||
lang,
|
||||
onPause,
|
||||
onResume,
|
||||
onStop,
|
||||
onSkip,
|
||||
}: PresenterOverlayProps) {
|
||||
const i = t(lang)
|
||||
const slideName = i.slideNames[currentIndex] || SLIDE_ORDER[currentIndex] || ''
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{state !== 'idle' && (
|
||||
<motion.div
|
||||
initial={{ y: 100, opacity: 0 }}
|
||||
animate={{ y: 0, opacity: 1 }}
|
||||
exit={{ y: 100, opacity: 0 }}
|
||||
transition={{ duration: 0.3, ease: 'easeOut' }}
|
||||
className="fixed bottom-0 left-0 right-0 z-40 pointer-events-none"
|
||||
>
|
||||
<div className="mx-auto max-w-4xl px-4 pb-4 pointer-events-auto">
|
||||
<div className="bg-black/80 backdrop-blur-xl border border-white/10 rounded-2xl overflow-hidden shadow-2xl">
|
||||
{/* Progress bar */}
|
||||
<div className="h-1 bg-white/5">
|
||||
<motion.div
|
||||
className="h-full bg-gradient-to-r from-indigo-500 to-purple-500"
|
||||
style={{ width: `${Math.min(progress, 100)}%` }}
|
||||
transition={{ duration: 0.3 }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="px-4 py-3">
|
||||
{/* Top row: slide info + controls */}
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center gap-3">
|
||||
{/* State indicator */}
|
||||
<div className={`w-2 h-2 rounded-full ${
|
||||
state === 'presenting' ? 'bg-green-400 animate-pulse' :
|
||||
state === 'paused' ? 'bg-yellow-400' :
|
||||
state === 'answering' ? 'bg-blue-400 animate-pulse' :
|
||||
state === 'resuming' ? 'bg-indigo-400 animate-pulse' :
|
||||
'bg-white/30'
|
||||
}`} />
|
||||
<span className="text-xs text-white/50 font-medium">
|
||||
{lang === 'de' ? 'Folie' : 'Slide'} {currentIndex + 1}/{totalSlides} — {slideName}
|
||||
</span>
|
||||
<span className="text-xs text-white/30">
|
||||
{Math.round(progress)}%
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Controls */}
|
||||
<div className="flex items-center gap-1.5">
|
||||
<button
|
||||
onClick={onSkip}
|
||||
className="w-7 h-7 rounded-full bg-white/10 flex items-center justify-center
|
||||
hover:bg-white/20 transition-colors"
|
||||
title={lang === 'de' ? 'Naechste Folie' : 'Next slide'}
|
||||
>
|
||||
<SkipForward className="w-3.5 h-3.5 text-white/60" />
|
||||
</button>
|
||||
|
||||
{state === 'presenting' ? (
|
||||
<button
|
||||
onClick={onPause}
|
||||
className="w-7 h-7 rounded-full bg-white/10 flex items-center justify-center
|
||||
hover:bg-white/20 transition-colors"
|
||||
title={lang === 'de' ? 'Pausieren' : 'Pause'}
|
||||
>
|
||||
<Pause className="w-3.5 h-3.5 text-white/60" />
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={onResume}
|
||||
className="w-7 h-7 rounded-full bg-indigo-500/30 flex items-center justify-center
|
||||
hover:bg-indigo-500/50 transition-colors"
|
||||
title={lang === 'de' ? 'Fortsetzen' : 'Resume'}
|
||||
>
|
||||
<Play className="w-3.5 h-3.5 text-indigo-300" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={onStop}
|
||||
className="w-7 h-7 rounded-full bg-red-500/20 flex items-center justify-center
|
||||
hover:bg-red-500/30 transition-colors"
|
||||
title={lang === 'de' ? 'Stoppen' : 'Stop'}
|
||||
>
|
||||
<Square className="w-3 h-3 text-red-400" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Subtitle text */}
|
||||
<AnimatePresence mode="wait">
|
||||
{displayText && (
|
||||
<motion.p
|
||||
key={displayText.slice(0, 30)}
|
||||
initial={{ opacity: 0, y: 5 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -5 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
className="text-sm text-white/70 leading-relaxed"
|
||||
>
|
||||
{displayText}
|
||||
</motion.p>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* State message */}
|
||||
{state === 'paused' && (
|
||||
<p className="text-xs text-yellow-400/60 mt-1">
|
||||
{lang === 'de' ? 'Pausiert — stellen Sie eine Frage oder druecken Sie Play' : 'Paused — ask a question or press play'}
|
||||
</p>
|
||||
)}
|
||||
{state === 'answering' && (
|
||||
<p className="text-xs text-blue-400/60 mt-1">
|
||||
{lang === 'de' ? 'Beantworte Ihre Frage...' : 'Answering your question...'}
|
||||
</p>
|
||||
)}
|
||||
{state === 'resuming' && (
|
||||
<p className="text-xs text-indigo-400/60 mt-1">
|
||||
{lang === 'de' ? 'Setze Praesentation fort...' : 'Resuming presentation...'}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user