首页 最新 热门 推荐

  • 首页
  • 最新
  • 热门
  • 推荐

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

  • 25-04-20 23:21
  • 4367
  • 10858
juejin.cn

前言

最近推出的 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 地址:github.com/YuiGod/py-d…

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

本章 vue 前端源代码 Github 地址:github.com/YuiGod/vue-…

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

一、准备工作

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

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

preview_1.gif

preview_2.gif

preview_3.gif

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

CF1D4F91C5842425E33C10FFBD6CA408_0.jpg

因为git压缩了帧率,看起来不够流畅。可点击这里下载预览视频观看:预览视频。

二、 项目目录结构预览

bash
代码解读
复制代码
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 中:

arduino
代码解读
复制代码
src ├─http │ └─fetch │ └─config.ts # fetch 拦截器处理

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

typescript
代码解读
复制代码
// src\http\types\index.ts /** * fetch 扩展配置参数,继承 fetch 原有 config */ export interface FetchConfigany> 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 中,配置响应拦截器:

typescript
代码解读
复制代码
/** * 响应拦截器 * @returns 响应拦截器管理 */ function responseInterceptor(interceptors: InterceptorManager<FetchResponse>) { 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 } 让响应拦截器拦截并处理流。

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

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

typescript
代码解读
复制代码
// 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 进行了封装。

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

封装代码如下:

typescript
代码解读
复制代码
// 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() 函数中,才能使代码块高亮显示。

typescript
代码解读
复制代码
// 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() 引入插件的方式,插件代码如下:

typescript
代码解读
复制代码
// 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() 引入该插件:

typescript
代码解读
复制代码
// src\utils\markdownit\index.ts // 导入 codeCopyPlugins.ts。 const md = new MarkdownIt(MegertOptions).use(codeCopyPlugins).disable('image')

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

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

typescript
代码解读
复制代码
// 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,代码如下:

typescript
代码解读
复制代码
<template> ... <div class="mdmdt"> <div v-html="renderedContent">div> div> ... template>

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

5. 最终效果

QQ2025310-2378-HD.gif

6. 关于 markdown 样式

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

arduino
代码解读
复制代码
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:

scss
代码解读
复制代码
// src\styles\markdown\mdmdt-light.scss .mdmdt { ... }

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

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

typescript
代码解读
复制代码
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

typescript
代码解读
复制代码
// src\views\chat\components\AssistantChat.vue ...
class="mdmdt"> <div v-html="renderedContent">div>
...

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

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

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

结语

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

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

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

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

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

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

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

注:本文转载自juejin.cn的YuiGod的文章"https://juejin.cn/post/7480009518175567907"。版权归原作者所有,此博客不拥有其著作权,亦不承担相应法律责任。如有侵权,请联系我们删除。
复制链接
复制链接
相关推荐
发表评论
登录后才能发表评论和回复 注册

/ 登录

评论记录:

未查询到任何数据!
回复评论:

分类栏目

后端 (14832) 前端 (14280) 移动开发 (3760) 编程语言 (3851) Java (3904) Python (3298) 人工智能 (10119) AIGC (2810) 大数据 (3499) 数据库 (3945) 数据结构与算法 (3757) 音视频 (2669) 云原生 (3145) 云平台 (2965) 前沿技术 (2993) 开源 (2160) 小程序 (2860) 运维 (2533) 服务器 (2698) 操作系统 (2325) 硬件开发 (2491) 嵌入式 (2955) 微软技术 (2769) 软件工程 (2056) 测试 (2865) 网络空间安全 (2948) 网络与通信 (2797) 用户体验设计 (2592) 学习和成长 (2593) 搜索 (2744) 开发工具 (7108) 游戏 (2829) HarmonyOS (2935) 区块链 (2782) 数学 (3112) 3C硬件 (2759) 资讯 (2909) Android (4709) iOS (1850) 代码人生 (3043) 阅读 (2841)

热门文章

101
推荐
关于我们 隐私政策 免责声明 联系我们
Copyright © 2020-2025 蚁人论坛 (iYenn.com) All Rights Reserved.
Scroll to Top