《从零开始DeepSeek R1搭建本地知识库问答系统》五:实现问答系统前端 UI 框架,基于 vue3 + typescript + ElementPlus

前言

最近推出的 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 流程项目。

猜你喜欢

转载自blog.csdn.net/RyanYui/article/details/146179346
今日推荐