Vollständige Next.js 15 Portfolio-Website mit: - Blog-System mit 100+ Artikeln - Supabase-Integration - Responsive Design mit Tailwind CSS - TypeScript-Konfiguration - Testing-Setup mit Vitest und Playwright Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
615 lines
16 KiB
Markdown
615 lines
16 KiB
Markdown
# Socket.IO für AI Chat Applications: Real-Time Implementation Guide
|
|
|
|
**Meta-Description:** Production-ready AI Chat mit Socket.IO. Rooms, Typing Indicators, Message History und LLM-Streaming für skalierbare Chat-Anwendungen.
|
|
|
|
**Keywords:** Socket.IO, AI Chat, Real-Time Chat, Node.js Chat, WebSocket Chat, LLM Streaming, Chat Application
|
|
|
|
---
|
|
|
|
## Einführung
|
|
|
|
Socket.IO ist der De-facto-Standard für Real-Time-Kommunikation in Node.js. Für AI-Chat-Anwendungen bietet es **bidirektionale Events, automatische Reconnection und Room-basierte Isolation**.
|
|
|
|
---
|
|
|
|
## Architecture Overview
|
|
|
|
```
|
|
┌─────────────────────────────────────────────────────────────┐
|
|
│ AI CHAT WITH SOCKET.IO │
|
|
├─────────────────────────────────────────────────────────────┤
|
|
│ │
|
|
│ Client Server │
|
|
│ ┌─────────────┐ ┌─────────────────────┐ │
|
|
│ │ React │ socket.io │ Express + │ │
|
|
│ │ App │◄──────────────►│ Socket.IO │ │
|
|
│ └─────────────┘ └──────────┬──────────┘ │
|
|
│ │ │
|
|
│ ▼ │
|
|
│ ┌─────────────────────┐ │
|
|
│ │ LLM Service │ │
|
|
│ │ (Claude/GPT) │ │
|
|
│ └─────────────────────┘ │
|
|
│ │ │
|
|
│ ▼ │
|
|
│ ┌─────────────────────┐ │
|
|
│ │ Redis │ │
|
|
│ │ (Sessions/Cache) │ │
|
|
│ └─────────────────────┘ │
|
|
│ │
|
|
└─────────────────────────────────────────────────────────────┘
|
|
```
|
|
|
|
---
|
|
|
|
## Server Implementation
|
|
|
|
### Basic Setup
|
|
|
|
```typescript
|
|
// src/server.ts
|
|
import express from 'express';
|
|
import { createServer } from 'http';
|
|
import { Server, Socket } from 'socket.io';
|
|
import { Redis } from 'ioredis';
|
|
import Anthropic from '@anthropic-ai/sdk';
|
|
|
|
const app = express();
|
|
const server = createServer(app);
|
|
const io = new Server(server, {
|
|
cors: {
|
|
origin: process.env.CLIENT_URL,
|
|
credentials: true
|
|
},
|
|
pingTimeout: 60000,
|
|
pingInterval: 25000
|
|
});
|
|
|
|
const redis = new Redis(process.env.REDIS_URL!);
|
|
const anthropic = new Anthropic();
|
|
|
|
// Middleware
|
|
io.use(async (socket, next) => {
|
|
const token = socket.handshake.auth.token;
|
|
try {
|
|
const user = await verifyToken(token);
|
|
socket.data.user = user;
|
|
next();
|
|
} catch (error) {
|
|
next(new Error('Authentication failed'));
|
|
}
|
|
});
|
|
|
|
// Connection Handler
|
|
io.on('connection', (socket: Socket) => {
|
|
const userId = socket.data.user.id;
|
|
console.log(`User connected: ${userId}`);
|
|
|
|
// Persönlicher Room für User
|
|
socket.join(`user:${userId}`);
|
|
|
|
setupChatHandlers(socket);
|
|
setupTypingHandlers(socket);
|
|
|
|
socket.on('disconnect', () => {
|
|
console.log(`User disconnected: ${userId}`);
|
|
});
|
|
});
|
|
|
|
server.listen(3000);
|
|
```
|
|
|
|
### Chat Event Handlers
|
|
|
|
```typescript
|
|
// src/handlers/chat.ts
|
|
interface ChatMessage {
|
|
id: string;
|
|
conversationId: string;
|
|
role: 'user' | 'assistant';
|
|
content: string;
|
|
timestamp: Date;
|
|
}
|
|
|
|
function setupChatHandlers(socket: Socket) {
|
|
const userId = socket.data.user.id;
|
|
|
|
// Conversation beitreten
|
|
socket.on('join:conversation', async (conversationId: string) => {
|
|
// Berechtigung prüfen
|
|
const hasAccess = await checkConversationAccess(userId, conversationId);
|
|
if (!hasAccess) {
|
|
socket.emit('error', { message: 'Access denied' });
|
|
return;
|
|
}
|
|
|
|
socket.join(`conversation:${conversationId}`);
|
|
|
|
// History laden
|
|
const history = await loadConversationHistory(conversationId);
|
|
socket.emit('conversation:history', history);
|
|
});
|
|
|
|
// Neue Nachricht senden
|
|
socket.on('message:send', async (data: {
|
|
conversationId: string;
|
|
content: string;
|
|
}) => {
|
|
const { conversationId, content } = data;
|
|
|
|
// User-Nachricht speichern
|
|
const userMessage: ChatMessage = {
|
|
id: crypto.randomUUID(),
|
|
conversationId,
|
|
role: 'user',
|
|
content,
|
|
timestamp: new Date()
|
|
};
|
|
|
|
await saveMessage(userMessage);
|
|
|
|
// An alle in der Conversation senden
|
|
io.to(`conversation:${conversationId}`).emit('message:new', userMessage);
|
|
|
|
// AI Response generieren
|
|
await generateAIResponse(socket, conversationId, content);
|
|
});
|
|
|
|
// Conversation verlassen
|
|
socket.on('leave:conversation', (conversationId: string) => {
|
|
socket.leave(`conversation:${conversationId}`);
|
|
});
|
|
}
|
|
```
|
|
|
|
### AI Response mit Streaming
|
|
|
|
```typescript
|
|
// src/handlers/ai-response.ts
|
|
async function generateAIResponse(
|
|
socket: Socket,
|
|
conversationId: string,
|
|
userMessage: string
|
|
) {
|
|
const responseId = crypto.randomUUID();
|
|
|
|
// Typing indicator starten
|
|
io.to(`conversation:${conversationId}`).emit('ai:typing', {
|
|
conversationId,
|
|
isTyping: true
|
|
});
|
|
|
|
try {
|
|
// Conversation History laden
|
|
const history = await loadConversationHistory(conversationId);
|
|
|
|
// Stream starten
|
|
const stream = await anthropic.messages.stream({
|
|
model: 'claude-3-haiku-20240307',
|
|
max_tokens: 1000,
|
|
system: 'Du bist ein hilfreicher Assistent.',
|
|
messages: history.map(m => ({
|
|
role: m.role,
|
|
content: m.content
|
|
}))
|
|
});
|
|
|
|
let fullContent = '';
|
|
|
|
// Token-by-Token streamen
|
|
for await (const event of stream) {
|
|
if (event.type === 'content_block_delta') {
|
|
const delta = event.delta.text;
|
|
fullContent += delta;
|
|
|
|
// Delta an Client senden
|
|
io.to(`conversation:${conversationId}`).emit('message:delta', {
|
|
messageId: responseId,
|
|
conversationId,
|
|
delta,
|
|
fullContent
|
|
});
|
|
}
|
|
}
|
|
|
|
// Vollständige Nachricht speichern
|
|
const assistantMessage: ChatMessage = {
|
|
id: responseId,
|
|
conversationId,
|
|
role: 'assistant',
|
|
content: fullContent,
|
|
timestamp: new Date()
|
|
};
|
|
|
|
await saveMessage(assistantMessage);
|
|
|
|
// Completion Event
|
|
io.to(`conversation:${conversationId}`).emit('message:complete', {
|
|
messageId: responseId,
|
|
conversationId
|
|
});
|
|
|
|
} catch (error) {
|
|
io.to(`conversation:${conversationId}`).emit('ai:error', {
|
|
conversationId,
|
|
error: 'AI response failed'
|
|
});
|
|
} finally {
|
|
// Typing indicator stoppen
|
|
io.to(`conversation:${conversationId}`).emit('ai:typing', {
|
|
conversationId,
|
|
isTyping: false
|
|
});
|
|
}
|
|
}
|
|
```
|
|
|
|
### Typing Indicators
|
|
|
|
```typescript
|
|
// src/handlers/typing.ts
|
|
function setupTypingHandlers(socket: Socket) {
|
|
const userId = socket.data.user.id;
|
|
const typingTimeouts = new Map<string, NodeJS.Timeout>();
|
|
|
|
socket.on('typing:start', (conversationId: string) => {
|
|
// An andere User im Room senden
|
|
socket.to(`conversation:${conversationId}`).emit('user:typing', {
|
|
userId,
|
|
conversationId,
|
|
isTyping: true
|
|
});
|
|
|
|
// Auto-Stop nach 3 Sekunden
|
|
const existing = typingTimeouts.get(conversationId);
|
|
if (existing) clearTimeout(existing);
|
|
|
|
typingTimeouts.set(conversationId, setTimeout(() => {
|
|
socket.to(`conversation:${conversationId}`).emit('user:typing', {
|
|
userId,
|
|
conversationId,
|
|
isTyping: false
|
|
});
|
|
}, 3000));
|
|
});
|
|
|
|
socket.on('typing:stop', (conversationId: string) => {
|
|
const existing = typingTimeouts.get(conversationId);
|
|
if (existing) clearTimeout(existing);
|
|
|
|
socket.to(`conversation:${conversationId}`).emit('user:typing', {
|
|
userId,
|
|
conversationId,
|
|
isTyping: false
|
|
});
|
|
});
|
|
|
|
socket.on('disconnect', () => {
|
|
// Cleanup
|
|
typingTimeouts.forEach(timeout => clearTimeout(timeout));
|
|
});
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## Client Implementation
|
|
|
|
### React Hook
|
|
|
|
```typescript
|
|
// src/hooks/useChat.ts
|
|
import { useEffect, useState, useCallback, useRef } from 'react';
|
|
import { io, Socket } from 'socket.io-client';
|
|
|
|
interface Message {
|
|
id: string;
|
|
role: 'user' | 'assistant';
|
|
content: string;
|
|
timestamp: Date;
|
|
isStreaming?: boolean;
|
|
}
|
|
|
|
export function useChat(conversationId: string) {
|
|
const socketRef = useRef<Socket | null>(null);
|
|
const [messages, setMessages] = useState<Message[]>([]);
|
|
const [isConnected, setIsConnected] = useState(false);
|
|
const [isAITyping, setIsAITyping] = useState(false);
|
|
const [streamingMessage, setStreamingMessage] = useState<string>('');
|
|
|
|
useEffect(() => {
|
|
// Socket Connection
|
|
const socket = io(process.env.NEXT_PUBLIC_WS_URL!, {
|
|
auth: { token: getAuthToken() },
|
|
transports: ['websocket']
|
|
});
|
|
|
|
socketRef.current = socket;
|
|
|
|
socket.on('connect', () => {
|
|
setIsConnected(true);
|
|
socket.emit('join:conversation', conversationId);
|
|
});
|
|
|
|
socket.on('disconnect', () => {
|
|
setIsConnected(false);
|
|
});
|
|
|
|
// Event Handlers
|
|
socket.on('conversation:history', (history: Message[]) => {
|
|
setMessages(history);
|
|
});
|
|
|
|
socket.on('message:new', (message: Message) => {
|
|
setMessages(prev => [...prev, message]);
|
|
});
|
|
|
|
socket.on('message:delta', ({ messageId, delta, fullContent }) => {
|
|
setStreamingMessage(fullContent);
|
|
});
|
|
|
|
socket.on('message:complete', ({ messageId }) => {
|
|
setMessages(prev => [
|
|
...prev,
|
|
{
|
|
id: messageId,
|
|
role: 'assistant',
|
|
content: streamingMessage,
|
|
timestamp: new Date()
|
|
}
|
|
]);
|
|
setStreamingMessage('');
|
|
});
|
|
|
|
socket.on('ai:typing', ({ isTyping }) => {
|
|
setIsAITyping(isTyping);
|
|
});
|
|
|
|
socket.on('ai:error', ({ error }) => {
|
|
console.error('AI Error:', error);
|
|
setIsAITyping(false);
|
|
});
|
|
|
|
return () => {
|
|
socket.emit('leave:conversation', conversationId);
|
|
socket.disconnect();
|
|
};
|
|
}, [conversationId]);
|
|
|
|
const sendMessage = useCallback((content: string) => {
|
|
if (socketRef.current) {
|
|
socketRef.current.emit('message:send', {
|
|
conversationId,
|
|
content
|
|
});
|
|
}
|
|
}, [conversationId]);
|
|
|
|
const startTyping = useCallback(() => {
|
|
socketRef.current?.emit('typing:start', conversationId);
|
|
}, [conversationId]);
|
|
|
|
const stopTyping = useCallback(() => {
|
|
socketRef.current?.emit('typing:stop', conversationId);
|
|
}, [conversationId]);
|
|
|
|
return {
|
|
messages,
|
|
streamingMessage,
|
|
isConnected,
|
|
isAITyping,
|
|
sendMessage,
|
|
startTyping,
|
|
stopTyping
|
|
};
|
|
}
|
|
```
|
|
|
|
### Chat Component
|
|
|
|
```tsx
|
|
// src/components/Chat.tsx
|
|
import { useState, useRef, useEffect } from 'react';
|
|
import { useChat } from '../hooks/useChat';
|
|
|
|
export function Chat({ conversationId }: { conversationId: string }) {
|
|
const {
|
|
messages,
|
|
streamingMessage,
|
|
isConnected,
|
|
isAITyping,
|
|
sendMessage,
|
|
startTyping,
|
|
stopTyping
|
|
} = useChat(conversationId);
|
|
|
|
const [input, setInput] = useState('');
|
|
const messagesEndRef = useRef<HTMLDivElement>(null);
|
|
|
|
// Auto-scroll
|
|
useEffect(() => {
|
|
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
|
}, [messages, streamingMessage]);
|
|
|
|
const handleSubmit = (e: React.FormEvent) => {
|
|
e.preventDefault();
|
|
if (input.trim()) {
|
|
sendMessage(input);
|
|
setInput('');
|
|
stopTyping();
|
|
}
|
|
};
|
|
|
|
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
setInput(e.target.value);
|
|
if (e.target.value) {
|
|
startTyping();
|
|
} else {
|
|
stopTyping();
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div className="flex flex-col h-full">
|
|
{/* Connection Status */}
|
|
<div className={`px-4 py-2 text-sm ${
|
|
isConnected ? 'bg-green-100' : 'bg-red-100'
|
|
}`}>
|
|
{isConnected ? 'Connected' : 'Reconnecting...'}
|
|
</div>
|
|
|
|
{/* Messages */}
|
|
<div className="flex-1 overflow-y-auto p-4 space-y-4">
|
|
{messages.map(message => (
|
|
<div
|
|
key={message.id}
|
|
className={`p-3 rounded-lg ${
|
|
message.role === 'user'
|
|
? 'bg-blue-100 ml-auto max-w-[80%]'
|
|
: 'bg-gray-100 mr-auto max-w-[80%]'
|
|
}`}
|
|
>
|
|
{message.content}
|
|
</div>
|
|
))}
|
|
|
|
{/* Streaming Message */}
|
|
{streamingMessage && (
|
|
<div className="bg-gray-100 mr-auto max-w-[80%] p-3 rounded-lg">
|
|
{streamingMessage}
|
|
<span className="animate-pulse">▋</span>
|
|
</div>
|
|
)}
|
|
|
|
{/* AI Typing Indicator */}
|
|
{isAITyping && !streamingMessage && (
|
|
<div className="bg-gray-100 mr-auto p-3 rounded-lg">
|
|
<span className="animate-pulse">● ● ●</span>
|
|
</div>
|
|
)}
|
|
|
|
<div ref={messagesEndRef} />
|
|
</div>
|
|
|
|
{/* Input */}
|
|
<form onSubmit={handleSubmit} className="p-4 border-t">
|
|
<div className="flex gap-2">
|
|
<input
|
|
type="text"
|
|
value={input}
|
|
onChange={handleInputChange}
|
|
placeholder="Nachricht eingeben..."
|
|
className="flex-1 px-4 py-2 border rounded-lg"
|
|
disabled={!isConnected}
|
|
/>
|
|
<button
|
|
type="submit"
|
|
disabled={!isConnected || !input.trim()}
|
|
className="px-4 py-2 bg-blue-500 text-white rounded-lg disabled:opacity-50"
|
|
>
|
|
Senden
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
);
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## Scaling mit Redis Adapter
|
|
|
|
```typescript
|
|
// src/server.ts
|
|
import { createAdapter } from '@socket.io/redis-adapter';
|
|
import { createClient } from 'redis';
|
|
|
|
const pubClient = createClient({ url: process.env.REDIS_URL });
|
|
const subClient = pubClient.duplicate();
|
|
|
|
await Promise.all([pubClient.connect(), subClient.connect()]);
|
|
|
|
io.adapter(createAdapter(pubClient, subClient));
|
|
|
|
// Jetzt können mehrere Server-Instanzen kommunizieren
|
|
```
|
|
|
|
---
|
|
|
|
## Best Practices
|
|
|
|
### 1. Rate Limiting
|
|
|
|
```typescript
|
|
import rateLimit from 'socket.io-rate-limiter';
|
|
|
|
io.use(rateLimit({
|
|
windowMs: 1000, // 1 Sekunde
|
|
max: 10 // Max 10 Events pro Sekunde
|
|
}));
|
|
```
|
|
|
|
### 2. Input Validation
|
|
|
|
```typescript
|
|
import { z } from 'zod';
|
|
|
|
const messageSchema = z.object({
|
|
conversationId: z.string().uuid(),
|
|
content: z.string().min(1).max(4000)
|
|
});
|
|
|
|
socket.on('message:send', async (data) => {
|
|
const result = messageSchema.safeParse(data);
|
|
if (!result.success) {
|
|
socket.emit('error', { message: 'Invalid message format' });
|
|
return;
|
|
}
|
|
// Process...
|
|
});
|
|
```
|
|
|
|
### 3. Error Handling
|
|
|
|
```typescript
|
|
socket.on('error', (error) => {
|
|
console.error('Socket error:', error);
|
|
socket.emit('error', { message: 'An error occurred' });
|
|
});
|
|
|
|
io.engine.on('connection_error', (error) => {
|
|
console.error('Connection error:', error);
|
|
});
|
|
```
|
|
|
|
---
|
|
|
|
## Fazit
|
|
|
|
Socket.IO für AI Chat bietet:
|
|
|
|
1. **Bidirektionale Kommunikation**: Perfekt für Streaming-Responses
|
|
2. **Rooms**: Isolation für Conversations
|
|
3. **Auto-Reconnection**: Robuste Verbindung
|
|
4. **Skalierbarkeit**: Redis Adapter für Multi-Server
|
|
|
|
Für produktionsreife AI-Chat-Anwendungen ist Socket.IO die pragmatische Wahl.
|
|
|
|
---
|
|
|
|
## Bildprompts
|
|
|
|
1. "Chat interface with AI assistant, real-time message bubbles appearing, modern app design"
|
|
2. "WebSocket connection diagram between client and server, data flowing both ways"
|
|
3. "Multiple users in chat room with AI, collaborative interface, friendly tech illustration"
|
|
|
|
---
|
|
|
|
## Quellen
|
|
|
|
- [Socket.IO Documentation](https://socket.io/docs/v4/)
|
|
- [Socket.IO Best Practices](https://ably.com/topic/socketio)
|
|
- [Building Real-Time Chat with Socket.IO](https://socket.io/get-started/chat)
|
|
- [Socket.IO Redis Adapter](https://socket.io/docs/v4/redis-adapter/)
|