feat(chat): TTS for chat responses + fix team FAQ with real founder names

- Chat answers are now read aloud via Edge TTS (auto, with mute toggle)
- FAQ team answer: vague text → Benjamin Boenisch (CEO) + Sharang (CTO)
- System prompt: explicit instruction to always cite team names from DB
- Speaker icon in chat header shows speaking state, click to mute/unmute
- Audio stops on new message, chat close, or mute

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Benjamin Admin
2026-03-20 17:18:30 +01:00
parent f126b40574
commit 959986356b
3 changed files with 97 additions and 4 deletions

View File

@@ -65,6 +65,9 @@ Stattdessen: "Proprietäre KI-Engine", "Self-Hosted Appliance auf Apple-Hardware
## Erlaubt: Geschäftsmodell, Preise, Marktdaten, Features, Team, Finanzen, Use of Funds, Hardware-Specs (öffentlich), LLM-Größen (32b/40b/1000b).
## Team-Antworten (WICHTIG)
Wenn nach dem Team gefragt wird: IMMER die Namen, Rollen und Expertise der Gründer aus den bereitgestellten Daten nennen. NIEMALS vage Antworten wie "unser Team vereint Expertise" ohne Namen. Zitiere die konkreten Personen aus den Unternehmensdaten.
## Slide-Awareness (IMMER beachten)
Du erhältst den aktuellen Slide-Kontext. Nutze ihn für kontextuelle Antworten.
Wenn der Investor etwas fragt, was in einer späteren Slide detailliert wird und er diese noch nicht gesehen hat:

View File

@@ -2,7 +2,7 @@
import { useState, useRef, useEffect, useMemo } from 'react'
import { motion, AnimatePresence } from 'framer-motion'
import { X, Send, Bot, User, Sparkles, Maximize2, Minimize2, ArrowRight } from 'lucide-react'
import { X, Send, Bot, User, Sparkles, Maximize2, Minimize2, ArrowRight, Volume2, VolumeX } from 'lucide-react'
import { ChatMessage, Language, SlideId } from '@/lib/types'
import { t } from '@/lib/i18n'
import { SLIDE_ORDER } from '@/lib/hooks/useSlideNavigation'
@@ -116,9 +116,72 @@ export default function ChatFAB({
const [isStreaming, setIsStreaming] = useState(false)
const [isWaiting, setIsWaiting] = useState(false)
const [parsedResponses, setParsedResponses] = useState<Map<number, ParsedMessage>>(new Map())
const [chatTtsEnabled, setChatTtsEnabled] = useState(true)
const [isChatSpeaking, setIsChatSpeaking] = useState(false)
const messagesEndRef = useRef<HTMLDivElement>(null)
const inputRef = useRef<HTMLInputElement>(null)
const abortRef = useRef<AbortController | null>(null)
const chatAudioRef = useRef<HTMLAudioElement | null>(null)
const ttsAbortRef = useRef<AbortController | null>(null)
const cancelChatAudio = () => {
if (chatAudioRef.current) {
chatAudioRef.current.pause()
chatAudioRef.current = null
}
if (ttsAbortRef.current) {
ttsAbortRef.current.abort()
ttsAbortRef.current = null
}
setIsChatSpeaking(false)
}
const speakResponse = async (text: string) => {
if (!chatTtsEnabled) return
cancelChatAudio()
// Clean text for TTS: remove markdown formatting, keep plain speech
const cleanText = text
.replace(/\*\*(.+?)\*\*/g, '$1')
.replace(/\[GOTO:[\w-]+\]/g, '')
.replace(/\[Q\]\s*/g, '')
.replace(/---/g, '')
.trim()
if (!cleanText) return
const controller = new AbortController()
ttsAbortRef.current = controller
try {
const res = await fetch('/api/presenter/tts', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text: cleanText, language: lang }),
signal: controller.signal,
})
if (!res.ok || controller.signal.aborted) return
const blob = await res.blob()
const url = URL.createObjectURL(blob)
const audio = new Audio(url)
chatAudioRef.current = audio
setIsChatSpeaking(true)
audio.onended = () => {
setIsChatSpeaking(false)
chatAudioRef.current = null
}
audio.onerror = () => {
setIsChatSpeaking(false)
chatAudioRef.current = null
}
await audio.play()
} catch {
setIsChatSpeaking(false)
}
}
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' })
@@ -143,6 +206,9 @@ export default function ChatFAB({
const msg = messages[lastAssistantIndex]
const parsed = parseAgentResponse(msg.content, lang)
setParsedResponses(prev => new Map(prev).set(lastAssistantIndex, parsed))
// Speak the response via TTS
speakResponse(parsed.text)
}
}, [isStreaming, lastAssistantIndex, messages, parsedResponses, lang])
@@ -155,6 +221,7 @@ export default function ChatFAB({
onPresenterInterrupt()
}
cancelChatAudio()
setInput('')
setMessages(prev => [...prev, { role: 'user', content: message }])
setIsStreaming(true)
@@ -374,6 +441,26 @@ export default function ChatFAB({
</div>
</div>
<div className="flex items-center gap-1">
<button
onClick={() => {
setChatTtsEnabled(prev => {
if (prev) cancelChatAudio()
return !prev
})
}}
className={`w-7 h-7 rounded-full flex items-center justify-center transition-colors ${
isChatSpeaking ? 'bg-indigo-500/30' : 'bg-white/10 hover:bg-white/20'
}`}
title={chatTtsEnabled
? (lang === 'de' ? 'Sprachausgabe aus' : 'Mute voice')
: (lang === 'de' ? 'Sprachausgabe ein' : 'Unmute voice')
}
>
{chatTtsEnabled
? <Volume2 className={`w-3.5 h-3.5 ${isChatSpeaking ? 'text-indigo-400' : 'text-white/60'}`} />
: <VolumeX className="w-3.5 h-3.5 text-white/40" />
}
</button>
<button
onClick={() => setIsExpanded(prev => !prev)}
className="w-7 h-7 rounded-full bg-white/10 flex items-center justify-center hover:bg-white/20 transition-colors"
@@ -381,7 +468,10 @@ export default function ChatFAB({
{isExpanded ? <Minimize2 className="w-3.5 h-3.5 text-white/60" /> : <Maximize2 className="w-3.5 h-3.5 text-white/60" />}
</button>
<button
onClick={() => setIsOpen(false)}
onClick={() => {
cancelChatAudio()
setIsOpen(false)
}}
className="w-7 h-7 rounded-full bg-white/10 flex items-center justify-center hover:bg-white/20 transition-colors"
>
<X className="w-4 h-4 text-white/60" />

View File

@@ -175,8 +175,8 @@ export const PRESENTER_FAQ: FAQEntry[] = [
keywords: ['team', 'gründer', 'founders', 'wer', 'who', 'erfahrung', 'experience', 'hintergrund', 'background'],
question_de: 'Wer sind die Gründer?',
question_en: 'Who are the founders?',
answer_de: 'Unser Gründerteam vereint tiefe Domain-Expertise: Compliance-Wissen aus der Praxis, Software-Architektur für skalierbare Systeme, und KI-Erfahrung mit Large Language Models. Details finden Sie auf der Team-Slide.',
answer_en: 'Our founding team combines deep domain expertise: Compliance knowledge from practice, software architecture for scalable systems, and AI experience with large language models. Details on the team slide.',
answer_de: 'Das Gründerteam besteht aus Benjamin Boenisch (CEO & Co-Founder, 50%) und Sharang (CTO & Co-Founder, 50%). Benjamin bringt Expertise in EdTech, DSGVO und Produktstrategie mit. Sharang ist Experte für AI/ML, Apple Silicon und Full-Stack-Entwicklung. Zusammen decken sie Compliance-Domain-Wissen und technische Umsetzung ab.',
answer_en: 'The founding team consists of Benjamin Boenisch (CEO & Co-Founder, 50%) and Sharang (CTO & Co-Founder, 50%). Benjamin brings expertise in EdTech, GDPR and product strategy. Sharang is an expert in AI/ML, Apple Silicon and full-stack development. Together they cover compliance domain knowledge and technical execution.',
goto_slide: 'team',
priority: 7,
},