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