前言

最近推出的 DeepSeek R1 异常火爆,我也想趁此机会捣鼓一下,实现 DeepSeek R1 本地化部署并搭建本地知识库问答系统,其中实现的思路如下:

  1. 使用 windows 11 WSL2,创建子系统Linux,并使用 Anaconda 创建 pythn 环境。
  2. 下载 DeepSeek R1 蒸馏模型,使用 Ollama 框架作为服务载体部署运行。
  3. 基于 LangChain 构建本地知识库问答 RAG 应用。
  4. 利用 FastApi 框架,搭建后端服务系统。
  5. 使用 vue3 + ElementPlus 作为前端ui框架,实现问答系统前端功能。(本章内容)
  6. 不依赖于 Langchain 框架,而选择 LightRAG 架构,构建 RAG 应用。

相信绝大部分的前端项目都是使用 Vue 或 React,python 写前端 web 框架毕竟不是主流。

在企业级项目中,绝大部分的做法是将大模型 RAG 模块单独写 Api,然后接入到现有的业务系统 server 端,再统一接口给前端调用,亦或者直接给前端调用。

上一章完成了 FastAPI 框架搭建 server 端系统。

server 端源代码 GitHub 地址:https://github.com/YuiGod/py-doc-qa-deepseek-server

本章开始着手搭建前端框架,实现对话聊天和文档管理等功能。

本章 vue 前端源代码 Github 地址:https://github.com/YuiGod/vue-doc-qa-chat

下面章节的内容,请结合源代码食用。

一、准备工作

还需要准备啥,Api 接口丢给前端小姐姐,跟她说按照 deepseek 官网的聊天界面效果做出来就好了。

啊对对对,就做成这样的界面,先这样,再这样,然后这样,最后这样。

vue-doc-qa-chat 预览

在这里插入图片描述

好了本章到此结束。(bushi)

在这里插入图片描述

二、 项目目录结构预览

  src
  ├─api                 # api接口
  │  ├─chat             # 聊天接口
  │  ├─chatSession      # 聊天历史管理接口
  │  └─documents        # 文档管理接口,包含向量化api
  ├─assets              # 静态资源文件
  ├─components          # 公共组件
  │  ├─Dialog           # 表单弹窗
  │  │  └─BaseDialog
  │  ├─Icon             # 图标扩展
  │  └─Loading          # 加载样式
  │      └─ChatLoading
  ├─enums               # 常用枚举
  ├─http                # http 封装
  │  ├─axios            # axios 封装,拦截器处理
  │  ├─fetch            # fetch 封装,拦截器处理
  │  ├─helper           # 内有取消请求封装,状态检查,错误处理
  │  └─types            # http ts 声明
  ├─layout              # 框架布局模块
  │  └─components
  │      └─base
  ├─router              # 路由管理
  ├─stores              # pinia store
  ├─styles              # 全局样式
  │  ├─element          # elementplus 样式
  │  └─markdown         # markdown 样式
  ├─utils               # 公共 utils
  │  └─markdownit       # markdown-it 封装,内有高亮代码,代码块样式美化
  └─views               # 项目所有页面
      ├─chat            # 对话聊天
      │  └─components   # 对话聊天子组件
      ├─documents       # 文档管理
      └─test            # markdown 样式预览

三、 思路整理

关于文档管理这种业务功能的逻辑我就不展开说了,都是基操。

重点是对话聊天部分的功能实现。

接收流式响应的 response,并且把内容提取出来。由于大语言模型返回的文本是有 markdown 语法的文本,所以需要将 markdown 文本解析转换成 html,代码块部分,需要做高亮处理。为了让内容效果好看些,需要提供好看的 markdown css 样式。

前两章我也提到过,想要完美的处理流式响应,Axios 是做不到的,需要用到 js 原生的 Fetch。
原因可以看看我之前的解释:为什么浏览器中的 Axios 不能直接处理流?

实现思路:

  1. 利用 Fetch 处理响应流,接收处理每个数据块提取出 assistant 回答的字符串文本。
  2. 利用 markdown-it 插件,将文本解析转换成 html 文本,利用 vue 响应式将文本输出到界面中。
  3. 代码块部分,用 markdown-it 的扩展插件 highlight.js 处理渲染高亮效果。
  4. 代码块部分,还需要做一个 header ,能够点击复制代码。

四、核心功能实现

1. Fetch 响应拦截器处理

我对 Fetch 进行了二次封装,添加了请求拦截器和响应拦截器,结构和 Axios 的拦截器一样。
封装代码位置在 src\http\fetch\config.ts 中:

src
  ├─http
  │  └─fetch
  │      └─config.ts     # fetch 拦截器处理

实现流式响应拦截器之前,先定义好类型,自定义 FetchConfig 并继承 Fetch 的原有 RequestInit
重点是添加回调函数,onReady()onStream()

// src\http\types\index.ts

/**
 * fetch 扩展配置参数,继承 fetch 原有 config
 */
export interface FetchConfig<D = any> extends RequestInit {
  baseURL?: string
  url?: string
  method?: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'
  params?: object
  data?: D
  timeout?: number
  cancel?: boolean
  /**
   * `onReady()`请求响应成功,准备流式输出
   * @param response 响应值 response
   * @returns
   */
  onReady?: (response: FetchResponse<D>) => void
  /**
   * `onChunk()` 开启 stream 流式响应并回调函数
   * @param reader 二进制字节流,一般用于下载文件流
   * @param chunk TextDecoder()解码后的文本流,一般用于文字流式输出
   * @returns
   */
  onStream?: (reader: Uint8Array<ArrayBufferLike>, chunk: string) => void
}

src\http\fetch\config.ts 中,配置响应拦截器:

/**
 * 响应拦截器
 * @returns 响应拦截器管理
 */
function responseInterceptor<T>(interceptors: InterceptorManager<FetchResponse<T>>) {
  let fetchConfig: FetchConfig
  // 添加响应拦截器,处理 Fetch 返回的数据,此时 response 还需要进一步处理
  interceptors.use({
    onFulfilled: response => {
      if (!response.ok) {
        return Promise.reject(response.json())
        // 如果不需要处理服务器返回的错误信息
        // return Promise.reject(new HttpError(response.status, ''))
      }

      const { config } = response
      config && (fetchConfig = config)

      // 文本流式响应单独处理
      if (config?.onStream) {
        return handleStream<T>(response, config)
      }

      const contentType = response.headers.get('content-type') || ''
      if (contentType.includes('application/json')) {
        return response.json()
      } else if (contentType.startsWith('text/')) {
        return response.text()
      } else if (contentType.includes('image/')) {
        return response.blob()
      } else if (contentType.includes('multipart/form-data')) {
        return response.formData()
      }
      // 其他类型默认返回文本
      return response.text()
    },
    onRejected: error => {
      // 处理除了 2xx 和 5xx 状态码的错误信息。
      return Promise.reject(new HttpError(error.code || 400, error.message))
    }
  })

  /**
   * 添加响应拦截器,处理最终的数据和错误信息。
   */
  interceptors.use({
    onFulfilled: response => {
      // 请求响应完成,在 AbortController 管理中移除该请求
      removePending(fetchConfig)
      return response
    },
    onRejected: async error => {
      // 处理服务器返回 5xx 的错误信息
      const response = await error
      // 统一处理 promise 链的 reject 错误。
      return Promise.reject(checkStatus(response.code, response.message))
    }
  })

  return interceptors
}

/**
 * 处理流式响应
 * @param Response response fetch返回的响应对象
 * @param Function onChunk 处理每个数据块的函数
 */
async function handleStream<T>(response: FetchResponse<T>, config: FetchConfig<T>) {
  if (!config.onStream) {
    return Promise.reject(checkStatus(701, false))
  }

  if (!response.body) {
    return Promise.reject(checkStatus(702, false))
  }
  
  const reader = response.body.getReader()
  const decoder = new TextDecoder()
  // 执行 onReady() 回调函数
  config.onReady && config.onReady(response)

  // 循环遍历获取二进制流
  while (true) {
    const { done, value } = await reader.read()
    if (done) break
    // 将二进制文本流解码,获取 ndjson 字符串行
    const chunk = decoder.decode(value, { stream: true })
    // 执行 onStream() 回调函数
    config.onStream(value, chunk)
  }

  return Promise.resolve({ code: 700, message: '流式响应完成!' })
}

上面的代码中,会判断 if (config?.onStream) ,如果添加了 onStream() 回调函数,就单独处理流式响应。

handleStream() 是对二进制流做初步处理。

const chunk = decoder.decode(value, { stream: true }) 是对 server 端返回的 json 二进制字符串流解码成字符串。

2. Api 调用 Fetch 并处理流响应

请求api中,通过添加 config 属性 { onReady, onStream } 让响应拦截器拦截并处理流。

// src\api\chat\index.ts

/**
 * Fetch 请求,chat对话内容
 * @param data data
 * @param onReady 回调函数,请求响应成功,准备流式输出
 * @param onStream 回调函数,开启 stream 流式响应并回调函数
 * @returns `Promise<ChatResponseType>`
 */
const chatApi = (data: ChatRequestType, onReady: OnReady<ChatResponseType>, onStream: OnStream): Promise<ChatResponseType> => {
  return http.fetchPostChat('/chat', data, { onReady, onStream })
}

接着可以在组件中,请求 api,编写 onStream() 回调函数来处理流式响应。

// src\views\chat\index.vue

/**
 * 开始对话,流式响应
 */
function startChatting() {

  ...

  // 请求参数
  const data = {
    model: 'deepseek-r1:7b',
    messages: {
      role: userChat.value.role,
      content: userChat.value.content
    },
    chat_session_id: chatSessionId.value,
    stream: true
  }
  let isThinking = false
  // 请求后台 chat
  chatApi(
    data,
    // 这里是 onReady() 回调函数
    () => {
      ...
    },
    // 这里 onStream() 回调函数,处理每一行的 chunk
    (_reader, chunk) => {
      // 可能一个 chunk 会返回多个 ndjson 行。正常来说是不会的,但为了防止万一
      // 通过 '\n' 来截取行
      const lines = chunk.split('\n').filter(line => line.trim())
      for (const line of lines) {
        if (line.trim() === '') {
          continue
        }
        const data = JSON.parse(line)
        const content = data.message.content as string

        // 截取 think 标签的内容
        if (content === '<think>') {
          isThinking = true
          continue
        }
        if (content === '</think>') {
          isThinking = false
          continue
        }

        // 将文本流字符串拼接,并传递给子组件 AssistantChat.vue
        if (isThinking) {
          assistantChat.value.think += content
        } else {
          assistantChat.value.content += content
        }
        
      }
    }
  )
}

3. 处理 markdown 语法的文本

大模型回答的文本,都是带有 markdown 语法的文本,将文本流传递给子组件 AssistantChat.vue后,将对这些文本进行处理,这里用到的是 markdown-it 来处理文本。

src\utils\markdownit\index.ts中,对 markdown-it 进行了封装。

  src
  ├─utils               # 公共 utils
  │  └─markdownit       # markdown-it 封装,内有高亮代码,代码块样式美化

封装代码如下:

// src\utils\markdownit\index.ts

import MarkdownIt, { type Options } from 'markdown-it'
import hljs from './hljsConfig'
import codeCopyPlugins from './codeCopyPlugins'

/**
 * 初始化 MarkdownIt
 * @param options MarkdownIt option 参数
 * @returns
 */
function MarkdownItRender(options: Options = {}) {
  // Options 配置
  const defaultOptions: Options = {
    html: true,
    linkify: true,
    breaks: true,
    xhtmlOut: true,
    typographer: true,
    // 代码块高亮
    highlight: (str, lang): any => {
      if (lang && hljs.getLanguage(lang)) {
        try {
          return `<pre><code class="hljs language-${lang}">${hljs.highlight(str, { language: lang, ignoreIllegals: true }).value}</code></pre>`
        } catch (e: any) {
          throw new Error(e)
        }
      }
      return `<pre><code class="hljs language-${lang}">${md.utils.escapeHtml(str)}</code></pre>`
    }
  }

  const MegertOptions = {
    ...defaultOptions,
    ...options
  }
  // 通过 use(codeCopyPlugins),引入 codeCopyPlugins 插件,使代码块添加 header 和复制代码功能。
  const md = new MarkdownIt(MegertOptions).use(codeCopyPlugins).disable('image')
  return md
}

export default MarkdownItRender

highlight.js 必须主动引入相关的 css 样式,并注册到 registerLanguage() 函数中,才能使代码块高亮显示。

// src\utils\markdownit\hljsConfig.ts

import hljs from 'highlight.js/lib/core'
import 'highlight.js/styles/github-dark.min.css'

import bash from 'highlight.js/lib/languages/bash'
import javascript from 'highlight.js/lib/languages/javascript'
import typescript from 'highlight.js/lib/languages/typescript'
import python from 'highlight.js/lib/languages/python'
import java from 'highlight.js/lib/languages/java'
import sql from 'highlight.js/lib/languages/sql'
import nginx from 'highlight.js/lib/languages/nginx'
import json from 'highlight.js/lib/languages/json'
import yaml from 'highlight.js/lib/languages/yaml'
import xml from 'highlight.js/lib/languages/xml'
import shell from 'highlight.js/lib/languages/shell'
import kotlin from 'highlight.js/lib/languages/kotlin'

hljs.registerLanguage('bash', bash)
hljs.registerLanguage('javascript', javascript)
hljs.registerLanguage('typescript', typescript)
hljs.registerLanguage('vue', typescript)
hljs.registerLanguage('python', python)
hljs.registerLanguage('java', java)
hljs.registerLanguage('sql', sql)
hljs.registerLanguage('nginx', nginx)
hljs.registerLanguage('json', json)
hljs.registerLanguage('yaml', yaml)
hljs.registerLanguage('xml', xml)
hljs.registerLanguage('shell', shell)
hljs.registerLanguage('kotlin', kotlin)

export default hljs

4. 代码块添加 header 和复制代码功能

利用 markdown-it use() 引入插件的方式,插件代码如下:

// src\utils\markdownit\codeCopyPlugins.ts

import type MarkdownIt from 'markdown-it'
import type { Renderer } from 'markdown-it/dist/markdown-it.min.js'
import ClipboardJS from 'clipboard'
import { escape } from 'lodash-es'

const clipboard = new ClipboardJS('.markdown-it-code-copy')

// 未 copy 时按钮的 innerHTML
const copyInnerHTML = `
  <svg aria-hidden="true" focusable="false" role="img" class="octicon octicon-copy" viewBox="0 0 16 16" width="12" height="12" fill="currentColor" style="display: inline-block; user-select: none; vertical-align: text-bottom; overflow: visible;"><path d="M0 6.75C0 5.784.784 5 1.75 5h1.5a.75.75 0 0 1 0 1.5h-1.5a.25.25 0 0 0-.25.25v7.5c0 .138.112.25.25.25h7.5a.25.25 0 0 0 .25-.25v-1.5a.75.75 0 0 1 1.5 0v1.5A1.75 1.75 0 0 1 9.25 16h-7.5A1.75 1.75 0 0 1 0 14.25Z"></path><path d="M5 1.75C5 .784 5.784 0 6.75 0h7.5C15.216 0 16 .784 16 1.75v7.5A1.75 1.75 0 0 1 14.25 11h-7.5A1.75 1.75 0 0 1 5 9.25Zm1.75-.25a.25.25 0 0 0-.25.25v7.5c0 .138.112.25.25.25h7.5a.25.25 0 0 0 .25-.25v-7.5a.25.25 0 0 0-.25-.25Z"></path></svg>
  <span>Copy</span>
`
// copy 后按钮的 innerHTML
const copiedInnerHTML = `
  <svg aria-hidden="true" focusable="false" role="img" class="octicon octicon-check" viewBox="0 0 16 16" width="12" height="12" fill="currentColor" style="display: inline-block; user-select: none; vertical-align: text-bottom; overflow: visible;"><path d="M13.78 4.22a.75.75 0 0 1 0 1.06l-7.25 7.25a.75.75 0 0 1-1.06 0L2.22 9.28a.751.751 0 0 1 .018-1.042.751.751 0 0 1 1.042-.018L6 10.94l6.72-6.72a.75.75 0 0 1 1.06 0Z"></path></svg>
  <span>Copied!</span>
`

clipboard.on('success', e => {
  const trigger = e.trigger
  e.clearSelection()

  trigger.innerHTML = copiedInnerHTML
  setTimeout(() => {
    trigger.innerHTML = copyInnerHTML
  }, 3000)
})

// 用正则提取出 code 的语言
const getCodeLangFragment = (htmlString: string) => {
  const regex = /<code class="hljs (language-([a-z]+))">/
  const match = htmlString.match(regex)
  return match?.[2] || ''
}

const renderCode = (renderer: Renderer.RenderRule): Renderer.RenderRule => {
  return (...args) => {
    const [tokens, idx] = args
    const content = escape(tokens[idx].content)
    const origRendered = renderer.apply(this, args)

    if (content.length === 0) return origRendered

    const lang = getCodeLangFragment(origRendered)

    return `
      <div class="code-enhance">
        <div class="code-enhance-header">
          <span>${lang}</span>
          <span class="markdown-it-code-copy code-enhance-copy" data-clipboard-text="${content}">
            ${copyInnerHTML}
          </span>
        </div>
        <div class="code-enhance-content">
          ${origRendered}
        </div>
      </div>
    `
  }
}

/**
 * markdown-it 的插件,添加代码语言显示和 copy 代码按钮
 */
export default (md: MarkdownIt) => {
  if (md.renderer.rules.code_block != null) {
    md.renderer.rules.code_block = renderCode(md.renderer.rules.code_block)
  }

  if (md.renderer.rules.fence != null) {
    md.renderer.rules.fence = renderCode(md.renderer.rules.fence)
  }
}

写好插件代码后,在 src\utils\markdownit\index.ts 中,通过 use() 引入该插件:

// src\utils\markdownit\index.ts

// 导入 codeCopyPlugins.ts。
const md = new MarkdownIt(MegertOptions).use(codeCopyPlugins).disable('image')

最后,在 main.ts 中导入该插件代码块部分的样式,注意导入样式顺序。

import '@/styles/markdown/plugins.scss'

// main.ts

import { createApp } from 'vue'
import { createPinia } from 'pinia'

import App from './App.vue'
import router from './router'

// 重置默认样式
import '@/styles/reset.scss'

// markdown 样式
import '@/styles/markdown/mdmdt-light.scss'
// markdown-it 插件样式,这里是关于插件代码块的样式。
import '@/styles/markdown/plugins.scss'

// elementplus 自定义样式
import '@/styles/index.scss'
// elementplus 图标
import * as ElementPlusIconsVue from '@element-plus/icons-vue'

const app = createApp(App)

app.use(createPinia())
app.use(router)

// elementplus 图标注册
for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
  app.component(key, component)
}

app.mount('#app')

5. 在子组件 AssistantChat.vue 引入 markdown-it

src\views\chat\components\AssistantChat.vue 组件中,引入 markdown-it,代码如下:

<script setup lang="ts">
import DOMPurify from 'dompurify'
import MarkdownItRender from '@/utils/markdownit'

/** props */
interface Props {
  /** 文本内容 */
  content?: string
}
const { content = '' } = defineProps<Props>()

...

/**
 * 初始化 MarkdownIt 并将文本流传进去
 */
const md = MarkdownItRender()

/**
 * 输出 md 转换后的 html
 */
const renderedContent = computed(() => {
  // XSS 防护
  return DOMPurify.sanitize(md.render(content))
})

</script>

<template>
  ...
  <div class="mdmdt">
    <div v-html="renderedContent"></div>
  </div>
  ...
</template>

将 markdown 语法文本转换成 html,使用 dompurify 插件做好 XSS 防护。

5. 最终效果

在这里插入图片描述

6. 关于 markdown 样式

markdown 样式存放在 src\styles\markdown 目录下:

  src
  ├─styles
  │  └─markdown         # markdown 样式

可以从 Themes Gallery — Typora 网站下载 markdown 的 css 样式。

但需要做一些修改,下载喜欢的 css 样式后,复制到 src\styles\markdown 目录下,将文件后缀 css 改成 scss。在文件顶层套上一个自定义的 class。顶层加上 class 是为了防止css样式污染。

例如,我下载的是 mdmdt-light.css,改成 mdmdt-light.scss,然后打开文件,顶部套上 .mdmdt class:

// src\styles\markdown\mdmdt-light.scss

.mdmdt {
  ...
}

下载的 css 样式,关于 pre 属性部分样式,可能需要删除。否则会影响markdown-it插件代码块部分的样式。

接着在 main.ts 中导入该样式:import '@/styles/markdown/mdmdt-light.scss'。注意导入样式的顺序。

import { createApp } from 'vue'
import { createPinia } from 'pinia'

import App from './App.vue'
import router from './router'

// 重置默认样式
import '@/styles/reset.scss'
// markdown 样式
import '@/styles/markdown/mdmdt-light.scss'
// markdown-it 插件样式,这里是关于插件代码块的样式。
import '@/styles/markdown/plugins.scss'

// elementplus 自定义样式
import '@/styles/index.scss'
// elementplus 图标
import * as ElementPlusIconsVue from '@element-plus/icons-vue'

const app = createApp(App)

app.use(createPinia())
app.use(router)

// elementplus 图标注册
for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
  app.component(key, component)
}

app.mount('#app')

最后,在 AssistantChat.vue 添加该自定义 class:mdmdt

// src\views\chat\components\AssistantChat.vue
...
<div class="mdmdt">
  <div v-html="renderedContent"></div>
</div>
...

既然防止样式污染,为什么不在组件中引入 css ?

这是因为,markdown-it 生成的 html 代码,使用 v-html 指令嵌入的 html 代码,是不会生成该组件样式 scoped 的,也就是 div 没有独特的属性选择器(例如 data-v-f3f3eg9 )。

所以只能从全局导入 css,但为了不让样式污染,最好在样式文件 css 顶层加上自定义的 class。

结语

vue 前端部分也已经搞定了。

本章 vue 前端源代码 Github 地址:https://github.com/YuiGod/vue-doc-qa-chat,欢迎 Start。

目前网上关于 LLM 模型的 UI 框架部分,大多数都是使用 python 来写,很少有与我们主流的 vue 或 react UI 框架结合。对于原有 Web 项目,想要嵌入大模型聊天功能来说,会比较困难。

所以才有了这次的教程,只要有 Api 接口,我们前端就可以根据需求做出炫酷的界面效果,最后只需要调用 Api 接口来获取数据即可显示在界面上。

如果有这样需求的前端彦祖亦非们,可以少走弯路啦。

下一章将尝试不依赖于 Langchain 框架,而选择 LightRAG 架构,构建 RAG 应用。

当然,后面还会添加 LangGraph Tools 工具,构建 Agents 。做一个完整的 Agents 流程项目。

Logo

欢迎加入 MCP 技术社区!与志同道合者携手前行,一同解锁 MCP 技术的无限可能!

更多推荐