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;  
相关推荐
程序员小杰@1 小时前
✨WordToCard使用分享✨
前端·人工智能·开源·云计算
larntin20021 小时前
vue2开发者sass预处理注意
前端·css·sass
Enti7c1 小时前
利用jQuery 实现多选标签下拉框,提升表单交互体验
前端·交互·jquery
SHUIPING_YANG2 小时前
在Fiddler中添加自定义HTTP方法列并高亮显示
前端·http·fiddler
互联网搬砖老肖3 小时前
Web 架构之前后端分离
前端·架构
水银嘻嘻3 小时前
web 自动化之 selenium+webdriver 环境搭建及原理讲解
前端·selenium·自动化
寧笙(Lycode)3 小时前
为什么使用Less替代原始CSS?
前端·css·less
m0_zj3 小时前
57.[前端开发-前端工程化]Day04-webpack插件模式-搭建本地服务器
前端·webpack·node.js
GoFly开发者4 小时前
GoFly企业版框架升级2.6.6版本说明(框架在2025-05-06发布了)
前端·javascript·vue.js
qq_392794484 小时前
前端缓存踩坑指南:如何优雅地解决浏览器缓存问题?
前端·缓存