Files
breakpilot-core/pitch-deck/components/ChatFAB.tsx
Benjamin Admin 92c86ec6ba [split-required] [guardrail-change] Enforce 500 LOC budget across all services
Install LOC guardrails (check-loc.sh, architecture.md, pre-commit hook)
and split all 44 files exceeding 500 LOC into domain-focused modules:

- consent-service (Go): models, handlers, services, database splits
- backend-core (Python): security_api, rbac_api, pdf_service, auth splits
- admin-core (TypeScript): 5 page.tsx + sidebar extractions
- pitch-deck (TypeScript): 6 slides, 3 UI components, engine.ts splits
- voice-service (Python): enhanced_task_orchestrator split

Result: 0 violations, 36 exempted (pipeline, tests, pure-data files).
Go build verified clean. No behavior changes — pure structural splits.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-27 00:09:30 +02:00

482 lines
18 KiB
TypeScript

'use client'
import { useState, useRef, useEffect, useMemo } from 'react'
import { motion, AnimatePresence } from 'framer-motion'
import { X, Send, Bot, User, Sparkles, Maximize2, Minimize2, ArrowRight, Volume2, VolumeX } from 'lucide-react'
import { ChatMessage } from '@/lib/types'
import { t } from '@/lib/i18n'
import { SLIDE_ORDER } from '@/lib/hooks/useSlideNavigation'
import { matchFAQMultiple, buildFAQContext } from '@/lib/presenter/faq-matcher'
import {
ChatFABProps,
ParsedMessage,
parseAgentResponse,
cleanTextForTts,
detectTtsLanguage,
} from './ChatFAB.helpers'
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)
const [messages, setMessages] = useState<ChatMessage[]>([])
const [input, setInput] = useState('')
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()
const cleanText = cleanTextForTts(text)
if (!cleanText) return
const controller = new AbortController()
ttsAbortRef.current = controller
try {
const textLang = detectTtsLanguage(cleanText, lang)
const res = await fetch('/api/presenter/tts', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text: cleanText, language: textLang }),
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' })
}, [messages, parsedResponses])
useEffect(() => {
if (isOpen && inputRef.current) {
setTimeout(() => inputRef.current?.focus(), 200)
}
}, [isOpen])
// Parse the latest assistant message when streaming ends
const lastAssistantIndex = useMemo(() => {
for (let idx = messages.length - 1; idx >= 0; idx--) {
if (messages[idx].role === 'assistant') return idx
}
return -1
}, [messages])
useEffect(() => {
if (!isStreaming && lastAssistantIndex >= 0 && !parsedResponses.has(lastAssistantIndex)) {
const msg = messages[lastAssistantIndex]
const parsed = parseAgentResponse(msg.content, lang)
setParsedResponses(prev => new Map(prev).set(lastAssistantIndex, parsed))
speakResponse(parsed.text)
}
}, [isStreaming, lastAssistantIndex, messages, parsedResponses, lang])
async function sendMessage(text?: string) {
const message = text || input.trim()
if (!message || isStreaming) return
if (presenterState === 'presenting' && onPresenterInterrupt) {
onPresenterInterrupt()
}
cancelChatAudio()
setInput('')
setMessages(prev => [...prev, { role: 'user', content: message }])
setIsStreaming(true)
setIsWaiting(true)
const faqMatches = matchFAQMultiple(message, lang, 3)
const faqContext = buildFAQContext(faqMatches, 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 (faqContext) requestBody.faqContext = faqContext
const res = await fetch('/api/chat', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(requestBody),
signal: abortRef.current.signal,
})
if (!res.ok) throw new Error(`HTTP ${res.status}`)
const reader = res.body!.getReader()
const decoder = new TextDecoder()
let content = ''
let firstChunk = true
while (true) {
const { done, value } = await reader.read()
if (done) break
content += decoder.decode(value, { stream: true })
if (firstChunk) {
firstChunk = false
setIsWaiting(false)
setMessages(prev => [...prev, { role: 'assistant', content }])
} else {
const currentText = content
setMessages(prev => {
const updated = [...prev]
updated[updated.length - 1] = { role: 'assistant', content: currentText }
return updated
})
}
}
// If FAQ matched and has a goto_slide, add a GOTO marker to the response
const topMatch = faqMatches[0]
if (topMatch?.goto_slide) {
const gotoIdx = SLIDE_ORDER.indexOf(topMatch.goto_slide)
if (gotoIdx >= 0) {
content += `\n\n[GOTO:${topMatch.goto_slide}]`
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)
setIsWaiting(false)
setMessages(prev => [
...prev,
{ role: 'assistant', content: lang === 'de'
? 'Verbindung fehlgeschlagen. Bitte versuchen Sie es erneut.'
: 'Connection failed. Please try again.'
},
])
} finally {
setIsStreaming(false)
setIsWaiting(false)
abortRef.current = null
}
}
function stopGeneration() {
if (abortRef.current) { abortRef.current.abort(); setIsStreaming(false) }
}
const suggestions = i.aiqa.suggestions.slice(0, 3)
function renderMessageContent(msg: ChatMessage, idx: number) {
const parsed = parsedResponses.get(idx)
const displayText = parsed ? parsed.text : msg.content
return (
<>
<div className="whitespace-pre-wrap">{displayText}</div>
{isStreaming && idx === messages.length - 1 && msg.role === 'assistant' && (
<span className="inline-block w-1.5 h-3.5 bg-indigo-400 animate-pulse ml-0.5" />
)}
{parsed && parsed.gotos.length > 0 && (
<div className="mt-2 space-y-1">
{parsed.gotos.map((g, gi) => (
<button
key={gi}
onClick={() => onGoToSlide(g.index)}
className="flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg
bg-indigo-500/20 border border-indigo-500/30
hover:bg-indigo-500/30 transition-colors
text-xs text-indigo-300"
>
<ArrowRight className="w-3 h-3" />
{g.label}
</button>
))}
</div>
)}
{parsed && parsed.followUps.length > 0 && !isStreaming && (
<div className="mt-3 space-y-1.5 border-t border-white/10 pt-2">
{parsed.followUps.map((q, qi) => (
<button
key={qi}
onClick={() => sendMessage(q)}
className="block w-full text-left px-2.5 py-2 rounded-lg
bg-white/[0.05] border border-white/10
hover:bg-white/[0.1] transition-colors
text-xs text-white/60 hover:text-white/90"
>
{q}
</button>
))}
</div>
)}
</>
)
}
return (
<>
{/* FAB Button */}
<AnimatePresence>
{!isOpen && (
<motion.button
initial={{ scale: 0 }}
animate={{ scale: 1 }}
exit={{ scale: 0 }}
whileHover={{ scale: 1.1 }}
whileTap={{ scale: 0.95 }}
onClick={() => setIsOpen(true)}
className="fixed bottom-6 right-[5.5rem] z-50 w-14 h-14 rounded-full
bg-indigo-600 hover:bg-indigo-500 text-white
flex items-center justify-center shadow-lg shadow-indigo-600/30
transition-colors"
aria-label={lang === 'de' ? 'Investor Agent öffnen' : 'Open Investor Agent'}
>
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M21 15a2 2 0 01-2 2H7l-4 4V5a2 2 0 012-2h14a2 2 0 012 2z" />
<circle cx="9" cy="10" r="1" fill="currentColor" />
<circle cx="12" cy="10" r="1" fill="currentColor" />
<circle cx="15" cy="10" r="1" fill="currentColor" />
</svg>
{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>
{/* Chat Panel */}
<AnimatePresence>
{isOpen && (
<motion.div
initial={{ opacity: 0, scale: 0.9, y: 20 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.9, y: 20 }}
transition={{ duration: 0.2 }}
className={`fixed bottom-6 right-6 z-50
${isExpanded ? 'w-[700px] h-[80vh]' : 'w-[400px] h-[520px]'}
rounded-2xl overflow-hidden
bg-black/90 backdrop-blur-xl border border-white/10
shadow-2xl shadow-black/50 flex flex-col
transition-all duration-200`}
>
{/* Header */}
<div className="flex items-center justify-between px-4 py-3 border-b border-white/10 shrink-0">
<div className="flex items-center gap-2">
<div className="w-7 h-7 rounded-full bg-indigo-500/20 flex items-center justify-center">
<Bot className="w-4 h-4 text-indigo-400" />
</div>
<div>
<span className="text-sm font-semibold text-white">Investor Agent</span>
<span className="text-xs text-white/30 ml-2">
{isStreaming
? (lang === 'de' ? 'antwortet...' : 'responding...')
: presenterState !== 'idle'
? (lang === 'de' ? 'Presenter aktiv' : 'Presenter active')
: (lang === 'de' ? 'online' : 'online')
}
</span>
</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"
>
{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={() => { 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" />
</button>
</div>
</div>
{/* Messages */}
<div className="flex-1 overflow-y-auto px-4 py-3 space-y-3">
{messages.length === 0 && (
<div className="space-y-2">
<div className="flex items-center gap-2 text-white/40 text-xs mb-3">
<Sparkles className="w-3.5 h-3.5" />
<span>{lang === 'de' ? 'Fragen Sie den Investor Agent:' : 'Ask the Investor Agent:'}</span>
</div>
{suggestions.map((q, idx) => (
<motion.button
key={idx}
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.1 + idx * 0.08 }}
onClick={() => sendMessage(q)}
className="block w-full text-left px-3 py-2.5 rounded-xl
bg-white/[0.05] border border-white/10
hover:bg-white/[0.1] transition-colors
text-xs text-white/70 hover:text-white"
>
{q}
</motion.button>
))}
</div>
)}
{/* Waiting indicator */}
<AnimatePresence>
{isWaiting && (
<motion.div
initial={{ opacity: 0, y: 6 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0 }}
className="flex gap-2.5"
>
<div className="w-7 h-7 rounded-full bg-indigo-500/20 flex items-center justify-center shrink-0 mt-0.5">
<Bot className="w-3.5 h-3.5 text-indigo-400" />
</div>
<div className="bg-white/[0.06] rounded-2xl px-3.5 py-3 flex items-center gap-1">
{[0, 1, 2].map(dotIdx => (
<motion.span
key={dotIdx}
className="block w-1.5 h-1.5 rounded-full bg-indigo-400/70"
animate={{ opacity: [0.3, 1, 0.3], y: [0, -3, 0] }}
transition={{ duration: 0.7, repeat: Infinity, delay: dotIdx * 0.15 }}
/>
))}
</div>
</motion.div>
)}
</AnimatePresence>
{messages.map((msg, idx) => (
<div key={idx} className={`flex gap-2.5 ${msg.role === 'user' ? 'justify-end' : ''}`}>
{msg.role === 'assistant' && (
<div className="w-7 h-7 rounded-full bg-indigo-500/20 flex items-center justify-center shrink-0 mt-0.5">
<Bot className="w-3.5 h-3.5 text-indigo-400" />
</div>
)}
<div className={`max-w-[85%] rounded-2xl px-3.5 py-2.5 text-xs leading-relaxed ${
msg.role === 'user' ? 'bg-indigo-500/20 text-white' : 'bg-white/[0.06] text-white/80'
}`}>
{msg.role === 'assistant' ? renderMessageContent(msg, idx) : (
<div className="whitespace-pre-wrap">{msg.content}</div>
)}
</div>
{msg.role === 'user' && (
<div className="w-7 h-7 rounded-full bg-white/10 flex items-center justify-center shrink-0 mt-0.5">
<User className="w-3.5 h-3.5 text-white/60" />
</div>
)}
</div>
))}
<div ref={messagesEndRef} />
</div>
{/* Input */}
<div className="border-t border-white/10 px-4 py-3 shrink-0">
{isStreaming && (
<button
onClick={stopGeneration}
className="w-full mb-2 px-3 py-1.5 rounded-lg bg-white/[0.06] hover:bg-white/[0.1]
text-xs text-white/50 transition-colors"
>
{lang === 'de' ? 'Antwort stoppen' : 'Stop response'}
</button>
)}
<div className="flex gap-2">
<input
ref={inputRef}
type="text"
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && sendMessage()}
placeholder={lang === 'de' ? 'Frage stellen...' : 'Ask a question...'}
disabled={isStreaming}
className="flex-1 bg-white/[0.06] border border-white/10 rounded-xl px-3.5 py-2.5
text-xs text-white placeholder-white/30 outline-none
focus:border-indigo-500/50 focus:ring-1 focus:ring-indigo-500/20
disabled:opacity-50 transition-all"
/>
<button
onClick={() => sendMessage()}
disabled={isStreaming || !input.trim()}
className="px-3.5 py-2.5 bg-indigo-500 hover:bg-indigo-600 disabled:opacity-30
rounded-xl transition-all text-white"
>
<Send className="w-3.5 h-3.5" />
</button>
</div>
</div>
</motion.div>
)}
</AnimatePresence>
</>
)
}