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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user