使用 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
)。
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"><inputclassName="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 內容,并寫入 UIconst 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><selectvalue={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"><ReactMarkdownremarkPlugins={[remarkGfm]}components={{code(props: any) {const { inline, className, children, ...rest } = props;const match = /language-(\w+)/.exec(className || '');if (!inline && match) {return (<SyntaxHighlighterstyle={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;