All checks were successful
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / test-go-consent (push) Successful in 27s
CI / test-python-voice (push) Successful in 30s
CI / test-bqas (push) Successful in 25s
isWaiting im async Closure war immer true — lokale Variable firstChunk ersetzt den State-Check zuverlaessig. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
205 lines
7.0 KiB
TypeScript
205 lines
7.0 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 = ''
|
|
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 {
|
|
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>
|
|
)
|
|
}
|