前言

需要本地环境及测试代码可以参考👇面项目搭建本地的gpt业务

本地搭建属于自己的GPT(保姆级别教程)


效果展示

gpt效果图
gpt效果图2

提示:以下是本篇文章正文内容,下面案例可供参考

一、所需插件

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(保姆级别教程)

总结

不懂可以评论,制作不易。请大佬们动动发财的小手点点关注

Logo

欢迎加入DeepSeek 技术社区。在这里,你可以找到志同道合的朋友,共同探索AI技术的奥秘。

更多推荐