1、实现效果
AI流式输出
2、实现流程
1、页面内容
<template>
<div class="app-container">
<!-- 聊天界面 -->
<div class="chat-container">
<!-- 消息展示区域 -->
<div class="chat-box" ref="chatBox">
<div
v-for="message in messages"
:key="message.id"
class="message"
:class="message.from === 'user' ? 'user-message' : 'ai-message'"
>
<div v-if="!message.content" class="chat-message waiting">
<!-- 加载动画,例如一个旋转的图标 -->
<div class="loading-spinner"></div>
容我思考片刻 !
</div>
<p v-else v-html="markMessage(message.content)"></p>
</div>
</div>
<!-- 输入框与发送按钮 -->
<div class="input-container">
<el-input
v-model="userInput"
placeholder="请输入消息..."
clearable
@keyup.enter.native="sendMessage"
class="chat-input"
/>
<el-button type="primary" icon="el-icon-send" @click="sendMessage" class="send-button">发送</el-button>
</div>
</div>
</div>
</template>
循环展示消息内容,根据是用户发送消息和AI返回消息展示不同的样式
添加了一个等待动画
2、js部分与样式
<script>
// api部分大家根据自己的前端框架自己封装即可,分别调用后端的两个controller
import {sendMessage, sendMessage1} from "@/api/ai";
import { Marked } from 'marked'
import { markedHighlight } from "marked-highlight";
import hljs from 'highlight.js';
import 'highlight.js/styles/github.css';
import "highlight.js/styles/paraiso-light.css";
export default {
data() {
return {
messages: [], // 消息记录
userInput: "你好", // 用户输入,默认一开始发一个“你好”
pollingActive: false, // 是否正在长轮询
isEnd: false, // 标记是否结束轮询
currentAiMessageId: null, // 当前正在回复的 AI 消息的 ID
userMsgData: {}, // 用户消息数据
};
},
async mounted() {
this.sendMessage();
},
// computed: {
// newMessages() {
// this.messages.forEach(message=>{
// message.content=this.markMessage(message.content)
// console.log(message.content)
// })
// return this.messages
// }
// },
methods: {
markMessage(message) {
message=message.replaceAll('\\n','\n')
console.log('调用前'+message)
const marked = new Marked(
markedHighlight({
pedantic: false,
gfm: true, // 开启gfm
breaks: true,
smartLists: true,
xhtml: true,
async: false, // 如果是异步的,可以设置为 true
langPrefix: 'hljs language-', // 可选:用于给代码块添加前缀
emptyLangClass: 'no-lang', // 没有指定语言的代码块会添加这个类名
highlight: (code) => {
return hljs.highlightAuto(code).value
}
})
);
let markedMessage = marked.parse(message)
console.log('调用了'+markedMessage)
return markedMessage
},
sendMessage() {
if (!this.userInput.trim()) return;
// 添加用户消息
this.messages.push({
id: Date.now(),
content: this.userInput,
from: "user",
});
this.userMsgData.content = this.markMessage(this.userInput);
// send(this.userMsgData);
// 清空输入框
this.userInput = "";
// 添加 AI 回复占位
let newAiMessage = {
id: Date.now() + 1,
content: "",
from: "ai",
};
this.messages.push(newAiMessage);
this.currentAiMessageId = newAiMessage.id;
// 启动轮询
// if (this.isEnd || !this.pollingActive) {
// this.isEnd = false;
// this.pollingActive = true;
// this.polling();
// }
this.polling()
},
async polling() {
try {
// 给定的字符串
const response = await sendMessage1(this.userMsgData.content);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const reader = response.body.getReader();
const decoder = new TextDecoder('utf-8');
let buffer = ''; // 用于累积部分消息
while (true) {
const {done, value} = await reader.read();
if (done) {
this.isEnd = true;
this.pollingActive = false;
break
}
buffer = decoder.decode(value, {stream: true});
this.processServerSentEvent(buffer);
}
// 流结束时处理可能剩余的部分消息
// this.processServerSentEvent(buffer);
} catch (e) {
console.log(e.toString())
}
},
processServerSentEvent(eventData, isFinal = false) {
const lines = eventData.split('\n');
let currentMessage = ''
lines.forEach(line => {
if (line.startsWith('data:')) {
// 提取data字段的值(去掉前面的'data: ')
let a = line.split(':')
currentMessage += a[1];
} else {
currentMessage+=line.trim()
}
})
this.addNewMessage(currentMessage)
},
addNewMessage(data) {
if (data) {
try {
let newMessageContent = data;
// 通过消息id获取目前的AI输入位置
const aiMessage = this.messages.find(
(msg) => msg.id === this.currentAiMessageId
);
// newMessageContent = this.markMessage(newMessageContent)
if (aiMessage) {
aiMessage.content = `${aiMessage.content}${newMessageContent}`;
}
this.scrollToBottom()
} catch (error) {
console.error('Failed to parse JSON:', error);
}
}
},
scrollToBottom() {
const chatBox = this.$refs.chatBox;
chatBox.scrollTop = chatBox.scrollHeight;
}
}
};
</script>
<style scoped>
.app-container {
display: flex;
height: 90vh;
background-color: #f3f4f6;
font-family: "Arial", sans-serif;
}
/* 聊天容器 */
.chat-container {
flex: 1; /* 右侧占比 */
display: flex;
flex-direction: column;
border-left: 1px solid #ddd;
background-color: #fff;
overflow: hidden;
}
.chat-box {
flex: 1;
overflow-y: auto;
padding: 20px;
background-color: #fafafa;
display: flex;
flex-direction: column;
}
/* 通用消息样式 */
.message {
margin: 10px 0;
padding: 10px;
max-width: 70%;
word-wrap: break-word;
border-radius: 8px;
}
/* 用户消息:右对齐 */
.user-message {
align-self: flex-end;
background-color: #e0f7fa;
text-align: left;
}
/* AI 消息:左对齐 */
.ai-message {
align-self: flex-start;
background-color: #f1f1f1;
text-align: left;
}
/* 输入框和发送按钮 */
.input-container {
display: flex;
padding: 10px;
border-top: 1px solid #e0e0e0;
background-color: #f9f9f9;
}
.chat-input {
flex: 1;
margin-right: 10px;
}
.send-button {
flex-shrink: 0;
}
/* 加载指示器的样式 */
.loading-spinner {
border: 4px solid rgba(0, 0, 0, 0.1);
border-left-color: #4caf50; /* 可以根据需要调整颜色 */
border-radius: 50%;
width: 20px;
height: 20px;
animation: spin 1s linear infinite;
margin-right: 10px; /* 与文本之间留出一些空间 */
}
/* 定义旋转动画 */
@keyframes spin {
to { transform: rotate(360deg); }
}
/* 聊天消息的基本样式 */
.chat-message {
padding: 10px;
border-radius: 8px;
margin: 5px 0;
position: relative;
display: flex;
align-items: center;
}
/* 正在等待的消息样式 */
.waiting {
color: #777; /* 设置文本颜色 */
background-color: #f0f0f0; /* 设置背景颜色 */
}
</style>
发送消息后,生成用户消息和AI回复占位
给AI接口发送消息,发送消息后,获取到响应,然后使用reader.read方法读取内容。
给AI接口发送消息
export async function sendMessage1(message) {
console.log(message+"----")
try {
const response = await fetch( '你的接口路径', {
method: 'POST',
body:message
})
return response
} catch (error) {
console.error('请求失败:', error);
}
}
因为是流式响应,所以连接不是一次响应就直接断开的,使用while(true)循环不断从中获取到响应内容,并将响应的内容解码。
AI接口响应 content-type: text/event-stream;charset=utf-8是这样的,不是平常用的application/json,这表明响应体是一个服务器发送事件(Server-Sent Events,简称SSE)流。SSE 允许服务器向客户端(通常是浏览器)推送实时更新,而无需客户端轮询服务器。
async polling() {
try {
// 给定的字符串
const response = await sendMessage1(this.userMsgData.content);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const reader = response.body.getReader();
const decoder = new TextDecoder('utf-8');
let buffer = ''; // 用于累积部分消息
while (true) {
const {done, value} = await reader.read();
if (done) {
this.isEnd = true;
this.pollingActive = false;
break
}
buffer = decoder.decode(value, {stream: true});
this.processServerSentEvent(buffer);
}
// 流结束时处理可能剩余的部分消息
// this.processServerSentEvent(buffer);
} catch (e) {
console.log(e.toString())
}
将解码后内容经过处理后添加到messages数组中。
processServerSentEvent(eventData, isFinal = false) {
console.log('收到数据: '+eventData)
const lines = eventData.split('\n');
let currentMessage = ''
lines.forEach(line => {
if (line.startsWith('data:')) {
// 提取data字段的值(去掉前面的'data: ')
let a = line.split(':')
currentMessage += a[1];
} else {
currentMessage+=line.trim()
}
})
this.addNewMessage(currentMessage)
},
3、marked.js和highlight.js
marked.js
是一个用于将 Markdown 文本转换为 HTML 的 JavaScript 库,而 highlight.js
是一个用于语法高亮的库,它可以与 marked.js
一起使用来高亮 Markdown 中的代码块
安装marked.js和hightlight.js然后导入
npm install marked
npm install highlight.js
import { Marked } from 'marked'
import { markedHighlight } from "marked-highlight";
import hljs from 'highlight.js';
import 'highlight.js/styles/github.css';
import "highlight.js/styles/paraiso-light.css";
markMessage(message) {
message=message.replaceAll('\\n','\n')
// console.log('调用前'+message)
const marked = new Marked(
markedHighlight({
pedantic: false,
gfm: true, // 开启gfm
breaks: true,
smartLists: true,
xhtml: true,
async: false, // 如果是异步的,可以设置为 true
langPrefix: 'hljs language-', // 可选:用于给代码块添加前缀
emptyLangClass: 'no-lang', // 没有指定语言的代码块会添加这个类名
highlight: (code) => {
return hljs.highlightAuto(code).value
}
})
);
let markedMessage = marked.parse(message)
// console.log('调用了'+markedMessage)
return markedMessage
},
message就是markdown格式的文本内容
4、添加等待效果
当消息内容为空时,显示等待动画,不为空显示消息内容
<div v-if="!message.content" class="chat-message waiting">
<!-- 加载动画,例如一个旋转的图标 -->
<div class="loading-spinner"></div>
容我思考片刻 !
</div>
<p v-else v-html="markMessage(message.content)"></p>
等待样式
/* 加载指示器的样式 */
.loading-spinner {
border: 4px solid rgba(0, 0, 0, 0.1);
border-left-color: #4caf50; /* 可以根据需要调整颜色 */
border-radius: 50%;
width: 20px;
height: 20px;
animation: spin 1s linear infinite;
margin-right: 10px; /* 与文本之间留出一些空间 */
}
/* 定义旋转动画 */
@keyframes spin {
to { transform: rotate(360deg); }
}
/* 聊天消息的基本样式 */
.chat-message {
padding: 10px;
border-radius: 8px;
margin: 5px 0;
position: relative;
display: flex;
align-items: center;
}
/* 正在等待的消息样式 */
.waiting {
color: #777; /* 设置文本颜色 */
background-color: #f0f0f0; /* 设置背景颜色 */
}
5、引入marked.js报错解决
引入marked.js后,打包工程后,代码报错如下
You may need an appropriate loader to handle this file type, currently no loaders are configured to process this file. See https://webpack.js.org/concepts#loaders
| cells.shift();
| }
> if (cells.length > 0 && !cells.at(-1)?.trim()) {
| cells.pop();
| }
大概意思就是没有loader来处理新语法.?,
解决方案vue.config.js中configurewebpack中增加如下代码
module: {
rules: [
{
test: /\.js$/,
use: {
loader: 'babel-loader',
},
},
// 其他rules...
],
},
configureWebpack: {
// provide the app's title in webpack's name field, so that
// it can be accessed in index.html to inject the correct title.
name: name,
output: {
chunkFilename: 'static/js/[name].js'
// chunkFilename: 'static/js/[name][contenthash].js'
},
resolve: {
alias: {
'@': resolve('src')
}
},
module: {
rules: [
{
test: /\.js$/,
use: {
loader: 'babel-loader',
},
},
// 其他rules...
],
},
}