使用 React 搭建一个现代化的聊天界面,支持与 Ollama 本地部署的大语言模型进行多轮对话。界面清爽、功能完整,支持 Markdown 渲染、代码高亮、
启动时自动请求 http://localhost:11434/api/tags
获取所有本地模型。
允许用户通过下拉框动态切换聊天使用的模型。
聊天上下文由历史消息 messages
组成,发送请求时一并传入。
用户每次发送内容后,bot 的响应将基于历史记录生成。
使用 ReadableStream
实现逐段渲染。
区块被识别并自动隐藏,直到关闭 后再更新 UI。
借助 react-markdown
+ remark-gfm
支持 GitHub 风格 Markdown。
使用 react-syntax-highlighter
实现代码块高亮显示,自动识别语言。
使用 Tailwind CSS 快速构建布局。
检测 HTML dark
类名切换对应代码主题(oneLight
/ oneDark
)。
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) => void; onSend: () => void };
const ChatInput: React.FC = React.memo(({ value, onChange, onSend }) => (
e.key === 'Enter' && onSend()}
/>
));
const ChatWindow: React.FC = () => {
const [models, setModels] = useState([]);
const [selectedModel, setSelectedModel] = useState('');
const [messages, setMessages] = useState([
{ text: '你好,我是 Ollama!请选择模型后开始聊天。', sender: 'bot' },
]);
const [input, setInput] = useState('');
const [isThinking, setIsThinking] = useState(false);
const messagesEndRef = useRef(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' }]);
// 清洗 … 的工具
const cleanThink = (text: string) => text.replace(/[\s\S]*?<\/think>/g, '');
let fullText = '';
let thinkOpen = false; // 标记是否在 … 区间
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('')) {
thinkOpen = true;
setIsThinking(true);
}
fullText += c;
// 检测思考结束
if (c.includes(' ')) {
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),那也一次性更新
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 (
{/* 模型选择 */}
{/* 聊天记录 */}
{/* 聊天记录渲染 */}
{messages.map((msg, i) => (
{msg.sender === 'bot' ? (
{String(children).replace(/\n$/, '')}
);
}
return (
{children}
);
}
}}
>
{msg.text}
) : (
{msg.text}
)}
))}
{isThinking && 正在思考中…}
{/* 输入区 */}
setInput(e.target.value)} onSend={handleSend} />
);
};
export default ChatWindow;