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