
【GPT前端实用系列】流式请求+渲染内容兼容deepseek返回think思考标签(保姆级别教程)
需要本地环境及测试代码可以参考👇面项目搭建本地的gpt业务本地搭建属于自己的GPT(保姆级别教程)lucide-react:图标库。(非必要库,展示代码需要可自信更改)二、实现思路我的思路核心是将think标签进行替换成:::think内容 :::形式,使用remark-directive进行解析成标签,再使用unist-util-visit进行映射组件,在与react-markdown中 co
文章目录
前言
需要本地环境及测试代码可以参考👇面项目搭建本地的gpt业务
效果展示
提示:以下是本篇文章正文内容,下面案例可供参考
一、所需插件
lucide-react:图标库。(非必要库,展示代码需要可自信更改)
npm install react-markdown lucide-react unist-util-visit remark-directive
二、实现思路
我的思路核心是将think标签进行替换成:::think 内容 ::: 形式,使用remark-directive进行解析成标签,再使用unist-util-visit进行映射组件,在与react-markdown中 components定义组件进行实现
三、搭建流式请求
hook主要功能,展示当前状态、手动取消、实时接收回调消息、ts类型支持
不想看代码的兄弟直接看下面是用方法即可
import { useEffect, useRef } from 'react'
export type SSEStatus =
| 'idle'
| 'connecting'
| 'message'
| 'error'
| 'closed'
| 'aborted'
interface UsePostSSEParams<TRequest = any, TResponse = any> {
url: string
body: TRequest
onMessage: (msg: {
status: SSEStatus
data: TResponse | string | null
}) => void
autoStart?: boolean
}
export function usePostSSE<TRequest = any, TResponse = any>({
url,
body,
onMessage,
autoStart = true,
}: UsePostSSEParams<TRequest, TResponse>) {
const controllerRef = useRef<AbortController | null>(null)
const start = () => {
const controller = new AbortController()
controllerRef.current = controller
fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Accept: 'text/event-stream',
},
body: JSON.stringify(body),
signal: controller.signal,
})
.then((response) => {
if (!response.ok)
throw new Error(`HTTP error! status: ${response.status}`)
const reader = response.body?.getReader()
const decoder = new TextDecoder('utf-8')
let buffer = ''
const read = () => {
reader
?.read()
.then(({ done, value }) => {
if (done) {
onMessage({ status: 'closed', data: null })
return
}
buffer += decoder.decode(value, { stream: true })
const lines = buffer.split('\n')
buffer = lines.pop() || ''
for (let line of lines) {
line = line.trim()
if (line.startsWith('data:')) {
const jsonData = line.slice(5).trim()
try {
const parsed = JSON.parse(jsonData)
onMessage({ status: 'message', data: parsed })
} catch {
onMessage({ status: 'error', data: jsonData })
}
}
}
read()
})
.catch((err) => {
onMessage({ status: 'error', data: err.message })
})
}
onMessage({ status: 'connecting', data: null })
read()
})
.catch((err) => {
onMessage({ status: 'error', data: err.message })
})
}
const stop = () => {
controllerRef.current?.abort()
onMessage({ status: 'aborted', data: null })
}
useEffect(() => {
if (autoStart) start()
return () => stop() // Clean up on unmount
}, [])
return { start, stop }
}
'use client'
import React, { useState } from 'react'
import { usePostSSE, SSEStatus } from '@/hooks/usePostSSE' // 你封装的 hook
interface GPTStreamResponse {
sendUserInfo: {
user_id: number
message: string
sender_type: 'user'
}
sender_type: 'gpt'
message: string
}
export default function ChatSSE() {
const [gptReply, setGptReply] = useState('')
const [status, setStatus] = useState<SSEStatus>('idle')
const { stop } = usePostSSE<{ message: string }, GPTStreamResponse>({
url: '/api/chat',
body: {
message: `帮我写个描述天气的好语句,50字`,
}, // 用户输入的消息
onMessage: ({ status, data }) => {
setStatus(status)
if (status === 'message' && data && typeof data === 'object') {
const gptData = data as GPTStreamResponse
setGptReply((prev) => prev + gptData.message)
}
},
})
return (
<div className="w-screen h-screen flex">
<div className="flex-1 flex flex-col h-screen items-center">
<div className="p-4 border rounded min-h-[100px]">
{gptReply || '等待响应...'}
</div>
<p className="text-sm mt-2 text-gray-500">状态:{status}</p>
<button
className="mt-4 px-4 py-2 rounded bg-red-500 text-white hover:bg-red-600"
onClick={stop}
>
停止生成
</button>
</div>
</div>
)
}
基本是用方法
import React, { useState } from 'react'
import { usePostSSE, SSEStatus } from '@/hooks/usePostSSE' // 你封装的 hook
interface GPTStreamResponse {
sendUserInfo: {
user_id: number
message: string
sender_type: 'user'
}
sender_type: 'gpt'
message: string
}
export default function ChatSSE() {
const [gptReply, setGptReply] = useState('')
const [status, setStatus] = useState<SSEStatus>('idle')
const { stop } = usePostSSE<{ message: string }, GPTStreamResponse>({
url: '/api/chat',
body: {
message: `帮我写个描天气的100字文案`,
}, // 用户输入的消息
onMessage: ({ status, data }) => {
setStatus(status)
if (status === 'message' && data && typeof data === 'object') {
const gptData = data as GPTStreamResponse
setGptReply((prev) => prev + gptData.message)
}
},
})
return (
<div className="w-screen h-screen flex">
<div className="flex-1 flex flex-col h-screen items-center">
<h2 className="text-xl font-semibold mb-2">与 GPT 的对话</h2>
<div className="p-4 border rounded min-h-[100px]">
{gptReply || '等待响应...'}
</div>
<p className="text-sm mt-2 text-gray-500">状态:{status}</p>
<button
className="mt-4 px-4 py-2 rounded bg-red-500 text-white hover:bg-red-600"
onClick={stop}
>
停止生成
</button>
</div>
</div>
)
}
四、markDown渲染详细版本
1.搭建组件Markdown,及加载所需插件
这里就简单实现一个组件
import { FC } from 'react'
import ReactMarkdown from 'react-markdown'
import rehypeHighlight from 'rehype-highlight'
import 'highlight.js/styles/atom-one-dark.css'
import remarkDirective from 'remark-directive'
const Markdown: FC<{ content: string }> = ({ content }) => {
return (
<ReactMarkdown
remarkPlugins={[remarkDirective]}
rehypePlugins={[rehypeHighlight]}
>
{content}
</ReactMarkdown>
)
}
export default Markdown
2.定义内容替换函数
我的建议是让后端进行处理,因为deepseek的思考一般是不存入数据库的。同时think标签是直接返回的还是比较好处理的。如果不处理咱们前端也可以进行处理
const replaceThink = (str: string) => {
try {
return str
.replace(/<think[^>]*>/gi, '\n:::think\n')
.replace(/<\/think>/gi, '\n:::\n')
} catch (error) {
console.error('Error replacing think:', error)
return str
}
}
前端示例
const replaceThink = (str: string) => {
try {
return str
.replace(/<think[^>]*>/gi, '\n:::think\n')
.replace(/<\/think>/gi, '\n:::\n')
} catch (error) {
console.error('Error replacing think:', error)
return str
}
}
const { stop } = usePostSSE<{ message: string }, GPTStreamResponse>({
url: '/api/chat',
body: {
message: `帮我写个描天气的100字文案`,
}, // 用户输入的消息
onMessage: ({ status, data }) => {
setStatus(status)
if (status === 'message' && data && typeof data === 'object') {
const gptData = data as GPTStreamResponse
setGptReply((prev) => prev + replaceThink(gptData.message))
}
},
})
next后端示例
import { NextRequest } from 'next/server'
import OpenAI from 'openai'
import { mysql, redis } from '@/utils/db'
let openai: OpenAI
let model: string = 'gpt-3.5-turbo'
if (process.env.LOC_GPT_URL) {
openai = new OpenAI({
baseURL: process.env.LOC_GPT_URL,
})
model = 'deepseek-r1:1.5b'
} else {
openai = new OpenAI({
baseURL: process.env.OPENAI_BASE_URL || 'https://api.chatanywhere.tech',
apiKey: process.env.OPENAI_API_KEY || '',
})
}
const pro = {
role: 'system',
content: '你是一个编程助手',
}
const userId = 1
const replaceThink = (str: string) => {
try {
return str
.replace(/<think[^>]*>/gi, '\n:::think\n')
.replace(/<\/think>/gi, '\n:::\n')
} catch (error) {
console.error('Error replacing think:', error)
return str
}
}
async function getChatRedisHistory(key: string) {
try {
const redis_history = (await redis.get(key)) as string
const list = JSON.parse(redis_history)
return list
} catch (e) {
return []
}
}
export async function POST(request: NextRequest) {
try {
const body = await request.json()
const message = body.message
if (!message) {
return new Response(JSON.stringify({ error: 'Message is required' }), {
status: 400,
headers: { 'Content-Type': 'application/json' },
})
}
const redis_history =
(await getChatRedisHistory(`user_${userId}_chatHistory`)) || []
redis_history.push({ role: 'user', content: message })
redis_history.unshift(pro)
const res = await mysql.gpt_chat_history.create({
data: { user_id: 1, message, sender_type: 'user' },
})
const stream = new ReadableStream({
async start(controller) {
try {
const completionStream = await openai.chat.completions.create({
model,
messages: redis_history,
stream: true,
})
let obj: any = {
user_id: 1,
sender_type: 'gpt',
message: '',
}
// think 标签处理缓存
for await (const chunk of completionStream) {
let content = chunk.choices[0]?.delta?.content || ''
if (!content) continue
console.log('content', content)
const text =replaceThink(content)
// 处理think标签:开始标签 <think>
obj.message += text
// 非think标签内容,正常返回
controller.enqueue(
new TextEncoder().encode(
`data: ${JSON.stringify({
sendUserInfo: res,
sender_type: 'gpt',
message: text,
})}\n\n`
)
)
}
await mysql.gpt_chat_history.create({ data: obj })
redis_history.push({ role: 'system', content: obj.message })
redis.set(
'user_1_chatHistory',
JSON.stringify(redis_history.slice(-10))
)
} catch (error: any) {
console.error('OpenAI API error:', error)
controller.enqueue(
new TextEncoder().encode(
`data: ${JSON.stringify({ error: error.message })}\n\n`
)
)
} finally {
controller.close()
}
},
})
return new Response(stream, {
headers: {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
Connection: 'keep-alive',
'Access-Control-Allow-Origin': '*',
},
})
} catch (error: any) {
return new Response(JSON.stringify({ error: error.message }), {
status: 500,
headers: { 'Content-Type': 'application/json' },
})
}
}
组件实现
节点替换处理
上述操作把think转换成了:::think了,remarkDirective插件又能转换成标签,我们只需要是用visit访问根节点把Directive扩展类型进行处理替换即可
Directive数据类型结构
type Directive = Content & {
type: 'textDirective' | 'leafDirective' | 'containerDirective'
name: string
attributes?: Record<string, string | number | boolean>
data?: Data
}
那么我的实现方式就是这样
import { Node, Data } from 'unist'
import { Root, Content } from 'mdast'
// 扩展 Directive 节点类型
type Directive = Content & {
type: 'textDirective' | 'leafDirective' | 'containerDirective'
name: string
attributes?: Record<string, string | number | boolean>
data?: Data
}
const remarkCustomDirectives = () => {
return (tree: Root) => {
visit<Root, Directive>(tree, (node: any) => {
if (
node.type === 'textDirective' ||
node.type === 'leafDirective' ||
node.type === 'containerDirective'
) {
if (node.name === 'think') {
node.data = {
...node.data,
hName: 'ThinkBlock',
hProperties: {},
}
}
}
})
}
}
注册展开插件
展开组件
import { FC, ReactNode } from 'react'
const ThinkBlock: FC<{ children?: ReactNode }> = ({ children }) => (
<details open className="mb-4 max-w-full break-words">
<summary className="text-sm text-gray-400 cursor-default ml-2">
思考中
</summary>
<div className="mt-1 text-sm text-gray-500 border-l-2 border-gray-300 pl-3 ml-3">
{children}
</div>
</details>
)
注册
import { FC, ReactNode } from 'react'
import ReactMarkdown, { Components } from 'react-markdown'
import rehypeHighlight from 'rehype-highlight'
import 'highlight.js/styles/atom-one-dark.css'
import remarkDirective from 'remark-directive'
import { visit } from 'unist-util-visit'
import { Node, Data } from 'unist'
import { Root, Content } from 'mdast'
// 扩展 Directive 节点类型
type Directive = Content & {
type: 'textDirective' | 'leafDirective' | 'containerDirective'
name: string
attributes?: Record<string, string | number | boolean>
data?: Data
}
const remarkCustomDirectives = () => {
return (tree: Root) => {
visit<Root, Directive>(tree, (node: any) => {
if (
node.type === 'textDirective' ||
node.type === 'leafDirective' ||
node.type === 'containerDirective'
) {
if (node.name === 'think') {
node.data = {
...node.data,
hName: 'ThinkBlock',
hProperties: {},
}
}
}
})
}
}
const ThinkBlock: FC<{ children?: ReactNode }> = ({ children }) => (
<details open className="mb-4 max-w-full break-words">
<summary className="text-sm text-gray-400 cursor-default ml-2">
思考中
</summary>
<div className="mt-1 text-sm text-gray-500 border-l-2 border-gray-300 pl-3 ml-3">
{children}
</div>
</details>
)
const Markdown: FC<{ content: string }> = ({ content }) => {
return (
<ReactMarkdown
remarkPlugins={[remarkDirective, remarkCustomDirectives]}
rehypePlugins={[rehypeHighlight]}
components={
{
ThinkBlock,
} as Components
}
>
{content}
</ReactMarkdown>
)
}
export default Markdown
到此已经完成了
补充下code基本样式,随便gpt生成的样式丑勿怪
import { FC, ReactNode } from 'react'
import ReactMarkdown, { Components } from 'react-markdown'
import rehypeHighlight from 'rehype-highlight'
import { Terminal } from 'lucide-react'
import 'highlight.js/styles/atom-one-dark.css'
import CopyButton from './CopyButton'
import { visit } from 'unist-util-visit'
import remarkDirective from 'remark-directive'
import { Node, Data } from 'unist'
import { Root, Content } from 'mdast'
// 扩展 Directive 节点类型
type Directive = Content & {
type: 'textDirective' | 'leafDirective' | 'containerDirective'
name: string
attributes?: Record<string, string | number | boolean>
data?: Data
}
// 扩展 Code 节点类型
interface CodeNode extends Node {
lang?: string
meta?: string
data?: Data & {
meta?: string
}
}
const remarkCustomDirectives = () => {
return (tree: Root) => {
visit<Root, Directive>(tree, (node: any) => {
if (
node.type === 'textDirective' ||
node.type === 'leafDirective' ||
node.type === 'containerDirective'
) {
if (node.name === 'think') {
node.data = {
...node.data,
hName: 'ThinkBlock',
hProperties: {},
}
}
}
})
}
}
const ThinkBlock: FC<{ children?: ReactNode }> = ({ children }) => (
<details open className="mb-4 max-w-full break-words">
<summary className="text-sm text-gray-400 cursor-default ml-2">
思考中
</summary>
<div className="mt-1 text-sm text-gray-500 border-l-2 border-gray-300 pl-3 ml-3">
{children}
</div>
</details>
)
const Markdown: FC<{ content: string }> = ({ content }) => {
return (
<ReactMarkdown
remarkPlugins={[remarkCustomDirectives, remarkDirective]}
rehypePlugins={[rehypeHighlight]}
components={
{
ThinkBlock,
pre: ({ children }) => <pre className="not-prose">{children}</pre>,
code: ({ node, className, children, ...props }) => {
const codeNode = node as CodeNode
const match = /language-(\w+)/.exec(className || '')
if (match) {
const lang = match[1]
const id = `code-${Math.random().toString(36).substr(2, 9)}`
return (
<div className="not-prose rounded-mdoverflow-x-auto">
<div className="flex h-12 items-center justify-between bg-zinc-100 px-4 dark:bg-zinc-900">
<div className="flex items-center gap-2">
<Terminal size={18} />
<p className="text-sm text-zinc-600 dark:text-zinc-400">
{codeNode?.data?.meta || lang}
</p>
</div>
<CopyButton id={id} />
</div>
<div className="overflow-x-auto w-10/12 box-border">
<div id={id} className="p-4">
<code className={className} {...props}>
{children}
</code>
</div>
</div>
</div>
)
}
return (
<code
{...props}
className="not-prose rounded bg-gray-100 px-1 dark:bg-zinc-900"
>
{children}
</code>
)
},
} as Components
}
>
{content}
</ReactMarkdown>
)
}
export default Markdown
五、页面完成示例
'use client'
import React, { useState } from 'react'
import { usePostSSE, SSEStatus } from '@/hooks/usePostSSE' // 你封装的 hook
import Markdown from '@/app/components/markDown'
interface GPTStreamResponse {
sendUserInfo: {
user_id: number
message: string
sender_type: 'user'
}
sender_type: 'gpt'
message: string
}
export default function ChatSSE() {
const [gptReply, setGptReply] = useState('')
const [status, setStatus] = useState<SSEStatus>('idle')
const replaceThink = (str: string) => {
try {
return str
.replace(/<think[^>]*>/gi, '\n:::think\n')
.replace(/<\/think>/gi, '\n:::\n')
} catch (error) {
console.error('Error replacing think:', error)
return str
}
}
const { stop } = usePostSSE<{ message: string }, GPTStreamResponse>({
url: '/api/chat',
body: {
message: `帮我写个描天气的100字文案`,
}, // 用户输入的消息
onMessage: ({ status, data }) => {
setStatus(status)
if (status === 'message' && data && typeof data === 'object') {
const gptData = data as GPTStreamResponse
setGptReply((prev) => prev + replaceThink(gptData.message))
}
},
})
return (
<div className="w-screen h-screen flex">
<div className="flex-1 flex flex-col h-screen items-center">
<h2 className="text-xl font-semibold mb-2">与 GPT 的对话</h2>
<div className="w-10/12 flex-1 overflow-y-auto flex flex-col custom-scrollbar">
<Markdown content={gptReply}></Markdown>
</div>
{/* <div className="p-4 border rounded min-h-[100px]">
{gptReply || '等待响应...'}
</div> */}
<p className="text-sm mt-2 text-gray-500">状态:{status}</p>
<button
className="mt-4 px-4 py-2 rounded bg-red-500 text-white hover:bg-red-600"
onClick={stop}
>
停止生成
</button>
</div>
</div>
)
}
git地址
教学地址:gitee地址
https://gitee.com/dabao1214/csdn-gpt
内涵next后端不会请参看
本地搭建属于自己的GPT(保姆级别教程)
总结
不懂可以评论,制作不易。请大佬们动动发财的小手点点关注
更多推荐
所有评论(0)