'use client' 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 { 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 currentSlide: SlideId currentIndex: number visitedSlides: Set onGoToSlide: (index: number) => void presenterState?: PresenterState onPresenterInterrupt?: () => void } interface ParsedMessage { text: string followUps: string[] gotos: { index: number; label: string }[] } function parseAgentResponse(content: string, lang: Language): ParsedMessage { const followUps: string[] = [] const gotos: { index: number; label: string }[] = [] // Split on the follow-up separator — flexible: "---", "- - -", "___", or multiple dashes const parts = content.split(/\n\s*[-_]{3,}\s*\n/) let text = parts[0] // Parse follow-up questions from second part if (parts.length > 1) { const qSection = parts.slice(1).join('\n') // Match [Q], **[Q]**, or numbered/bulleted question patterns const qMatches = qSection.matchAll(/(?:\[Q\]|\*\*\[Q\]\*\*)\s*(.+?)(?:\n|$)/g) for (const m of qMatches) { const q = m[1].trim().replace(/^\*\*|\*\*$/g, '') if (q.length > 5) followUps.push(q) } // Fallback: if no [Q] markers found, look for numbered or bulleted questions in the section if (followUps.length === 0) { const lineMatches = qSection.matchAll(/(?:^|\n)\s*(?:\d+[\.\)]\s*|[-•]\s*)(.+?\?)\s*$/gm) for (const m of lineMatches) { const q = m[1].trim() if (q.length > 5 && followUps.length < 3) followUps.push(q) } } } // Also look for [Q] questions anywhere in the text (sometimes model puts them without ---) if (followUps.length === 0) { const inlineMatches = content.matchAll(/\[Q\]\s*(.+?)(?:\n|$)/g) const inlineQs: string[] = [] for (const m of inlineMatches) { inlineQs.push(m[1].trim()) } if (inlineQs.length >= 2) { followUps.push(...inlineQs) // Remove [Q] lines from main text text = text.replace(/\n?\s*\[Q\]\s*.+?(?:\n|$)/g, '\n').trim() } } // 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 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:[\w-]+\]/g, '') // Clean up trailing reminder instruction that might leak through text = text.replace(/\n*\(Erinnerung:.*?\)\s*$/s, '').trim() return { text: text.trim(), followUps, gotos } } 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([]) const [input, setInput] = useState('') const [isStreaming, setIsStreaming] = useState(false) const [isWaiting, setIsWaiting] = useState(false) const [parsedResponses, setParsedResponses] = useState>(new Map()) const messagesEndRef = useRef(null) const inputRef = useRef(null) const abortRef = useRef(null) 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 i = messages.length - 1; i >= 0; i--) { if (messages[i].role === 'assistant') return i } 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)) } }, [isStreaming, lastAssistantIndex, messages, parsedResponses, lang]) async function sendMessage(text?: string) { 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 = { 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(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 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) 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 ( <>
{displayText}
{isStreaming && idx === messages.length - 1 && msg.role === 'assistant' && ( )} {/* GOTO Buttons */} {parsed && parsed.gotos.length > 0 && (
{parsed.gotos.map((g, gi) => ( ))}
)} {/* Follow-Up Suggestions */} {parsed && parsed.followUps.length > 0 && !isStreaming && (
{parsed.followUps.map((q, qi) => ( ))}
)} ) } return ( <> {/* FAB Button — sits to the left of NavigationFAB */} {!isOpen && ( 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 oeffnen' : 'Open Investor Agent'} > {/* Presenter active indicator */} {presenterState !== 'idle' && ( )} )} {/* Chat Panel */} {isOpen && ( {/* Header */}
Investor Agent {isStreaming ? (lang === 'de' ? 'antwortet...' : 'responding...') : presenterState !== 'idle' ? (lang === 'de' ? 'Presenter aktiv' : 'Presenter active') : (lang === 'de' ? 'online' : 'online') }
{/* Messages */}
{messages.length === 0 && (
{lang === 'de' ? 'Fragen Sie den Investor Agent:' : 'Ask the Investor Agent:'}
{suggestions.map((q, idx) => ( 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} ))}
)} {/* Waiting indicator */} {isWaiting && (
{[0, 1, 2].map(i => ( ))}
)}
{messages.map((msg, idx) => (
{msg.role === 'assistant' && (
)}
{msg.role === 'assistant' ? renderMessageContent(msg, idx) : (
{msg.content}
)}
{msg.role === 'user' && (
)}
))}
{/* Input */}
{isStreaming && ( )}
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" />
)} ) }