Files
breakpilot-core/pitch-deck/components/ui/ChatInterface.tsx
Benjamin Admin 806d3e0b56
All checks were successful
CI / test-go-consent (push) Successful in 27s
CI / test-bqas (push) Successful in 29s
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / test-python-voice (push) Successful in 31s
feat(pitch-deck): Waiting-Indicator im Investor Agent Chat
Drei animierte Punkte (iMessage-Style) erscheinen sofort nach dem
Absenden und verschwinden wenn der erste Token eintrifft.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-06 09:16:22 +01:00

205 lines
7.1 KiB
TypeScript

'use client'
import { useState, useRef, useEffect } from 'react'
import { motion, AnimatePresence } from 'framer-motion'
import { Send, Bot, User, Sparkles } from 'lucide-react'
import { ChatMessage, Language } from '@/lib/types'
import { t } from '@/lib/i18n'
interface ChatInterfaceProps {
lang: Language
}
export default function ChatInterface({ lang }: ChatInterfaceProps) {
const i = t(lang)
const [messages, setMessages] = useState<ChatMessage[]>([])
const [input, setInput] = useState('')
const [isStreaming, setIsStreaming] = useState(false)
const [isWaiting, setIsWaiting] = useState(false)
const messagesEndRef = useRef<HTMLDivElement>(null)
const inputRef = useRef<HTMLInputElement>(null)
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' })
}, [messages])
async function sendMessage(text?: string) {
const message = text || input.trim()
if (!message || isStreaming) return
setInput('')
setMessages(prev => [...prev, { role: 'user', content: message }])
setIsStreaming(true)
setIsWaiting(true)
try {
const res = await fetch('/api/chat', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
message,
history: messages.slice(-10),
lang,
}),
})
if (!res.ok) throw new Error(`HTTP ${res.status}`)
const reader = res.body!.getReader()
const decoder = new TextDecoder()
let content = ''
while (true) {
const { done, value } = await reader.read()
if (done) break
const chunk = decoder.decode(value, { stream: true })
content += chunk
if (isWaiting || content.length === chunk.length) {
// First chunk arrived — replace waiting indicator with real content
setIsWaiting(false)
setMessages(prev => [...prev, { role: 'assistant', content }])
} else {
setMessages(prev => {
const updated = [...prev]
updated[updated.length - 1] = { role: 'assistant', content }
return updated
})
}
}
} catch (err) {
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)
}
}
return (
<div className="flex flex-col h-full max-h-[500px]">
{/* Messages */}
<div className="flex-1 overflow-y-auto space-y-4 mb-4 pr-2">
{messages.length === 0 && (
<div className="space-y-3">
<div className="flex items-center gap-2 text-white/40 text-sm mb-4">
<Sparkles className="w-4 h-4" />
<span>{lang === 'de' ? 'Vorgeschlagene Fragen:' : 'Suggested questions:'}</span>
</div>
{i.aiqa.suggestions.map((q, idx) => (
<motion.button
key={idx}
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: idx * 0.1 }}
onClick={() => sendMessage(q)}
className="block w-full text-left px-4 py-3 rounded-xl bg-white/[0.05] border border-white/10
hover:bg-white/[0.1] transition-colors text-sm text-white/70 hover:text-white"
>
{q}
</motion.button>
))}
</div>
)}
<AnimatePresence mode="popLayout">
{messages.map((msg, idx) => (
<motion.div
key={idx}
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
className={`flex gap-3 ${msg.role === 'user' ? 'justify-end' : ''}`}
>
{msg.role === 'assistant' && (
<div className="w-8 h-8 rounded-full bg-indigo-500/20 flex items-center justify-center shrink-0">
<Bot className="w-4 h-4 text-indigo-400" />
</div>
)}
<div
className={`
max-w-[80%] rounded-2xl px-4 py-3 text-sm leading-relaxed
${msg.role === 'user'
? 'bg-indigo-500/20 text-white'
: 'bg-white/[0.06] text-white/80'
}
`}
>
<div className="whitespace-pre-wrap">{msg.content}</div>
{isStreaming && idx === messages.length - 1 && msg.role === 'assistant' && (
<span className="inline-block w-2 h-4 bg-indigo-400 animate-pulse ml-1" />
)}
</div>
{msg.role === 'user' && (
<div className="w-8 h-8 rounded-full bg-white/10 flex items-center justify-center shrink-0">
<User className="w-4 h-4 text-white/60" />
</div>
)}
</motion.div>
))}
</AnimatePresence>
{/* Waiting indicator — shown between send and first token */}
<AnimatePresence>
{isWaiting && (
<motion.div
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -4 }}
className="flex gap-3"
>
<div className="w-8 h-8 rounded-full bg-indigo-500/20 flex items-center justify-center shrink-0">
<Bot className="w-4 h-4 text-indigo-400" />
</div>
<div className="bg-white/[0.06] rounded-2xl px-4 py-3 flex items-center gap-1">
{[0, 1, 2].map(i => (
<motion.span
key={i}
className="block w-2 h-2 rounded-full bg-indigo-400/70"
animate={{ opacity: [0.3, 1, 0.3], y: [0, -4, 0] }}
transition={{ duration: 0.8, repeat: Infinity, delay: i * 0.18 }}
/>
))}
</div>
</motion.div>
)}
</AnimatePresence>
<div ref={messagesEndRef} />
</div>
{/* Input */}
<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={i.aiqa.placeholder}
disabled={isStreaming}
className="flex-1 bg-white/[0.06] border border-white/10 rounded-xl px-4 py-3
text-sm 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-4 py-3 bg-indigo-500 hover:bg-indigo-600 disabled:opacity-30
rounded-xl transition-all text-white"
>
<Send className="w-4 h-4" />
</button>
</div>
</div>
)
}