React 集成
useChatStream Hook、打字机效果
7.1 useChatStream Hook
typescript
import { useState, useCallback, useRef } from 'react';
interface Message {
role: 'user' | 'assistant';
content: string;
}
export function useChatStream() {
const [messages, setMessages] = useState<Message[]>([]);
const [isStreaming, setIsStreaming] = useState(false);
const controllerRef = useRef<AbortController | null>(null);
const sendMessage = useCallback(async (content: string) => {
const userMessage: Message = { role: 'user', content };
const newMessages = [...messages, userMessage];
setMessages(newMessages);
setIsStreaming(true);
// 添加空的 assistant 消息
let assistantContent = '';
setMessages([...newMessages, { role: 'assistant', content: '' }]);
controllerRef.current = new AbortController();
try {
const response = await fetch('/api/chat', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ messages: newMessages }),
signal: controllerRef.current.signal
});
const reader = response.body!.getReader();
const decoder = new TextDecoder();
let buffer = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop() || '';
for (const line of lines) {
if (line.startsWith('data: ') && line !== 'data: [DONE]') {
const json = JSON.parse(line.slice(6));
const token = json.choices?.[0]?.delta?.content || '';
assistantContent += token;
setMessages(prev => [
...prev.slice(0, -1),
{ role: 'assistant', content: assistantContent }
]);
}
}
}
} catch (error: any) {
if (error.name !== 'AbortError') {
console.error(error);
}
} finally {
setIsStreaming(false);
controllerRef.current = null;
}
}, [messages]);
const stopStream = useCallback(() => {
controllerRef.current?.abort();
}, []);
return { messages, isStreaming, sendMessage, stopStream };
}7.2 ChatUI 组件
tsx
import { useState } from 'react';
import { useChatStream } from './useChatStream';
import ReactMarkdown from 'react-markdown';
export function ChatUI() {
const { messages, isStreaming, sendMessage, stopStream } = useChatStream();
const [input, setInput] = useState('');
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (input.trim() && !isStreaming) {
sendMessage(input);
setInput('');
}
};
return (
<div className="chat-container">
<div className="messages">
{messages.map((msg, i) => (
<div key={i} className={`message ${msg.role}`}>
<ReactMarkdown>{msg.content}</ReactMarkdown>
{msg.role === 'assistant' && isStreaming && i === messages.length - 1 && (
<span className="cursor">▋</span>
)}
</div>
))}
</div>
<form onSubmit={handleSubmit}>
<input
value={input}
onChange={e => setInput(e.target.value)}
disabled={isStreaming}
placeholder="输入消息..."
/>
{isStreaming ? (
<button type="button" onClick={stopStream}>停止</button>
) : (
<button type="submit">发送</button>
)}
</form>
</div>
);
}7.3 打字机光标动画
css
.cursor {
display: inline-block;
animation: blink 1s steps(1) infinite;
}
@keyframes blink {
0%, 50% { opacity: 1; }
51%, 100% { opacity: 0; }
}
.message.assistant {
white-space: pre-wrap;
}