Der Backend ist fertig, die LLM-API läuft — aber was macht den Unterschied zwischen einem funktionierenden und einem guten AI-Chatbot? Das Frontend. Benutzer erwarten Streaming (Token für Token), Markdown-Support, Code-Highlighting und flüssige Interaktion. Dieser Artikel zeigt euch, wie ihr ein Production-Ready React Chatbot Frontend baut, das professionell aussieht und sich professionell anfühlt. Mit TypeScript, React Hooks und modernem CSS.
Setup und Dependencies
Zuerst das Projekt initialisieren:
```bash # Create React app with TypeScript npx create-react-app ai-chatbot --template typescript cd ai-chatbot
# Dependencies für unser Chatbot npm install axios react-markdown react-syntax-highlighter zustand npm install -D tailwindcss postcss autoprefixer npx tailwindcss init -p ```
Wir nutzen: - axios: HTTP-Requests - react-markdown: Markdown rendern - react-syntax-highlighter: Code-Highlighting - zustand: State Management (leichter als Redux) - tailwindcss: Styling
In `tailwind.config.js`:
```javascript module.exports = { content: [ "./index.html", "./src/**/*.{js,ts,jsx,tsx}", ], theme: { extend: {}, }, plugins: [], } ```
State Management mit Zustand
Definiert euren Chat State mit Zustand (deutlich einfacher als Redux):
```typescript // src/store/chatStore.ts import { create } from 'zustand';
export interface Message { id: string; role: 'user' | 'assistant'; content: string; timestamp: Date; }
export interface ChatSession { id: string; title: string; messages: Message[]; }
interface ChatStore { sessions: ChatSession[]; currentSessionId: string | null; loading: boolean; error: string | null; // Actions createSession: () => void; selectSession: (id: string) => void; addMessage: (role: 'user' | 'assistant', content: string) => void; setLoading: (loading: boolean) => void; setError: (error: string | null) => void; getCurrentSession: () => ChatSession | undefined; }
export const useChatStore = create<ChatStore>((set, get) => ({ sessions: [], currentSessionId: null, loading: false, error: null, createSession: () => set((state) => { const newSession: ChatSession = { id: Date.now().toString(), title: 'New Chat', messages: [], }; return { sessions: [newSession, ...state.sessions], currentSessionId: newSession.id, }; }), selectSession: (id: string) => set({ currentSessionId: id }), addMessage: (role: 'user' | 'assistant', content: string) => set((state) => { if (!state.currentSessionId) return state; return { sessions: state.sessions.map((session) => { if (session.id === state.currentSessionId) { return { ...session, messages: [ ...session.messages, { id: Date.now().toString(), role, content, timestamp: new Date(), }, ], }; } return session; }), }; }), setLoading: (loading: boolean) => set({ loading }), setError: (error: string | null) => set({ error }), getCurrentSession: () => { const state = get(); return state.sessions.find((s) => s.id === state.currentSessionId); }, })); ```
Streaming Hook: Server-Sent Events richtig nutzen
Das Herz des Chatbots: Ein Hook, der Streaming Responses von hinten verarbeitet:
```typescript // src/hooks/useStreamingChat.ts import { useState, useCallback } from 'react'; import { useChatStore } from '../store/chatStore';
interface UseStreamingChatOptions { apiUrl?: string; }
export const useStreamingChat = (options: UseStreamingChatOptions = {}) => { const apiUrl = options.apiUrl || process.env.REACT_APP_API_URL || 'http://localhost:8000'; const [isStreaming, setIsStreaming] = useState(false); const { addMessage, setLoading, setError, currentSessionId } = useChatStore();
const sendMessage = useCallback( async (userMessage: string) => { if (!userMessage.trim() || !currentSessionId) { return; }
try { // Add user message immediately addMessage('user', userMessage); setLoading(true); setError(null); setIsStreaming(true);
// Make streaming request const response = await fetch(`${apiUrl}/api/chat/stream/`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${localStorage.getItem('authToken')}`, }, body: JSON.stringify({ message: userMessage, session_id: currentSessionId, }), });
if (!response.ok) { throw new Error(`API error: ${response.statusText}`); }
// Handle streaming response const reader = response.body?.getReader(); if (!reader) throw new Error('No reader available');
const decoder = new TextDecoder(); let assistantMessage = '';
while (true) { const { done, value } = await reader.read(); if (done) break;
const text = decoder.decode(value); const lines = text.split('\n');
for (const line of lines) { if (line.startsWith('data: ')) { const data = JSON.parse(line.slice(6)); if (data.chunk) { // Accumulate the message assistantMessage += data.chunk; // Update the last message in real-time useChatStore.setState((state) => { const session = state.getCurrentSession(); if (!session) return state; return { sessions: state.sessions.map((s) => { if (s.id === session.id) { const messages = [...s.messages]; if (messages[messages.length - 1]?.role === 'assistant') { messages[messages.length - 1].content = assistantMessage; } else { messages.push({ id: Date.now().toString(), role: 'assistant', content: assistantMessage, timestamp: new Date(), }); } return { ...s, messages }; } return s; }), }; }); }
if (data.done) { // Streaming complete setIsStreaming(false); } if (data.error) { setError(data.error); setIsStreaming(false); } } } } } catch (error) { const errorMessage = error instanceof Error ? error.message : 'Unknown error'; setError(errorMessage); setIsStreaming(false); } finally { setLoading(false); } }, [currentSessionId, addMessage, setLoading, setError, apiUrl] );
return { sendMessage, isStreaming }; }; ```
Message Component mit Markdown und Code-Highlighting
Ein reusable Message Component, der Markdown und Code-Highlighting handelt:
```typescript // src/components/Message.tsx import React from 'react'; import ReactMarkdown from 'react-markdown'; import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter'; import { oneDark } from 'react-syntax-highlighter/dist/esm/styles/prism';
interface MessageProps { role: 'user' | 'assistant'; content: string; timestamp?: Date; }
export const Message: React.FC<MessageProps> = ({ role, content, timestamp }) => { const isAssistant = role === 'assistant';
return ( <div className={`mb-4 flex ${isAssistant ? 'justify-start' : 'justify-end'}`}> <div className={`max-w-xs lg:max-w-md px-4 py-3 rounded-lg ${ isAssistant ? 'bg-gray-200 text-gray-900' : 'bg-blue-600 text-white' }`} > <ReactMarkdown components={{ code({ node, inline, className, children, ...props }) { const match = /language-(\w+)/.exec(className || ''); const language = match ? match[1] : 'text';
if (inline) { return ( <code className="bg-gray-900 text-green-400 px-1 rounded text-sm"> {children} </code> ); }
return ( <SyntaxHighlighter style={oneDark} language={language} PreTag="div" {...props} > {String(children).replace(/\n$/, '')} </SyntaxHighlighter> ); }, a: ({ node, ...props }) => ( <a {...props} className="underline hover:text-gray-600" /> ), ul: ({ node, ...props }) => ( <ul {...props} className="list-disc list-inside" /> ), ol: ({ node, ...props }) => ( <ol {...props} className="list-decimal list-inside" /> ), }} > {content} </ReactMarkdown> {timestamp && ( <p className="text-xs mt-2 opacity-70"> {timestamp.toLocaleTimeString()} </p> )} </div> </div> ); }; ```
Chat Container mit Typing Indicator
Der Main Chat Container:
```typescript // src/components/ChatContainer.tsx import React, { useEffect, useRef } from 'react'; import { useChatStore } from '../store/chatStore'; import { useStreamingChat } from '../hooks/useStreamingChat'; import { Message } from './Message';
export const ChatContainer: React.FC = () => { const { getCurrentSession, loading } = useChatStore(); const { sendMessage, isStreaming } = useStreamingChat(); const [input, setInput] = React.useState(''); const messagesEndRef = useRef<HTMLDivElement>(null);
const session = getCurrentSession(); const messages = session?.messages || [];
// Auto-scroll to bottom useEffect(() => { messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }); }, [messages]);
const handleSubmit = (e: React.FormEvent) => { e.preventDefault(); if (input.trim()) { sendMessage(input); setInput(''); } };
return ( <div className="flex flex-col h-full max-w-4xl mx-auto"> {/* Messages */} <div className="flex-1 overflow-y-auto p-4 space-y-4"> {messages.length === 0 && !isStreaming && ( <div className="flex items-center justify-center h-full"> <p className="text-gray-400 text-center"> Starten Sie ein Gespräch, indem Sie eine Nachricht eingeben. </p> </div> )}
{messages.map((message) => ( <Message key={message.id} role={message.role} content={message.content} timestamp={message.timestamp} /> ))}
{/* Typing Indicator */} {isStreaming && ( <div className="flex justify-start mb-4"> <div className="bg-gray-200 px-4 py-3 rounded-lg"> <div className="flex space-x-2"> <div className="w-2 h-2 bg-gray-500 rounded-full animate-bounce" /> <div className="w-2 h-2 bg-gray-500 rounded-full animate-bounce delay-100" /> <div className="w-2 h-2 bg-gray-500 rounded-full animate-bounce delay-200" /> </div> </div> </div> )}
<div ref={messagesEndRef} /> </div>
{/* Input */} <form onSubmit={handleSubmit} className="border-t border-gray-200 p-4 bg-white" > <div className="flex gap-2"> <input type="text" value={input} onChange={(e) => setInput(e.target.value)} placeholder="Schreiben Sie eine Nachricht..." disabled={loading || isStreaming} className="flex-1 px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:opacity-50" /> <button type="submit" disabled={loading || isStreaming || !input.trim()} className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed" > {isStreaming ? 'Senden...' : 'Senden'} </button> </div> </form> </div> ); }; ```
Sidebar mit Session Management
Eine Sidebar zum Verwalten mehrerer Chat-Sessions:
```typescript // src/components/Sidebar.tsx import React from 'react'; import { useChatStore } from '../store/chatStore';
export const Sidebar: React.FC = () => { const { sessions, currentSessionId, createSession, selectSession } = useChatStore();
return ( <div className="w-64 bg-gray-100 border-r border-gray-200 flex flex-col"> {/* Header */} <div className="p-4 border-b border-gray-200"> <button onClick={createSession} className="w-full px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 font-medium" > + Neuer Chat </button> </div>
{/* Sessions List */} <div className="flex-1 overflow-y-auto"> {sessions.map((session) => ( <button key={session.id} onClick={() => selectSession(session.id)} className={`w-full text-left px-4 py-3 border-b border-gray-200 hover:bg-gray-200 transition ${ session.id === currentSessionId ? 'bg-gray-300 font-bold' : '' }`} > <div className="truncate">{session.title || 'Unnamed Chat'}</div> <div className="text-sm text-gray-600 mt-1"> {session.messages.length} messages </div> </button> ))} </div>
{/* Settings */} <div className="p-4 border-t border-gray-200"> <button className="w-full px-4 py-2 text-gray-700 hover:bg-gray-200 rounded-lg"> ⚙️ Einstellungen </button> </div> </div> ); }; ```
Main App Component
Alles zusammenbinden:
```typescript // src/App.tsx import React, { useEffect } from 'react'; import { useChatStore } from './store/chatStore'; import { Sidebar } from './components/Sidebar'; import { ChatContainer } from './components/ChatContainer';
function App() { const { createSession, sessions } = useChatStore();
// Create initial session on load useEffect(() => { if (sessions.length === 0) { createSession(); } }, []);
return ( <div className="flex h-screen bg-white"> <Sidebar /> <div className="flex-1 flex flex-col"> <div className="bg-white border-b border-gray-200 p-4"> <h1 className="text-2xl font-bold">AI Chatbot</h1> <p className="text-gray-600">Powered by Claude AI</p> </div> <ChatContainer /> </div> </div> ); }
export default App; ```
Und in `index.css`:
```css @tailwind base; @tailwind components; @tailwind utilities;
@layer utilities { .delay-100 { animation-delay: 100ms; } .delay-200 { animation-delay: 200ms; } } ```
Error Handling und Toast Notifications
In Produktion braucht ihr auch Error Handling:
```typescript // src/components/Toast.tsx import React, { useState, useEffect } from 'react'; import { useChatStore } from '../store/chatStore';
export const Toast: React.FC = () => { const { error } = useChatStore(); const [visible, setVisible] = useState(false);
useEffect(() => { if (error) { setVisible(true); const timer = setTimeout(() => setVisible(false), 5000); return () => clearTimeout(timer); } }, [error]);
if (!visible || !error) return null;
return ( <div className="fixed bottom-4 right-4 bg-red-500 text-white px-4 py-3 rounded-lg shadow-lg"> <p className="font-bold">Fehler</p> <p className="text-sm">{error}</p> </div> ); }; ```
Und in `App.tsx` einbauen:
```typescript function App() { // ... rest of the code return ( <div className="flex h-screen bg-white"> <Sidebar /> <div className="flex-1 flex flex-col"> {/* ... */} </div> <Toast /> {/* Add this */} </div> ); } ```
Performance und Accessibility
Ein paar wichtige Optimierungen:
1. Lazy Loading für lange Chat-Historien: Bei vielen Nachrichten nur die neuesten rendern
```typescript const VISIBLE_MESSAGES_LIMIT = 50; const visibleMessages = messages.slice(-VISIBLE_MESSAGES_LIMIT); ```
2. Memoization: Für Message Components um Re-renders zu vermeiden
```typescript export const Message = React.memo(MessageComponent); ```
3. ARIA Labels: Für Accessibility
```typescript <button aria-label="Send message" role="button" aria-disabled={isStreaming} > ```
4. Keyboard Navigation: Enter zum Senden
```typescript const handleKeyDown = (e: React.KeyboardEvent) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); handleSubmit(e as any); } }; ```
Fazit
Ihr habt jetzt ein vollständiges, Production-Ready React Chatbot Frontend mit: - Streaming Responses - Markdown Support mit Code-Highlighting - Session Management - Error Handling - Responsive Design - Accessibility Features
Das ist nicht nur ein Frontend, das funktioniert — es ist eines, das Professional aussieht und sich Professional anfühlt. Benutzer werden die Streaming UX lieben, Entwickler werden die saubere Struktur mit Zustand lieben.
Nächste Schritte: Deployment (Vercel, Netlify), Progressive Web App Features, oder Dark Mode. Oder kontaktiert e-laborat für ein vollständiges Full-Stack Setup mit Django Backend, React Frontend und einer RAG Pipeline — alles production-ready.