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).
|
## 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)
|
## Slide-Awareness (IMMER beachten)
|
||||||
Du erhältst den aktuellen Slide-Kontext. Nutze ihn für kontextuelle Antworten.
|
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:
|
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 { useState, useRef, useEffect, useMemo } from 'react'
|
||||||
import { motion, AnimatePresence } from 'framer-motion'
|
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 { ChatMessage, Language, SlideId } from '@/lib/types'
|
||||||
import { t } from '@/lib/i18n'
|
import { t } from '@/lib/i18n'
|
||||||
import { SLIDE_ORDER } from '@/lib/hooks/useSlideNavigation'
|
import { SLIDE_ORDER } from '@/lib/hooks/useSlideNavigation'
|
||||||
@@ -116,9 +116,72 @@ export default function ChatFAB({
|
|||||||
const [isStreaming, setIsStreaming] = useState(false)
|
const [isStreaming, setIsStreaming] = useState(false)
|
||||||
const [isWaiting, setIsWaiting] = useState(false)
|
const [isWaiting, setIsWaiting] = useState(false)
|
||||||
const [parsedResponses, setParsedResponses] = useState<Map<number, ParsedMessage>>(new Map())
|
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 messagesEndRef = useRef<HTMLDivElement>(null)
|
||||||
const inputRef = useRef<HTMLInputElement>(null)
|
const inputRef = useRef<HTMLInputElement>(null)
|
||||||
const abortRef = useRef<AbortController | null>(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(() => {
|
useEffect(() => {
|
||||||
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' })
|
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' })
|
||||||
@@ -143,6 +206,9 @@ export default function ChatFAB({
|
|||||||
const msg = messages[lastAssistantIndex]
|
const msg = messages[lastAssistantIndex]
|
||||||
const parsed = parseAgentResponse(msg.content, lang)
|
const parsed = parseAgentResponse(msg.content, lang)
|
||||||
setParsedResponses(prev => new Map(prev).set(lastAssistantIndex, parsed))
|
setParsedResponses(prev => new Map(prev).set(lastAssistantIndex, parsed))
|
||||||
|
|
||||||
|
// Speak the response via TTS
|
||||||
|
speakResponse(parsed.text)
|
||||||
}
|
}
|
||||||
}, [isStreaming, lastAssistantIndex, messages, parsedResponses, lang])
|
}, [isStreaming, lastAssistantIndex, messages, parsedResponses, lang])
|
||||||
|
|
||||||
@@ -155,6 +221,7 @@ export default function ChatFAB({
|
|||||||
onPresenterInterrupt()
|
onPresenterInterrupt()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
cancelChatAudio()
|
||||||
setInput('')
|
setInput('')
|
||||||
setMessages(prev => [...prev, { role: 'user', content: message }])
|
setMessages(prev => [...prev, { role: 'user', content: message }])
|
||||||
setIsStreaming(true)
|
setIsStreaming(true)
|
||||||
@@ -374,6 +441,26 @@ export default function ChatFAB({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1">
|
<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
|
<button
|
||||||
onClick={() => setIsExpanded(prev => !prev)}
|
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"
|
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" />}
|
{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>
|
||||||
<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"
|
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" />
|
<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'],
|
keywords: ['team', 'gründer', 'founders', 'wer', 'who', 'erfahrung', 'experience', 'hintergrund', 'background'],
|
||||||
question_de: 'Wer sind die Gründer?',
|
question_de: 'Wer sind die Gründer?',
|
||||||
question_en: 'Who are the founders?',
|
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_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: '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_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',
|
goto_slide: 'team',
|
||||||
priority: 7,
|
priority: 7,
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user