《从零开始DeepSeek R1搭建本地知识库问答系统》五:实现问答系统前端 UI 框架,基于 vue3 + typescript + ElementPlus
前言
最近推出的 DeepSeek R1 异常火爆,我也想趁此机会捣鼓一下,实现 DeepSeek R1 本地化部署并搭建本地知识库问答系统,其中实现的思路如下:
- 使用 windows 11 WSL2,创建子系统Linux,并使用 Anaconda 创建 pythn 环境。
- 下载 DeepSeek R1 蒸馏模型,使用 Ollama 框架作为服务载体部署运行。
- 基于 LangChain 构建本地知识库问答 RAG 应用。
- 利用 FastApi 框架,搭建后端服务系统。
- 使用 vue3 + ElementPlus 作为前端ui框架,实现问答系统前端功能。(本章内容)
- 不依赖于 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 不能直接处理流?
实现思路:
- 利用 Fetch 处理响应流,接收处理每个数据块提取出 assistant 回答的字符串文本。
- 利用 markdown-it 插件,将文本解析转换成 html 文本,利用 vue 响应式将文本输出到界面中。
- 代码块部分,用 markdown-it 的扩展插件 highlight.js 处理渲染高亮效果。
- 代码块部分,还需要做一个 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 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) => void /** * `onChunk()` 开启 stream 流式响应并回调函数 * @param reader 二进制字节流,一般用于下载文件流 * @param chunk TextDecoder()解码后的文本流,一般用于文字流式输出 * @returns */ onStream?: (reader: Uint8Array, chunk: string) => void }
在 src\http\fetch\config.ts 中,配置响应拦截器:
/** * 响应拦截器 * @returns 响应拦截器管理 */ function responseInterceptor(interceptors: InterceptorManager) { 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(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(response: FetchResponse, config: FetchConfig) { 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` */ const chatApi = (data: ChatRequestType, onReady: OnReady, onStream: OnStream): Promise => { 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 === '') { isThinking = true continue } if (content === '') { 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 `${lang}">${hljs.highlight(str, { language: lang, ignoreIllegals: true }).value}` } catch (e: any) { throw new Error(e) } } return `${lang}">${md.utils.escapeHtml(str)}` } } 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 = ` Copy ` // copy 后按钮的 innerHTML const copiedInnerHTML = ` Copied! ` 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 = // 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 ` ${lang} ${content}"> ${copyInnerHTML} ${origRendered} ` } } /** * 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,代码如下:
import DOMPurify from 'dompurify' import MarkdownItRender from '@/utils/markdownit' /** props */ interface Props { /** 文本内容 */ content?: string } const { content = '' } = defineProps() ... /** * 初始化 MarkdownIt 并将文本流传进去 */ const md = MarkdownItRender() /** * 输出 md 转换后的 html */ const renderedContent = computed(() => { // XSS 防护 return DOMPurify.sanitize(md.render(content)) }) ......
将 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 ......
既然防止样式污染,为什么不在组件中引入 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 流程项目。