【GPT前端实用系列】流式请求+渲染内容兼容deepseek返回think思考标签(保姆级别教程)_vue think标签
文章目录
- 前言
- 效果展示
- 一、所需插件
- 二、实现思路
- 三、搭建流式请求
-
-
- 基本是用方法
-
- 四、markDown渲染详细版本
- 五、页面完成示例
- git地址
- 总结
前言
需要本地环境及测试代码可以参考👇面项目搭建本地的gpt业务
本地搭建属于自己的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\' // 你封装的 hookinterface 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\' // 你封装的 hookinterface 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(/]*>/gi, \'\\n:::think\\n\') .replace(//gi, \'\\n:::\\n\') } catch (error) { console.error(\'Error replacing think:\', error) return str }}
前端示例
const replaceThink = (str: string) => { try { return str .replace(/]*>/gi, \'\\n:::think\\n\') .replace(//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: OpenAIlet 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 = 1const replaceThink = (str: string) => { try { return str .replace(/]*>/gi, \'\\n:::think\\n\') .replace(//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标签:开始标签 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\' // 你封装的 hookimport 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(/]*>/gi, \'\\n:::think\\n\') .replace(//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> {/* {gptReply || \'等待响应...\'} */} <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(保姆级别教程)
总结
不懂可以评论,制作不易。请大佬们动动发财的小手点点关注