整理不易,请不要吝啬你的赞和收藏。
1. 前言
这篇文章是 Spring AI Q&A 系统的前端实现。这篇文章将介绍如何快速搭建一个基于 vue3 + ElementPlus 的前端项目,vue3 项目的目录结构介绍,如何在前端实现流式响应,如何高亮显示代码等。
效果展示:
2. 前提条件
-
后端实现:
【Spring AI】基于SpringAI+Vue3+ElementPlus的Q&A系统实现(后端)-CSDN博客文章浏览阅读762次,点赞26次,收藏24次。这篇文章将介绍如何基于 RAG 技术,使用 SpringAI + Vue3 + ElementPlus 实现一个 Q&A 系统。本文使用 deepseek 的 DeepSeek-V3 作为聊天模型,使用阿里百炼的 text-embedding-v3 作为向量模型,使用 redis 作为向量库。(PS:近期阿里百炼也上架了 DeepSeek-V3 和 DeepSeek-R1 模型供开发者调用,如果觉得 DeepSeek 官方 AP I比较慢的话,可以去试试)。https://blog.csdn.net/u013176571/article/details/145368559
-
已安装 18.3 或更高版本的 Node.js ,未安装进入 官网下载 ,LTS 为长期支持版,Current 为最新功能版。
3. 快速搭建 Elemenet-Plus 项目
我这里直接使用官网的快速搭建模板 element-plus-vite-starter ,其它方式参考 Element-Plus 官网 。
3.1 项目下载
# 下载模板
git clone https://github.com/element-plus/element-plus-vite-starter.git
# 进入项目,安装依赖包
npm install
3.2 项目结构介绍
VS Code 中引入项目,项目结构如下:
项目结构介绍:
element-plus-vite-starter/
├── node_modules/ # 存放所有安装的依赖包
├── public/ # 静态资源文件夹
│ └── favicon.svg # 默认的站点图标
├── src/ # 源代码文件夹
│ ├── assets/ # 静态资源(如图片、样式等)
│ ├── components/ # Vue 组件文件夹
│ ├──── layouts/
│ ├────── BaseHeader.vue # 顶部导航栏布局
│ ├────── BaseSide.vue # 侧边导航栏布局
│ ├── composables/ # Vue 3 Composition API 的逻辑复用文件夹,存放 useXXX 命名的函数
│ ├── pages/ # 页面视图文件夹
│ ├── styles/ # 样式文件夹,存放全局样式 css 类
│ ├──── element/
│ ├────── index.scss # 全局颜色主题配置文件
│ ├── App.vue # 根组件
│ ├── main.js # 项目入口文件
│ ├── components.d.ts # 组件的类型声明文件
│ ├── env.d.ts # 环境变量的类型声明文件
│ ├── typed-router.d.ts # 路由的类型声明文件
│ └── types.ts # 全局类型定义文件,用于定义 TypeScript 类型
├── .gitignore
├── eslint.config.ts # ESLint 配置文件,用于代码质量检查
├── index.html # 项目入口 HTML 文件,Vite 会以此文件为模板进行开发和构建
├── babel.config.js # Babel 配置文件(仅在 Vue CLI 项目中)
├── package-lock.json # npm 生成的锁定文件,确保依赖版本一致
├── package.json # 项目依赖和配置信息
├── pnpm-lock.yaml # pnpm 生成的锁定文件,确保依赖版本一致
├── README.md # 项目说明文档
├── tsconfig.json # TypeScript 配置文件,定义 TypeScript 编译选项
├── uno.config.ts # UnoCSS 配置文件,UnoCSS 是一个用于生成原子 CSS 的工具
└── vite.config.ts # Vite 配置文件(仅在 Vite 项目中)
3.3 启动项目
执行以下命令启动:
# 启动项目
npm run dev
浏览器访问:http://localhost:5173/
4. 页面开发
这篇文章不会提供所有前端代码,我会在主要的卡点提供相应的代码节选。
4.1 .vue 文件介绍
在 Vue3 中,我们通过创建一个 .vue 格式文件来创建页面,一个 .vue 文件由以下几个部分组成:
-
template:组件的模板部分,用于定义组件的 HTML 结构。Vue 会将模板编译为渲染函数,用于生成最终的 DOM。
-
script:组件的逻辑部分,用于定义组件的数据、方法、生命周期钩子等。
-
style:组件的样式部分,用于定义组件的 CSS 样式,支持 CSS、SCSS、Sass、Less 等。
样例:
<template>
<div class = "m-container" >
<h1 class = "m-title" >{{ title }}</h1>
<button @click = "handleClick" >@click 用来绑定点击事件</button>
</div>
</template>
// lang="ts" 表示标签中的代码是用 TypeScript 编写的
<script lang="ts" setup>
import { onMounted, reactive, toRefs } from "vue";
// 定义变量
const state = reactive({
title: "页面1",
});
// 用于将响应式对象中的属性转换为响应式引用(ref)
const { title } = toRefs(state)
/**
* 页面加载事件
*/
onMounted(() => {
});
/**
* 绑定事件
*/
const handleClick = () => {
// 变量赋值
title.value = "页面2"
}
</script>
// lang="less" 表示使用 less 语法,scoped 用来限制作用域,只对当前组件模板生效
<style lang="less" scoped>
.m-container{
.m-title{
}
}
</style>
4.2 网络请求工具类
在 src 目录下创建一个 utils 文件夹,然后创建一个 api.ts 文件,用于发起网络请求。
4.2.1 代码
import axios from 'axios';
import { ElMessage } from 'element-plus';
// 创建一个 axios 实例
const baseURL = 'http://127.0.0.1:8082/your_service_name';
const apiClient = axios.create({
baseURL: baseURL,
timeout: 200000, // 请求超时时间
headers: {
'Content-Type': 'application/json',
},
});
const err = (error) => {
console.log('error', error)
if (axios.isCancel(error)) {
return
}
if (!error.response) {
ElMessage.error({
message: '请求超时请检查网络链接!',
offset: 80,
})
return Promise.reject(error)
}
const data = error.response.data
if (!data || data.code !== 200) {
ElMessage.error(data.message || "发生未知错误,请稍后再试!")
}
return Promise.reject(error)
}
// 请求拦截器
apiClient.interceptors.request.use(
(config) => {
// 在发送请求之前做些什么,例如添加 token
const token = localStorage.getItem('token');
if (token) {
config.headers['Authorization'] = `Bearer ${token}`;
}
return config;
},
err
);
// 响应拦截器
apiClient.interceptors.response.use(
(response) => {
// 对响应数据做点什么
return response.data;
},
err
);
// 定义 API 请求方法
const api = {
getBaseUrl() {
return baseURL;
},
get(url, params) {
return apiClient.get(url, { params });
},
post(url, params, config) {
return apiClient.post(url, params, config);
},
};
export default api;
4.2.2 如何引用?
在需要引用的页面的 script 标签中 或 .ts 文件下键入:
import api from '@/utils/api';
4.2.3 如何调用?
const param = {};
const config = {
headers: {
'Content-Type': 'multipart/form-data',
},
};
api.post("/ai/chat/fileUploadWithRag",param)
.then((response) => {
console.log("Response received:", response);
})
.catch((error) => {
// 处理错误
console.error("请求失败:", error);
});
4.3 如何接收 SSE 响应式消息
SSE (Server-Sent Events)是一种允许服务器向客户端推送数据的技术,属于 HTML5 的一部分。它支持服务器向客户端的单向通信,客户端通过一次长连接持续接收服务器推送的数据。响应式编程(Reactive Programming)非常适合实现 SSE,因为它允许以非阻塞的方式持续推送数据,不会阻塞服务器资源。在 SpringBoot 中可以使用 Spring WebFlux 框架来实现 SSE。在 Web 端实现 SSE 通常使用 EventSource 对象。
4.3.1 代码
同样在 utils 目录下,创建一个 sse.ts 的工具类。
class SSE {
private eventSource: EventSource | null = null;
/**
* 连接 SSE
* @param url
* @param onMessage
* @param onError
*/
public connect(url: string, onMessage: (event: MessageEvent) => void, onError?: (event: Event) => void): void {
this.eventSource = new EventSource(url);
// 监听消息事件
this.eventSource.onmessage = onMessage;
// 监听错误事件
if (onError) {
this.eventSource.onerror = onError;
}
}
/**
* 关闭 SSE 连接
*/
public close(): void {
if (this.eventSource) {
this.eventSource.close();
this.eventSource = null;
}
}
}
export default SSE;
4.3.2 如何引用?
import SSE from "@/utils/sse";
4.3.3 如何调用
需要注意的是,SSE 仅支持 GET 调用。
const state = reactive({
sse: new SSE(),
});
const { sse } = toRefs(state);
// 接收消息的回调函数
const handleMessage = (event: MessageEvent) => {
eventMessage = eventMessage.concat(event.data);
};
// 错误处理的回调函数
const handleError = (event: Event) => {
stopGenerate();
console.log('SSE 连接错误:', event);
};
const url = "接口地址?param1=param1¶m2=param2"
sse.value.connect(url, handleMessage, handleError);
4.4 对话组件
template 中主要代码节选,通过 message.sender 的值加载用户和AI消息的样式。
<el-scrollbar ref="scrollbar" class="message-list" always @scroll="handleScroll">
<div v-for="message in messages" :key="message.id" class="message-wrapper" :class="{
'user-message': message.sender === 'user',
'bot-message': message.sender === 'bot',
}">
<div class="message-bubble">
<div class="message-content" v-html="message.content"></div>
</div>
</div>
</el-scrollbar>
script 中主要代码节选:
// 定义一个 Message 接口
interface Message {
id: number;
content: string;
sender: "user" | "bot";
timestamp: number;
}
// 定义变量
const state = reactive({
messages: [] as Message[],
});
const { messages } = toRefs(state);
4.5 如何加载 Markdown 内容
由于大模型返回的流一般为 Markdown 格式,我们使用第三方库 markdown-it 来加载内容。
4.5.1 安装 markdown-it
由于我需要支持代码高亮、数学公式、流程图等,所以我安装了额外的插件来扩展 MarkdownIt 的功能。
npm install markdown-it highlight.js katex mermaid markdown-it-sub markdown-it-sup markdown-it-emoji markdown-it-task-lists markdown-it-footnote markdown-it-deflist markdown-it-abbr markdown-it-ins markdown-it-mark
4.5.2 如何使用
template 中代码节选:
<template>
<div class="message-content" v-html="message.content"></div>
</template>
script 中代码节选:
// 引入 markdown-it
import MarkdownIt from "markdown-it";
// 定义MarkdownIt对象
const md = new MarkdownIt();
// SSE 接收消息的回调函数
const handleMessage = (event: MessageEvent) => {
eventMessage = eventMessage.concat(event.data);
botMessage.content = computed(() => {
return md.value.render(eventMessage);
});
// 设置
nextTick(() => {
scrollToBottom();
});
};
4.6 其它
4.6.1 上传组件
使用 ElementPlus 的 upload 组件。
4.6.2 使用 Alt + Enter 键换行
Input 默认的换行快捷键为 Shift + Enter,不符合我们平时的使用习惯。
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "Enter") {
// 按下 Alt + Enter 键时,插入换行符
if (event.altKey) {
const cursorPosition = event.target.selectionStart;
const textBeforeCursor = inputMessage.value.slice(0, cursorPosition);
const textAfterCursor = inputMessage.value.slice(cursorPosition);
inputMessage.value = textBeforeCursor + '\n' + textAfterCursor;
event.target.selectionStart = cursorPosition + 1;
event.target.selectionEnd = cursorPosition + 1;
return;
}
event.preventDefault();
sendMessage();
}
};
4.6.3 消息自动滚动到最下方
代码节选:
// 定义变量
const state = reactive({
autoScroll: true,
scrollbar: {} as any,
});
const { autoScroll, scrollbar } = toRefs(state);
/**
* 消息滚动事件监听
*/
const handleScroll = ({ scrollTop }: { scrollTop: number }) => {
const scrollHeight = scrollbar.value.wrapRef?.scrollHeight || 0;
const clientHeight = scrollbar.value.wrapRef?.clientHeight || 0;
autoScroll.value = scrollTop + clientHeight >= scrollHeight - 10;
};
/**
* 滚动到最下方
*/
const scrollToBottom = () => {
if (autoScroll.value) {
nextTick(() => {
scrollbar.value?.setScrollTop(scrollbar.value.wrapRef?.scrollHeight);
});
}
};
5. 参考文档
- Vue3 文档
- Element Plus 文档
- markdown-it 文档