一、引言
在现代Web应用中,聊天机器人已经成为一个非常常见的功能。为了实现流畅的用户体验,流式输出(Streaming Output)是一个关键技术。通过流式输出,聊天机器人可以在生成响应的过程中逐步将内容推送到前端,而不是等待整个响应完成后再一次性发送。这种方式可以显著减少用户等待时间,提升用户体验。
本文将介绍如何结合Spring AI和Vue3,利用Server-Sent Events (SSE) 实现聊天机器人的流式输出。
二、技术栈
-
后端: Spring Boot + Spring AI
-
前端: Vue3 + Vite
-
通信协议: Server-Sent Events (SSE)
三、Server-Sent Events (SSE)简介
Server-Sent Events (SSE) 是一种允许服务器向客户端推送实时更新的技术。与 WebSocket 不同,SSE 是单向的,即服务器可以向客户端发送数据,但客户端不能向服务器发送数据。SSE 基于 HTTP 协议,使用简单且易于实现,非常适合需要服务器向客户端推送实时数据的场景,如新闻更新、股票价格、聊天机器人等。
SSE 的工作原理非常简单。客户端通过创建一个 EventSource
对象来连接到服务器的一个端点。服务器通过保持 HTTP 连接打开,并持续向客户端发送事件流。每个事件都是一个文本块,通常以 data:
开头,后面跟着实际的数据。客户端通过监听 onmessage
事件来处理这些数据。
优点
-
简单易用: SSE 基于 HTTP 协议,使用简单,易于实现。
-
自动重连: 如果连接断开,
EventSource
会自动尝试重新连接。 -
文本格式: 数据以纯文本格式发送,易于解析和处理。
-
单向通信: 适合服务器向客户端推送数据的场景。
缺点
-
单向通信: SSE 只支持服务器向客户端发送数据,不支持客户端向服务器发送数据。
-
文本格式限制: 数据必须以文本格式发送,不支持二进制数据。
-
连接限制: 浏览器对每个源的 SSE 连接数有限制(通常是 6 个)。
四、后端实现
后端实现参考前文:
Spring AI进阶:使用DeepSeek本地模型搭建AI聊天机器人(一)-CSDN博客
Spring AI进阶:AI聊天机器人之ChatMemory持久化(二)-CSDN博客
五、前端实现
1、使用 Vite 创建 Vue 3 项目
Vite 是一个现代化的前端构建工具,具有极快的启动速度和热更新能力。如果你还没有安装 Vite,可以通过以下命令全局安装:
npm install -g create-vite
运行以下命令来创建一个新的 Vue 3 项目:
npm create vite@latest ai-chat-robot -- --template vue
进入程序目录,运行以下命令
cd ai-chat-robot
npm install
npm run dev
可以看到程序已运行在本地的5173端口,点击打开看到以下画面则创建成功
2、安装必要组件库
安装Axios
npm install axios --save
安装sass 与 sass-loader
npm install sass sass-loader --save
安装Element-plus
npm install element-plus --save
安装完成后,你需要在项目中引入 Element Plus,打开main.js
import {createApp} from 'vue'
import './style.css'
import App from './App.vue'
import ElementPlus from 'element-plus' // 引入 Element Plus
import 'element-plus/dist/index.css' // 引入 Element Plus 的样式
createApp(App)
.use(ElementPlus) // 使用 Element Plus
.mount('#app')
安装MarkdownIt插件,大模型返回的内容都是Markdown格式的文本,所以需要安装此插件实现markdown文本的回显
npm install markdown-it --save
安装highlight.js,Highlight.js 是一个用 JavaScript 编写的语法高亮库。它可以在浏览器和服务器上工作,几乎可以处理任何标记,并且具有自动语言检测功能。
npm install highlight.js --save
3、新建Chat.vue
引入MarkdownIt插件与highlight.js插件,编写一个简单的聊天对话框页面,并添加一些点击事件,代码如下:
<template>
<div class="chat-container">
<!-- 左侧聊天会话列表 -->
<div class="chat-sessions">
<div class="chat-header">
AI聊天机器人
<el-button type="primary" style="float: right" @click="openNewSession">开启新会话</el-button>
</div>
<ul>
<li v-for="session in sessions" :key="session.sessionId" @click="selectSession(session)">
{
{ session.sessionName }}
</li>
</ul>
</div>
<!-- 右侧聊天窗口 -->
<div class="chat-window">
<div class="chat-messages" ref="chatMessages">
<div v-for="message in messages" :key="message" class="message"
:class="{'sent': message.type===1}">
<pre v-html="message.msg"></pre>
</div>
</div>
<!-- 右侧下方聊天内容输入框 -->
<div class="chat-input">
<textarea v-model="newMessage" @keydown.enter="sendMessage" placeholder="请输入问题..."></textarea>
<button @click="sendMessage">发送</button>
</div>
</div>
</div>
</template>
<script setup>
import hljs from 'highlight.js';
import MarkdownIt from 'markdown-it';
import {nextTick, ref} from 'vue';
// 初始化MarkdownIt实例
const md = new MarkdownIt({
html: true,
linkify: true,
typographer: true,
highlight: (str, lang) => {
if (lang && hljs.getLanguage(lang)) {
try {
return '<pre class="hljs"><code>' +
hljs.highlight(lang, str, true).value +
'</code></pre>';
} catch (__) {
}
}
return '<pre class="hljs"><code>' + md.utils.escapeHtml(str) + '</code></pre>';
}
});
// 引用聊天消息容器
const chatMessages = ref(null);
// 聊天会话列表
const sessions = ref([]);
// 当前选中的会话
const currentSession = ref({});
// 新消息输入框内容
const newMessage = ref('');
// 聊天记录
const messages = ref([]);
// 定义事件源的引用,用于实时通信
const eventSource = ref(null);
// 选择会话
const selectSession = (session) => {
currentSession.value = session;
};
// 创建新会话
const openNewSession = () => {
newMessage.value = '';
messages.value = [];
currentSession.value = {};
};
// 发送消息
const sendMessage = () => {
};
</script>
<style scoped lang="scss">
.chat-container {
display: flex;
height: 100vh;
padding: 0;
}
.chat-sessions {
width: 25%;
background-color: #f4f4f4;
padding: 10px;
.chat-header {
text-align: center;
line-height: 30px;
width: 100%;
}
ul {
list-style-type: none;
padding: 0;
li {
padding: 10px;
cursor: pointer;
&:hover {
background-color: #ddd;
}
}
}
}
.chat-window {
width: 75%;
display: flex;
flex-direction: column;
}
.chat-messages {
flex: 1;
overflow-y: auto;
padding: 10px;
background-color: #fff;
.message {
margin-bottom: 10px;
pre {
background-color: #f9f9f9;
padding: 10px;
border-radius: 5px;
white-space: pre-wrap;
}
:deep(.think){
display: inline-block;
padding: 0 10px;
color: #999999;
font-size: 13px;
background-color: #efecec;
border-radius: 5px;
}
&.sent {
text-align: right;
pre {
background-color: #e1ffc7;
}
}
}
}
.chat-input {
display: flex;
padding: 10px;
textarea {
flex: 1;
padding: 10px;
border: 1px solid #ddd;
border-radius: 5px;
resize: none;
}
button {
margin-left: 10px;
padding: 10px 20px;
border: none;
background-color: #007bff;
color: #fff;
border-radius: 5px;
cursor: pointer;
&:hover {
background-color: #0056b3;
}
}
}
</style>
运行程序后页面效果如下:
4、添加接口定义的JS代码
修改vite.config.js,添加后台服务器地址
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import path from 'path';
// https://vite.dev/config/
export default defineConfig({
plugins: [vue()],
resolve: {
alias: {
'@': path.resolve(__dirname, 'src')
}
},
server: {
host: true,
port: 5173,
proxy: {
"/api/": {
target: "http://127.0.0.1:8080", // 后台接口
changeOrigin: true,
secure: false, // 如果是https接口,需要配置这个参数
// ws: true, //websocket支持
rewrite: (path) => path.replace(/^\/api/, "/api"),
},
}
}
})
增加axios.js工具
import axios from "axios";
// createAxios
function createAxios() {
// instance
const instance = axios.create({
baseURL: 'http://127.0.0.1:8080',
timeout: 50000,
});
// defaults
instance.defaults.headers.post["Content-Type"] = "application/json;charset=utf-8";
instance.defaults.headers.get["Content-Type"] = "application/x-www-form-urlencoded;charset=utf-8";
return instance;
}
//
export default createAxios;
新建request.js定义接口
import createAxios from "@/utils/axios.js";
// instance
const instance = createAxios();
// API
instance.API = {
// 查询聊天记录
getMessages: (params = {}) => {
return instance.get(`/api/chat/ai/messages?sessionId=` + params);
},
// 查询聊天会话
getSession: (params = {}) => {
return instance.get(`/api/chat/ai/sessions?userId=` + params);
},
};
//
export default instance;
5、在Chat.vue中新增查询聊天会话的方法
/**
* 初始化会话列表
* @param init 是否初次加载
*/
const init = (init) => {
let userId = localStorage.getItem('WANGANUI_USER');
// 设置一个默认的用户ID,并存储到缓存
if (!userId) {
userId = String(new Date().getTime());
localStorage.setItem('WANGANUI_USER', userId);
}
ChatApi.API.getSession(userId).then(res => {
sessions.value = res.data;
if (sessions.value.length > 0 && init) {
currentSession.value = sessions?.value[0];
}
});
};
// 初始化会话列表
init(true)
6、在Chat.vue中新增查询聊天记录的方法
在初始化session的方法和selectSession方法中调用,这里我将DeepSeek-R1返回的<think>逻辑推理的内容做了自定义处理。
// 查询聊天记录
const loadMessages = () => {
ChatApi.API.getMessages(currentSession.value.sessionId).then(res => {
res.data.forEach(item => {
if (item.messageType === 'USER') {
messages.value.push({
msg: item.text,
type: 1
});
} else {
const text = item.text.replaceAll("<think>", "<div class='think'>").replaceAll("</think>", "</div>");
messages.value.push({
msg: md.render(text),
type: 2
});
}
});
});
};
7、在Chat.vue中新增发送消息的方法
-
构建 SSE 请求:
-
使用
EventSource
创建一个 SSE 连接,向后端发送用户输入的消息、会话 ID 和用户 ID。 -
请求 URL 示例:
http://127.0.0.1:8080/api/chat/ai/generateStream?message=用户输入&sessionId=会话ID&userId=用户ID
。
-
-
处理 SSE 响应:
-
监听
onmessage
事件,逐步接收后端返回的数据。 -
将接收到的数据拼接成完整的消息(
messageOrigin
),并使用md.render
将 Markdown 格式的文本渲染为 HTML。 -
将渲染后的内容更新到消息列表的最后一条消息中(
messages.value[messages.value.length - 1].msg
)。
-
-
结束处理:
-
当后端返回
finishReason: "stop"
时,表示消息传输完成,对消息内容进行格式化(替换<think>
标签为<div class='think'>
)。 -
调用
scrollToBottom
方法,将聊天窗口滚动到底部。
-
-
错误处理:
-
监听
onerror
事件,如果发生错误,关闭 SSE 连接。 -
监听
onclose
事件,在连接关闭时执行相关操作。
-
// 发送消息
const sendMessage = () => {
const value = newMessage.value;
if (!value) {
return ElMessage.warning('请输入问题');
}
if (!currentSession.value) {
// 添加一个模拟的新Session
const sessionId = '';
const sessionName = value.length >= 15 ? String(value).substring(0, 15) + '...' : value;
sessions.value = [{
sessionName,
sessionId
}].concat(sessions.value);
}
if (eventSource.value != null) {
eventSource.value.close();
}
// 将用户输入的消息添加到消息列表中,并设置消息类型为用户发送
messages.value.push({
msg: newMessage.value,
type: 1
});
messages.value.push({
msg: '',
type: 2
});
newMessage.value = '';
let messageOrigin = '';
const apiBaseUrl = "http://127.0.0.1:8080/api/chat/ai/generateStream";
const encodedValue = encodeURIComponent(value);
const encodedSessionId = currentSession.value?.sessionId ? encodeURIComponent(currentSession.value.sessionId) : '';
const userId = localStorage.getItem('WANGANUI_USER') || '';
eventSource.value = new EventSource(`${apiBaseUrl}?message=${encodedValue}&sessionId=${encodedSessionId}&userId=${userId}`);
eventSource.value.onmessage = function (event) {
try {
let substring = event.data.replaceAll("data:", "");
let parse = JSON.parse(substring);
messageOrigin += parse.result?.output?.text;
if (parse.result?.metadata?.finishReason === "stop") {
messageOrigin = messageOrigin.replace("<think>", "<div class='think'>").replace("</think>", "</div>");
eventSource.value.close();
}
messages.value[messages.value.length - 1].msg = md.render(messageOrigin);
// 调用滚动方法
scrollToBottom();
if (!currentSession.value.sessionId) {
init(false);
}
} catch (error) {
console.error("消息异常:", error);
}
};
eventSource.value.onerror = function (event) {
eventSource.value.close();
};
eventSource.value.onclose = function (event) {
console.log("事件关闭:", event);
};
};
在大模型输出时,我们可以根据输出的内容动态滚动聊天窗口
/**
* 滚动到聊天框底部
*/
const scrollToBottom = async () => {
await nextTick();
if (chatMessages.value) {
const lastMessage = chatMessages.value?.children[chatMessages.value.children.length - 1];
if (lastMessage) {
lastMessage.scrollIntoView({behavior: 'smooth', block: 'end'});
}
} else {
console.error('聊天框不可用');
}
};
六、总结
通过以上步骤,你已经成功实现了一个基于 Spring AI 和 Vue 3 的聊天机器人应用,利用 SSE 实现了流式输出,提升了用户体验。你可以根据需要进一步扩展和优化功能。
希望本文对你有所帮助!如果有任何问题或建议,请随时联系。
参考资料:
前端源码(main分支):wangxt_base/ai-chat-web - Gitee.com
后端源码:ai-chat: Spring AI 相关技术介绍
markdown-it文档:Core | markdown-it 中文文档
highlight.js文档:开始 | highlight.js中文网