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

- 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:
Benjamin Admin
2026-03-20 11:45:55 +01:00
parent df0a9d6cf0
commit 3a2567b44d
20 changed files with 2434 additions and 164 deletions

View File

@@ -5,6 +5,9 @@ import { motion, AnimatePresence } from 'framer-motion'
import { X, Send, Bot, User, Sparkles, Maximize2, Minimize2, ArrowRight } from 'lucide-react'
import { ChatMessage, Language, SlideId } from '@/lib/types'
import { t } from '@/lib/i18n'
import { SLIDE_ORDER } from '@/lib/hooks/useSlideNavigation'
import { PresenterState } from '@/lib/presenter/types'
import { matchFAQ, getFAQAnswer } from '@/lib/presenter/faq-matcher'
interface ChatFABProps {
lang: Language
@@ -12,6 +15,8 @@ interface ChatFABProps {
currentIndex: number
visitedSlides: Set<number>
onGoToSlide: (index: number) => void
presenterState?: PresenterState
onPresenterInterrupt?: () => void
}
interface ParsedMessage {
@@ -62,18 +67,31 @@ function parseAgentResponse(content: string, lang: Language): ParsedMessage {
}
}
// Parse GOTO markers from the text
const gotoRegex = /\[GOTO:(\d+)\]/g
// Parse GOTO markers — support both [GOTO:N] (numeric) and [GOTO:slide-id] (string)
const gotoRegex = /\[GOTO:([\w-]+)\]/g
let gotoMatch
while ((gotoMatch = gotoRegex.exec(text)) !== null) {
const slideIndex = parseInt(gotoMatch[1])
gotos.push({
index: slideIndex,
label: lang === 'de' ? `Zu Slide ${slideIndex + 1} springen` : `Jump to slide ${slideIndex + 1}`,
})
const target = gotoMatch[1]
let slideIndex: number
// Try numeric index first
const numericIndex = parseInt(target)
if (!isNaN(numericIndex) && numericIndex >= 0 && numericIndex < SLIDE_ORDER.length) {
slideIndex = numericIndex
} else {
// Try slide ID lookup
slideIndex = SLIDE_ORDER.indexOf(target as SlideId)
}
if (slideIndex >= 0) {
gotos.push({
index: slideIndex,
label: lang === 'de' ? `Zu Slide ${slideIndex + 1} springen` : `Jump to slide ${slideIndex + 1}`,
})
}
}
// Remove GOTO markers from visible text
text = text.replace(/\s*\[GOTO:\d+\]/g, '')
text = text.replace(/\s*\[GOTO:[\w-]+\]/g, '')
// Clean up trailing reminder instruction that might leak through
text = text.replace(/\n*\(Erinnerung:.*?\)\s*$/s, '').trim()
@@ -81,7 +99,15 @@ function parseAgentResponse(content: string, lang: Language): ParsedMessage {
return { text: text.trim(), followUps, gotos }
}
export default function ChatFAB({ lang, currentSlide, currentIndex, visitedSlides, onGoToSlide }: ChatFABProps) {
export default function ChatFAB({
lang,
currentSlide,
currentIndex,
visitedSlides,
onGoToSlide,
presenterState = 'idle',
onPresenterInterrupt,
}: ChatFABProps) {
const i = t(lang)
const [isOpen, setIsOpen] = useState(false)
const [isExpanded, setIsExpanded] = useState(false)
@@ -124,28 +150,43 @@ export default function ChatFAB({ lang, currentSlide, currentIndex, visitedSlide
const message = text || input.trim()
if (!message || isStreaming) return
// Interrupt presenter if it's running
if (presenterState === 'presenting' && onPresenterInterrupt) {
onPresenterInterrupt()
}
setInput('')
setMessages(prev => [...prev, { role: 'user', content: message }])
setIsStreaming(true)
setIsWaiting(true)
// Check FAQ first for instant response
const faqMatch = matchFAQ(message, lang)
abortRef.current = new AbortController()
try {
const requestBody: Record<string, unknown> = {
message,
history: messages.slice(-10),
lang,
slideContext: {
currentSlide,
currentIndex,
visitedSlides: Array.from(visitedSlides),
totalSlides: SLIDE_ORDER.length,
},
}
// If FAQ matched, send the cached answer for fast streaming (no LLM call)
if (faqMatch) {
requestBody.faqAnswer = getFAQAnswer(faqMatch, lang)
}
const res = await fetch('/api/chat', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
message,
history: messages.slice(-10),
lang,
slideContext: {
currentSlide,
currentIndex,
visitedSlides: Array.from(visitedSlides),
totalSlides: 13,
},
}),
body: JSON.stringify(requestBody),
signal: abortRef.current.signal,
})
@@ -175,6 +216,20 @@ export default function ChatFAB({ lang, currentSlide, currentIndex, visitedSlide
})
}
}
// If FAQ matched and has a goto_slide, add a GOTO marker to the response
if (faqMatch?.goto_slide) {
const gotoIdx = SLIDE_ORDER.indexOf(faqMatch.goto_slide)
if (gotoIdx >= 0) {
const suffix = `\n\n[GOTO:${faqMatch.goto_slide}]`
content += suffix
setMessages(prev => {
const updated = [...prev]
updated[updated.length - 1] = { role: 'assistant', content }
return updated
})
}
}
} catch (err: unknown) {
if (err instanceof Error && err.name === 'AbortError') return
console.error('Chat error:', err)
@@ -277,6 +332,10 @@ export default function ChatFAB({ lang, currentSlide, currentIndex, visitedSlide
<circle cx="12" cy="10" r="1" fill="currentColor" />
<circle cx="15" cy="10" r="1" fill="currentColor" />
</svg>
{/* Presenter active indicator */}
{presenterState !== 'idle' && (
<span className="absolute -top-1 -right-1 w-3 h-3 rounded-full bg-green-400 border-2 border-black animate-pulse" />
)}
</motion.button>
)}
</AnimatePresence>
@@ -307,7 +366,9 @@ export default function ChatFAB({ lang, currentSlide, currentIndex, visitedSlide
<span className="text-xs text-white/30 ml-2">
{isStreaming
? (lang === 'de' ? 'antwortet...' : 'responding...')
: (lang === 'de' ? 'online' : 'online')
: presenterState !== 'idle'
? (lang === 'de' ? 'Presenter aktiv' : 'Presenter active')
: (lang === 'de' ? 'online' : 'online')
}
</span>
</div>