React -> AI组件 -> 调用Ollama模型, qwen3:1.7B非常聪明

使用 React 搭建一个现代化的聊天界面,支持与 Ollama 本地部署的大语言模型进行多轮对话。界面清爽、功能完整,支持 Markdown 渲染、代码高亮、<think> 隐藏思考标签、流式渐进反馈、暗黑模式适配等特性。


🧩 核心功能亮点

✅ 模型选择支持
  • 启动时自动请求 http://localhost:11434/api/tags 获取所有本地模型。

  • 允许用户通过下拉框动态切换聊天使用的模型。

✅ 多轮对话支持
  • 聊天上下文由历史消息 messages 组成,发送请求时一并传入。

  • 用户每次发送内容后,bot 的响应将基于历史记录生成。

✅ 实时流式响应 + <think> 处理
  • 使用 ReadableStream 实现逐段渲染。

  • <think>...</think> 区块被识别并自动隐藏,直到关闭 </think> 后再更新 UI。

✅ Markdown 渲染 & 代码高亮
  • 借助 react-markdown + remark-gfm 支持 GitHub 风格 Markdown。

  • 使用 react-syntax-highlighter 实现代码块高亮显示,自动识别语言。

✅ 响应式 UI & 暗黑模式适配
  • 使用 Tailwind CSS 快速构建布局。

  • 检测 HTML dark 类名切换对应代码主题(oneLight / oneDark)。


javascript 复制代码
import React, { useState, useRef, useEffect } from 'react';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
import { oneLight, oneDark } from 'react-syntax-highlighter/dist/cjs/styles/prism';

type Message = { text: string; sender: 'user' | 'bot' };

type Props = { value: string; onChange: (e: React.ChangeEvent<HTMLInputElement>) => void; onSend: () => void };
const ChatInput: React.FC<Props> = React.memo(({ value, onChange, onSend }) => (
    <div className="mt-2 flex">
        <input
            className="flex-1 px-3 py-2 border rounded-l"
            value={value}
            onChange={onChange}
            onKeyDown={e => e.key === 'Enter' && onSend()}
        />
        <button onClick={onSend} className="px-4 bg-neutral-600 text-white rounded-r">
            发送
        </button>
    </div>
));

const ChatWindow: React.FC = () => {
    const [models, setModels] = useState<string[]>([]);
    const [selectedModel, setSelectedModel] = useState<string>('');
    const [messages, setMessages] = useState<Message[]>([
        { text: '你好,我是 Ollama!请选择模型后开始聊天。', sender: 'bot' },
    ]);
    const [input, setInput] = useState('');
    const [isThinking, setIsThinking] = useState(false);
    const messagesEndRef = useRef<HTMLDivElement>(null);
    const isDark = document.documentElement.classList.contains('dark');

    const scrollToBottom = () => messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
    useEffect(scrollToBottom, [messages, isThinking]);

    // 获取模型列表
    useEffect(() => {
        fetch('http://localhost:11434/api/tags')
            .then(res => res.json())
            .then(data => {
                const names = data.models?.map((m: any) => m.name) || [];
                setModels(names);
                if (names.length) setSelectedModel(names[0]);
            })
            .catch(err => {
                console.error('获取模型失败:', err);
                setMessages(prev => [...prev, { text: '无法获取模型列表', sender: 'bot' }]);
            });
    }, []);


    const handleSend = async () => {
        if (!input.trim() || !selectedModel) return;

        // 1. 把用户消息加入
        setMessages(prev => [...prev, { text: input, sender: 'user' }]);
        setInput('');

        // 2. 预插入一条 bot 占位,用于后面一次性更新
        setMessages(prev => [...prev, { text: '', sender: 'bot' }]);

        // 清洗 <think>...</think> 的工具
        const cleanThink = (text: string) => text.replace(/<think>[\s\S]*?<\/think>/g, '');

        let fullText = '';
        let thinkOpen = false;  // 标记是否在 <think>...</think> 区间

        try {
            const response = await fetch('http://localhost:11434/api/chat', {
                method: 'POST',
                headers: { 'Content-Type': 'application/json' },
                body: JSON.stringify({
                    model: selectedModel,
                    messages: [{ role: 'user', content: input }],
                }),
            });

            const reader = response.body!.getReader();
            const decoder = new TextDecoder('utf-8');

            while (true) {
                const { value, done } = await reader.read();
                if (done) break;

                const chunk = decoder.decode(value, { stream: true });
                const lines = chunk.split('\n').filter(l => l.trim());

                for (const line of lines) {
                    try {
                        const data = JSON.parse(line);
                        const c = data.message?.content || '';

                        // 检测思考开始
                        if (c.includes('<think>')) {
                            thinkOpen = true;
                            setIsThinking(true);
                        }

                        fullText += c;

                        // 检测思考结束
                        if (c.includes('</think>')) {
                            thinkOpen = false;
                            setIsThinking(false);

                            // 这时才做一次性更新:清洗掉所有 think 内容,并写入 UI
                            const display = cleanThink(fullText).trim();
                            setMessages(prev => {
                                const copy = [...prev];
                                copy[copy.length - 1] = { text: display, sender: 'bot' };
                                return copy;
                            });
                        }
                    } catch (e) {
                        console.warn('解析流片段失败:', e);
                    }
                }
            }

            // 如果整个流结束后,之前从未触发 </think>(比如模型不输出 think),那也一次性更新
            if (!thinkOpen) {
                // 每次都更新显示
                const display = cleanThink(fullText).trim();
                setMessages(prev => {
                    const copy = [...prev];
                    copy[copy.length - 1] = { text: display, sender: 'bot' };
                    return copy;
                });
            }
        } catch (err) {
            console.error('请求出错:', err);
            setMessages(prev => [
                ...prev,
                { text: '请求出错,请检查服务是否开启。', sender: 'bot' },
            ]);
            setIsThinking(false);
        }
    };



    return (
        <div className="h-screen flex flex-col p-4 bg-gray-100 dark:bg-gray-900">
            {/* 模型选择 */}
            <div className="mb-2">
                <label className="mr-2 text-sm text-gray-700 dark:text-gray-300">选择模型:</label>
                <select
                    value={selectedModel}
                    onChange={e => setSelectedModel(e.target.value)}
                    className="p-1 text-sm border rounded dark:bg-gray-700 dark:text-white"
                >
                    {models.map(m => (
                        <option key={m} value={m}>{m}</option>
                    ))}
                </select>
            </div>

            {/* 聊天记录 */}
            <div className="flex-1 overflow-y-auto p-4 space-y-4 bg-white dark:bg-gray-800 rounded">
                {/* 聊天记录渲染 */}
                {messages.map((msg, i) => (
                    <div key={i} className={msg.sender === 'bot' ? '' : 'text-right'}>
                        {msg.sender === 'bot' ? (
                            <div className="prose dark:prose-invert">
                                <ReactMarkdown
                                    remarkPlugins={[remarkGfm]}
                                    components={{
                                        code(props: any) {
                                            const { inline, className, children, ...rest } = props;
                                            const match = /language-(\w+)/.exec(className || '');
                                            if (!inline && match) {
                                                return (
                                                    <SyntaxHighlighter
                                                        style={isDark ? oneDark : oneLight}
                                                        language={match[1]}
                                                        PreTag="div"
                                                        {...rest}
                                                    >
                                                        {String(children).replace(/\n$/, '')}
                                                    </SyntaxHighlighter>
                                                );
                                            }
                                            return (
                                                <code className="bg-gray-200 dark:bg-gray-700 px-1 rounded text-sm" {...rest}>
                                                    {children}
                                                </code>
                                            );
                                        }
                                    }}
                                >
                                    {msg.text}
                                </ReactMarkdown>
                            </div>
                        ) : (
                            <div className="inline-block px-3 py-1 bg-neutral-300 dark:bg-neutral-600 rounded text-sm">
                                {msg.text}
                            </div>
                        )}
                    </div>
                ))}
                {isThinking && <div className="italic text-gray-500">正在思考中...</div>}
                <div ref={messagesEndRef} />
            </div>

            {/* 输入区 */}
            <ChatInput value={input} onChange={e => setInput(e.target.value)} onSend={handleSend} />

        </div>
    );
};

export default ChatWindow;  
相关推荐
崔庆才丨静觅8 小时前
hCaptcha 验证码图像识别 API 对接教程
前端
passerby60619 小时前
完成前端时间处理的另一块版图
前端·github·web components
掘了9 小时前
「2025 年终总结」在所有失去的人中,我最怀念我自己
前端·后端·年终总结
崔庆才丨静觅9 小时前
实用免费的 Short URL 短链接 API 对接说明
前端
崔庆才丨静觅10 小时前
5分钟快速搭建 AI 平台并用它赚钱!
前端
崔庆才丨静觅10 小时前
比官方便宜一半以上!Midjourney API 申请及使用
前端
Moment10 小时前
富文本编辑器在 AI 时代为什么这么受欢迎
前端·javascript·后端
崔庆才丨静觅10 小时前
刷屏全网的“nano-banana”API接入指南!0.1元/张量产高清创意图,开发者必藏
前端
剪刀石头布啊10 小时前
jwt介绍
前端
爱敲代码的小鱼11 小时前
AJAX(异步交互的技术来实现从服务端中获取数据):
前端·javascript·ajax