Skip to content

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;
}

前端面试知识库