
使用 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;